From e7fbd6ff86aba0bf7d83f7642335372703f45c9e Mon Sep 17 00:00:00 2001 From: fy59 Date: Wed, 23 Sep 2026 22:14:25 +0200 Subject: [PATCH] refactor(web): refine dashboard analysis and settings UI --- CHANGELOG.md | 10 +- docs/current_state.md | 18 ++- docs/roadmap.md | 2 +- docs/tests.md | 13 +++ web/src/app/App.test.tsx | 2 +- web/src/app/App.tsx | 12 +- web/src/app/routes.ts | 5 +- web/src/components/Header.tsx | 14 ++- web/src/dashboard/BodyZoneFigure.tsx | 4 +- web/src/dashboard/DashboardTiles.test.tsx | 9 +- web/src/dashboard/DashboardTiles.tsx | 115 ++++++++----------- web/src/presentation/LanguagePreferences.tsx | 35 ++++++ web/src/routes/AnalysisPage.test.tsx | 8 +- web/src/routes/AnalysisPage.tsx | 73 ++++++++---- web/src/routes/EquipmentPage.tsx | 64 +++++++++++ web/src/routes/SettingsPage.tsx | 39 +++++++ web/src/theme/app.css | 83 ++++++++++++- web/src/theme/tokens.css | 4 +- 18 files changed, 397 insertions(+), 113 deletions(-) create mode 100644 web/src/presentation/LanguagePreferences.tsx create mode 100644 web/src/routes/EquipmentPage.tsx create mode 100644 web/src/routes/SettingsPage.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index d4fd89a..f8adf73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,14 @@ ## Unreleased — 0.1.6 -Development opened after the stable v0.1.5 release. No 0.1.6 feature tranche -is selected by this version-opening commit. +Development opened after the stable v0.1.5 release. The Web refinement tranche +adds persistent French/English settings, denser shared surfaces, ISO-week +Dashboard activity, factual exercise-count overview charts, measured-timeline +muscle-group drill-down, normalized measurement summaries, one shared refined +BODY ZONES silhouette, and a compact Program header. A new Equipment workspace +lists factual references and transactionally absorbs a custom duplicate into a +canonical identity while retaining history/MAX context and recording a causal +deletion marker; immutable embedded drafts cause an explicit conflict. ## 0.1.5 — 2026-09-23 diff --git a/docs/current_state.md b/docs/current_state.md index 188bc06..72cf9f6 100644 --- a/docs/current_state.md +++ b/docs/current_state.md @@ -159,7 +159,7 @@ parallel implementation of its rules. | Sleep tonight readiness delta | `TRAINLOG_SLEEP_TONIGHT_CLOSEOUT_V1=PASS`: Meson 111/111, import checks 6/6, Android `testDebugUnitTest` plus `assembleDebug`, Web 191/191 plus typecheck/build, exact V2 quantity replay, private vc47 schema-v32 migration, fresh CYCPLUS BPM and non-mutating Android/Web visual review. | | 0.1.5 validation | `TRAINLOG_0_1_5_VALIDATION_V1=PASS`: native 111/111; Android unit-test task and APK build; Web 191/191 plus typecheck/build; JSON/import, C-format and diff checks pass. Private vc47 preserves real data through schema v32, renders pre-bed medication quantity controls, reconnects the CYCPLUS and presents the multi-night Web Sleep agenda. | | Tomorrow session readiness | `TRAINLOG_TOMORROW_SESSION_CARDIO_READINESS_V1=PASS`: the private vc46 is clean on schema v31 with fresh CYCPLUS BPM and the next Program session is unambiguous; one combined regression proves two ordered occurrences loaded from the synchronized Program, Program provenance, factual timeline/HR ownership, finalization, full generation, desktop import, replay and correlated ACK | -| Current operational cursor | 0.1.6 development is open after immutable stable v0.1.5; no new feature tranche is selected by the version-opening commit. | +| Current operational cursor | 0.1.6 Web UI refinement implemented; validation and private deployment review are in progress after immutable stable v0.1.5. | | Complete synchronization gap contract | Frozen dependency contract; operational USB/Drive slices required by v0.1.2 are delivered | | Isolated synchronization test environment | `TRAINLOG_SYNC_TEST_ENV_V1=PASS/FROZEN` | | Synchronization characterization | `TRAINLOG_SYNC_CHARACTERIZATION_V1=PASS/FROZEN` | @@ -306,6 +306,22 @@ the result. The Web aggregate now exposes the distinct factual snapshot through ## Local Web +The 0.1.6 refinement adds a persistent French/English preference and a dedicated +Settings route without moving the global synchronization action. Dashboard +activity is grouped by calendar month/ISO week, its progression tile is now a +global factual synthesis, and Analyse progresses from primary BODY ZONE to +exercise to individual detail. Exercise work time is derived exclusively from +`session_exercise_timeline` start/end facts; missing timelines remain unknown. +Measurements first expose unit-safe relative change cards before real-unit +detail. Dashboard and Analyse reuse the same human-proportioned BODY ZONES SVG. + +The Equipment route projects supplied and custom definitions with exercise, +history, preparation and Program reference counts. Its guarded merge accepts a +custom duplicate only, transfers relational references in one SQLite IMMEDIATE +transaction, preserves occurrence/MAX facts indirectly through occurrence +identity, and writes the existing causal deletion boundary. An immutable +execution-draft JSON reference rejects the operation rather than being edited. + `TRAINLOG_WEB_V1` architecture, API independence, local-network boundary, browser shell, Dashboard, layout ownership, build/runtime separation, and security invariants are canonical. The CLI/local HTTP infrastructure and diff --git a/docs/roadmap.md b/docs/roadmap.md index 5e507a9..5d15478 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -64,7 +64,7 @@ The heart-rate path remains independent from the frozen Bluetooth Trainlog synchronization transport. ```text -CURRENT_OPERATIONAL_CURSOR=0.1.6 development open; next feature tranche not selected. +CURRENT_OPERATIONAL_CURSOR=0.1.6 Web UI refinement implemented; validation and private deployment review pending. ``` Sleep Diary V1 stays frozen/readable while active capable peers require diff --git a/docs/tests.md b/docs/tests.md index 80ee778..1015eb3 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -1,5 +1,18 @@ # Tests and validation +## Web UI refinement 0.1.6 + +Web regressions cover calendar-week activity grouping, global rather than +single-exercise Dashboard progression, exercises-per-date overview data, +measured-only group drill-down, normalized measurement selection, the shared +BODY ZONES figure, persistent language/settings navigation, multi-night Sleep +selection invariants, and Equipment response validation. Native equipment +coverage exercises a synthetic custom duplicate and proves that a transactional +merge repoints completed history to the retained canonical identity without +touching real user equipment. Strict C compilation, the full Meson inventory, +Web typecheck/build/Vitest, JSON/import validation, changed-C formatting and +diff checks remain the closeout gates. + Sleep Diary coverage includes schema creation/migration, midnight and DST offsets, revision conflict, logical deletion/reopen, companion replay and rollback, Web periods/deep links, Android persistence, and vector PDF diff --git a/web/src/app/App.test.tsx b/web/src/app/App.test.tsx index 6c4a38d..2cc1e8e 100644 --- a/web/src/app/App.test.tsx +++ b/web/src/app/App.test.tsx @@ -163,7 +163,7 @@ describe('shell Trainlog', () => { it('expose les cinq destinations dans une navigation clavier sémantique', () => { mockHealth() render() - expect(screen.getAllByRole('link').filter((link) => link.closest('nav'))).toHaveLength(5) + expect(screen.getAllByRole('link').filter((link) => link.closest('nav'))).toHaveLength(6) expect(screen.getByRole('link', { name: 'Dashboard' })).toHaveAttribute('aria-current', 'page') }) }) diff --git a/web/src/app/App.tsx b/web/src/app/App.tsx index 148c178..db9fb07 100644 --- a/web/src/app/App.tsx +++ b/web/src/app/App.tsx @@ -10,10 +10,13 @@ import { ProgramsCalendarPage } from '../routes/ProgramsCalendarPage' import { SessionsPage } from '../routes/SessionsPage' import { ExercisesPage } from '../routes/ExercisesPage' import { AnalysisPage } from '../routes/AnalysisPage' +import { SettingsPage } from '../routes/SettingsPage' +import { EquipmentPage } from '../routes/EquipmentPage' import type { RouteId } from './routes' import { useRoute } from './useRoute' import { DatePreferencesProvider } from '../presentation/DatePreferences' import { fetchAnalysis, type AnalysisSnapshot } from '../api/analysis' +import { LanguagePreferencesProvider, useLanguagePreferences } from '../presentation/LanguagePreferences' async function fetchDashboardAnalysis(signal?: AbortSignal): Promise { const [windowed, allMeasurements] = await Promise.all([ @@ -23,11 +26,12 @@ async function fetchDashboardAnalysis(signal?: AbortSignal): Promise, [string, string, string]> = { +const placeholderContent: Record, [string, string, string]> = { sessions: ['Séances', 'ORGANISER', 'Les séances préparées et terminées seront présentées sans confondre plan et réalisé.'], } function AppContent() { + const { language } = useLanguagePreferences() const [route, navigate] = useRoute() const [health, setHealth] = useState(null) const [healthPending, setHealthPending] = useState(true) @@ -105,13 +109,15 @@ function AppContent() { route.id === 'sessions' ? : route.id === 'programs' ? : route.id === 'exercises' ? : + route.id === 'equipment' ? : + route.id === 'settings' ? : return (
- Aller au contenu + {language === 'en' ? 'Skip to content' : 'Aller au contenu'}
{ reloadDashboard() reloadPreparedItems() @@ -124,5 +130,5 @@ function AppContent() { } export function App() { - return + return } diff --git a/web/src/app/routes.ts b/web/src/app/routes.ts index 2326a59..462e75d 100644 --- a/web/src/app/routes.ts +++ b/web/src/app/routes.ts @@ -1,4 +1,4 @@ -export type RouteId = 'dashboard' | 'analysis' | 'programs' | 'sessions' | 'exercises' +export type RouteId = 'dashboard' | 'analysis' | 'programs' | 'sessions' | 'exercises' | 'equipment' | 'settings' export interface AppRoute { id: RouteId @@ -12,6 +12,7 @@ export const routes: readonly AppRoute[] = [ { id: 'programs', path: '/programmes', label: 'Programmes' }, { id: 'sessions', path: '/seances', label: 'Séances' }, { id: 'exercises', path: '/exercices', label: 'Exercices' }, + { id: 'equipment', path: '/equipements', label: 'Équipements' }, ] export function routeFromPath(pathname: string): AppRoute { @@ -24,5 +25,7 @@ export function routeFromPath(pathname: string): AppRoute { if (pathname === '/exercices' || pathname.startsWith('/exercices/')) { return { ...routes[4], path: pathname } } + if (pathname === '/equipements' || pathname.startsWith('/equipements/')) return routes[5] + if (pathname === '/parametres') return { id: 'settings', path: '/parametres', label: 'Paramètres' } return routes.find((route) => route.path === pathname) ?? routes[0] } diff --git a/web/src/components/Header.tsx b/web/src/components/Header.tsx index 6906f49..1c4212d 100644 --- a/web/src/components/Header.tsx +++ b/web/src/components/Header.tsx @@ -1,7 +1,7 @@ import type { MouseEvent } from 'react' import { routes, type AppRoute } from '../app/routes' import { SyncControl } from './SyncControl' -import { DisplayPreferences } from './DisplayPreferences' +import { useLanguagePreferences } from '../presentation/LanguagePreferences' interface HeaderProps { activeRoute: AppRoute @@ -10,6 +10,11 @@ interface HeaderProps { } export function Header({ activeRoute, onNavigate, onSyncCommitted }: HeaderProps) { + const { language } = useLanguagePreferences() + const labels: Record = language === 'en' ? { + dashboard: 'Dashboard', analysis: 'Analysis', programs: 'Programs', sessions: 'Sessions', + exercises: 'Exercises', equipment: 'Equipment', + } : {} const follow = (event: MouseEvent, path: string) => { if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return event.preventDefault() @@ -23,17 +28,18 @@ export function Header({ activeRoute, onNavigate, onSyncCommitted }: HeaderProps TRAINLOG -
) diff --git a/web/src/dashboard/BodyZoneFigure.tsx b/web/src/dashboard/BodyZoneFigure.tsx index 091d744..921d7f9 100644 --- a/web/src/dashboard/BodyZoneFigure.tsx +++ b/web/src/dashboard/BodyZoneFigure.tsx @@ -58,8 +58,8 @@ type RegionProps = (zoneId: MappedBodyZoneId) => Record function BodyView({ title, side, zoneProps, language }: { title: string; side: 'front' | 'back'; zoneProps: RegionProps; language: 'fr' | 'en' }) { return
{title}
- - + + {side === 'front' ? <> diff --git a/web/src/dashboard/DashboardTiles.test.tsx b/web/src/dashboard/DashboardTiles.test.tsx index 4e18952..a1c6d64 100644 --- a/web/src/dashboard/DashboardTiles.test.tsx +++ b/web/src/dashboard/DashboardTiles.test.tsx @@ -222,7 +222,7 @@ describe("tuiles Dashboard alimentées par le contrat", () => { expect(screen.queryByText(/%/)).not.toBeInTheDocument(); }); - it("masque les séries incompatibles et humanise la durée pour une activité continue", () => { + it("remplace l’exercice arbitraire par une synthèse globale factuelle", () => { const analysis = { ...analysisFixture, exercise: { @@ -252,10 +252,9 @@ describe("tuiles Dashboard alimentées par le contrat", () => { analysis={analysis} />, ); - expect(screen.getByText("15 min")).toBeInTheDocument(); - expect(screen.getByText("1,43 km")).toBeInTheDocument(); - expect(screen.getByText("6,4 km/h")).toBeInTheDocument(); - expect(screen.queryByText("Séries")).not.toBeInTheDocument(); + expect(screen.getByText("Exercices distincts")).toBeInTheDocument(); + expect(screen.getByText("Explorer dans Analyse")).toBeInTheDocument(); + expect(screen.queryByText("15 min")).not.toBeInTheDocument(); }); it("réserve la courbe aux tailles medium et large", async () => { diff --git a/web/src/dashboard/DashboardTiles.tsx b/web/src/dashboard/DashboardTiles.tsx index 97577cd..f6d0535 100644 --- a/web/src/dashboard/DashboardTiles.tsx +++ b/web/src/dashboard/DashboardTiles.tsx @@ -237,6 +237,7 @@ export function ActivityTile({ snapshot, size, analysis }: TileDataProps) { analysis?.overview.sets ?? days.reduce((total, day) => total + day.set_count, 0); const shown = size === "compact" ? days.slice(-35) : days; + const weeks = calendarWeeks(shown); return (
@@ -255,17 +256,19 @@ export function ActivityTile({ snapshot, size, analysis }: TileDataProps) { )}
- {shown.map((day) => ( - 1 ? " is-multiple" : ""}`} - title={`${formatDate(day.date, dateFormat)} — ${count(day.session_count, "séance")}, ${count(day.set_count, "série")}`} - /> - ))} + {weeks.map((week) =>
+

{week.label}

{week.days.map((day) => ( + 1 ? " is-multiple" : ""}`} + title={`${formatDate(day.date, dateFormat)} — ${count(day.session_count, "séance")}, ${count(day.set_count, "série")}`} + aria-label={`${formatDate(day.date, dateFormat)} — ${count(day.session_count, "séance")}, ${count(day.set_count, "série")}`}> + {activityDayLabel(day.date)}{/^\d{4}-\d{2}-\d{2}$/.test(day.date) ? Number(day.date.slice(8, 10)) : '—'} + {day.active && {day.session_count}} + ))}
+
)}
{size === "large" && (

@@ -277,71 +280,51 @@ export function ActivityTile({ snapshot, size, analysis }: TileDataProps) { ); } +function activityDayLabel(value: string): string { + const date = new Date(`${value}T12:00:00Z`) + return Number.isFinite(date.getTime()) + ? new Intl.DateTimeFormat('fr-FR', { weekday: 'short', timeZone: 'UTC' }).format(date) + : '—' +} + +export function calendarWeeks(days: DashboardSnapshot['data']['activity']['days']) { + const groups = new Map() + days.forEach((day, index) => { + const date = new Date(`${day.date}T12:00:00Z`) + if (!Number.isFinite(date.getTime())) { + const key = `unknown-${Math.floor(index / 7)}` + groups.set(key, [...(groups.get(key) ?? []), day]) + return + } + const weekday = date.getUTCDay() || 7 + const thursday = new Date(date) + thursday.setUTCDate(date.getUTCDate() + 4 - weekday) + const yearStart = new Date(Date.UTC(thursday.getUTCFullYear(), 0, 1, 12)) + const week = Math.ceil((((thursday.getTime() - yearStart.getTime()) / 86400000) + 1) / 7) + const key = `${thursday.getUTCFullYear()}-${String(week).padStart(2, '0')}` + groups.set(key, [...(groups.get(key) ?? []), day]) + }) + return [...groups.entries()].map(([key, weekDays]) => ({ + key, + days: weekDays, + label: key.startsWith('unknown-') ? 'Période' : `${new Intl.DateTimeFormat('fr-FR', { month: 'long', year: 'numeric', timeZone: 'UTC' }).format(new Date(`${weekDays[0].date}T12:00:00Z`))} — S${Number(key.slice(-2))}`, + })) +} + export function ProgressionTile({ snapshot, size, analysis }: TileDataProps) { const { dateFormat } = useDatePreferences(); - if (analysis?.exercise !== null && analysis?.exercise !== undefined) { - const exercise = analysis.exercise; - const latest = exercise.points[0]; + const distinctExercises = analysis?.overview.distinct_exercises ?? 0; + if (analysis !== null && analysis !== undefined) { + const top = analysis.exercise_groups.flatMap((group) => group.exercises).sort((left, right) => right.occurrences - left.occurrences || left.name.localeCompare(right.name, 'fr')).slice(0, 3); return (

-
-

{exercise.name}

-

- {exercise.tracking_mode === "reps" ? "Répétitions" : "Durée"} -

-
- {latest === undefined ? ( - - ) : ( -
- {exercise.recording_mode === "sets" && ( - - )} - {latest.reps !== null && ( - - )} - {(latest.continuous_duration_seconds ?? - latest.set_duration_seconds) !== null && ( - - )} - {latest.distance_km !== null && ( - - )} - {latest.speed_kmh !== null && ( - - )} - {latest.external_volume_kg !== null && ( - - )} - {latest.explicit_max_kg !== null && ( - - )} -
- )} +
+ {size !== 'compact' && top.length > 0 &&
    {top.map((exercise) =>
  1. {exercise.name}{exercise.occurrences} occurrences
  2. )}
} - Voir dans Analyse + Explorer dans Analyse
); diff --git a/web/src/presentation/LanguagePreferences.tsx b/web/src/presentation/LanguagePreferences.tsx new file mode 100644 index 0000000..f5bf8aa --- /dev/null +++ b/web/src/presentation/LanguagePreferences.tsx @@ -0,0 +1,35 @@ +import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from 'react' + +export type WebLanguage = 'fr' | 'en' + +const STORAGE_KEY = 'trainlog.web.language.v1' + +interface LanguageContextValue { + language: WebLanguage + setLanguage: (language: WebLanguage) => void +} + +const LanguageContext = createContext(null) + +export function LanguagePreferencesProvider({ children }: { children: ReactNode }) { + const [language, updateLanguage] = useState(() => + window.localStorage.getItem(STORAGE_KEY) === 'en' ? 'en' : 'fr') + + const setLanguage = (value: WebLanguage) => { + window.localStorage.setItem(STORAGE_KEY, value) + updateLanguage(value) + } + + useEffect(() => { + document.documentElement.lang = language + }, [language]) + + const value = useMemo(() => ({ language, setLanguage }), [language]) + return {children} +} + +export function useLanguagePreferences(): LanguageContextValue { + const value = useContext(LanguageContext) + if (value === null) throw new Error('LanguagePreferencesProvider is missing') + return value +} diff --git a/web/src/routes/AnalysisPage.test.tsx b/web/src/routes/AnalysisPage.test.tsx index b8b196d..bdd7bf1 100644 --- a/web/src/routes/AnalysisPage.test.tsx +++ b/web/src/routes/AnalysisPage.test.tsx @@ -190,15 +190,15 @@ describe("AnalysisPage", () => { expect(document.querySelectorAll(".chart-x-label")).toHaveLength(2); }); - it("uses a factual sessions-by-date histogram in the overview", async () => { + it("uses a factual exercises-by-date histogram in the overview", async () => { render(); - expect(await screen.findByText("Séances par date")).toBeInTheDocument(); + expect(await screen.findByText("Exercices réalisés par date")).toBeInTheDocument(); expect( - screen.getByRole("img", { name: "Histogramme des séances par date" }), + screen.getByRole("img", { name: "Histogramme des exercices réalisés par date" }), ).toBeInTheDocument(); expect(document.querySelector(".activity-bar")).toHaveAttribute( "title", - expect.stringContaining("2 séance(s)"), + expect.stringContaining("1 exercice(s)"), ); }); }); diff --git a/web/src/routes/AnalysisPage.tsx b/web/src/routes/AnalysisPage.tsx index dda9e8e..4921326 100644 --- a/web/src/routes/AnalysisPage.tsx +++ b/web/src/routes/AnalysisPage.tsx @@ -342,33 +342,33 @@ function ActivityHistogram({ const ordered = [...points].sort((left, right) => left.date.localeCompare(right.date), ); - const maximum = Math.max(...ordered.map((point) => point.sessions), 1); + const maximum = Math.max(...ordered.map((point) => point.exercises), 1); return (
- {language === "fr" ? "Séances par date" : "Sessions by date"} + {language === "fr" ? "Exercices réalisés par date" : "Exercises completed by date"} - {language === "fr" ? "séances" : "sessions"} + {language === "fr" ? "exercices" : "exercises"}
{ordered.map((point, index) => (
- {point.sessions} + {point.exercises} {(ordered.length <= 10 || index === 0 || @@ -518,6 +518,7 @@ export function AnalysisPage() { const [exerciseMetric, setExerciseMetric] = useState( null, ); + const [selectedGroup, setSelectedGroup] = useState(null); const [snapshot, setSnapshot] = useState(null); const [pending, setPending] = useState(true); const [failed, setFailed] = useState(false); @@ -595,6 +596,8 @@ export function AnalysisPage() { 1, ...(snapshot?.body_zones.map((zone) => zone.exposures) ?? []), ); + const distinctExercises = snapshot?.overview.distinct_exercises ?? 0; + const activeGroup = snapshot?.exercise_groups.find((group) => group.zone_id === selectedGroup); return (
@@ -666,6 +669,10 @@ export function AnalysisPage() { label={t.sets} value={formatNumber(snapshot.overview.sets, language)} /> + {snapshot.overview.duration_seconds !== null && (

{t.exercises}

- {snapshot.exercise === null ? ( + {distinctExercises === 0 ? (

{t.noExercise}

) : ( <> -

{snapshot.exercise.name}

- +

{distinctExercises}

+

{language === "fr" ? "exercices distincts sur la période" : "distinct exercises in this period"}

+ )}
@@ -750,7 +753,32 @@ export function AnalysisPage() { )} {section === "exercise" && (
-