Refactor code structure for improved readability and maintainability

This commit is contained in:
Artem Kashaev
2026-05-28 10:36:17 +05:00
commit d5a889ed6d
49 changed files with 6853 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
POSTGRES_USER=train_watcher
POSTGRES_PASSWORD=train_watcher
POSTGRES_DB=train_watcher
BFF_DATABASE_URL=postgresql+psycopg://train_watcher:train_watcher@postgres:5432/train_watcher
LOGIC_DATABASE_URL=postgresql+psycopg://train_watcher:train_watcher@postgres:5432/train_watcher
LOGIC_BASE_URL=http://logic:8000
SERVICE_TOKEN=dev-service-token-change-me
JWT_SECRET=dev-jwt-secret-change-me
S3_ENDPOINT_URL=http://minio:9000
S3_PUBLIC_BASE_URL=http://localhost:9000
S3_ACCESS_KEY_ID=minioadmin
S3_SECRET_ACCESS_KEY=minioadmin
S3_BUCKET=train-watcher-media
S3_REGION=us-east-1
VITE_API_BASE_URL=/api
+18
View File
@@ -0,0 +1,18 @@
.venv/
__pycache__/
.pytest_cache/
.ruff_cache/
.mypy_cache/
.ty/
node_modules/
dist/
build/
*.tsbuildinfo
.env
.env.*
!.env.example
*.pyc
*.pyo
*.log
coverage/
.DS_Store
+4
View File
@@ -0,0 +1,4 @@
node_modules/
dist/
*.tsbuildinfo
.env
+13
View File
@@ -0,0 +1,13 @@
FROM node:24-alpine AS build
WORKDIR /app
ARG VITE_API_BASE_URL=/api
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
COPY package.json ./
RUN corepack enable && corepack prepare pnpm@10.12.1 --activate && pnpm install --frozen-lockfile=false
COPY . .
RUN pnpm build
FROM nginx:1.27-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
+25
View File
@@ -0,0 +1,25 @@
import js from "@eslint/js";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import globals from "globals";
import tseslint from "typescript-eslint";
export default tseslint.config(
{ ignores: ["dist"] },
js.configs.recommended,
...tseslint.configs.recommended,
{
files: ["**/*.{ts,tsx}"],
languageOptions: {
globals: globals.browser,
},
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
},
},
);
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Train Watcher</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+19
View File
@@ -0,0 +1,19 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location /api/ {
proxy_pass http://bff:8000/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
try_files $uri $uri/ /index.html;
}
}
+32
View File
@@ -0,0 +1,32 @@
{
"name": "train-watcher-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"packageManager": "pnpm@10.12.1",
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "tsc -b && vite build",
"typecheck": "tsc -b",
"lint": "eslint ."
},
"dependencies": {
"@tanstack/react-query": "^5.77.2",
"@tanstack/react-router": "^1.120.15",
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
"devDependencies": {
"@eslint/js": "^9.27.0",
"@types/react": "^19.1.5",
"@types/react-dom": "^19.1.5",
"@vitejs/plugin-react": "^4.5.0",
"eslint": "^9.27.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.2.0",
"typescript": "^5.8.3",
"typescript-eslint": "^8.32.1",
"vite": "^6.3.5"
}
}
+421
View File
@@ -0,0 +1,421 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { FormEvent, useMemo, useState } from "react";
import { api, type AuthState } from "./api";
import type { CatalogEntity, Workout } from "./types";
type Tab = "dashboard" | "catalog" | "workout" | "history" | "analytics";
const authStorageKey = "train-watcher-auth";
function loadAuth(): AuthState | null {
const raw = localStorage.getItem(authStorageKey);
if (!raw) return null;
try {
return JSON.parse(raw) as AuthState;
} catch {
localStorage.removeItem(authStorageKey);
return null;
}
}
export function App() {
const [auth, setAuth] = useState<AuthState | null>(loadAuth);
const [tab, setTab] = useState<Tab>("dashboard");
const queryClient = useQueryClient();
function saveAuth(next: AuthState) {
localStorage.setItem(authStorageKey, JSON.stringify(next));
setAuth(next);
}
function logout() {
localStorage.removeItem(authStorageKey);
queryClient.clear();
setAuth(null);
}
if (!auth) {
return <AuthScreen onAuth={saveAuth} />;
}
return (
<div className="app-shell">
<aside className="sidebar">
<div>
<p className="eyebrow">Train Watcher</p>
<h1>Дневник тренировок</h1>
</div>
<nav>
{([
["dashboard", "Обзор"],
["catalog", "Каталог"],
["workout", "Тренировка"],
["history", "История"],
["analytics", "Аналитика"],
] as Array<[Tab, string]>).map(([key, label]) => (
<button className={tab === key ? "active" : ""} key={key} onClick={() => setTab(key)}>
{label}
</button>
))}
</nav>
<div className="profile-card">
<span>{auth.user.display_name}</span>
<small>{auth.user.email}</small>
<button onClick={logout}>Выйти</button>
</div>
</aside>
<main>
{tab === "dashboard" && <Dashboard token={auth.accessToken} onStart={() => setTab("workout")} />}
{tab === "catalog" && <Catalog token={auth.accessToken} />}
{tab === "workout" && <ActiveWorkout token={auth.accessToken} />}
{tab === "history" && <History token={auth.accessToken} />}
{tab === "analytics" && <Analytics token={auth.accessToken} />}
</main>
</div>
);
}
function AuthScreen({ onAuth }: { onAuth: (auth: AuthState) => void }) {
const [mode, setMode] = useState<"login" | "register">("login");
const [email, setEmail] = useState("demo@example.com");
const [password, setPassword] = useState("password123");
const [displayName, setDisplayName] = useState("Demo Athlete");
const [error, setError] = useState<string | null>(null);
const mutation = useMutation({
mutationFn: () =>
mode === "login"
? api.login({ email, password })
: api.register({ email, password, display_name: displayName }),
onSuccess: onAuth,
onError: (err) => setError(err.message),
});
function submit(event: FormEvent) {
event.preventDefault();
setError(null);
mutation.mutate();
}
return (
<main className="auth-layout">
<section className="hero-panel">
<p className="eyebrow">Progressive overload tracker</p>
<h1>Фиксируй подходы, рабочий вес и динамику без лишнего шума.</h1>
<p>
MVP уже разделен на frontend, BFF и logic-service, а изображения каталога хранятся в MinIO как S3-объекты.
</p>
</section>
<form className="card auth-card" onSubmit={submit}>
<h2>{mode === "login" ? "Вход" : "Регистрация"}</h2>
{mode === "register" && (
<label>
Имя
<input value={displayName} onChange={(event) => setDisplayName(event.target.value)} />
</label>
)}
<label>
Email
<input type="email" value={email} onChange={(event) => setEmail(event.target.value)} />
</label>
<label>
Пароль
<input type="password" value={password} onChange={(event) => setPassword(event.target.value)} />
</label>
{error && <p className="error">{error}</p>}
<button className="primary" disabled={mutation.isPending}>
{mutation.isPending ? "Отправка..." : mode === "login" ? "Войти" : "Создать аккаунт"}
</button>
<button type="button" className="ghost" onClick={() => setMode(mode === "login" ? "register" : "login")}>
{mode === "login" ? "Нужна регистрация" : "Уже есть аккаунт"}
</button>
</form>
</main>
);
}
function useCatalog(token: string) {
const equipment = useQuery({ queryKey: ["equipment"], queryFn: () => api.equipment(token) });
const exercises = useQuery({ queryKey: ["exercises"], queryFn: () => api.exercises(token) });
return { equipment, exercises };
}
function Dashboard({ token, onStart }: { token: string; onStart: () => void }) {
const workouts = useQuery({ queryKey: ["workouts"], queryFn: () => api.workouts(token) });
const calories = useQuery({ queryKey: ["calories"], queryFn: () => api.calories(token) });
const latest = workouts.data?.[0];
return (
<section className="stack">
<div className="page-header">
<div>
<p className="eyebrow">Dashboard</p>
<h2>Обзор</h2>
</div>
<button className="primary" onClick={onStart}>Начать тренировку</button>
</div>
<div className="stats-grid">
<Metric label="Тренировок" value={workouts.data?.length ?? 0} />
<Metric label="Калорий всего" value={Math.round(calories.data?.total_calories ?? 0)} />
<Metric label="Последняя" value={latest ? new Date(latest.started_at).toLocaleDateString("ru-RU") : "нет"} />
</div>
<section className="card">
<h3>Последняя тренировка</h3>
{latest ? <WorkoutSummary workout={latest} /> : <p className="muted">Создай первую тренировку, чтобы увидеть историю.</p>}
</section>
</section>
);
}
function Catalog({ token }: { token: string }) {
const [kind, setKind] = useState<"equipment" | "exercise">("equipment");
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [file, setFile] = useState<File | null>(null);
const queryClient = useQueryClient();
const { equipment, exercises } = useCatalog(token);
const list = kind === "equipment" ? equipment.data : exercises.data;
const createMutation = useMutation({
mutationFn: async () => {
const image = file ? await api.uploadImage(token, kind, file) : {};
const payload = { name, description, ...image };
return kind === "equipment" ? api.createEquipment(token, payload) : api.createExercise(token, payload);
},
onSuccess: () => {
setName("");
setDescription("");
setFile(null);
void queryClient.invalidateQueries({ queryKey: [kind === "equipment" ? "equipment" : "exercises"] });
},
});
return (
<section className="stack">
<div className="page-header">
<div>
<p className="eyebrow">Catalog</p>
<h2>Тренажеры и упражнения</h2>
</div>
<div className="segmented">
<button className={kind === "equipment" ? "active" : ""} onClick={() => setKind("equipment")}>Тренажеры</button>
<button className={kind === "exercise" ? "active" : ""} onClick={() => setKind("exercise")}>Упражнения</button>
</div>
</div>
<form
className="card form-grid"
onSubmit={(event) => {
event.preventDefault();
createMutation.mutate();
}}
>
<label>
Название
<input value={name} onChange={(event) => setName(event.target.value)} required />
</label>
<label>
Описание
<input value={description} onChange={(event) => setDescription(event.target.value)} />
</label>
<label>
Картинка
<input type="file" accept="image/png,image/jpeg,image/webp" onChange={(event) => setFile(event.target.files?.[0] ?? null)} />
</label>
<button className="primary" disabled={createMutation.isPending}>{createMutation.isPending ? "Сохранение..." : "Добавить"}</button>
{createMutation.error && <p className="error">{createMutation.error.message}</p>}
</form>
<div className="catalog-grid">
{list?.map((entity) => <CatalogCard entity={entity} key={entity.id} />)}
</div>
</section>
);
}
function ActiveWorkout({ token }: { token: string }) {
const queryClient = useQueryClient();
const { equipment, exercises } = useCatalog(token);
const workouts = useQuery({ queryKey: ["workouts"], queryFn: () => api.workouts(token) });
const [activeWorkout, setActiveWorkout] = useState<Workout | null>(null);
const [kind, setKind] = useState<"exercise" | "equipment">("exercise");
const [entityId, setEntityId] = useState("");
const [activeItemId, setActiveItemId] = useState("");
const [weight, setWeight] = useState(60);
const [reps, setReps] = useState(8);
const startMutation = useMutation({
mutationFn: () => api.createWorkout(token),
onSuccess: (workout) => {
setActiveWorkout(workout);
void queryClient.invalidateQueries({ queryKey: ["workouts"] });
},
});
const addItemMutation = useMutation({
mutationFn: () => {
if (!activeWorkout) throw new Error("Нет активной тренировки");
return api.addWorkoutItem(token, activeWorkout.id, {
exercise_id: kind === "exercise" ? entityId : null,
equipment_id: kind === "equipment" ? entityId : null,
planned_working_weight: weight,
});
},
onSuccess: (item) => {
setActiveItemId(item.id);
setActiveWorkout((workout) => workout ? { ...workout, items: [...workout.items, item] } : workout);
void queryClient.invalidateQueries({ queryKey: ["workouts"] });
},
});
const addSetMutation = useMutation({
mutationFn: () => api.addWorkoutSet(token, activeItemId, { weight, reps }),
onSuccess: (workoutSet) => {
setActiveWorkout((workout) => {
if (!workout) return workout;
return {
...workout,
estimated_calories: workout.estimated_calories + (workoutSet.calories ?? 0),
items: workout.items.map((item) =>
item.id === activeItemId ? { ...item, sets: [...item.sets, workoutSet] } : item,
),
};
});
void queryClient.invalidateQueries({ queryKey: ["workouts"] });
void queryClient.invalidateQueries({ queryKey: ["calories"] });
},
});
const current = activeWorkout ?? workouts.data?.find((workout) => !workout.finished_at) ?? null;
const choices = kind === "exercise" ? exercises.data : equipment.data;
return (
<section className="stack">
<div className="page-header">
<div>
<p className="eyebrow">Active workout</p>
<h2>Текущая тренировка</h2>
</div>
<button className="primary" onClick={() => startMutation.mutate()} disabled={startMutation.isPending}>Новая тренировка</button>
</div>
{current ? (
<>
<section className="card form-grid">
<label>
Тип
<select value={kind} onChange={(event) => setKind(event.target.value as "exercise" | "equipment")}>
<option value="exercise">Упражнение</option>
<option value="equipment">Тренажер</option>
</select>
</label>
<label>
Элемент
<select value={entityId} onChange={(event) => setEntityId(event.target.value)}>
<option value="">Выбрать</option>
{choices?.map((entity) => <option key={entity.id} value={entity.id}>{entity.name}</option>)}
</select>
</label>
<label>
Вес
<input type="number" value={weight} onChange={(event) => setWeight(Number(event.target.value))} />
</label>
<label>
Повторы
<input type="number" value={reps} onChange={(event) => setReps(Number(event.target.value))} />
</label>
<button className="primary" disabled={!entityId || addItemMutation.isPending} onClick={() => addItemMutation.mutate()}>Добавить элемент</button>
<button disabled={!activeItemId || addSetMutation.isPending} onClick={() => addSetMutation.mutate()}>Записать подход</button>
</section>
<WorkoutSummary workout={current} />
</>
) : (
<section className="card empty-state"><h3>Нет активной тренировки</h3><p>Начни тренировку и добавь упражнения или тренажеры.</p></section>
)}
</section>
);
}
function History({ token }: { token: string }) {
const workouts = useQuery({ queryKey: ["workouts"], queryFn: () => api.workouts(token) });
return (
<section className="stack">
<div className="page-header"><div><p className="eyebrow">History</p><h2>История</h2></div></div>
{workouts.data?.map((workout) => <WorkoutSummary workout={workout} key={workout.id} />)}
</section>
);
}
function Analytics({ token }: { token: string }) {
const { exercises } = useCatalog(token);
const [exerciseId, setExerciseId] = useState("");
const progression = useQuery({
queryKey: ["progression", exerciseId],
queryFn: () => api.progression(token, "exercise", exerciseId || undefined),
});
const calories = useQuery({ queryKey: ["calories"], queryFn: () => api.calories(token) });
return (
<section className="stack">
<div className="page-header"><div><p className="eyebrow">Analytics</p><h2>Прогрессия и калораж</h2></div></div>
<section className="card form-grid">
<label>
Упражнение
<select value={exerciseId} onChange={(event) => setExerciseId(event.target.value)}>
<option value="">Все упражнения</option>
{exercises.data?.map((exercise) => <option key={exercise.id} value={exercise.id}>{exercise.name}</option>)}
</select>
</label>
<Metric label="Последний вес" value={progression.data?.last_weight ?? "нет"} />
<Metric label="Максимальный вес" value={progression.data?.max_weight ?? "нет"} />
<Metric label="Калорий всего" value={Math.round(calories.data?.total_calories ?? 0)} />
</section>
<section className="card chart-card">
<h3>Объем по датам</h3>
<div className="bars">
{progression.data?.points.map((point) => (
<div className="bar-row" key={point.date}>
<span>{point.date}</span>
<div><i style={{ width: `${Math.min(point.volume / 20, 100)}%` }} /></div>
<b>{Math.round(point.volume)}</b>
</div>
))}
</div>
</section>
</section>
);
}
function CatalogCard({ entity }: { entity: CatalogEntity }) {
return (
<article className="card catalog-card">
{entity.image_s3_url ? <img src={entity.image_s3_url} alt="" /> : <div className="image-placeholder">TW</div>}
<div>
<span className={entity.is_builtin ? "pill" : "pill user"}>{entity.is_builtin ? "стандартное" : "мое"}</span>
<h3>{entity.name}</h3>
<p>{entity.description || "Без описания"}</p>
</div>
</article>
);
}
function WorkoutSummary({ workout }: { workout: Workout }) {
const setCount = useMemo(() => workout.items.reduce((total, item) => total + item.sets.length, 0), [workout.items]);
return (
<section className="card workout-card">
<div>
<h3>{new Date(workout.started_at).toLocaleString("ru-RU")}</h3>
<p className="muted">{workout.notes || "Без заметок"}</p>
</div>
<div className="workout-stats">
<Metric label="Элементов" value={workout.items.length} />
<Metric label="Подходов" value={setCount} />
<Metric label="Ккал" value={Math.round(workout.estimated_calories ?? 0)} />
</div>
</section>
);
}
function Metric({ label, value }: { label: string; value: string | number }) {
return (
<div className="metric">
<span>{label}</span>
<strong>{value}</strong>
</div>
);
}
+81
View File
@@ -0,0 +1,81 @@
import type { Calories, CatalogEntity, Progression, User, Workout, WorkoutItem, WorkoutSet } from "./types";
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "/api";
export type AuthState = {
accessToken: string;
user: User;
};
async function request<T>(path: string, options: RequestInit = {}, token?: string): Promise<T> {
const headers = new Headers(options.headers);
if (!(options.body instanceof FormData)) {
headers.set("Content-Type", "application/json");
}
if (token) {
headers.set("Authorization", `Bearer ${token}`);
}
const response = await fetch(`${API_BASE_URL}${path}`, { ...options, headers });
if (!response.ok) {
const body = await response.json().catch(() => ({ detail: response.statusText }));
throw new Error(typeof body.detail === "string" ? body.detail : JSON.stringify(body.detail));
}
return response.json() as Promise<T>;
}
export const api = {
register(payload: { email: string; password: string; display_name: string }) {
return request<AuthState>("/auth/register", { method: "POST", body: JSON.stringify(payload) });
},
login(payload: { email: string; password: string }) {
return request<AuthState>("/auth/login", { method: "POST", body: JSON.stringify(payload) });
},
me(token: string) {
return request<User>("/me", {}, token);
},
equipment(token: string) {
return request<CatalogEntity[]>("/catalog/equipment", {}, token);
},
exercises(token: string) {
return request<CatalogEntity[]>("/catalog/exercises", {}, token);
},
uploadImage(token: string, entityType: "equipment" | "exercise", file: File) {
const form = new FormData();
form.append("file", file);
return request<{ image_s3_url: string; image_s3_key: string }>(
`/media/images?entity_type=${entityType}`,
{ method: "POST", body: form },
token,
);
},
createEquipment(token: string, payload: Partial<CatalogEntity>) {
return request<CatalogEntity>("/catalog/equipment", { method: "POST", body: JSON.stringify(payload) }, token);
},
createExercise(token: string, payload: Partial<CatalogEntity>) {
return request<CatalogEntity>("/catalog/exercises", { method: "POST", body: JSON.stringify(payload) }, token);
},
workouts(token: string) {
return request<Workout[]>("/workouts", {}, token);
},
createWorkout(token: string, notes?: string) {
return request<Workout>("/workouts", { method: "POST", body: JSON.stringify({ notes }) }, token);
},
updateWorkout(token: string, workoutId: string, payload: Partial<Workout>) {
return request<Workout>(`/workouts/${workoutId}`, { method: "PATCH", body: JSON.stringify(payload) }, token);
},
addWorkoutItem(token: string, workoutId: string, payload: Partial<WorkoutItem>) {
return request<WorkoutItem>(`/workouts/${workoutId}/items`, { method: "POST", body: JSON.stringify(payload) }, token);
},
addWorkoutSet(token: string, itemId: string, payload: Partial<WorkoutSet>) {
return request<WorkoutSet>(`/workout-items/${itemId}/sets`, { method: "POST", body: JSON.stringify(payload) }, token);
},
progression(token: string, kind: "exercise" | "equipment", entityId?: string) {
const params = new URLSearchParams({ kind });
if (entityId) params.set("entity_id", entityId);
return request<Progression>(`/analytics/progression?${params.toString()}`, {}, token);
},
calories(token: string) {
return request<Calories>("/analytics/calories", {}, token);
},
};
+16
View File
@@ -0,0 +1,16 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
import "./styles.css";
const queryClient = new QueryClient();
createRoot(document.getElementById("root")!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</StrictMode>,
);
+79
View File
@@ -0,0 +1,79 @@
:root {
color: #111827;
background: #f4f0e8;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-synthesis: none;
text-rendering: optimizeLegibility;
}
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; min-height: 100vh; }
button, input, select { font: inherit; }
button { border: 0; cursor: pointer; }
button:disabled { cursor: not-allowed; opacity: 0.55; }
.app-shell { min-height: 100vh; display: grid; grid-template-columns: 280px 1fr; }
.sidebar { background: #151515; color: #f9fafb; padding: 28px; display: flex; flex-direction: column; gap: 28px; }
.sidebar h1 { font-size: 28px; line-height: 1; margin: 8px 0 0; }
.sidebar nav { display: grid; gap: 8px; }
.sidebar nav button, .profile-card button { color: #f9fafb; background: transparent; text-align: left; border-radius: 14px; padding: 12px 14px; }
.sidebar nav button.active, .sidebar nav button:hover { background: #dbff5b; color: #151515; }
.profile-card { margin-top: auto; display: grid; gap: 6px; padding: 16px; border: 1px solid #333; border-radius: 20px; }
.profile-card small { color: #a3a3a3; }
main { padding: 32px; }
.auth-layout { min-height: 100vh; display: grid; grid-template-columns: 1.2fr 0.8fr; gap: 24px; align-items: center; }
.hero-panel { min-height: calc(100vh - 64px); border-radius: 32px; padding: 48px; color: #f9fafb; background: radial-gradient(circle at 20% 20%, #dbff5b 0 12%, transparent 13%), linear-gradient(135deg, #111827, #3f2d20); display: flex; flex-direction: column; justify-content: flex-end; }
.hero-panel h1 { font-size: clamp(36px, 6vw, 76px); line-height: 0.95; max-width: 920px; margin: 0; }
.hero-panel p:not(.eyebrow) { max-width: 680px; color: #e5e7eb; font-size: 18px; }
.auth-card { max-width: 440px; width: 100%; }
.stack { display: grid; gap: 24px; }
.page-header { display: flex; justify-content: space-between; gap: 16px; align-items: center; }
.page-header h2, .card h3, .auth-card h2 { margin: 0; }
.eyebrow { color: #8a5cf6; font-size: 12px; font-weight: 800; letter-spacing: 0.16em; margin: 0 0 8px; text-transform: uppercase; }
.card { background: rgba(255,255,255,0.82); border: 1px solid rgba(17,24,39,0.08); border-radius: 24px; box-shadow: 0 18px 60px rgba(17,24,39,0.08); padding: 22px; }
.primary { background: #151515; color: #fff; border-radius: 14px; padding: 12px 18px; font-weight: 800; }
.ghost { background: transparent; color: #374151; padding: 10px; }
label { display: grid; gap: 8px; font-weight: 700; color: #374151; }
input, select { width: 100%; border: 1px solid #d1d5db; border-radius: 14px; padding: 12px 14px; background: #fff; color: #111827; }
.error { color: #b91c1c; margin: 0; }
.muted { color: #6b7280; }
.stats-grid, .catalog-grid, .workout-stats { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; }
.catalog-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.metric { background: #fff; border-radius: 18px; padding: 16px; display: grid; gap: 8px; }
.metric span { color: #6b7280; font-size: 13px; }
.metric strong { font-size: 26px; }
.segmented { display: flex; background: #fff; border-radius: 999px; padding: 4px; }
.segmented button { background: transparent; border-radius: 999px; padding: 10px 14px; }
.segmented button.active { background: #151515; color: #fff; }
.form-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 16px; align-items: end; }
.catalog-card { overflow: hidden; padding: 0; }
.catalog-card img, .image-placeholder { width: 100%; height: 170px; object-fit: cover; background: linear-gradient(135deg, #dbff5b, #8a5cf6); display: grid; place-items: center; font-weight: 900; color: #151515; }
.catalog-card div:last-child { padding: 18px; }
.catalog-card p { color: #6b7280; }
.pill { display: inline-flex; border-radius: 999px; background: #e5e7eb; padding: 5px 10px; font-size: 12px; font-weight: 800; }
.pill.user { background: #dcfce7; color: #166534; }
.empty-state { text-align: center; padding: 48px; }
.workout-card { display: grid; gap: 18px; }
.bars { display: grid; gap: 12px; }
.bar-row { display: grid; grid-template-columns: 110px 1fr 70px; gap: 12px; align-items: center; }
.bar-row div { height: 16px; border-radius: 999px; background: #e5e7eb; overflow: hidden; }
.bar-row i { display: block; height: 100%; border-radius: inherit; background: linear-gradient(90deg, #8a5cf6, #dbff5b); }
@media (max-width: 980px) {
.app-shell { grid-template-columns: 1fr; padding-bottom: 88px; }
.sidebar { position: fixed; inset: auto 12px 12px; z-index: 10; border-radius: 24px; padding: 12px; display: block; }
.sidebar > div:first-child, .profile-card { display: none; }
.sidebar nav { grid-template-columns: repeat(5, 1fr); }
.sidebar nav button { text-align: center; padding: 10px 6px; font-size: 12px; }
main { padding: 20px; }
.auth-layout { grid-template-columns: 1fr; }
.hero-panel { min-height: 48vh; padding: 28px; }
.catalog-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.form-grid, .stats-grid { grid-template-columns: 1fr; }
}
@media (max-width: 620px) {
.page-header { align-items: stretch; flex-direction: column; }
.catalog-grid, .workout-stats { grid-template-columns: 1fr; }
.bar-row { grid-template-columns: 1fr; }
}
+62
View File
@@ -0,0 +1,62 @@
export type User = {
id: string;
email: string;
display_name: string;
created_at: string;
};
export type CatalogEntity = {
id: string;
owner_user_id: string | null;
equipment_id?: string | null;
name: string;
description: string | null;
image_s3_url: string | null;
image_s3_key: string | null;
is_builtin: boolean;
default_calories_per_minute?: number | null;
};
export type WorkoutSet = {
id: string;
workout_item_id: string;
set_index: number;
weight: number;
reps: number;
duration_seconds: number | null;
calories: number | null;
completed_at: string;
};
export type WorkoutItem = {
id: string;
workout_id: string;
exercise_id: string | null;
equipment_id: string | null;
order_index: number;
planned_working_weight: number | null;
created_at: string;
sets: WorkoutSet[];
};
export type Workout = {
id: string;
user_id: string;
started_at: string;
finished_at: string | null;
notes: string | null;
estimated_calories: number;
items: WorkoutItem[];
};
export type Progression = {
last_weight: number | null;
max_weight: number | null;
previous_delta: number | null;
points: Array<{ date: string; max_weight: number; volume: number }>;
};
export type Calories = {
total_calories: number;
workouts: Array<{ id: string; date: string; calories: number }>;
};
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src", "vite.config.ts", "eslint.config.js"]
}
+6
View File
@@ -0,0 +1,6 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [react()],
});
+110
View File
@@ -0,0 +1,110 @@
services:
postgres:
image: postgres:17-alpine
environment:
POSTGRES_USER: ${POSTGRES_USER:-train_watcher}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-train_watcher}
POSTGRES_DB: ${POSTGRES_DB:-train_watcher}
ports:
- "5432:5432"
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-train_watcher} -d ${POSTGRES_DB:-train_watcher}"]
interval: 5s
timeout: 5s
retries: 20
minio:
image: minio/minio:RELEASE.2025-04-22T22-12-26Z
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${S3_ACCESS_KEY_ID:-minioadmin}
MINIO_ROOT_PASSWORD: ${S3_SECRET_ACCESS_KEY:-minioadmin}
ports:
- "9000:9000"
- "9001:9001"
volumes:
- minio-data:/data
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 5s
timeout: 5s
retries: 20
minio-init:
image: minio/mc:RELEASE.2025-04-16T18-13-26Z
depends_on:
minio:
condition: service_healthy
environment:
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}
entrypoint: >
/bin/sh -c "
mc alias set local http://minio:9000 $$S3_ACCESS_KEY_ID $$S3_SECRET_ACCESS_KEY &&
mc mb --ignore-existing local/$$S3_BUCKET &&
mc anonymous set download local/$$S3_BUCKET
"
logic:
build:
context: ../services/logic
environment:
DATABASE_URL: ${LOGIC_DATABASE_URL:-postgresql+psycopg://train_watcher:train_watcher@postgres:5432/train_watcher}
SERVICE_TOKEN: ${SERVICE_TOKEN:-dev-service-token-change-me}
ports:
- "8002:8000"
depends_on:
postgres:
condition: service_healthy
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
timeout: 5s
retries: 20
bff:
build:
context: ../services/bff
environment:
DATABASE_URL: ${BFF_DATABASE_URL:-postgresql+psycopg://train_watcher:train_watcher@postgres:5432/train_watcher}
LOGIC_BASE_URL: ${LOGIC_BASE_URL:-http://logic:8000}
SERVICE_TOKEN: ${SERVICE_TOKEN:-dev-service-token-change-me}
JWT_SECRET: ${JWT_SECRET:-dev-jwt-secret-change-me}
S3_ENDPOINT_URL: ${S3_ENDPOINT_URL:-http://minio: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}
ports:
- "8001:8000"
depends_on:
postgres:
condition: service_healthy
logic:
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
timeout: 5s
retries: 20
frontend:
build:
context: ../frontend
args:
VITE_API_BASE_URL: ${VITE_API_BASE_URL:-/api}
ports:
- "5173:80"
depends_on:
bff:
condition: service_healthy
volumes:
postgres-data:
minio-data:
+11
View File
@@ -0,0 +1,11 @@
{
"name": "train-watcher",
"private": true,
"packageManager": "pnpm@10.12.1",
"scripts": {
"dev:frontend": "pnpm --filter train-watcher-frontend dev",
"build:frontend": "pnpm --filter train-watcher-frontend build",
"lint:frontend": "pnpm --filter train-watcher-frontend lint",
"typecheck:frontend": "pnpm --filter train-watcher-frontend typecheck"
}
}
+2138
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -0,0 +1,2 @@
packages:
- "frontend"
+24
View File
@@ -0,0 +1,24 @@
Я хочу спланировать веб-приложение с адаптивным интерфейсом.
Логические объекты:
1. пользователи
2. тренажеры
3. упражнения
4. тренировки
тренажеры и упражнения будут какие-то стандартные,
но пользователь также может добавлять свои и прикрепить к ним картинки
отображаются в виде карточек
Открывая меню тренажера/упражнения пользователь может выбрать свой рабочий вес.
Также отслеживать количество подходов, которые сделал пользователь и с каким весом каждый подход.
Приложение должно отслеживать прогрессию весов, калораж тренировок
В перспективе можно будет добавить еще один тип логических объектов - мышцы, и зависимость каждого тренажера/упражнения к мышцам,
таким образом приложение сможет составлять прорграмму тренировок исходя из запроса пользователя (но это пока не реализуем)
Стэк:
Бэкенд: Python 3.14+uv+ty+ruff+FastAPI+SQLAlchemy+Alembic+Postgres
Фронтенд: TypeScript+pnpm+React+(я не особо шарю за фронт, предложи актуальные необходимые технологии)
Бэкенд для фронтенда и логику раздели на два сервиса, соедини их через REST API
(Впоследствии логический сервис может разростись сильно вширь, так что отделим его от бэкенда для фронтенда)
+6
View File
@@ -0,0 +1,6 @@
.venv/
.ruff_cache/
__pycache__/
**/__pycache__/
*.pyc
.pytest_cache/
+11
View File
@@ -0,0 +1,11 @@
FROM ghcr.io/astral-sh/uv:python3.14-bookworm-slim
WORKDIR /app
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev
COPY app ./app
COPY alembic.ini ./alembic.ini
COPY alembic ./alembic
EXPOSE 8000
CMD ["/app/.venv/bin/uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+37
View File
@@ -0,0 +1,37 @@
[alembic]
script_location = alembic
prepend_sys_path = .
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+44
View File
@@ -0,0 +1,44 @@
from logging.config import fileConfig
from sqlalchemy import engine_from_config, pool
from alembic import context
from app.core import settings
from app.models import Base
config = context.config
config.set_main_option("sqlalchemy.url", settings.database_url)
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
context.configure(
url=settings.database_url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
@@ -0,0 +1,36 @@
"""initial bff schema
Revision ID: 0001_initial
Revises:
Create Date: 2026-05-28 10:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from alembic import op
revision: str = "0001_initial"
down_revision: str | None = None
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"bff_users",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("email", sa.String(length=320), nullable=False),
sa.Column("password_hash", sa.String(length=512), nullable=False),
sa.Column("display_name", sa.String(length=160), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
)
op.create_index("ix_bff_users_email", "bff_users", ["email"], unique=True)
def downgrade() -> None:
op.drop_index("ix_bff_users_email", table_name="bff_users")
op.drop_table("bff_users")
View File
+30
View File
@@ -0,0 +1,30 @@
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
database_url: str = "postgresql+psycopg://train_watcher:train_watcher@localhost:5432/train_watcher"
logic_base_url: str = "http://localhost:8002"
service_token: str = "dev-service-token-change-me"
jwt_secret: str = "dev-jwt-secret-change-me"
jwt_algorithm: str = "HS256"
access_token_ttl_minutes: int = 60 * 24
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"
max_upload_bytes: int = 5 * 1024 * 1024
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
@lru_cache
def get_settings() -> Settings:
return Settings()
settings = get_settings()
+34
View File
@@ -0,0 +1,34 @@
from collections.abc import Generator
from time import sleep
from sqlalchemy import MetaData, create_engine, text
from sqlalchemy.exc import OperationalError
from sqlalchemy.orm import Session, sessionmaker
from app.core import settings
engine = create_engine(settings.database_url, pool_pre_ping=True)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
def create_schema(metadata: MetaData, attempts: int = 30, delay_seconds: int = 2) -> None:
last_error: OperationalError | None = None
for _ in range(attempts):
try:
with engine.begin() as connection:
connection.execute(text("SELECT 1"))
metadata.create_all(bind=connection)
return
except OperationalError as exc:
last_error = exc
sleep(delay_seconds)
if last_error:
raise last_error
def get_db() -> Generator[Session]:
db = SessionLocal()
try:
yield db
finally:
db.close()
+193
View File
@@ -0,0 +1,193 @@
from typing import Annotated, Any
import httpx
from fastapi import Depends, FastAPI, File, HTTPException, Query, UploadFile, status
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.core import settings
from app.db import create_schema, get_db
from app.models import Base, User
from app.s3 import upload_catalog_image
from app.schemas import MediaUploadRead, TokenRead, UserCreate, UserLogin, UserRead
from app.security import create_access_token, get_current_user, hash_password, verify_password
app = FastAPI(title="Train Watcher BFF", version="0.1.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173", "http://127.0.0.1:5173"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
Db = Annotated[Session, Depends(get_db)]
CurrentUser = Annotated[User, Depends(get_current_user)]
@app.on_event("startup")
def on_startup() -> None:
create_schema(Base.metadata)
@app.get("/health")
def health() -> dict[str, str]:
return {"status": "ok"}
@app.post("/auth/register", response_model=TokenRead, status_code=status.HTTP_201_CREATED)
def register(payload: UserCreate, db: Db) -> TokenRead:
user = User(
email=payload.email.lower(),
password_hash=hash_password(payload.password),
display_name=payload.display_name,
)
db.add(user)
try:
db.commit()
except IntegrityError as exc:
db.rollback()
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Email already registered",
) from exc
db.refresh(user)
return TokenRead(access_token=create_access_token(user), user=UserRead.model_validate(user))
@app.post("/auth/login", response_model=TokenRead)
def login(payload: UserLogin, db: Db) -> TokenRead:
user = db.scalar(select(User).where(User.email == payload.email.lower()))
if not user or not verify_password(payload.password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid email or password",
)
return TokenRead(access_token=create_access_token(user), user=UserRead.model_validate(user))
@app.post("/auth/logout")
def logout() -> dict[str, str]:
return {"status": "ok"}
@app.get("/me", response_model=UserRead)
def me(user: CurrentUser) -> User:
return user
def logic_headers(user: User) -> dict[str, str]:
return {"X-Service-Token": settings.service_token, "X-User-Id": str(user.id)}
async def logic_request(
method: str,
path: str,
user: User,
*,
json: dict[str, Any] | None = None,
params: dict[str, Any] | None = None,
) -> Any:
async with httpx.AsyncClient(base_url=settings.logic_base_url, timeout=10) as client:
response = await client.request(
method,
path,
headers=logic_headers(user),
json=json,
params=params,
)
if response.status_code >= 400:
try:
detail = response.json().get("detail", response.text)
except ValueError:
detail = response.text
raise HTTPException(status_code=response.status_code, detail=detail)
if not response.content:
return None
return response.json()
@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)$")],
file: Annotated[UploadFile, File()],
) -> dict[str, str]:
return await upload_catalog_image(file, user.id, entity_type)
@app.get("/catalog/equipment")
async def list_equipment(user: CurrentUser, search: str | None = None) -> Any:
return await logic_request(
"GET", "/internal/catalog/equipment", user, params={"search": search}
)
@app.post("/catalog/equipment", status_code=status.HTTP_201_CREATED)
async def create_equipment(payload: dict[str, Any], user: CurrentUser) -> Any:
return await logic_request("POST", "/internal/catalog/equipment", user, json=payload)
@app.get("/catalog/exercises")
async def list_exercises(user: CurrentUser, search: str | None = None) -> Any:
return await logic_request(
"GET", "/internal/catalog/exercises", user, params={"search": search}
)
@app.post("/catalog/exercises", status_code=status.HTTP_201_CREATED)
async def create_exercise(payload: dict[str, Any], user: CurrentUser) -> Any:
return await logic_request("POST", "/internal/catalog/exercises", user, json=payload)
@app.get("/workouts")
async def list_workouts(user: CurrentUser) -> Any:
return await logic_request("GET", "/internal/workouts", user)
@app.post("/workouts", status_code=status.HTTP_201_CREATED)
async def create_workout(payload: dict[str, Any], user: CurrentUser) -> Any:
return await logic_request("POST", "/internal/workouts", user, json=payload)
@app.get("/workouts/{workout_id}")
async def get_workout(workout_id: str, user: CurrentUser) -> Any:
return await logic_request("GET", f"/internal/workouts/{workout_id}", user)
@app.patch("/workouts/{workout_id}")
async def update_workout(workout_id: str, payload: dict[str, Any], user: CurrentUser) -> Any:
return await logic_request("PATCH", f"/internal/workouts/{workout_id}", user, json=payload)
@app.post("/workouts/{workout_id}/items", status_code=status.HTTP_201_CREATED)
async def add_workout_item(workout_id: str, payload: dict[str, Any], user: CurrentUser) -> Any:
return await logic_request("POST", f"/internal/workouts/{workout_id}/items", user, json=payload)
@app.post("/workout-items/{item_id}/sets", status_code=status.HTTP_201_CREATED)
async def add_workout_set(item_id: str, payload: dict[str, Any], user: CurrentUser) -> Any:
return await logic_request(
"POST", f"/internal/workout-items/{item_id}/sets", user, json=payload
)
@app.get("/analytics/progression")
async def progression(
user: CurrentUser,
kind: Annotated[str, Query(pattern="^(exercise|equipment)$")] = "exercise",
entity_id: str | None = None,
) -> Any:
return await logic_request(
"GET",
"/internal/analytics/progression",
user,
params={"kind": kind, "entity_id": entity_id},
)
@app.get("/analytics/calories")
async def calories(user: CurrentUser) -> Any:
return await logic_request("GET", "/internal/analytics/calories", user)
+25
View File
@@ -0,0 +1,25 @@
import uuid
from datetime import UTC, datetime
from sqlalchemy import DateTime, String
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
class Base(DeclarativeBase):
pass
def now() -> datetime:
return datetime.now(UTC)
class User(Base):
__tablename__ = "bff_users"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
email: Mapped[str] = mapped_column(String(320), unique=True, index=True)
password_hash: Mapped[str] = mapped_column(String(512))
display_name: Mapped[str] = mapped_column(String(160))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now, onupdate=now)
+58
View File
@@ -0,0 +1,58 @@
import pathlib
import uuid
import boto3
from fastapi import HTTPException, UploadFile, status
from app.core import settings
ALLOWED_CONTENT_TYPES = {
"image/jpeg": ".jpg",
"image/png": ".png",
"image/webp": ".webp",
}
def s3_client():
return 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,
)
async def upload_catalog_image(
file: UploadFile,
user_id: uuid.UUID,
entity_type: str,
) -> dict[str, str]:
if file.content_type not in ALLOWED_CONTENT_TYPES:
raise HTTPException(
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
detail="Only JPEG, PNG and WEBP images are supported",
)
content = await file.read()
if len(content) > settings.max_upload_bytes:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail="File is too large",
)
extension = ALLOWED_CONTENT_TYPES[file.content_type]
original_stem = pathlib.Path(file.filename or "image").stem[:48]
object_key = f"users/{user_id}/{entity_type}/pending/{uuid.uuid4()}-{original_stem}{extension}"
s3_client().put_object(
Bucket=settings.s3_bucket,
Key=object_key,
Body=content,
ContentType=file.content_type,
)
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}",
}
+35
View File
@@ -0,0 +1,35 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict, EmailStr, Field
class UserCreate(BaseModel):
email: EmailStr
password: str = Field(min_length=8, max_length=128)
display_name: str = Field(min_length=1, max_length=160)
class UserLogin(BaseModel):
email: EmailStr
password: str
class UserRead(BaseModel):
id: uuid.UUID
email: EmailStr
display_name: str
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class TokenRead(BaseModel):
access_token: str
token_type: str = "bearer"
user: UserRead
class MediaUploadRead(BaseModel):
image_s3_url: str
image_s3_key: str
+55
View File
@@ -0,0 +1,55 @@
import uuid
from datetime import UTC, datetime, timedelta
from typing import Annotated
import jwt
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from pwdlib import PasswordHash
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core import settings
from app.db import get_db
from app.models import User
password_hash = PasswordHash.recommended()
bearer = HTTPBearer(auto_error=False)
def hash_password(password: str) -> str:
return password_hash.hash(password)
def verify_password(password: str, hashed: str) -> bool:
return password_hash.verify(password, hashed)
def create_access_token(user: User) -> str:
expires_at = datetime.now(UTC) + timedelta(minutes=settings.access_token_ttl_minutes)
payload = {"sub": str(user.id), "email": user.email, "exp": expires_at}
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
def get_current_user(
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(bearer)],
db: Annotated[Session, Depends(get_db)],
) -> User:
if credentials is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing bearer token")
try:
payload = jwt.decode(
credentials.credentials,
settings.jwt_secret,
algorithms=[settings.jwt_algorithm],
)
user_id = uuid.UUID(payload["sub"])
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token",
) from exc
user = db.scalar(select(User).where(User.id == user_id))
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
return user
+31
View File
@@ -0,0 +1,31 @@
[project]
name = "train-watcher-bff"
version = "0.1.0"
requires-python = ">=3.14"
dependencies = [
"alembic>=1.16.0",
"boto3>=1.38.23",
"fastapi[standard]>=0.115.12",
"email-validator>=2.2.0",
"httpx>=0.28.1",
"psycopg[binary]>=3.2.9",
"pydantic-settings>=2.9.1",
"pyjwt>=2.10.1",
"pwdlib[argon2]>=0.2.1",
"python-multipart>=0.0.20",
"sqlalchemy>=2.0.41",
]
[dependency-groups]
dev = [
"pytest>=8.3.5",
"ruff>=0.11.11",
"ty>=0.0.1a6",
]
[tool.ruff]
line-length = 100
target-version = "py314"
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B"]
+1191
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -0,0 +1,6 @@
.venv/
.ruff_cache/
__pycache__/
**/__pycache__/
*.pyc
.pytest_cache/
+11
View File
@@ -0,0 +1,11 @@
FROM ghcr.io/astral-sh/uv:python3.14-bookworm-slim
WORKDIR /app
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev
COPY app ./app
COPY alembic.ini ./alembic.ini
COPY alembic ./alembic
EXPOSE 8000
CMD ["/app/.venv/bin/uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+37
View File
@@ -0,0 +1,37 @@
[alembic]
script_location = alembic
prepend_sys_path = .
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+47
View File
@@ -0,0 +1,47 @@
from logging.config import fileConfig
from sqlalchemy import engine_from_config, pool
from alembic import context
from app.core import settings
from app.models import Base
config = context.config
config.set_main_option("sqlalchemy.url", settings.database_url)
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
context.configure(
url=settings.database_url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
@@ -0,0 +1,117 @@
"""initial logic schema
Revision ID: 0001_initial
Revises:
Create Date: 2026-05-28 10:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from alembic import op
revision: str = "0001_initial"
down_revision: str | None = None
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"logic_equipment",
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("name", sa.String(length=160), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
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("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
)
op.create_index("ix_logic_equipment_is_builtin", "logic_equipment", ["is_builtin"])
op.create_index("ix_logic_equipment_name", "logic_equipment", ["name"])
op.create_index("ix_logic_equipment_owner_user_id", "logic_equipment", ["owner_user_id"])
op.create_table(
"logic_exercises",
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("equipment_id", postgresql.UUID(as_uuid=True), nullable=True),
sa.Column("name", sa.String(length=160), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
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.ForeignKeyConstraint(["equipment_id"], ["logic_equipment.id"]),
)
op.create_index("ix_logic_exercises_is_builtin", "logic_exercises", ["is_builtin"])
op.create_index("ix_logic_exercises_name", "logic_exercises", ["name"])
op.create_index("ix_logic_exercises_owner_user_id", "logic_exercises", ["owner_user_id"])
op.create_table(
"logic_workouts",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("notes", sa.Text(), nullable=True),
sa.Column("estimated_calories", sa.Numeric(10, 2), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
)
op.create_index("ix_logic_workouts_user_id", "logic_workouts", ["user_id"])
op.create_table(
"logic_workout_items",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("workout_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("exercise_id", postgresql.UUID(as_uuid=True), nullable=True),
sa.Column("equipment_id", postgresql.UUID(as_uuid=True), nullable=True),
sa.Column("order_index", sa.Integer(), nullable=False),
sa.Column("planned_working_weight", sa.Numeric(8, 2), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.CheckConstraint(
"(exercise_id IS NOT NULL AND equipment_id IS NULL) OR "
"(exercise_id IS NULL AND equipment_id IS NOT NULL)",
name="ck_workout_item_exactly_one_entity",
),
sa.ForeignKeyConstraint(["equipment_id"], ["logic_equipment.id"]),
sa.ForeignKeyConstraint(["exercise_id"], ["logic_exercises.id"]),
sa.ForeignKeyConstraint(["workout_id"], ["logic_workouts.id"], ondelete="CASCADE"),
)
op.create_table(
"logic_workout_sets",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("workout_item_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("set_index", sa.Integer(), nullable=False),
sa.Column("weight", sa.Numeric(8, 2), nullable=False),
sa.Column("reps", sa.Integer(), nullable=False),
sa.Column("duration_seconds", sa.Integer(), nullable=True),
sa.Column("calories", sa.Numeric(8, 2), nullable=True),
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["workout_item_id"], ["logic_workout_items.id"], ondelete="CASCADE"
),
)
def downgrade() -> None:
op.drop_table("logic_workout_sets")
op.drop_table("logic_workout_items")
op.drop_index("ix_logic_workouts_user_id", table_name="logic_workouts")
op.drop_table("logic_workouts")
op.drop_index("ix_logic_exercises_owner_user_id", table_name="logic_exercises")
op.drop_index("ix_logic_exercises_name", table_name="logic_exercises")
op.drop_index("ix_logic_exercises_is_builtin", table_name="logic_exercises")
op.drop_table("logic_exercises")
op.drop_index("ix_logic_equipment_owner_user_id", table_name="logic_equipment")
op.drop_index("ix_logic_equipment_name", table_name="logic_equipment")
op.drop_index("ix_logic_equipment_is_builtin", table_name="logic_equipment")
op.drop_table("logic_equipment")
View File
+18
View File
@@ -0,0 +1,18 @@
from functools import lru_cache
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"
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
@lru_cache
def get_settings() -> Settings:
return Settings()
settings = get_settings()
+34
View File
@@ -0,0 +1,34 @@
from collections.abc import Generator
from time import sleep
from sqlalchemy import MetaData, create_engine, text
from sqlalchemy.exc import OperationalError
from sqlalchemy.orm import Session, sessionmaker
from app.core import settings
engine = create_engine(settings.database_url, pool_pre_ping=True)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
def create_schema(metadata: MetaData, attempts: int = 30, delay_seconds: int = 2) -> None:
last_error: OperationalError | None = None
for _ in range(attempts):
try:
with engine.begin() as connection:
connection.execute(text("SELECT 1"))
metadata.create_all(bind=connection)
return
except OperationalError as exc:
last_error = exc
sleep(delay_seconds)
if last_error:
raise last_error
def get_db() -> Generator[Session]:
db = SessionLocal()
try:
yield db
finally:
db.close()
+412
View File
@@ -0,0 +1,412 @@
import uuid
from collections import defaultdict
from datetime import UTC, datetime
from typing import Annotated
from fastapi import 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.schemas import (
CaloriesRead,
EquipmentCreate,
EquipmentRead,
ExerciseCreate,
ExerciseRead,
ProgressionPoint,
ProgressionRead,
WorkoutCreate,
WorkoutItemCreate,
WorkoutItemRead,
WorkoutRead,
WorkoutSetCreate,
WorkoutSetRead,
WorkoutUpdate,
)
app = FastAPI(title="Train Watcher Logic Service", version="0.1.0")
def require_service_token(x_service_token: Annotated[str | None, Header()] = None) -> None:
if x_service_token != settings.service_token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid service token",
)
def get_user_id(x_user_id: Annotated[str | None, Header()] = None) -> uuid.UUID:
if not x_user_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Missing X-User-Id header",
)
try:
return uuid.UUID(x_user_id)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid X-User-Id",
) from exc
InternalAuth = Depends(require_service_token)
Db = Annotated[Session, Depends(get_db)]
CurrentUserId = Annotated[uuid.UUID, Depends(get_user_id)]
@app.on_event("startup")
def on_startup() -> None:
create_schema(Base.metadata)
with SessionLocal() as db:
seed_builtin_catalog(db)
@app.get("/health")
def health() -> dict[str, str]:
return {"status": "ok"}
def seed_builtin_catalog(db: Session) -> None:
exists = db.scalar(
select(func.count()).select_from(Equipment).where(Equipment.is_builtin.is_(True))
)
if exists:
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,
),
]
)
db.commit()
def accessible_equipment(db: Session, user_id: uuid.UUID):
return select(Equipment).where(
(Equipment.is_builtin.is_(True)) | (Equipment.owner_user_id == user_id)
)
def accessible_exercises(db: Session, user_id: uuid.UUID):
return select(Exercise).where(
(Exercise.is_builtin.is_(True)) | (Exercise.owner_user_id == user_id)
)
@app.get(
"/internal/catalog/equipment",
dependencies=[InternalAuth],
response_model=list[EquipmentRead],
)
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
)
if search:
statement = statement.where(Equipment.name.ilike(f"%{search}%"))
return list(db.scalars(statement))
@app.post(
"/internal/catalog/equipment",
dependencies=[InternalAuth],
response_model=EquipmentRead,
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)
db.commit()
db.refresh(equipment)
return equipment
@app.get(
"/internal/catalog/exercises",
dependencies=[InternalAuth],
response_model=list[ExerciseRead],
)
def list_exercises(db: Db, user_id: CurrentUserId, search: str | None = None) -> list[Exercise]:
statement = accessible_exercises(db, user_id).order_by(
Exercise.is_builtin.desc(), Exercise.name
)
if search:
statement = statement.where(Exercise.name.ilike(f"%{search}%"))
return list(db.scalars(statement))
@app.post(
"/internal/catalog/exercises",
dependencies=[InternalAuth],
response_model=ExerciseRead,
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)
db.commit()
db.refresh(exercise)
return exercise
@app.get("/internal/workouts", dependencies=[InternalAuth], response_model=list[WorkoutRead])
def list_workouts(db: Db, user_id: CurrentUserId) -> list[Workout]:
return list(
db.scalars(
select(Workout)
.where(Workout.user_id == user_id)
.options(selectinload(Workout.items).selectinload(WorkoutItem.sets))
.order_by(Workout.started_at.desc())
)
)
@app.post(
"/internal/workouts",
dependencies=[InternalAuth],
response_model=WorkoutRead,
status_code=status.HTTP_201_CREATED,
)
def create_workout(payload: WorkoutCreate, db: Db, user_id: CurrentUserId) -> Workout:
workout = Workout(
user_id=user_id,
started_at=payload.started_at or datetime.now(UTC),
notes=payload.notes,
)
db.add(workout)
db.commit()
db.refresh(workout)
return workout
@app.get("/internal/workouts/{workout_id}", dependencies=[InternalAuth], response_model=WorkoutRead)
def get_workout(workout_id: uuid.UUID, db: Db, user_id: CurrentUserId) -> Workout:
workout = db.scalar(
select(Workout)
.where(Workout.id == workout_id, Workout.user_id == user_id)
.options(selectinload(Workout.items).selectinload(WorkoutItem.sets))
)
if not workout:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Workout not found")
return workout
@app.patch(
"/internal/workouts/{workout_id}",
dependencies=[InternalAuth],
response_model=WorkoutRead,
)
def update_workout(
workout_id: uuid.UUID, payload: WorkoutUpdate, db: Db, user_id: CurrentUserId
) -> Workout:
workout = get_workout(workout_id, db, user_id)
if payload.finished_at is not None:
workout.finished_at = payload.finished_at
if payload.notes is not None:
workout.notes = payload.notes
recalculate_workout_calories(db, workout.id)
db.commit()
db.refresh(workout)
return workout
@app.post(
"/internal/workouts/{workout_id}/items",
dependencies=[InternalAuth],
response_model=WorkoutItemRead,
status_code=status.HTTP_201_CREATED,
)
def add_workout_item(
workout_id: uuid.UUID, payload: WorkoutItemCreate, db: Db, user_id: CurrentUserId
) -> WorkoutItem:
workout = get_workout(workout_id, db, user_id)
if 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")
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")
next_index = payload.order_index
if next_index is None:
next_index = len(workout.items)
item = WorkoutItem(
workout_id=workout.id,
**payload.model_dump(exclude={"order_index"}),
order_index=next_index,
)
db.add(item)
db.commit()
db.refresh(item)
return item
@app.post(
"/internal/workout-items/{item_id}/sets",
dependencies=[InternalAuth],
response_model=WorkoutSetRead,
status_code=status.HTTP_201_CREATED,
)
def add_workout_set(
item_id: uuid.UUID,
payload: WorkoutSetCreate,
db: Db,
user_id: CurrentUserId,
) -> WorkoutSet:
item = db.scalar(
select(WorkoutItem)
.join(Workout)
.where(WorkoutItem.id == item_id, Workout.user_id == user_id)
.options(selectinload(WorkoutItem.sets), selectinload(WorkoutItem.exercise))
)
if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Workout item not found")
calories = payload.calories
if calories is None:
calories = estimate_set_calories(item, payload)
workout_set = WorkoutSet(
workout_item_id=item.id,
set_index=len(item.sets) + 1,
weight=payload.weight,
reps=payload.reps,
duration_seconds=payload.duration_seconds,
calories=calories,
completed_at=payload.completed_at or datetime.now(UTC),
)
db.add(workout_set)
db.flush()
recalculate_workout_calories(db, item.workout_id)
db.commit()
db.refresh(workout_set)
return workout_set
def estimate_set_calories(item: WorkoutItem, payload: WorkoutSetCreate) -> float:
if item.exercise and item.exercise.default_calories_per_minute and payload.duration_seconds:
return round(
float(item.exercise.default_calories_per_minute) * payload.duration_seconds / 60,
2,
)
return round((payload.weight * max(payload.reps, 1)) / 120, 2)
def recalculate_workout_calories(db: Session, workout_id: uuid.UUID) -> None:
total = db.scalar(
select(func.coalesce(func.sum(WorkoutSet.calories), 0))
.join(WorkoutItem, WorkoutSet.workout_item_id == WorkoutItem.id)
.where(WorkoutItem.workout_id == workout_id)
)
workout = db.get(Workout, workout_id)
if workout:
workout.estimated_calories = float(total or 0)
@app.get(
"/internal/analytics/progression",
dependencies=[InternalAuth],
response_model=ProgressionRead,
)
def get_progression(
db: Db,
user_id: CurrentUserId,
kind: str = Query(pattern="^(exercise|equipment)$"),
entity_id: uuid.UUID | None = None,
) -> ProgressionRead:
statement = (
select(Workout.started_at, WorkoutSet.weight, WorkoutSet.reps)
.join(WorkoutItem, WorkoutSet.workout_item_id == WorkoutItem.id)
.join(Workout, WorkoutItem.workout_id == Workout.id)
.where(Workout.user_id == user_id)
.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)
rows = list(db.execute(statement))
grouped: dict[str, dict[str, float]] = defaultdict(lambda: {"max_weight": 0, "volume": 0})
weights: list[float] = []
for started_at, weight, reps in rows:
date_key = started_at.date().isoformat()
numeric_weight = float(weight or 0)
grouped[date_key]["max_weight"] = max(grouped[date_key]["max_weight"], numeric_weight)
grouped[date_key]["volume"] += numeric_weight * int(reps or 0)
weights.append(numeric_weight)
points = [
ProgressionPoint(date=date, max_weight=values["max_weight"], volume=values["volume"])
for date, values in sorted(grouped.items())
]
previous_delta = None
if len(weights) >= 2:
previous_delta = round(weights[-1] - weights[-2], 2)
return ProgressionRead(
last_weight=weights[-1] if weights else None,
max_weight=max(weights) if weights else None,
previous_delta=previous_delta,
points=points,
)
@app.get("/internal/analytics/calories", dependencies=[InternalAuth], response_model=CaloriesRead)
def get_calories(db: Db, user_id: CurrentUserId) -> CaloriesRead:
workouts = list(
db.scalars(
select(Workout).where(Workout.user_id == user_id).order_by(Workout.started_at.desc())
)
)
total = sum(float(workout.estimated_calories or 0) for workout in workouts)
return CaloriesRead(
total_calories=round(total, 2),
workouts=[
{
"id": str(workout.id),
"date": workout.started_at.date().isoformat(),
"calories": float(workout.estimated_calories or 0),
}
for workout in workouts
],
)
+125
View File
@@ -0,0 +1,125 @@
from __future__ import annotations
import uuid
from datetime import UTC, datetime
from sqlalchemy import (
Boolean,
CheckConstraint,
DateTime,
ForeignKey,
Integer,
Numeric,
String,
Text,
)
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
class Base(DeclarativeBase):
pass
def uuid_pk() -> Mapped[uuid.UUID]:
return mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
def now() -> datetime:
return datetime.now(UTC)
class TimestampMixin:
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now, onupdate=now)
class Equipment(Base, TimestampMixin):
__tablename__ = "logic_equipment"
id: Mapped[uuid.UUID] = uuid_pk()
owner_user_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), index=True)
name: Mapped[str] = mapped_column(String(160), index=True)
description: Mapped[str | None] = mapped_column(Text)
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)
workout_items: Mapped[list[WorkoutItem]] = relationship(back_populates="equipment")
class Exercise(Base, TimestampMixin):
__tablename__ = "logic_exercises"
id: Mapped[uuid.UUID] = uuid_pk()
owner_user_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), index=True)
equipment_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("logic_equipment.id"))
name: Mapped[str] = mapped_column(String(160), index=True)
description: Mapped[str | None] = mapped_column(Text)
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))
equipment: Mapped[Equipment | None] = relationship()
workout_items: Mapped[list[WorkoutItem]] = relationship(back_populates="exercise")
class Workout(Base, TimestampMixin):
__tablename__ = "logic_workouts"
id: Mapped[uuid.UUID] = uuid_pk()
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True)
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
notes: Mapped[str | None] = mapped_column(Text)
estimated_calories: Mapped[float] = mapped_column(Numeric(10, 2), default=0)
items: Mapped[list[WorkoutItem]] = relationship(
back_populates="workout", cascade="all, delete-orphan", order_by="WorkoutItem.order_index"
)
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)",
name="ck_workout_item_exactly_one_entity",
),
)
id: Mapped[uuid.UUID] = uuid_pk()
workout_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("logic_workouts.id", ondelete="CASCADE")
)
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)
planned_working_weight: Mapped[float | None] = mapped_column(Numeric(8, 2))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
workout: Mapped[Workout] = relationship(back_populates="items")
exercise: Mapped[Exercise | None] = relationship(back_populates="workout_items")
equipment: Mapped[Equipment | None] = relationship(back_populates="workout_items")
sets: Mapped[list[WorkoutSet]] = relationship(
back_populates="workout_item", cascade="all, delete-orphan", order_by="WorkoutSet.set_index"
)
class WorkoutSet(Base):
__tablename__ = "logic_workout_sets"
id: Mapped[uuid.UUID] = uuid_pk()
workout_item_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("logic_workout_items.id", ondelete="CASCADE")
)
set_index: Mapped[int] = mapped_column(Integer)
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)
calories: Mapped[float | None] = mapped_column(Numeric(8, 2))
completed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
workout_item: Mapped[WorkoutItem] = relationship(back_populates="sets")
+122
View File
@@ -0,0 +1,122 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field, model_validator
class EquipmentCreate(BaseModel):
name: str = Field(min_length=1, max_length=160)
description: str | None = None
image_s3_url: str | None = None
image_s3_key: str | None = None
class EquipmentRead(EquipmentCreate):
id: uuid.UUID
owner_user_id: uuid.UUID | None
is_builtin: bool
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
class ExerciseCreate(BaseModel):
name: str = Field(min_length=1, max_length=160)
description: str | None = None
equipment_id: uuid.UUID | None = None
image_s3_url: str | None = None
image_s3_key: str | None = None
default_calories_per_minute: float | None = None
class ExerciseRead(ExerciseCreate):
id: uuid.UUID
owner_user_id: uuid.UUID | None
is_builtin: bool
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
class WorkoutCreate(BaseModel):
started_at: datetime | None = None
notes: str | None = None
class WorkoutUpdate(BaseModel):
finished_at: datetime | None = None
notes: str | None = None
class WorkoutSetCreate(BaseModel):
weight: float = 0
reps: int = 0
duration_seconds: int | None = None
calories: float | None = None
completed_at: datetime | None = None
class WorkoutSetRead(WorkoutSetCreate):
id: uuid.UUID
workout_item_id: uuid.UUID
set_index: int
completed_at: datetime
model_config = ConfigDict(from_attributes=True)
class WorkoutItemCreate(BaseModel):
exercise_id: uuid.UUID | None = None
equipment_id: uuid.UUID | None = None
order_index: int | None = None
planned_working_weight: float | None = None
@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")
return self
class WorkoutItemRead(WorkoutItemCreate):
id: uuid.UUID
workout_id: uuid.UUID
order_index: int
created_at: datetime
sets: list[WorkoutSetRead] = []
model_config = ConfigDict(from_attributes=True)
class WorkoutRead(BaseModel):
id: uuid.UUID
user_id: uuid.UUID
started_at: datetime
finished_at: datetime | None
notes: str | None
estimated_calories: float
created_at: datetime
updated_at: datetime
items: list[WorkoutItemRead] = []
model_config = ConfigDict(from_attributes=True)
class ProgressionPoint(BaseModel):
date: str
max_weight: float
volume: float
class ProgressionRead(BaseModel):
last_weight: float | None
max_weight: float | None
previous_delta: float | None
points: list[ProgressionPoint]
class CaloriesRead(BaseModel):
total_calories: float
workouts: list[dict[str, str | float]]
+25
View File
@@ -0,0 +1,25 @@
[project]
name = "train-watcher-logic"
version = "0.1.0"
requires-python = ">=3.14"
dependencies = [
"alembic>=1.16.0",
"fastapi[standard]>=0.115.12",
"psycopg[binary]>=3.2.9",
"pydantic-settings>=2.9.1",
"sqlalchemy>=2.0.41",
]
[dependency-groups]
dev = [
"pytest>=8.3.5",
"ruff>=0.11.11",
"ty>=0.0.1a6",
]
[tool.ruff]
line-length = 100
target-version = "py314"
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B"]
+1001
View File
File diff suppressed because it is too large Load Diff