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
@@ -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>
);
}