Add boto3 dependency and update exercise/machine assets

- Added boto3 as a dependency in pyproject.toml and uv.lock.
- Introduced multiple new exercise images in various formats (jpg, webp, avif, png).
- Added new machine images to enhance the workout assets library.
This commit is contained in:
Artem Kashaev
2026-05-29 15:50:33 +05:00
parent 7b34ce1a98
commit 800dee31b2
120 changed files with 1151 additions and 167 deletions
+4 -2
View File
@@ -19,7 +19,7 @@ function AppShell() {
const nav = [
{ to: "/", label: "Сегодня" },
{ to: "/workout/active", label: "Тренировка" },
{ to: "/catalog/exercises", label: "Упражнения" },
{ to: "/catalog/exercises", label: "Каталог" },
{ to: "/history", label: "История" },
{ to: "/analytics", label: "Аналитика" },
] as const;
@@ -112,8 +112,9 @@ function HomePage() {
const rootRoute = createRootRoute({ component: AppShell });
const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: "/", component: HomePage });
const activeWorkoutRoute = createRoute({ getParentRoute: () => rootRoute, path: "/workout/active", component: ActiveWorkoutPage });
const catalogRoute = createRoute({ getParentRoute: () => rootRoute, path: "/catalog", component: () => <CatalogPage kind="mine" /> });
const catalogExercisesRoute = createRoute({ getParentRoute: () => rootRoute, path: "/catalog/exercises", component: () => <CatalogPage kind="exercise" /> });
const catalogEquipmentRoute = createRoute({ getParentRoute: () => rootRoute, path: "/catalog/equipment", component: () => <CatalogPage kind="equipment" /> });
const catalogEquipmentRoute = createRoute({ getParentRoute: () => rootRoute, path: "/catalog/equipment", component: () => <CatalogPage kind="machine" /> });
const historyRoute = createRoute({ getParentRoute: () => rootRoute, path: "/history", component: HistoryPage });
const analyticsRoute = createRoute({ getParentRoute: () => rootRoute, path: "/analytics", component: AnalyticsPage });
const workoutDetailRoute = createRoute({ getParentRoute: () => rootRoute, path: "/workouts/$workoutId", component: WorkoutDetailPage });
@@ -121,6 +122,7 @@ const workoutDetailRoute = createRoute({ getParentRoute: () => rootRoute, path:
const routeTree = rootRoute.addChildren([
indexRoute,
activeWorkoutRoute,
catalogRoute,
catalogExercisesRoute,
catalogEquipmentRoute,
historyRoute,
+13 -1
View File
@@ -1,5 +1,6 @@
import type {
Calories,
CatalogCreateInput,
CatalogEntity,
CatalogKind,
Progression,
@@ -72,6 +73,14 @@ export const api = {
exercises(token: string) {
return request<CatalogEntity[]>("/catalog/exercises", {}, token);
},
activitySources(token: string, filters: { kind?: CatalogKind; category?: string; scope?: "all" | "builtin" | "mine"; search?: string } = {}) {
const params = new URLSearchParams();
Object.entries(filters).forEach(([key, value]) => {
if (value) params.set(key, value);
});
const query = params.toString();
return request<CatalogEntity[]>(`/catalog/activity-sources${query ? `?${query}` : ""}`, {}, token);
},
uploadImage(token: string, entityType: CatalogKind, file: File) {
const form = new FormData();
form.append("file", file);
@@ -87,6 +96,9 @@ export const api = {
createExercise(token: string, payload: Partial<CatalogEntity>) {
return request<CatalogEntity>("/catalog/exercises", { method: "POST", body: JSON.stringify(payload) }, token);
},
createActivitySource(token: string, payload: CatalogCreateInput) {
return request<CatalogEntity>("/catalog/activity-sources", { method: "POST", body: JSON.stringify(payload) }, token);
},
workouts(token: string) {
return request<Workout[]>("/workouts", {}, token);
},
@@ -111,7 +123,7 @@ export const api = {
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 }) {
addWorkoutItem(token: string, workoutId: string, payload: { activity_source_id?: string | null; exercise_id?: string | null; equipment_id?: string | null }) {
return request<WorkoutItem>(`/workouts/${workoutId}/items`, { method: "POST", body: JSON.stringify(payload) }, token);
},
addWorkoutSet(token: string, itemId: string, payload: WorkoutSetInput) {
@@ -32,7 +32,7 @@ export function AnalyticsPage() {
Упражнение
<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>)}
{exercises.data?.map((exercise) => <option key={exercise.id} value={exercise.id}>{exercise.title}</option>)}
</select>
</label>
<Metric label="Последний вес" value={progression.data?.last_weight ?? "нет"} />
+11 -2
View File
@@ -1,15 +1,24 @@
import type { ReactNode } from "react";
import type { CatalogEntity } from "../../types";
import { categoryLabels, difficultyLabels, equipmentLabels, measurementLabels } from "./meta";
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>
<div className="card-pills">
<span className={entity.is_builtin ? "pill" : "pill user"}>{entity.is_builtin ? "стандартное" : "мое"}</span>
<span className="pill kind">{entity.kind === "exercise" ? "упражнение" : "тренажер"}</span>
</div>
<h3>{entity.title}</h3>
<small>{categoryLabels[entity.category]} · {equipmentLabels[entity.equipment]}</small>
<p>{entity.description || "Без описания"}</p>
<div className="card-pills muted-pills">
<span>{measurementLabels[entity.measurement_type]}</span>
<span>{difficultyLabels[entity.difficulty]}</span>
</div>
{action}
</div>
</article>
+91 -15
View File
@@ -3,34 +3,73 @@ 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 type { ActivityCategory, ActivityEquipment, CatalogKind, Difficulty, MeasurementType } from "../../types";
import { useAuth } from "../auth/AuthContext";
import { CatalogCard } from "./CatalogCard";
import { useCatalog } from "./hooks";
import { categoryLabels, categoryOptions, difficultyLabels, difficultyOptions, equipmentLabels, equipmentOptions, measurementLabels, measurementOptions } from "./meta";
export function CatalogPage({ kind }: { kind: CatalogKind }) {
type CatalogView = CatalogKind | "mine";
const categoryTabs: Array<ActivityCategory | "all" | "arms"> = ["all", "chest", "back", "legs", "shoulders", "arms", "core", "cardio"];
function categoryTabLabel(category: ActivityCategory | "all" | "arms") {
if (category === "all") return "Все";
if (category === "arms") return "Руки";
return categoryLabels[category];
}
export function CatalogPage({ kind = "exercise" }: { kind?: CatalogView }) {
const { auth } = useAuth();
const token = auth.accessToken;
const queryClient = useQueryClient();
const { equipment, exercises } = useCatalog(token);
const [name, setName] = useState("");
const { sources } = useCatalog(token);
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [category, setCategory] = useState<ActivityCategory>("other");
const [equipment, setEquipment] = useState<ActivityEquipment>("other");
const [measurementType, setMeasurementType] = useState<MeasurementType>("weight_reps");
const [difficulty, setDifficulty] = useState<Difficulty>("intermediate");
const [file, setFile] = useState<File | null>(null);
const [search, setSearch] = useState("");
const [categoryFilter, setCategoryFilter] = useState<ActivityCategory | "all" | "arms">("all");
const list = useMemo(() => (kind === "exercise" ? exercises.data : equipment.data) ?? [], [kind, exercises.data, equipment.data]);
const createKind: CatalogKind = kind === "machine" ? "machine" : "exercise";
const list = useMemo(() => {
const needle = search.trim().toLowerCase();
return (sources.data ?? []).filter((entity) => {
if (kind === "mine") {
if (entity.owner_user_id !== auth.user.id) return false;
} else if (entity.kind !== kind) {
return false;
}
if (categoryFilter === "arms" && entity.category !== "biceps" && entity.category !== "triceps") return false;
if (categoryFilter !== "all" && categoryFilter !== "arms" && entity.category !== categoryFilter) return false;
if (!needle) return true;
return `${entity.title} ${entity.description ?? ""}`.toLowerCase().includes(needle);
});
}, [auth.user.id, categoryFilter, kind, search, sources.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);
const image = file ? await api.uploadImage(token, createKind, file) : {};
return api.createActivitySource(token, {
kind: createKind,
title,
description: description || null,
category,
equipment,
measurement_type: measurementType,
difficulty,
...image,
});
},
onSuccess: () => {
setName("");
setTitle("");
setDescription("");
setFile(null);
void queryClient.invalidateQueries({ queryKey: ["catalog", kind === "equipment" ? "equipment" : "exercises"] });
void queryClient.invalidateQueries({ queryKey: ["catalog", "activity-sources"] });
},
});
@@ -44,18 +83,54 @@ export function CatalogPage({ kind }: { kind: CatalogKind }) {
<div className="page-header">
<div>
<p className="eyebrow">Catalog</p>
<h2>{kind === "exercise" ? "Упражнения" : "Тренажеры"}</h2>
<h2>{kind === "machine" ? "Тренажеры" : kind === "mine" ? "Мой каталог" : "Упражнения"}</h2>
</div>
<div className="segmented">
<Link to="/catalog/exercises" activeProps={{ className: "active" }}>Упражнения</Link>
<Link to="/catalog/equipment" activeProps={{ className: "active" }}>Тренажеры</Link>
<Link to="/catalog" activeProps={{ className: "active" }} activeOptions={{ exact: true }}>Мои</Link>
</div>
</div>
<form className="card form-grid" onSubmit={submit}>
<section className="card catalog-controls">
<input placeholder="Поиск по названию" value={search} onChange={(event) => setSearch(event.target.value)} />
<div className="chip-row">
{categoryTabs.map((tab) => (
<button key={tab} className={categoryFilter === tab ? "active" : ""} onClick={() => setCategoryFilter(tab)}>
{categoryTabLabel(tab)}
</button>
))}
</div>
</section>
<form className="card form-grid catalog-form" onSubmit={submit}>
<label>
Название
<input value={name} onChange={(event) => setName(event.target.value)} required />
<input value={title} onChange={(event) => setTitle(event.target.value)} required />
</label>
<label>
Категория
<select value={category} onChange={(event) => setCategory(event.target.value as ActivityCategory)}>
{categoryOptions.map((option) => <option key={option} value={option}>{categoryLabels[option]}</option>)}
</select>
</label>
<label>
Оборудование
<select value={equipment} onChange={(event) => setEquipment(event.target.value as ActivityEquipment)}>
{equipmentOptions.map((option) => <option key={option} value={option}>{equipmentLabels[option]}</option>)}
</select>
</label>
<label>
Логирование
<select value={measurementType} onChange={(event) => setMeasurementType(event.target.value as MeasurementType)}>
{measurementOptions.map((option) => <option key={option} value={option}>{measurementLabels[option]}</option>)}
</select>
</label>
<label>
Сложность
<select value={difficulty} onChange={(event) => setDifficulty(event.target.value as Difficulty)}>
{difficultyOptions.map((option) => <option key={option} value={option}>{difficultyLabels[option]}</option>)}
</select>
</label>
<label>
Описание
@@ -63,7 +138,7 @@ export function CatalogPage({ kind }: { kind: CatalogKind }) {
</label>
<label>
Картинка
<input type="file" accept="image/png,image/jpeg,image/webp" onChange={(event) => setFile(event.target.files?.[0] ?? null)} />
<input type="file" accept="image/png,image/jpeg,image/webp,image/avif" 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>}
@@ -72,6 +147,7 @@ export function CatalogPage({ kind }: { kind: CatalogKind }) {
<div className="catalog-grid">
{list.map((entity) => <CatalogCard entity={entity} key={entity.id} />)}
</div>
{list.length === 0 && <p className="muted">Ничего не найдено.</p>}
</section>
);
}
+4 -3
View File
@@ -3,7 +3,8 @@ 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 };
const sources = useQuery({ queryKey: ["catalog", "activity-sources"], queryFn: () => api.activitySources(token) });
const exercises = { ...sources, data: sources.data?.filter((source) => source.kind === "exercise") };
const machines = { ...sources, data: sources.data?.filter((source) => source.kind === "machine") };
return { sources, exercises, machines, equipment: machines };
}
+44
View File
@@ -0,0 +1,44 @@
import type { ActivityCategory, ActivityEquipment, Difficulty, MeasurementType } from "../../types";
export const categoryLabels: Record<ActivityCategory, string> = {
chest: "Грудь",
back: "Спина",
legs: "Ноги",
shoulders: "Плечи",
biceps: "Бицепс",
triceps: "Трицепс",
core: "Пресс",
cardio: "Кардио",
full_body: "Все тело",
other: "Другое",
};
export const equipmentLabels: Record<ActivityEquipment, string> = {
barbell: "Штанга",
dumbbell: "Гантели",
machine: "Тренажер",
cable: "Блок",
bodyweight: "Свой вес",
kettlebell: "Гиря",
cardio_machine: "Кардио",
other: "Другое",
};
export const measurementLabels: Record<MeasurementType, string> = {
weight_reps: "Вес × повторы",
reps_only: "Повторы",
duration: "Время",
distance_duration: "Дистанция + время",
duration_calories: "Время + ккал",
};
export const difficultyLabels: Record<Difficulty, string> = {
beginner: "Новичок",
intermediate: "Средний",
advanced: "Сложно",
};
export const categoryOptions = Object.keys(categoryLabels) as ActivityCategory[];
export const equipmentOptions = Object.keys(equipmentLabels) as ActivityEquipment[];
export const measurementOptions = Object.keys(measurementLabels) as MeasurementType[];
export const difficultyOptions = Object.keys(difficultyLabels) as Difficulty[];
@@ -4,6 +4,7 @@ import { useQuery } from "@tanstack/react-query";
import { api } from "../../api";
import { Metric } from "../../shared/Metric";
import { useAuth } from "../auth/AuthContext";
import { formatSet } from "../workout/setFormat";
export function WorkoutDetailPage() {
const { workoutId } = useParams({ from: "/workouts/$workoutId" });
@@ -37,8 +38,8 @@ export function WorkoutDetailPage() {
{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 className="set-data">{formatSet(set, item.measurement_type_snapshot)}</span>
<span className="set-cal">{set.calories !== null && set.calories !== undefined ? Math.round(set.calories) : "—"} ккал</span>
<span />
</div>
))}
@@ -1,8 +1,17 @@
import { useMemo, useState } from "react";
import type { CatalogEntity, CatalogKind, Workout } from "../../types";
import type { ActivityCategory, CatalogEntity, CatalogKind, Workout } from "../../types";
import { categoryLabels, measurementLabels } from "../catalog/meta";
import { useCatalog } from "../catalog/hooks";
const categoryTabs: Array<ActivityCategory | "all" | "arms"> = ["all", "chest", "back", "legs", "shoulders", "arms", "core", "cardio"];
function categoryTabLabel(category: ActivityCategory | "all" | "arms") {
if (category === "all") return "Все";
if (category === "arms") return "Руки";
return categoryLabels[category];
}
export function AddExerciseDrawer({
open,
token,
@@ -20,11 +29,13 @@ export function AddExerciseDrawer({
}) {
const [kind, setKind] = useState<CatalogKind>("exercise");
const [search, setSearch] = useState("");
const { exercises, equipment } = useCatalog(token);
const [category, setCategory] = useState<ActivityCategory | "all" | "arms">("all");
const { exercises, machines } = useCatalog(token);
const addedIds = useMemo(() => {
const ids = new Set<string>();
workout.items.forEach((item) => {
if (item.activity_source_id) ids.add(item.activity_source_id);
if (item.exercise_id) ids.add(item.exercise_id);
if (item.equipment_id) ids.add(item.equipment_id);
});
@@ -32,10 +43,15 @@ export function AddExerciseDrawer({
}, [workout.items]);
const list = useMemo(() => {
const source = kind === "exercise" ? exercises.data ?? [] : equipment.data ?? [];
const source = kind === "exercise" ? exercises.data ?? [] : machines.data ?? [];
const needle = search.trim().toLowerCase();
return needle ? source.filter((entity) => entity.name.toLowerCase().includes(needle)) : source;
}, [kind, search, exercises.data, equipment.data]);
return source.filter((entity) => {
if (category === "arms" && entity.category !== "biceps" && entity.category !== "triceps") return false;
if (category !== "all" && category !== "arms" && entity.category !== category) return false;
if (!needle) return true;
return `${entity.title} ${entity.description ?? ""}`.toLowerCase().includes(needle);
});
}, [category, kind, search, exercises.data, machines.data]);
if (!open) return null;
@@ -53,7 +69,12 @@ export function AddExerciseDrawer({
<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>
<button className={kind === "machine" ? "active" : ""} onClick={() => setKind("machine")}>Тренажеры</button>
</div>
<div className="chip-row drawer-categories">
{categoryTabs.map((tab) => (
<button key={tab} className={category === tab ? "active" : ""} onClick={() => setCategory(tab)}>{categoryTabLabel(tab)}</button>
))}
</div>
<div className="drawer-list">
@@ -61,10 +82,10 @@ export function AddExerciseDrawer({
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>}
{entity.image_s3_url ? <img src={entity.image_s3_url} alt="" /> : <span className="drawer-placeholder">{kind === "exercise" ? "EX" : "MC"}</span>}
<div>
<h3>{entity.name}</h3>
<p>{entity.description || "Без описания"}</p>
<h3>{entity.title}</h3>
<p>{categoryLabels[entity.category]} · {measurementLabels[entity.measurement_type]}</p>
{added && <b>В тренировке</b>}
</div>
<button
@@ -1,6 +1,8 @@
import { useEffect, useMemo, useState } from "react";
import type { WorkoutItem, WorkoutSetInput } from "../../types";
import { measurementLabels } from "../catalog/meta";
import { formatSet, volumeForSet } from "./setFormat";
import { WorkoutSetRow } from "./WorkoutSetRow";
function initialDraft(item: WorkoutItem) {
@@ -8,6 +10,9 @@ function initialDraft(item: WorkoutItem) {
return {
weight: last?.weight ?? item.planned_working_weight ?? 0,
reps: last?.reps ?? 8,
durationSeconds: last?.duration_seconds ?? 60,
distanceKm: last?.distance_km ?? 1,
calories: last?.calories ?? 100,
};
}
@@ -28,23 +33,27 @@ export function WorkoutExerciseCard({
onUpdateSet: (setId: string, payload: Partial<WorkoutSetInput>) => void;
pending?: boolean;
}) {
const measurementType = item.measurement_type_snapshot;
const seed = useMemo(() => initialDraft(item), [item]);
const [weight, setWeight] = useState(seed.weight);
const [reps, setReps] = useState(seed.reps);
const [durationSeconds, setDurationSeconds] = useState(seed.durationSeconds);
const [distanceKm, setDistanceKm] = useState(seed.distanceKm);
const [calories, setCalories] = useState(seed.calories);
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]);
setDurationSeconds(seed.durationSeconds);
setDistanceKm(seed.distanceKm);
setCalories(seed.calories);
}, [item.id, seed.weight, seed.reps, seed.durationSeconds, seed.distanceKm, seed.calories]);
const volume = item.sets.reduce((sum, set) => sum + set.weight * set.reps, 0);
const volume = item.sets.reduce((sum, set) => sum + volumeForSet(set, measurementType), 0);
const lastSet = item.sets[item.sets.length - 1];
const volumeLabel = measurementType === "distance_duration" ? `${volume.toFixed(1)} км` : measurementType === "duration" || measurementType === "duration_calories" ? `${Math.round(volume / 60)} мин` : `${Math.round(volume)} кг объема`;
function stepWeight(delta: number) {
setWeight((value) => Math.max(0, Number((value + delta).toFixed(1))));
@@ -54,30 +63,38 @@ export function WorkoutExerciseCard({
setReps((value) => Math.max(0, value + delta));
}
function currentPayload(): WorkoutSetInput {
if (measurementType === "reps_only") return { weight: 0, reps };
if (measurementType === "duration") return { weight: 0, reps: 0, duration_seconds: durationSeconds };
if (measurementType === "distance_duration") return { weight: 0, reps: 0, distance_km: distanceKm, duration_seconds: durationSeconds };
if (measurementType === "duration_calories") return { weight: 0, reps: 0, duration_seconds: durationSeconds, calories };
return { weight, reps };
}
function recordBatch() {
onRecordBatch(Array.from({ length: batchCount }, () => ({ weight: batchWeight, reps: batchReps })));
onRecordBatch(Array.from({ length: batchCount }, () => currentPayload()));
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>}
{item.image_s3_url_snapshot ? <img src={item.image_s3_url_snapshot} alt="" /> : <span>{item.source_kind === "exercise" ? "EX" : "MC"}</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>
<p>{lastSet ? `Последний раз: ${formatSet(lastSet, measurementType)}` : "Подходов еще нет"}</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>
<span>{volumeLabel}</span>
<span>{measurementLabels[measurementType]}</span>
</div>
<div className="sets-list">
@@ -85,6 +102,7 @@ export function WorkoutExerciseCard({
<WorkoutSetRow
key={set.id}
set={set}
measurementType={measurementType}
disabled={pending}
onRemove={() => onRemoveSet(set.id)}
onUpdate={(payload) => onUpdateSet(set.id, payload)}
@@ -92,24 +110,33 @@ export function WorkoutExerciseCard({
))}
</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 className="single-set-console dynamic-console">
{measurementType === "weight_reps" && (
<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>
<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>
)}
{(measurementType === "weight_reps" || measurementType === "reps_only") && (
<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>
</div>
<button className="primary record-set" disabled={pending} onClick={() => onRecordSet({ weight, reps })}>
)}
{(measurementType === "duration" || measurementType === "distance_duration" || measurementType === "duration_calories") && (
<label className="stepper-field compact-input"><span>Секунды</span><input aria-label="Секунды" type="number" min="0" step="5" value={durationSeconds} onChange={(event) => setDurationSeconds(Number(event.target.value))} /></label>
)}
{measurementType === "distance_duration" && <label className="stepper-field compact-input"><span>Км</span><input aria-label="Километры" type="number" min="0" step="0.1" value={distanceKm} onChange={(event) => setDistanceKm(Number(event.target.value))} /></label>}
{measurementType === "duration_calories" && <label className="stepper-field compact-input"><span>Ккал</span><input aria-label="Калории" type="number" min="0" step="1" value={calories} onChange={(event) => setCalories(Number(event.target.value))} /></label>}
<button className="primary record-set" disabled={pending} onClick={() => onRecordSet(currentPayload())}>
{pending ? "Запись..." : "Записать подход"}
</button>
</div>
@@ -124,9 +151,8 @@ export function WorkoutExerciseCard({
<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>
<p className="muted">Будет записан текущий шаблон: {measurementLabels[measurementType]}.</p>
<div className="modal-actions">
<button className="ghost" onClick={() => setShowBatch(false)}>Отмена</button>
<button className="primary" disabled={pending} onClick={recordBatch}>Добавить</button>
@@ -1,14 +1,17 @@
import { useState } from "react";
import type { WorkoutSet, WorkoutSetInput } from "../../types";
import type { MeasurementType, WorkoutSet, WorkoutSetInput } from "../../types";
import { formatSet } from "./setFormat";
export function WorkoutSetRow({
set,
measurementType,
onRemove,
onUpdate,
disabled,
}: {
set: WorkoutSet;
measurementType: MeasurementType;
onRemove: () => void;
onUpdate: (payload: Partial<WorkoutSetInput>) => void;
disabled?: boolean;
@@ -16,15 +19,30 @@ export function WorkoutSetRow({
const [editing, setEditing] = useState(false);
const [weight, setWeight] = useState(set.weight);
const [reps, setReps] = useState(set.reps);
const [durationSeconds, setDurationSeconds] = useState(set.duration_seconds ?? 60);
const [distanceKm, setDistanceKm] = useState(set.distance_km ?? 1);
const [calories, setCalories] = useState(set.calories ?? 0);
function save() {
if (measurementType === "reps_only") onUpdate({ weight: 0, reps });
else if (measurementType === "duration") onUpdate({ weight: 0, reps: 0, duration_seconds: durationSeconds });
else if (measurementType === "distance_duration") onUpdate({ weight: 0, reps: 0, distance_km: distanceKm, duration_seconds: durationSeconds });
else if (measurementType === "duration_calories") onUpdate({ weight: 0, reps: 0, duration_seconds: durationSeconds, calories });
else onUpdate({ weight, reps });
setEditing(false);
}
if (editing) {
return (
<div className="set-row editing">
<div className="set-row editing dynamic-set-row">
<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))} />
{(measurementType === "weight_reps") && <input aria-label="Вес подхода" type="number" min="0" step="0.5" value={weight} onChange={(event) => setWeight(Number(event.target.value))} />}
{(measurementType === "weight_reps" || measurementType === "reps_only") && <input aria-label="Повторы подхода" type="number" min="0" step="1" value={reps} onChange={(event) => setReps(Number(event.target.value))} />}
{(measurementType === "duration" || measurementType === "distance_duration" || measurementType === "duration_calories") && <input aria-label="Секунды" type="number" min="0" step="5" value={durationSeconds} onChange={(event) => setDurationSeconds(Number(event.target.value))} />}
{measurementType === "distance_duration" && <input aria-label="Километры" type="number" min="0" step="0.1" value={distanceKm} onChange={(event) => setDistanceKm(Number(event.target.value))} />}
{measurementType === "duration_calories" && <input aria-label="Калории" type="number" min="0" step="1" value={calories} onChange={(event) => setCalories(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 success" disabled={disabled} onClick={save}>OK</button>
<button className="tiny" onClick={() => setEditing(false)}>×</button>
</div>
</div>
@@ -35,9 +53,9 @@ export function WorkoutSetRow({
<div className="set-row">
<span className="set-index">{set.set_index}</span>
<button className="set-data" onClick={() => setEditing(true)} title="Редактировать подход">
{set.weight} кг × {set.reps}
{formatSet(set, measurementType)}
</button>
<span className="set-cal">{set.calories ? Math.round(set.calories) : "—"} ккал</span>
<span className="set-cal">{set.calories !== null && set.calories !== undefined ? Math.round(set.calories) : "—"} ккал</span>
<button className="set-remove" disabled={disabled} onClick={onRemove} aria-label="Удалить подход">×</button>
</div>
);
+4 -3
View File
@@ -39,10 +39,11 @@ export function useWorkoutMutations(options: { onStartConflict?: () => void; onF
});
const addWorkoutItem = useMutation({
mutationFn: ({ workoutId, sourceId, kind }: { workoutId: string; sourceId: string; kind: CatalogKind }) =>
mutationFn: ({ workoutId, sourceId }: { workoutId: string; sourceId: string; kind: CatalogKind }) =>
workoutApi.addItem(token, workoutId, {
exercise_id: kind === "exercise" ? sourceId : null,
equipment_id: kind === "equipment" ? sourceId : null,
activity_source_id: sourceId,
exercise_id: null,
equipment_id: null,
}),
onSuccess: refresh,
});
@@ -0,0 +1,23 @@
import type { MeasurementType, WorkoutSet } from "../../types";
export function formatDuration(seconds?: number | null) {
const value = seconds ?? 0;
if (value < 60) return `${value} сек`;
const minutes = Math.floor(value / 60);
const rest = value % 60;
return rest ? `${minutes} мин ${rest} сек` : `${minutes} мин`;
}
export function formatSet(set: WorkoutSet, measurementType: MeasurementType) {
if (measurementType === "reps_only") return `${set.reps} повторов`;
if (measurementType === "duration") return formatDuration(set.duration_seconds);
if (measurementType === "distance_duration") return `${set.distance_km ?? 0} км · ${formatDuration(set.duration_seconds)}`;
if (measurementType === "duration_calories") return `${formatDuration(set.duration_seconds)} · ${Math.round(set.calories ?? 0)} ккал`;
return `${set.weight} кг × ${set.reps}`;
}
export function volumeForSet(set: WorkoutSet, measurementType: MeasurementType) {
if (measurementType === "distance_duration") return set.distance_km ?? 0;
if (measurementType === "duration" || measurementType === "duration_calories") return set.duration_seconds ?? 0;
return set.weight * set.reps;
}
+11
View File
@@ -178,6 +178,13 @@ input:focus, select:focus, textarea:focus { border-color: rgba(39,100,255,0.5);
.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; display: grid; gap: 10px; }
.catalog-card p { color: var(--muted); margin: 0; }
.catalog-card small { color: #334155; font-weight: 900; }
.card-pills, .chip-row { display: flex; flex-wrap: wrap; gap: 7px; }
.card-pills.muted-pills span, .chip-row button { border: 1px solid var(--line); border-radius: 999px; padding: 7px 10px; background: rgba(255,255,255,.68); color: #526071; font-size: 12px; font-weight: 900; }
.chip-row button.active { background: var(--charcoal); color: #fff8e8; border-color: var(--charcoal); }
.pill.kind { background: #dbeafe; color: #1d4ed8; }
.catalog-controls { display: grid; gap: 12px; }
.catalog-form { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.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; }
@@ -217,10 +224,13 @@ input:focus, select:focus, textarea:focus { border-color: rgba(39,100,255,0.5);
.set-row-actions { display: flex; gap: 4px; }
.tiny.success { color: var(--green); }
.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; }
.dynamic-console { grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); }
.dynamic-set-row { grid-template-columns: 36px repeat(auto-fit, minmax(90px, 1fr)) auto; }
.stepper-field > span { display: block; color: #9aa9bc; font-size: 11px; font-weight: 950; text-transform: uppercase; letter-spacing: .08em; margin-bottom: 8px; }
.stepper-field > div { display: grid; grid-template-columns: 42px minmax(80px, 1fr) 42px; gap: 7px; }
.stepper-field button { background: rgba(255,255,255,.1); color: #fff8e8; border-radius: 14px; font-size: 22px; font-weight: 950; }
.stepper-field input { text-align: center; font-weight: 950; }
.compact-input { color: #9aa9bc; }
.record-set { min-height: 48px; background: var(--acid); color: var(--charcoal); }
.batch-link { justify-self: start; }
@@ -230,6 +240,7 @@ input:focus, select:focus, textarea:focus { border-color: rgba(39,100,255,0.5);
.add-drawer h2, .modal-card h2 { margin: 0; font-size: 34px; line-height: .96; letter-spacing: -0.055em; }
.round-close { width: 40px; height: 40px; border-radius: 50%; background: #11151e; color: #fff8ea; font-size: 24px; line-height: 1; }
.drawer-tabs { width: fit-content; }
.drawer-categories { margin-top: -4px; }
.drawer-list { display: grid; gap: 10px; }
.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; }
.drawer-pick:hover { background: #fff; }
+33 -4
View File
@@ -5,20 +5,43 @@ export type User = {
created_at: string;
};
export type CatalogKind = "exercise" | "equipment";
export type CatalogKind = "exercise" | "machine";
export type SourceKind = CatalogKind | "equipment";
export type ActivityCategory = "chest" | "back" | "legs" | "shoulders" | "biceps" | "triceps" | "core" | "cardio" | "full_body" | "other";
export type ActivityEquipment = "barbell" | "dumbbell" | "machine" | "cable" | "bodyweight" | "kettlebell" | "cardio_machine" | "other";
export type MeasurementType = "weight_reps" | "reps_only" | "duration" | "distance_duration" | "duration_calories";
export type Difficulty = "beginner" | "intermediate" | "advanced";
export type CatalogEntity = {
id: string;
owner_user_id: string | null;
equipment_id?: string | null;
name: string;
slug: string;
kind: CatalogKind;
title: string;
description: string | null;
category: ActivityCategory;
equipment: ActivityEquipment;
measurement_type: MeasurementType;
difficulty: Difficulty;
image_s3_url: string | null;
image_s3_key: string | null;
is_builtin: boolean;
default_calories_per_minute?: number | null;
};
export type CatalogCreateInput = {
slug?: string | null;
kind: CatalogKind;
title: string;
description?: string | null;
category: ActivityCategory;
equipment: ActivityEquipment;
measurement_type: MeasurementType;
difficulty: Difficulty;
image_s3_url?: string | null;
image_s3_key?: string | null;
};
export type WorkoutStatus = "active" | "finished" | "discarded";
export type WorkoutSet = {
@@ -28,6 +51,7 @@ export type WorkoutSet = {
weight: number;
reps: number;
duration_seconds: number | null;
distance_km: number | null;
calories: number | null;
completed_at: string;
};
@@ -35,11 +59,15 @@ export type WorkoutSet = {
export type WorkoutItem = {
id: string;
workout_id: string;
source_kind: CatalogKind;
source_kind: SourceKind;
activity_source_id: string | null;
exercise_id: string | null;
equipment_id: string | null;
title_snapshot: string;
image_s3_url_snapshot: string | null;
measurement_type_snapshot: MeasurementType;
category_snapshot: ActivityCategory;
equipment_snapshot: ActivityEquipment;
order_index: number;
planned_working_weight: number | null;
created_at: string;
@@ -77,5 +105,6 @@ export type WorkoutSetInput = {
weight: number;
reps: number;
duration_seconds?: number | null;
distance_km?: number | null;
calories?: number | null;
};