refactor(web): refine dashboard analysis and settings UI

This commit is contained in:
fy59 2026-09-23 22:14:25 +02:00
parent 7adeb59b06
commit e7fbd6ff86
18 changed files with 397 additions and 113 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -163,7 +163,7 @@ describe('shell Trainlog', () => {
it('expose les cinq destinations dans une navigation clavier sémantique', () => {
mockHealth()
render(<App />)
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')
})
})

View file

@ -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<AnalysisSnapshot> {
const [windowed, allMeasurements] = await Promise.all([
@ -23,11 +26,12 @@ async function fetchDashboardAnalysis(signal?: AbortSignal): Promise<AnalysisSna
return { ...windowed, measurements: allMeasurements.measurements }
}
const placeholderContent: Record<Exclude<RouteId, 'dashboard' | 'analysis' | 'programs' | 'exercises'>, [string, string, string]> = {
const placeholderContent: Record<Extract<RouteId, 'sessions'>, [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<Health | null>(null)
const [healthPending, setHealthPending] = useState(true)
@ -105,13 +109,15 @@ function AppContent() {
route.id === 'sessions' ? <SessionsPage /> :
route.id === 'programs' ? <ProgramsCalendarPage onNavigate={navigate} /> :
route.id === 'exercises' ? <ExercisesPage path={route.path} onNavigate={navigate} /> :
route.id === 'equipment' ? <EquipmentPage /> :
route.id === 'settings' ? <SettingsPage /> :
<PlaceholderPage title={placeholderContent[route.id][0]}
eyebrow={placeholderContent[route.id][1]}
description={placeholderContent[route.id][2]} />
return (
<div className="app-shell">
<a className="skip-link" href="#main-content">Aller au contenu</a>
<a className="skip-link" href="#main-content">{language === 'en' ? 'Skip to content' : 'Aller au contenu'}</a>
<Header activeRoute={route} onNavigate={navigate} onSyncCommitted={() => {
reloadDashboard()
reloadPreparedItems()
@ -124,5 +130,5 @@ function AppContent() {
}
export function App() {
return <DatePreferencesProvider><AppContent /></DatePreferencesProvider>
return <LanguagePreferencesProvider><DatePreferencesProvider><AppContent /></DatePreferencesProvider></LanguagePreferencesProvider>
}

View file

@ -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]
}

View file

@ -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<string, string> = language === 'en' ? {
dashboard: 'Dashboard', analysis: 'Analysis', programs: 'Programs', sessions: 'Sessions',
exercises: 'Exercises', equipment: 'Equipment',
} : {}
const follow = (event: MouseEvent<HTMLAnchorElement>, 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
<span className="brand-mark" aria-hidden="true">TL</span>
<span>TRAINLOG</span>
</a>
<nav className="main-nav" aria-label="Navigation principale">
<nav className="main-nav" aria-label={language === 'en' ? 'Main navigation' : 'Navigation principale'}>
{routes.map((route) => (
<a key={route.id} href={route.path}
className={activeRoute.id === route.id ? 'nav-link is-active' : 'nav-link'}
aria-current={activeRoute.id === route.id ? 'page' : undefined}
onClick={(event) => follow(event, route.path)}>
{route.label}
{labels[route.id] ?? route.label}
</a>
))}
</nav>
<DisplayPreferences />
<a href="/parametres" className={activeRoute.id === 'settings' ? 'nav-link settings-link is-active' : 'nav-link settings-link'}
onClick={(event) => follow(event, '/parametres')}>⚙ {language === 'en' ? 'Settings' : 'Paramètres'}</a>
<SyncControl onCommitted={onSyncCommitted} />
</header>
)

View file

@ -58,8 +58,8 @@ type RegionProps = (zoneId: MappedBodyZoneId) => Record<string, unknown>
function BodyView({ title, side, zoneProps, language }: { title: string; side: 'front' | 'back'; zoneProps: RegionProps; language: 'fr' | 'en' }) {
return <figure><figcaption>{title}</figcaption><svg viewBox="0 0 120 260" role="group" aria-label={language === 'fr' ? `Silhouette, vue ${title.toLowerCase()}` : `Silhouette, ${title.toLowerCase()} view`}>
<circle className="body-outline" cx="60" cy="23" r="16" />
<path className="body-outline" d="M42 43 Q60 36 78 43 L88 116 75 150 70 244H52L45 150 32 116Z" />
<path className="body-outline" d="M60 5C49 5 43 13 44 24c1 10 7 17 16 17s15-7 16-17C77 13 71 5 60 5Z" />
<path className="body-outline" d="M53 40v7c-13 2-21 8-25 20l-9 55c-1 7 2 11 8 12 5 1 9-3 10-9l7-43 1 49c0 8-5 17-7 27l-4 38 9 52c1 6 6 9 11 7 4-1 6-5 6-10v-43l4-28 4 28v43c0 5 2 9 6 10 5 2 10-1 11-7l9-52-4-38c-2-10-7-19-7-27l1-49 7 43c1 6 5 10 10 9 6-1 9-5 8-12l-9-55c-4-12-12-18-25-20v-7Z" />
{side === 'front' ? <>
<ellipse id="front-shoulder-left" {...zoneProps('shoulders')} cx="37" cy="57" rx="10" ry="12" /><ellipse id="front-shoulder-right" {...zoneProps('shoulders')} cx="83" cy="57" rx="10" ry="12" />
<path id="front-chest-left" {...zoneProps('chest')} d="M43 58 Q52 53 58 59 L57 85 Q47 85 42 77Z" /><path id="front-chest-right" {...zoneProps('chest')} d="M77 58 Q68 53 62 59 L63 85 Q73 85 78 77Z" />

View file

@ -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 () => {

View file

@ -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 (
<div className="activity-content">
<div className="metric-row">
@ -255,17 +256,19 @@ export function ActivityTile({ snapshot, size, analysis }: TileDataProps) {
)}
</div>
<div
className="activity-heatmap"
className="activity-calendar"
role="img"
aria-label={`Sur 90 jours : ${count(activeDays, "jour actif", "jours actifs")}, ${count(sessions, "séance")}, ${count(sets, "série")}.`}
>
{shown.map((day) => (
<span
key={day.date}
className={`activity-day${day.active ? " is-active" : ""}${day.session_count > 1 ? " is-multiple" : ""}`}
title={`${formatDate(day.date, dateFormat)} — ${count(day.session_count, "séance")}, ${count(day.set_count, "série")}`}
/>
))}
{weeks.map((week) => <section className="activity-week" key={week.key}>
<h4>{week.label}</h4><div className="activity-week-days">{week.days.map((day) => (
<span key={day.date} className={`activity-day activity-calendar-day${day.active ? " is-active" : ""}${day.session_count > 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")}`}>
<small>{activityDayLabel(day.date)}</small><strong>{/^\d{4}-\d{2}-\d{2}$/.test(day.date) ? Number(day.date.slice(8, 10)) : '—'}</strong>
{day.active && <b>{day.session_count}</b>}
</span>))}</div>
</section>)}
</div>
{size === "large" && (
<p className="chart-legend">
@ -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<string, typeof days>()
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 (
<div className="progression-content">
<div>
<p className="primary-label">{exercise.name}</p>
<p className="secondary-label">
{exercise.tracking_mode === "reps" ? "Répétitions" : "Durée"}
</p>
</div>
{latest === undefined ? (
<Unavailable text="Aucune donnée compatible sur 30 jours" />
) : (
<dl className="fact-list horizontal">
{exercise.recording_mode === "sets" && (
<Fact label="Séries" value={String(latest.sets)} />
)}
{latest.reps !== null && (
<Fact label="Répétitions" value={String(latest.reps)} />
)}
{(latest.continuous_duration_seconds ??
latest.set_duration_seconds) !== null && (
<Fact
label="Durée"
value={formatDuration(
latest.continuous_duration_seconds ??
latest.set_duration_seconds ??
0,
)}
/>
)}
{latest.distance_km !== null && (
<Fact
label="Distance"
value={`${latest.distance_km.toLocaleString("fr-FR", { maximumFractionDigits: 2 })} km`}
/>
)}
{latest.speed_kmh !== null && (
<Fact
label="Vitesse"
value={`${latest.speed_kmh.toLocaleString("fr-FR", { maximumFractionDigits: 2 })} km/h`}
/>
)}
{latest.external_volume_kg !== null && (
<Fact
label="Volume externe"
value={formatWeight(latest.external_volume_kg)}
/>
)}
{latest.explicit_max_kg !== null && (
<Fact
label="MAX explicite"
value={formatWeight(latest.explicit_max_kg)}
/>
)}
</dl>
)}
<dl className="fact-list horizontal"><Fact label="Exercices distincts" value={String(distinctExercises)} /><Fact label="Séances" value={String(analysis.overview.sessions)} /><Fact label="Séries" value={String(analysis.overview.sets)} /></dl>
{size !== 'compact' && top.length > 0 && <ol className="progression-top">{top.map((exercise) => <li key={exercise.exercise_id}><span>{exercise.name}</span><strong>{exercise.occurrences} occurrences</strong></li>)}</ol>}
<a
className="tile-context-link"
href={`/analyse?section=exercise&exercise_id=${encodeURIComponent(exercise.exercise_id)}&period=30d`}
href="/analyse?section=exercise&period=30d"
>
Voir dans Analyse
Explorer dans Analyse
</a>
</div>
);

View file

@ -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<LanguageContextValue | null>(null)
export function LanguagePreferencesProvider({ children }: { children: ReactNode }) {
const [language, updateLanguage] = useState<WebLanguage>(() =>
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 <LanguageContext.Provider value={value}>{children}</LanguageContext.Provider>
}
export function useLanguagePreferences(): LanguageContextValue {
const value = useContext(LanguageContext)
if (value === null) throw new Error('LanguagePreferencesProvider is missing')
return value
}

View file

@ -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(<AnalysisPage />);
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)"),
);
});
});

View file

@ -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 (
<figure className="activity-histogram">
<div className="analysis-chart-heading">
<strong>
{language === "fr" ? "Séances par date" : "Sessions by date"}
{language === "fr" ? "Exercices réalisés par date" : "Exercises completed by date"}
</strong>
<span>{language === "fr" ? "séances" : "sessions"}</span>
<span>{language === "fr" ? "exercices" : "exercises"}</span>
</div>
<div
className="activity-bars"
role="img"
aria-label={
language === "fr"
? "Histogramme des séances par date"
: "Sessions-by-date histogram"
? "Histogramme des exercices réalisés par date"
: "Exercises-by-date histogram"
}
>
{ordered.map((point, index) => (
<div className="activity-bar-column" key={point.date}>
<span className="activity-bar-value">{point.sessions}</span>
<span className="activity-bar-value">{point.exercises}</span>
<span
className="activity-bar"
style={{
height: `${Math.max(8, (point.sessions / maximum) * 100)}%`,
height: `${Math.max(8, (point.exercises / maximum) * 100)}%`,
}}
title={`${shortDate(`${point.date}T00:00:00Z`, language)} · ${point.sessions} ${language === "fr" ? "séance(s)" : "session(s)"}`}
title={`${shortDate(`${point.date}T00:00:00Z`, language)} · ${point.exercises} ${language === "fr" ? "exercice(s)" : "exercise(s)"} · ${point.sets} ${language === "fr" ? "série(s)" : "set(s)"}`}
/>
{(ordered.length <= 10 ||
index === 0 ||
@ -518,6 +518,7 @@ export function AnalysisPage() {
const [exerciseMetric, setExerciseMetric] = useState<ExerciseMetric | null>(
null,
);
const [selectedGroup, setSelectedGroup] = useState<string | null>(null);
const [snapshot, setSnapshot] = useState<AnalysisSnapshot | null>(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 (
<section className="page analysis-page" aria-labelledby="page-title">
<div className="page-heading">
@ -666,6 +669,10 @@ export function AnalysisPage() {
label={t.sets}
value={formatNumber(snapshot.overview.sets, language)}
/>
<Fact
label={language === "fr" ? "Exercices distincts" : "Distinct exercises"}
value={formatNumber(distinctExercises, language)}
/>
{snapshot.overview.duration_seconds !== null && (
<Fact
label={t.duration}
@ -732,17 +739,13 @@ export function AnalysisPage() {
</article>
<article className="analysis-panel">
<h2>{t.exercises}</h2>
{snapshot.exercise === null ? (
{distinctExercises === 0 ? (
<p className="analysis-empty">{t.noExercise}</p>
) : (
<>
<h3>{snapshot.exercise.name}</h3>
<ExerciseFacts
exercise={snapshot.exercise}
language={language}
selectedMetric={exerciseMetric}
onMetricChange={setExerciseMetric}
/>
<p className="analysis-summary-value">{distinctExercises}</p>
<p className="analysis-note">{language === "fr" ? "exercices distincts sur la période" : "distinct exercises in this period"}</p>
<button className="analysis-link" type="button" onClick={() => chooseSection("exercise")}>{language === "fr" ? "Explorer par groupe" : "Explore by group"}</button>
</>
)}
</article>
@ -750,7 +753,32 @@ export function AnalysisPage() {
)}
{section === "exercise" && (
<article className="analysis-panel analysis-wide">
<label className="analysis-picker">
{selectedGroup === null ? <>
<h2>{language === "fr" ? "Groupes musculaires" : "Muscle groups"}</h2>
<p className="analysis-note">{language === "fr" ? "Temps provenant uniquement des timestamps réels début/fin des exercices." : "Time comes only from real exercise start/end timestamps."}</p>
<div className="analysis-group-bars">{snapshot.exercise_groups.map((group) => {
const seconds = group.exercises.reduce((sum, item) => sum + item.measured_seconds, 0)
const measured = group.exercises.reduce((sum, item) => sum + item.measured_occurrences, 0)
const maximum = Math.max(1, ...snapshot.exercise_groups.map((candidate) => candidate.exercises.reduce((sum, item) => sum + item.measured_seconds, 0)))
return <button type="button" key={group.zone_id} onClick={() => { setSelectedGroup(group.zone_id); setExerciseId(undefined) }}>
<span>{language === "en" ? (zoneLabelsEn[group.zone_id] ?? group.label) : group.label}</span>
<span className="group-bar-track"><span style={{ width: `${(seconds / maximum) * 100}%` }} /></span>
<strong>{formatDuration(seconds)}</strong><small>{language === "fr" ? `Temps mesuré sur ${measured} occurrences` : `Time measured across ${measured} occurrences`}</small>
</button>
})}</div>
</> : activeGroup !== undefined && exerciseId !== undefined && snapshot.exercise?.exercise_id === exerciseId ? <>
<button type="button" className="analysis-back" onClick={() => setExerciseId(undefined)}>← {language === "fr" ? "Exercices du groupe" : "Group exercises"}</button>
<h2>{snapshot.exercise.name}</h2>
<ExerciseFacts exercise={snapshot.exercise} language={language} selectedMetric={exerciseMetric} onMetricChange={setExerciseMetric} detailed />
</> : activeGroup !== undefined ? <>
<button type="button" className="analysis-back" onClick={() => setSelectedGroup(null)}>← {language === "fr" ? "Groupes musculaires" : "Muscle groups"}</button>
<h2>{language === "en" ? (zoneLabelsEn[activeGroup.zone_id] ?? activeGroup.label) : activeGroup.label}</h2>
<div className="analysis-group-bars">{activeGroup.exercises.map((exercise) => <button type="button" key={exercise.exercise_id} onClick={() => chooseExercise(exercise.exercise_id)}>
<span>{exercise.name}</span><strong>{formatDuration(exercise.measured_seconds)}</strong>
<small>{exercise.occurrences} {language === "fr" ? "occurrences" : "occurrences"} · {exercise.sessions} {language === "fr" ? "séances" : "sessions"} · {exercise.sets} {language === "fr" ? "séries" : "sets"}<br/>{language === "fr" ? `Temps mesuré sur ${exercise.measured_occurrences} occurrences` : `Time measured across ${exercise.measured_occurrences} occurrences`}</small>
</button>)}</div>
</> : null}
<label className="analysis-picker legacy-exercise-picker">
{t.exercise}
<select
value={snapshot.exercise?.exercise_id ?? ""}
@ -769,9 +797,9 @@ export function AnalysisPage() {
))}
</select>
</label>
{snapshot.exercise === null ? (
{selectedGroup === null && snapshot.exercise === null ? (
<p className="analysis-empty">{t.noExercise}</p>
) : (
) : selectedGroup === null && snapshot.exercise !== null ? (
<>
<h2>{snapshot.exercise.name}</h2>
<ExerciseFacts
@ -782,7 +810,7 @@ export function AnalysisPage() {
detailed
/>
</>
)}
) : null}
</article>
)}
{section === "body-zones" && (
@ -827,6 +855,11 @@ export function AnalysisPage() {
)}
{section === "measurements" && (
<article className="analysis-panel analysis-wide">
<h2>{language === "fr" ? "Vue globale" : "Overview"}</h2>
<div className="measurement-overview">{snapshot.measurements.summaries.filter((summary) => summary.count > 0).map((summary) => {
const relative = summary.first !== null && summary.last !== null && summary.first !== 0 ? ((summary.last - summary.first) / Math.abs(summary.first)) * 100 : null
return <button type="button" key={summary.metric} aria-pressed={metric === summary.metric} onClick={() => chooseMetric(summary.metric)}><strong>{metricLabels[language][summary.metric]}</strong><span>{summary.first} {summary.unit} → {summary.last} {summary.unit}</span><em>{relative === null ? '—' : `${relative > 0 ? '+' : ''}${formatNumber(relative, language, 1)} %`}</em></button>
})}</div>
<label className="analysis-picker">
{t.metric}
<select

View file

@ -0,0 +1,64 @@
import { useEffect, useState } from 'react'
import { fetchEquipment, mergeEquipment, type EquipmentSnapshot } from '../api/equipment'
import { useLanguagePreferences } from '../presentation/LanguagePreferences'
export function EquipmentPage() {
const { language } = useLanguagePreferences()
const english = language === 'en'
const [snapshot, setSnapshot] = useState<EquipmentSnapshot | null>(null)
const [failed, setFailed] = useState(false)
const [canonicalId, setCanonicalId] = useState('')
const [duplicateId, setDuplicateId] = useState('')
const [confirming, setConfirming] = useState(false)
const [busy, setBusy] = useState(false)
const [mutationError, setMutationError] = useState('')
useEffect(() => {
const controller = new AbortController()
fetchEquipment(controller.signal).then(setSnapshot).catch(() => setFailed(true))
return () => controller.abort()
}, [])
const canonical = snapshot?.items.find((item) => item.equipment_id === canonicalId)
const duplicate = snapshot?.items.find((item) => item.equipment_id === duplicateId)
const canMerge = canonical !== undefined && duplicate !== undefined && canonicalId !== duplicateId
const performMerge = async () => {
if (!canMerge) return
setBusy(true); setMutationError('')
try { setSnapshot(await mergeEquipment(canonicalId, duplicateId)); setConfirming(false); setDuplicateId('') }
catch (error) { setMutationError(error instanceof Error ? error.message : 'equipment_merge_failed') }
finally { setBusy(false) }
}
return <section className="page equipment-page" aria-labelledby="page-title">
<div className="page-heading"><div><p className="eyebrow">{english ? 'HARDWARE' : 'MATÉRIEL'}</p>
<h1 id="page-title">{english ? 'Equipment' : 'Équipements'}</h1></div></div>
{failed && <p className="error-panel" role="alert">{english ? 'Equipment is unavailable.' : 'Les équipements sont indisponibles.'}</p>}
{snapshot === null && !failed && <p role="status">{english ? 'Loading…' : 'Chargement…'}</p>}
{snapshot && <>
<div className="equipment-grid">{snapshot.items.map((item) => <article className="equipment-card" key={item.equipment_id}>
<div><span className="equipment-origin">{item.origin}</span><h2>{item.display_name}</h2></div>
<p>{item.equipment_type} · {item.load_semantics}</p>
<dl className="compact-facts"><div><dt>{english ? 'Exercises' : 'Exercices'}</dt><dd>{item.exercise_count}</dd></div>
<div><dt>{english ? 'History' : 'Historique'}</dt><dd>{item.historical_occurrences}</dd></div></dl>
</article>)}</div>
<article className="equipment-merge-panel">
<h2>{english ? 'Merge duplicates' : 'Fusionner des doublons'}</h2>
<p>{english ? 'All legitimate references are moved atomically to the equipment you keep.' : 'Toutes les références légitimes sont transférées atomiquement vers l’équipement conservé.'}</p>
<div className="equipment-merge-fields">
<label>{english ? 'Keep' : 'Conserver'}<select value={canonicalId} onChange={(event) => { setCanonicalId(event.target.value); setConfirming(false) }}><option value="">—</option>{snapshot.items.map((item) => <option key={item.equipment_id} value={item.equipment_id}>{item.display_name}</option>)}</select></label>
<label>{english ? 'Merge into it' : 'Fusionner dedans'}<select value={duplicateId} onChange={(event) => { setDuplicateId(event.target.value); setConfirming(false) }}><option value="">—</option>{snapshot.items.map((item) => <option key={item.equipment_id} value={item.equipment_id}>{item.display_name}</option>)}</select></label>
</div>
{canMerge && <div className="merge-consequences"><strong>{english ? 'Consequences' : 'Conséquences'}</strong><ul>
<li>{duplicate.exercise_count} {english ? 'exercise associations' : 'associations exercice'}</li>
<li>{duplicate.historical_occurrences} {english ? 'historical occurrences (facts, timestamps and MAX retained)' : 'occurrences historiques (faits, timestamps et MAX conservés)'}</li>
<li>{duplicate.preparation_references} {english ? 'preparation references' : 'références de préparation'} · {duplicate.program_references} {english ? 'program references' : 'références de programme'}</li>
</ul></div>}
{!confirming ? <button type="button" className="danger-action" disabled={!canMerge} onClick={() => setConfirming(true)}>{english ? 'Review merge' : 'Vérifier la fusion'}</button> : <div className="merge-confirmation" role="alertdialog" aria-label={english ? 'Confirm equipment merge' : 'Confirmer la fusion des équipements'}>
<p><strong>{english ? 'Keep' : 'Conserver'} :</strong> {canonical?.display_name}<br/><strong>{english ? 'Merge into it' : 'Fusionner dedans'} :</strong> {duplicate?.display_name}</p>
<button type="button" className="danger-action" disabled={busy} onClick={() => void performMerge()}>{busy ? (english ? 'Merging…' : 'Fusion…') : (english ? 'Confirm irreversible merge' : 'Confirmer la fusion irréversible')}</button>
<button type="button" className="quiet-action" disabled={busy} onClick={() => setConfirming(false)}>{english ? 'Cancel' : 'Annuler'}</button>
</div>}
{mutationError && <p className="form-error" role="alert">{mutationError}</p>}
</article>
</>}
</section>
}

View file

@ -0,0 +1,39 @@
import { useDatePreferences } from '../presentation/DatePreferences'
import { useLanguagePreferences } from '../presentation/LanguagePreferences'
export function SettingsPage() {
const { language, setLanguage } = useLanguagePreferences()
const { dateFormat, pending, error, save } = useDatePreferences()
const english = language === 'en'
return <section className="page settings-page" aria-labelledby="page-title">
<div className="page-heading"><div>
<p className="eyebrow">{english ? 'PREFERENCES' : 'PRÉFÉRENCES'}</p>
<h1 id="page-title">{english ? 'Settings' : 'Paramètres'}</h1>
</div></div>
<div className="settings-grid">
<article className="settings-card">
<h2>{english ? 'Language' : 'Langue'}</h2>
<p>{english ? 'Language used throughout the Web interface.' : 'Langue utilisée dans toute l’interface Web.'}</p>
<fieldset>
<legend className="sr-only">{english ? 'Language' : 'Langue'}</legend>
<label><input type="radio" name="language" checked={language === 'fr'} onChange={() => setLanguage('fr')} /> Français</label>
<label><input type="radio" name="language" checked={language === 'en'} onChange={() => setLanguage('en')} /> English</label>
</fieldset>
</article>
<article className="settings-card">
<h2>{english ? 'Appearance' : 'Apparence'}</h2>
<fieldset disabled={pending}>
<legend>{english ? 'Date format' : 'Format des dates'}</legend>
<label><input type="radio" name="date-format" checked={dateFormat === 'fr'} onChange={() => void save('fr').catch(() => undefined)} /> {english ? 'French — 17/09/2026' : 'Français — 17/09/2026'}</label>
<label><input type="radio" name="date-format" checked={dateFormat === 'iso'} onChange={() => void save('iso').catch(() => undefined)} /> ISO — 2026-09-17</label>
</fieldset>
{error && <p className="form-error" role="alert">{error}</p>}
</article>
<article className="settings-card">
<h2>Dashboard</h2>
<p>{english ? 'Tile visibility and layout remain available directly on the Dashboard.' : 'La visibilité et l’agencement des cartes restent accessibles directement sur le Dashboard.'}</p>
<a className="analysis-action" href="/">{english ? 'Open Dashboard' : 'Ouvrir le Dashboard'}</a>
</article>
</div>
</section>
}

View file

@ -349,7 +349,8 @@ h2 {
align-self: start;
border: 1px solid var(--surface-1);
border-radius: 0.75rem;
background: var(--mantle);
background: color-mix(in srgb, var(--base) 78%, var(--surface-0));
box-shadow: 0 0.45rem 1.4rem rgb(0 0 0 / 10%);
overflow: hidden;
}
.analysis-panel h3 {
@ -1617,6 +1618,86 @@ h2 {
justify-content: space-between;
gap: 2rem;
}
.program-calendar-header h1 {
max-width: 24ch;
display: -webkit-box;
overflow: hidden;
font-size: clamp(2.15rem, 4vw, 2.8rem);
line-height: 1.02;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.settings-grid,
.equipment-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 17rem), 1fr));
gap: 1rem;
}
.settings-card,
.equipment-card,
.equipment-merge-panel {
padding: clamp(1rem, 2vw, 1.4rem);
background: color-mix(in srgb, var(--base) 76%, var(--surface-0));
border: 1px solid var(--surface-1);
border-radius: var(--radius);
box-shadow: 0 0.45rem 1.4rem rgb(0 0 0 / 10%);
}
.settings-card fieldset {
padding: 0;
display: grid;
gap: .65rem;
border: 0;
}
.settings-card label { display: flex; gap: .55rem; align-items: center; }
.settings-card p,
.equipment-card p { color: var(--subtext-0); line-height: 1.55; }
.equipment-origin { color: var(--lavender); font: 700 var(--font-size-xs) var(--font-mono); text-transform: uppercase; }
.equipment-card h2 { margin: .35rem 0 0; }
.compact-facts { margin: 1rem 0 0; display: flex; gap: 1.5rem; }
.compact-facts dt { color: var(--subtext-0); font-size: var(--font-size-xs); }
.compact-facts dd { margin: .2rem 0 0; font-weight: 750; }
.equipment-merge-panel { margin-top: 1rem; }
.equipment-merge-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; }
.equipment-merge-fields label { display: grid; gap: .35rem; color: var(--subtext-0); }
.equipment-merge-fields select { min-height: 2.7rem; padding: .5rem; color: var(--text); background: var(--mantle); border: 1px solid var(--surface-2); border-radius: .5rem; }
.merge-consequences { margin: 1rem 0; padding: .9rem; background: var(--mantle); border-radius: .55rem; }
.merge-confirmation { margin-top: 1rem; padding: 1rem; border: 1px solid var(--red); border-radius: .55rem; }
.merge-confirmation .quiet-action { margin-left: .6rem; }
.activity-calendar { max-height: 22rem; margin-top: 1rem; overflow: auto; display: grid; gap: .75rem; }
.activity-week h4 { margin: 0 0 .35rem; color: var(--subtext-0); font-size: var(--font-size-xs); text-transform: capitalize; }
.activity-week-days { display: grid; grid-template-columns: repeat(7, minmax(2.4rem, 1fr)); gap: .3rem; }
.activity-calendar-day { min-height: 3.15rem; padding: .35rem; position: relative; display: grid; align-content: space-between; color: var(--overlay-1); background: var(--base); border: 1px solid var(--surface-1); border-radius: .42rem; }
.activity-calendar-day small { text-transform: capitalize; }
.activity-calendar-day.is-active { color: var(--text); background: rgb(180 190 254 / 18%); border-color: var(--lavender); }
.activity-calendar-day.is-multiple { background: rgb(203 166 247 / 27%); }
.activity-calendar-day b { position: absolute; right: .35rem; bottom: .35rem; color: var(--lavender); font-size: .7rem; }
.progression-top { margin: .8rem 0; padding: 0; display: grid; gap: .4rem; list-style: none; }
.progression-top li { display: flex; justify-content: space-between; gap: 1rem; }
.progression-top strong { color: var(--subtext-0); font-size: var(--font-size-xs); }
.analysis-summary-value { margin: .6rem 0 0; font-size: 2.5rem; font-weight: 750; }
.analysis-group-bars { display: grid; gap: .55rem; }
.analysis-group-bars > button { padding: .75rem; display: grid; grid-template-columns: minmax(8rem, 1fr) minmax(8rem, 2fr) auto; gap: .4rem 1rem; align-items: center; color: var(--text); text-align: left; background: var(--base); border: 1px solid var(--surface-1); border-radius: .55rem; cursor: pointer; }
.analysis-group-bars > button:hover { border-color: var(--lavender); }
.analysis-group-bars small { grid-column: 1 / -1; color: var(--subtext-0); }
.group-bar-track { height: .55rem; overflow: hidden; background: var(--surface-0); border-radius: 1rem; }
.group-bar-track > span { height: 100%; display: block; background: var(--lavender); border-radius: inherit; }
.analysis-back { margin-bottom: 1rem; color: var(--lavender); background: transparent; border: 0; cursor: pointer; }
.legacy-exercise-picker { margin-top: 1.5rem; }
.measurement-overview { margin-bottom: 1.25rem; display: grid; grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr)); gap: .55rem; }
.measurement-overview button { padding: .75rem; display: grid; gap: .3rem; color: var(--text); text-align: left; background: var(--base); border: 1px solid var(--surface-1); border-radius: .55rem; cursor: pointer; }
.measurement-overview button[aria-pressed="true"] { border-color: var(--lavender); }
.measurement-overview span { color: var(--subtext-0); font-size: var(--font-size-xs); }
.measurement-overview em { color: var(--lavender); font-style: normal; font-weight: 700; }
@media (max-width: 760px) {
.equipment-merge-fields { grid-template-columns: 1fr; }
.activity-week-days { grid-template-columns: repeat(7, minmax(2rem, 1fr)); }
.analysis-group-bars > button { grid-template-columns: 1fr auto; }
.group-bar-track { grid-column: 1 / -1; }
}
.program-calendar-summary {
margin: 0.7rem 0 0;
color: var(--subtext-0);

View file

@ -28,8 +28,8 @@
--font-size-lg: 1.0625rem;
--font-size-xl: 1.5rem;
--font-size-display: clamp(2.25rem, 5vw, 4.5rem);
--shell-gutter: clamp(1rem, 3vw, 3rem);
--radius: 1rem;
--shell-gutter: clamp(0.9rem, 2vw, 2rem);
--radius: 0.78rem;
font-family: var(--font-sans);
color: var(--text);
background: var(--crust);