diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5da03c9..c6721e2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,62 +1,41 @@ +import { Link, Outlet, RouterProvider, createRootRoute, createRoute, createRouter, useNavigate } from "@tanstack/react-router"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { FormEvent, useEffect, useMemo, useState } from "react"; +import { useState } from "react"; -import { api, type AuthState } from "./api"; -import type { CatalogEntity, Workout, WorkoutItem as WorkoutItemType, WorkoutSet } from "./types"; +import { ApiError, api, type AuthState } from "./api"; +import { AnalyticsPage } from "./features/analytics/AnalyticsPage"; +import { AuthProvider, useAuth } from "./features/auth/AuthContext"; +import { AuthScreen } from "./features/auth/AuthScreen"; +import { clearAuth, loadAuth, saveAuth } from "./features/auth/authStorage"; +import { CatalogPage } from "./features/catalog/CatalogPage"; +import { HistoryPage } from "./features/history/HistoryPage"; +import { WorkoutDetailPage } from "./features/history/WorkoutDetailPage"; +import { ActiveWorkoutPage } from "./features/workout/ActiveWorkoutPage"; +import { invalidateWorkoutQueries } from "./features/workout/hooks"; +import { Metric } from "./shared/Metric"; -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(loadAuth); - const [tab, setTab] = useState("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 ; - } +function AppShell() { + const { auth, logout } = useAuth(); + const nav = [ + { to: "/", label: "Сегодня" }, + { to: "/workout/active", label: "Тренировка" }, + { to: "/catalog/exercises", label: "Упражнения" }, + { to: "/history", label: "История" }, + { to: "/analytics", label: "Аналитика" }, + ] as const; return (
-
- {tab === "dashboard" && setTab("workout")} />} - {tab === "catalog" && } - {tab === "workout" && } - {tab === "history" && } - {tab === "analytics" && } +
+
); } -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(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 ( -
-
-

Progressive overload tracker

-

Фиксируй подходы, рабочий вес и динамику без лишнего шума.

-

- MVP уже разделен на frontend, BFF и logic-service, а изображения каталога хранятся в MinIO как S3-объекты. -

-
-
-

{mode === "login" ? "Вход" : "Регистрация"}

- {mode === "register" && ( - - )} - - - {error &&

{error}

} - - -
-
- ); -} - -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 ( -
-
-
-

Dashboard

-

Обзор

-
- -
-
- - - -
-
-

Последняя тренировка

- {latest ? :

Создай первую тренировку, чтобы увидеть историю.

} -
-
- ); -} - -function Catalog({ token }: { token: string }) { - const [kind, setKind] = useState<"equipment" | "exercise">("equipment"); - const [name, setName] = useState(""); - const [description, setDescription] = useState(""); - const [file, setFile] = useState(null); +function HomePage() { + const { auth } = useAuth(); + const navigate = useNavigate(); 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 ( -
-
-
-

Catalog

-

Тренажеры и упражнения

-
-
- - -
-
-
{ - event.preventDefault(); - createMutation.mutate(); - }} - > - - - - - {createMutation.error &&

{createMutation.error.message}

} -
-
- {list?.map((entity) => )} -
-
- ); -} - -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(null); - const [catalogKind, setCatalogKind] = useState<"exercise" | "equipment">("exercise"); - const [expandedItemId, setExpandedItemId] = useState(null); - const [showFinishModal, setShowFinishModal] = useState(false); - const [finishNotes, setFinishNotes] = useState(""); - const [elapsed, setElapsed] = useState(""); - - const current = useMemo( - () => activeWorkout ?? workouts.data?.find((w) => !w.finished_at) ?? null, - [activeWorkout, workouts.data], - ); - - useEffect(() => { - if (!current?.started_at) return; - const started = new Date(current.started_at).getTime(); - const tick = () => { - const diff = Date.now() - started; - const m = Math.floor(diff / 60000); - const s = Math.floor((diff % 60000) / 1000); - setElapsed(`${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`); - }; - tick(); - const id = setInterval(tick, 1000); - return () => clearInterval(id); - }, [current?.started_at]); - - const addedEntityIds = useMemo(() => { - const ids = new Set(); - current?.items.forEach((item) => { - if (item.exercise_id) ids.add(item.exercise_id); - if (item.equipment_id) ids.add(item.equipment_id); - }); - return ids; - }, [current?.items]); - - const catalogList = catalogKind === "exercise" ? exercises.data : equipment.data; + const activeWorkout = useQuery({ queryKey: ["workout", "active"], queryFn: () => api.activeWorkout(auth.accessToken) }); + const workouts = useQuery({ queryKey: ["workouts"], queryFn: () => api.workouts(auth.accessToken) }); + const calories = useQuery({ queryKey: ["calories"], queryFn: () => api.calories(auth.accessToken) }); const startMutation = useMutation({ - mutationFn: () => api.createWorkout(token), - onSuccess: (workout) => { - setActiveWorkout(workout); - void queryClient.invalidateQueries({ queryKey: ["workouts"] }); + mutationFn: () => api.createWorkout(auth.accessToken), + onSuccess: async () => { + await invalidateWorkoutQueries(queryClient); + void navigate({ to: "/workout/active" }); }, - }); - - const addItemMutation = useMutation({ - mutationFn: (entity: CatalogEntity) => { - if (!current) throw new Error("Нет активной тренировки"); - return api.addWorkoutItem(token, current.id, { - exercise_id: catalogKind === "exercise" ? entity.id : null, - equipment_id: catalogKind === "equipment" ? entity.id : null, - }); - }, - onSuccess: (item) => { - setActiveWorkout((w) => (w ? { ...w, items: [...w.items, item] } : w)); - setExpandedItemId(item.id); - void queryClient.invalidateQueries({ queryKey: ["workouts"] }); - }, - }); - - const removeItemMutation = useMutation({ - mutationFn: (itemId: string) => api.removeWorkoutItem(token, itemId), - onSuccess: (_, itemId) => { - setActiveWorkout((w) => (w ? { ...w, items: w.items.filter((i) => i.id !== itemId) } : w)); - if (expandedItemId === itemId) setExpandedItemId(null); - void queryClient.invalidateQueries({ queryKey: ["workouts"] }); - void queryClient.invalidateQueries({ queryKey: ["calories"] }); - }, - }); - - const addSetsMutation = useMutation({ - mutationFn: async ({ itemId, sets }: { itemId: string; sets: Array<{ weight: number; reps: number }> }) => { - const created: WorkoutSet[] = []; - for (const set of sets) { - created.push(await api.addWorkoutSet(token, itemId, set)); + onError: async (error) => { + if (error instanceof ApiError && error.status === 409) { + await invalidateWorkoutQueries(queryClient); + void navigate({ to: "/workout/active" }); } - return created; - }, - onSuccess: (workoutSets, { itemId }) => { - setActiveWorkout((w) => { - if (!w) return w; - const addedCalories = workoutSets.reduce((sum, set) => sum + (set.calories ?? 0), 0); - return { - ...w, - estimated_calories: w.estimated_calories + addedCalories, - items: w.items.map((item) => - item.id === itemId ? { ...item, sets: [...item.sets, ...workoutSets] } : item, - ), - }; - }); - void queryClient.invalidateQueries({ queryKey: ["workouts"] }); - void queryClient.invalidateQueries({ queryKey: ["calories"] }); }, }); - const removeSetMutation = useMutation({ - mutationFn: ({ itemId, setId }: { itemId: string; setId: string }) => - api.removeWorkoutSet(token, itemId, setId), - onSuccess: (_, { itemId, setId }) => { - setActiveWorkout((w) => { - if (!w) return w; - const items = w.items.map((item) => - item.id === itemId ? { ...item, sets: item.sets.filter((s) => s.id !== setId) } : item, - ); - return { ...w, items }; - }); - void queryClient.invalidateQueries({ queryKey: ["workouts"] }); - void queryClient.invalidateQueries({ queryKey: ["calories"] }); - }, - }); - - const finishMutation = useMutation({ - mutationFn: () => { - if (!current) throw new Error("Нет активной тренировки"); - return api.finishWorkout(token, current.id, finishNotes || undefined); - }, - onSuccess: () => { - setActiveWorkout(null); - setShowFinishModal(false); - setFinishNotes(""); - setExpandedItemId(null); - void queryClient.invalidateQueries({ queryKey: ["workouts"] }); - void queryClient.invalidateQueries({ queryKey: ["calories"] }); - }, - }); - - if (!current) { - return ( -
-
-
-

Workout

-

Тренировка

-
-
-
-

Готов к тренировке?

-

Добавляй упражнения и тренажеры, записывай подходы — всё в одном месте.

- -
-
- ); - } - - const totalSets = current.items.reduce((sum, item) => sum + item.sets.length, 0); + const active = activeWorkout.data; + const latestFinished = workouts.data?.find((workout) => workout.status === "finished"); return ( -
+
-

Workout

-

Текущая тренировка

-
-
- {elapsed || "00:00"} - {Math.round(current.estimated_calories)} ккал +

Сегодня

+

Состояние дня

-
-
-

Каталог

-
- - -
+
+
+

{active ? "Active workout" : "Ready state"}

+

{active ? "Активная тренировка" : "Нет активной тренировки"}

+

+ {active + ? `Начата ${new Date(active.started_at).toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" })}. Продолжи журнал подходов.` + : "Запусти сессию и добавляй упражнения по ходу тренировки — каталог откроется отдельно."} +

-
- {catalogList?.map((entity) => { - const added = addedEntityIds.has(entity.id); - return ( -
- {entity.image_s3_url ? :
TW
} -
-

{entity.name}

- -
-
- ); - })} -
-
- -
-

Тренировка ({current.items.length} эл., {totalSets} подх.)

- {current.items.length === 0 ? ( -

Добавь упражнения из каталога выше.

- ) : ( -
- {current.items.map((item) => ( - setExpandedItemId(expandedItemId === item.id ? null : item.id)} - onRemoveItem={() => removeItemMutation.mutate(item.id)} - onAddSets={(sets) => addSetsMutation.mutate({ itemId: item.id, sets })} - onRemoveSet={(setId) => removeSetMutation.mutate({ itemId: item.id, setId })} - isAddingSet={addSetsMutation.isPending} - isRemovingItem={removeItemMutation.isPending} - /> - ))} -
- )} -
- {showFinishModal && ( -
setShowFinishModal(false)}> -
e.stopPropagation()}> -

Завершить тренировку?

-
- - - -
- -
- - -
-
-
- )} +
+ w.status !== "discarded").length ?? 0} /> + + +
); } -function CartItemRow({ - item, - exercises, - equipment, - expanded, - onToggle, - onRemoveItem, - onAddSets, - onRemoveSet, - isAddingSet, - isRemovingItem, -}: { - item: WorkoutItemType; - exercises: CatalogEntity[] | undefined; - equipment: CatalogEntity[] | undefined; - expanded: boolean; - onToggle: () => void; - onRemoveItem: () => void; - onAddSets: (sets: Array<{ weight: number; reps: number }>) => void; - onRemoveSet: (setId: string) => void; - isAddingSet: boolean; - isRemovingItem: boolean; -}) { - const entityName = useMemo(() => { - if (item.exercise_id) return exercises?.find((e) => e.id === item.exercise_id)?.name ?? "Упражнение"; - if (item.equipment_id) return equipment?.find((e) => e.id === item.equipment_id)?.name ?? "Тренажер"; - return "Неизвестно"; - }, [item.exercise_id, item.equipment_id, exercises, equipment]); - const [draftCount, setDraftCount] = useState(3); - const [draftSets, setDraftSets] = useState(() => - Array.from({ length: 8 }, () => ({ weight: 60, reps: 8 })), - ); +const rootRoute = createRootRoute({ component: AppShell }); +const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: "/", component: HomePage }); +const activeWorkoutRoute = createRoute({ getParentRoute: () => rootRoute, path: "/workout/active", component: ActiveWorkoutPage }); +const catalogExercisesRoute = createRoute({ getParentRoute: () => rootRoute, path: "/catalog/exercises", 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 }); - function updateDraftSet(index: number, key: "weight" | "reps", value: number) { - setDraftSets((sets) => sets.map((set, i) => (i === index ? { ...set, [key]: value } : set))); +const routeTree = rootRoute.addChildren([ + indexRoute, + activeWorkoutRoute, + catalogExercisesRoute, + catalogEquipmentRoute, + historyRoute, + analyticsRoute, + workoutDetailRoute, +]); + +const router = createRouter({ routeTree }); + +declare module "@tanstack/react-router" { + interface Register { + router: typeof router; + } +} + +export function App() { + const [auth, setAuth] = useState(loadAuth); + const queryClient = useQueryClient(); + + function handleAuth(next: AuthState) { + saveAuth(next); + setAuth(next); } - function saveDraftSets() { - onAddSets(draftSets.slice(0, draftCount)); + function logout() { + clearAuth(); + queryClient.clear(); + setAuth(null); + } + + if (!auth) { + return ; } return ( -
-
-
- {entityName} - - {item.sets.length > 0 ? `${item.sets.length} × ${item.sets[item.sets.length - 1].weight} кг` : "нет подходов"} - -
- -
- {expanded && ( -
- {item.sets.length > 0 && ( -
- {item.sets.map((set) => ( -
- {set.set_index} - {set.weight} кг × {set.reps} - {set.calories ? Math.round(set.calories) : "—"} ккал - -
- ))} -
- )} -
-
- Новые подходы - {draftCount} -
- setDraftCount(Number(e.target.value))} - /> -
- {draftSets.slice(0, draftCount).map((set, index) => ( -
- {index + 1} - - -
- ))} -
- -
-
- )} -
- ); -} - -function History({ token }: { token: string }) { - const workouts = useQuery({ queryKey: ["workouts"], queryFn: () => api.workouts(token) }); - return ( -
-

History

История

- {workouts.data?.map((workout) => )} -
- ); -} - -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 ( -
-

Analytics

Прогрессия и калораж

-
- - - - -
-
-

Объем по датам

-
- {progression.data?.points.map((point) => ( -
- {point.date} -
- {Math.round(point.volume)} -
- ))} -
-
-
- ); -} - -function CatalogCard({ entity }: { entity: CatalogEntity }) { - return ( -
- {entity.image_s3_url ? :
TW
} -
- {entity.is_builtin ? "стандартное" : "мое"} -

{entity.name}

-

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

-
-
- ); -} - -function WorkoutSummary({ workout }: { workout: Workout }) { - const setCount = useMemo(() => workout.items.reduce((total, item) => total + item.sets.length, 0), [workout.items]); - return ( -
-
-

{new Date(workout.started_at).toLocaleString("ru-RU")}

-

{workout.notes || "Без заметок"}

-
-
- - - -
-
- ); -} - -function Metric({ label, value }: { label: string; value: string | number }) { - return ( -
- {label} - {value} -
+ + + ); } diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 8ee09d6..8c8b122 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1,4 +1,14 @@ -import type { Calories, CatalogEntity, Progression, User, Workout, WorkoutItem, WorkoutSet } from "./types"; +import type { + Calories, + CatalogEntity, + CatalogKind, + Progression, + User, + Workout, + WorkoutItem, + WorkoutSet, + WorkoutSetInput, +} from "./types"; const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "/api"; @@ -7,9 +17,19 @@ export type AuthState = { user: User; }; +export class ApiError extends Error { + status: number; + + constructor(message: string, status: number) { + super(message); + this.name = "ApiError"; + this.status = status; + } +} + async function request(path: string, options: RequestInit = {}, token?: string): Promise { const headers = new Headers(options.headers); - if (!(options.body instanceof FormData)) { + if (!(options.body instanceof FormData) && options.body !== undefined) { headers.set("Content-Type", "application/json"); } if (token) { @@ -19,7 +39,8 @@ async function request(path: string, options: RequestInit = {}, token?: strin 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)); + const detail = typeof body.detail === "string" ? body.detail : JSON.stringify(body.detail); + throw new ApiError(detail, response.status); } if (response.status === 204 || response.headers.get("content-length") === "0") { return undefined as T; @@ -29,11 +50,17 @@ async function request(path: string, options: RequestInit = {}, token?: strin export const api = { async register(payload: { email: string; password: string; display_name: string }): Promise { - const raw = await request<{ access_token: string; user: User }>("/auth/register", { method: "POST", body: JSON.stringify(payload) }); + const raw = await request<{ access_token: string; user: User }>( + "/auth/register", + { method: "POST", body: JSON.stringify(payload) }, + ); return { accessToken: raw.access_token, user: raw.user }; }, async login(payload: { email: string; password: string }): Promise { - const raw = await request<{ access_token: string; user: User }>("/auth/login", { method: "POST", body: JSON.stringify(payload) }); + const raw = await request<{ access_token: string; user: User }>( + "/auth/login", + { method: "POST", body: JSON.stringify(payload) }, + ); return { accessToken: raw.access_token, user: raw.user }; }, me(token: string) { @@ -45,7 +72,7 @@ export const api = { exercises(token: string) { return request("/catalog/exercises", {}, token); }, - uploadImage(token: string, entityType: "equipment" | "exercise", file: File) { + uploadImage(token: string, entityType: CatalogKind, file: File) { const form = new FormData(); form.append("file", file); return request<{ image_s3_url: string; image_s3_key: string }>( @@ -63,31 +90,46 @@ export const api = { workouts(token: string) { return request("/workouts", {}, token); }, - createWorkout(token: string, notes?: string) { - return request("/workouts", { method: "POST", body: JSON.stringify({ notes }) }, token); + activeWorkout(token: string) { + return request("/workouts/active", {}, token); + }, + getWorkout(token: string, workoutId: string) { + return request(`/workouts/${workoutId}`, {}, token); + }, + createWorkout(token: string) { + return request("/workouts", { method: "POST", body: JSON.stringify({}) }, token); }, updateWorkout(token: string, workoutId: string, payload: Partial) { return request(`/workouts/${workoutId}`, { method: "PATCH", body: JSON.stringify(payload) }, token); }, - addWorkoutItem(token: string, workoutId: string, payload: Partial) { + finishWorkout(token: string, workoutId: string, notes?: string) { + return request(`/workouts/${workoutId}/finish`, { + method: "POST", + body: JSON.stringify(notes ? { notes } : {}), + }, token); + }, + 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 }) { return request(`/workouts/${workoutId}/items`, { method: "POST", body: JSON.stringify(payload) }, token); }, - addWorkoutSet(token: string, itemId: string, payload: Partial) { + addWorkoutSet(token: string, itemId: string, payload: WorkoutSetInput) { return request(`/workout-items/${itemId}/sets`, { method: "POST", body: JSON.stringify(payload) }, token); }, + addWorkoutSetBatch(token: string, itemId: string, payload: { sets: WorkoutSetInput[] }) { + return request(`/workout-items/${itemId}/sets/batch`, { method: "POST", body: JSON.stringify(payload) }, token); + }, + updateWorkoutSet(token: string, setId: string, payload: Partial) { + return request(`/workout-sets/${setId}`, { method: "PATCH", body: JSON.stringify(payload) }, token); + }, removeWorkoutItem(token: string, itemId: string) { return request(`/workout-items/${itemId}`, { method: "DELETE" }, token); }, removeWorkoutSet(token: string, itemId: string, setId: string) { return request(`/workout-items/${itemId}/sets/${setId}`, { method: "DELETE" }, token); }, - finishWorkout(token: string, workoutId: string, notes?: string) { - return request(`/workouts/${workoutId}`, { - method: "PATCH", - body: JSON.stringify({ finished_at: new Date().toISOString(), notes }), - }, token); - }, - progression(token: string, kind: "exercise" | "equipment", entityId?: string) { + progression(token: string, kind: CatalogKind, entityId?: string) { const params = new URLSearchParams({ kind }); if (entityId) params.set("entity_id", entityId); return request(`/analytics/progression?${params.toString()}`, {}, token); diff --git a/frontend/src/features/analytics/AnalyticsPage.tsx b/frontend/src/features/analytics/AnalyticsPage.tsx new file mode 100644 index 0000000..b5c1789 --- /dev/null +++ b/frontend/src/features/analytics/AnalyticsPage.tsx @@ -0,0 +1,56 @@ +import { useQuery } from "@tanstack/react-query"; +import { useState } from "react"; + +import { api } from "../../api"; +import { Metric } from "../../shared/Metric"; +import { useAuth } from "../auth/AuthContext"; +import { useCatalog } from "../catalog/hooks"; + +export function AnalyticsPage() { + const { auth } = useAuth(); + const token = auth.accessToken; + 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) }); + + const maxVolume = Math.max(...(progression.data?.points.map((point) => point.volume) ?? [1]), 1); + + return ( +
+
+
+

Analytics

+

Прогрессия и калораж

+
+
+
+ + + + +
+
+

Объем по датам

+
+ {progression.data?.points.map((point) => ( +
+ {point.date} +
+ {Math.round(point.volume)} +
+ ))} +
+
+
+ ); +} diff --git a/frontend/src/features/auth/AuthContext.tsx b/frontend/src/features/auth/AuthContext.tsx new file mode 100644 index 0000000..d791549 --- /dev/null +++ b/frontend/src/features/auth/AuthContext.tsx @@ -0,0 +1,22 @@ +/* eslint-disable react-refresh/only-export-components */ +import { createContext, useContext, useMemo, type ReactNode } from "react"; + +import type { AuthState } from "../../api"; + +const AuthContext = createContext<{ + auth: AuthState; + logout: () => void; +} | null>(null); + +export function useAuth() { + const context = useContext(AuthContext); + if (!context) { + throw new Error("useAuth must be used inside AuthContext"); + } + return context; +} + +export function AuthProvider({ auth, logout, children }: { auth: AuthState; logout: () => void; children: ReactNode }) { + const value = useMemo(() => ({ auth, logout }), [auth, logout]); + return {children}; +} diff --git a/frontend/src/features/auth/AuthScreen.tsx b/frontend/src/features/auth/AuthScreen.tsx new file mode 100644 index 0000000..0562f34 --- /dev/null +++ b/frontend/src/features/auth/AuthScreen.tsx @@ -0,0 +1,70 @@ +import { useMutation } from "@tanstack/react-query"; +import type { FormEvent } from "react"; +import { useState } from "react"; + +import { api, type AuthState } from "../../api"; + +export 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(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 ( +
+
+

Live workout console

+

Один экран. Один подход. Весь прогресс под рукой.

+

+ Train Watcher теперь работает как живой журнал: стартуй сессию, добавляй упражнения через drawer и фиксируй каждый подход без ухода в каталог. +

+
+
+

{mode === "login" ? "Welcome back" : "New athlete"}

+

{mode === "login" ? "Вход" : "Регистрация"}

+ {mode === "register" && ( + + )} + + + {error &&

{error}

} + + +
+
+ ); +} diff --git a/frontend/src/features/auth/authStorage.ts b/frontend/src/features/auth/authStorage.ts new file mode 100644 index 0000000..53f1f52 --- /dev/null +++ b/frontend/src/features/auth/authStorage.ts @@ -0,0 +1,22 @@ +import type { AuthState } from "../../api"; + +const authStorageKey = "train-watcher-auth"; + +export 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 saveAuth(auth: AuthState) { + localStorage.setItem(authStorageKey, JSON.stringify(auth)); +} + +export function clearAuth() { + localStorage.removeItem(authStorageKey); +} diff --git a/frontend/src/features/catalog/CatalogCard.tsx b/frontend/src/features/catalog/CatalogCard.tsx new file mode 100644 index 0000000..16404a0 --- /dev/null +++ b/frontend/src/features/catalog/CatalogCard.tsx @@ -0,0 +1,17 @@ +import type { ReactNode } from "react"; + +import type { CatalogEntity } from "../../types"; + +export function CatalogCard({ entity, action }: { entity: CatalogEntity; action?: ReactNode }) { + return ( +
+ {entity.image_s3_url ? :
TW
} +
+ {entity.is_builtin ? "стандартное" : "мое"} +

{entity.name}

+

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

+ {action} +
+
+ ); +} diff --git a/frontend/src/features/catalog/CatalogPage.tsx b/frontend/src/features/catalog/CatalogPage.tsx new file mode 100644 index 0000000..7f39374 --- /dev/null +++ b/frontend/src/features/catalog/CatalogPage.tsx @@ -0,0 +1,77 @@ +import { Link } from "@tanstack/react-router"; +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 { CatalogCard } from "./CatalogCard"; +import { useCatalog } from "./hooks"; + +export function CatalogPage({ kind }: { kind: CatalogKind }) { + const { auth } = useAuth(); + const token = auth.accessToken; + const queryClient = useQueryClient(); + const { equipment, exercises } = useCatalog(token); + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [file, setFile] = useState(null); + + const list = useMemo(() => (kind === "exercise" ? exercises.data : equipment.data) ?? [], [kind, exercises.data, equipment.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: ["catalog", kind === "equipment" ? "equipment" : "exercises"] }); + }, + }); + + function submit(event: FormEvent) { + event.preventDefault(); + createMutation.mutate(); + } + + return ( +
+
+
+

Catalog

+

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

+
+
+ Упражнения + Тренажеры +
+
+ +
+ + + + + {createMutation.error &&

{createMutation.error.message}

} +
+ +
+ {list.map((entity) => )} +
+
+ ); +} diff --git a/frontend/src/features/catalog/hooks.ts b/frontend/src/features/catalog/hooks.ts new file mode 100644 index 0000000..6b55ccc --- /dev/null +++ b/frontend/src/features/catalog/hooks.ts @@ -0,0 +1,9 @@ +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 }; +} diff --git a/frontend/src/features/history/HistoryPage.tsx b/frontend/src/features/history/HistoryPage.tsx new file mode 100644 index 0000000..7276a58 --- /dev/null +++ b/frontend/src/features/history/HistoryPage.tsx @@ -0,0 +1,33 @@ +import { useQuery } from "@tanstack/react-query"; + +import { api } from "../../api"; +import { useAuth } from "../auth/AuthContext"; +import { WorkoutSummary } from "./WorkoutSummary"; + +export function HistoryPage() { + const { auth } = useAuth(); + const workouts = useQuery({ queryKey: ["workouts"], queryFn: () => api.workouts(auth.accessToken) }); + const visible = workouts.data?.filter((workout) => workout.status !== "discarded") ?? []; + const discarded = workouts.data?.filter((workout) => workout.status === "discarded") ?? []; + + return ( +
+
+
+

History

+

История тренировок

+
+
+ {visible.length === 0 &&
История пока пустая.
} + {visible.map((workout) => )} + {discarded.length > 0 && ( +
+ Отмененные тренировки ({discarded.length}) +
+ {discarded.map((workout) => )} +
+
+ )} +
+ ); +} diff --git a/frontend/src/features/history/WorkoutDetailPage.tsx b/frontend/src/features/history/WorkoutDetailPage.tsx new file mode 100644 index 0000000..ddbcead --- /dev/null +++ b/frontend/src/features/history/WorkoutDetailPage.tsx @@ -0,0 +1,50 @@ +import { useParams } from "@tanstack/react-router"; +import { useQuery } from "@tanstack/react-query"; + +import { api } from "../../api"; +import { Metric } from "../../shared/Metric"; +import { useAuth } from "../auth/AuthContext"; + +export function WorkoutDetailPage() { + const { workoutId } = useParams({ from: "/workouts/$workoutId" }); + const { auth } = useAuth(); + const workout = useQuery({ queryKey: ["workouts", workoutId], queryFn: () => api.getWorkout(auth.accessToken, workoutId) }); + + if (workout.isLoading) return
Загружаю тренировку...
; + if (!workout.data) return
Тренировка не найдена.
; + + const data = workout.data; + return ( +
+
+
+

Workout detail

+

{new Date(data.started_at).toLocaleDateString("ru-RU")}

+
+
+
+ + + + + +
+ {data.notes &&

Заметки

{data.notes}

} + {data.items.map((item) => ( +
+

{item.title_snapshot}

+
+ {item.sets.map((set) => ( +
+ {set.set_index} + {set.weight} кг × {set.reps} + {set.calories ? Math.round(set.calories) : "—"} ккал + +
+ ))} +
+
+ ))} +
+ ); +} diff --git a/frontend/src/features/history/WorkoutSummary.tsx b/frontend/src/features/history/WorkoutSummary.tsx new file mode 100644 index 0000000..27e358b --- /dev/null +++ b/frontend/src/features/history/WorkoutSummary.tsx @@ -0,0 +1,34 @@ +import { Link } from "@tanstack/react-router"; + +import type { Workout } from "../../types"; +import { Metric } from "../../shared/Metric"; + +function statusLabel(status: Workout["status"]) { + return status === "finished" ? "завершена" : status === "discarded" ? "отменена" : "активная"; +} + +export function WorkoutSummary({ workout }: { workout: Workout }) { + const duration = workout.finished_at + ? Math.round((new Date(workout.finished_at).getTime() - new Date(workout.started_at).getTime()) / 60000) + : null; + + return ( +
+
+
+ {statusLabel(workout.status)} +

{new Date(workout.started_at).toLocaleString("ru-RU")}

+

{workout.notes || "Без заметок"}

+
+ Детали +
+
+ + + + + +
+
+ ); +} diff --git a/frontend/src/features/workout/ActiveWorkoutPage.tsx b/frontend/src/features/workout/ActiveWorkoutPage.tsx new file mode 100644 index 0000000..e390a59 --- /dev/null +++ b/frontend/src/features/workout/ActiveWorkoutPage.tsx @@ -0,0 +1,166 @@ +import { useNavigate } from "@tanstack/react-router"; +import { useEffect, useMemo, useState } from "react"; + +import { useAuth } from "../auth/AuthContext"; +import type { CatalogEntity, CatalogKind, WorkoutSetInput } from "../../types"; +import { Metric } from "../../shared/Metric"; +import { AddExerciseDrawer } from "./AddExerciseDrawer"; +import { EmptyWorkoutState } from "./EmptyWorkoutState"; +import { FinishWorkoutDialog } from "./FinishWorkoutDialog"; +import { useActiveWorkout, useWorkoutMutations } from "./hooks"; +import { WorkoutExerciseCard } from "./WorkoutExerciseCard"; + +function useElapsed(startedAt?: string) { + const [elapsed, setElapsed] = useState("00:00"); + + useEffect(() => { + if (!startedAt) return; + const started = new Date(startedAt).getTime(); + const tick = () => { + const diff = Math.max(0, Date.now() - started); + const totalSeconds = Math.floor(diff / 1000); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + setElapsed(hours > 0 ? `${hours}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}` : `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`); + }; + tick(); + const intervalId = window.setInterval(tick, 1000); + return () => window.clearInterval(intervalId); + }, [startedAt]); + + return elapsed; +} + +export function ActiveWorkoutPage() { + const { auth } = useAuth(); + const navigate = useNavigate(); + const activeWorkout = useActiveWorkout(); + const [drawerOpen, setDrawerOpen] = useState(false); + const [finishOpen, setFinishOpen] = useState(false); + const [discardOpen, setDiscardOpen] = useState(false); + const workout = activeWorkout.data; + const elapsed = useElapsed(workout?.started_at); + const mutations = useWorkoutMutations({ + onStartConflict: () => void navigate({ to: "/workout/active" }), + onFinish: () => void navigate({ to: "/history" }), + onDiscard: () => void navigate({ to: "/" }), + }); + + const sortedItems = useMemo(() => [...(workout?.items ?? [])].sort((a, b) => a.order_index - b.order_index), [workout?.items]); + + function addFromDrawer(entity: CatalogEntity, kind: CatalogKind, closeAfter: boolean) { + if (!workout) return; + mutations.addWorkoutItem.mutate( + { workoutId: workout.id, sourceId: entity.id, kind }, + { onSuccess: () => { if (closeAfter) setDrawerOpen(false); } }, + ); + } + + if (activeWorkout.isLoading) { + return
Загружаю активную тренировку...
; + } + + if (!workout) { + return ( +
+
+
+

Active Workout

+

Живой журнал

+
+
+
+

Нет активной тренировки

+

Новая сессия создаст защищенный active workout. Если сессия уже существует, приложение восстановит ее после обновления.

+ +
+
+ ); + } + + const anyPending = + mutations.addWorkoutItem.isPending || + mutations.recordWorkoutSet.isPending || + mutations.recordWorkoutSetsBatch.isPending || + mutations.removeWorkoutItem.isPending || + mutations.removeWorkoutSet.isPending || + mutations.updateWorkoutSet.isPending; + + return ( +
+
+
+

Active Workout

+

Живой журнал

+ Старт: {new Date(workout.started_at).toLocaleString("ru-RU", { hour: "2-digit", minute: "2-digit", day: "2-digit", month: "short" })} +
+
{elapsed}
+
+ + + +
+
+ + + +
+
+ + {sortedItems.length === 0 ? ( + setDrawerOpen(true)} /> + ) : ( +
+ {sortedItems.map((item) => ( + mutations.recordWorkoutSet.mutate({ itemId: item.id, payload })} + onRecordBatch={(sets) => mutations.recordWorkoutSetsBatch.mutate({ itemId: item.id, sets })} + onRemoveItem={() => mutations.removeWorkoutItem.mutate(item.id)} + onRemoveSet={(setId) => mutations.removeWorkoutSet.mutate({ itemId: item.id, setId })} + onUpdateSet={(setId, payload) => mutations.updateWorkoutSet.mutate({ setId, payload })} + /> + ))} +
+ )} + + + + setDrawerOpen(false)} + onAdd={addFromDrawer} + /> + setFinishOpen(false)} + onFinish={(notes) => mutations.finishWorkout.mutate({ workoutId: workout.id, notes })} + /> + {discardOpen && ( +
setDiscardOpen(false)}> +
event.stopPropagation()}> +

Danger command

+

Отменить тренировку?

+

Запись будет помечена как отмененная и исчезнет из основной аналитики. Подходы не удаляются физически.

+
+ + +
+
+
+ )} +
+ ); +} diff --git a/frontend/src/features/workout/AddExerciseDrawer.tsx b/frontend/src/features/workout/AddExerciseDrawer.tsx new file mode 100644 index 0000000..452c66c --- /dev/null +++ b/frontend/src/features/workout/AddExerciseDrawer.tsx @@ -0,0 +1,89 @@ +import { useMemo, useState } from "react"; + +import type { CatalogEntity, CatalogKind, Workout } from "../../types"; +import { useCatalog } from "../catalog/hooks"; + +export function AddExerciseDrawer({ + open, + token, + workout, + onClose, + onAdd, + pending, +}: { + open: boolean; + token: string; + workout: Workout; + onClose: () => void; + onAdd: (entity: CatalogEntity, kind: CatalogKind, closeAfter: boolean) => void; + pending?: boolean; +}) { + const [kind, setKind] = useState("exercise"); + const [search, setSearch] = useState(""); + const { exercises, equipment } = useCatalog(token); + + const addedIds = useMemo(() => { + const ids = new Set(); + workout.items.forEach((item) => { + if (item.exercise_id) ids.add(item.exercise_id); + if (item.equipment_id) ids.add(item.equipment_id); + }); + return ids; + }, [workout.items]); + + const list = useMemo(() => { + const source = kind === "exercise" ? exercises.data ?? [] : equipment.data ?? []; + const needle = search.trim().toLowerCase(); + return needle ? source.filter((entity) => entity.name.toLowerCase().includes(needle)) : source; + }, [kind, search, exercises.data, equipment.data]); + + if (!open) return null; + + return ( +
+ +
+ ); +} diff --git a/frontend/src/features/workout/EmptyWorkoutState.tsx b/frontend/src/features/workout/EmptyWorkoutState.tsx new file mode 100644 index 0000000..fd17e75 --- /dev/null +++ b/frontend/src/features/workout/EmptyWorkoutState.tsx @@ -0,0 +1,10 @@ +export function EmptyWorkoutState({ onOpenDrawer }: { onOpenDrawer: () => void }) { + return ( +
+

Журнал пуст

+

Добавь первое упражнение

+

Каталог теперь открывается отдельным drawer, поэтому основной экран остается чистым журналом выполнения.

+ +
+ ); +} diff --git a/frontend/src/features/workout/FinishWorkoutDialog.tsx b/frontend/src/features/workout/FinishWorkoutDialog.tsx new file mode 100644 index 0000000..a30a4de --- /dev/null +++ b/frontend/src/features/workout/FinishWorkoutDialog.tsx @@ -0,0 +1,52 @@ +import { useState } from "react"; + +import type { Workout } from "../../types"; +import { Metric } from "../../shared/Metric"; + +function minutesBetween(start: string, end = new Date().toISOString()) { + return Math.max(0, Math.round((new Date(end).getTime() - new Date(start).getTime()) / 60000)); +} + +export function FinishWorkoutDialog({ + workout, + open, + pending, + onClose, + onFinish, +}: { + workout: Workout; + open: boolean; + pending?: boolean; + onClose: () => void; + onFinish: (notes?: string) => void; +}) { + const [notes, setNotes] = useState(workout.notes ?? ""); + + if (!open) return null; + + return ( +
+
event.stopPropagation()}> +

Workout summary

+

Итог тренировки

+
+ + + + + +
+