refactor(web): improve dashboard summaries

This commit is contained in:
fy59 2026-09-20 20:38:39 +02:00
parent 109eb6fcd4
commit c31c394e4f
6 changed files with 25 additions and 17 deletions

View file

@ -395,7 +395,7 @@ unchanged at ten pre-existing errors: one `NewApi`, one
`LocalContextConfigurationRead`, and eight bilingual `StringFormatMatches` `LocalContextConfigurationRead`, and eight bilingual `StringFormatMatches`
diagnostics. This UI tranche adds no lint error and changes no lint policy. diagnostics. This UI tranche adds no lint error and changes no lint policy.
The next private daily Android candidate remains on development The next private daily Android candidate remains on development
`versionName=0.1.3`; only its strictly increasing `versionCode` may be supplied `versionName=0.1.4`; only its strictly increasing `versionCode` may be supplied
as a build-time override. No public tag or release is created by that private as a build-time override. No public tag or release is created by that private
candidate process. candidate process.

View file

@ -666,7 +666,7 @@ TUI product version, asset names, and release title must agree.
Private update rollout may set `TRAINLOG_ANDROID_VERSION_CODE` to a positive Private update rollout may set `TRAINLOG_ANDROID_VERSION_CODE` to a positive
integer above the installed package while retaining the current development integer above the installed package while retaining the current development
`versionName=0.1.3`. The version-code override is build-only and does not change `versionName=0.1.4`. The version-code override is build-only and does not change
any schema or exchange-format version. Private daily candidates must not any schema or exchange-format version. Private daily candidates must not
downgrade the logical product version to the earlier 0.1.2 stable line. downgrade the logical product version to the earlier 0.1.2 stable line.

View file

@ -65,7 +65,7 @@ class DeploymentToolsTest(unittest.TestCase):
) )
self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual(result.returncode, 0, result.stderr)
inventory = json.loads((output / "candidate-inventory.json").read_text()) inventory = json.loads((output / "candidate-inventory.json").read_text())
self.assertEqual(inventory["product_version"], "0.1.3") self.assertEqual(inventory["product_version"], "0.1.4")
self.assertEqual(inventory["desktop_schema"], 28) self.assertEqual(inventory["desktop_schema"], 28)
self.assertEqual(inventory["android_schema"], 25) self.assertEqual(inventory["android_schema"], 25)
self.assertIn("session-preparations-v2", inventory["protocols"]) self.assertIn("session-preparations-v2", inventory["protocols"])
@ -88,7 +88,7 @@ class DeploymentToolsTest(unittest.TestCase):
text=True, text=True,
) )
self.assertEqual(linked.returncode, 0, linked.stderr) self.assertEqual(linked.returncode, 0, linked.stderr)
self.assertIn("trainlog 0.1.3", linked.stdout) self.assertIn("trainlog 0.1.4", linked.stdout)
imported = subprocess.run( imported = subprocess.run(
[ [
"python3", "python3",

View file

@ -103,7 +103,7 @@ def main() -> int:
"source_commit": subprocess.check_output( "source_commit": subprocess.check_output(
["git", "rev-parse", "HEAD"], cwd=ROOT, text=True ["git", "rev-parse", "HEAD"], cwd=ROOT, text=True
).strip(), ).strip(),
"product_version": "0.1.3", "product_version": "0.1.4",
"desktop_schema": 28, "desktop_schema": 28,
"android_schema": 25, "android_schema": 25,
"protocols": [ "protocols": [

View file

@ -24,9 +24,9 @@ export function muscleIntensityLevel(sessionCount: number, maximum: number): 0 |
return 3 return 3
} }
interface BodyZoneFigureProps { zones: WorkedZone[]; views?: 'front' | 'both' } interface BodyZoneFigureProps { zones: WorkedZone[]; views?: 'front' | 'both'; language?: 'fr' | 'en' }
export function BodyZoneFigure({ zones, views = 'both' }: BodyZoneFigureProps) { export function BodyZoneFigure({ zones, views = 'both', language = 'fr' }: BodyZoneFigureProps) {
const [focused, setFocused] = useState<WorkedZone | null>(null) const [focused, setFocused] = useState<WorkedZone | null>(null)
const byId = new Map(zones.map((zone) => [zone.zone_id, zone])) const byId = new Map(zones.map((zone) => [zone.zone_id, zone]))
const maximum = Math.max(1, ...zones.map((zone) => zone.session_count)) const maximum = Math.max(1, ...zones.map((zone) => zone.session_count))
@ -37,25 +37,27 @@ export function BodyZoneFigure({ zones, views = 'both' }: BodyZoneFigureProps) {
className: `body-region intensity-${muscleIntensityLevel(zone.session_count, maximum)}`, className: `body-region intensity-${muscleIntensityLevel(zone.session_count, maximum)}`,
tabIndex: 0, tabIndex: 0,
role: 'button', role: 'button',
'aria-label': `${zone.label} : ${count(zone.session_count, 'séance')}, ${count(zone.occurrence_count, 'occurrence')}, ${count(zone.set_count, 'série')}.`, 'aria-label': language === 'fr'
? `${zone.label} : ${count(zone.session_count, 'séance')}, ${count(zone.occurrence_count, 'occurrence')}, ${count(zone.set_count, 'série')}.`
: `${zone.label}: ${zone.session_count} ${zone.session_count === 1 ? 'session' : 'sessions'}, ${zone.occurrence_count} ${zone.occurrence_count === 1 ? 'exposure' : 'exposures'}, ${zone.set_count} ${zone.set_count === 1 ? 'associated set' : 'associated sets'}.`,
onFocus: () => setFocused(zone), onBlur: () => setFocused(null), onFocus: () => setFocused(zone), onBlur: () => setFocused(null),
onMouseEnter: () => setFocused(zone), onMouseLeave: () => setFocused(null), onMouseEnter: () => setFocused(zone), onMouseLeave: () => setFocused(null),
} }
} }
return <div className="body-zone-figure"> return <div className="body-zone-figure">
<div className="body-views"> <div className="body-views">
<BodyView title="Avant" side="front" zoneProps={zoneProps} /> <BodyView title={language === 'fr' ? 'Avant' : 'Front'} side="front" zoneProps={zoneProps} language={language} />
{views === 'both' && <BodyView title="Arrière" side="back" zoneProps={zoneProps} />} {views === 'both' && <BodyView title={language === 'fr' ? 'Arrière' : 'Back'} side="back" zoneProps={zoneProps} language={language} />}
</div> </div>
<div className="muscle-legend" aria-label="Échelle visuelle des séances sur 30 jours"><span className="intensity-0" />0<span className="intensity-1" />Faible<span className="intensity-2" />Intermédiaire<span className="intensity-3" />Maximum observé</div> <div className="muscle-legend" aria-label={language === 'fr' ? 'Échelle visuelle des séances sur 30 jours' : 'Visual scale of sessions over 30 days'}><span className="intensity-0" />0<span className="intensity-1" />{language === 'fr' ? 'Faible' : 'Low'}<span className="intensity-2" />{language === 'fr' ? 'Intermédiaire' : 'Medium'}<span className="intensity-3" />{language === 'fr' ? 'Maximum observé' : 'Observed maximum'}</div>
{focused && <div className="body-tooltip" role="status"><strong>{focused.label}</strong><span>{count(focused.session_count, 'séance')} · {count(focused.occurrence_count, 'occurrence')} · {count(focused.set_count, 'série')}</span></div>} {focused && <div className="body-tooltip" role="status"><strong>{focused.label}</strong><span>{language === 'fr' ? `${count(focused.session_count, 'séance')} · ${count(focused.occurrence_count, 'occurrence')} · ${count(focused.set_count, 'série')}` : `${focused.session_count} sessions · ${focused.occurrence_count} exposures · ${focused.set_count} associated sets`}</span></div>}
</div> </div>
} }
type RegionProps = (zoneId: MappedBodyZoneId) => Record<string, unknown> type RegionProps = (zoneId: MappedBodyZoneId) => Record<string, unknown>
function BodyView({ title, side, zoneProps }: { title: string; side: 'front' | 'back'; zoneProps: RegionProps }) { 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={`Silhouette, vue ${title.toLowerCase()}`}> 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" /> <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="M42 43 Q60 36 78 43 L88 116 75 150 70 244H52L45 150 32 116Z" />
{side === 'front' ? <> {side === 'front' ? <>

View file

@ -12,6 +12,12 @@ const metricLabels = {
en: { weight: 'Weight', neck: 'Neck', shoulders: 'Shoulders', chest: 'Chest', waist: 'Waist', hips: 'Hips', left_arm: 'Left arm', right_arm: 'Right arm', left_forearm: 'Left forearm', right_forearm: 'Right forearm', left_thigh: 'Left thigh', right_thigh: 'Right thigh', left_calf: 'Left calf', right_calf: 'Right calf' }, en: { weight: 'Weight', neck: 'Neck', shoulders: 'Shoulders', chest: 'Chest', waist: 'Waist', hips: 'Hips', left_arm: 'Left arm', right_arm: 'Right arm', left_forearm: 'Left forearm', right_forearm: 'Right forearm', left_thigh: 'Left thigh', right_thigh: 'Right thigh', left_calf: 'Left calf', right_calf: 'Right calf' },
} satisfies Record<AnalysisLanguage, Record<MeasurementMetric, string>> } satisfies Record<AnalysisLanguage, Record<MeasurementMetric, string>>
const zoneLabelsEn: Readonly<Record<string, string>> = {
full_body: 'Full body', upper_body: 'Upper body', chest: 'Chest', back: 'Back',
shoulders: 'Shoulders', arms: 'Arms', core: 'Core', lower_body: 'Lower body',
glutes: 'Glutes', thighs: 'Thighs', calves: 'Calves',
}
function safeInitialQuery() { function safeInitialQuery() {
const query = new URLSearchParams(window.location.search) const query = new URLSearchParams(window.location.search)
const sectionValue = query.get('section') const sectionValue = query.get('section')
@ -105,7 +111,7 @@ export function AnalysisPage() {
function chooseMetric(value: MeasurementMetric) { setMetric(value); updateUrl('measurements', period, exerciseId, value); setSection('measurements') } function chooseMetric(value: MeasurementMetric) { setMetric(value); updateUrl('measurements', period, exerciseId, value); setSection('measurements') }
const selectedSummary = snapshot?.measurements.summaries.find((item) => item.metric === metric) const selectedSummary = snapshot?.measurements.summaries.find((item) => item.metric === metric)
const zoneFigure = snapshot?.body_zones.map((zone) => ({ zone_id: zone.zone_id, label: zone.label, session_count: zone.exposures, occurrence_count: zone.exposures, set_count: zone.associated_sets })) ?? [] const zoneFigure = snapshot?.body_zones.map((zone) => ({ zone_id: zone.zone_id, label: language === 'en' ? zoneLabelsEn[zone.zone_id] ?? zone.zone_id : zone.label, session_count: zone.exposures, occurrence_count: zone.exposures, set_count: zone.associated_sets })) ?? []
return <section className="page analysis-page" aria-labelledby="page-title"> return <section className="page analysis-page" aria-labelledby="page-title">
<div className="page-heading"><div><p className="eyebrow">{t.eyebrow}</p><h1 id="page-title">{t.title}</h1></div><label className="analysis-language">{t.language}<select value={language} onChange={(event) => setLanguage(event.target.value as AnalysisLanguage)}><option value="fr">Français</option><option value="en">English</option></select></label></div> <div className="page-heading"><div><p className="eyebrow">{t.eyebrow}</p><h1 id="page-title">{t.title}</h1></div><label className="analysis-language">{t.language}<select value={language} onChange={(event) => setLanguage(event.target.value as AnalysisLanguage)}><option value="fr">Français</option><option value="en">English</option></select></label></div>
<div className="analysis-toolbar"><div className="analysis-tabs" role="tablist" aria-label={t.title}>{sections.map((id) => <button key={id} type="button" role="tab" aria-selected={section === id} onClick={() => chooseSection(id)}>{id === 'overview' ? t.overview : id === 'exercise' ? t.exercises : id === 'body-zones' ? t.distribution : t.measurements}</button>)}</div><label>{t.period}<select aria-label={t.period} value={period} onChange={(event) => choosePeriod(event.target.value as AnalysisPeriod)}>{ANALYSIS_PERIODS.map((value) => <option key={value} value={value}>{t.periods[value]}</option>)}</select></label></div> <div className="analysis-toolbar"><div className="analysis-tabs" role="tablist" aria-label={t.title}>{sections.map((id) => <button key={id} type="button" role="tab" aria-selected={section === id} onClick={() => chooseSection(id)}>{id === 'overview' ? t.overview : id === 'exercise' ? t.exercises : id === 'body-zones' ? t.distribution : t.measurements}</button>)}</div><label>{t.period}<select aria-label={t.period} value={period} onChange={(event) => choosePeriod(event.target.value as AnalysisPeriod)}>{ANALYSIS_PERIODS.map((value) => <option key={value} value={value}>{t.periods[value]}</option>)}</select></label></div>
@ -116,11 +122,11 @@ export function AnalysisPage() {
{section === 'overview' && <div className="analysis-section-grid"> {section === 'overview' && <div className="analysis-section-grid">
<article className="analysis-panel"><h2>{t.overview}</h2><dl className="analysis-facts"><Fact label={t.sessions} value={snapshot.overview.sessions} language={language} /><Fact label={t.sets} value={snapshot.overview.sets} language={language} />{snapshot.overview.duration_seconds !== null && <div><dt>{t.duration}</dt><dd>{duration(snapshot.overview.duration_seconds, language)}</dd></div>}</dl>{snapshot.overview.sessions === 0 && <p className="analysis-empty">{t.noData}</p>}<MiniLineChart points={snapshot.activity.map((day) => ({ timestamp: `${day.date}T00:00:00Z`, value: day.sessions }))} unit={t.sessions.toLowerCase()} language={language} label={t.sessions} /></article> <article className="analysis-panel"><h2>{t.overview}</h2><dl className="analysis-facts"><Fact label={t.sessions} value={snapshot.overview.sessions} language={language} /><Fact label={t.sets} value={snapshot.overview.sets} language={language} />{snapshot.overview.duration_seconds !== null && <div><dt>{t.duration}</dt><dd>{duration(snapshot.overview.duration_seconds, language)}</dd></div>}</dl>{snapshot.overview.sessions === 0 && <p className="analysis-empty">{t.noData}</p>}<MiniLineChart points={snapshot.activity.map((day) => ({ timestamp: `${day.date}T00:00:00Z`, value: day.sessions }))} unit={t.sessions.toLowerCase()} language={language} label={t.sessions} /></article>
<article className="analysis-panel"><h2>{t.activeProgram}</h2>{snapshot.active_program === null ? <p className="analysis-empty">{t.noProgram}</p> : <><h3>{snapshot.active_program.title}</h3><p>{snapshot.active_program.completed_sessions}/{snapshot.active_program.total_sessions}</p><progress max={snapshot.active_program.total_sessions || 1} value={snapshot.active_program.completed_sessions} aria-label={t.programProgress} />{snapshot.active_program.next_session_title && <p>{t.nextSession}: {snapshot.active_program.next_session_title}</p>}<a href="/programmes">Programmes</a></>}</article> <article className="analysis-panel"><h2>{t.activeProgram}</h2>{snapshot.active_program === null ? <p className="analysis-empty">{t.noProgram}</p> : <><h3>{snapshot.active_program.title}</h3><p>{snapshot.active_program.completed_sessions}/{snapshot.active_program.total_sessions}</p><progress max={snapshot.active_program.total_sessions || 1} value={snapshot.active_program.completed_sessions} aria-label={t.programProgress} />{snapshot.active_program.next_session_title && <p>{t.nextSession}: {snapshot.active_program.next_session_title}</p>}<a href="/programmes">Programmes</a></>}</article>
<article className="analysis-panel"><h2>{t.distribution}</h2>{zoneFigure.length === 0 ? <p className="analysis-empty">{t.noData}</p> : <BodyZoneFigure zones={zoneFigure} />}<button className="analysis-link" type="button" onClick={() => chooseSection('body-zones')}>{t.distribution}</button></article> <article className="analysis-panel"><h2>{t.distribution}</h2>{zoneFigure.length === 0 ? <p className="analysis-empty">{t.noData}</p> : <BodyZoneFigure zones={zoneFigure} language={language} />}<button className="analysis-link" type="button" onClick={() => chooseSection('body-zones')}>{t.distribution}</button></article>
<article className="analysis-panel"><h2>{t.exercises}</h2>{snapshot.exercise === null ? <p className="analysis-empty">{t.noExercise}</p> : <><h3>{snapshot.exercise.name}</h3><ExerciseFacts points={snapshot.exercise.points} language={language} /></>}</article> <article className="analysis-panel"><h2>{t.exercises}</h2>{snapshot.exercise === null ? <p className="analysis-empty">{t.noExercise}</p> : <><h3>{snapshot.exercise.name}</h3><ExerciseFacts points={snapshot.exercise.points} language={language} /></>}</article>
</div>} </div>}
{section === 'exercise' && <article className="analysis-panel analysis-wide"><label className="analysis-picker">{t.exercise}<select value={snapshot.exercise?.exercise_id ?? ''} onChange={(event) => chooseExercise(event.target.value)}><option value="" disabled>{t.noExercise}</option>{snapshot.exercises.map((exercise) => <option key={exercise.exercise_id} value={exercise.exercise_id}>{exercise.name}</option>)}</select></label>{snapshot.exercise === null ? <p className="analysis-empty">{t.noExercise}</p> : <><h2>{snapshot.exercise.name}</h2><ExerciseFacts points={snapshot.exercise.points} language={language} /></>}</article>} {section === 'exercise' && <article className="analysis-panel analysis-wide"><label className="analysis-picker">{t.exercise}<select value={snapshot.exercise?.exercise_id ?? ''} onChange={(event) => chooseExercise(event.target.value)}><option value="" disabled>{t.noExercise}</option>{snapshot.exercises.map((exercise) => <option key={exercise.exercise_id} value={exercise.exercise_id}>{exercise.name}</option>)}</select></label>{snapshot.exercise === null ? <p className="analysis-empty">{t.noExercise}</p> : <><h2>{snapshot.exercise.name}</h2><ExerciseFacts points={snapshot.exercise.points} language={language} /></>}</article>}
{section === 'body-zones' && <article className="analysis-panel analysis-wide"><h2>{t.exposures}</h2><p className="analysis-note">{t.zonesHelp}</p>{zoneFigure.length === 0 ? <p className="analysis-empty">{t.noData}</p> : <div className="analysis-zones"><BodyZoneFigure zones={zoneFigure} /><ul>{snapshot.body_zones.map((zone) => <li key={zone.zone_id}><strong>{zone.label}</strong><span>{zone.exposures} {t.exposures.toLowerCase()} · {zone.associated_sets} {t.sets.toLowerCase()}</span></li>)}</ul></div>}</article>} {section === 'body-zones' && <article className="analysis-panel analysis-wide"><h2>{t.exposures}</h2><p className="analysis-note">{t.zonesHelp}</p>{zoneFigure.length === 0 ? <p className="analysis-empty">{t.noData}</p> : <div className="analysis-zones"><BodyZoneFigure zones={zoneFigure} language={language} /><ul>{snapshot.body_zones.map((zone) => <li key={zone.zone_id}><strong>{language === 'en' ? zoneLabelsEn[zone.zone_id] ?? zone.zone_id : zone.label}</strong><span>{zone.exposures} {t.exposures.toLowerCase()} · {zone.associated_sets} {t.sets.toLowerCase()}</span></li>)}</ul></div>}</article>}
{section === 'measurements' && <article className="analysis-panel analysis-wide"><label className="analysis-picker">{t.metric}<select value={metric} onChange={(event) => chooseMetric(event.target.value as MeasurementMetric)}>{MEASUREMENT_METRICS.map((value) => <option key={value} value={value}>{metricLabels[language][value]}</option>)}</select></label><h2>{metricLabels[language][metric]}</h2>{selectedSummary === undefined || selectedSummary.count === 0 ? <p className="analysis-empty">{t.noData}</p> : <><dl className="analysis-facts"><div><dt>{t.first}</dt><dd>{selectedSummary.first === null ? '—' : `${formatNumber(selectedSummary.first, language)} ${selectedSummary.unit}`}</dd></div><div><dt>{t.last}</dt><dd>{selectedSummary.last === null ? '—' : `${formatNumber(selectedSummary.last, language)} ${selectedSummary.unit}`}</dd></div><div><dt>{t.delta}</dt><dd>{selectedSummary.delta === null ? '—' : `${selectedSummary.delta > 0 ? '+' : ''}${formatNumber(selectedSummary.delta, language)} ${selectedSummary.unit}`}</dd></div></dl>{selectedSummary.count === 1 && <p className="analysis-note">{t.onePoint}</p>}<MiniLineChart points={snapshot.measurements.points} unit={selectedSummary.unit} language={language} label={metricLabels[language][metric]} /></>}</article>} {section === 'measurements' && <article className="analysis-panel analysis-wide"><label className="analysis-picker">{t.metric}<select value={metric} onChange={(event) => chooseMetric(event.target.value as MeasurementMetric)}>{MEASUREMENT_METRICS.map((value) => <option key={value} value={value}>{metricLabels[language][value]}</option>)}</select></label><h2>{metricLabels[language][metric]}</h2>{selectedSummary === undefined || selectedSummary.count === 0 ? <p className="analysis-empty">{t.noData}</p> : <><dl className="analysis-facts"><div><dt>{t.first}</dt><dd>{selectedSummary.first === null ? '—' : `${formatNumber(selectedSummary.first, language)} ${selectedSummary.unit}`}</dd></div><div><dt>{t.last}</dt><dd>{selectedSummary.last === null ? '—' : `${formatNumber(selectedSummary.last, language)} ${selectedSummary.unit}`}</dd></div><div><dt>{t.delta}</dt><dd>{selectedSummary.delta === null ? '—' : `${selectedSummary.delta > 0 ? '+' : ''}${formatNumber(selectedSummary.delta, language)} ${selectedSummary.unit}`}</dd></div></dl>{selectedSummary.count === 1 && <p className="analysis-note">{t.onePoint}</p>}<MiniLineChart points={snapshot.measurements.points} unit={selectedSummary.unit} language={language} label={metricLabels[language][metric]} /></>}</article>}
</>} </>}
</section> </section>