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:
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user