Implement workout item and set deletion endpoints, enhance API response handling for empty responses
This commit is contained in:
+296
-55
@@ -1,8 +1,8 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
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 { 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";
|
type Tab = "dashboard" | "catalog" | "workout" | "history" | "analytics";
|
||||||
|
|
||||||
@@ -236,11 +236,43 @@ function ActiveWorkout({ token }: { token: string }) {
|
|||||||
const { equipment, exercises } = useCatalog(token);
|
const { equipment, exercises } = useCatalog(token);
|
||||||
const workouts = useQuery({ queryKey: ["workouts"], queryFn: () => api.workouts(token) });
|
const workouts = useQuery({ queryKey: ["workouts"], queryFn: () => api.workouts(token) });
|
||||||
const [activeWorkout, setActiveWorkout] = useState<Workout | null>(null);
|
const [activeWorkout, setActiveWorkout] = useState<Workout | null>(null);
|
||||||
const [kind, setKind] = useState<"exercise" | "equipment">("exercise");
|
const [catalogKind, setCatalogKind] = useState<"exercise" | "equipment">("exercise");
|
||||||
const [entityId, setEntityId] = useState("");
|
const [expandedItemId, setExpandedItemId] = useState<string | null>(null);
|
||||||
const [activeItemId, setActiveItemId] = useState("");
|
|
||||||
const [weight, setWeight] = useState(60);
|
const [weight, setWeight] = useState(60);
|
||||||
const [reps, setReps] = useState(8);
|
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<string>();
|
||||||
|
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({
|
const startMutation = useMutation({
|
||||||
mutationFn: () => api.createWorkout(token),
|
mutationFn: () => api.createWorkout(token),
|
||||||
@@ -249,31 +281,42 @@ function ActiveWorkout({ token }: { token: string }) {
|
|||||||
void queryClient.invalidateQueries({ queryKey: ["workouts"] });
|
void queryClient.invalidateQueries({ queryKey: ["workouts"] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const addItemMutation = useMutation({
|
const addItemMutation = useMutation({
|
||||||
mutationFn: () => {
|
mutationFn: (entity: CatalogEntity) => {
|
||||||
if (!activeWorkout) throw new Error("Нет активной тренировки");
|
if (!current) throw new Error("Нет активной тренировки");
|
||||||
return api.addWorkoutItem(token, activeWorkout.id, {
|
return api.addWorkoutItem(token, current.id, {
|
||||||
exercise_id: kind === "exercise" ? entityId : null,
|
exercise_id: catalogKind === "exercise" ? entity.id : null,
|
||||||
equipment_id: kind === "equipment" ? entityId : null,
|
equipment_id: catalogKind === "equipment" ? entity.id : null,
|
||||||
planned_working_weight: weight,
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onSuccess: (item) => {
|
onSuccess: (item) => {
|
||||||
setActiveItemId(item.id);
|
setActiveWorkout((w) => (w ? { ...w, items: [...w.items, item] } : w));
|
||||||
setActiveWorkout((workout) => workout ? { ...workout, items: [...workout.items, item] } : workout);
|
setExpandedItemId(item.id);
|
||||||
void queryClient.invalidateQueries({ queryKey: ["workouts"] });
|
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({
|
const addSetMutation = useMutation({
|
||||||
mutationFn: () => api.addWorkoutSet(token, activeItemId, { weight, reps }),
|
mutationFn: (itemId: string) => api.addWorkoutSet(token, itemId, { weight, reps }),
|
||||||
onSuccess: (workoutSet) => {
|
onSuccess: (workoutSet, itemId) => {
|
||||||
setActiveWorkout((workout) => {
|
setActiveWorkout((w) => {
|
||||||
if (!workout) return workout;
|
if (!w) return w;
|
||||||
return {
|
return {
|
||||||
...workout,
|
...w,
|
||||||
estimated_calories: workout.estimated_calories + (workoutSet.calories ?? 0),
|
estimated_calories: w.estimated_calories + (workoutSet.calories ?? 0),
|
||||||
items: workout.items.map((item) =>
|
items: w.items.map((item) =>
|
||||||
item.id === activeItemId ? { ...item, sets: [...item.sets, workoutSet] } : 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 removeSetMutation = useMutation({
|
||||||
const choices = kind === "exercise" ? exercises.data : equipment.data;
|
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 (
|
||||||
|
<section className="stack">
|
||||||
|
<div className="page-header">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Workout</p>
|
||||||
|
<h2>Тренировка</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<section className="card workout-cta">
|
||||||
|
<h3>Готов к тренировке?</h3>
|
||||||
|
<p>Добавляй упражнения и тренажеры, записывай подходы — всё в одном месте.</p>
|
||||||
|
<button className="primary" onClick={() => startMutation.mutate()} disabled={startMutation.isPending}>
|
||||||
|
{startMutation.isPending ? "Создание..." : "Начать тренировку"}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalSets = current.items.reduce((sum, item) => sum + item.sets.length, 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="stack">
|
<section className="stack">
|
||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">Active workout</p>
|
<p className="eyebrow">Workout</p>
|
||||||
<h2>Текущая тренировка</h2>
|
<h2>Текущая тренировка</h2>
|
||||||
</div>
|
</div>
|
||||||
<button className="primary" onClick={() => startMutation.mutate()} disabled={startMutation.isPending}>Новая тренировка</button>
|
<div className="workout-live-stats">
|
||||||
|
<span className="timer-badge">{elapsed || "00:00"}</span>
|
||||||
|
<span className="kcal-badge">{Math.round(current.estimated_calories)} ккал</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="card catalog-picker-section">
|
||||||
|
<div className="catalog-picker-header">
|
||||||
|
<h3>Каталог</h3>
|
||||||
|
<div className="segmented">
|
||||||
|
<button className={catalogKind === "exercise" ? "active" : ""} onClick={() => setCatalogKind("exercise")}>Упражнения</button>
|
||||||
|
<button className={catalogKind === "equipment" ? "active" : ""} onClick={() => setCatalogKind("equipment")}>Тренажеры</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="catalog-grid workout-catalog-grid">
|
||||||
|
{catalogList?.map((entity) => {
|
||||||
|
const added = addedEntityIds.has(entity.id);
|
||||||
|
return (
|
||||||
|
<article className={`card catalog-card workout-pick-card${added ? " added" : ""}`} key={entity.id}>
|
||||||
|
{entity.image_s3_url ? <img src={entity.image_s3_url} alt="" /> : <div className="image-placeholder">TW</div>}
|
||||||
|
<div>
|
||||||
|
<h3>{entity.name}</h3>
|
||||||
|
<button
|
||||||
|
className={`pick-btn${added ? " picked" : " primary"}`}
|
||||||
|
disabled={added || addItemMutation.isPending}
|
||||||
|
onClick={() => addItemMutation.mutate(entity)}
|
||||||
|
>
|
||||||
|
{added ? "В тренировке" : "Добавить"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
{current ? (
|
|
||||||
<>
|
|
||||||
<section className="card form-grid">
|
|
||||||
<label>
|
|
||||||
Тип
|
|
||||||
<select value={kind} onChange={(event) => setKind(event.target.value as "exercise" | "equipment")}>
|
|
||||||
<option value="exercise">Упражнение</option>
|
|
||||||
<option value="equipment">Тренажер</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Элемент
|
|
||||||
<select value={entityId} onChange={(event) => setEntityId(event.target.value)}>
|
|
||||||
<option value="">Выбрать</option>
|
|
||||||
{choices?.map((entity) => <option key={entity.id} value={entity.id}>{entity.name}</option>)}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Вес
|
|
||||||
<input type="number" value={weight} onChange={(event) => setWeight(Number(event.target.value))} />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Повторы
|
|
||||||
<input type="number" value={reps} onChange={(event) => setReps(Number(event.target.value))} />
|
|
||||||
</label>
|
|
||||||
<button className="primary" disabled={!entityId || addItemMutation.isPending} onClick={() => addItemMutation.mutate()}>Добавить элемент</button>
|
|
||||||
<button disabled={!activeItemId || addSetMutation.isPending} onClick={() => addSetMutation.mutate()}>Записать подход</button>
|
|
||||||
</section>
|
</section>
|
||||||
<WorkoutSummary workout={current} />
|
|
||||||
</>
|
<section className="card workout-cart">
|
||||||
|
<h3>Тренировка ({current.items.length} эл., {totalSets} подх.)</h3>
|
||||||
|
{current.items.length === 0 ? (
|
||||||
|
<p className="muted">Добавь упражнения из каталога выше.</p>
|
||||||
) : (
|
) : (
|
||||||
<section className="card empty-state"><h3>Нет активной тренировки</h3><p>Начни тренировку и добавь упражнения или тренажеры.</p></section>
|
<div className="cart-items">
|
||||||
|
{current.items.map((item) => (
|
||||||
|
<CartItemRow
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
exercises={exercises.data}
|
||||||
|
equipment={equipment.data}
|
||||||
|
expanded={expandedItemId === item.id}
|
||||||
|
onToggle={() => 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}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="primary finish-btn"
|
||||||
|
disabled={current.items.length === 0 || finishMutation.isPending}
|
||||||
|
onClick={() => setShowFinishModal(true)}
|
||||||
|
>
|
||||||
|
Завершить тренировку
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{showFinishModal && (
|
||||||
|
<div className="modal-overlay" onClick={() => setShowFinishModal(false)}>
|
||||||
|
<div className="modal-card card" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<h2>Завершить тренировку?</h2>
|
||||||
|
<div className="modal-stats">
|
||||||
|
<Metric label="Упражнений" value={current.items.length} />
|
||||||
|
<Metric label="Подходов" value={totalSets} />
|
||||||
|
<Metric label="Ккал" value={Math.round(current.estimated_calories)} />
|
||||||
|
</div>
|
||||||
|
<label>
|
||||||
|
Заметки
|
||||||
|
<input value={finishNotes} onChange={(e) => setFinishNotes(e.target.value)} placeholder="Как прошла тренировка?" />
|
||||||
|
</label>
|
||||||
|
<div className="modal-actions">
|
||||||
|
<button className="ghost" onClick={() => setShowFinishModal(false)}>Отмена</button>
|
||||||
|
<button className="primary" disabled={finishMutation.isPending} onClick={() => finishMutation.mutate()}>
|
||||||
|
{finishMutation.isPending ? "Сохранение..." : "Завершить"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className={`cart-item${expanded ? " expanded" : ""}`}>
|
||||||
|
<div className="cart-item-header" onClick={onToggle}>
|
||||||
|
<div className="cart-item-info">
|
||||||
|
<span className="cart-item-name">{entityName}</span>
|
||||||
|
<span className="cart-item-summary">
|
||||||
|
{item.sets.length > 0 ? `${item.sets.length} × ${item.sets[item.sets.length - 1].weight} кг` : "нет подходов"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button className="cart-item-remove" disabled={isRemovingItem} onClick={(e) => { e.stopPropagation(); onRemoveItem(); }}>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{expanded && (
|
||||||
|
<div className="cart-item-body">
|
||||||
|
{item.sets.length > 0 && (
|
||||||
|
<div className="sets-list">
|
||||||
|
{item.sets.map((set) => (
|
||||||
|
<div className="set-row" key={set.id}>
|
||||||
|
<span className="set-index">{set.set_index}</span>
|
||||||
|
<span className="set-data">{set.weight} кг × {set.reps}</span>
|
||||||
|
<span className="set-cal">{set.calories ? Math.round(set.calories) : "—"} ккал</span>
|
||||||
|
<button className="set-remove" onClick={() => onRemoveSet(set.id)}>×</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{onAddSet && (
|
||||||
|
<div className="add-set-row">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={weight}
|
||||||
|
onChange={(e) => onWeightChange(Number(e.target.value))}
|
||||||
|
placeholder="Вес"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={reps}
|
||||||
|
onChange={(e) => onRepsChange(Number(e.target.value))}
|
||||||
|
placeholder="Повторы"
|
||||||
|
/>
|
||||||
|
<button className="primary" disabled={isAddingSet} onClick={onAddSet}>
|
||||||
|
{isAddingSet ? "..." : "Подход"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function History({ token }: { token: string }) {
|
function History({ token }: { token: string }) {
|
||||||
const workouts = useQuery({ queryKey: ["workouts"], queryFn: () => api.workouts(token) });
|
const workouts = useQuery({ queryKey: ["workouts"], queryFn: () => api.workouts(token) });
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ async function request<T>(path: string, options: RequestInit = {}, token?: strin
|
|||||||
const body = await response.json().catch(() => ({ detail: response.statusText }));
|
const body = await response.json().catch(() => ({ detail: response.statusText }));
|
||||||
throw new Error(typeof body.detail === "string" ? body.detail : JSON.stringify(body.detail));
|
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<T>;
|
return response.json() as Promise<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,6 +75,18 @@ export const api = {
|
|||||||
addWorkoutSet(token: string, itemId: string, payload: Partial<WorkoutSet>) {
|
addWorkoutSet(token: string, itemId: string, payload: Partial<WorkoutSet>) {
|
||||||
return request<WorkoutSet>(`/workout-items/${itemId}/sets`, { method: "POST", body: JSON.stringify(payload) }, token);
|
return request<WorkoutSet>(`/workout-items/${itemId}/sets`, { method: "POST", body: JSON.stringify(payload) }, token);
|
||||||
},
|
},
|
||||||
|
removeWorkoutItem(token: string, itemId: string) {
|
||||||
|
return request<void>(`/workout-items/${itemId}`, { method: "DELETE" }, token);
|
||||||
|
},
|
||||||
|
removeWorkoutSet(token: string, itemId: string, setId: string) {
|
||||||
|
return request<void>(`/workout-items/${itemId}/sets/${setId}`, { method: "DELETE" }, token);
|
||||||
|
},
|
||||||
|
finishWorkout(token: string, workoutId: string, notes?: string) {
|
||||||
|
return request<Workout>(`/workouts/${workoutId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({ finished_at: new Date().toISOString(), notes }),
|
||||||
|
}, token);
|
||||||
|
},
|
||||||
progression(token: string, kind: "exercise" | "equipment", entityId?: string) {
|
progression(token: string, kind: "exercise" | "equipment", entityId?: string) {
|
||||||
const params = new URLSearchParams({ kind });
|
const params = new URLSearchParams({ kind });
|
||||||
if (entityId) params.set("entity_id", entityId);
|
if (entityId) params.set("entity_id", entityId);
|
||||||
|
|||||||
+55
-1
@@ -54,6 +54,54 @@ input, select { width: 100%; border: 1px solid #d1d5db; border-radius: 14px; pad
|
|||||||
.pill.user { background: #dcfce7; color: #166534; }
|
.pill.user { background: #dcfce7; color: #166534; }
|
||||||
.empty-state { text-align: center; padding: 48px; }
|
.empty-state { text-align: center; padding: 48px; }
|
||||||
.workout-card { display: grid; gap: 18px; }
|
.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; }
|
.bars { display: grid; gap: 12px; }
|
||||||
.bar-row { display: grid; grid-template-columns: 110px 1fr 70px; gap: 12px; align-items: center; }
|
.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; }
|
.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; }
|
.hero-panel { min-height: 48vh; padding: 28px; }
|
||||||
.catalog-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
.catalog-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
.form-grid, .stats-grid { grid-template-columns: 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) {
|
@media (max-width: 620px) {
|
||||||
.page-header { align-items: stretch; flex-direction: column; }
|
.page-header { align-items: stretch; flex-direction: column; }
|
||||||
.catalog-grid, .workout-stats { grid-template-columns: 1fr; }
|
.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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
@app.get("/analytics/progression")
|
||||||
async def progression(
|
async def progression(
|
||||||
user: CurrentUser,
|
user: CurrentUser,
|
||||||
|
|||||||
@@ -323,6 +323,61 @@ def add_workout_set(
|
|||||||
return 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:
|
def estimate_set_calories(item: WorkoutItem, payload: WorkoutSetCreate) -> float:
|
||||||
if item.exercise and item.exercise.default_calories_per_minute and payload.duration_seconds:
|
if item.exercise and item.exercise.default_calories_per_minute and payload.duration_seconds:
|
||||||
return round(
|
return round(
|
||||||
|
|||||||
Reference in New Issue
Block a user