feat: Implement active workout flow with status management

- Added `status`, `total_sets`, and `total_volume` fields to the Workout model.
- Introduced `source_kind`, `title_snapshot`, and `image_s3_url_snapshot` fields to the WorkoutItem model.
- Created endpoints for managing active workouts, including finishing and discarding workouts.
- Updated workout creation to ensure only one active workout exists per user.
- Implemented batch addition of workout sets and updates to workout set details.
- Enhanced database schema with Alembic migrations to support new fields and constraints.
- Added validation to ensure at least one field is provided for workout set updates.
- Updated calorie estimation logic to reflect new workout set structure.
This commit is contained in:
Artem Kashaev
2026-05-29 10:09:56 +05:00
parent d7b0c7754f
commit 7b34ce1a98
30 changed files with 2081 additions and 846 deletions
+102
View File
@@ -0,0 +1,102 @@
import type { QueryClient } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ApiError } from "../../api";
import type { CatalogKind, WorkoutSetInput } from "../../types";
import { useAuth } from "../auth/AuthContext";
import { workoutApi } from "./api";
export async function invalidateWorkoutQueries(queryClient: QueryClient) {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["workout", "active"] }),
queryClient.invalidateQueries({ queryKey: ["workouts"] }),
queryClient.invalidateQueries({ queryKey: ["calories"] }),
queryClient.invalidateQueries({ queryKey: ["progression"] }),
]);
}
export function useActiveWorkout() {
const { auth } = useAuth();
return useQuery({ queryKey: ["workout", "active"], queryFn: () => workoutApi.active(auth.accessToken) });
}
export function useWorkoutMutations(options: { onStartConflict?: () => void; onFinish?: () => void; onDiscard?: () => void } = {}) {
const { auth } = useAuth();
const token = auth.accessToken;
const queryClient = useQueryClient();
const refresh = () => invalidateWorkoutQueries(queryClient);
const startWorkout = useMutation({
mutationFn: () => workoutApi.start(token),
onSuccess: refresh,
onError: async (error) => {
if (error instanceof ApiError && error.status === 409) {
await refresh();
options.onStartConflict?.();
}
},
});
const addWorkoutItem = useMutation({
mutationFn: ({ workoutId, sourceId, kind }: { workoutId: string; sourceId: string; kind: CatalogKind }) =>
workoutApi.addItem(token, workoutId, {
exercise_id: kind === "exercise" ? sourceId : null,
equipment_id: kind === "equipment" ? sourceId : null,
}),
onSuccess: refresh,
});
const recordWorkoutSet = useMutation({
mutationFn: ({ itemId, payload }: { itemId: string; payload: WorkoutSetInput }) => workoutApi.addSet(token, itemId, payload),
onSuccess: refresh,
});
const recordWorkoutSetsBatch = useMutation({
mutationFn: ({ itemId, sets }: { itemId: string; sets: WorkoutSetInput[] }) => workoutApi.addSetBatch(token, itemId, sets),
onSuccess: refresh,
});
const removeWorkoutItem = useMutation({
mutationFn: (itemId: string) => workoutApi.removeItem(token, itemId),
onSuccess: refresh,
});
const removeWorkoutSet = useMutation({
mutationFn: ({ itemId, setId }: { itemId: string; setId: string }) => workoutApi.removeSet(token, itemId, setId),
onSuccess: refresh,
});
const updateWorkoutSet = useMutation({
mutationFn: ({ setId, payload }: { setId: string; payload: Partial<WorkoutSetInput> }) => workoutApi.updateSet(token, setId, payload),
onSuccess: refresh,
});
const finishWorkout = useMutation({
mutationFn: ({ workoutId, notes }: { workoutId: string; notes?: string }) => workoutApi.finish(token, workoutId, notes),
onSuccess: async () => {
await refresh();
options.onFinish?.();
},
});
const discardWorkout = useMutation({
mutationFn: (workoutId: string) => workoutApi.discard(token, workoutId),
onSuccess: async () => {
await refresh();
options.onDiscard?.();
},
});
return {
startWorkout,
addWorkoutItem,
recordWorkoutSet,
recordWorkoutSetsBatch,
removeWorkoutItem,
removeWorkoutSet,
updateWorkoutSet,
finishWorkout,
discardWorkout,
};
}