From c17c65fcfae80455a1be54290867575d2d1d9dbd Mon Sep 17 00:00:00 2001 From: Artem Kashaev Date: Thu, 28 May 2026 14:08:15 +0500 Subject: [PATCH] Implement workout item and set deletion endpoints, enhance API response handling for empty responses --- frontend/src/App.tsx | 351 +++++++++++++++++++++++++++++++------ frontend/src/api.ts | 15 ++ frontend/src/styles.css | 56 +++++- services/bff/app/main.py | 10 ++ services/logic/app/main.py | 55 ++++++ 5 files changed, 431 insertions(+), 56 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8110c76..ea49523 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,8 +1,8 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { FormEvent, useMemo, useState } from "react"; +import { FormEvent, useEffect, useMemo, useState } from "react"; import { api, type AuthState } from "./api"; -import type { CatalogEntity, Workout } from "./types"; +import type { CatalogEntity, Workout, WorkoutItem as WorkoutItemType } from "./types"; type Tab = "dashboard" | "catalog" | "workout" | "history" | "analytics"; @@ -236,11 +236,43 @@ function ActiveWorkout({ token }: { token: string }) { const { equipment, exercises } = useCatalog(token); const workouts = useQuery({ queryKey: ["workouts"], queryFn: () => api.workouts(token) }); const [activeWorkout, setActiveWorkout] = useState(null); - const [kind, setKind] = useState<"exercise" | "equipment">("exercise"); - const [entityId, setEntityId] = useState(""); - const [activeItemId, setActiveItemId] = useState(""); + const [catalogKind, setCatalogKind] = useState<"exercise" | "equipment">("exercise"); + const [expandedItemId, setExpandedItemId] = useState(null); const [weight, setWeight] = useState(60); const [reps, setReps] = useState(8); + const [showFinishModal, setShowFinishModal] = useState(false); + const [finishNotes, setFinishNotes] = useState(""); + const [elapsed, setElapsed] = useState(""); + + const current = useMemo( + () => activeWorkout ?? workouts.data?.find((w) => !w.finished_at) ?? null, + [activeWorkout, workouts.data], + ); + + useEffect(() => { + if (!current?.started_at) return; + const started = new Date(current.started_at).getTime(); + const tick = () => { + const diff = Date.now() - started; + const m = Math.floor(diff / 60000); + const s = Math.floor((diff % 60000) / 1000); + setElapsed(`${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`); + }; + tick(); + const id = setInterval(tick, 1000); + return () => clearInterval(id); + }, [current?.started_at]); + + const addedEntityIds = useMemo(() => { + const ids = new Set(); + current?.items.forEach((item) => { + if (item.exercise_id) ids.add(item.exercise_id); + if (item.equipment_id) ids.add(item.equipment_id); + }); + return ids; + }, [current?.items]); + + const catalogList = catalogKind === "exercise" ? exercises.data : equipment.data; const startMutation = useMutation({ mutationFn: () => api.createWorkout(token), @@ -249,31 +281,42 @@ function ActiveWorkout({ token }: { token: string }) { void queryClient.invalidateQueries({ queryKey: ["workouts"] }); }, }); + const addItemMutation = useMutation({ - mutationFn: () => { - if (!activeWorkout) throw new Error("Нет активной тренировки"); - return api.addWorkoutItem(token, activeWorkout.id, { - exercise_id: kind === "exercise" ? entityId : null, - equipment_id: kind === "equipment" ? entityId : null, - planned_working_weight: weight, + mutationFn: (entity: CatalogEntity) => { + if (!current) throw new Error("Нет активной тренировки"); + return api.addWorkoutItem(token, current.id, { + exercise_id: catalogKind === "exercise" ? entity.id : null, + equipment_id: catalogKind === "equipment" ? entity.id : null, }); }, onSuccess: (item) => { - setActiveItemId(item.id); - setActiveWorkout((workout) => workout ? { ...workout, items: [...workout.items, item] } : workout); + setActiveWorkout((w) => (w ? { ...w, items: [...w.items, item] } : w)); + setExpandedItemId(item.id); void queryClient.invalidateQueries({ queryKey: ["workouts"] }); }, }); + + const removeItemMutation = useMutation({ + mutationFn: (itemId: string) => api.removeWorkoutItem(token, itemId), + onSuccess: (_, itemId) => { + setActiveWorkout((w) => (w ? { ...w, items: w.items.filter((i) => i.id !== itemId) } : w)); + if (expandedItemId === itemId) setExpandedItemId(null); + void queryClient.invalidateQueries({ queryKey: ["workouts"] }); + void queryClient.invalidateQueries({ queryKey: ["calories"] }); + }, + }); + const addSetMutation = useMutation({ - mutationFn: () => api.addWorkoutSet(token, activeItemId, { weight, reps }), - onSuccess: (workoutSet) => { - setActiveWorkout((workout) => { - if (!workout) return workout; + mutationFn: (itemId: string) => api.addWorkoutSet(token, itemId, { weight, reps }), + onSuccess: (workoutSet, itemId) => { + setActiveWorkout((w) => { + if (!w) return w; return { - ...workout, - estimated_calories: workout.estimated_calories + (workoutSet.calories ?? 0), - items: workout.items.map((item) => - item.id === activeItemId ? { ...item, sets: [...item.sets, workoutSet] } : item, + ...w, + estimated_calories: w.estimated_calories + (workoutSet.calories ?? 0), + items: w.items.map((item) => + item.id === itemId ? { ...item, sets: [...item.sets, workoutSet] } : item, ), }; }); @@ -282,55 +325,253 @@ function ActiveWorkout({ token }: { token: string }) { }, }); - const current = activeWorkout ?? workouts.data?.find((workout) => !workout.finished_at) ?? null; - const choices = kind === "exercise" ? exercises.data : equipment.data; + const removeSetMutation = useMutation({ + mutationFn: ({ itemId, setId }: { itemId: string; setId: string }) => + api.removeWorkoutSet(token, itemId, setId), + onSuccess: (_, { itemId, setId }) => { + setActiveWorkout((w) => { + if (!w) return w; + const items = w.items.map((item) => + item.id === itemId ? { ...item, sets: item.sets.filter((s) => s.id !== setId) } : item, + ); + return { ...w, items }; + }); + void queryClient.invalidateQueries({ queryKey: ["workouts"] }); + void queryClient.invalidateQueries({ queryKey: ["calories"] }); + }, + }); + + const finishMutation = useMutation({ + mutationFn: () => { + if (!current) throw new Error("Нет активной тренировки"); + return api.finishWorkout(token, current.id, finishNotes || undefined); + }, + onSuccess: () => { + setActiveWorkout(null); + setShowFinishModal(false); + setFinishNotes(""); + setExpandedItemId(null); + void queryClient.invalidateQueries({ queryKey: ["workouts"] }); + void queryClient.invalidateQueries({ queryKey: ["calories"] }); + }, + }); + + if (!current) { + return ( +
+
+
+

Workout

+

Тренировка

+
+
+
+

Готов к тренировке?

+

Добавляй упражнения и тренажеры, записывай подходы — всё в одном месте.

+ +
+
+ ); + } + + const totalSets = current.items.reduce((sum, item) => sum + item.sets.length, 0); return (
-

Active workout

+

Workout

Текущая тренировка

- +
+ {elapsed || "00:00"} + {Math.round(current.estimated_calories)} ккал +
- {current ? ( - <> -
+ +
+
+

Каталог

+
+ + +
+
+
+ {catalogList?.map((entity) => { + const added = addedEntityIds.has(entity.id); + return ( +
+ {entity.image_s3_url ? :
TW
} +
+

{entity.name}

+ +
+
+ ); + })} +
+
+ +
+

Тренировка ({current.items.length} эл., {totalSets} подх.)

+ {current.items.length === 0 ? ( +

Добавь упражнения из каталога выше.

+ ) : ( +
+ {current.items.map((item) => ( + setExpandedItemId(expandedItemId === item.id ? null : item.id)} + onRemoveItem={() => removeItemMutation.mutate(item.id)} + onAddSet={expandedItemId === item.id ? () => addSetMutation.mutate(item.id) : undefined} + onRemoveSet={(setId) => removeSetMutation.mutate({ itemId: item.id, setId })} + weight={weight} + reps={reps} + onWeightChange={setWeight} + onRepsChange={setReps} + isAddingSet={addSetMutation.isPending} + isRemovingItem={removeItemMutation.isPending} + /> + ))} +
+ )} + +
+ + {showFinishModal && ( +
setShowFinishModal(false)}> +
e.stopPropagation()}> +

Завершить тренировку?

+
+ + + +
- - - - - -
- - - ) : ( -

Нет активной тренировки

Начни тренировку и добавь упражнения или тренажеры.

+
+ + +
+ + )}
); } +function CartItemRow({ + item, + exercises, + equipment, + expanded, + onToggle, + onRemoveItem, + onAddSet, + onRemoveSet, + weight, + reps, + onWeightChange, + onRepsChange, + isAddingSet, + isRemovingItem, +}: { + item: WorkoutItemType; + exercises: CatalogEntity[] | undefined; + equipment: CatalogEntity[] | undefined; + expanded: boolean; + onToggle: () => void; + onRemoveItem: () => void; + onAddSet: (() => void) | undefined; + onRemoveSet: (setId: string) => void; + weight: number; + reps: number; + onWeightChange: (v: number) => void; + onRepsChange: (v: number) => void; + isAddingSet: boolean; + isRemovingItem: boolean; +}) { + const entityName = useMemo(() => { + if (item.exercise_id) return exercises?.find((e) => e.id === item.exercise_id)?.name ?? "Упражнение"; + if (item.equipment_id) return equipment?.find((e) => e.id === item.equipment_id)?.name ?? "Тренажер"; + return "Неизвестно"; + }, [item.exercise_id, item.equipment_id, exercises, equipment]); + + return ( +
+
+
+ {entityName} + + {item.sets.length > 0 ? `${item.sets.length} × ${item.sets[item.sets.length - 1].weight} кг` : "нет подходов"} + +
+ +
+ {expanded && ( +
+ {item.sets.length > 0 && ( +
+ {item.sets.map((set) => ( +
+ {set.set_index} + {set.weight} кг × {set.reps} + {set.calories ? Math.round(set.calories) : "—"} ккал + +
+ ))} +
+ )} + {onAddSet && ( +
+ onWeightChange(Number(e.target.value))} + placeholder="Вес" + /> + onRepsChange(Number(e.target.value))} + placeholder="Повторы" + /> + +
+ )} +
+ )} +
+ ); +} + function History({ token }: { token: string }) { const workouts = useQuery({ queryKey: ["workouts"], queryFn: () => api.workouts(token) }); return ( diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 204c1c8..8ee09d6 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -21,6 +21,9 @@ async function request(path: string, options: RequestInit = {}, token?: strin const body = await response.json().catch(() => ({ detail: response.statusText })); throw new Error(typeof body.detail === "string" ? body.detail : JSON.stringify(body.detail)); } + if (response.status === 204 || response.headers.get("content-length") === "0") { + return undefined as T; + } return response.json() as Promise; } @@ -72,6 +75,18 @@ export const api = { addWorkoutSet(token: string, itemId: string, payload: Partial) { return request(`/workout-items/${itemId}/sets`, { method: "POST", body: JSON.stringify(payload) }, token); }, + removeWorkoutItem(token: string, itemId: string) { + return request(`/workout-items/${itemId}`, { method: "DELETE" }, token); + }, + removeWorkoutSet(token: string, itemId: string, setId: string) { + return request(`/workout-items/${itemId}/sets/${setId}`, { method: "DELETE" }, token); + }, + finishWorkout(token: string, workoutId: string, notes?: string) { + return request(`/workouts/${workoutId}`, { + method: "PATCH", + body: JSON.stringify({ finished_at: new Date().toISOString(), notes }), + }, token); + }, progression(token: string, kind: "exercise" | "equipment", entityId?: string) { const params = new URLSearchParams({ kind }); if (entityId) params.set("entity_id", entityId); diff --git a/frontend/src/styles.css b/frontend/src/styles.css index c7e6b65..e10e99b 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -54,6 +54,54 @@ input, select { width: 100%; border: 1px solid #d1d5db; border-radius: 14px; pad .pill.user { background: #dcfce7; color: #166534; } .empty-state { text-align: center; padding: 48px; } .workout-card { display: grid; gap: 18px; } +.workout-cta { text-align: center; padding: 48px 32px; display: flex; flex-direction: column; align-items: center; gap: 16px; } +.workout-cta h3 { margin: 0; font-size: 24px; } +.workout-cta p { color: #6b7280; max-width: 420px; } +.workout-live-stats { display: flex; gap: 10px; align-items: center; } +.timer-badge { background: #151515; color: #dbff5b; font-family: "SF Mono", "Cascadia Code", "Fira Code", ui-monospace, monospace; font-size: 18px; font-weight: 800; padding: 8px 16px; border-radius: 14px; letter-spacing: 0.04em; } +.kcal-badge { background: #f3e8ff; color: #7c3aed; font-weight: 800; padding: 8px 14px; border-radius: 14px; font-size: 14px; } +.catalog-picker-section { padding: 20px 24px; } +.catalog-picker-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; } +.catalog-picker-header h3 { margin: 0; } +.workout-catalog-grid { grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); } +.workout-pick-card { padding: 0; cursor: default; transition: box-shadow 0.2s; } +.workout-pick-card.added { opacity: 0.55; } +.workout-pick-card .image-placeholder { height: 100px; } +.workout-pick-card img { height: 100px; } +.workout-pick-card div:last-child { padding: 14px; display: flex; flex-direction: column; gap: 8px; } +.workout-pick-card h3 { margin: 0; font-size: 14px; } +.pick-btn { border-radius: 10px; padding: 8px 12px; font-weight: 800; font-size: 12px; width: 100%; } +.pick-btn.picked { background: #dcfce7; color: #166534; cursor: default; } +.workout-cart { display: flex; flex-direction: column; gap: 16px; } +.workout-cart > h3 { margin: 0; } +.cart-items { display: flex; flex-direction: column; gap: 10px; } +.cart-item { border: 1px solid rgba(17,24,39,0.08); border-radius: 16px; overflow: hidden; transition: border-color 0.2s; } +.cart-item.expanded { border-color: #8a5cf6; } +.cart-item-header { display: flex; justify-content: space-between; align-items: center; padding: 14px 18px; cursor: pointer; } +.cart-item-header:hover { background: rgba(0,0,0,0.02); } +.cart-item-info { display: flex; flex-direction: column; gap: 2px; } +.cart-item-name { font-weight: 700; font-size: 15px; } +.cart-item-summary { color: #6b7280; font-size: 13px; } +.cart-item-remove { background: transparent; color: #9ca3af; font-size: 20px; padding: 4px 8px; border-radius: 8px; line-height: 1; } +.cart-item-remove:hover { color: #b91c1c; background: #fef2f2; } +.cart-item-body { border-top: 1px solid rgba(17,24,39,0.06); padding: 14px 18px; display: flex; flex-direction: column; gap: 12px; } +.sets-list { display: flex; flex-direction: column; gap: 6px; } +.set-row { display: grid; grid-template-columns: 28px 1fr 70px 28px; gap: 8px; align-items: center; padding: 6px 0; } +.set-index { font-weight: 800; color: #8a5cf6; font-size: 13px; } +.set-data { font-size: 14px; font-weight: 600; } +.set-cal { color: #6b7280; font-size: 12px; } +.set-remove { background: transparent; color: #d1d5db; font-size: 16px; padding: 2px 6px; border-radius: 6px; line-height: 1; } +.set-remove:hover { color: #b91c1c; background: #fef2f2; } +.add-set-row { display: grid; grid-template-columns: 1fr 1fr auto; gap: 10px; align-items: center; } +.add-set-row input { padding: 10px 12px; font-size: 14px; border-radius: 12px; } +.add-set-row button { padding: 10px 16px; font-size: 13px; white-space: nowrap; } +.finish-btn { align-self: flex-end; margin-top: 8px; } +.modal-overlay { position: fixed; inset: 0; z-index: 100; background: rgba(17,24,39,0.45); display: grid; place-items: center; backdrop-filter: blur(4px); } +.modal-card { max-width: 440px; width: calc(100% - 32px); display: flex; flex-direction: column; gap: 18px; } +.modal-card h2 { margin: 0; } +.modal-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; } +.modal-stats .metric { padding: 12px; } +.modal-actions { display: flex; justify-content: flex-end; gap: 12px; } .bars { display: grid; gap: 12px; } .bar-row { display: grid; grid-template-columns: 110px 1fr 70px; gap: 12px; align-items: center; } .bar-row div { height: 16px; border-radius: 999px; background: #e5e7eb; overflow: hidden; } @@ -70,10 +118,16 @@ input, select { width: 100%; border: 1px solid #d1d5db; border-radius: 14px; pad .hero-panel { min-height: 48vh; padding: 28px; } .catalog-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .form-grid, .stats-grid { grid-template-columns: 1fr; } + .workout-catalog-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .workout-live-stats { gap: 6px; } + .timer-badge { font-size: 15px; padding: 6px 12px; } + .catalog-picker-header { flex-direction: column; align-items: stretch; gap: 10px; } } @media (max-width: 620px) { .page-header { align-items: stretch; flex-direction: column; } .catalog-grid, .workout-stats { grid-template-columns: 1fr; } - .bar-row { grid-template-columns: 1fr; } + .workout-catalog-grid { grid-template-columns: 1fr; } + .add-set-row { grid-template-columns: 1fr 1fr; } + .add-set-row button { grid-column: 1 / -1; } } diff --git a/services/bff/app/main.py b/services/bff/app/main.py index 05b943a..64290d0 100644 --- a/services/bff/app/main.py +++ b/services/bff/app/main.py @@ -174,6 +174,16 @@ async def add_workout_set(item_id: str, payload: dict[str, Any], user: CurrentUs ) +@app.delete("/workout-items/{item_id}", status_code=status.HTTP_204_NO_CONTENT) +async def remove_workout_item(item_id: str, user: CurrentUser) -> None: + await logic_request("DELETE", f"/internal/workout-items/{item_id}", user) + + +@app.delete("/workout-items/{item_id}/sets/{set_id}", status_code=status.HTTP_204_NO_CONTENT) +async def remove_workout_set(item_id: str, set_id: str, user: CurrentUser) -> None: + await logic_request("DELETE", f"/internal/workout-items/{item_id}/sets/{set_id}", user) + + @app.get("/analytics/progression") async def progression( user: CurrentUser, diff --git a/services/logic/app/main.py b/services/logic/app/main.py index 9b5d072..1e9820f 100644 --- a/services/logic/app/main.py +++ b/services/logic/app/main.py @@ -323,6 +323,61 @@ def add_workout_set( return workout_set +@app.delete( + "/internal/workout-items/{item_id}", + dependencies=[InternalAuth], + status_code=status.HTTP_204_NO_CONTENT, +) +def delete_workout_item(item_id: uuid.UUID, db: Db, user_id: CurrentUserId) -> None: + item = db.scalar( + select(WorkoutItem) + .join(Workout) + .where(WorkoutItem.id == item_id, Workout.user_id == user_id) + ) + if not item: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Workout item not found") + workout = db.get(Workout, item.workout_id) + if workout and workout.finished_at: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Workout already finished") + workout_id = item.workout_id + db.delete(item) + db.flush() + recalculate_workout_calories(db, workout_id) + db.commit() + + +@app.delete( + "/internal/workout-items/{item_id}/sets/{set_id}", + dependencies=[InternalAuth], + status_code=status.HTTP_204_NO_CONTENT, +) +def delete_workout_set( + item_id: uuid.UUID, set_id: uuid.UUID, db: Db, user_id: CurrentUserId +) -> None: + ws = db.scalar( + select(WorkoutSet) + .join(WorkoutItem) + .join(Workout) + .where( + WorkoutSet.id == set_id, + WorkoutItem.id == item_id, + Workout.user_id == user_id, + ) + ) + if not ws: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Set not found") + workout = db.scalar( + select(Workout).join(WorkoutItem).where(WorkoutItem.id == item_id) + ) + if workout and workout.finished_at: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Workout already finished") + db.delete(ws) + db.flush() + if workout: + recalculate_workout_calories(db, workout.id) + db.commit() + + def estimate_set_calories(item: WorkoutItem, payload: WorkoutSetCreate) -> float: if item.exercise and item.exercise.default_calories_per_minute and payload.duration_seconds: return round(