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
+110 -636
View File
@@ -1,62 +1,41 @@
import { Link, Outlet, RouterProvider, createRootRoute, createRoute, createRouter, useNavigate } from "@tanstack/react-router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { FormEvent, useEffect, useMemo, useState } from "react"; import { useState } from "react";
import { api, type AuthState } from "./api"; import { ApiError, api, type AuthState } from "./api";
import type { CatalogEntity, Workout, WorkoutItem as WorkoutItemType, WorkoutSet } from "./types"; import { AnalyticsPage } from "./features/analytics/AnalyticsPage";
import { AuthProvider, useAuth } from "./features/auth/AuthContext";
import { AuthScreen } from "./features/auth/AuthScreen";
import { clearAuth, loadAuth, saveAuth } from "./features/auth/authStorage";
import { CatalogPage } from "./features/catalog/CatalogPage";
import { HistoryPage } from "./features/history/HistoryPage";
import { WorkoutDetailPage } from "./features/history/WorkoutDetailPage";
import { ActiveWorkoutPage } from "./features/workout/ActiveWorkoutPage";
import { invalidateWorkoutQueries } from "./features/workout/hooks";
import { Metric } from "./shared/Metric";
type Tab = "dashboard" | "catalog" | "workout" | "history" | "analytics"; function AppShell() {
const { auth, logout } = useAuth();
const authStorageKey = "train-watcher-auth"; const nav = [
{ to: "/", label: "Сегодня" },
function loadAuth(): AuthState | null { { to: "/workout/active", label: "Тренировка" },
const raw = localStorage.getItem(authStorageKey); { to: "/catalog/exercises", label: "Упражнения" },
if (!raw) return null; { to: "/history", label: "История" },
try { { to: "/analytics", label: "Аналитика" },
return JSON.parse(raw) as AuthState; ] as const;
} catch {
localStorage.removeItem(authStorageKey);
return null;
}
}
export function App() {
const [auth, setAuth] = useState<AuthState | null>(loadAuth);
const [tab, setTab] = useState<Tab>("dashboard");
const queryClient = useQueryClient();
function saveAuth(next: AuthState) {
localStorage.setItem(authStorageKey, JSON.stringify(next));
setAuth(next);
}
function logout() {
localStorage.removeItem(authStorageKey);
queryClient.clear();
setAuth(null);
}
if (!auth) {
return <AuthScreen onAuth={saveAuth} />;
}
return ( return (
<div className="app-shell"> <div className="app-shell">
<aside className="sidebar"> <aside className="sidebar">
<div> <div className="brand-mark">
<p className="eyebrow">Train Watcher</p> <p className="eyebrow">Train Watcher</p>
<h1>Дневник тренировок</h1> <h1>Дневник силовых сессий</h1>
</div> </div>
<nav> <nav aria-label="Основная навигация">
{([ {nav.map((item) => (
["dashboard", "Обзор"], <Link key={item.to} to={item.to} activeProps={{ className: "active" }} activeOptions={{ exact: item.to === "/" }}>
["catalog", "Каталог"], {item.label}
["workout", "Тренировка"], </Link>
["history", "История"],
["analytics", "Аналитика"],
] as Array<[Tab, string]>).map(([key, label]) => (
<button className={tab === key ? "active" : ""} key={key} onClick={() => setTab(key)}>
{label}
</button>
))} ))}
</nav> </nav>
<div className="profile-card"> <div className="profile-card">
@@ -65,625 +44,120 @@ export function App() {
<button onClick={logout}>Выйти</button> <button onClick={logout}>Выйти</button>
</div> </div>
</aside> </aside>
<main> <main className="main-stage">
{tab === "dashboard" && <Dashboard token={auth.accessToken} onStart={() => setTab("workout")} />} <Outlet />
{tab === "catalog" && <Catalog token={auth.accessToken} />}
{tab === "workout" && <ActiveWorkout token={auth.accessToken} />}
{tab === "history" && <History token={auth.accessToken} />}
{tab === "analytics" && <Analytics token={auth.accessToken} />}
</main> </main>
</div> </div>
); );
} }
function AuthScreen({ onAuth }: { onAuth: (auth: AuthState) => void }) { function HomePage() {
const [mode, setMode] = useState<"login" | "register">("login"); const { auth } = useAuth();
const [email, setEmail] = useState("demo@example.com"); const navigate = useNavigate();
const [password, setPassword] = useState("password123");
const [displayName, setDisplayName] = useState("Demo Athlete");
const [error, setError] = useState<string | null>(null);
const mutation = useMutation({
mutationFn: () =>
mode === "login"
? api.login({ email, password })
: api.register({ email, password, display_name: displayName }),
onSuccess: onAuth,
onError: (err) => setError(err.message),
});
function submit(event: FormEvent) {
event.preventDefault();
setError(null);
mutation.mutate();
}
return (
<main className="auth-layout">
<section className="hero-panel">
<p className="eyebrow">Progressive overload tracker</p>
<h1>Фиксируй подходы, рабочий вес и динамику без лишнего шума.</h1>
<p>
MVP уже разделен на frontend, BFF и logic-service, а изображения каталога хранятся в MinIO как S3-объекты.
</p>
</section>
<form className="card auth-card" onSubmit={submit}>
<h2>{mode === "login" ? "Вход" : "Регистрация"}</h2>
{mode === "register" && (
<label>
Имя
<input value={displayName} onChange={(event) => setDisplayName(event.target.value)} />
</label>
)}
<label>
Email
<input type="email" autoComplete="username" value={email} onChange={(event) => setEmail(event.target.value)} />
</label>
<label>
Пароль
<input type="password" autoComplete={mode === "register" ? "new-password" : "current-password"} value={password} onChange={(event) => setPassword(event.target.value)} />
</label>
{error && <p className="error">{error}</p>}
<button className="primary" disabled={mutation.isPending}>
{mutation.isPending ? "Отправка..." : mode === "login" ? "Войти" : "Создать аккаунт"}
</button>
<button type="button" className="ghost" onClick={() => setMode(mode === "login" ? "register" : "login")}>
{mode === "login" ? "Нужна регистрация" : "Уже есть аккаунт"}
</button>
</form>
</main>
);
}
function useCatalog(token: string) {
const equipment = useQuery({ queryKey: ["equipment"], queryFn: () => api.equipment(token) });
const exercises = useQuery({ queryKey: ["exercises"], queryFn: () => api.exercises(token) });
return { equipment, exercises };
}
function Dashboard({ token, onStart }: { token: string; onStart: () => void }) {
const workouts = useQuery({ queryKey: ["workouts"], queryFn: () => api.workouts(token) });
const calories = useQuery({ queryKey: ["calories"], queryFn: () => api.calories(token) });
const latest = workouts.data?.[0];
return (
<section className="stack">
<div className="page-header">
<div>
<p className="eyebrow">Dashboard</p>
<h2>Обзор</h2>
</div>
<button className="primary" onClick={onStart}>Начать тренировку</button>
</div>
<div className="stats-grid">
<Metric label="Тренировок" value={workouts.data?.length ?? 0} />
<Metric label="Калорий всего" value={Math.round(calories.data?.total_calories ?? 0)} />
<Metric label="Последняя" value={latest ? new Date(latest.started_at).toLocaleDateString("ru-RU") : "нет"} />
</div>
<section className="card">
<h3>Последняя тренировка</h3>
{latest ? <WorkoutSummary workout={latest} /> : <p className="muted">Создай первую тренировку, чтобы увидеть историю.</p>}
</section>
</section>
);
}
function Catalog({ token }: { token: string }) {
const [kind, setKind] = useState<"equipment" | "exercise">("equipment");
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [file, setFile] = useState<File | null>(null);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { equipment, exercises } = useCatalog(token); const activeWorkout = useQuery({ queryKey: ["workout", "active"], queryFn: () => api.activeWorkout(auth.accessToken) });
const list = kind === "equipment" ? equipment.data : exercises.data; const workouts = useQuery({ queryKey: ["workouts"], queryFn: () => api.workouts(auth.accessToken) });
const calories = useQuery({ queryKey: ["calories"], queryFn: () => api.calories(auth.accessToken) });
const createMutation = useMutation({
mutationFn: async () => {
const image = file ? await api.uploadImage(token, kind, file) : {};
const payload = { name, description, ...image };
return kind === "equipment" ? api.createEquipment(token, payload) : api.createExercise(token, payload);
},
onSuccess: () => {
setName("");
setDescription("");
setFile(null);
void queryClient.invalidateQueries({ queryKey: [kind === "equipment" ? "equipment" : "exercises"] });
},
});
return (
<section className="stack">
<div className="page-header">
<div>
<p className="eyebrow">Catalog</p>
<h2>Тренажеры и упражнения</h2>
</div>
<div className="segmented">
<button className={kind === "equipment" ? "active" : ""} onClick={() => setKind("equipment")}>Тренажеры</button>
<button className={kind === "exercise" ? "active" : ""} onClick={() => setKind("exercise")}>Упражнения</button>
</div>
</div>
<form
className="card form-grid"
onSubmit={(event) => {
event.preventDefault();
createMutation.mutate();
}}
>
<label>
Название
<input value={name} onChange={(event) => setName(event.target.value)} required />
</label>
<label>
Описание
<input value={description} onChange={(event) => setDescription(event.target.value)} />
</label>
<label>
Картинка
<input type="file" accept="image/png,image/jpeg,image/webp" onChange={(event) => setFile(event.target.files?.[0] ?? null)} />
</label>
<button className="primary" disabled={createMutation.isPending}>{createMutation.isPending ? "Сохранение..." : "Добавить"}</button>
{createMutation.error && <p className="error">{createMutation.error.message}</p>}
</form>
<div className="catalog-grid">
{list?.map((entity) => <CatalogCard entity={entity} key={entity.id} />)}
</div>
</section>
);
}
function ActiveWorkout({ token }: { token: string }) {
const queryClient = useQueryClient();
const { equipment, exercises } = useCatalog(token);
const workouts = useQuery({ queryKey: ["workouts"], queryFn: () => api.workouts(token) });
const [activeWorkout, setActiveWorkout] = useState<Workout | null>(null);
const [catalogKind, setCatalogKind] = useState<"exercise" | "equipment">("exercise");
const [expandedItemId, setExpandedItemId] = useState<string | null>(null);
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(auth.accessToken),
onSuccess: (workout) => { onSuccess: async () => {
setActiveWorkout(workout); await invalidateWorkoutQueries(queryClient);
void queryClient.invalidateQueries({ queryKey: ["workouts"] }); void navigate({ to: "/workout/active" });
}, },
}); onError: async (error) => {
if (error instanceof ApiError && error.status === 409) {
const addItemMutation = useMutation({ await invalidateWorkoutQueries(queryClient);
mutationFn: (entity: CatalogEntity) => { void navigate({ to: "/workout/active" });
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) => {
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 addSetsMutation = useMutation({
mutationFn: async ({ itemId, sets }: { itemId: string; sets: Array<{ weight: number; reps: number }> }) => {
const created: WorkoutSet[] = [];
for (const set of sets) {
created.push(await api.addWorkoutSet(token, itemId, set));
} }
return created;
},
onSuccess: (workoutSets, { itemId }) => {
setActiveWorkout((w) => {
if (!w) return w;
const addedCalories = workoutSets.reduce((sum, set) => sum + (set.calories ?? 0), 0);
return {
...w,
estimated_calories: w.estimated_calories + addedCalories,
items: w.items.map((item) =>
item.id === itemId ? { ...item, sets: [...item.sets, ...workoutSets] } : item,
),
};
});
void queryClient.invalidateQueries({ queryKey: ["workouts"] });
void queryClient.invalidateQueries({ queryKey: ["calories"] });
}, },
}); });
const removeSetMutation = useMutation({ const active = activeWorkout.data;
mutationFn: ({ itemId, setId }: { itemId: string; setId: string }) => const latestFinished = workouts.data?.find((workout) => workout.status === "finished");
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 home-stack">
<div className="page-header"> <div className="page-header">
<div> <div>
<p className="eyebrow">Workout</p> <p className="eyebrow">Сегодня</p>
<h2>Текущая тренировка</h2> <h2>Состояние дня</h2>
</div>
<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>
</div> </div>
<section className="card catalog-picker-section"> <section className={`daily-command card ${active ? "active-session" : "idle-session"}`}>
<div className="catalog-picker-header"> <div>
<h3>Каталог</h3> <p className="eyebrow">{active ? "Active workout" : "Ready state"}</p>
<div className="segmented"> <h3>{active ? "Активная тренировка" : "Нет активной тренировки"}</h3>
<button className={catalogKind === "exercise" ? "active" : ""} onClick={() => setCatalogKind("exercise")}>Упражнения</button> <p>
<button className={catalogKind === "equipment" ? "active" : ""} onClick={() => setCatalogKind("equipment")}>Тренажеры</button> {active
</div> ? `Начата ${new Date(active.started_at).toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" })}. Продолжи журнал подходов.`
: "Запусти сессию и добавляй упражнения по ходу тренировки — каталог откроется отдельно."}
</p>
</div> </div>
<div className="catalog-grid workout-catalog-grid"> <button className="primary pulse-action" onClick={() => (active ? navigate({ to: "/workout/active" }) : startMutation.mutate())} disabled={startMutation.isPending}>
{catalogList?.map((entity) => { {startMutation.isPending ? "Создание..." : active ? "Продолжить" : "Начать тренировку"}
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>
</section>
<section className="card workout-cart">
<h3>Тренировка ({current.items.length} эл., {totalSets} подх.)</h3>
{current.items.length === 0 ? (
<p className="muted">Добавь упражнения из каталога выше.</p>
) : (
<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)}
onAddSets={(sets) => addSetsMutation.mutate({ itemId: item.id, sets })}
onRemoveSet={(setId) => removeSetMutation.mutate({ itemId: item.id, setId })}
isAddingSet={addSetsMutation.isPending}
isRemovingItem={removeItemMutation.isPending}
/>
))}
</div>
)}
<button
className="primary finish-btn"
disabled={current.items.length === 0 || finishMutation.isPending}
onClick={() => setShowFinishModal(true)}
>
Завершить тренировку
</button> </button>
</section> </section>
{showFinishModal && ( <div className="stats-grid">
<div className="modal-overlay" onClick={() => setShowFinishModal(false)}> <Metric label="Всего тренировок" value={workouts.data?.filter((w) => w.status !== "discarded").length ?? 0} />
<div className="modal-card card" onClick={(e) => e.stopPropagation()}> <Metric label="Калорий" value={Math.round(calories.data?.total_calories ?? 0)} />
<h2>Завершить тренировку?</h2> <Metric label="Последняя" value={latestFinished ? new Date(latestFinished.started_at).toLocaleDateString("ru-RU") : "нет"} />
<div className="modal-stats"> </div>
<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({ const rootRoute = createRootRoute({ component: AppShell });
item, const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: "/", component: HomePage });
exercises, const activeWorkoutRoute = createRoute({ getParentRoute: () => rootRoute, path: "/workout/active", component: ActiveWorkoutPage });
equipment, const catalogExercisesRoute = createRoute({ getParentRoute: () => rootRoute, path: "/catalog/exercises", component: () => <CatalogPage kind="exercise" /> });
expanded, const catalogEquipmentRoute = createRoute({ getParentRoute: () => rootRoute, path: "/catalog/equipment", component: () => <CatalogPage kind="equipment" /> });
onToggle, const historyRoute = createRoute({ getParentRoute: () => rootRoute, path: "/history", component: HistoryPage });
onRemoveItem, const analyticsRoute = createRoute({ getParentRoute: () => rootRoute, path: "/analytics", component: AnalyticsPage });
onAddSets, const workoutDetailRoute = createRoute({ getParentRoute: () => rootRoute, path: "/workouts/$workoutId", component: WorkoutDetailPage });
onRemoveSet,
isAddingSet,
isRemovingItem,
}: {
item: WorkoutItemType;
exercises: CatalogEntity[] | undefined;
equipment: CatalogEntity[] | undefined;
expanded: boolean;
onToggle: () => void;
onRemoveItem: () => void;
onAddSets: (sets: Array<{ weight: number; reps: number }>) => void;
onRemoveSet: (setId: string) => 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]);
const [draftCount, setDraftCount] = useState(3);
const [draftSets, setDraftSets] = useState(() =>
Array.from({ length: 8 }, () => ({ weight: 60, reps: 8 })),
);
function updateDraftSet(index: number, key: "weight" | "reps", value: number) { const routeTree = rootRoute.addChildren([
setDraftSets((sets) => sets.map((set, i) => (i === index ? { ...set, [key]: value } : set))); indexRoute,
activeWorkoutRoute,
catalogExercisesRoute,
catalogEquipmentRoute,
historyRoute,
analyticsRoute,
workoutDetailRoute,
]);
const router = createRouter({ routeTree });
declare module "@tanstack/react-router" {
interface Register {
router: typeof router;
}
}
export function App() {
const [auth, setAuth] = useState<AuthState | null>(loadAuth);
const queryClient = useQueryClient();
function handleAuth(next: AuthState) {
saveAuth(next);
setAuth(next);
} }
function saveDraftSets() { function logout() {
onAddSets(draftSets.slice(0, draftCount)); clearAuth();
queryClient.clear();
setAuth(null);
}
if (!auth) {
return <AuthScreen onAuth={handleAuth} />;
} }
return ( return (
<div className={`cart-item${expanded ? " expanded" : ""}`}> <AuthProvider auth={auth} logout={logout}>
<div className="cart-item-header" onClick={onToggle}> <RouterProvider router={router} />
<div className="cart-item-info"> </AuthProvider>
<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>
)}
<div className="set-builder">
<div className="slider-head">
<span>Новые подходы</span>
<strong>{draftCount}</strong>
</div>
<input
aria-label="Количество подходов"
className="set-count-slider"
type="range"
min="1"
max="8"
value={draftCount}
onChange={(e) => setDraftCount(Number(e.target.value))}
/>
<div className="draft-sets">
{draftSets.slice(0, draftCount).map((set, index) => (
<div className="draft-set-row" key={index}>
<span>{index + 1}</span>
<label>
Вес
<input
type="number"
value={set.weight}
onChange={(e) => updateDraftSet(index, "weight", Number(e.target.value))}
/>
</label>
<label>
Повторы
<input
type="number"
value={set.reps}
onChange={(e) => updateDraftSet(index, "reps", Number(e.target.value))}
/>
</label>
</div>
))}
</div>
<button className="primary add-sets-btn" disabled={isAddingSet} onClick={saveDraftSets}>
{isAddingSet ? "Записываю..." : `Записать ${draftCount} подх.`}
</button>
</div>
</div>
)}
</div>
);
}
function History({ token }: { token: string }) {
const workouts = useQuery({ queryKey: ["workouts"], queryFn: () => api.workouts(token) });
return (
<section className="stack">
<div className="page-header"><div><p className="eyebrow">History</p><h2>История</h2></div></div>
{workouts.data?.map((workout) => <WorkoutSummary workout={workout} key={workout.id} />)}
</section>
);
}
function Analytics({ token }: { token: string }) {
const { exercises } = useCatalog(token);
const [exerciseId, setExerciseId] = useState("");
const progression = useQuery({
queryKey: ["progression", exerciseId],
queryFn: () => api.progression(token, "exercise", exerciseId || undefined),
});
const calories = useQuery({ queryKey: ["calories"], queryFn: () => api.calories(token) });
return (
<section className="stack">
<div className="page-header"><div><p className="eyebrow">Analytics</p><h2>Прогрессия и калораж</h2></div></div>
<section className="card form-grid">
<label>
Упражнение
<select value={exerciseId} onChange={(event) => setExerciseId(event.target.value)}>
<option value="">Все упражнения</option>
{exercises.data?.map((exercise) => <option key={exercise.id} value={exercise.id}>{exercise.name}</option>)}
</select>
</label>
<Metric label="Последний вес" value={progression.data?.last_weight ?? "нет"} />
<Metric label="Максимальный вес" value={progression.data?.max_weight ?? "нет"} />
<Metric label="Калорий всего" value={Math.round(calories.data?.total_calories ?? 0)} />
</section>
<section className="card chart-card">
<h3>Объем по датам</h3>
<div className="bars">
{progression.data?.points.map((point) => (
<div className="bar-row" key={point.date}>
<span>{point.date}</span>
<div><i style={{ width: `${Math.min(point.volume / 20, 100)}%` }} /></div>
<b>{Math.round(point.volume)}</b>
</div>
))}
</div>
</section>
</section>
);
}
function CatalogCard({ entity }: { entity: CatalogEntity }) {
return (
<article className="card catalog-card">
{entity.image_s3_url ? <img src={entity.image_s3_url} alt="" /> : <div className="image-placeholder">TW</div>}
<div>
<span className={entity.is_builtin ? "pill" : "pill user"}>{entity.is_builtin ? "стандартное" : "мое"}</span>
<h3>{entity.name}</h3>
<p>{entity.description || "Без описания"}</p>
</div>
</article>
);
}
function WorkoutSummary({ workout }: { workout: Workout }) {
const setCount = useMemo(() => workout.items.reduce((total, item) => total + item.sets.length, 0), [workout.items]);
return (
<section className="card workout-card">
<div>
<h3>{new Date(workout.started_at).toLocaleString("ru-RU")}</h3>
<p className="muted">{workout.notes || "Без заметок"}</p>
</div>
<div className="workout-stats">
<Metric label="Элементов" value={workout.items.length} />
<Metric label="Подходов" value={setCount} />
<Metric label="Ккал" value={Math.round(workout.estimated_calories ?? 0)} />
</div>
</section>
);
}
function Metric({ label, value }: { label: string; value: string | number }) {
return (
<div className="metric">
<span>{label}</span>
<strong>{value}</strong>
</div>
); );
} }
+59 -17
View File
@@ -1,4 +1,14 @@
import type { Calories, CatalogEntity, Progression, User, Workout, WorkoutItem, WorkoutSet } from "./types"; import type {
Calories,
CatalogEntity,
CatalogKind,
Progression,
User,
Workout,
WorkoutItem,
WorkoutSet,
WorkoutSetInput,
} from "./types";
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "/api"; const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "/api";
@@ -7,9 +17,19 @@ export type AuthState = {
user: User; user: User;
}; };
export class ApiError extends Error {
status: number;
constructor(message: string, status: number) {
super(message);
this.name = "ApiError";
this.status = status;
}
}
async function request<T>(path: string, options: RequestInit = {}, token?: string): Promise<T> { async function request<T>(path: string, options: RequestInit = {}, token?: string): Promise<T> {
const headers = new Headers(options.headers); const headers = new Headers(options.headers);
if (!(options.body instanceof FormData)) { if (!(options.body instanceof FormData) && options.body !== undefined) {
headers.set("Content-Type", "application/json"); headers.set("Content-Type", "application/json");
} }
if (token) { if (token) {
@@ -19,7 +39,8 @@ async function request<T>(path: string, options: RequestInit = {}, token?: strin
const response = await fetch(`${API_BASE_URL}${path}`, { ...options, headers }); const response = await fetch(`${API_BASE_URL}${path}`, { ...options, headers });
if (!response.ok) { if (!response.ok) {
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)); const detail = typeof body.detail === "string" ? body.detail : JSON.stringify(body.detail);
throw new ApiError(detail, response.status);
} }
if (response.status === 204 || response.headers.get("content-length") === "0") { if (response.status === 204 || response.headers.get("content-length") === "0") {
return undefined as T; return undefined as T;
@@ -29,11 +50,17 @@ async function request<T>(path: string, options: RequestInit = {}, token?: strin
export const api = { export const api = {
async register(payload: { email: string; password: string; display_name: string }): Promise<AuthState> { async register(payload: { email: string; password: string; display_name: string }): Promise<AuthState> {
const raw = await request<{ access_token: string; user: User }>("/auth/register", { method: "POST", body: JSON.stringify(payload) }); const raw = await request<{ access_token: string; user: User }>(
"/auth/register",
{ method: "POST", body: JSON.stringify(payload) },
);
return { accessToken: raw.access_token, user: raw.user }; return { accessToken: raw.access_token, user: raw.user };
}, },
async login(payload: { email: string; password: string }): Promise<AuthState> { async login(payload: { email: string; password: string }): Promise<AuthState> {
const raw = await request<{ access_token: string; user: User }>("/auth/login", { method: "POST", body: JSON.stringify(payload) }); const raw = await request<{ access_token: string; user: User }>(
"/auth/login",
{ method: "POST", body: JSON.stringify(payload) },
);
return { accessToken: raw.access_token, user: raw.user }; return { accessToken: raw.access_token, user: raw.user };
}, },
me(token: string) { me(token: string) {
@@ -45,7 +72,7 @@ export const api = {
exercises(token: string) { exercises(token: string) {
return request<CatalogEntity[]>("/catalog/exercises", {}, token); return request<CatalogEntity[]>("/catalog/exercises", {}, token);
}, },
uploadImage(token: string, entityType: "equipment" | "exercise", file: File) { uploadImage(token: string, entityType: CatalogKind, file: File) {
const form = new FormData(); const form = new FormData();
form.append("file", file); form.append("file", file);
return request<{ image_s3_url: string; image_s3_key: string }>( return request<{ image_s3_url: string; image_s3_key: string }>(
@@ -63,31 +90,46 @@ export const api = {
workouts(token: string) { workouts(token: string) {
return request<Workout[]>("/workouts", {}, token); return request<Workout[]>("/workouts", {}, token);
}, },
createWorkout(token: string, notes?: string) { activeWorkout(token: string) {
return request<Workout>("/workouts", { method: "POST", body: JSON.stringify({ notes }) }, token); return request<Workout | null>("/workouts/active", {}, token);
},
getWorkout(token: string, workoutId: string) {
return request<Workout>(`/workouts/${workoutId}`, {}, token);
},
createWorkout(token: string) {
return request<Workout>("/workouts", { method: "POST", body: JSON.stringify({}) }, token);
}, },
updateWorkout(token: string, workoutId: string, payload: Partial<Workout>) { updateWorkout(token: string, workoutId: string, payload: Partial<Workout>) {
return request<Workout>(`/workouts/${workoutId}`, { method: "PATCH", body: JSON.stringify(payload) }, token); return request<Workout>(`/workouts/${workoutId}`, { method: "PATCH", body: JSON.stringify(payload) }, token);
}, },
addWorkoutItem(token: string, workoutId: string, payload: Partial<WorkoutItem>) { finishWorkout(token: string, workoutId: string, notes?: string) {
return request<Workout>(`/workouts/${workoutId}/finish`, {
method: "POST",
body: JSON.stringify(notes ? { notes } : {}),
}, token);
},
discardWorkout(token: string, workoutId: string) {
return request<Workout>(`/workouts/${workoutId}/discard`, { method: "POST" }, token);
},
addWorkoutItem(token: string, workoutId: string, payload: { exercise_id?: string | null; equipment_id?: string | null }) {
return request<WorkoutItem>(`/workouts/${workoutId}/items`, { method: "POST", body: JSON.stringify(payload) }, token); return request<WorkoutItem>(`/workouts/${workoutId}/items`, { method: "POST", body: JSON.stringify(payload) }, token);
}, },
addWorkoutSet(token: string, itemId: string, payload: Partial<WorkoutSet>) { addWorkoutSet(token: string, itemId: string, payload: WorkoutSetInput) {
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);
}, },
addWorkoutSetBatch(token: string, itemId: string, payload: { sets: WorkoutSetInput[] }) {
return request<WorkoutSet[]>(`/workout-items/${itemId}/sets/batch`, { method: "POST", body: JSON.stringify(payload) }, token);
},
updateWorkoutSet(token: string, setId: string, payload: Partial<WorkoutSetInput>) {
return request<WorkoutSet>(`/workout-sets/${setId}`, { method: "PATCH", body: JSON.stringify(payload) }, token);
},
removeWorkoutItem(token: string, itemId: string) { removeWorkoutItem(token: string, itemId: string) {
return request<void>(`/workout-items/${itemId}`, { method: "DELETE" }, token); return request<void>(`/workout-items/${itemId}`, { method: "DELETE" }, token);
}, },
removeWorkoutSet(token: string, itemId: string, setId: string) { removeWorkoutSet(token: string, itemId: string, setId: string) {
return request<void>(`/workout-items/${itemId}/sets/${setId}`, { method: "DELETE" }, token); return request<void>(`/workout-items/${itemId}/sets/${setId}`, { method: "DELETE" }, token);
}, },
finishWorkout(token: string, workoutId: string, notes?: string) { progression(token: string, kind: CatalogKind, entityId?: 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) {
const params = new URLSearchParams({ kind }); const params = new URLSearchParams({ kind });
if (entityId) params.set("entity_id", entityId); if (entityId) params.set("entity_id", entityId);
return request<Progression>(`/analytics/progression?${params.toString()}`, {}, token); return request<Progression>(`/analytics/progression?${params.toString()}`, {}, token);
@@ -0,0 +1,56 @@
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { api } from "../../api";
import { Metric } from "../../shared/Metric";
import { useAuth } from "../auth/AuthContext";
import { useCatalog } from "../catalog/hooks";
export function AnalyticsPage() {
const { auth } = useAuth();
const token = auth.accessToken;
const { exercises } = useCatalog(token);
const [exerciseId, setExerciseId] = useState("");
const progression = useQuery({
queryKey: ["progression", exerciseId],
queryFn: () => api.progression(token, "exercise", exerciseId || undefined),
});
const calories = useQuery({ queryKey: ["calories"], queryFn: () => api.calories(token) });
const maxVolume = Math.max(...(progression.data?.points.map((point) => point.volume) ?? [1]), 1);
return (
<section className="stack">
<div className="page-header">
<div>
<p className="eyebrow">Analytics</p>
<h2>Прогрессия и калораж</h2>
</div>
</div>
<section className="card analytics-controls">
<label>
Упражнение
<select value={exerciseId} onChange={(event) => setExerciseId(event.target.value)}>
<option value="">Все упражнения</option>
{exercises.data?.map((exercise) => <option key={exercise.id} value={exercise.id}>{exercise.name}</option>)}
</select>
</label>
<Metric label="Последний вес" value={progression.data?.last_weight ?? "нет"} />
<Metric label="Максимальный вес" value={progression.data?.max_weight ?? "нет"} />
<Metric label="Калорий всего" value={Math.round(calories.data?.total_calories ?? 0)} />
</section>
<section className="card chart-card">
<h3>Объем по датам</h3>
<div className="bars">
{progression.data?.points.map((point) => (
<div className="bar-row" key={point.date}>
<span>{point.date}</span>
<div><i style={{ width: `${Math.max(4, (point.volume / maxVolume) * 100)}%` }} /></div>
<b>{Math.round(point.volume)}</b>
</div>
))}
</div>
</section>
</section>
);
}
@@ -0,0 +1,22 @@
/* eslint-disable react-refresh/only-export-components */
import { createContext, useContext, useMemo, type ReactNode } from "react";
import type { AuthState } from "../../api";
const AuthContext = createContext<{
auth: AuthState;
logout: () => void;
} | null>(null);
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used inside AuthContext");
}
return context;
}
export function AuthProvider({ auth, logout, children }: { auth: AuthState; logout: () => void; children: ReactNode }) {
const value = useMemo(() => ({ auth, logout }), [auth, logout]);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
+70
View File
@@ -0,0 +1,70 @@
import { useMutation } from "@tanstack/react-query";
import type { FormEvent } from "react";
import { useState } from "react";
import { api, type AuthState } from "../../api";
export function AuthScreen({ onAuth }: { onAuth: (auth: AuthState) => void }) {
const [mode, setMode] = useState<"login" | "register">("login");
const [email, setEmail] = useState("demo@example.com");
const [password, setPassword] = useState("password123");
const [displayName, setDisplayName] = useState("Demo Athlete");
const [error, setError] = useState<string | null>(null);
const mutation = useMutation({
mutationFn: () =>
mode === "login"
? api.login({ email, password })
: api.register({ email, password, display_name: displayName }),
onSuccess: onAuth,
onError: (err) => setError(err.message),
});
function submit(event: FormEvent) {
event.preventDefault();
setError(null);
mutation.mutate();
}
return (
<main className="auth-layout">
<section className="hero-panel" aria-label="Train Watcher intro">
<p className="eyebrow">Live workout console</p>
<h1>Один экран. Один подход. Весь прогресс под рукой.</h1>
<p>
Train Watcher теперь работает как живой журнал: стартуй сессию, добавляй упражнения через drawer и фиксируй каждый подход без ухода в каталог.
</p>
</section>
<form className="card auth-card" onSubmit={submit}>
<p className="eyebrow">{mode === "login" ? "Welcome back" : "New athlete"}</p>
<h2>{mode === "login" ? "Вход" : "Регистрация"}</h2>
{mode === "register" && (
<label>
Имя
<input value={displayName} onChange={(event) => setDisplayName(event.target.value)} />
</label>
)}
<label>
Email
<input type="email" autoComplete="username" value={email} onChange={(event) => setEmail(event.target.value)} />
</label>
<label>
Пароль
<input
type="password"
autoComplete={mode === "register" ? "new-password" : "current-password"}
value={password}
onChange={(event) => setPassword(event.target.value)}
/>
</label>
{error && <p className="error">{error}</p>}
<button className="primary" disabled={mutation.isPending}>
{mutation.isPending ? "Отправка..." : mode === "login" ? "Войти" : "Создать аккаунт"}
</button>
<button type="button" className="ghost" onClick={() => setMode(mode === "login" ? "register" : "login")}>
{mode === "login" ? "Нужна регистрация" : "Уже есть аккаунт"}
</button>
</form>
</main>
);
}
+22
View File
@@ -0,0 +1,22 @@
import type { AuthState } from "../../api";
const authStorageKey = "train-watcher-auth";
export function loadAuth(): AuthState | null {
const raw = localStorage.getItem(authStorageKey);
if (!raw) return null;
try {
return JSON.parse(raw) as AuthState;
} catch {
localStorage.removeItem(authStorageKey);
return null;
}
}
export function saveAuth(auth: AuthState) {
localStorage.setItem(authStorageKey, JSON.stringify(auth));
}
export function clearAuth() {
localStorage.removeItem(authStorageKey);
}
@@ -0,0 +1,17 @@
import type { ReactNode } from "react";
import type { CatalogEntity } from "../../types";
export function CatalogCard({ entity, action }: { entity: CatalogEntity; action?: ReactNode }) {
return (
<article className="card catalog-card">
{entity.image_s3_url ? <img src={entity.image_s3_url} alt="" /> : <div className="image-placeholder">TW</div>}
<div>
<span className={entity.is_builtin ? "pill" : "pill user"}>{entity.is_builtin ? "стандартное" : "мое"}</span>
<h3>{entity.name}</h3>
<p>{entity.description || "Без описания"}</p>
{action}
</div>
</article>
);
}
@@ -0,0 +1,77 @@
import { Link } from "@tanstack/react-router";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { FormEvent } from "react";
import { useMemo, useState } from "react";
import { useAuth } from "../auth/AuthContext";
import { api } from "../../api";
import type { CatalogKind } from "../../types";
import { CatalogCard } from "./CatalogCard";
import { useCatalog } from "./hooks";
export function CatalogPage({ kind }: { kind: CatalogKind }) {
const { auth } = useAuth();
const token = auth.accessToken;
const queryClient = useQueryClient();
const { equipment, exercises } = useCatalog(token);
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [file, setFile] = useState<File | null>(null);
const list = useMemo(() => (kind === "exercise" ? exercises.data : equipment.data) ?? [], [kind, exercises.data, equipment.data]);
const createMutation = useMutation({
mutationFn: async () => {
const image = file ? await api.uploadImage(token, kind, file) : {};
const payload = { name, description, ...image };
return kind === "equipment" ? api.createEquipment(token, payload) : api.createExercise(token, payload);
},
onSuccess: () => {
setName("");
setDescription("");
setFile(null);
void queryClient.invalidateQueries({ queryKey: ["catalog", kind === "equipment" ? "equipment" : "exercises"] });
},
});
function submit(event: FormEvent) {
event.preventDefault();
createMutation.mutate();
}
return (
<section className="stack">
<div className="page-header">
<div>
<p className="eyebrow">Catalog</p>
<h2>{kind === "exercise" ? "Упражнения" : "Тренажеры"}</h2>
</div>
<div className="segmented">
<Link to="/catalog/exercises" activeProps={{ className: "active" }}>Упражнения</Link>
<Link to="/catalog/equipment" activeProps={{ className: "active" }}>Тренажеры</Link>
</div>
</div>
<form className="card form-grid" onSubmit={submit}>
<label>
Название
<input value={name} onChange={(event) => setName(event.target.value)} required />
</label>
<label>
Описание
<input value={description} onChange={(event) => setDescription(event.target.value)} />
</label>
<label>
Картинка
<input type="file" accept="image/png,image/jpeg,image/webp" onChange={(event) => setFile(event.target.files?.[0] ?? null)} />
</label>
<button className="primary" disabled={createMutation.isPending}>{createMutation.isPending ? "Сохранение..." : "Добавить"}</button>
{createMutation.error && <p className="error">{createMutation.error.message}</p>}
</form>
<div className="catalog-grid">
{list.map((entity) => <CatalogCard entity={entity} key={entity.id} />)}
</div>
</section>
);
}
+9
View File
@@ -0,0 +1,9 @@
import { useQuery } from "@tanstack/react-query";
import { api } from "../../api";
export function useCatalog(token: string) {
const equipment = useQuery({ queryKey: ["catalog", "equipment"], queryFn: () => api.equipment(token) });
const exercises = useQuery({ queryKey: ["catalog", "exercises"], queryFn: () => api.exercises(token) });
return { equipment, exercises };
}
@@ -0,0 +1,33 @@
import { useQuery } from "@tanstack/react-query";
import { api } from "../../api";
import { useAuth } from "../auth/AuthContext";
import { WorkoutSummary } from "./WorkoutSummary";
export function HistoryPage() {
const { auth } = useAuth();
const workouts = useQuery({ queryKey: ["workouts"], queryFn: () => api.workouts(auth.accessToken) });
const visible = workouts.data?.filter((workout) => workout.status !== "discarded") ?? [];
const discarded = workouts.data?.filter((workout) => workout.status === "discarded") ?? [];
return (
<section className="stack">
<div className="page-header">
<div>
<p className="eyebrow">History</p>
<h2>История тренировок</h2>
</div>
</div>
{visible.length === 0 && <section className="card empty-state">История пока пустая.</section>}
{visible.map((workout) => <WorkoutSummary workout={workout} key={workout.id} />)}
{discarded.length > 0 && (
<details className="discarded-list card">
<summary>Отмененные тренировки ({discarded.length})</summary>
<div className="stack compact-stack">
{discarded.map((workout) => <WorkoutSummary workout={workout} key={workout.id} />)}
</div>
</details>
)}
</section>
);
}
@@ -0,0 +1,50 @@
import { useParams } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query";
import { api } from "../../api";
import { Metric } from "../../shared/Metric";
import { useAuth } from "../auth/AuthContext";
export function WorkoutDetailPage() {
const { workoutId } = useParams({ from: "/workouts/$workoutId" });
const { auth } = useAuth();
const workout = useQuery({ queryKey: ["workouts", workoutId], queryFn: () => api.getWorkout(auth.accessToken, workoutId) });
if (workout.isLoading) return <section className="card loading-card">Загружаю тренировку...</section>;
if (!workout.data) return <section className="card empty-state">Тренировка не найдена.</section>;
const data = workout.data;
return (
<section className="stack workout-detail">
<div className="page-header">
<div>
<p className="eyebrow">Workout detail</p>
<h2>{new Date(data.started_at).toLocaleDateString("ru-RU")}</h2>
</div>
</div>
<section className="card detail-hero">
<Metric label="Статус" value={data.status} />
<Metric label="Упражнения" value={data.items.length} />
<Metric label="Подходы" value={data.total_sets} />
<Metric label="Объем" value={`${Math.round(data.total_volume)} кг`} />
<Metric label="Ккал" value={`~${Math.round(data.estimated_calories)}`} />
</section>
{data.notes && <section className="card notes-card"><h3>Заметки</h3><p>{data.notes}</p></section>}
{data.items.map((item) => (
<section className="card detail-item" key={item.id}>
<h3>{item.title_snapshot}</h3>
<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>
<span />
</div>
))}
</div>
</section>
))}
</section>
);
}
@@ -0,0 +1,34 @@
import { Link } from "@tanstack/react-router";
import type { Workout } from "../../types";
import { Metric } from "../../shared/Metric";
function statusLabel(status: Workout["status"]) {
return status === "finished" ? "завершена" : status === "discarded" ? "отменена" : "активная";
}
export function WorkoutSummary({ workout }: { workout: Workout }) {
const duration = workout.finished_at
? Math.round((new Date(workout.finished_at).getTime() - new Date(workout.started_at).getTime()) / 60000)
: null;
return (
<section className={`card workout-summary status-${workout.status}`}>
<div className="summary-title">
<div>
<span className="status-chip">{statusLabel(workout.status)}</span>
<h3>{new Date(workout.started_at).toLocaleString("ru-RU")}</h3>
<p>{workout.notes || "Без заметок"}</p>
</div>
<Link to="/workouts/$workoutId" params={{ workoutId: workout.id }} className="ghost detail-link">Детали</Link>
</div>
<div className="workout-stats">
<Metric label="Длительность" value={duration === null ? "идет" : `${duration} мин`} />
<Metric label="Упражнения" value={workout.items.length} />
<Metric label="Подходы" value={workout.total_sets} />
<Metric label="Объем" value={`${Math.round(workout.total_volume)} кг`} />
<Metric label="Ккал" value={`~${Math.round(workout.estimated_calories ?? 0)}`} />
</div>
</section>
);
}
@@ -0,0 +1,166 @@
import { useNavigate } from "@tanstack/react-router";
import { useEffect, useMemo, useState } from "react";
import { useAuth } from "../auth/AuthContext";
import type { CatalogEntity, CatalogKind, WorkoutSetInput } from "../../types";
import { Metric } from "../../shared/Metric";
import { AddExerciseDrawer } from "./AddExerciseDrawer";
import { EmptyWorkoutState } from "./EmptyWorkoutState";
import { FinishWorkoutDialog } from "./FinishWorkoutDialog";
import { useActiveWorkout, useWorkoutMutations } from "./hooks";
import { WorkoutExerciseCard } from "./WorkoutExerciseCard";
function useElapsed(startedAt?: string) {
const [elapsed, setElapsed] = useState("00:00");
useEffect(() => {
if (!startedAt) return;
const started = new Date(startedAt).getTime();
const tick = () => {
const diff = Math.max(0, Date.now() - started);
const totalSeconds = Math.floor(diff / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
setElapsed(hours > 0 ? `${hours}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}` : `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`);
};
tick();
const intervalId = window.setInterval(tick, 1000);
return () => window.clearInterval(intervalId);
}, [startedAt]);
return elapsed;
}
export function ActiveWorkoutPage() {
const { auth } = useAuth();
const navigate = useNavigate();
const activeWorkout = useActiveWorkout();
const [drawerOpen, setDrawerOpen] = useState(false);
const [finishOpen, setFinishOpen] = useState(false);
const [discardOpen, setDiscardOpen] = useState(false);
const workout = activeWorkout.data;
const elapsed = useElapsed(workout?.started_at);
const mutations = useWorkoutMutations({
onStartConflict: () => void navigate({ to: "/workout/active" }),
onFinish: () => void navigate({ to: "/history" }),
onDiscard: () => void navigate({ to: "/" }),
});
const sortedItems = useMemo(() => [...(workout?.items ?? [])].sort((a, b) => a.order_index - b.order_index), [workout?.items]);
function addFromDrawer(entity: CatalogEntity, kind: CatalogKind, closeAfter: boolean) {
if (!workout) return;
mutations.addWorkoutItem.mutate(
{ workoutId: workout.id, sourceId: entity.id, kind },
{ onSuccess: () => { if (closeAfter) setDrawerOpen(false); } },
);
}
if (activeWorkout.isLoading) {
return <section className="card loading-card">Загружаю активную тренировку...</section>;
}
if (!workout) {
return (
<section className="stack">
<div className="page-header">
<div>
<p className="eyebrow">Active Workout</p>
<h2>Живой журнал</h2>
</div>
</div>
<section className="card workout-cta">
<h3>Нет активной тренировки</h3>
<p>Новая сессия создаст защищенный active workout. Если сессия уже существует, приложение восстановит ее после обновления.</p>
<button className="primary" disabled={mutations.startWorkout.isPending} onClick={() => mutations.startWorkout.mutate()}>
{mutations.startWorkout.isPending ? "Создание..." : "Начать тренировку"}
</button>
</section>
</section>
);
}
const anyPending =
mutations.addWorkoutItem.isPending ||
mutations.recordWorkoutSet.isPending ||
mutations.recordWorkoutSetsBatch.isPending ||
mutations.removeWorkoutItem.isPending ||
mutations.removeWorkoutSet.isPending ||
mutations.updateWorkoutSet.isPending;
return (
<section className="stack workout-page">
<div className="live-header card">
<div>
<p className="eyebrow">Active Workout</p>
<h2>Живой журнал</h2>
<span>Старт: {new Date(workout.started_at).toLocaleString("ru-RU", { hour: "2-digit", minute: "2-digit", day: "2-digit", month: "short" })}</span>
</div>
<div className="live-clock" aria-label="Прошло времени">{elapsed}</div>
<div className="live-metrics">
<Metric label="Подходы" value={workout.total_sets} tone="dark" />
<Metric label="Объем" value={`${Math.round(workout.total_volume)} кг`} tone="dark" />
<Metric label="Ккал" value={`~${Math.round(workout.estimated_calories)}`} tone="dark" />
</div>
<div className="live-actions">
<button className="primary" onClick={() => setDrawerOpen(true)}>+ Добавить упражнение</button>
<button className="ghost" onClick={() => setFinishOpen(true)}>Завершить</button>
<button className="danger-ghost" onClick={() => setDiscardOpen(true)}>Отменить</button>
</div>
</div>
{sortedItems.length === 0 ? (
<EmptyWorkoutState onOpenDrawer={() => setDrawerOpen(true)} />
) : (
<div className="exercise-journal">
{sortedItems.map((item) => (
<WorkoutExerciseCard
key={item.id}
item={item}
pending={anyPending}
onRecordSet={(payload: WorkoutSetInput) => mutations.recordWorkoutSet.mutate({ itemId: item.id, payload })}
onRecordBatch={(sets) => mutations.recordWorkoutSetsBatch.mutate({ itemId: item.id, sets })}
onRemoveItem={() => mutations.removeWorkoutItem.mutate(item.id)}
onRemoveSet={(setId) => mutations.removeWorkoutSet.mutate({ itemId: item.id, setId })}
onUpdateSet={(setId, payload) => mutations.updateWorkoutSet.mutate({ setId, payload })}
/>
))}
</div>
)}
<button className="primary sticky-add" onClick={() => setDrawerOpen(true)}>+ Добавить упражнение</button>
<AddExerciseDrawer
open={drawerOpen}
token={auth.accessToken}
workout={workout}
pending={mutations.addWorkoutItem.isPending}
onClose={() => setDrawerOpen(false)}
onAdd={addFromDrawer}
/>
<FinishWorkoutDialog
open={finishOpen}
workout={workout}
pending={mutations.finishWorkout.isPending}
onClose={() => setFinishOpen(false)}
onFinish={(notes) => mutations.finishWorkout.mutate({ workoutId: workout.id, notes })}
/>
{discardOpen && (
<div className="dialog-backdrop" onClick={() => setDiscardOpen(false)}>
<div className="modal-card discard-dialog card" onClick={(event) => event.stopPropagation()}>
<p className="eyebrow">Danger command</p>
<h2>Отменить тренировку?</h2>
<p>Запись будет помечена как отмененная и исчезнет из основной аналитики. Подходы не удаляются физически.</p>
<div className="modal-actions">
<button className="ghost" onClick={() => setDiscardOpen(false)}>Назад</button>
<button className="danger" disabled={mutations.discardWorkout.isPending} onClick={() => mutations.discardWorkout.mutate(workout.id)}>
{mutations.discardWorkout.isPending ? "Отмена..." : "Отменить тренировку"}
</button>
</div>
</div>
</div>
)}
</section>
);
}
@@ -0,0 +1,89 @@
import { useMemo, useState } from "react";
import type { CatalogEntity, CatalogKind, Workout } from "../../types";
import { useCatalog } from "../catalog/hooks";
export function AddExerciseDrawer({
open,
token,
workout,
onClose,
onAdd,
pending,
}: {
open: boolean;
token: string;
workout: Workout;
onClose: () => void;
onAdd: (entity: CatalogEntity, kind: CatalogKind, closeAfter: boolean) => void;
pending?: boolean;
}) {
const [kind, setKind] = useState<CatalogKind>("exercise");
const [search, setSearch] = useState("");
const { exercises, equipment } = useCatalog(token);
const addedIds = useMemo(() => {
const ids = new Set<string>();
workout.items.forEach((item) => {
if (item.exercise_id) ids.add(item.exercise_id);
if (item.equipment_id) ids.add(item.equipment_id);
});
return ids;
}, [workout.items]);
const list = useMemo(() => {
const source = kind === "exercise" ? exercises.data ?? [] : equipment.data ?? [];
const needle = search.trim().toLowerCase();
return needle ? source.filter((entity) => entity.name.toLowerCase().includes(needle)) : source;
}, [kind, search, exercises.data, equipment.data]);
if (!open) return null;
return (
<div className="drawer-scrim" onClick={onClose}>
<aside className="add-drawer" aria-label="Добавить в тренировку" onClick={(event) => event.stopPropagation()}>
<header>
<div>
<p className="eyebrow">Catalog drawer</p>
<h2>Добавить в тренировку</h2>
</div>
<button className="round-close" onClick={onClose} aria-label="Закрыть">×</button>
</header>
<input className="drawer-search" placeholder="Поиск упражнения или тренажера" value={search} onChange={(event) => setSearch(event.target.value)} />
<div className="segmented drawer-tabs">
<button className={kind === "exercise" ? "active" : ""} onClick={() => setKind("exercise")}>Упражнения</button>
<button className={kind === "equipment" ? "active" : ""} onClick={() => setKind("equipment")}>Тренажеры</button>
</div>
<div className="drawer-list">
{list.map((entity) => {
const added = addedIds.has(entity.id);
return (
<article className={`drawer-pick ${added ? "already-added" : ""}`} key={`${kind}-${entity.id}`} onClick={() => onAdd(entity, kind, true)}>
{entity.image_s3_url ? <img src={entity.image_s3_url} alt="" /> : <span className="drawer-placeholder">{kind === "exercise" ? "EX" : "EQ"}</span>}
<div>
<h3>{entity.name}</h3>
<p>{entity.description || "Без описания"}</p>
{added && <b>В тренировке</b>}
</div>
<button
className="plus-chip"
disabled={pending}
onClick={(event) => {
event.stopPropagation();
onAdd(entity, kind, false);
}}
aria-label="Добавить и оставить drawer открытым"
>
+
</button>
</article>
);
})}
{list.length === 0 && <p className="muted">Ничего не найдено.</p>}
</div>
</aside>
</div>
);
}
@@ -0,0 +1,10 @@
export function EmptyWorkoutState({ onOpenDrawer }: { onOpenDrawer: () => void }) {
return (
<section className="empty-workout card">
<p className="eyebrow">Журнал пуст</p>
<h3>Добавь первое упражнение</h3>
<p>Каталог теперь открывается отдельным drawer, поэтому основной экран остается чистым журналом выполнения.</p>
<button className="primary" onClick={onOpenDrawer}>+ Добавить упражнение</button>
</section>
);
}
@@ -0,0 +1,52 @@
import { useState } from "react";
import type { Workout } from "../../types";
import { Metric } from "../../shared/Metric";
function minutesBetween(start: string, end = new Date().toISOString()) {
return Math.max(0, Math.round((new Date(end).getTime() - new Date(start).getTime()) / 60000));
}
export function FinishWorkoutDialog({
workout,
open,
pending,
onClose,
onFinish,
}: {
workout: Workout;
open: boolean;
pending?: boolean;
onClose: () => void;
onFinish: (notes?: string) => void;
}) {
const [notes, setNotes] = useState(workout.notes ?? "");
if (!open) return null;
return (
<div className="dialog-backdrop" onClick={onClose}>
<div className="modal-card finish-dialog card" onClick={(event) => event.stopPropagation()}>
<p className="eyebrow">Workout summary</p>
<h2>Итог тренировки</h2>
<div className="modal-stats">
<Metric label="Длительность" value={`${minutesBetween(workout.started_at)} мин`} />
<Metric label="Упражнения" value={workout.items.length} />
<Metric label="Подходы" value={workout.total_sets} />
<Metric label="Объем" value={`${Math.round(workout.total_volume)} кг`} />
<Metric label="Калории" value={`~${Math.round(workout.estimated_calories)}`} />
</div>
<label>
Заметка
<textarea value={notes} onChange={(event) => setNotes(event.target.value)} placeholder="Сегодня хорошо пошёл жим..." />
</label>
<div className="modal-actions">
<button className="ghost" onClick={onClose}>Продолжить</button>
<button className="primary" disabled={pending} onClick={() => onFinish(notes || undefined)}>
{pending ? "Сохранение..." : "Сохранить"}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,139 @@
import { useEffect, useMemo, useState } from "react";
import type { WorkoutItem, WorkoutSetInput } from "../../types";
import { WorkoutSetRow } from "./WorkoutSetRow";
function initialDraft(item: WorkoutItem) {
const last = item.sets[item.sets.length - 1];
return {
weight: last?.weight ?? item.planned_working_weight ?? 0,
reps: last?.reps ?? 8,
};
}
export function WorkoutExerciseCard({
item,
onRecordSet,
onRecordBatch,
onRemoveItem,
onRemoveSet,
onUpdateSet,
pending,
}: {
item: WorkoutItem;
onRecordSet: (payload: WorkoutSetInput) => void;
onRecordBatch: (sets: WorkoutSetInput[]) => void;
onRemoveItem: () => void;
onRemoveSet: (setId: string) => void;
onUpdateSet: (setId: string, payload: Partial<WorkoutSetInput>) => void;
pending?: boolean;
}) {
const seed = useMemo(() => initialDraft(item), [item]);
const [weight, setWeight] = useState(seed.weight);
const [reps, setReps] = useState(seed.reps);
const [showBatch, setShowBatch] = useState(false);
const [batchCount, setBatchCount] = useState(3);
const [batchWeight, setBatchWeight] = useState(seed.weight);
const [batchReps, setBatchReps] = useState(seed.reps);
useEffect(() => {
setWeight(seed.weight);
setReps(seed.reps);
setBatchWeight(seed.weight);
setBatchReps(seed.reps);
}, [item.id, seed.weight, seed.reps]);
const volume = item.sets.reduce((sum, set) => sum + set.weight * set.reps, 0);
const lastSet = item.sets[item.sets.length - 1];
function stepWeight(delta: number) {
setWeight((value) => Math.max(0, Number((value + delta).toFixed(1))));
}
function stepReps(delta: number) {
setReps((value) => Math.max(0, value + delta));
}
function recordBatch() {
onRecordBatch(Array.from({ length: batchCount }, () => ({ weight: batchWeight, reps: batchReps })));
setShowBatch(false);
}
return (
<article className="workout-exercise-card card">
<div className="exercise-card-media">
{item.image_s3_url_snapshot ? <img src={item.image_s3_url_snapshot} alt="" /> : <span>{item.source_kind === "exercise" ? "EX" : "EQ"}</span>}
</div>
<div className="exercise-card-body">
<header className="exercise-card-header">
<div>
<span className="source-pill">{item.source_kind === "exercise" ? "упражнение" : "тренажер"}</span>
<h3>{item.title_snapshot}</h3>
<p>{lastSet ? `Последний раз: ${lastSet.weight} кг × ${lastSet.reps}` : "Подходов еще нет"}</p>
</div>
<button className="danger-ghost" disabled={pending} onClick={onRemoveItem}>Удалить</button>
</header>
<div className="exercise-microstats">
<span>{item.sets.length} подх.</span>
<span>{Math.round(volume)} кг объема</span>
<span>рек. {Math.max(weight, item.planned_working_weight ?? weight)} × {reps}</span>
</div>
<div className="sets-list">
{item.sets.map((set) => (
<WorkoutSetRow
key={set.id}
set={set}
disabled={pending}
onRemove={() => onRemoveSet(set.id)}
onUpdate={(payload) => onUpdateSet(set.id, payload)}
/>
))}
</div>
<div className="single-set-console">
<div className="stepper-field">
<span>Вес</span>
<div>
<button onClick={() => stepWeight(-2.5)}>-</button>
<input aria-label="Вес" type="number" min="0" step="0.5" value={weight} onChange={(event) => setWeight(Number(event.target.value))} />
<button onClick={() => stepWeight(2.5)}>+</button>
</div>
</div>
<div className="stepper-field">
<span>Повторы</span>
<div>
<button onClick={() => stepReps(-1)}>-</button>
<input aria-label="Повторы" type="number" min="0" step="1" value={reps} onChange={(event) => setReps(Number(event.target.value))} />
<button onClick={() => stepReps(1)}>+</button>
</div>
</div>
<button className="primary record-set" disabled={pending} onClick={() => onRecordSet({ weight, reps })}>
{pending ? "Запись..." : "Записать подход"}
</button>
</div>
<button className="ghost batch-link" onClick={() => setShowBatch(true)}>Добавить несколько подходов</button>
</div>
{showBatch && (
<div className="dialog-backdrop" onClick={() => setShowBatch(false)}>
<div className="modal-card batch-dialog card" onClick={(event) => event.stopPropagation()}>
<p className="eyebrow">Batch sets</p>
<h2>Добавить несколько подходов</h2>
<div className="batch-grid">
<label>Кол-во<input type="number" min="1" max="12" value={batchCount} onChange={(event) => setBatchCount(Number(event.target.value))} /></label>
<label>Вес<input type="number" min="0" step="0.5" value={batchWeight} onChange={(event) => setBatchWeight(Number(event.target.value))} /></label>
<label>Повторы<input type="number" min="0" value={batchReps} onChange={(event) => setBatchReps(Number(event.target.value))} /></label>
</div>
<div className="modal-actions">
<button className="ghost" onClick={() => setShowBatch(false)}>Отмена</button>
<button className="primary" disabled={pending} onClick={recordBatch}>Добавить</button>
</div>
</div>
</div>
)}
</article>
);
}
@@ -0,0 +1,44 @@
import { useState } from "react";
import type { WorkoutSet, WorkoutSetInput } from "../../types";
export function WorkoutSetRow({
set,
onRemove,
onUpdate,
disabled,
}: {
set: WorkoutSet;
onRemove: () => void;
onUpdate: (payload: Partial<WorkoutSetInput>) => void;
disabled?: boolean;
}) {
const [editing, setEditing] = useState(false);
const [weight, setWeight] = useState(set.weight);
const [reps, setReps] = useState(set.reps);
if (editing) {
return (
<div className="set-row editing">
<span className="set-index">{set.set_index}</span>
<input aria-label="Вес подхода" type="number" min="0" step="0.5" value={weight} onChange={(event) => setWeight(Number(event.target.value))} />
<input aria-label="Повторы подхода" type="number" min="0" step="1" value={reps} onChange={(event) => setReps(Number(event.target.value))} />
<div className="set-row-actions">
<button className="tiny success" disabled={disabled} onClick={() => { onUpdate({ weight, reps }); setEditing(false); }}>OK</button>
<button className="tiny" onClick={() => setEditing(false)}>×</button>
</div>
</div>
);
}
return (
<div className="set-row">
<span className="set-index">{set.set_index}</span>
<button className="set-data" onClick={() => setEditing(true)} title="Редактировать подход">
{set.weight} кг × {set.reps}
</button>
<span className="set-cal">{set.calories ? Math.round(set.calories) : "—"} ккал</span>
<button className="set-remove" disabled={disabled} onClick={onRemove} aria-label="Удалить подход">×</button>
</div>
);
}
+15
View File
@@ -0,0 +1,15 @@
import { api } from "../../api";
import type { WorkoutSetInput } from "../../types";
export const workoutApi = {
active: api.activeWorkout,
start: api.createWorkout,
finish: api.finishWorkout,
discard: api.discardWorkout,
addItem: api.addWorkoutItem,
addSet: api.addWorkoutSet,
addSetBatch: (token: string, itemId: string, sets: WorkoutSetInput[]) => api.addWorkoutSetBatch(token, itemId, { sets }),
updateSet: api.updateWorkoutSet,
removeItem: api.removeWorkoutItem,
removeSet: api.removeWorkoutSet,
};
+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,
};
}
+9
View File
@@ -0,0 +1,9 @@
import type { CatalogKind, Workout, WorkoutItem, WorkoutSet, WorkoutSetInput } from "../../types";
export type { CatalogKind, Workout, WorkoutItem, WorkoutSet, WorkoutSetInput };
export type BatchSetDraft = {
count: number;
weight: number;
reps: number;
};
+10
View File
@@ -0,0 +1,10 @@
import type { ReactNode } from "react";
export function Metric({ label, value, tone = "default" }: { label: string; value: ReactNode; tone?: "default" | "dark" | "danger" }) {
return (
<div className={`metric metric-${tone}`}>
<span>{label}</span>
<strong>{value}</strong>
</div>
);
}
+220 -148
View File
@@ -1,18 +1,23 @@
:root { :root {
color: #172033; color: #131821;
background: #edf1f5; background: #ece7dc;
font-family: "Manrope", "IBM Plex Sans", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-family: "Aptos", "IBM Plex Sans", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-synthesis: none; font-synthesis: none;
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
--ink: #111827; --ink: #121620;
--muted: #64748b; --ink-2: #1f2937;
--line: rgba(17, 24, 39, 0.1); --muted: #6b7280;
--panel: rgba(255, 255, 255, 0.88); --paper: rgba(255, 252, 242, 0.9);
--panel-strong: #ffffff; --paper-strong: #fffaf0;
--lime: #c8ff3d; --charcoal: #0b0e14;
--blue: #2563eb; --amber: #ffb000;
--danger: #dc2626; --acid: #d9ff45;
--shadow: 0 22px 70px rgba(15, 23, 42, 0.12); --blue: #2764ff;
--danger: #df2b2b;
--green: #1f9d55;
--line: rgba(18, 22, 32, 0.12);
--white-line: rgba(255, 255, 255, 0.12);
--shadow: 0 26px 90px rgba(34, 28, 18, 0.16);
} }
* { box-sizing: border-box; } * { box-sizing: border-box; }
@@ -21,208 +26,275 @@ body {
min-width: 320px; min-width: 320px;
min-height: 100vh; min-height: 100vh;
background: background:
radial-gradient(circle at 85% -10%, rgba(200,255,61,0.22), transparent 32%), linear-gradient(90deg, rgba(18,22,32,0.035) 1px, transparent 1px) 0 0 / 38px 38px,
radial-gradient(circle at 15% 8%, rgba(37,99,235,0.12), transparent 30%), linear-gradient(0deg, rgba(18,22,32,0.035) 1px, transparent 1px) 0 0 / 38px 38px,
linear-gradient(180deg, #f7f8fb 0%, #e7ecf2 100%); radial-gradient(circle at 86% -8%, rgba(255,176,0,0.42), transparent 30%),
radial-gradient(circle at 6% 10%, rgba(39,100,255,0.18), transparent 32%),
linear-gradient(180deg, #fff8e8 0%, #e9e2d5 100%);
} }
button, input, select { font: inherit; } body::before {
button { border: 0; cursor: pointer; transition: transform .16s ease, background .16s ease, border-color .16s ease, opacity .16s ease; } content: "";
button:hover:not(:disabled) { transform: translateY(-1px); } position: fixed;
inset: 0;
pointer-events: none;
opacity: 0.24;
background-image: radial-gradient(rgba(18,22,32,0.18) 0.7px, transparent 0.7px);
background-size: 5px 5px;
mix-blend-mode: multiply;
}
button, input, select, textarea { font: inherit; }
button, a { transition: transform .16s ease, background .16s ease, border-color .16s ease, color .16s ease, opacity .16s ease; }
button { border: 0; cursor: pointer; }
button:hover:not(:disabled), a:hover { transform: translateY(-1px); }
button:disabled { cursor: not-allowed; opacity: 0.55; } button:disabled { cursor: not-allowed; opacity: 0.55; }
a { color: inherit; text-decoration: none; }
.app-shell { min-height: 100vh; display: grid; grid-template-columns: 292px 1fr; } .app-shell { min-height: 100vh; display: grid; grid-template-columns: 304px 1fr; }
.sidebar { .sidebar {
position: sticky; position: sticky;
top: 0; top: 0;
height: 100vh; height: 100vh;
background: #0b0f16; color: #fff7e8;
color: #f8fafc;
padding: 30px 22px; padding: 30px 22px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 30px; gap: 32px;
background:
linear-gradient(155deg, rgba(217,255,69,0.12), transparent 34%),
linear-gradient(180deg, #11151e, #07090d);
border-right: 1px solid rgba(255,255,255,0.08); border-right: 1px solid rgba(255,255,255,0.08);
} }
.sidebar h1 { font-size: 30px; line-height: .94; letter-spacing: -0.05em; margin: 8px 0 0; } .brand-mark h1 { font-size: 31px; line-height: .93; letter-spacing: -0.06em; margin: 8px 0 0; }
.sidebar nav { display: grid; gap: 8px; } .sidebar nav { display: grid; gap: 8px; }
.sidebar nav button, .profile-card button { .sidebar nav a, .profile-card button {
color: #dbe4ef; color: #dce4f2;
background: transparent; background: transparent;
text-align: left; text-align: left;
border-radius: 16px; border-radius: 18px;
padding: 13px 14px; padding: 13px 14px;
font-weight: 750; font-weight: 850;
} }
.sidebar nav button.active, .sidebar nav button:hover { .sidebar nav a.active, .sidebar nav a:hover {
background: var(--lime); background: var(--acid);
color: #0b0f16; color: #0b0e14;
} }
.profile-card { .profile-card {
margin-top: auto; margin-top: auto;
display: grid; display: grid;
gap: 7px; gap: 7px;
padding: 16px; padding: 17px;
border: 1px solid rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.11);
border-radius: 22px; border-radius: 24px;
background: linear-gradient(180deg, rgba(255,255,255,0.08), rgba(255,255,255,0.03)); background: linear-gradient(180deg, rgba(255,255,255,0.08), rgba(255,255,255,0.03));
} }
.profile-card small { color: #94a3b8; } .profile-card small { color: #9aa9bc; word-break: break-word; }
main { padding: 34px; } .main-stage { padding: 34px; }
.auth-layout { min-height: 100vh; display: grid; grid-template-columns: 1.18fr 0.82fr; gap: 24px; align-items: center; } .auth-layout { min-height: 100vh; display: grid; grid-template-columns: 1.16fr 0.84fr; gap: 24px; align-items: center; padding: 34px; }
.hero-panel { .hero-panel {
min-height: calc(100vh - 68px); min-height: calc(100vh - 68px);
border-radius: 36px; border-radius: 42px;
padding: 52px; padding: 54px;
color: #f8fafc; color: #fff8ea;
background: background:
linear-gradient(135deg, rgba(200,255,61,0.95), rgba(200,255,61,0) 26%), linear-gradient(135deg, rgba(217,255,69,0.92), rgba(217,255,69,0) 25%),
radial-gradient(circle at 80% 24%, rgba(37,99,235,0.55), transparent 30%), radial-gradient(circle at 80% 24%, rgba(39,100,255,0.62), transparent 30%),
#0b0f16; #0b0e14;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: flex-end; justify-content: flex-end;
box-shadow: var(--shadow); box-shadow: var(--shadow);
} }
.hero-panel h1 { font-size: clamp(40px, 6vw, 82px); line-height: .88; letter-spacing: -0.07em; max-width: 920px; margin: 0; } .hero-panel h1 { font-size: clamp(42px, 6.4vw, 92px); line-height: .86; letter-spacing: -0.08em; max-width: 960px; margin: 0; }
.hero-panel p:not(.eyebrow) { max-width: 680px; color: #cbd5e1; font-size: 18px; } .hero-panel p:not(.eyebrow) { max-width: 700px; color: #cbd5e1; font-size: 18px; }
.auth-card { max-width: 440px; width: 100%; } .auth-card { max-width: 450px; width: 100%; display: grid; gap: 16px; }
.auth-card h2 { margin: 0; font-size: 36px; letter-spacing: -0.05em; }
.stack { display: grid; gap: 22px; max-width: 1240px; } .stack { display: grid; gap: 22px; max-width: 1260px; }
.compact-stack { margin-top: 16px; gap: 12px; }
.page-header { display: flex; justify-content: space-between; gap: 16px; align-items: center; } .page-header { display: flex; justify-content: space-between; gap: 16px; align-items: center; }
.page-header h2, .card h3, .auth-card h2 { margin: 0; } .page-header h2, .card h3 { margin: 0; }
.page-header h2 { font-size: clamp(30px, 4vw, 52px); line-height: .95; letter-spacing: -0.055em; } .page-header h2 { font-size: clamp(32px, 4vw, 56px); line-height: .92; letter-spacing: -0.065em; }
.eyebrow { color: #2563eb; font-size: 11px; font-weight: 900; letter-spacing: .18em; margin: 0 0 8px; text-transform: uppercase; } .eyebrow { color: var(--blue); font-size: 11px; font-weight: 950; letter-spacing: .18em; margin: 0 0 8px; text-transform: uppercase; }
.card { .card {
background: var(--panel); background: var(--paper);
border: 1px solid rgba(255,255,255,0.74); border: 1px solid rgba(255,255,255,0.68);
border-radius: 28px; border-radius: 30px;
box-shadow: var(--shadow); box-shadow: var(--shadow);
padding: 22px; padding: 22px;
backdrop-filter: blur(18px); backdrop-filter: blur(18px);
} }
.primary { background: #0b0f16; color: #fff; border-radius: 16px; padding: 12px 18px; font-weight: 900; box-shadow: inset 0 0 0 1px rgba(255,255,255,.08); } .primary, .danger, .ghost, .danger-ghost, .segmented a, .segmented button {
.primary:hover:not(:disabled) { background: #172033; } border-radius: 16px;
.ghost { background: transparent; color: #475569; padding: 10px 12px; border-radius: 12px; } font-weight: 950;
.ghost:hover { background: rgba(15,23,42,0.06); } }
label { display: grid; gap: 8px; font-weight: 800; color: #475569; font-size: 13px; } .primary { background: var(--charcoal); color: #fffaf0; padding: 12px 18px; box-shadow: inset 0 0 0 1px rgba(255,255,255,.08); }
input, select { .primary:hover:not(:disabled) { background: #1a2230; }
.danger { background: var(--danger); color: #fff; padding: 12px 18px; }
.ghost { background: transparent; color: #475569; padding: 10px 12px; }
.ghost:hover { background: rgba(15,23,42,0.07); }
.danger-ghost { background: rgba(223,43,43,.09); color: #b91c1c; padding: 10px 12px; }
.danger-ghost:hover { background: rgba(223,43,43,.16); }
label { display: grid; gap: 8px; font-weight: 850; color: #566170; font-size: 13px; }
input, select, textarea {
width: 100%; width: 100%;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 16px; border-radius: 16px;
padding: 12px 14px; padding: 12px 14px;
background: rgba(255,255,255,0.92); background: rgba(255,255,255,0.9);
color: var(--ink); color: var(--ink);
outline: none; outline: none;
} }
input:focus, select:focus { border-color: rgba(37,99,235,0.45); box-shadow: 0 0 0 4px rgba(37,99,235,0.1); } textarea { min-height: 112px; resize: vertical; }
input[type="range"] { padding: 0; box-shadow: none; border: 0; background: transparent; accent-color: var(--lime); } input:focus, select:focus, textarea:focus { border-color: rgba(39,100,255,0.5); box-shadow: 0 0 0 4px rgba(39,100,255,0.11); }
.error { color: var(--danger); margin: 0; } .error { color: var(--danger); margin: 0; }
.muted { color: var(--muted); } .muted { color: var(--muted); }
.loading-card, .empty-state { text-align: center; padding: 48px; color: var(--muted); }
.stats-grid, .catalog-grid, .workout-stats { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; } .stats-grid, .workout-stats { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; }
.catalog-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); } .workout-stats { grid-template-columns: repeat(5, minmax(0, 1fr)); }
.metric { background: var(--panel-strong); border-radius: 20px; padding: 16px; display: grid; gap: 8px; border: 1px solid var(--line); } .metric { background: var(--paper-strong); border-radius: 20px; padding: 16px; display: grid; gap: 8px; border: 1px solid var(--line); min-width: 0; }
.metric span { color: var(--muted); font-size: 12px; font-weight: 800; text-transform: uppercase; letter-spacing: .08em; } .metric span { color: var(--muted); font-size: 11px; font-weight: 900; text-transform: uppercase; letter-spacing: .08em; }
.metric strong { font-size: 28px; letter-spacing: -0.04em; } .metric strong { font-size: 26px; letter-spacing: -0.045em; overflow-wrap: anywhere; }
.metric-dark { background: rgba(255,255,255,.075); border-color: var(--white-line); color: #fff7e8; }
.metric-dark span { color: #9aa9bc; }
.metric-danger strong { color: var(--danger); }
.segmented { display: flex; background: rgba(15,23,42,0.06); border-radius: 999px; padding: 4px; border: 1px solid var(--line); } .segmented { display: flex; background: rgba(15,23,42,0.06); border-radius: 999px; padding: 4px; border: 1px solid var(--line); }
.segmented button { background: transparent; border-radius: 999px; padding: 10px 14px; font-weight: 850; color: #475569; } .segmented a, .segmented button { background: transparent; padding: 10px 14px; color: #586579; }
.segmented button.active { background: #0b0f16; color: #fff; } .segmented a.active, .segmented button.active { background: var(--charcoal); color: #fff8e8; }
.form-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 16px; align-items: end; } .form-grid { display: grid; grid-template-columns: 1fr 1fr 1fr auto; gap: 16px; align-items: end; }
.daily-command { min-height: 260px; display: flex; justify-content: space-between; gap: 26px; align-items: flex-end; overflow: hidden; position: relative; }
.daily-command::after { content: ""; position: absolute; inset: auto -10% -38% auto; width: 360px; height: 360px; border-radius: 50%; background: rgba(217,255,69,.32); filter: blur(6px); }
.daily-command h3 { font-size: clamp(34px, 5vw, 70px); line-height: .9; letter-spacing: -0.07em; max-width: 660px; }
.daily-command p { color: var(--muted); max-width: 560px; }
.active-session { background: linear-gradient(135deg, rgba(11,14,20,.96), rgba(37,100,255,.55)); color: #fff8e8; border-color: rgba(255,255,255,.14); }
.active-session p { color: #cbd5e1; }
.pulse-action { position: relative; z-index: 1; min-width: 190px; }
.catalog-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 16px; }
.catalog-card { overflow: hidden; padding: 0; transition: transform .18s ease, box-shadow .18s ease; } .catalog-card { overflow: hidden; padding: 0; transition: transform .18s ease, box-shadow .18s ease; }
.catalog-card:hover { transform: translateY(-2px); } .catalog-card:hover { transform: translateY(-2px); box-shadow: 0 28px 80px rgba(34, 28, 18, 0.21); }
.catalog-card img, .image-placeholder { width: 100%; height: 170px; object-fit: cover; background: linear-gradient(135deg, #c8ff3d, #dbeafe 55%, #1e293b); display: grid; place-items: center; font-weight: 950; color: #0b0f16; } .catalog-card img, .image-placeholder { width: 100%; height: 172px; object-fit: cover; background: linear-gradient(135deg, var(--acid), #dbeafe 56%, #1e293b); display: grid; place-items: center; font-weight: 950; color: var(--charcoal); }
.catalog-card div:last-child { padding: 18px; } .catalog-card div:last-child { padding: 18px; display: grid; gap: 10px; }
.catalog-card p { color: var(--muted); } .catalog-card p { color: var(--muted); margin: 0; }
.pill { display: inline-flex; border-radius: 999px; background: #eef2f7; color: #475569; padding: 5px 10px; font-size: 11px; font-weight: 900; } .pill, .source-pill, .status-chip { display: inline-flex; width: fit-content; border-radius: 999px; background: #eef2f7; color: #475569; padding: 5px 10px; font-size: 11px; font-weight: 950; text-transform: uppercase; letter-spacing: .05em; }
.pill.user { background: #dcfce7; color: #166534; } .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: 58px 32px; display: flex; flex-direction: column; align-items: center; gap: 16px; background: linear-gradient(135deg, rgba(255,255,255,.92), rgba(200,255,61,.18)); } .workout-cta { text-align: center; padding: 58px 32px; display: flex; flex-direction: column; align-items: center; gap: 16px; background: linear-gradient(135deg, rgba(255,250,240,.94), rgba(217,255,69,.18)); }
.workout-cta h3 { margin: 0; font-size: 32px; letter-spacing: -0.04em; } .workout-cta h3 { font-size: 34px; letter-spacing: -0.045em; }
.workout-cta p { color: var(--muted); max-width: 460px; } .workout-cta p { color: var(--muted); max-width: 580px; }
.workout-live-stats { display: flex; gap: 10px; align-items: center; } .live-header { position: sticky; top: 18px; z-index: 4; display: grid; grid-template-columns: 1.15fr auto 1.4fr auto; gap: 18px; align-items: center; color: #fff8e8; background: linear-gradient(135deg, #0b0e14, #161e2a); border-color: rgba(255,255,255,.1); }
.timer-badge { background: #0b0f16; color: var(--lime); font-family: "SF Mono", "Cascadia Code", "Fira Code", ui-monospace, monospace; font-size: 18px; font-weight: 900; padding: 9px 16px; border-radius: 16px; letter-spacing: .04em; } .live-header h2 { margin: 0; font-size: clamp(34px, 5vw, 58px); line-height: .9; letter-spacing: -0.07em; }
.kcal-badge { background: #dbeafe; color: #1d4ed8; font-weight: 900; padding: 9px 14px; border-radius: 16px; font-size: 14px; } .live-header span { color: #9aa9bc; }
.catalog-picker-section { padding: 20px 24px; } .live-clock { font-family: "Cascadia Code", "SF Mono", ui-monospace, monospace; font-size: clamp(34px, 5vw, 58px); font-weight: 950; color: var(--acid); letter-spacing: -0.06em; }
.catalog-picker-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; } .live-metrics { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; }
.catalog-picker-header h3 { margin: 0; } .live-actions { display: flex; flex-wrap: wrap; gap: 8px; justify-content: flex-end; }
.workout-catalog-grid { grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); } .exercise-journal { display: grid; gap: 18px; }
.workout-pick-card { padding: 0; cursor: default; } .empty-workout { min-height: 330px; display: grid; place-items: center; text-align: center; align-content: center; gap: 14px; background: linear-gradient(135deg, rgba(255,250,240,.92), rgba(39,100,255,.12)); }
.workout-pick-card.added { opacity: .56; } .empty-workout h3 { font-size: 40px; letter-spacing: -0.055em; }
.workout-pick-card .image-placeholder, .workout-pick-card img { height: 112px; } .empty-workout p { color: var(--muted); max-width: 520px; }
.workout-pick-card div:last-child { padding: 14px; display: flex; flex-direction: column; gap: 10px; } .sticky-add { justify-self: center; }
.workout-pick-card h3 { margin: 0; font-size: 15px; }
.pick-btn { border-radius: 13px; padding: 9px 12px; font-weight: 900; font-size: 12px; width: 100%; } .workout-exercise-card { padding: 0; overflow: hidden; display: grid; grid-template-columns: 180px 1fr; background: rgba(255,252,242,.94); }
.pick-btn.picked { background: #dcfce7; color: #166534; cursor: default; } .exercise-card-media { min-height: 100%; background: #0b0e14; display: grid; place-items: center; color: var(--acid); font-weight: 950; letter-spacing: .12em; }
.workout-cart { display: flex; flex-direction: column; gap: 16px; background: #0b0f16; color: #f8fafc; border-color: rgba(255,255,255,.1); } .exercise-card-media img { width: 100%; height: 100%; object-fit: cover; }
.workout-cart > h3 { margin: 0; } .exercise-card-body { padding: 22px; display: grid; gap: 16px; }
.workout-cart .muted { color: #94a3b8; } .exercise-card-header { display: flex; justify-content: space-between; gap: 16px; align-items: flex-start; }
.cart-items { display: flex; flex-direction: column; gap: 10px; } .exercise-card-header h3 { font-size: 30px; letter-spacing: -0.055em; margin: 8px 0 4px; }
.cart-item { border: 1px solid rgba(255,255,255,.1); border-radius: 20px; overflow: hidden; background: rgba(255,255,255,.05); } .exercise-card-header p { color: var(--muted); margin: 0; }
.cart-item.expanded { border-color: rgba(200,255,61,.72); background: rgba(255,255,255,.075); } .exercise-microstats { display: flex; gap: 8px; flex-wrap: wrap; }
.cart-item-header { display: flex; justify-content: space-between; align-items: center; padding: 16px 18px; cursor: pointer; } .exercise-microstats span { border: 1px solid var(--line); border-radius: 999px; padding: 7px 10px; font-size: 12px; font-weight: 900; color: #475569; background: rgba(255,255,255,.64); }
.cart-item-header:hover { background: rgba(255,255,255,.04); } .sets-list { display: grid; gap: 8px; }
.cart-item-info { display: flex; flex-direction: column; gap: 3px; } .set-row { display: grid; grid-template-columns: 36px 1fr 82px 42px; gap: 8px; align-items: center; padding: 8px 10px; background: rgba(11,14,20,.055); border-radius: 14px; }
.cart-item-name { font-weight: 900; font-size: 16px; } .set-row.editing { grid-template-columns: 36px 1fr 1fr auto; }
.cart-item-summary { color: #94a3b8; font-size: 13px; } .set-index { width: 28px; height: 28px; border-radius: 10px; background: var(--charcoal); color: var(--acid); display: grid; place-items: center; font-weight: 950; font-size: 12px; }
.cart-item-remove { background: transparent; color: #94a3b8; font-size: 22px; padding: 4px 9px; border-radius: 10px; line-height: 1; } .set-data { background: transparent; text-align: left; color: var(--ink); font-weight: 900; padding: 6px; border-radius: 10px; }
.cart-item-remove:hover { color: #fecaca; background: rgba(220,38,38,.18); } .set-data:hover { background: rgba(39,100,255,.08); }
.cart-item-body { border-top: 1px solid rgba(255,255,255,.08); padding: 16px 18px; display: flex; flex-direction: column; gap: 16px; } .set-cal { color: var(--muted); font-size: 12px; }
.sets-list { display: flex; flex-direction: column; gap: 8px; } .set-remove, .tiny { background: transparent; color: #64748b; font-size: 17px; padding: 4px 7px; border-radius: 9px; line-height: 1; }
.set-row { display: grid; grid-template-columns: 32px 1fr 78px 32px; gap: 8px; align-items: center; padding: 8px 10px; background: rgba(255,255,255,.06); border-radius: 12px; } .set-remove:hover, .tiny:hover { color: #fecaca; background: rgba(223,43,43,.18); }
.set-index { font-weight: 950; color: var(--lime); font-size: 13px; } .set-row-actions { display: flex; gap: 4px; }
.set-data { font-size: 14px; font-weight: 850; } .tiny.success { color: var(--green); }
.set-cal { color: #94a3b8; font-size: 12px; } .single-set-console { display: grid; grid-template-columns: 1fr 1fr auto; gap: 12px; align-items: end; padding: 16px; border-radius: 22px; background: #11151e; color: #fff8e8; }
.set-remove { background: transparent; color: #64748b; font-size: 17px; padding: 2px 6px; border-radius: 8px; line-height: 1; } .stepper-field > span { display: block; color: #9aa9bc; font-size: 11px; font-weight: 950; text-transform: uppercase; letter-spacing: .08em; margin-bottom: 8px; }
.set-remove:hover { color: #fecaca; background: rgba(220,38,38,.18); } .stepper-field > div { display: grid; grid-template-columns: 42px minmax(80px, 1fr) 42px; gap: 7px; }
.set-builder { display: grid; gap: 14px; padding: 16px; border-radius: 18px; background: rgba(255,255,255,.08); } .stepper-field button { background: rgba(255,255,255,.1); color: #fff8e8; border-radius: 14px; font-size: 22px; font-weight: 950; }
.slider-head { display: flex; justify-content: space-between; align-items: center; color: #cbd5e1; font-weight: 900; } .stepper-field input { text-align: center; font-weight: 950; }
.slider-head strong { width: 42px; height: 42px; display: grid; place-items: center; border-radius: 50%; background: var(--lime); color: #0b0f16; font-size: 20px; } .record-set { min-height: 48px; background: var(--acid); color: var(--charcoal); }
.set-count-slider { height: 30px; } .batch-link { justify-self: start; }
.draft-sets { display: grid; gap: 10px; }
.draft-set-row { display: grid; grid-template-columns: 36px 1fr 1fr; gap: 10px; align-items: end; } .drawer-scrim, .dialog-backdrop { position: fixed; inset: 0; z-index: 50; background: rgba(5,7,11,.62); backdrop-filter: blur(9px); }
.draft-set-row > span { width: 36px; height: 36px; display: grid; place-items: center; border-radius: 12px; background: rgba(200,255,61,.16); color: var(--lime); font-weight: 950; } .add-drawer { position: absolute; inset: 18px 18px 18px auto; width: min(500px, calc(100vw - 36px)); overflow: auto; border-radius: 34px; padding: 24px; background: #fff8ea; box-shadow: 0 34px 120px rgba(0,0,0,.36); display: grid; align-content: start; gap: 16px; }
.draft-set-row label { color: #cbd5e1; } .add-drawer header { display: flex; justify-content: space-between; gap: 16px; align-items: flex-start; }
.draft-set-row input { background: rgba(255,255,255,.92); } .add-drawer h2, .modal-card h2 { margin: 0; font-size: 34px; line-height: .96; letter-spacing: -0.055em; }
.add-sets-btn { justify-self: end; } .round-close { width: 40px; height: 40px; border-radius: 50%; background: #11151e; color: #fff8ea; font-size: 24px; line-height: 1; }
.finish-btn { align-self: flex-end; margin-top: 8px; background: var(--lime); color: #0b0f16; } .drawer-tabs { width: fit-content; }
.modal-overlay { position: fixed; inset: 0; z-index: 100; background: rgba(2,6,23,.6); display: grid; place-items: center; backdrop-filter: blur(8px); } .drawer-list { display: grid; gap: 10px; }
.modal-card { max-width: 460px; width: calc(100% - 32px); display: flex; flex-direction: column; gap: 18px; } .drawer-pick { display: grid; grid-template-columns: 76px 1fr 42px; gap: 12px; align-items: center; padding: 10px; border-radius: 20px; border: 1px solid var(--line); background: rgba(255,255,255,.56); cursor: pointer; }
.modal-card h2 { margin: 0; letter-spacing: -0.04em; } .drawer-pick:hover { background: #fff; }
.modal-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; } .drawer-pick.already-added { box-shadow: inset 0 0 0 2px rgba(31,157,85,.22); }
.modal-stats .metric { padding: 12px; } .drawer-pick img, .drawer-placeholder { width: 76px; height: 76px; border-radius: 16px; object-fit: cover; display: grid; place-items: center; background: linear-gradient(135deg, var(--acid), #dbeafe); font-weight: 950; }
.drawer-pick h3 { font-size: 16px; margin: 0 0 4px; }
.drawer-pick p { margin: 0; color: var(--muted); font-size: 13px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.drawer-pick b { color: var(--green); font-size: 12px; }
.plus-chip { width: 38px; height: 38px; border-radius: 14px; background: var(--charcoal); color: #fff8ea; font-size: 22px; font-weight: 950; }
.modal-card { max-width: 560px; width: calc(100% - 32px); display: grid; gap: 18px; position: fixed; inset: 50% auto auto 50%; transform: translate(-50%, -50%); }
.modal-card:hover { transform: translate(-50%, -50%); }
.modal-stats { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
.modal-actions { display: flex; justify-content: flex-end; gap: 12px; } .modal-actions { display: flex; justify-content: flex-end; gap: 12px; }
.batch-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
.discard-dialog { border-color: rgba(223,43,43,.25); }
.discard-dialog p { color: var(--muted); }
.workout-summary { display: grid; gap: 18px; }
.status-discarded { opacity: .7; filter: grayscale(.35); }
.summary-title { display: flex; justify-content: space-between; gap: 16px; align-items: flex-start; }
.summary-title h3 { margin: 8px 0 6px; }
.summary-title p { margin: 0; color: var(--muted); }
.detail-link { display: inline-flex; }
.discarded-list summary { cursor: pointer; font-weight: 950; }
.detail-hero, .analytics-controls { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 14px; align-items: end; }
.analytics-controls { grid-template-columns: 1.4fr repeat(3, 1fr); }
.detail-item h3, .notes-card h3, .chart-card h3 { margin-bottom: 14px; }
.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: #e2e8f0; overflow: hidden; } .bar-row div { height: 16px; border-radius: 999px; background: #e2dccf; overflow: hidden; }
.bar-row i { display: block; height: 100%; border-radius: inherit; background: linear-gradient(90deg, #2563eb, #c8ff3d); } .bar-row i { display: block; height: 100%; border-radius: inherit; background: linear-gradient(90deg, var(--blue), var(--acid), var(--amber)); }
@media (max-width: 1180px) {
.live-header { grid-template-columns: 1fr; position: static; }
.live-actions { justify-content: flex-start; }
.detail-hero, .analytics-controls { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 980px) { @media (max-width: 980px) {
.app-shell { grid-template-columns: 1fr; padding-bottom: 88px; } .app-shell { grid-template-columns: 1fr; padding-bottom: 88px; }
.sidebar { position: fixed; inset: auto 12px 12px; z-index: 10; height: auto; border-radius: 24px; padding: 12px; display: block; } .sidebar { position: fixed; inset: auto 12px 12px; z-index: 20; height: auto; border-radius: 26px; padding: 12px; display: block; }
.sidebar > div:first-child, .profile-card { display: none; } .brand-mark, .profile-card { display: none; }
.sidebar nav { grid-template-columns: repeat(5, 1fr); } .sidebar nav { grid-template-columns: repeat(5, 1fr); }
.sidebar nav button { text-align: center; padding: 10px 6px; font-size: 12px; } .sidebar nav a { text-align: center; padding: 10px 6px; font-size: 12px; }
main { padding: 20px; } .main-stage { padding: 20px; }
.auth-layout { grid-template-columns: 1fr; } .auth-layout { grid-template-columns: 1fr; padding: 20px; }
.hero-panel { min-height: 48vh; padding: 28px; } .hero-panel { min-height: 48vh; padding: 30px; }
.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, .workout-stats { grid-template-columns: 1fr; }
.workout-catalog-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .workout-exercise-card { grid-template-columns: 1fr; }
.workout-live-stats { gap: 6px; } .exercise-card-media { min-height: 130px; }
.timer-badge { font-size: 15px; padding: 6px 12px; } .single-set-console { grid-template-columns: 1fr; }
.catalog-picker-header { flex-direction: column; align-items: stretch; gap: 10px; } .live-metrics { grid-template-columns: 1fr; }
} }
@media (max-width: 620px) { @media (max-width: 620px) {
.page-header { align-items: stretch; flex-direction: column; } .page-header, .daily-command, .exercise-card-header, .summary-title { align-items: stretch; flex-direction: column; }
.catalog-grid, .workout-stats { grid-template-columns: 1fr; } .daily-command { min-height: auto; }
.workout-catalog-grid { grid-template-columns: 1fr; } .catalog-grid, .detail-hero, .analytics-controls, .batch-grid, .modal-stats { grid-template-columns: 1fr; }
.draft-set-row { grid-template-columns: 28px 1fr 1fr; } .add-drawer { inset: auto 0 0; width: 100%; max-height: 86vh; border-radius: 32px 32px 0 0; }
.modal-stats { grid-template-columns: 1fr; } .drawer-pick { grid-template-columns: 62px 1fr 38px; }
.drawer-pick img, .drawer-placeholder { width: 62px; height: 62px; }
.set-row, .set-row.editing { grid-template-columns: 30px 1fr; }
.set-cal, .set-remove, .set-row-actions { justify-self: start; }
.bar-row { grid-template-columns: 1fr; } .bar-row { grid-template-columns: 1fr; }
.modal-actions { flex-direction: column-reverse; }
.modal-actions button { width: 100%; }
} }
+19
View File
@@ -5,6 +5,8 @@ export type User = {
created_at: string; created_at: string;
}; };
export type CatalogKind = "exercise" | "equipment";
export type CatalogEntity = { export type CatalogEntity = {
id: string; id: string;
owner_user_id: string | null; owner_user_id: string | null;
@@ -17,6 +19,8 @@ export type CatalogEntity = {
default_calories_per_minute?: number | null; default_calories_per_minute?: number | null;
}; };
export type WorkoutStatus = "active" | "finished" | "discarded";
export type WorkoutSet = { export type WorkoutSet = {
id: string; id: string;
workout_item_id: string; workout_item_id: string;
@@ -31,8 +35,11 @@ export type WorkoutSet = {
export type WorkoutItem = { export type WorkoutItem = {
id: string; id: string;
workout_id: string; workout_id: string;
source_kind: CatalogKind;
exercise_id: string | null; exercise_id: string | null;
equipment_id: string | null; equipment_id: string | null;
title_snapshot: string;
image_s3_url_snapshot: string | null;
order_index: number; order_index: number;
planned_working_weight: number | null; planned_working_weight: number | null;
created_at: string; created_at: string;
@@ -42,10 +49,15 @@ export type WorkoutItem = {
export type Workout = { export type Workout = {
id: string; id: string;
user_id: string; user_id: string;
status: WorkoutStatus;
started_at: string; started_at: string;
finished_at: string | null; finished_at: string | null;
notes: string | null; notes: string | null;
total_sets: number;
total_volume: number;
estimated_calories: number; estimated_calories: number;
created_at?: string;
updated_at?: string;
items: WorkoutItem[]; items: WorkoutItem[];
}; };
@@ -60,3 +72,10 @@ export type Calories = {
total_calories: number; total_calories: number;
workouts: Array<{ id: string; date: string; calories: number }>; workouts: Array<{ id: string; date: string; calories: number }>;
}; };
export type WorkoutSetInput = {
weight: number;
reps: number;
duration_seconds?: number | null;
calories?: number | null;
};
+34 -1
View File
@@ -1,7 +1,7 @@
from typing import Annotated, Any from typing import Annotated, Any
import httpx import httpx
from fastapi import Depends, FastAPI, File, HTTPException, Query, UploadFile, status from fastapi import Body, Depends, FastAPI, File, HTTPException, Query, UploadFile, status
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
@@ -152,6 +152,11 @@ async def create_workout(payload: dict[str, Any], user: CurrentUser) -> Any:
return await logic_request("POST", "/internal/workouts", user, json=payload) return await logic_request("POST", "/internal/workouts", user, json=payload)
@app.get("/workouts/active")
async def get_active_workout(user: CurrentUser) -> Any:
return await logic_request("GET", "/internal/workouts/active", user)
@app.get("/workouts/{workout_id}") @app.get("/workouts/{workout_id}")
async def get_workout(workout_id: str, user: CurrentUser) -> Any: async def get_workout(workout_id: str, user: CurrentUser) -> Any:
return await logic_request("GET", f"/internal/workouts/{workout_id}", user) return await logic_request("GET", f"/internal/workouts/{workout_id}", user)
@@ -162,6 +167,22 @@ async def update_workout(workout_id: str, payload: dict[str, Any], user: Current
return await logic_request("PATCH", f"/internal/workouts/{workout_id}", user, json=payload) return await logic_request("PATCH", f"/internal/workouts/{workout_id}", user, json=payload)
@app.post("/workouts/{workout_id}/finish")
async def finish_workout(
workout_id: str,
user: CurrentUser,
payload: Annotated[dict[str, Any] | None, Body()] = None,
) -> Any:
return await logic_request(
"POST", f"/internal/workouts/{workout_id}/finish", user, json=payload
)
@app.post("/workouts/{workout_id}/discard")
async def discard_workout(workout_id: str, user: CurrentUser) -> Any:
return await logic_request("POST", f"/internal/workouts/{workout_id}/discard", user)
@app.post("/workouts/{workout_id}/items", status_code=status.HTTP_201_CREATED) @app.post("/workouts/{workout_id}/items", status_code=status.HTTP_201_CREATED)
async def add_workout_item(workout_id: str, payload: dict[str, Any], user: CurrentUser) -> Any: async def add_workout_item(workout_id: str, payload: dict[str, Any], user: CurrentUser) -> Any:
return await logic_request("POST", f"/internal/workouts/{workout_id}/items", user, json=payload) return await logic_request("POST", f"/internal/workouts/{workout_id}/items", user, json=payload)
@@ -174,6 +195,18 @@ async def add_workout_set(item_id: str, payload: dict[str, Any], user: CurrentUs
) )
@app.post("/workout-items/{item_id}/sets/batch", status_code=status.HTTP_201_CREATED)
async def add_workout_sets_batch(item_id: str, payload: dict[str, Any], user: CurrentUser) -> Any:
return await logic_request(
"POST", f"/internal/workout-items/{item_id}/sets/batch", user, json=payload
)
@app.patch("/workout-sets/{set_id}")
async def update_workout_set(set_id: str, payload: dict[str, Any], user: CurrentUser) -> Any:
return await logic_request("PATCH", f"/internal/workout-sets/{set_id}", user, json=payload)
@app.delete("/workout-items/{item_id}", status_code=status.HTTP_204_NO_CONTENT) @app.delete("/workout-items/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
async def remove_workout_item(item_id: str, user: CurrentUser) -> None: async def remove_workout_item(item_id: str, user: CurrentUser) -> None:
await logic_request("DELETE", f"/internal/workout-items/{item_id}", user) await logic_request("DELETE", f"/internal/workout-items/{item_id}", user)
@@ -0,0 +1,116 @@
"""active workout flow
Revision ID: 0002_active_workout_flow
Revises: 0001_initial
Create Date: 2026-05-29 08:40:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0002_active_workout_flow"
down_revision: str | None = "0001_initial"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"logic_workouts",
sa.Column("status", sa.String(length=20), nullable=False, server_default="active"),
)
op.add_column(
"logic_workouts",
sa.Column("total_sets", sa.Integer(), nullable=False, server_default="0"),
)
op.add_column(
"logic_workouts",
sa.Column("total_volume", sa.Numeric(12, 2), nullable=False, server_default="0"),
)
op.create_index("ix_logic_workouts_status", "logic_workouts", ["status"])
op.create_check_constraint(
"ck_workout_status",
"logic_workouts",
"status IN ('active', 'finished', 'discarded')",
)
op.execute(
"UPDATE logic_workouts SET status = 'finished' WHERE finished_at IS NOT NULL"
)
op.add_column(
"logic_workout_items",
sa.Column("source_kind", sa.String(length=20), nullable=True),
)
op.add_column(
"logic_workout_items",
sa.Column("title_snapshot", sa.String(length=160), nullable=True),
)
op.add_column(
"logic_workout_items",
sa.Column("image_s3_url_snapshot", sa.Text(), nullable=True),
)
op.execute(
"""
UPDATE logic_workout_items AS item
SET
source_kind = 'exercise',
title_snapshot = exercise.name,
image_s3_url_snapshot = exercise.image_s3_url
FROM logic_exercises AS exercise
WHERE item.exercise_id = exercise.id
"""
)
op.execute(
"""
UPDATE logic_workout_items AS item
SET
source_kind = 'equipment',
title_snapshot = equipment.name,
image_s3_url_snapshot = equipment.image_s3_url
FROM logic_equipment AS equipment
WHERE item.equipment_id = equipment.id
"""
)
op.alter_column("logic_workout_items", "source_kind", nullable=False)
op.alter_column("logic_workout_items", "title_snapshot", nullable=False)
op.create_check_constraint(
"ck_workout_item_source_kind",
"logic_workout_items",
"source_kind IN ('exercise', 'equipment')",
)
op.execute(
"""
UPDATE logic_workouts AS workout
SET
total_sets = totals.total_sets,
total_volume = totals.total_volume,
estimated_calories = totals.estimated_calories
FROM (
SELECT
item.workout_id,
COUNT(set_row.id) AS total_sets,
COALESCE(SUM(set_row.weight * set_row.reps), 0) AS total_volume,
COALESCE(SUM(set_row.calories), 0) AS estimated_calories
FROM logic_workout_items AS item
LEFT JOIN logic_workout_sets AS set_row ON set_row.workout_item_id = item.id
GROUP BY item.workout_id
) AS totals
WHERE workout.id = totals.workout_id
"""
)
def downgrade() -> None:
op.drop_constraint("ck_workout_item_source_kind", "logic_workout_items", type_="check")
op.drop_column("logic_workout_items", "image_s3_url_snapshot")
op.drop_column("logic_workout_items", "title_snapshot")
op.drop_column("logic_workout_items", "source_kind")
op.drop_constraint("ck_workout_status", "logic_workouts", type_="check")
op.drop_index("ix_logic_workouts_status", table_name="logic_workouts")
op.drop_column("logic_workouts", "total_volume")
op.drop_column("logic_workouts", "total_sets")
op.drop_column("logic_workouts", "status")
+163 -2
View File
@@ -1,7 +1,8 @@
from collections.abc import Generator from collections.abc import Generator
from time import sleep from time import sleep
from typing import Any
from sqlalchemy import MetaData, create_engine, text from sqlalchemy import Connection, MetaData, create_engine, text
from sqlalchemy.exc import OperationalError from sqlalchemy.exc import OperationalError
from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.orm import Session, sessionmaker
@@ -23,6 +24,7 @@ def create_schema(metadata: MetaData, attempts: int = 30, delay_seconds: int = 2
with engine.begin() as connection: with engine.begin() as connection:
connection.execute(text("SELECT 1")) connection.execute(text("SELECT 1"))
metadata.create_all(bind=connection) metadata.create_all(bind=connection)
upgrade_existing_schema(connection)
print("Database schema is ready", flush=True) print("Database schema is ready", flush=True)
return return
except OperationalError as exc: except OperationalError as exc:
@@ -33,8 +35,167 @@ def create_schema(metadata: MetaData, attempts: int = 30, delay_seconds: int = 2
raise last_error raise last_error
def upgrade_existing_schema(connection: Connection) -> None:
"""Apply safe additive upgrades for DBs created by pre-Alembic create_all.
Local Compose databases may already have the initial tables without the
tidy-wolf columns. SQLAlchemy create_all intentionally does not ALTER
existing tables, so keep this idempotent compatibility upgrade until
migrations are wired into service startup.
"""
connection.execute(
text("ALTER TABLE logic_workouts ADD COLUMN IF NOT EXISTS status VARCHAR(20)")
)
connection.execute(
text("ALTER TABLE logic_workouts ADD COLUMN IF NOT EXISTS total_sets INTEGER")
)
connection.execute(
text("ALTER TABLE logic_workouts ADD COLUMN IF NOT EXISTS total_volume NUMERIC(12, 2)")
)
connection.execute(
text(
"""
UPDATE logic_workouts
SET status = CASE WHEN finished_at IS NULL THEN 'active' ELSE 'finished' END
WHERE status IS NULL
"""
)
)
connection.execute(text("UPDATE logic_workouts SET total_sets = 0 WHERE total_sets IS NULL"))
connection.execute(
text("UPDATE logic_workouts SET total_volume = 0 WHERE total_volume IS NULL")
)
connection.execute(text("ALTER TABLE logic_workouts ALTER COLUMN status SET DEFAULT 'active'"))
connection.execute(text("ALTER TABLE logic_workouts ALTER COLUMN status SET NOT NULL"))
connection.execute(text("ALTER TABLE logic_workouts ALTER COLUMN total_sets SET DEFAULT 0"))
connection.execute(text("ALTER TABLE logic_workouts ALTER COLUMN total_sets SET NOT NULL"))
connection.execute(text("ALTER TABLE logic_workouts ALTER COLUMN total_volume SET DEFAULT 0"))
connection.execute(text("ALTER TABLE logic_workouts ALTER COLUMN total_volume SET NOT NULL"))
connection.execute(
text("CREATE INDEX IF NOT EXISTS ix_logic_workouts_status ON logic_workouts (status)")
)
add_check_constraint_if_missing(
connection,
constraint_name="ck_workout_status",
table_name="logic_workouts",
check_sql="status IN ('active', 'finished', 'discarded')",
)
connection.execute(
text("ALTER TABLE logic_workout_items ADD COLUMN IF NOT EXISTS source_kind VARCHAR(20)")
)
connection.execute(
text("ALTER TABLE logic_workout_items ADD COLUMN IF NOT EXISTS title_snapshot VARCHAR(160)")
)
connection.execute(
text("ALTER TABLE logic_workout_items ADD COLUMN IF NOT EXISTS image_s3_url_snapshot TEXT")
)
connection.execute(
text(
"""
UPDATE logic_workout_items AS item
SET
source_kind = 'exercise',
title_snapshot = exercise.name,
image_s3_url_snapshot = exercise.image_s3_url
FROM logic_exercises AS exercise
WHERE item.exercise_id = exercise.id
AND (item.source_kind IS NULL OR item.title_snapshot IS NULL)
"""
)
)
connection.execute(
text(
"""
UPDATE logic_workout_items AS item
SET
source_kind = 'equipment',
title_snapshot = equipment.name,
image_s3_url_snapshot = equipment.image_s3_url
FROM logic_equipment AS equipment
WHERE item.equipment_id = equipment.id
AND (item.source_kind IS NULL OR item.title_snapshot IS NULL)
"""
)
)
connection.execute(
text(
"""
UPDATE logic_workout_items
SET source_kind = CASE WHEN exercise_id IS NOT NULL THEN 'exercise' ELSE 'equipment' END
WHERE source_kind IS NULL
"""
)
)
connection.execute(
text(
"UPDATE logic_workout_items "
"SET title_snapshot = 'Без названия' "
"WHERE title_snapshot IS NULL"
)
)
connection.execute(
text("ALTER TABLE logic_workout_items ALTER COLUMN source_kind SET NOT NULL")
)
connection.execute(
text("ALTER TABLE logic_workout_items ALTER COLUMN title_snapshot SET NOT NULL")
)
add_check_constraint_if_missing(
connection,
constraint_name="ck_workout_item_source_kind",
table_name="logic_workout_items",
check_sql="source_kind IN ('exercise', 'equipment')",
)
connection.execute(
text(
"""
UPDATE logic_workouts AS workout
SET
total_sets = totals.total_sets,
total_volume = totals.total_volume,
estimated_calories = totals.estimated_calories
FROM (
SELECT
workout_source.id AS workout_id,
COUNT(set_row.id) AS total_sets,
COALESCE(SUM(set_row.weight * set_row.reps), 0) AS total_volume,
COALESCE(SUM(set_row.calories), 0) AS estimated_calories
FROM logic_workouts AS workout_source
LEFT JOIN logic_workout_items AS item ON item.workout_id = workout_source.id
LEFT JOIN logic_workout_sets AS set_row ON set_row.workout_item_id = item.id
GROUP BY workout_source.id
) AS totals
WHERE workout.id = totals.workout_id
"""
)
)
def add_check_constraint_if_missing(
connection: Connection,
*,
constraint_name: str,
table_name: str,
check_sql: str,
) -> None:
exists = connection.execute(
text("SELECT 1 FROM pg_constraint WHERE conname = :constraint_name"),
{"constraint_name": constraint_name},
).scalar()
if exists:
return
connection.execute(
text(
f"ALTER TABLE {table_name} "
f"ADD CONSTRAINT {constraint_name} "
f"CHECK ({check_sql})"
)
)
def get_db() -> Generator[Session]: def get_db() -> Generator[Session]:
db = SessionLocal() db: Any = SessionLocal()
try: try:
yield db yield db
finally: finally:
+285 -40
View File
@@ -3,7 +3,7 @@ from collections import defaultdict
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Annotated from typing import Annotated
from fastapi import Depends, FastAPI, Header, HTTPException, Query, status from fastapi import Body, Depends, FastAPI, Header, HTTPException, Query, status
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.orm import Session, selectinload from sqlalchemy.orm import Session, selectinload
@@ -19,11 +19,14 @@ from app.schemas import (
ProgressionPoint, ProgressionPoint,
ProgressionRead, ProgressionRead,
WorkoutCreate, WorkoutCreate,
WorkoutFinishRequest,
WorkoutItemCreate, WorkoutItemCreate,
WorkoutItemRead, WorkoutItemRead,
WorkoutRead, WorkoutRead,
WorkoutSetBatchCreate,
WorkoutSetCreate, WorkoutSetCreate,
WorkoutSetRead, WorkoutSetRead,
WorkoutSetUpdate,
WorkoutUpdate, WorkoutUpdate,
) )
@@ -127,6 +130,34 @@ def accessible_exercises(db: Session, user_id: uuid.UUID):
) )
def load_workout(db: Session, workout_id: uuid.UUID, user_id: uuid.UUID) -> Workout:
workout = db.scalar(
select(Workout)
.where(Workout.id == workout_id, Workout.user_id == user_id)
.options(selectinload(Workout.items).selectinload(WorkoutItem.sets))
)
if not workout:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Workout not found")
return workout
def get_active_workout_for_user(db: Session, user_id: uuid.UUID) -> Workout | None:
return db.scalar(
select(Workout)
.where(Workout.user_id == user_id, Workout.status == "active")
.options(selectinload(Workout.items).selectinload(WorkoutItem.sets))
.order_by(Workout.started_at.desc())
)
def ensure_active_workout(workout: Workout) -> None:
if workout.status != "active":
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Workout is not active",
)
@app.get( @app.get(
"/internal/catalog/equipment", "/internal/catalog/equipment",
dependencies=[InternalAuth], dependencies=[InternalAuth],
@@ -206,8 +237,14 @@ def list_workouts(db: Db, user_id: CurrentUserId) -> list[Workout]:
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
) )
def create_workout(payload: WorkoutCreate, db: Db, user_id: CurrentUserId) -> Workout: def create_workout(payload: WorkoutCreate, db: Db, user_id: CurrentUserId) -> Workout:
if get_active_workout_for_user(db, user_id):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Active workout already exists",
)
workout = Workout( workout = Workout(
user_id=user_id, user_id=user_id,
status="active",
started_at=payload.started_at or datetime.now(UTC), started_at=payload.started_at or datetime.now(UTC),
notes=payload.notes, notes=payload.notes,
) )
@@ -217,16 +254,18 @@ def create_workout(payload: WorkoutCreate, db: Db, user_id: CurrentUserId) -> Wo
return workout return workout
@app.get(
"/internal/workouts/active",
dependencies=[InternalAuth],
response_model=WorkoutRead | None,
)
def get_active_workout(db: Db, user_id: CurrentUserId) -> Workout | None:
return get_active_workout_for_user(db, user_id)
@app.get("/internal/workouts/{workout_id}", dependencies=[InternalAuth], response_model=WorkoutRead) @app.get("/internal/workouts/{workout_id}", dependencies=[InternalAuth], response_model=WorkoutRead)
def get_workout(workout_id: uuid.UUID, db: Db, user_id: CurrentUserId) -> Workout: def get_workout(workout_id: uuid.UUID, db: Db, user_id: CurrentUserId) -> Workout:
workout = db.scalar( return load_workout(db, workout_id, user_id)
select(Workout)
.where(Workout.id == workout_id, Workout.user_id == user_id)
.options(selectinload(Workout.items).selectinload(WorkoutItem.sets))
)
if not workout:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Workout not found")
return workout
@app.patch( @app.patch(
@@ -237,12 +276,52 @@ def get_workout(workout_id: uuid.UUID, db: Db, user_id: CurrentUserId) -> Workou
def update_workout( def update_workout(
workout_id: uuid.UUID, payload: WorkoutUpdate, db: Db, user_id: CurrentUserId workout_id: uuid.UUID, payload: WorkoutUpdate, db: Db, user_id: CurrentUserId
) -> Workout: ) -> Workout:
workout = get_workout(workout_id, db, user_id) workout = load_workout(db, workout_id, user_id)
if payload.finished_at is not None: if payload.finished_at is not None:
workout.finished_at = payload.finished_at workout.finished_at = payload.finished_at
workout.status = "finished"
if payload.notes is not None: if payload.notes is not None:
workout.notes = payload.notes workout.notes = payload.notes
recalculate_workout_calories(db, workout.id) recalculate_workout_totals(db, workout.id)
db.commit()
db.refresh(workout)
return workout
@app.post(
"/internal/workouts/{workout_id}/finish",
dependencies=[InternalAuth],
response_model=WorkoutRead,
)
def finish_workout(
workout_id: uuid.UUID,
db: Db,
user_id: CurrentUserId,
payload: Annotated[WorkoutFinishRequest | None, Body()] = None,
) -> Workout:
workout = load_workout(db, workout_id, user_id)
ensure_active_workout(workout)
if payload and payload.notes is not None:
workout.notes = payload.notes
recalculate_workout_totals(db, workout.id)
workout.finished_at = datetime.now(UTC)
workout.status = "finished"
db.commit()
db.refresh(workout)
return workout
@app.post(
"/internal/workouts/{workout_id}/discard",
dependencies=[InternalAuth],
response_model=WorkoutRead,
)
def discard_workout(workout_id: uuid.UUID, db: Db, user_id: CurrentUserId) -> Workout:
workout = load_workout(db, workout_id, user_id)
ensure_active_workout(workout)
recalculate_workout_totals(db, workout.id)
workout.finished_at = datetime.now(UTC)
workout.status = "discarded"
db.commit() db.commit()
db.refresh(workout) db.refresh(workout)
return workout return workout
@@ -257,21 +336,37 @@ def update_workout(
def add_workout_item( def add_workout_item(
workout_id: uuid.UUID, payload: WorkoutItemCreate, db: Db, user_id: CurrentUserId workout_id: uuid.UUID, payload: WorkoutItemCreate, db: Db, user_id: CurrentUserId
) -> WorkoutItem: ) -> WorkoutItem:
workout = get_workout(workout_id, db, user_id) workout = load_workout(db, workout_id, user_id)
ensure_active_workout(workout)
source_kind: str
title_snapshot: str
image_s3_url_snapshot: str | None
if payload.exercise_id: if payload.exercise_id:
exercise = db.get(Exercise, payload.exercise_id) exercise = db.get(Exercise, payload.exercise_id)
if not exercise or (not exercise.is_builtin and exercise.owner_user_id != user_id): if not exercise or (not exercise.is_builtin and exercise.owner_user_id != user_id):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Exercise not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Exercise not found")
if payload.equipment_id: source_kind = "exercise"
title_snapshot = exercise.name
image_s3_url_snapshot = exercise.image_s3_url
elif payload.equipment_id:
equipment = db.get(Equipment, payload.equipment_id) equipment = db.get(Equipment, payload.equipment_id)
if not equipment or (not equipment.is_builtin and equipment.owner_user_id != user_id): if not equipment or (not equipment.is_builtin and equipment.owner_user_id != user_id):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Equipment not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Equipment not found")
source_kind = "equipment"
title_snapshot = equipment.name
image_s3_url_snapshot = equipment.image_s3_url
next_index = payload.order_index next_index = payload.order_index
if next_index is None: if next_index is None:
next_index = len(workout.items) max_index = db.scalar(
select(func.max(WorkoutItem.order_index)).where(WorkoutItem.workout_id == workout.id)
)
next_index = int(max_index or 0) + 1 if max_index is not None else 0
item = WorkoutItem( item = WorkoutItem(
workout_id=workout.id, workout_id=workout.id,
source_kind=source_kind,
title_snapshot=title_snapshot,
image_s3_url_snapshot=image_s3_url_snapshot,
**payload.model_dump(exclude={"order_index"}), **payload.model_dump(exclude={"order_index"}),
order_index=next_index, order_index=next_index,
) )
@@ -297,18 +392,30 @@ def add_workout_set(
select(WorkoutItem) select(WorkoutItem)
.join(Workout) .join(Workout)
.where(WorkoutItem.id == item_id, Workout.user_id == user_id) .where(WorkoutItem.id == item_id, Workout.user_id == user_id)
.options(selectinload(WorkoutItem.sets), selectinload(WorkoutItem.exercise)) .options(
selectinload(WorkoutItem.sets),
selectinload(WorkoutItem.exercise),
selectinload(WorkoutItem.workout),
)
) )
if not item: if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Workout item not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Workout item not found")
ensure_active_workout(item.workout)
calories = payload.calories calories = estimate_set_calories(
if calories is None: item,
calories = estimate_set_calories(item, payload) payload.weight,
payload.reps,
payload.duration_seconds,
payload.calories,
)
max_index = db.scalar(
select(func.max(WorkoutSet.set_index)).where(WorkoutSet.workout_item_id == item.id)
)
workout_set = WorkoutSet( workout_set = WorkoutSet(
workout_item_id=item.id, workout_item_id=item.id,
set_index=len(item.sets) + 1, set_index=int(max_index or 0) + 1,
weight=payload.weight, weight=payload.weight,
reps=payload.reps, reps=payload.reps,
duration_seconds=payload.duration_seconds, duration_seconds=payload.duration_seconds,
@@ -317,7 +424,112 @@ def add_workout_set(
) )
db.add(workout_set) db.add(workout_set)
db.flush() db.flush()
recalculate_workout_calories(db, item.workout_id) recalculate_workout_totals(db, item.workout_id)
db.commit()
db.refresh(workout_set)
return workout_set
@app.post(
"/internal/workout-items/{item_id}/sets/batch",
dependencies=[InternalAuth],
response_model=list[WorkoutSetRead],
status_code=status.HTTP_201_CREATED,
)
def add_workout_sets_batch(
item_id: uuid.UUID,
payload: WorkoutSetBatchCreate,
db: Db,
user_id: CurrentUserId,
) -> list[WorkoutSet]:
item = db.scalar(
select(WorkoutItem)
.join(Workout)
.where(WorkoutItem.id == item_id, Workout.user_id == user_id)
.options(selectinload(WorkoutItem.exercise), selectinload(WorkoutItem.workout))
)
if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Workout item not found")
ensure_active_workout(item.workout)
max_index = db.scalar(
select(func.max(WorkoutSet.set_index)).where(WorkoutSet.workout_item_id == item.id)
)
next_index = int(max_index or 0) + 1
workout_sets: list[WorkoutSet] = []
for set_payload in payload.sets:
workout_set = WorkoutSet(
workout_item_id=item.id,
set_index=next_index,
weight=set_payload.weight,
reps=set_payload.reps,
duration_seconds=set_payload.duration_seconds,
calories=estimate_set_calories(
item,
set_payload.weight,
set_payload.reps,
set_payload.duration_seconds,
set_payload.calories,
),
completed_at=set_payload.completed_at or datetime.now(UTC),
)
db.add(workout_set)
workout_sets.append(workout_set)
next_index += 1
db.flush()
recalculate_workout_totals(db, item.workout_id)
db.commit()
for workout_set in workout_sets:
db.refresh(workout_set)
return workout_sets
@app.patch(
"/internal/workout-sets/{set_id}",
dependencies=[InternalAuth],
response_model=WorkoutSetRead,
)
def update_workout_set(
set_id: uuid.UUID,
payload: WorkoutSetUpdate,
db: Db,
user_id: CurrentUserId,
) -> WorkoutSet:
workout_set = db.scalar(
select(WorkoutSet)
.join(WorkoutItem)
.join(Workout)
.where(WorkoutSet.id == set_id, Workout.user_id == user_id)
.options(
selectinload(WorkoutSet.workout_item).selectinload(WorkoutItem.exercise),
selectinload(WorkoutSet.workout_item).selectinload(WorkoutItem.workout),
)
)
if not workout_set:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Set not found")
ensure_active_workout(workout_set.workout_item.workout)
if payload.weight is not None:
workout_set.weight = payload.weight
if payload.reps is not None:
workout_set.reps = payload.reps
if "duration_seconds" in payload.model_fields_set:
workout_set.duration_seconds = payload.duration_seconds
if "completed_at" in payload.model_fields_set and payload.completed_at is not None:
workout_set.completed_at = payload.completed_at
if "calories" in payload.model_fields_set:
workout_set.calories = payload.calories
else:
workout_set.calories = estimate_set_calories(
workout_set.workout_item,
float(workout_set.weight or 0),
int(workout_set.reps or 0),
workout_set.duration_seconds,
None,
)
recalculate_workout_totals(db, workout_set.workout_item.workout_id)
db.commit() db.commit()
db.refresh(workout_set) db.refresh(workout_set)
return workout_set return workout_set
@@ -337,12 +549,12 @@ def delete_workout_item(item_id: uuid.UUID, db: Db, user_id: CurrentUserId) -> N
if not item: if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Workout item not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Workout item not found")
workout = db.get(Workout, item.workout_id) workout = db.get(Workout, item.workout_id)
if workout and workout.finished_at: if workout:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Workout already finished") ensure_active_workout(workout)
workout_id = item.workout_id workout_id = item.workout_id
db.delete(item) db.delete(item)
db.flush() db.flush()
recalculate_workout_calories(db, workout_id) recalculate_workout_totals(db, workout_id)
db.commit() db.commit()
@@ -369,33 +581,64 @@ def delete_workout_set(
workout = db.scalar( workout = db.scalar(
select(Workout).join(WorkoutItem).where(WorkoutItem.id == item_id) select(Workout).join(WorkoutItem).where(WorkoutItem.id == item_id)
) )
if workout and workout.finished_at: if workout:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Workout already finished") ensure_active_workout(workout)
db.delete(ws) db.delete(ws)
db.flush() db.flush()
reindex_workout_item_sets(db, item_id)
if workout: if workout:
recalculate_workout_calories(db, workout.id) recalculate_workout_totals(db, workout.id)
db.commit() db.commit()
def estimate_set_calories(item: WorkoutItem, payload: WorkoutSetCreate) -> float: def reindex_workout_item_sets(db: Session, item_id: uuid.UUID) -> None:
if item.exercise and item.exercise.default_calories_per_minute and payload.duration_seconds: remaining_sets = list(
db.scalars(
select(WorkoutSet)
.where(WorkoutSet.workout_item_id == item_id)
.order_by(WorkoutSet.set_index.asc(), WorkoutSet.completed_at.asc())
)
)
for index, workout_set in enumerate(remaining_sets, start=1):
workout_set.set_index = index
def estimate_set_calories(
item: WorkoutItem,
weight: float,
reps: int,
duration_seconds: int | None,
calories: float | None,
) -> float:
if calories is not None:
return calories
if item.exercise and item.exercise.default_calories_per_minute and duration_seconds:
return round( return round(
float(item.exercise.default_calories_per_minute) * payload.duration_seconds / 60, float(item.exercise.default_calories_per_minute) * duration_seconds / 60,
2, 2,
) )
return round((payload.weight * max(payload.reps, 1)) / 120, 2) return round((weight * max(reps, 1)) / 120, 2)
def recalculate_workout_totals(db: Session, workout_id: uuid.UUID) -> None:
total_sets, total_volume, estimated_calories = db.execute(
select(
func.count(WorkoutSet.id),
func.coalesce(func.sum(WorkoutSet.weight * WorkoutSet.reps), 0),
func.coalesce(func.sum(WorkoutSet.calories), 0),
)
.join(WorkoutItem, WorkoutSet.workout_item_id == WorkoutItem.id)
.where(WorkoutItem.workout_id == workout_id)
).one()
workout = db.get(Workout, workout_id)
if workout:
workout.total_sets = int(total_sets or 0)
workout.total_volume = float(total_volume or 0)
workout.estimated_calories = float(estimated_calories or 0)
def recalculate_workout_calories(db: Session, workout_id: uuid.UUID) -> None: def recalculate_workout_calories(db: Session, workout_id: uuid.UUID) -> None:
total = db.scalar( recalculate_workout_totals(db, workout_id)
select(func.coalesce(func.sum(WorkoutSet.calories), 0))
.join(WorkoutItem, WorkoutSet.workout_item_id == WorkoutItem.id)
.where(WorkoutItem.workout_id == workout_id)
)
workout = db.get(Workout, workout_id)
if workout:
workout.estimated_calories = float(total or 0)
@app.get( @app.get(
@@ -413,7 +656,7 @@ def get_progression(
select(Workout.started_at, WorkoutSet.weight, WorkoutSet.reps) select(Workout.started_at, WorkoutSet.weight, WorkoutSet.reps)
.join(WorkoutItem, WorkoutSet.workout_item_id == WorkoutItem.id) .join(WorkoutItem, WorkoutSet.workout_item_id == WorkoutItem.id)
.join(Workout, WorkoutItem.workout_id == Workout.id) .join(Workout, WorkoutItem.workout_id == Workout.id)
.where(Workout.user_id == user_id) .where(Workout.user_id == user_id, Workout.status != "discarded")
.order_by(Workout.started_at.asc(), WorkoutSet.completed_at.asc()) .order_by(Workout.started_at.asc(), WorkoutSet.completed_at.asc())
) )
if entity_id and kind == "exercise": if entity_id and kind == "exercise":
@@ -450,7 +693,9 @@ def get_progression(
def get_calories(db: Db, user_id: CurrentUserId) -> CaloriesRead: def get_calories(db: Db, user_id: CurrentUserId) -> CaloriesRead:
workouts = list( workouts = list(
db.scalars( db.scalars(
select(Workout).where(Workout.user_id == user_id).order_by(Workout.started_at.desc()) select(Workout)
.where(Workout.user_id == user_id, Workout.status != "discarded")
.order_by(Workout.started_at.desc())
) )
) )
total = sum(float(workout.estimated_calories or 0) for workout in workouts) total = sum(float(workout.estimated_calories or 0) for workout in workouts)
+16
View File
@@ -67,12 +67,21 @@ class Exercise(Base, TimestampMixin):
class Workout(Base, TimestampMixin): class Workout(Base, TimestampMixin):
__tablename__ = "logic_workouts" __tablename__ = "logic_workouts"
__table_args__ = (
CheckConstraint(
"status IN ('active', 'finished', 'discarded')",
name="ck_workout_status",
),
)
id: Mapped[uuid.UUID] = uuid_pk() id: Mapped[uuid.UUID] = uuid_pk()
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True) user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True)
status: Mapped[str] = mapped_column(String(20), default="active", index=True)
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now) started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
notes: Mapped[str | None] = mapped_column(Text) notes: Mapped[str | None] = mapped_column(Text)
total_sets: Mapped[int] = mapped_column(Integer, default=0)
total_volume: Mapped[float] = mapped_column(Numeric(12, 2), default=0)
estimated_calories: Mapped[float] = mapped_column(Numeric(10, 2), default=0) estimated_calories: Mapped[float] = mapped_column(Numeric(10, 2), default=0)
items: Mapped[list[WorkoutItem]] = relationship( items: Mapped[list[WorkoutItem]] = relationship(
@@ -88,12 +97,19 @@ class WorkoutItem(Base):
"(exercise_id IS NULL AND equipment_id IS NOT NULL)", "(exercise_id IS NULL AND equipment_id IS NOT NULL)",
name="ck_workout_item_exactly_one_entity", name="ck_workout_item_exactly_one_entity",
), ),
CheckConstraint(
"source_kind IN ('exercise', 'equipment')",
name="ck_workout_item_source_kind",
),
) )
id: Mapped[uuid.UUID] = uuid_pk() id: Mapped[uuid.UUID] = uuid_pk()
workout_id: Mapped[uuid.UUID] = mapped_column( workout_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("logic_workouts.id", ondelete="CASCADE") ForeignKey("logic_workouts.id", ondelete="CASCADE")
) )
source_kind: Mapped[str] = mapped_column(String(20))
title_snapshot: Mapped[str] = mapped_column(String(160))
image_s3_url_snapshot: Mapped[str | None] = mapped_column(Text)
exercise_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("logic_exercises.id")) exercise_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("logic_exercises.id"))
equipment_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("logic_equipment.id")) equipment_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("logic_equipment.id"))
order_index: Mapped[int] = mapped_column(Integer, default=0) order_index: Mapped[int] = mapped_column(Integer, default=0)
+33 -2
View File
@@ -1,5 +1,8 @@
from __future__ import annotations
import uuid import uuid
from datetime import datetime from datetime import datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator from pydantic import BaseModel, ConfigDict, Field, model_validator
@@ -50,14 +53,36 @@ class WorkoutUpdate(BaseModel):
notes: str | None = None notes: str | None = None
class WorkoutFinishRequest(BaseModel):
notes: str | None = None
class WorkoutSetCreate(BaseModel): class WorkoutSetCreate(BaseModel):
weight: float = 0 weight: float = Field(default=0, ge=0)
reps: int = 0 reps: int = Field(default=0, ge=0)
duration_seconds: int | None = None duration_seconds: int | None = None
calories: float | None = None calories: float | None = None
completed_at: datetime | None = None completed_at: datetime | None = None
class WorkoutSetBatchCreate(BaseModel):
sets: list[WorkoutSetCreate] = Field(min_length=1)
class WorkoutSetUpdate(BaseModel):
weight: float | None = Field(default=None, ge=0)
reps: int | None = Field(default=None, ge=0)
duration_seconds: int | None = None
calories: float | None = None
completed_at: datetime | None = None
@model_validator(mode="after")
def at_least_one_field(self) -> WorkoutSetUpdate:
if not self.model_fields_set:
raise ValueError("Provide at least one field to update")
return self
class WorkoutSetRead(WorkoutSetCreate): class WorkoutSetRead(WorkoutSetCreate):
id: uuid.UUID id: uuid.UUID
workout_item_id: uuid.UUID workout_item_id: uuid.UUID
@@ -83,6 +108,9 @@ class WorkoutItemCreate(BaseModel):
class WorkoutItemRead(WorkoutItemCreate): class WorkoutItemRead(WorkoutItemCreate):
id: uuid.UUID id: uuid.UUID
workout_id: uuid.UUID workout_id: uuid.UUID
source_kind: Literal["exercise", "equipment"]
title_snapshot: str
image_s3_url_snapshot: str | None
order_index: int order_index: int
created_at: datetime created_at: datetime
sets: list[WorkoutSetRead] = [] sets: list[WorkoutSetRead] = []
@@ -93,9 +121,12 @@ class WorkoutItemRead(WorkoutItemCreate):
class WorkoutRead(BaseModel): class WorkoutRead(BaseModel):
id: uuid.UUID id: uuid.UUID
user_id: uuid.UUID user_id: uuid.UUID
status: Literal["active", "finished", "discarded"]
started_at: datetime started_at: datetime
finished_at: datetime | None finished_at: datetime | None
notes: str | None notes: str | None
total_sets: int
total_volume: float
estimated_calories: float estimated_calories: float
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime