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.
@@ -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,
|
||||
|
||||
@@ -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 ?? "нет"} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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}
|
||||
]
|
||||
@@ -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",
|
||||
|
||||
@@ -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" },
|
||||
|
||||
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 137 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 246 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 5.0 MiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 107 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 303 KiB |
|
After Width: | Height: | Size: 169 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 201 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 310 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 382 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 333 KiB |
|
After Width: | Height: | Size: 159 KiB |
|
After Width: | Height: | Size: 388 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 28 KiB |