diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c6721e2..4bb3cd3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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: () => }); const catalogExercisesRoute = createRoute({ getParentRoute: () => rootRoute, path: "/catalog/exercises", component: () => }); -const catalogEquipmentRoute = createRoute({ getParentRoute: () => rootRoute, path: "/catalog/equipment", component: () => }); +const catalogEquipmentRoute = createRoute({ getParentRoute: () => rootRoute, path: "/catalog/equipment", component: () => }); 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, diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 8c8b122..425c510 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1,5 +1,6 @@ import type { Calories, + CatalogCreateInput, CatalogEntity, CatalogKind, Progression, @@ -72,6 +73,14 @@ export const api = { exercises(token: string) { return request("/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(`/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) { return request("/catalog/exercises", { method: "POST", body: JSON.stringify(payload) }, token); }, + createActivitySource(token: string, payload: CatalogCreateInput) { + return request("/catalog/activity-sources", { method: "POST", body: JSON.stringify(payload) }, token); + }, workouts(token: string) { return request("/workouts", {}, token); }, @@ -111,7 +123,7 @@ export const api = { discardWorkout(token: string, workoutId: string) { return request(`/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(`/workouts/${workoutId}/items`, { method: "POST", body: JSON.stringify(payload) }, token); }, addWorkoutSet(token: string, itemId: string, payload: WorkoutSetInput) { diff --git a/frontend/src/features/analytics/AnalyticsPage.tsx b/frontend/src/features/analytics/AnalyticsPage.tsx index b5c1789..dfc46b3 100644 --- a/frontend/src/features/analytics/AnalyticsPage.tsx +++ b/frontend/src/features/analytics/AnalyticsPage.tsx @@ -32,7 +32,7 @@ export function AnalyticsPage() { Упражнение diff --git a/frontend/src/features/catalog/CatalogCard.tsx b/frontend/src/features/catalog/CatalogCard.tsx index 16404a0..a48737c 100644 --- a/frontend/src/features/catalog/CatalogCard.tsx +++ b/frontend/src/features/catalog/CatalogCard.tsx @@ -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 (
{entity.image_s3_url ? :
TW
}
- {entity.is_builtin ? "стандартное" : "мое"} -

{entity.name}

+
+ {entity.is_builtin ? "стандартное" : "мое"} + {entity.kind === "exercise" ? "упражнение" : "тренажер"} +
+

{entity.title}

+ {categoryLabels[entity.category]} · {equipmentLabels[entity.equipment]}

{entity.description || "Без описания"}

+
+ {measurementLabels[entity.measurement_type]} + {difficultyLabels[entity.difficulty]} +
{action}
diff --git a/frontend/src/features/catalog/CatalogPage.tsx b/frontend/src/features/catalog/CatalogPage.tsx index 7f39374..85e0e86 100644 --- a/frontend/src/features/catalog/CatalogPage.tsx +++ b/frontend/src/features/catalog/CatalogPage.tsx @@ -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 = ["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("other"); + const [equipment, setEquipment] = useState("other"); + const [measurementType, setMeasurementType] = useState("weight_reps"); + const [difficulty, setDifficulty] = useState("intermediate"); const [file, setFile] = useState(null); + const [search, setSearch] = useState(""); + const [categoryFilter, setCategoryFilter] = useState("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 }) {

Catalog

-

{kind === "exercise" ? "Упражнения" : "Тренажеры"}

+

{kind === "machine" ? "Тренажеры" : kind === "mine" ? "Мой каталог" : "Упражнения"}

Упражнения Тренажеры + Мои
-
+
+ setSearch(event.target.value)} /> +
+ {categoryTabs.map((tab) => ( + + ))} +
+
+ + + + + + {createMutation.error &&

{createMutation.error.message}

} @@ -72,6 +147,7 @@ export function CatalogPage({ kind }: { kind: CatalogKind }) {
{list.map((entity) => )}
+ {list.length === 0 &&

Ничего не найдено.

} ); } diff --git a/frontend/src/features/catalog/hooks.ts b/frontend/src/features/catalog/hooks.ts index 6b55ccc..8764103 100644 --- a/frontend/src/features/catalog/hooks.ts +++ b/frontend/src/features/catalog/hooks.ts @@ -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 }; } diff --git a/frontend/src/features/catalog/meta.ts b/frontend/src/features/catalog/meta.ts new file mode 100644 index 0000000..ed23345 --- /dev/null +++ b/frontend/src/features/catalog/meta.ts @@ -0,0 +1,44 @@ +import type { ActivityCategory, ActivityEquipment, Difficulty, MeasurementType } from "../../types"; + +export const categoryLabels: Record = { + chest: "Грудь", + back: "Спина", + legs: "Ноги", + shoulders: "Плечи", + biceps: "Бицепс", + triceps: "Трицепс", + core: "Пресс", + cardio: "Кардио", + full_body: "Все тело", + other: "Другое", +}; + +export const equipmentLabels: Record = { + barbell: "Штанга", + dumbbell: "Гантели", + machine: "Тренажер", + cable: "Блок", + bodyweight: "Свой вес", + kettlebell: "Гиря", + cardio_machine: "Кардио", + other: "Другое", +}; + +export const measurementLabels: Record = { + weight_reps: "Вес × повторы", + reps_only: "Повторы", + duration: "Время", + distance_duration: "Дистанция + время", + duration_calories: "Время + ккал", +}; + +export const difficultyLabels: Record = { + 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[]; diff --git a/frontend/src/features/history/WorkoutDetailPage.tsx b/frontend/src/features/history/WorkoutDetailPage.tsx index ddbcead..95ff093 100644 --- a/frontend/src/features/history/WorkoutDetailPage.tsx +++ b/frontend/src/features/history/WorkoutDetailPage.tsx @@ -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) => (
{set.set_index} - {set.weight} кг × {set.reps} - {set.calories ? Math.round(set.calories) : "—"} ккал + {formatSet(set, item.measurement_type_snapshot)} + {set.calories !== null && set.calories !== undefined ? Math.round(set.calories) : "—"} ккал
))} diff --git a/frontend/src/features/workout/AddExerciseDrawer.tsx b/frontend/src/features/workout/AddExerciseDrawer.tsx index 452c66c..253b924 100644 --- a/frontend/src/features/workout/AddExerciseDrawer.tsx +++ b/frontend/src/features/workout/AddExerciseDrawer.tsx @@ -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 = ["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("exercise"); const [search, setSearch] = useState(""); - const { exercises, equipment } = useCatalog(token); + const [category, setCategory] = useState("all"); + const { exercises, machines } = useCatalog(token); const addedIds = useMemo(() => { const ids = new Set(); 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({ setSearch(event.target.value)} />
- + +
+
+ {categoryTabs.map((tab) => ( + + ))}
@@ -61,10 +82,10 @@ export function AddExerciseDrawer({ const added = addedIds.has(entity.id); return (
onAdd(entity, kind, true)}> - {entity.image_s3_url ? : {kind === "exercise" ? "EX" : "EQ"}} + {entity.image_s3_url ? : {kind === "exercise" ? "EX" : "MC"}}
-

{entity.name}

-

{entity.description || "Без описания"}

+

{entity.title}

+

{categoryLabels[entity.category]} · {measurementLabels[entity.measurement_type]}

{added && В тренировке}
{item.sets.length} подх. - {Math.round(volume)} кг объема - рек. {Math.max(weight, item.planned_working_weight ?? weight)} × {reps} + {volumeLabel} + {measurementLabels[measurementType]}
@@ -85,6 +102,7 @@ export function WorkoutExerciseCard({ onRemoveSet(set.id)} onUpdate={(payload) => onUpdateSet(set.id, payload)} @@ -92,24 +110,33 @@ export function WorkoutExerciseCard({ ))}
-
-
- Вес -
- - setWeight(Number(event.target.value))} /> - +
+ {measurementType === "weight_reps" && ( +
+ Вес +
+ + setWeight(Number(event.target.value))} /> + +
-
-
- Повторы -
- - setReps(Number(event.target.value))} /> - + )} + {(measurementType === "weight_reps" || measurementType === "reps_only") && ( +
+ Повторы +
+ + setReps(Number(event.target.value))} /> + +
-
-
@@ -124,9 +151,8 @@ export function WorkoutExerciseCard({

Добавить несколько подходов

- -
+

Будет записан текущий шаблон: {measurementLabels[measurementType]}.

diff --git a/frontend/src/features/workout/WorkoutSetRow.tsx b/frontend/src/features/workout/WorkoutSetRow.tsx index 6a7f037..d2e7d77 100644 --- a/frontend/src/features/workout/WorkoutSetRow.tsx +++ b/frontend/src/features/workout/WorkoutSetRow.tsx @@ -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) => 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 ( -
+
{set.set_index} - setWeight(Number(event.target.value))} /> - setReps(Number(event.target.value))} /> + {(measurementType === "weight_reps") && setWeight(Number(event.target.value))} />} + {(measurementType === "weight_reps" || measurementType === "reps_only") && setReps(Number(event.target.value))} />} + {(measurementType === "duration" || measurementType === "distance_duration" || measurementType === "duration_calories") && setDurationSeconds(Number(event.target.value))} />} + {measurementType === "distance_duration" && setDistanceKm(Number(event.target.value))} />} + {measurementType === "duration_calories" && setCalories(Number(event.target.value))} />}
- +
@@ -35,9 +53,9 @@ export function WorkoutSetRow({
{set.set_index} - {set.calories ? Math.round(set.calories) : "—"} ккал + {set.calories !== null && set.calories !== undefined ? Math.round(set.calories) : "—"} ккал
); diff --git a/frontend/src/features/workout/hooks.ts b/frontend/src/features/workout/hooks.ts index 05339ec..2f41340 100644 --- a/frontend/src/features/workout/hooks.ts +++ b/frontend/src/features/workout/hooks.ts @@ -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, }); diff --git a/frontend/src/features/workout/setFormat.ts b/frontend/src/features/workout/setFormat.ts new file mode 100644 index 0000000..a700158 --- /dev/null +++ b/frontend/src/features/workout/setFormat.ts @@ -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; +} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index afac5e4..938bc95 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -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; } diff --git a/frontend/src/types.ts b/frontend/src/types.ts index d97a6cf..714cf55 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -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; }; diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml index 818635c..09fa982 100644 --- a/infra/docker-compose.yml +++ b/infra/docker-compose.yml @@ -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 diff --git a/services/bff/app/main.py b/services/bff/app/main.py index 6ab7ae0..4139ac1 100644 --- a/services/bff/app/main.py +++ b/services/bff/app/main.py @@ -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} diff --git a/services/bff/app/s3.py b/services/bff/app/s3.py index ef3e597..323bb55 100644 --- a/services/bff/app/s3.py +++ b/services/bff/app/s3.py @@ -10,6 +10,7 @@ ALLOWED_CONTENT_TYPES = { "image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp", + "image/avif": ".avif", } diff --git a/services/logic/alembic/versions/0003_activity_sources.py b/services/logic/alembic/versions/0003_activity_sources.py new file mode 100644 index 0000000..1e3b7bb --- /dev/null +++ b/services/logic/alembic/versions/0003_activity_sources.py @@ -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") diff --git a/services/logic/app/core.py b/services/logic/app/core.py index cc9f29b..5d338f7 100644 --- a/services/logic/app/core.py +++ b/services/logic/app/core.py @@ -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") diff --git a/services/logic/app/db.py b/services/logic/app/db.py index 5300ee3..2de5b66 100644 --- a/services/logic/app/db.py +++ b/services/logic/app/db.py @@ -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: diff --git a/services/logic/app/main.py b/services/logic/app/main.py index 52468bc..202b28e 100644 --- a/services/logic/app/main.py +++ b/services/logic/app/main.py @@ -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], + response_model=list[ActivitySourceRead], ) -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 - ) +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}) diff --git a/services/logic/app/models.py b/services/logic/app/models.py index e7322d8..b2842b3 100644 --- a/services/logic/app/models.py +++ b/services/logic/app/models.py @@ -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) diff --git a/services/logic/app/schemas.py b/services/logic/app/schemas.py index 53efe21..606cd99 100644 --- a/services/logic/app/schemas.py +++ b/services/logic/app/schemas.py @@ -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] = [] diff --git a/services/logic/app/seeds/activity_sources.json b/services/logic/app/seeds/activity_sources.json new file mode 100644 index 0000000..a495986 --- /dev/null +++ b/services/logic/app/seeds/activity_sources.json @@ -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} +] diff --git a/services/logic/pyproject.toml b/services/logic/pyproject.toml index 724ea55..820f949 100644 --- a/services/logic/pyproject.toml +++ b/services/logic/pyproject.toml @@ -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", diff --git a/services/uv.lock b/services/uv.lock index 2817e3e..f0ce619 100644 --- a/services/uv.lock +++ b/services/uv.lock @@ -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" }, diff --git a/workout_assets/exercises/ab_wheel_rollout.jpg b/workout_assets/exercises/ab_wheel_rollout.jpg new file mode 100644 index 0000000..f10b1be Binary files /dev/null and b/workout_assets/exercises/ab_wheel_rollout.jpg differ diff --git a/workout_assets/exercises/arnold_press.webp b/workout_assets/exercises/arnold_press.webp new file mode 100644 index 0000000..b345996 Binary files /dev/null and b/workout_assets/exercises/arnold_press.webp differ diff --git a/workout_assets/exercises/barbell_curl.jpg b/workout_assets/exercises/barbell_curl.jpg new file mode 100644 index 0000000..95e1668 Binary files /dev/null and b/workout_assets/exercises/barbell_curl.jpg differ diff --git a/workout_assets/exercises/barbell_row.jpg b/workout_assets/exercises/barbell_row.jpg new file mode 100644 index 0000000..d329b37 Binary files /dev/null and b/workout_assets/exercises/barbell_row.jpg differ diff --git a/workout_assets/exercises/bench_dip.webp b/workout_assets/exercises/bench_dip.webp new file mode 100644 index 0000000..3eb5f4c Binary files /dev/null and b/workout_assets/exercises/bench_dip.webp differ diff --git a/workout_assets/exercises/bench_press.jpg b/workout_assets/exercises/bench_press.jpg new file mode 100644 index 0000000..5245764 Binary files /dev/null and b/workout_assets/exercises/bench_press.jpg differ diff --git a/workout_assets/exercises/bulgarian_split_squat.jpg b/workout_assets/exercises/bulgarian_split_squat.jpg new file mode 100644 index 0000000..60c418d Binary files /dev/null and b/workout_assets/exercises/bulgarian_split_squat.jpg differ diff --git a/workout_assets/exercises/burpee.jpg b/workout_assets/exercises/burpee.jpg new file mode 100644 index 0000000..4b77a59 Binary files /dev/null and b/workout_assets/exercises/burpee.jpg differ diff --git a/workout_assets/exercises/cable_crunch.avif b/workout_assets/exercises/cable_crunch.avif new file mode 100644 index 0000000..661e43e Binary files /dev/null and b/workout_assets/exercises/cable_crunch.avif differ diff --git a/workout_assets/exercises/cable_curl.webp b/workout_assets/exercises/cable_curl.webp new file mode 100644 index 0000000..60a217f Binary files /dev/null and b/workout_assets/exercises/cable_curl.webp differ diff --git a/workout_assets/exercises/cable_fly.webp b/workout_assets/exercises/cable_fly.webp new file mode 100644 index 0000000..044bb52 Binary files /dev/null and b/workout_assets/exercises/cable_fly.webp differ diff --git a/workout_assets/exercises/chin_up.jpg b/workout_assets/exercises/chin_up.jpg new file mode 100644 index 0000000..a7d0d89 Binary files /dev/null and b/workout_assets/exercises/chin_up.jpg differ diff --git a/workout_assets/exercises/close_grip_bench_press.webp b/workout_assets/exercises/close_grip_bench_press.webp new file mode 100644 index 0000000..973ff48 Binary files /dev/null and b/workout_assets/exercises/close_grip_bench_press.webp differ diff --git a/workout_assets/exercises/concentration_curl.webp b/workout_assets/exercises/concentration_curl.webp new file mode 100644 index 0000000..d3b9f03 Binary files /dev/null and b/workout_assets/exercises/concentration_curl.webp differ diff --git a/workout_assets/exercises/crunch.webp b/workout_assets/exercises/crunch.webp new file mode 100644 index 0000000..cb3fdeb Binary files /dev/null and b/workout_assets/exercises/crunch.webp differ diff --git a/workout_assets/exercises/cycling.png b/workout_assets/exercises/cycling.png new file mode 100644 index 0000000..40e59ac Binary files /dev/null and b/workout_assets/exercises/cycling.png differ diff --git a/workout_assets/exercises/deadlift.jpg b/workout_assets/exercises/deadlift.jpg new file mode 100644 index 0000000..f06bdf8 Binary files /dev/null and b/workout_assets/exercises/deadlift.jpg differ diff --git a/workout_assets/exercises/dip_chest.jpg b/workout_assets/exercises/dip_chest.jpg new file mode 100644 index 0000000..44dabbb Binary files /dev/null and b/workout_assets/exercises/dip_chest.jpg differ diff --git a/workout_assets/exercises/dumbbell_bench_press.webp b/workout_assets/exercises/dumbbell_bench_press.webp new file mode 100644 index 0000000..5922054 Binary files /dev/null and b/workout_assets/exercises/dumbbell_bench_press.webp differ diff --git a/workout_assets/exercises/dumbbell_curl.jpg b/workout_assets/exercises/dumbbell_curl.jpg new file mode 100644 index 0000000..3957026 Binary files /dev/null and b/workout_assets/exercises/dumbbell_curl.jpg differ diff --git a/workout_assets/exercises/dumbbell_fly.jpg b/workout_assets/exercises/dumbbell_fly.jpg new file mode 100644 index 0000000..6fc554d Binary files /dev/null and b/workout_assets/exercises/dumbbell_fly.jpg differ diff --git a/workout_assets/exercises/dumbbell_row.jpg b/workout_assets/exercises/dumbbell_row.jpg new file mode 100644 index 0000000..a668a53 Binary files /dev/null and b/workout_assets/exercises/dumbbell_row.jpg differ diff --git a/workout_assets/exercises/elliptical.jpg b/workout_assets/exercises/elliptical.jpg new file mode 100644 index 0000000..5df5db3 Binary files /dev/null and b/workout_assets/exercises/elliptical.jpg differ diff --git a/workout_assets/exercises/exerciseanimatic230500132.jpg b/workout_assets/exercises/exerciseanimatic230500132.jpg new file mode 100644 index 0000000..af86c5a Binary files /dev/null and b/workout_assets/exercises/exerciseanimatic230500132.jpg differ diff --git a/workout_assets/exercises/face_pull.webp b/workout_assets/exercises/face_pull.webp new file mode 100644 index 0000000..7a06504 Binary files /dev/null and b/workout_assets/exercises/face_pull.webp differ diff --git a/workout_assets/exercises/front_raise.webp b/workout_assets/exercises/front_raise.webp new file mode 100644 index 0000000..7763b61 Binary files /dev/null and b/workout_assets/exercises/front_raise.webp differ diff --git a/workout_assets/exercises/front_squat.png b/workout_assets/exercises/front_squat.png new file mode 100644 index 0000000..99c4e5f Binary files /dev/null and b/workout_assets/exercises/front_squat.png differ diff --git a/workout_assets/exercises/goblet_squat.png b/workout_assets/exercises/goblet_squat.png new file mode 100644 index 0000000..ef4c712 Binary files /dev/null and b/workout_assets/exercises/goblet_squat.png differ diff --git a/workout_assets/exercises/hammer_curl.jpg b/workout_assets/exercises/hammer_curl.jpg new file mode 100644 index 0000000..bb1fde9 Binary files /dev/null and b/workout_assets/exercises/hammer_curl.jpg differ diff --git a/workout_assets/exercises/hanging_leg_raise.webp b/workout_assets/exercises/hanging_leg_raise.webp new file mode 100644 index 0000000..aeae584 Binary files /dev/null and b/workout_assets/exercises/hanging_leg_raise.webp differ diff --git a/workout_assets/exercises/hip_thrust.jpg b/workout_assets/exercises/hip_thrust.jpg new file mode 100644 index 0000000..f01e9da Binary files /dev/null and b/workout_assets/exercises/hip_thrust.jpg differ diff --git a/workout_assets/exercises/hyperextension.jpg b/workout_assets/exercises/hyperextension.jpg new file mode 100644 index 0000000..86a641a Binary files /dev/null and b/workout_assets/exercises/hyperextension.jpg differ diff --git a/workout_assets/exercises/incline_bench_press.jpg b/workout_assets/exercises/incline_bench_press.jpg new file mode 100644 index 0000000..a12684f Binary files /dev/null and b/workout_assets/exercises/incline_bench_press.jpg differ diff --git a/workout_assets/exercises/incline_dumbbell_press.jpg b/workout_assets/exercises/incline_dumbbell_press.jpg new file mode 100644 index 0000000..157f464 Binary files /dev/null and b/workout_assets/exercises/incline_dumbbell_press.jpg differ diff --git a/workout_assets/exercises/jump_rope.avif b/workout_assets/exercises/jump_rope.avif new file mode 100644 index 0000000..427c5f4 Binary files /dev/null and b/workout_assets/exercises/jump_rope.avif differ diff --git a/workout_assets/exercises/lat_pulldown.jpg b/workout_assets/exercises/lat_pulldown.jpg new file mode 100644 index 0000000..cde3d5a Binary files /dev/null and b/workout_assets/exercises/lat_pulldown.jpg differ diff --git a/workout_assets/exercises/lateral_raise.jpg b/workout_assets/exercises/lateral_raise.jpg new file mode 100644 index 0000000..f8a639c Binary files /dev/null and b/workout_assets/exercises/lateral_raise.jpg differ diff --git a/workout_assets/exercises/leg_curl.jpg b/workout_assets/exercises/leg_curl.jpg new file mode 100644 index 0000000..8154973 Binary files /dev/null and b/workout_assets/exercises/leg_curl.jpg differ diff --git a/workout_assets/exercises/leg_extension.webp b/workout_assets/exercises/leg_extension.webp new file mode 100644 index 0000000..f826fa2 Binary files /dev/null and b/workout_assets/exercises/leg_extension.webp differ diff --git a/workout_assets/exercises/leg_press.jpg b/workout_assets/exercises/leg_press.jpg new file mode 100644 index 0000000..652d6eb Binary files /dev/null and b/workout_assets/exercises/leg_press.jpg differ diff --git a/workout_assets/exercises/lunge.png b/workout_assets/exercises/lunge.png new file mode 100644 index 0000000..3a734d3 Binary files /dev/null and b/workout_assets/exercises/lunge.png differ diff --git a/workout_assets/exercises/lying_leg_raise.jpg b/workout_assets/exercises/lying_leg_raise.jpg new file mode 100644 index 0000000..cfd2f77 Binary files /dev/null and b/workout_assets/exercises/lying_leg_raise.jpg differ diff --git a/workout_assets/exercises/overhead_press.png b/workout_assets/exercises/overhead_press.png new file mode 100644 index 0000000..db31246 Binary files /dev/null and b/workout_assets/exercises/overhead_press.png differ diff --git a/workout_assets/exercises/overhead_triceps_extension.jpg b/workout_assets/exercises/overhead_triceps_extension.jpg new file mode 100644 index 0000000..42c647a Binary files /dev/null and b/workout_assets/exercises/overhead_triceps_extension.jpg differ diff --git a/workout_assets/exercises/plank.jpg b/workout_assets/exercises/plank.jpg new file mode 100644 index 0000000..41b909d Binary files /dev/null and b/workout_assets/exercises/plank.jpg differ diff --git a/workout_assets/exercises/preacher_curl.jpg b/workout_assets/exercises/preacher_curl.jpg new file mode 100644 index 0000000..7c826f1 Binary files /dev/null and b/workout_assets/exercises/preacher_curl.jpg differ diff --git a/workout_assets/exercises/pull_up.jpg b/workout_assets/exercises/pull_up.jpg new file mode 100644 index 0000000..342a77d Binary files /dev/null and b/workout_assets/exercises/pull_up.jpg differ diff --git a/workout_assets/exercises/pullover.jpg b/workout_assets/exercises/pullover.jpg new file mode 100644 index 0000000..f737757 Binary files /dev/null and b/workout_assets/exercises/pullover.jpg differ diff --git a/workout_assets/exercises/push_up.jpg b/workout_assets/exercises/push_up.jpg new file mode 100644 index 0000000..c68ae85 Binary files /dev/null and b/workout_assets/exercises/push_up.jpg differ diff --git a/workout_assets/exercises/rear_delt_fly.jpg b/workout_assets/exercises/rear_delt_fly.jpg new file mode 100644 index 0000000..72bd53f Binary files /dev/null and b/workout_assets/exercises/rear_delt_fly.jpg differ diff --git a/workout_assets/exercises/romanian_deadlift.jpg b/workout_assets/exercises/romanian_deadlift.jpg new file mode 100644 index 0000000..075ac34 Binary files /dev/null and b/workout_assets/exercises/romanian_deadlift.jpg differ diff --git a/workout_assets/exercises/rope_pushdown.jpg b/workout_assets/exercises/rope_pushdown.jpg new file mode 100644 index 0000000..9534e72 Binary files /dev/null and b/workout_assets/exercises/rope_pushdown.jpg differ diff --git a/workout_assets/exercises/rowing.webp b/workout_assets/exercises/rowing.webp new file mode 100644 index 0000000..40ef88e Binary files /dev/null and b/workout_assets/exercises/rowing.webp differ diff --git a/workout_assets/exercises/russian_twist.avif b/workout_assets/exercises/russian_twist.avif new file mode 100644 index 0000000..39ea4c3 Binary files /dev/null and b/workout_assets/exercises/russian_twist.avif differ diff --git a/workout_assets/exercises/seated_cable_row.jpg b/workout_assets/exercises/seated_cable_row.jpg new file mode 100644 index 0000000..256c303 Binary files /dev/null and b/workout_assets/exercises/seated_cable_row.jpg differ diff --git a/workout_assets/exercises/seated_calf_raise.webp b/workout_assets/exercises/seated_calf_raise.webp new file mode 100644 index 0000000..f1bd346 Binary files /dev/null and b/workout_assets/exercises/seated_calf_raise.webp differ diff --git a/workout_assets/exercises/seated_dumbbell_press.jpg b/workout_assets/exercises/seated_dumbbell_press.jpg new file mode 100644 index 0000000..807e4be Binary files /dev/null and b/workout_assets/exercises/seated_dumbbell_press.jpg differ diff --git a/workout_assets/exercises/shrug.webp b/workout_assets/exercises/shrug.webp new file mode 100644 index 0000000..e77821b Binary files /dev/null and b/workout_assets/exercises/shrug.webp differ diff --git a/workout_assets/exercises/sit_up.png b/workout_assets/exercises/sit_up.png new file mode 100644 index 0000000..d9b059c Binary files /dev/null and b/workout_assets/exercises/sit_up.png differ diff --git a/workout_assets/exercises/skullcrusher.png b/workout_assets/exercises/skullcrusher.png new file mode 100644 index 0000000..57d6660 Binary files /dev/null and b/workout_assets/exercises/skullcrusher.png differ diff --git a/workout_assets/exercises/squat.webp b/workout_assets/exercises/squat.webp new file mode 100644 index 0000000..96a6f08 Binary files /dev/null and b/workout_assets/exercises/squat.webp differ diff --git a/workout_assets/exercises/stair_climber.jpg b/workout_assets/exercises/stair_climber.jpg new file mode 100644 index 0000000..638ddd0 Binary files /dev/null and b/workout_assets/exercises/stair_climber.jpg differ diff --git a/workout_assets/exercises/standing_calf_raise.webp b/workout_assets/exercises/standing_calf_raise.webp new file mode 100644 index 0000000..6cc6eb7 Binary files /dev/null and b/workout_assets/exercises/standing_calf_raise.webp differ diff --git a/workout_assets/exercises/t_bar_row.png b/workout_assets/exercises/t_bar_row.png new file mode 100644 index 0000000..4c7146b Binary files /dev/null and b/workout_assets/exercises/t_bar_row.png differ diff --git a/workout_assets/exercises/treadmill_running.jpg b/workout_assets/exercises/treadmill_running.jpg new file mode 100644 index 0000000..07cff52 Binary files /dev/null and b/workout_assets/exercises/treadmill_running.jpg differ diff --git a/workout_assets/exercises/treadmill_walking.jpg b/workout_assets/exercises/treadmill_walking.jpg new file mode 100644 index 0000000..07cff52 Binary files /dev/null and b/workout_assets/exercises/treadmill_walking.jpg differ diff --git a/workout_assets/exercises/triceps_pushdown.jpg b/workout_assets/exercises/triceps_pushdown.jpg new file mode 100644 index 0000000..3d6c429 Binary files /dev/null and b/workout_assets/exercises/triceps_pushdown.jpg differ diff --git a/workout_assets/exercises/upright_row.png b/workout_assets/exercises/upright_row.png new file mode 100644 index 0000000..e54587c Binary files /dev/null and b/workout_assets/exercises/upright_row.png differ diff --git a/workout_assets/machines/ab_crunch_machine.jpg b/workout_assets/machines/ab_crunch_machine.jpg new file mode 100644 index 0000000..7d08f87 Binary files /dev/null and b/workout_assets/machines/ab_crunch_machine.jpg differ diff --git a/workout_assets/machines/assisted_pullup_machine.png b/workout_assets/machines/assisted_pullup_machine.png new file mode 100644 index 0000000..14ff596 Binary files /dev/null and b/workout_assets/machines/assisted_pullup_machine.png differ diff --git a/workout_assets/machines/back_extension_bench.webp b/workout_assets/machines/back_extension_bench.webp new file mode 100644 index 0000000..04269f7 Binary files /dev/null and b/workout_assets/machines/back_extension_bench.webp differ diff --git a/workout_assets/machines/cable_crossover_machine.jpeg b/workout_assets/machines/cable_crossover_machine.jpeg new file mode 100644 index 0000000..2e2889c Binary files /dev/null and b/workout_assets/machines/cable_crossover_machine.jpeg differ diff --git a/workout_assets/machines/chest_press_machine.webp b/workout_assets/machines/chest_press_machine.webp new file mode 100644 index 0000000..3a0a0e2 Binary files /dev/null and b/workout_assets/machines/chest_press_machine.webp differ diff --git a/workout_assets/machines/elliptical_machine.jpg b/workout_assets/machines/elliptical_machine.jpg new file mode 100644 index 0000000..5df5db3 Binary files /dev/null and b/workout_assets/machines/elliptical_machine.jpg differ diff --git a/workout_assets/machines/glute_kickback_machine.jpg b/workout_assets/machines/glute_kickback_machine.jpg new file mode 100644 index 0000000..f14bced Binary files /dev/null and b/workout_assets/machines/glute_kickback_machine.jpg differ diff --git a/workout_assets/machines/hack_squat_machine.jpg b/workout_assets/machines/hack_squat_machine.jpg new file mode 100644 index 0000000..59439b5 Binary files /dev/null and b/workout_assets/machines/hack_squat_machine.jpg differ diff --git a/workout_assets/machines/hip_abductor_machine.avif b/workout_assets/machines/hip_abductor_machine.avif new file mode 100644 index 0000000..01523a1 Binary files /dev/null and b/workout_assets/machines/hip_abductor_machine.avif differ diff --git a/workout_assets/machines/hip_adductor_machine.png b/workout_assets/machines/hip_adductor_machine.png new file mode 100644 index 0000000..0ac140f Binary files /dev/null and b/workout_assets/machines/hip_adductor_machine.png differ diff --git a/workout_assets/machines/lat_pulldown_machine.avif b/workout_assets/machines/lat_pulldown_machine.avif new file mode 100644 index 0000000..4067602 Binary files /dev/null and b/workout_assets/machines/lat_pulldown_machine.avif differ diff --git a/workout_assets/machines/leg_curl_machine.avif b/workout_assets/machines/leg_curl_machine.avif new file mode 100644 index 0000000..cd1a7e9 Binary files /dev/null and b/workout_assets/machines/leg_curl_machine.avif differ diff --git a/workout_assets/machines/leg_extension_machine.jpg b/workout_assets/machines/leg_extension_machine.jpg new file mode 100644 index 0000000..ca24c5b Binary files /dev/null and b/workout_assets/machines/leg_extension_machine.jpg differ diff --git a/workout_assets/machines/leg_press_machine.webp b/workout_assets/machines/leg_press_machine.webp new file mode 100644 index 0000000..95b233e Binary files /dev/null and b/workout_assets/machines/leg_press_machine.webp differ diff --git a/workout_assets/machines/pec_deck_machine.jpg b/workout_assets/machines/pec_deck_machine.jpg new file mode 100644 index 0000000..2ddb9d0 Binary files /dev/null and b/workout_assets/machines/pec_deck_machine.jpg differ diff --git a/workout_assets/machines/preacher_curl_machine.jpg b/workout_assets/machines/preacher_curl_machine.jpg new file mode 100644 index 0000000..c5a9194 Binary files /dev/null and b/workout_assets/machines/preacher_curl_machine.jpg differ diff --git a/workout_assets/machines/rowing_machine.webp b/workout_assets/machines/rowing_machine.webp new file mode 100644 index 0000000..40ef88e Binary files /dev/null and b/workout_assets/machines/rowing_machine.webp differ diff --git a/workout_assets/machines/seated_calf_raise_machine.jpg b/workout_assets/machines/seated_calf_raise_machine.jpg new file mode 100644 index 0000000..8ca3508 Binary files /dev/null and b/workout_assets/machines/seated_calf_raise_machine.jpg differ diff --git a/workout_assets/machines/seated_row_machine.jpg b/workout_assets/machines/seated_row_machine.jpg new file mode 100644 index 0000000..41cd3b0 Binary files /dev/null and b/workout_assets/machines/seated_row_machine.jpg differ diff --git a/workout_assets/machines/shoulder_press_machine.avif b/workout_assets/machines/shoulder_press_machine.avif new file mode 100644 index 0000000..b8202f7 Binary files /dev/null and b/workout_assets/machines/shoulder_press_machine.avif differ diff --git a/workout_assets/machines/smith_machine.webp b/workout_assets/machines/smith_machine.webp new file mode 100644 index 0000000..f015440 Binary files /dev/null and b/workout_assets/machines/smith_machine.webp differ diff --git a/workout_assets/machines/stair_climber_machine.jpg b/workout_assets/machines/stair_climber_machine.jpg new file mode 100644 index 0000000..638ddd0 Binary files /dev/null and b/workout_assets/machines/stair_climber_machine.jpg differ diff --git a/workout_assets/machines/stationary_bike.png b/workout_assets/machines/stationary_bike.png new file mode 100644 index 0000000..40e59ac Binary files /dev/null and b/workout_assets/machines/stationary_bike.png differ diff --git a/workout_assets/machines/treadmill.jpg b/workout_assets/machines/treadmill.jpg new file mode 100644 index 0000000..07cff52 Binary files /dev/null and b/workout_assets/machines/treadmill.jpg differ diff --git a/workout_assets/machines/triceps_extension_machine.jpg b/workout_assets/machines/triceps_extension_machine.jpg new file mode 100644 index 0000000..7c98b8b Binary files /dev/null and b/workout_assets/machines/triceps_extension_machine.jpg differ