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 ?? "нет"} />
+10 -1
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>
<div className="card-pills">
<span className={entity.is_builtin ? "pill" : "pill user"}>{entity.is_builtin ? "стандартное" : "мое"}</span>
<h3>{entity.name}</h3>
<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,7 +110,8 @@ export function WorkoutExerciseCard({
))}
</div>
<div className="single-set-console">
<div className="single-set-console dynamic-console">
{measurementType === "weight_reps" && (
<div className="stepper-field">
<span>Вес</span>
<div>
@@ -101,6 +120,8 @@ export function WorkoutExerciseCard({
<button onClick={() => stepWeight(2.5)}>+</button>
</div>
</div>
)}
{(measurementType === "weight_reps" || measurementType === "reps_only") && (
<div className="stepper-field">
<span>Повторы</span>
<div>
@@ -109,7 +130,13 @@ export function WorkoutExerciseCard({
<button onClick={() => stepReps(1)}>+</button>
</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;
};
+11
View File
@@ -55,13 +55,24 @@ services:
environment:
DATABASE_URL: ${LOGIC_DATABASE_URL:-postgresql+psycopg://train_watcher:train_watcher@host.docker.internal:5432/train_watcher}
SERVICE_TOKEN: ${SERVICE_TOKEN:-dev-service-token-change-me}
S3_ENDPOINT_URL: ${S3_ENDPOINT_URL:-http://host.docker.internal:9000}
S3_PUBLIC_BASE_URL: ${S3_PUBLIC_BASE_URL:-http://localhost:9000}
S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-minioadmin}
S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-minioadmin}
S3_BUCKET: ${S3_BUCKET:-train-watcher-media}
S3_REGION: ${S3_REGION:-us-east-1}
BUILTIN_ASSETS_DIR: /app/workout_assets
extra_hosts:
- "host.docker.internal:host-gateway"
ports:
- "8002:8000"
volumes:
- ../workout_assets:/app/workout_assets:ro
depends_on:
postgres:
condition: service_healthy
minio-init:
condition: service_completed_successfully
healthcheck:
test: ["CMD", "/app/.venv/bin/python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=2)"]
interval: 5s
+35 -2
View File
@@ -112,12 +112,45 @@ async def logic_request(
@app.post("/media/images", response_model=MediaUploadRead, status_code=status.HTTP_201_CREATED)
async def upload_image(
user: CurrentUser,
entity_type: Annotated[str, Query(pattern="^(equipment|exercise)$")],
entity_type: Annotated[str, Query(pattern="^(equipment|exercise|machine)$")],
file: Annotated[UploadFile, File()],
) -> dict[str, str]:
if entity_type == "equipment":
entity_type = "machine"
return await upload_catalog_image(file, user.id, entity_type)
@app.get("/catalog/activity-sources")
async def list_activity_sources(
user: CurrentUser,
search: str | None = None,
kind: Annotated[str | None, Query(pattern="^(exercise|machine|equipment)$")] = None,
category: str | None = None,
scope: Annotated[str, Query(pattern="^(all|builtin|mine)$")] = "all",
) -> Any:
params = {
key: value
for key, value in {
"search": search,
"kind": kind,
"category": category,
"scope": scope,
}.items()
if value not in (None, "")
}
return await logic_request(
"GET",
"/internal/catalog/activity-sources",
user,
params=params,
)
@app.post("/catalog/activity-sources", status_code=status.HTTP_201_CREATED)
async def create_activity_source(payload: dict[str, Any], user: CurrentUser) -> Any:
return await logic_request("POST", "/internal/catalog/activity-sources", user, json=payload)
@app.get("/catalog/equipment")
async def list_equipment(user: CurrentUser, search: str | None = None) -> Any:
return await logic_request(
@@ -220,7 +253,7 @@ async def remove_workout_set(item_id: str, set_id: str, user: CurrentUser) -> No
@app.get("/analytics/progression")
async def progression(
user: CurrentUser,
kind: Annotated[str, Query(pattern="^(exercise|equipment)$")] = "exercise",
kind: Annotated[str, Query(pattern="^(exercise|machine|equipment)$")] = "exercise",
entity_id: str | None = None,
) -> Any:
p: dict[str, Any] = {"kind": kind}
+1
View File
@@ -10,6 +10,7 @@ ALLOWED_CONTENT_TYPES = {
"image/jpeg": ".jpg",
"image/png": ".png",
"image/webp": ".webp",
"image/avif": ".avif",
}
@@ -0,0 +1,155 @@
"""activity sources catalog
Revision ID: 0003_activity_sources
Revises: 0002_active_workout_flow
Create Date: 2026-05-29 14:50:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "0003_activity_sources"
down_revision: str | None = "0002_active_workout_flow"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"logic_activity_sources",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("owner_user_id", postgresql.UUID(as_uuid=True), nullable=True),
sa.Column("slug", sa.String(length=180), nullable=False),
sa.Column("kind", sa.String(length=20), nullable=False),
sa.Column("title", sa.String(length=160), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("category", sa.String(length=32), nullable=False),
sa.Column("equipment", sa.String(length=32), nullable=False),
sa.Column("measurement_type", sa.String(length=32), nullable=False),
sa.Column("difficulty", sa.String(length=20), nullable=False),
sa.Column("image_s3_url", sa.Text(), nullable=True),
sa.Column("image_s3_key", sa.Text(), nullable=True),
sa.Column("is_builtin", sa.Boolean(), nullable=False),
sa.Column("default_calories_per_minute", sa.Numeric(8, 2), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.CheckConstraint("kind IN ('exercise', 'machine')", name="ck_activity_source_kind"),
sa.CheckConstraint(
"category IN ('chest', 'back', 'legs', 'shoulders', 'biceps', 'triceps', "
"'core', 'cardio', 'full_body', 'other')",
name="ck_activity_source_category",
),
sa.CheckConstraint(
"equipment IN ('barbell', 'dumbbell', 'machine', 'cable', 'bodyweight', "
"'kettlebell', 'cardio_machine', 'other')",
name="ck_activity_source_equipment",
),
sa.CheckConstraint(
"measurement_type IN ('weight_reps', 'reps_only', 'duration', "
"'distance_duration', 'duration_calories')",
name="ck_activity_source_measurement_type",
),
sa.CheckConstraint(
"difficulty IN ('beginner', 'intermediate', 'advanced')",
name="ck_activity_source_difficulty",
),
)
op.create_index("ix_logic_activity_sources_category", "logic_activity_sources", ["category"])
op.create_index("ix_logic_activity_sources_equipment", "logic_activity_sources", ["equipment"])
op.create_index(
"ix_logic_activity_sources_is_builtin", "logic_activity_sources", ["is_builtin"]
)
op.create_index("ix_logic_activity_sources_kind", "logic_activity_sources", ["kind"])
op.create_index(
"ix_logic_activity_sources_owner_user_id",
"logic_activity_sources",
["owner_user_id"],
)
op.create_index(
"ix_logic_activity_sources_slug", "logic_activity_sources", ["slug"], unique=True
)
op.create_index("ix_logic_activity_sources_title", "logic_activity_sources", ["title"])
op.add_column(
"logic_workout_items",
sa.Column("activity_source_id", postgresql.UUID(as_uuid=True), nullable=True),
)
op.create_foreign_key(
"fk_logic_workout_items_activity_source_id",
"logic_workout_items",
"logic_activity_sources",
["activity_source_id"],
["id"],
)
op.add_column(
"logic_workout_items",
sa.Column(
"measurement_type_snapshot",
sa.String(length=32),
nullable=False,
server_default="weight_reps",
),
)
op.add_column(
"logic_workout_items",
sa.Column(
"category_snapshot", sa.String(length=32), nullable=False, server_default="other"
),
)
op.add_column(
"logic_workout_items",
sa.Column(
"equipment_snapshot", sa.String(length=32), nullable=False, server_default="other"
),
)
op.drop_constraint("ck_workout_item_exactly_one_entity", "logic_workout_items", type_="check")
op.create_check_constraint(
"ck_workout_item_exactly_one_entity",
"logic_workout_items",
"(CASE WHEN activity_source_id IS NOT NULL THEN 1 ELSE 0 END + "
"CASE WHEN exercise_id IS NOT NULL THEN 1 ELSE 0 END + "
"CASE WHEN equipment_id IS NOT NULL THEN 1 ELSE 0 END) = 1",
)
op.drop_constraint("ck_workout_item_source_kind", "logic_workout_items", type_="check")
op.create_check_constraint(
"ck_workout_item_source_kind",
"logic_workout_items",
"source_kind IN ('exercise', 'machine', 'equipment')",
)
op.add_column("logic_workout_sets", sa.Column("distance_km", sa.Numeric(8, 3), nullable=True))
def downgrade() -> None:
op.drop_column("logic_workout_sets", "distance_km")
op.drop_constraint("ck_workout_item_source_kind", "logic_workout_items", type_="check")
op.create_check_constraint(
"ck_workout_item_source_kind",
"logic_workout_items",
"source_kind IN ('exercise', 'equipment')",
)
op.drop_constraint("ck_workout_item_exactly_one_entity", "logic_workout_items", type_="check")
op.create_check_constraint(
"ck_workout_item_exactly_one_entity",
"logic_workout_items",
"(exercise_id IS NOT NULL AND equipment_id IS NULL) OR "
"(exercise_id IS NULL AND equipment_id IS NOT NULL)",
)
op.drop_column("logic_workout_items", "equipment_snapshot")
op.drop_column("logic_workout_items", "category_snapshot")
op.drop_column("logic_workout_items", "measurement_type_snapshot")
op.drop_constraint(
"fk_logic_workout_items_activity_source_id", "logic_workout_items", type_="foreignkey"
)
op.drop_column("logic_workout_items", "activity_source_id")
op.drop_index("ix_logic_activity_sources_title", table_name="logic_activity_sources")
op.drop_index("ix_logic_activity_sources_slug", table_name="logic_activity_sources")
op.drop_index("ix_logic_activity_sources_owner_user_id", table_name="logic_activity_sources")
op.drop_index("ix_logic_activity_sources_kind", table_name="logic_activity_sources")
op.drop_index("ix_logic_activity_sources_is_builtin", table_name="logic_activity_sources")
op.drop_index("ix_logic_activity_sources_equipment", table_name="logic_activity_sources")
op.drop_index("ix_logic_activity_sources_category", table_name="logic_activity_sources")
op.drop_table("logic_activity_sources")
+7
View File
@@ -6,6 +6,13 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
database_url: str = "postgresql+psycopg://train_watcher:train_watcher@localhost:5432/train_watcher"
service_token: str = "dev-service-token-change-me"
s3_endpoint_url: str = "http://localhost:9000"
s3_public_base_url: str = "http://localhost:9000"
s3_access_key_id: str = "minioadmin"
s3_secret_access_key: str = "minioadmin"
s3_bucket: str = "train-watcher-media"
s3_region: str = "us-east-1"
builtin_assets_dir: str = "/app/workout_assets"
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
+93 -1
View File
@@ -81,6 +81,13 @@ def upgrade_existing_schema(connection: Connection) -> None:
check_sql="status IN ('active', 'finished', 'discarded')",
)
connection.execute(
text(
"ALTER TABLE logic_workout_items "
"ADD COLUMN IF NOT EXISTS activity_source_id UUID "
"REFERENCES logic_activity_sources(id)"
)
)
connection.execute(
text("ALTER TABLE logic_workout_items ADD COLUMN IF NOT EXISTS source_kind VARCHAR(20)")
)
@@ -90,6 +97,24 @@ def upgrade_existing_schema(connection: Connection) -> None:
connection.execute(
text("ALTER TABLE logic_workout_items ADD COLUMN IF NOT EXISTS image_s3_url_snapshot TEXT")
)
connection.execute(
text(
"ALTER TABLE logic_workout_items "
"ADD COLUMN IF NOT EXISTS measurement_type_snapshot VARCHAR(32) DEFAULT 'weight_reps'"
)
)
connection.execute(
text(
"ALTER TABLE logic_workout_items "
"ADD COLUMN IF NOT EXISTS category_snapshot VARCHAR(32) DEFAULT 'other'"
)
)
connection.execute(
text(
"ALTER TABLE logic_workout_items "
"ADD COLUMN IF NOT EXISTS equipment_snapshot VARCHAR(32) DEFAULT 'other'"
)
)
connection.execute(
text(
"""
@@ -140,11 +165,63 @@ def upgrade_existing_schema(connection: Connection) -> None:
connection.execute(
text("ALTER TABLE logic_workout_items ALTER COLUMN title_snapshot SET NOT NULL")
)
connection.execute(
text(
"UPDATE logic_workout_items "
"SET measurement_type_snapshot = 'weight_reps' "
"WHERE measurement_type_snapshot IS NULL"
)
)
connection.execute(
text(
"UPDATE logic_workout_items "
"SET category_snapshot = 'other' "
"WHERE category_snapshot IS NULL"
)
)
connection.execute(
text(
"UPDATE logic_workout_items "
"SET equipment_snapshot = 'other' "
"WHERE equipment_snapshot IS NULL"
)
)
connection.execute(
text("ALTER TABLE logic_workout_items ALTER COLUMN measurement_type_snapshot SET NOT NULL")
)
connection.execute(
text("ALTER TABLE logic_workout_items ALTER COLUMN category_snapshot SET NOT NULL")
)
connection.execute(
text("ALTER TABLE logic_workout_items ALTER COLUMN equipment_snapshot SET NOT NULL")
)
drop_constraint_if_exists(
connection,
constraint_name="ck_workout_item_exactly_one_entity",
table_name="logic_workout_items",
)
add_check_constraint_if_missing(
connection,
constraint_name="ck_workout_item_exactly_one_entity",
table_name="logic_workout_items",
check_sql="(CASE WHEN activity_source_id IS NOT NULL THEN 1 ELSE 0 END + "
"CASE WHEN exercise_id IS NOT NULL THEN 1 ELSE 0 END + "
"CASE WHEN equipment_id IS NOT NULL THEN 1 ELSE 0 END) = 1",
)
drop_constraint_if_exists(
connection,
constraint_name="ck_workout_item_source_kind",
table_name="logic_workout_items",
)
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')",
check_sql="source_kind IN ('exercise', 'machine', 'equipment')",
)
connection.execute(
text("ALTER TABLE logic_workout_sets ADD COLUMN IF NOT EXISTS distance_km NUMERIC(8, 3)")
)
connection.execute(
@@ -194,6 +271,21 @@ def add_check_constraint_if_missing(
)
def drop_constraint_if_exists(
connection: Connection,
*,
constraint_name: str,
table_name: str,
) -> None:
exists = connection.execute(
text("SELECT 1 FROM pg_constraint WHERE conname = :constraint_name"),
{"constraint_name": constraint_name},
).scalar()
if not exists:
return
connection.execute(text(f"ALTER TABLE {table_name} DROP CONSTRAINT {constraint_name}"))
def get_db() -> Generator[Session]:
db: Any = SessionLocal()
try:
+278 -80
View File
@@ -1,21 +1,25 @@
import json
import re
import uuid
from collections import defaultdict
from datetime import UTC, datetime
from pathlib import Path
from typing import Annotated
import boto3
from fastapi import Body, Depends, FastAPI, Header, HTTPException, Query, status
from sqlalchemy import func, select
from sqlalchemy.orm import Session, selectinload
from app.core import settings
from app.db import SessionLocal, create_schema, get_db
from app.models import Base, Equipment, Exercise, Workout, WorkoutItem, WorkoutSet
from app.models import ActivitySource, Base, Equipment, Exercise, Workout, WorkoutItem, WorkoutSet
from app.schemas import (
ActivitySourceCreate,
ActivitySourceRead,
CaloriesRead,
EquipmentCreate,
EquipmentRead,
ExerciseCreate,
ExerciseRead,
ProgressionPoint,
ProgressionRead,
WorkoutCreate,
@@ -32,6 +36,15 @@ from app.schemas import (
app = FastAPI(title="Train Watcher Logic Service", version="0.1.0")
SEED_FILE = Path(__file__).parent / "seeds" / "activity_sources.json"
ASSET_CONTENT_TYPES = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".webp": "image/webp",
".avif": "image/avif",
}
def require_service_token(x_service_token: Annotated[str | None, Header()] = None) -> None:
if x_service_token != settings.service_token:
@@ -74,50 +87,74 @@ def health() -> dict[str, str]:
def seed_builtin_catalog(db: Session) -> None:
exists = db.scalar(
select(func.count()).select_from(Equipment).where(Equipment.is_builtin.is_(True))
)
if exists:
if not SEED_FILE.exists():
return
seed_rows = json.loads(SEED_FILE.read_text(encoding="utf-8"))
assets_dir = resolve_builtin_assets_dir()
if not assets_dir:
return
treadmill = Equipment(
name="Беговая дорожка",
description="Кардио-тренажер для ходьбы и бега.",
is_builtin=True,
)
smith = Equipment(
name="Машина Смита",
description="Силовая рама с фиксированной траекторией грифа.",
is_builtin=True,
)
db.add_all([treadmill, smith])
db.flush()
db.add_all(
[
Exercise(
name="Жим лежа",
description="Базовое упражнение для груди, трицепса и передней дельты.",
is_builtin=True,
default_calories_per_minute=6,
),
Exercise(
name="Приседания",
description="Базовое упражнение для ног и корпуса.",
is_builtin=True,
default_calories_per_minute=8,
),
Exercise(
name="Бег",
description="Кардио-нагрузка на беговой дорожке.",
equipment_id=treadmill.id,
is_builtin=True,
default_calories_per_minute=10,
),
]
)
for row in seed_rows:
asset_path = assets_dir / row["asset_filename"]
image = upload_builtin_asset(row["kind"], row["slug"], asset_path)
if not image:
continue
activity = db.scalar(select(ActivitySource).where(ActivitySource.slug == row["slug"]))
if activity is None:
activity = ActivitySource(slug=row["slug"], owner_user_id=None, is_builtin=True)
db.add(activity)
activity.kind = row["kind"]
activity.title = row["title"]
activity.description = row.get("description")
activity.category = row["category"]
activity.equipment = row["equipment"]
activity.measurement_type = row["measurement_type"]
activity.difficulty = row["difficulty"]
activity.default_calories_per_minute = row.get("default_calories_per_minute")
activity.image_s3_key = image["image_s3_key"]
activity.image_s3_url = image["image_s3_url"]
activity.is_builtin = True
db.commit()
def resolve_builtin_assets_dir() -> Path | None:
configured = Path(settings.builtin_assets_dir)
candidates = [configured, Path(__file__).resolve().parents[3] / "workout_assets"]
for candidate in candidates:
if candidate.exists() and candidate.is_dir():
return candidate
return None
def upload_builtin_asset(kind: str, slug: str, asset_path: Path) -> dict[str, str] | None:
if not asset_path.exists() or asset_path.suffix.lower() not in ASSET_CONTENT_TYPES:
return None
extension = asset_path.suffix.lower()
object_key = f"builtin/activity-sources/{kind}/{slug}{extension}"
try:
boto3.client(
"s3",
endpoint_url=settings.s3_endpoint_url,
aws_access_key_id=settings.s3_access_key_id,
aws_secret_access_key=settings.s3_secret_access_key,
region_name=settings.s3_region,
).put_object(
Bucket=settings.s3_bucket,
Key=object_key,
Body=asset_path.read_bytes(),
ContentType=ASSET_CONTENT_TYPES[extension],
)
except Exception as exc: # noqa: BLE001 - failed asset upload should not block service startup
print(f"Skipping builtin asset {asset_path}: {exc}", flush=True)
return None
public_base = settings.s3_public_base_url.rstrip("/")
return {
"image_s3_key": object_key,
"image_s3_url": f"{public_base}/{settings.s3_bucket}/{object_key}",
}
def accessible_equipment(db: Session, user_id: uuid.UUID):
return select(Equipment).where(
(Equipment.is_builtin.is_(True)) | (Equipment.owner_user_id == user_id)
@@ -130,11 +167,41 @@ def accessible_exercises(db: Session, user_id: uuid.UUID):
)
def accessible_activity_sources(db: Session, user_id: uuid.UUID):
return select(ActivitySource).where(
(ActivitySource.is_builtin.is_(True)) | (ActivitySource.owner_user_id == user_id)
)
def normalize_kind(kind: str | None) -> str | None:
if kind == "equipment":
return "machine"
return kind
def slugify(value: str) -> str:
slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
return slug or "activity"
def unique_user_slug(db: Session, title: str, user_id: uuid.UUID) -> str:
base = f"custom-{slugify(title)}-{str(user_id)[:8]}"
candidate = base
index = 2
while db.scalar(select(ActivitySource.id).where(ActivitySource.slug == candidate)):
candidate = f"{base}-{index}"
index += 1
return candidate
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))
.options(
selectinload(Workout.items).selectinload(WorkoutItem.sets),
selectinload(Workout.items).selectinload(WorkoutItem.activity_source),
)
)
if not workout:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Workout not found")
@@ -145,7 +212,10 @@ def get_active_workout_for_user(db: Session, user_id: uuid.UUID) -> Workout | No
return db.scalar(
select(Workout)
.where(Workout.user_id == user_id, Workout.status == "active")
.options(selectinload(Workout.items).selectinload(WorkoutItem.sets))
.options(
selectinload(Workout.items).selectinload(WorkoutItem.sets),
selectinload(Workout.items).selectinload(WorkoutItem.activity_source),
)
.order_by(Workout.started_at.desc())
)
@@ -158,64 +228,151 @@ def ensure_active_workout(workout: Workout) -> None:
)
@app.get(
"/internal/catalog/activity-sources",
dependencies=[InternalAuth],
response_model=list[ActivitySourceRead],
)
def list_activity_sources(
db: Db,
user_id: CurrentUserId,
search: str | None = None,
kind: Annotated[str | None, Query(pattern="^(exercise|machine|equipment)$")] = None,
category: str | None = None,
scope: Annotated[str, Query(pattern="^(all|builtin|mine)$")] = "all",
) -> list[ActivitySource]:
statement = accessible_activity_sources(db, user_id).order_by(
ActivitySource.is_builtin.desc(), ActivitySource.title
)
normalized_kind = normalize_kind(kind)
if normalized_kind:
statement = statement.where(ActivitySource.kind == normalized_kind)
if category:
statement = statement.where(ActivitySource.category == category)
if scope == "builtin":
statement = statement.where(ActivitySource.is_builtin.is_(True))
elif scope == "mine":
statement = statement.where(ActivitySource.owner_user_id == user_id)
if search:
needle = f"%{search}%"
statement = statement.where(
ActivitySource.title.ilike(needle) | ActivitySource.description.ilike(needle)
)
return list(db.scalars(statement))
@app.post(
"/internal/catalog/activity-sources",
dependencies=[InternalAuth],
response_model=ActivitySourceRead,
status_code=status.HTTP_201_CREATED,
)
def create_activity_source(
payload: ActivitySourceCreate, db: Db, user_id: CurrentUserId
) -> ActivitySource:
slug = payload.slug or unique_user_slug(db, payload.title, user_id)
if db.scalar(select(ActivitySource.id).where(ActivitySource.slug == slug)):
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Slug already exists")
data = payload.model_dump(exclude={"slug"})
activity = ActivitySource(
slug=slug,
owner_user_id=user_id,
is_builtin=False,
**data,
)
db.add(activity)
db.commit()
db.refresh(activity)
return activity
@app.get(
"/internal/catalog/equipment",
dependencies=[InternalAuth],
response_model=list[EquipmentRead],
response_model=list[ActivitySourceRead],
)
def list_equipment(db: Db, user_id: CurrentUserId, search: str | None = None) -> list[Equipment]:
statement = accessible_equipment(db, user_id).order_by(
Equipment.is_builtin.desc(), Equipment.name
def list_equipment(
db: Db, user_id: CurrentUserId, search: str | None = None
) -> list[ActivitySource]:
statement = (
accessible_activity_sources(db, user_id)
.where(ActivitySource.kind == "machine")
.order_by(ActivitySource.is_builtin.desc(), ActivitySource.title)
)
if search:
statement = statement.where(Equipment.name.ilike(f"%{search}%"))
statement = statement.where(ActivitySource.title.ilike(f"%{search}%"))
return list(db.scalars(statement))
@app.post(
"/internal/catalog/equipment",
dependencies=[InternalAuth],
response_model=EquipmentRead,
response_model=ActivitySourceRead,
status_code=status.HTTP_201_CREATED,
)
def create_equipment(payload: EquipmentCreate, db: Db, user_id: CurrentUserId) -> Equipment:
equipment = Equipment(owner_user_id=user_id, is_builtin=False, **payload.model_dump())
db.add(equipment)
def create_equipment(payload: EquipmentCreate, db: Db, user_id: CurrentUserId) -> ActivitySource:
activity = ActivitySource(
owner_user_id=user_id,
slug=unique_user_slug(db, payload.name, user_id),
kind="machine",
title=payload.name,
description=payload.description,
category="other",
equipment="machine",
measurement_type="weight_reps",
difficulty="intermediate",
image_s3_url=payload.image_s3_url,
image_s3_key=payload.image_s3_key,
is_builtin=False,
)
db.add(activity)
db.commit()
db.refresh(equipment)
return equipment
db.refresh(activity)
return activity
@app.get(
"/internal/catalog/exercises",
dependencies=[InternalAuth],
response_model=list[ExerciseRead],
)
def list_exercises(db: Db, user_id: CurrentUserId, search: str | None = None) -> list[Exercise]:
statement = accessible_exercises(db, user_id).order_by(
Exercise.is_builtin.desc(), Exercise.name
response_model=list[ActivitySourceRead],
)
def list_exercises(
db: Db, user_id: CurrentUserId, search: str | None = None
) -> list[ActivitySource]:
statement = accessible_activity_sources(db, user_id).where(
ActivitySource.kind == "exercise"
).order_by(ActivitySource.is_builtin.desc(), ActivitySource.title)
if search:
statement = statement.where(Exercise.name.ilike(f"%{search}%"))
statement = statement.where(ActivitySource.title.ilike(f"%{search}%"))
return list(db.scalars(statement))
@app.post(
"/internal/catalog/exercises",
dependencies=[InternalAuth],
response_model=ExerciseRead,
response_model=ActivitySourceRead,
status_code=status.HTTP_201_CREATED,
)
def create_exercise(payload: ExerciseCreate, db: Db, user_id: CurrentUserId) -> Exercise:
if 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):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Equipment not found")
exercise = Exercise(owner_user_id=user_id, is_builtin=False, **payload.model_dump())
db.add(exercise)
def create_exercise(payload: ExerciseCreate, db: Db, user_id: CurrentUserId) -> ActivitySource:
activity = ActivitySource(
owner_user_id=user_id,
slug=unique_user_slug(db, payload.name, user_id),
kind="exercise",
title=payload.name,
description=payload.description,
category="other",
equipment="other",
measurement_type="weight_reps",
difficulty="intermediate",
image_s3_url=payload.image_s3_url,
image_s3_key=payload.image_s3_key,
default_calories_per_minute=payload.default_calories_per_minute,
is_builtin=False,
)
db.add(activity)
db.commit()
db.refresh(exercise)
return exercise
db.refresh(activity)
return activity
@app.get("/internal/workouts", dependencies=[InternalAuth], response_model=list[WorkoutRead])
@@ -224,7 +381,10 @@ def list_workouts(db: Db, user_id: CurrentUserId) -> list[Workout]:
db.scalars(
select(Workout)
.where(Workout.user_id == user_id)
.options(selectinload(Workout.items).selectinload(WorkoutItem.sets))
.options(
selectinload(Workout.items).selectinload(WorkoutItem.sets),
selectinload(Workout.items).selectinload(WorkoutItem.activity_source),
)
.order_by(Workout.started_at.desc())
)
)
@@ -341,7 +501,22 @@ def add_workout_item(
source_kind: str
title_snapshot: str
image_s3_url_snapshot: str | None
if payload.exercise_id:
measurement_type_snapshot = "weight_reps"
category_snapshot = "other"
equipment_snapshot = "other"
if payload.activity_source_id:
activity = db.get(ActivitySource, payload.activity_source_id)
if not activity or (not activity.is_builtin and activity.owner_user_id != user_id):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Activity source not found"
)
source_kind = activity.kind
title_snapshot = activity.title
image_s3_url_snapshot = activity.image_s3_url
measurement_type_snapshot = activity.measurement_type
category_snapshot = activity.category
equipment_snapshot = activity.equipment
elif 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):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Exercise not found")
@@ -352,7 +527,7 @@ def add_workout_item(
equipment = db.get(Equipment, payload.equipment_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")
source_kind = "equipment"
source_kind = "machine"
title_snapshot = equipment.name
image_s3_url_snapshot = equipment.image_s3_url
@@ -367,6 +542,9 @@ def add_workout_item(
source_kind=source_kind,
title_snapshot=title_snapshot,
image_s3_url_snapshot=image_s3_url_snapshot,
measurement_type_snapshot=measurement_type_snapshot,
category_snapshot=category_snapshot,
equipment_snapshot=equipment_snapshot,
**payload.model_dump(exclude={"order_index"}),
order_index=next_index,
)
@@ -394,6 +572,7 @@ def add_workout_set(
.where(WorkoutItem.id == item_id, Workout.user_id == user_id)
.options(
selectinload(WorkoutItem.sets),
selectinload(WorkoutItem.activity_source),
selectinload(WorkoutItem.exercise),
selectinload(WorkoutItem.workout),
)
@@ -419,6 +598,7 @@ def add_workout_set(
weight=payload.weight,
reps=payload.reps,
duration_seconds=payload.duration_seconds,
distance_km=payload.distance_km,
calories=calories,
completed_at=payload.completed_at or datetime.now(UTC),
)
@@ -446,7 +626,11 @@ def add_workout_sets_batch(
select(WorkoutItem)
.join(Workout)
.where(WorkoutItem.id == item_id, Workout.user_id == user_id)
.options(selectinload(WorkoutItem.exercise), selectinload(WorkoutItem.workout))
.options(
selectinload(WorkoutItem.activity_source),
selectinload(WorkoutItem.exercise),
selectinload(WorkoutItem.workout),
)
)
if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Workout item not found")
@@ -464,6 +648,7 @@ def add_workout_sets_batch(
weight=set_payload.weight,
reps=set_payload.reps,
duration_seconds=set_payload.duration_seconds,
distance_km=set_payload.distance_km,
calories=estimate_set_calories(
item,
set_payload.weight,
@@ -502,6 +687,7 @@ def update_workout_set(
.join(Workout)
.where(WorkoutSet.id == set_id, Workout.user_id == user_id)
.options(
selectinload(WorkoutSet.workout_item).selectinload(WorkoutItem.activity_source),
selectinload(WorkoutSet.workout_item).selectinload(WorkoutItem.exercise),
selectinload(WorkoutSet.workout_item).selectinload(WorkoutItem.workout),
)
@@ -516,6 +702,8 @@ def update_workout_set(
workout_set.reps = payload.reps
if "duration_seconds" in payload.model_fields_set:
workout_set.duration_seconds = payload.duration_seconds
if "distance_km" in payload.model_fields_set:
workout_set.distance_km = payload.distance_km
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:
@@ -612,6 +800,15 @@ def estimate_set_calories(
) -> float:
if calories is not None:
return calories
if (
item.activity_source
and item.activity_source.default_calories_per_minute
and duration_seconds
):
return round(
float(item.activity_source.default_calories_per_minute) * duration_seconds / 60,
2,
)
if item.exercise and item.exercise.default_calories_per_minute and duration_seconds:
return round(
float(item.exercise.default_calories_per_minute) * duration_seconds / 60,
@@ -649,7 +846,7 @@ def recalculate_workout_calories(db: Session, workout_id: uuid.UUID) -> None:
def get_progression(
db: Db,
user_id: CurrentUserId,
kind: Annotated[str, Query(pattern="^(exercise|equipment)$")],
kind: Annotated[str, Query(pattern="^(exercise|machine|equipment)$")],
entity_id: Annotated[uuid.UUID | None, Query()] = None,
) -> ProgressionRead:
statement = (
@@ -659,10 +856,11 @@ def get_progression(
.where(Workout.user_id == user_id, Workout.status != "discarded")
.order_by(Workout.started_at.asc(), WorkoutSet.completed_at.asc())
)
if entity_id and kind == "exercise":
statement = statement.where(WorkoutItem.exercise_id == entity_id)
elif entity_id and kind == "equipment":
statement = statement.where(WorkoutItem.equipment_id == entity_id)
normalized_kind = normalize_kind(kind)
if entity_id:
statement = statement.where(WorkoutItem.activity_source_id == entity_id)
if normalized_kind:
statement = statement.where(WorkoutItem.source_kind == normalized_kind)
rows = list(db.execute(statement))
grouped: dict[str, dict[str, float]] = defaultdict(lambda: {"max_weight": 0, "volume": 0})
+55 -3
View File
@@ -48,6 +48,49 @@ class Equipment(Base, TimestampMixin):
workout_items: Mapped[list[WorkoutItem]] = relationship(back_populates="equipment")
class ActivitySource(Base, TimestampMixin):
__tablename__ = "logic_activity_sources"
__table_args__ = (
CheckConstraint("kind IN ('exercise', 'machine')", name="ck_activity_source_kind"),
CheckConstraint(
"category IN ('chest', 'back', 'legs', 'shoulders', 'biceps', 'triceps', "
"'core', 'cardio', 'full_body', 'other')",
name="ck_activity_source_category",
),
CheckConstraint(
"equipment IN ('barbell', 'dumbbell', 'machine', 'cable', 'bodyweight', "
"'kettlebell', 'cardio_machine', 'other')",
name="ck_activity_source_equipment",
),
CheckConstraint(
"measurement_type IN ('weight_reps', 'reps_only', 'duration', "
"'distance_duration', 'duration_calories')",
name="ck_activity_source_measurement_type",
),
CheckConstraint(
"difficulty IN ('beginner', 'intermediate', 'advanced')",
name="ck_activity_source_difficulty",
),
)
id: Mapped[uuid.UUID] = uuid_pk()
owner_user_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), index=True)
slug: Mapped[str] = mapped_column(String(180), unique=True, index=True)
kind: Mapped[str] = mapped_column(String(20), index=True)
title: Mapped[str] = mapped_column(String(160), index=True)
description: Mapped[str | None] = mapped_column(Text)
category: Mapped[str] = mapped_column(String(32), index=True)
equipment: Mapped[str] = mapped_column(String(32), index=True)
measurement_type: Mapped[str] = mapped_column(String(32))
difficulty: Mapped[str] = mapped_column(String(20), default="intermediate")
image_s3_url: Mapped[str | None] = mapped_column(Text)
image_s3_key: Mapped[str | None] = mapped_column(Text)
is_builtin: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
default_calories_per_minute: Mapped[float | None] = mapped_column(Numeric(8, 2))
workout_items: Mapped[list[WorkoutItem]] = relationship(back_populates="activity_source")
class Exercise(Base, TimestampMixin):
__tablename__ = "logic_exercises"
@@ -93,12 +136,13 @@ class WorkoutItem(Base):
__tablename__ = "logic_workout_items"
__table_args__ = (
CheckConstraint(
"(exercise_id IS NOT NULL AND equipment_id IS NULL) OR "
"(exercise_id IS NULL AND equipment_id IS NOT NULL)",
"(CASE WHEN activity_source_id IS NOT NULL THEN 1 ELSE 0 END + "
"CASE WHEN exercise_id IS NOT NULL THEN 1 ELSE 0 END + "
"CASE WHEN equipment_id IS NOT NULL THEN 1 ELSE 0 END) = 1",
name="ck_workout_item_exactly_one_entity",
),
CheckConstraint(
"source_kind IN ('exercise', 'equipment')",
"source_kind IN ('exercise', 'machine', 'equipment')",
name="ck_workout_item_source_kind",
),
)
@@ -110,6 +154,12 @@ class WorkoutItem(Base):
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)
measurement_type_snapshot: Mapped[str] = mapped_column(String(32), default="weight_reps")
category_snapshot: Mapped[str] = mapped_column(String(32), default="other")
equipment_snapshot: Mapped[str] = mapped_column(String(32), default="other")
activity_source_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("logic_activity_sources.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"))
order_index: Mapped[int] = mapped_column(Integer, default=0)
@@ -117,6 +167,7 @@ class WorkoutItem(Base):
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
workout: Mapped[Workout] = relationship(back_populates="items")
activity_source: Mapped[ActivitySource | None] = relationship(back_populates="workout_items")
exercise: Mapped[Exercise | None] = relationship(back_populates="workout_items")
equipment: Mapped[Equipment | None] = relationship(back_populates="workout_items")
sets: Mapped[list[WorkoutSet]] = relationship(
@@ -135,6 +186,7 @@ class WorkoutSet(Base):
weight: Mapped[float] = mapped_column(Numeric(8, 2), default=0)
reps: Mapped[int] = mapped_column(Integer, default=0)
duration_seconds: Mapped[int | None] = mapped_column(Integer)
distance_km: Mapped[float | None] = mapped_column(Numeric(8, 3))
calories: Mapped[float | None] = mapped_column(Numeric(8, 2))
completed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
+67 -3
View File
@@ -6,6 +6,63 @@ from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator
ActivityKind = Literal["exercise", "machine"]
ActivityCategory = Literal[
"chest",
"back",
"legs",
"shoulders",
"biceps",
"triceps",
"core",
"cardio",
"full_body",
"other",
]
ActivityEquipment = Literal[
"barbell",
"dumbbell",
"machine",
"cable",
"bodyweight",
"kettlebell",
"cardio_machine",
"other",
]
MeasurementType = Literal[
"weight_reps",
"reps_only",
"duration",
"distance_duration",
"duration_calories",
]
Difficulty = Literal["beginner", "intermediate", "advanced"]
class ActivitySourceCreate(BaseModel):
slug: str | None = Field(default=None, min_length=1, max_length=180)
kind: ActivityKind
title: str = Field(min_length=1, max_length=160)
description: str | None = None
category: ActivityCategory = "other"
equipment: ActivityEquipment = "other"
measurement_type: MeasurementType = "weight_reps"
difficulty: Difficulty = "intermediate"
image_s3_url: str | None = None
image_s3_key: str | None = None
default_calories_per_minute: float | None = None
class ActivitySourceRead(ActivitySourceCreate):
id: uuid.UUID
slug: str
owner_user_id: uuid.UUID | None
is_builtin: bool
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
class EquipmentCreate(BaseModel):
name: str = Field(min_length=1, max_length=160)
@@ -61,6 +118,7 @@ class WorkoutSetCreate(BaseModel):
weight: float = Field(default=0, ge=0)
reps: int = Field(default=0, ge=0)
duration_seconds: int | None = None
distance_km: float | None = Field(default=None, ge=0)
calories: float | None = None
completed_at: datetime | None = None
@@ -73,6 +131,7 @@ class WorkoutSetUpdate(BaseModel):
weight: float | None = Field(default=None, ge=0)
reps: int | None = Field(default=None, ge=0)
duration_seconds: int | None = None
distance_km: float | None = Field(default=None, ge=0)
calories: float | None = None
completed_at: datetime | None = None
@@ -93,6 +152,7 @@ class WorkoutSetRead(WorkoutSetCreate):
class WorkoutItemCreate(BaseModel):
activity_source_id: uuid.UUID | None = None
exercise_id: uuid.UUID | None = None
equipment_id: uuid.UUID | None = None
order_index: int | None = None
@@ -100,17 +160,21 @@ class WorkoutItemCreate(BaseModel):
@model_validator(mode="after")
def exactly_one_entity(self) -> WorkoutItemCreate:
if bool(self.exercise_id) == bool(self.equipment_id):
raise ValueError("Provide exactly one of exercise_id or equipment_id")
provided = [self.activity_source_id, self.exercise_id, self.equipment_id]
if sum(value is not None for value in provided) != 1:
raise ValueError("Provide exactly one activity source, exercise, or equipment id")
return self
class WorkoutItemRead(WorkoutItemCreate):
id: uuid.UUID
workout_id: uuid.UUID
source_kind: Literal["exercise", "equipment"]
source_kind: Literal["exercise", "machine", "equipment"]
title_snapshot: str
image_s3_url_snapshot: str | None
measurement_type_snapshot: MeasurementType
category_snapshot: ActivityCategory
equipment_snapshot: ActivityEquipment
order_index: int
created_at: datetime
sets: list[WorkoutSetRead] = []
@@ -0,0 +1,94 @@
[
{"slug":"bench_press","kind":"exercise","title":"Жим штанги лежа","category":"chest","equipment":"barbell","measurement_type":"weight_reps","difficulty":"intermediate","is_builtin":true,"asset_filename":"exercises/bench_press.jpg"},
{"slug":"incline_bench_press","kind":"exercise","title":"Жим штанги на наклонной скамье","category":"chest","equipment":"barbell","measurement_type":"weight_reps","difficulty":"intermediate","is_builtin":true,"asset_filename":"exercises/incline_bench_press.jpg"},
{"slug":"dumbbell_bench_press","kind":"exercise","title":"Жим гантелей лежа","category":"chest","equipment":"dumbbell","measurement_type":"weight_reps","difficulty":"intermediate","is_builtin":true,"asset_filename":"exercises/dumbbell_bench_press.webp"},
{"slug":"incline_dumbbell_press","kind":"exercise","title":"Жим гантелей на наклонной скамье","category":"chest","equipment":"dumbbell","measurement_type":"weight_reps","difficulty":"intermediate","is_builtin":true,"asset_filename":"exercises/incline_dumbbell_press.jpg"},
{"slug":"push_up","kind":"exercise","title":"Отжимания","category":"chest","equipment":"bodyweight","measurement_type":"weight_reps","difficulty":"beginner","is_builtin":true,"asset_filename":"exercises/push_up.jpg"},
{"slug":"dip_chest","kind":"exercise","title":"Отжимания на брусьях с акцентом на грудь","category":"chest","equipment":"bodyweight","measurement_type":"weight_reps","difficulty":"intermediate","is_builtin":true,"asset_filename":"exercises/dip_chest.jpg"},
{"slug":"dumbbell_fly","kind":"exercise","title":"Разводка гантелей лежа","category":"chest","equipment":"dumbbell","measurement_type":"weight_reps","difficulty":"beginner","is_builtin":true,"asset_filename":"exercises/dumbbell_fly.jpg"},
{"slug":"cable_fly","kind":"exercise","title":"Сведение рук в кроссовере","category":"chest","equipment":"cable","measurement_type":"weight_reps","difficulty":"beginner","is_builtin":true,"asset_filename":"exercises/cable_fly.webp"},
{"slug":"pull_up","kind":"exercise","title":"Подтягивания","category":"back","equipment":"bodyweight","measurement_type":"weight_reps","difficulty":"intermediate","is_builtin":true,"asset_filename":"exercises/pull_up.jpg"},
{"slug":"chin_up","kind":"exercise","title":"Подтягивания обратным хватом","category":"back","equipment":"bodyweight","measurement_type":"weight_reps","difficulty":"intermediate","is_builtin":true,"asset_filename":"exercises/chin_up.jpg"},
{"slug":"lat_pulldown","kind":"exercise","title":"Тяга верхнего блока","category":"back","equipment":"cable","measurement_type":"weight_reps","difficulty":"beginner","is_builtin":true,"asset_filename":"exercises/lat_pulldown.jpg"},
{"slug":"barbell_row","kind":"exercise","title":"Тяга штанги в наклоне","category":"back","equipment":"barbell","measurement_type":"weight_reps","difficulty":"intermediate","is_builtin":true,"asset_filename":"exercises/barbell_row.jpg"},
{"slug":"dumbbell_row","kind":"exercise","title":"Тяга гантели одной рукой","category":"back","equipment":"dumbbell","measurement_type":"weight_reps","difficulty":"beginner","is_builtin":true,"asset_filename":"exercises/dumbbell_row.jpg"},
{"slug":"seated_cable_row","kind":"exercise","title":"Горизонтальная тяга блока","category":"back","equipment":"cable","measurement_type":"weight_reps","difficulty":"beginner","is_builtin":true,"asset_filename":"exercises/seated_cable_row.jpg"},
{"slug":"t_bar_row","kind":"exercise","title":"Тяга T-грифа","category":"back","equipment":"machine","measurement_type":"weight_reps","difficulty":"intermediate","is_builtin":true,"asset_filename":"exercises/t_bar_row.png"},
{"slug":"deadlift","kind":"exercise","title":"Становая тяга","category":"back","equipment":"barbell","measurement_type":"weight_reps","difficulty":"advanced","is_builtin":true,"asset_filename":"exercises/deadlift.jpg"},
{"slug":"hyperextension","kind":"exercise","title":"Гиперэкстензия","category":"back","equipment":"bodyweight","measurement_type":"weight_reps","difficulty":"beginner","is_builtin":true,"asset_filename":"exercises/hyperextension.jpg"},
{"slug":"pullover","kind":"exercise","title":"Пуловер","category":"back","equipment":"dumbbell","measurement_type":"weight_reps","difficulty":"intermediate","is_builtin":true,"asset_filename":"exercises/pullover.jpg"},
{"slug":"squat","kind":"exercise","title":"Приседания со штангой","category":"legs","equipment":"barbell","measurement_type":"weight_reps","difficulty":"intermediate","is_builtin":true,"asset_filename":"exercises/squat.webp"},
{"slug":"front_squat","kind":"exercise","title":"Фронтальные приседания","category":"legs","equipment":"barbell","measurement_type":"weight_reps","difficulty":"advanced","is_builtin":true,"asset_filename":"exercises/front_squat.png"},
{"slug":"goblet_squat","kind":"exercise","title":"Гоблет-присед","category":"legs","equipment":"dumbbell","measurement_type":"weight_reps","difficulty":"beginner","is_builtin":true,"asset_filename":"exercises/goblet_squat.png"},
{"slug":"leg_press","kind":"exercise","title":"Жим ногами","category":"legs","equipment":"machine","measurement_type":"weight_reps","difficulty":"beginner","is_builtin":true,"asset_filename":"exercises/leg_press.jpg"},
{"slug":"lunge","kind":"exercise","title":"Выпады","category":"legs","equipment":"dumbbell","measurement_type":"weight_reps","difficulty":"beginner","is_builtin":true,"asset_filename":"exercises/lunge.png"},
{"slug":"bulgarian_split_squat","kind":"exercise","title":"Болгарские сплит-приседы","category":"legs","equipment":"dumbbell","measurement_type":"weight_reps","difficulty":"intermediate","is_builtin":true,"asset_filename":"exercises/bulgarian_split_squat.jpg"},
{"slug":"romanian_deadlift","kind":"exercise","title":"Румынская тяга","category":"legs","equipment":"barbell","measurement_type":"weight_reps","difficulty":"intermediate","is_builtin":true,"asset_filename":"exercises/romanian_deadlift.jpg"},
{"slug":"leg_extension","kind":"exercise","title":"Разгибание ног","category":"legs","equipment":"machine","measurement_type":"weight_reps","difficulty":"beginner","is_builtin":true,"asset_filename":"exercises/leg_extension.webp"},
{"slug":"leg_curl","kind":"exercise","title":"Сгибание ног","category":"legs","equipment":"machine","measurement_type":"weight_reps","difficulty":"beginner","is_builtin":true,"asset_filename":"exercises/leg_curl.jpg"},
{"slug":"standing_calf_raise","kind":"exercise","title":"Подъемы на икры стоя","category":"legs","equipment":"machine","measurement_type":"weight_reps","difficulty":"beginner","is_builtin":true,"asset_filename":"exercises/standing_calf_raise.webp"},
{"slug":"seated_calf_raise","kind":"exercise","title":"Подъемы на икры сидя","category":"legs","equipment":"machine","measurement_type":"weight_reps","difficulty":"beginner","is_builtin":true,"asset_filename":"exercises/seated_calf_raise.webp"},
{"slug":"hip_thrust","kind":"exercise","title":"Ягодичный мост / hip thrust","category":"legs","equipment":"barbell","measurement_type":"weight_reps","difficulty":"intermediate","is_builtin":true,"asset_filename":"exercises/hip_thrust.jpg"},
{"slug":"overhead_press","kind":"exercise","title":"Жим штанги стоя","category":"shoulders","equipment":"barbell","measurement_type":"weight_reps","difficulty":"intermediate","is_builtin":true,"asset_filename":"exercises/overhead_press.png"},
{"slug":"seated_dumbbell_press","kind":"exercise","title":"Жим гантелей сидя","category":"shoulders","equipment":"dumbbell","measurement_type":"weight_reps","difficulty":"beginner","is_builtin":true,"asset_filename":"exercises/seated_dumbbell_press.jpg"},
{"slug":"arnold_press","kind":"exercise","title":"Жим Арнольда","category":"shoulders","equipment":"dumbbell","measurement_type":"weight_reps","difficulty":"intermediate","is_builtin":true,"asset_filename":"exercises/arnold_press.webp"},
{"slug":"lateral_raise","kind":"exercise","title":"Махи гантелями в стороны","category":"shoulders","equipment":"dumbbell","measurement_type":"weight_reps","difficulty":"beginner","is_builtin":true,"asset_filename":"exercises/lateral_raise.jpg"},
{"slug":"front_raise","kind":"exercise","title":"Подъем гантелей перед собой","category":"shoulders","equipment":"dumbbell","measurement_type":"weight_reps","difficulty":"beginner","is_builtin":true,"asset_filename":"exercises/front_raise.webp"},
{"slug":"rear_delt_fly","kind":"exercise","title":"Разводка на заднюю дельту","category":"shoulders","equipment":"dumbbell","measurement_type":"weight_reps","difficulty":"beginner","is_builtin":true,"asset_filename":"exercises/rear_delt_fly.jpg"},
{"slug":"face_pull","kind":"exercise","title":"Face pull","category":"shoulders","equipment":"cable","measurement_type":"weight_reps","difficulty":"beginner","is_builtin":true,"asset_filename":"exercises/face_pull.webp"},
{"slug":"upright_row","kind":"exercise","title":"Тяга штанги к подбородку","category":"shoulders","equipment":"barbell","measurement_type":"weight_reps","difficulty":"intermediate","is_builtin":true,"asset_filename":"exercises/upright_row.png"},
{"slug":"shrug","kind":"exercise","title":"Шраги","category":"shoulders","equipment":"dumbbell","measurement_type":"weight_reps","difficulty":"beginner","is_builtin":true,"asset_filename":"exercises/shrug.webp"},
{"slug":"barbell_curl","kind":"exercise","title":"Подъем штанги на бицепс","category":"biceps","equipment":"barbell","measurement_type":"weight_reps","difficulty":"beginner","is_builtin":true,"asset_filename":"exercises/barbell_curl.jpg"},
{"slug":"dumbbell_curl","kind":"exercise","title":"Подъем гантелей на бицепс","category":"biceps","equipment":"dumbbell","measurement_type":"weight_reps","difficulty":"beginner","is_builtin":true,"asset_filename":"exercises/dumbbell_curl.jpg"},
{"slug":"hammer_curl","kind":"exercise","title":"Молотковые сгибания","category":"biceps","equipment":"dumbbell","measurement_type":"weight_reps","difficulty":"beginner","is_builtin":true,"asset_filename":"exercises/hammer_curl.jpg"},
{"slug":"preacher_curl","kind":"exercise","title":"Сгибания на скамье Скотта","category":"biceps","equipment":"barbell","measurement_type":"weight_reps","difficulty":"intermediate","is_builtin":true,"asset_filename":"exercises/preacher_curl.jpg"},
{"slug":"concentration_curl","kind":"exercise","title":"Концентрированные сгибания","category":"biceps","equipment":"dumbbell","measurement_type":"weight_reps","difficulty":"beginner","is_builtin":true,"asset_filename":"exercises/concentration_curl.webp"},
{"slug":"cable_curl","kind":"exercise","title":"Сгибание рук в блоке","category":"biceps","equipment":"cable","measurement_type":"weight_reps","difficulty":"beginner","is_builtin":true,"asset_filename":"exercises/cable_curl.webp"},
{"slug":"close_grip_bench_press","kind":"exercise","title":"Жим лежа узким хватом","category":"triceps","equipment":"barbell","measurement_type":"weight_reps","difficulty":"intermediate","is_builtin":true,"asset_filename":"exercises/close_grip_bench_press.webp"},
{"slug":"skullcrusher","kind":"exercise","title":"Французский жим лежа","category":"triceps","equipment":"barbell","measurement_type":"weight_reps","difficulty":"intermediate","is_builtin":true,"asset_filename":"exercises/skullcrusher.png"},
{"slug":"overhead_triceps_extension","kind":"exercise","title":"Разгибание рук из-за головы","category":"triceps","equipment":"dumbbell","measurement_type":"weight_reps","difficulty":"beginner","is_builtin":true,"asset_filename":"exercises/overhead_triceps_extension.jpg"},
{"slug":"triceps_pushdown","kind":"exercise","title":"Разгибание рук на блоке","category":"triceps","equipment":"cable","measurement_type":"weight_reps","difficulty":"beginner","is_builtin":true,"asset_filename":"exercises/triceps_pushdown.jpg"},
{"slug":"rope_pushdown","kind":"exercise","title":"Разгибание рук с канатом","category":"triceps","equipment":"cable","measurement_type":"weight_reps","difficulty":"beginner","is_builtin":true,"asset_filename":"exercises/rope_pushdown.jpg"},
{"slug":"bench_dip","kind":"exercise","title":"Обратные отжимания от скамьи","category":"triceps","equipment":"bodyweight","measurement_type":"weight_reps","difficulty":"beginner","is_builtin":true,"asset_filename":"exercises/bench_dip.webp"},
{"slug":"crunch","kind":"exercise","title":"Скручивания","category":"core","equipment":"bodyweight","measurement_type":"reps_only","difficulty":"beginner","is_builtin":true,"asset_filename":"exercises/crunch.webp"},
{"slug":"sit_up","kind":"exercise","title":"Подъем корпуса","category":"core","equipment":"bodyweight","measurement_type":"reps_only","difficulty":"beginner","is_builtin":true,"asset_filename":"exercises/sit_up.png"},
{"slug":"hanging_leg_raise","kind":"exercise","title":"Подъем ног в висе","category":"core","equipment":"bodyweight","measurement_type":"reps_only","difficulty":"intermediate","is_builtin":true,"asset_filename":"exercises/hanging_leg_raise.webp"},
{"slug":"lying_leg_raise","kind":"exercise","title":"Подъем ног лежа","category":"core","equipment":"bodyweight","measurement_type":"reps_only","difficulty":"beginner","is_builtin":true,"asset_filename":"exercises/lying_leg_raise.jpg"},
{"slug":"plank","kind":"exercise","title":"Планка","category":"core","equipment":"bodyweight","measurement_type":"duration","difficulty":"beginner","is_builtin":true,"asset_filename":"exercises/plank.jpg"},
{"slug":"russian_twist","kind":"exercise","title":"Русские скручивания","category":"core","equipment":"bodyweight","measurement_type":"reps_only","difficulty":"beginner","is_builtin":true,"asset_filename":"exercises/russian_twist.avif"},
{"slug":"cable_crunch","kind":"exercise","title":"Скручивания на блоке","category":"core","equipment":"cable","measurement_type":"weight_reps","difficulty":"beginner","is_builtin":true,"asset_filename":"exercises/cable_crunch.avif"},
{"slug":"ab_wheel_rollout","kind":"exercise","title":"Ролик для пресса","category":"core","equipment":"other","measurement_type":"reps_only","difficulty":"intermediate","is_builtin":true,"asset_filename":"exercises/ab_wheel_rollout.jpg"},
{"slug":"treadmill_running","kind":"exercise","title":"Бег на дорожке","category":"cardio","equipment":"cardio_machine","measurement_type":"distance_duration","difficulty":"beginner","is_builtin":true,"asset_filename":"exercises/treadmill_running.jpg","default_calories_per_minute":10},
{"slug":"treadmill_walking","kind":"exercise","title":"Ходьба на дорожке","category":"cardio","equipment":"cardio_machine","measurement_type":"distance_duration","difficulty":"beginner","is_builtin":true,"asset_filename":"exercises/treadmill_walking.jpg","default_calories_per_minute":5},
{"slug":"cycling","kind":"exercise","title":"Велотренажер","category":"cardio","equipment":"cardio_machine","measurement_type":"distance_duration","difficulty":"beginner","is_builtin":true,"asset_filename":"exercises/cycling.png","default_calories_per_minute":8},
{"slug":"rowing","kind":"exercise","title":"Гребля","category":"cardio","equipment":"cardio_machine","measurement_type":"distance_duration","difficulty":"beginner","is_builtin":true,"asset_filename":"exercises/rowing.webp","default_calories_per_minute":9},
{"slug":"elliptical","kind":"exercise","title":"Эллипсоид","category":"cardio","equipment":"cardio_machine","measurement_type":"distance_duration","difficulty":"beginner","is_builtin":true,"asset_filename":"exercises/elliptical.jpg","default_calories_per_minute":7},
{"slug":"stair_climber","kind":"exercise","title":"Степпер / лестница","category":"cardio","equipment":"cardio_machine","measurement_type":"duration_calories","difficulty":"beginner","is_builtin":true,"asset_filename":"exercises/stair_climber.jpg","default_calories_per_minute":8},
{"slug":"jump_rope","kind":"exercise","title":"Скакалка","category":"cardio","equipment":"other","measurement_type":"duration_calories","difficulty":"intermediate","is_builtin":true,"asset_filename":"exercises/jump_rope.avif","default_calories_per_minute":11},
{"slug":"burpee","kind":"exercise","title":"Берпи","category":"cardio","equipment":"bodyweight","measurement_type":"reps_only","difficulty":"intermediate","is_builtin":true,"asset_filename":"exercises/burpee.jpg"},
{"slug":"smith_machine","kind":"machine","title":"Машина Смита","category":"full_body","equipment":"machine","measurement_type":"weight_reps","difficulty":"beginner","is_builtin":true,"asset_filename":"machines/smith_machine.webp"},
{"slug":"leg_press_machine","kind":"machine","title":"Тренажер жим ногами","category":"legs","equipment":"machine","measurement_type":"weight_reps","difficulty":"beginner","is_builtin":true,"asset_filename":"machines/leg_press_machine.webp"},
{"slug":"hack_squat_machine","kind":"machine","title":"Гакк-машина","category":"legs","equipment":"machine","measurement_type":"weight_reps","difficulty":"beginner","is_builtin":true,"asset_filename":"machines/hack_squat_machine.jpg"},
{"slug":"leg_extension_machine","kind":"machine","title":"Тренажер разгибания ног","category":"legs","equipment":"machine","measurement_type":"weight_reps","difficulty":"beginner","is_builtin":true,"asset_filename":"machines/leg_extension_machine.jpg"},
{"slug":"leg_curl_machine","kind":"machine","title":"Тренажер сгибания ног","category":"legs","equipment":"machine","measurement_type":"weight_reps","difficulty":"beginner","is_builtin":true,"asset_filename":"machines/leg_curl_machine.avif"},
{"slug":"seated_calf_raise_machine","kind":"machine","title":"Тренажер икры сидя","category":"legs","equipment":"machine","measurement_type":"weight_reps","difficulty":"beginner","is_builtin":true,"asset_filename":"machines/seated_calf_raise_machine.jpg"},
{"slug":"chest_press_machine","kind":"machine","title":"Тренажер жим от груди","category":"chest","equipment":"machine","measurement_type":"weight_reps","difficulty":"beginner","is_builtin":true,"asset_filename":"machines/chest_press_machine.webp"},
{"slug":"pec_deck_machine","kind":"machine","title":"Пек-дек / бабочка","category":"chest","equipment":"machine","measurement_type":"weight_reps","difficulty":"beginner","is_builtin":true,"asset_filename":"machines/pec_deck_machine.jpg"},
{"slug":"shoulder_press_machine","kind":"machine","title":"Тренажер жим на плечи","category":"shoulders","equipment":"machine","measurement_type":"weight_reps","difficulty":"beginner","is_builtin":true,"asset_filename":"machines/shoulder_press_machine.avif"},
{"slug":"lat_pulldown_machine","kind":"machine","title":"Верхний блок","category":"back","equipment":"machine","measurement_type":"weight_reps","difficulty":"beginner","is_builtin":true,"asset_filename":"machines/lat_pulldown_machine.avif"},
{"slug":"seated_row_machine","kind":"machine","title":"Горизонтальная тяга","category":"back","equipment":"machine","measurement_type":"weight_reps","difficulty":"beginner","is_builtin":true,"asset_filename":"machines/seated_row_machine.jpg"},
{"slug":"assisted_pullup_machine","kind":"machine","title":"Гравитрон","category":"back","equipment":"machine","measurement_type":"weight_reps","difficulty":"beginner","is_builtin":true,"asset_filename":"machines/assisted_pullup_machine.png"},
{"slug":"cable_crossover_machine","kind":"machine","title":"Кроссовер","category":"full_body","equipment":"cable","measurement_type":"weight_reps","difficulty":"beginner","is_builtin":true,"asset_filename":"machines/cable_crossover_machine.jpeg"},
{"slug":"preacher_curl_machine","kind":"machine","title":"Скамья Скотта / тренажер бицепс","category":"biceps","equipment":"machine","measurement_type":"weight_reps","difficulty":"beginner","is_builtin":true,"asset_filename":"machines/preacher_curl_machine.jpg"},
{"slug":"triceps_extension_machine","kind":"machine","title":"Тренажер трицепс","category":"triceps","equipment":"machine","measurement_type":"weight_reps","difficulty":"beginner","is_builtin":true,"asset_filename":"machines/triceps_extension_machine.jpg"},
{"slug":"ab_crunch_machine","kind":"machine","title":"Тренажер пресс","category":"core","equipment":"machine","measurement_type":"weight_reps","difficulty":"beginner","is_builtin":true,"asset_filename":"machines/ab_crunch_machine.jpg"},
{"slug":"back_extension_bench","kind":"machine","title":"Римский стул / гиперэкстензия","category":"back","equipment":"machine","measurement_type":"weight_reps","difficulty":"beginner","is_builtin":true,"asset_filename":"machines/back_extension_bench.webp"},
{"slug":"glute_kickback_machine","kind":"machine","title":"Тренажер отведение ноги назад","category":"legs","equipment":"machine","measurement_type":"weight_reps","difficulty":"beginner","is_builtin":true,"asset_filename":"machines/glute_kickback_machine.jpg"},
{"slug":"hip_abductor_machine","kind":"machine","title":"Тренажер отведение бедра","category":"legs","equipment":"machine","measurement_type":"weight_reps","difficulty":"beginner","is_builtin":true,"asset_filename":"machines/hip_abductor_machine.avif"},
{"slug":"hip_adductor_machine","kind":"machine","title":"Тренажер приведение бедра","category":"legs","equipment":"machine","measurement_type":"weight_reps","difficulty":"beginner","is_builtin":true,"asset_filename":"machines/hip_adductor_machine.png"},
{"slug":"treadmill","kind":"machine","title":"Беговая дорожка","category":"cardio","equipment":"cardio_machine","measurement_type":"distance_duration","difficulty":"beginner","is_builtin":true,"asset_filename":"machines/treadmill.jpg","default_calories_per_minute":10},
{"slug":"stationary_bike","kind":"machine","title":"Велотренажер","category":"cardio","equipment":"cardio_machine","measurement_type":"distance_duration","difficulty":"beginner","is_builtin":true,"asset_filename":"machines/stationary_bike.png","default_calories_per_minute":8},
{"slug":"rowing_machine","kind":"machine","title":"Гребной тренажер","category":"cardio","equipment":"cardio_machine","measurement_type":"distance_duration","difficulty":"beginner","is_builtin":true,"asset_filename":"machines/rowing_machine.webp","default_calories_per_minute":9},
{"slug":"elliptical_machine","kind":"machine","title":"Эллиптический тренажер","category":"cardio","equipment":"cardio_machine","measurement_type":"distance_duration","difficulty":"beginner","is_builtin":true,"asset_filename":"machines/elliptical_machine.jpg","default_calories_per_minute":7},
{"slug":"stair_climber_machine","kind":"machine","title":"Лестничный тренажер","category":"cardio","equipment":"cardio_machine","measurement_type":"duration_calories","difficulty":"beginner","is_builtin":true,"asset_filename":"machines/stair_climber_machine.jpg","default_calories_per_minute":8}
]
+1
View File
@@ -4,6 +4,7 @@ version = "0.1.0"
requires-python = ">=3.14"
dependencies = [
"alembic>=1.16.0",
"boto3>=1.38.23",
"fastapi[standard]>=0.115.12",
"psycopg[binary]>=3.2.9",
"pydantic-settings>=2.9.1",
+2
View File
@@ -992,6 +992,7 @@ version = "0.1.0"
source = { virtual = "logic" }
dependencies = [
{ name = "alembic" },
{ name = "boto3" },
{ name = "fastapi", extra = ["standard"] },
{ name = "psycopg", extra = ["binary"] },
{ name = "pydantic-settings" },
@@ -1001,6 +1002,7 @@ dependencies = [
[package.metadata]
requires-dist = [
{ name = "alembic", specifier = ">=1.16.0" },
{ name = "boto3", specifier = ">=1.38.23" },
{ name = "fastapi", extras = ["standard"], specifier = ">=0.115.12" },
{ name = "psycopg", extras = ["binary"], specifier = ">=3.2.9" },
{ name = "pydantic-settings", specifier = ">=2.9.1" },
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 303 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 310 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 382 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 333 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 388 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Some files were not shown because too many files have changed in this diff Show More