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,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 };
}