diff --git a/CHANGELOG.md b/CHANGELOG.md index f8adf73..b8f2e14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,15 @@ 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. +The post-review correction keeps acknowledged-generation capacity recoverable +for legacy Android rows without ACK-ledger evidence, proves two successive +Sleep-only generation/ACK exchanges, and never evicts an unacknowledged +generation. Web can now causally remove only an orphaned zero-occurrence active +draft while retaining protection for real active drafts. Mutation UUIDs use +`crypto.getRandomValues()` on loopback HTTP origins. Sleep timeline geometry, +Dashboard sizing, Program progress contrast, chart proportions, duration +coverage messaging, and the right-aligned Settings/Sync action cluster are +also refined. ## 0.1.5 — 2026-09-23 diff --git a/contracts/dashboard-layout-v1.json b/contracts/dashboard-layout-v1.json index 61fb85a..bf0e8f3 100644 --- a/contracts/dashboard-layout-v1.json +++ b/contracts/dashboard-layout-v1.json @@ -4,12 +4,12 @@ "columns": 12, "max_y": 200, "tiles": [ - { "id": "next-session", "min_width": 4, "max_width": 8, "min_height": 3, "max_height": 7, "default": { "x": 0, "y": 0, "width": 5, "height": 4 } }, - { "id": "activity", "min_width": 5, "max_width": 12, "min_height": 3, "max_height": 7, "default": { "x": 5, "y": 0, "width": 7, "height": 4 } }, - { "id": "progression", "min_width": 6, "max_width": 12, "min_height": 4, "max_height": 9, "default": { "x": 0, "y": 4, "width": 8, "height": 5 } }, - { "id": "last-session", "min_width": 3, "max_width": 7, "min_height": 3, "max_height": 6, "default": { "x": 8, "y": 4, "width": 4, "height": 3 } }, - { "id": "max-records", "min_width": 3, "max_width": 8, "min_height": 3, "max_height": 7, "default": { "x": 8, "y": 7, "width": 4, "height": 3 } }, - { "id": "muscle-distribution", "min_width": 5, "max_width": 12, "min_height": 4, "max_height": 9, "default": { "x": 0, "y": 9, "width": 6, "height": 5 } }, - { "id": "cardio-recovery", "min_width": 5, "max_width": 12, "min_height": 4, "max_height": 8, "default": { "x": 6, "y": 10, "width": 6, "height": 4 } } + { "id": "next-session", "min_width": 4, "max_width": 12, "min_height": 3, "max_height": 10, "default": { "x": 0, "y": 0, "width": 5, "height": 4 } }, + { "id": "activity", "min_width": 5, "max_width": 12, "min_height": 3, "max_height": 12, "default": { "x": 5, "y": 0, "width": 7, "height": 4 } }, + { "id": "progression", "min_width": 6, "max_width": 12, "min_height": 4, "max_height": 12, "default": { "x": 0, "y": 4, "width": 8, "height": 5 } }, + { "id": "last-session", "min_width": 3, "max_width": 12, "min_height": 3, "max_height": 10, "default": { "x": 8, "y": 4, "width": 4, "height": 3 } }, + { "id": "max-records", "min_width": 3, "max_width": 12, "min_height": 3, "max_height": 10, "default": { "x": 8, "y": 7, "width": 4, "height": 3 } }, + { "id": "muscle-distribution", "min_width": 5, "max_width": 12, "min_height": 4, "max_height": 12, "default": { "x": 0, "y": 9, "width": 6, "height": 5 } }, + { "id": "cardio-recovery", "min_width": 5, "max_width": 12, "min_height": 4, "max_height": 12, "default": { "x": 6, "y": 10, "width": 6, "height": 4 } } ] } diff --git a/docs/current_state.md b/docs/current_state.md index 72cf9f6..3a2e77a 100644 --- a/docs/current_state.md +++ b/docs/current_state.md @@ -315,12 +315,40 @@ exercise to individual detail. Exercise work time is derived exclusively from Measurements first expose unit-safe relative change cards before real-unit detail. Dashboard and Analyse reuse the same human-proportioned BODY ZONES SVG. +The Dashboard layout keeps a 12-column canonical grid but permits every card +to reach full width and several useful heights; per-card minima, overlap +validation, exact save/cancel/reset semantics, and keyboard resizing remain +authoritative. Settings and synchronization form the rightmost Header action +cluster. Program progress uses the shared success color while retaining its +factual completed/total text. Responsive charts preserve their viewBox aspect +ratio, and Analyse explicitly distinguishes unavailable exercise timing from +partial measured coverage instead of deriving time from sets or repetitions. +The shared Sleep agenda owns one label/track/facts grid. Its five clock labels +and every event, interval and medication marker use the same coordinates: +18:00 at 0%, 00:00 at 25%, 06:00 at 50%, 12:00 at 75%, and 18:00 at 100%. + 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. +Browser mutation identities use the shared UUIDv4 helper backed by +`crypto.getRandomValues()`; no Web mutation depends on `crypto.randomUUID()`, +so `http://trainlog.perf` remains supported. + +An `execution_drafts` row marked active but containing no `exercises` or +legacy `entries` is an orphaned projection, not a resumable workout. Web may +delete that exact row through the existing revision-guarded causal transaction; +the durable tombstone prevents synchronization replay from resurrecting it. +An active draft with any occurrence remains protected from Web deletion. + +Generation admission archives only rows backed by an exact correlated consumed +ACK and retains the newest two acknowledged lineage members. Legacy terminal +Android rows that predate the durable ACK ledger are neither fabricated nor +deleted, but no longer consume the active admission window. Captured, +published, waiting-ACK, and current acknowledged rows continue to count until +safe archival, so capacity recovery never prunes unacknowledged evidence. `TRAINLOG_WEB_V1` architecture, API independence, local-network boundary, browser shell, Dashboard, layout ownership, build/runtime separation, and diff --git a/docs/tests.md b/docs/tests.md index 1015eb3..ddaef35 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -13,6 +13,23 @@ 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. +Post-review regressions cover a validated Sleep Diary revision plus nocturnal +BPM/RR capture through the production full-generation transport, desktop +import, correlated ACK, exact ACK replay, a second Sleep-only revision and a +second generation. The 24-conversation deployed-capacity regression removes +legacy ACK-ledger rows deliberately, recovers capacity without deleting +generation evidence, and continues through archive/replay. Native Web tests +prove that an active zero-occurrence execution draft is deleted causally and +that repeating its request is byte-identical, while a populated active draft +still conflicts. The equipment client test removes `crypto.randomUUID`, uses +deterministic `getRandomValues` bytes, and completes the protected merge HTTP +request; native merge tests retain their transaction/rollback coverage. + +UI regressions assert the Sleep labels at exactly 0/25/50/75/100%, persisted +Dashboard sizing and reset behavior, English preference persistence across a +remount, centralized Dashboard English labels, non-stretched chart aspect +ratios, and distinct missing/partial exercise-timing messages. + 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/tui/src/dashboard_layout.c b/tui/src/dashboard_layout.c index 313f6aa..6a49aec 100644 --- a/tui/src/dashboard_layout.c +++ b/tui/src/dashboard_layout.c @@ -21,13 +21,16 @@ typedef struct TileContract { } TileContract; static const TileContract contracts[TRAINLOG_DASHBOARD_LAYOUT_TILE_COUNT] = { - {"next-session", 4U, 8U, 3U, 7U}, - {"activity", 5U, 12U, 3U, 7U}, - {"progression", 6U, 12U, 4U, 9U}, - {"last-session", 3U, 7U, 3U, 6U}, - {"max-records", 3U, 8U, 3U, 7U}, - {"muscle-distribution", 5U, 12U, 4U, 9U}, - {"cardio-recovery", 5U, 12U, 4U, 8U}}; + /* CONTRACT: every tile can reach full width and multiple useful vertical + * sizes. Per-tile minima preserve legibility; the shared 12-column grid + * and overlap validation remain the bounded layout authority. */ + {"next-session", 4U, 12U, 3U, 10U}, + {"activity", 5U, 12U, 3U, 12U}, + {"progression", 6U, 12U, 4U, 12U}, + {"last-session", 3U, 12U, 3U, 10U}, + {"max-records", 3U, 12U, 3U, 10U}, + {"muscle-distribution", 5U, 12U, 4U, 12U}, + {"cardio-recovery", 5U, 12U, 4U, 12U}}; static const TrainlogDashboardTileLayout defaults[TRAINLOG_DASHBOARD_LAYOUT_TILE_COUNT] = { {"next-session", 0U, 0U, 5U, 4U}, diff --git a/web/src/app/App.test.tsx b/web/src/app/App.test.tsx index 2cc1e8e..4ceb28f 100644 --- a/web/src/app/App.test.tsx +++ b/web/src/app/App.test.tsx @@ -166,4 +166,21 @@ describe('shell Trainlog', () => { expect(screen.getAllByRole('link').filter((link) => link.closest('nav'))).toHaveLength(6) expect(screen.getByRole('link', { name: 'Dashboard' })).toHaveAttribute('aria-current', 'page') }) + + it('persists English and applies centralized Dashboard navigation labels after reload', async () => { + mockHealth() + window.history.replaceState(null, '', '/parametres') + const first = render() + fireEvent.click(screen.getByRole('radio', { name: 'English' })) + fireEvent.click(screen.getByRole('link', { name: 'Dashboard' })) + expect(await screen.findByRole('button', { name: 'Edit layout' })).toBeInTheDocument() + expect(screen.getByText('Next session')).toBeInTheDocument() + expect(screen.queryByText('Prochaine séance')).not.toBeInTheDocument() + first.unmount() + window.history.replaceState(null, '', '/') + render() + expect(await screen.findByRole('button', { name: 'Edit layout' })).toBeInTheDocument() + expect(document.documentElement.lang).toBe('en') + window.localStorage.removeItem('trainlog.web.language.v1') + }) }) diff --git a/web/src/components/Header.tsx b/web/src/components/Header.tsx index 1c4212d..56f6631 100644 --- a/web/src/components/Header.tsx +++ b/web/src/components/Header.tsx @@ -38,9 +38,11 @@ export function Header({ activeRoute, onNavigate, onSyncCommitted }: HeaderProps ))} - follow(event, '/parametres')}>⚙ {language === 'en' ? 'Settings' : 'Paramètres'} - +
+ follow(event, '/parametres')}>⚙ {language === 'en' ? 'Settings' : 'Paramètres'} + +
) } diff --git a/web/src/dashboard/DashboardGrid.tsx b/web/src/dashboard/DashboardGrid.tsx index 747b233..827d3b7 100644 --- a/web/src/dashboard/DashboardGrid.tsx +++ b/web/src/dashboard/DashboardGrid.tsx @@ -21,15 +21,17 @@ import { import { ActivityTile, LastSessionTile, MuscleDistributionTile, NextSessionTile, ProgressionTile, type DashboardTileComponent } from './DashboardTiles' import { MeasurementsTile, ProgramTile } from './DashboardTiles' import type { AnalysisSnapshot } from '../api/analysis' +import { useLanguagePreferences } from '../presentation/LanguagePreferences' +import { dashboardMessages } from './dashboardMessages' -const dashboardTiles: Readonly> = { - 'next-session': { title: 'Prochaine séance', eyebrow: 'Planification', component: NextSessionTile }, - activity: { title: 'Activité', eyebrow: 'Régularité', component: ActivityTile }, - progression: { title: 'Progression', eyebrow: 'Évolution', component: ProgressionTile }, - 'last-session': { title: 'Dernière séance', eyebrow: 'Historique', component: LastSessionTile }, - 'max-records': { title: 'Mensurations', eyebrow: 'Évolution', component: MeasurementsTile }, - 'muscle-distribution': { title: 'Répartition musculaire', eyebrow: 'Zones', component: MuscleDistributionTile }, - 'cardio-recovery': { title: 'Programme actif', eyebrow: 'Planification', component: ProgramTile }, +const dashboardComponents: Readonly> = { + 'next-session': NextSessionTile, + activity: ActivityTile, + progression: ProgressionTile, + 'last-session': LastSessionTile, + 'max-records': MeasurementsTile, + 'muscle-distribution': MuscleDistributionTile, + 'cardio-recovery': ProgramTile, } function breakpoint(width: number): { columns: 12 | 6 | 1, rowHeight: number } { @@ -57,6 +59,13 @@ export function DashboardGrid({ preparedItemsFailed = false, analysis = null, }: DashboardGridProps) { + const { language } = useLanguagePreferences() + const messages = dashboardMessages[language] + const dashboardTiles = Object.fromEntries(TILE_IDS.map((id) => [id, { + title: messages.tiles[id][0], + eyebrow: messages.tiles[id][1], + component: dashboardComponents[id], + }])) as Readonly> const [layout, setLayout] = useState(() => cloneLayout(DEFAULT_DASHBOARD_LAYOUT)) const [editing, setEditing] = useState(false) const [announcement, setAnnouncement] = useState('') @@ -151,27 +160,27 @@ export function DashboardGrid({ const libraryLayout = toGridLayout(visibleLayout, editing && desktop, view.columns) - if (loading) return

Chargement de l’agencement…

+ if (loading) return

{messages.loading}

return (
-
+
{!editing ? ( - + ) : ( <> -

Flèches : déplacer · Maj + flèches : redimensionner

+

{messages.help}

{!desktop &&

Revenez sur un écran large pour modifier la grille canonique.

} - - - + + + )}
{preferenceError &&
{preferenceError}{preferenceError.includes('autre onglet') && }
} - {pending &&
Chargement des données du Dashboard…
} - {failed &&
Les données du Dashboard sont momentanément indisponibles.
} - {dashboard?.meta.invalid_data &&
Certaines données invalides ont été écartées.
} + {pending &&
{messages.dashboardLoading}
} + {failed &&
{messages.dashboardFailed}
} + {dashboard?.meta.invalid_data &&
{messages.invalidData}
}

{announcement}

onTileKeyDown(event, id)} - stateLabel={tilePending ? 'CHARGEMENT' : available ? (partial ? 'RÉCENT' : 'DISPONIBLE') : 'INDISPONIBLE'} + stateLabel={tilePending ? (language === 'fr' ? 'CHARGEMENT' : 'LOADING') : available ? (partial ? messages.recent : messages.available) : messages.unavailable} stateTone={available ? (partial ? 'partial' : 'available') : 'unavailable'} > {id === 'next-session' ? ( diff --git a/web/src/dashboard/dashboardMessages.ts b/web/src/dashboard/dashboardMessages.ts new file mode 100644 index 0000000..90ce665 --- /dev/null +++ b/web/src/dashboard/dashboardMessages.ts @@ -0,0 +1,56 @@ +import type { WebLanguage } from '../presentation/LanguagePreferences' + +export const dashboardMessages = { + fr: { + tiles: { + 'next-session': ['Prochaine séance', 'Planification'], + activity: ['Activité', 'Régularité'], + progression: ['Progression', 'Évolution'], + 'last-session': ['Dernière séance', 'Historique'], + 'max-records': ['Mensurations', 'Évolution'], + 'muscle-distribution': ['Répartition musculaire', 'Zones'], + 'cardio-recovery': ['Programme actif', 'Planification'], + }, + layout: 'Agencement du Dashboard', + edit: 'Modifier l’agencement', + cancel: 'Annuler', + reset: 'Réinitialiser', + save: 'Enregistrer', + saving: 'Enregistrement…', + help: 'Flèches : déplacer · Maj + flèches : redimensionner', + loading: 'Chargement de l’agencement…', + dashboardLoading: 'Chargement des données du Dashboard…', + dashboardFailed: 'Les données du Dashboard sont momentanément indisponibles.', + invalidData: 'Certaines données invalides ont été écartées.', + unavailable: 'INDISPONIBLE', + available: 'DISPONIBLE', + recent: 'RÉCENT', + tileEditable: 'tuile modifiable', + }, + en: { + tiles: { + 'next-session': ['Next session', 'Planning'], + activity: ['Activity', 'Consistency'], + progression: ['Progress', 'Evolution'], + 'last-session': ['Last session', 'History'], + 'max-records': ['Measurements', 'Evolution'], + 'muscle-distribution': ['Muscle distribution', 'Zones'], + 'cardio-recovery': ['Active program', 'Planning'], + }, + layout: 'Dashboard layout', + edit: 'Edit layout', + cancel: 'Cancel', + reset: 'Reset', + save: 'Save', + saving: 'Saving…', + help: 'Arrow keys: move · Shift + arrow keys: resize', + loading: 'Loading layout…', + dashboardLoading: 'Loading Dashboard data…', + dashboardFailed: 'Dashboard data is temporarily unavailable.', + invalidData: 'Some invalid data was excluded.', + unavailable: 'UNAVAILABLE', + available: 'AVAILABLE', + recent: 'RECENT', + tileEditable: 'editable tile', + }, +} as const satisfies Record diff --git a/web/src/presentation/LanguagePreferences.tsx b/web/src/presentation/LanguagePreferences.tsx index f5bf8aa..f8aefaf 100644 --- a/web/src/presentation/LanguagePreferences.tsx +++ b/web/src/presentation/LanguagePreferences.tsx @@ -9,7 +9,7 @@ interface LanguageContextValue { setLanguage: (language: WebLanguage) => void } -const LanguageContext = createContext(null) +const LanguageContext = createContext({ language: 'fr', setLanguage: () => undefined }) export function LanguagePreferencesProvider({ children }: { children: ReactNode }) { const [language, updateLanguage] = useState(() => @@ -29,7 +29,5 @@ export function LanguagePreferencesProvider({ children }: { children: ReactNode } export function useLanguagePreferences(): LanguageContextValue { - const value = useContext(LanguageContext) - if (value === null) throw new Error('LanguagePreferencesProvider is missing') - return value + return useContext(LanguageContext) } diff --git a/web/src/routes/AnalysisPage.test.tsx b/web/src/routes/AnalysisPage.test.tsx index bdd7bf1..0ae4538 100644 --- a/web/src/routes/AnalysisPage.test.tsx +++ b/web/src/routes/AnalysisPage.test.tsx @@ -188,6 +188,8 @@ describe("AnalysisPage", () => { expect(delta.parentElement).toHaveClass("analysis-delta"); expect(screen.getByText(/Poids · kg · 2 points réels/)).toBeInTheDocument(); expect(document.querySelectorAll(".chart-x-label")).toHaveLength(2); + expect(document.querySelector(".analysis-chart svg")) + .toHaveAttribute("preserveAspectRatio", "xMidYMid meet"); }); it("uses a factual exercises-by-date histogram in the overview", async () => { @@ -201,4 +203,36 @@ describe("AnalysisPage", () => { expect.stringContaining("1 exercice(s)"), ); }); + + it("reports missing and partial exercise timing without inventing duration", async () => { + const missing: AnalysisSnapshot = { + ...analysisFixture, + exercise_groups: [{ + zone_id: "legs", label: "Jambes", exercises: [{ + exercise_id: "ex_timing", name: "Presse", occurrences: 2, sessions: 2, + sets: 5, measured_seconds: 0, measured_occurrences: 0, + }], + }], + }; + vi.mocked(globalThis.fetch).mockResolvedValue( + new Response(JSON.stringify(missing), { status: 200 }), + ); + const { rerender } = render(); + expect(await screen.findByTestId("duration-coverage")).toHaveTextContent( + "Temps non mesuré · durée indisponible", + ); + vi.mocked(globalThis.fetch).mockResolvedValue( + new Response(JSON.stringify({ + ...missing, + exercise_groups: [{ + ...missing.exercise_groups[0], + exercises: [{ ...missing.exercise_groups[0].exercises[0], measured_seconds: 600, measured_occurrences: 1 }], + }], + }), { status: 200 }), + ); + rerender(); + expect(await screen.findByTestId("duration-coverage")).toHaveTextContent( + "Couverture temporelle partielle", + ); + }); }); diff --git a/web/src/routes/AnalysisPage.tsx b/web/src/routes/AnalysisPage.tsx index 4921326..14cdd7d 100644 --- a/web/src/routes/AnalysisPage.tsx +++ b/web/src/routes/AnalysisPage.tsx @@ -283,7 +283,7 @@ export function AnalysisLineChart({ viewBox="0 0 112 78" role="img" aria-label={`${label}: ${points.length} points`} - preserveAspectRatio="none" + preserveAspectRatio="xMidYMid meet" > @@ -680,6 +680,23 @@ export function AnalysisPage() { /> )} + {(() => { + const occurrences = snapshot.exercise_groups.flatMap((group) => group.exercises) + .reduce((sum, exercise) => sum + exercise.occurrences, 0); + const measured = snapshot.exercise_groups.flatMap((group) => group.exercises) + .reduce((sum, exercise) => sum + exercise.measured_occurrences, 0); + if (occurrences === 0 || measured === occurrences) return null; + const coverage = measured === 0 + ? (language === "fr" + ? "Temps non mesuré · durée indisponible" + : "Time not measured · duration unavailable") + : (language === "fr" + ? "Couverture temporelle partielle" + : "Partial time coverage"); + return

+ {coverage} +

; + })()} {snapshot.overview.sessions === 0 && (

{t.noData}

)} diff --git a/web/src/routes/SleepDiaryWorkspace.test.tsx b/web/src/routes/SleepDiaryWorkspace.test.tsx index a244536..d4d4e25 100644 --- a/web/src/routes/SleepDiaryWorkspace.test.tsx +++ b/web/src/routes/SleepDiaryWorkspace.test.tsx @@ -639,9 +639,11 @@ describe("SleepDiaryWorkspace", () => { 9, ); expect(intake).toHaveStyle({ left: "18.75%" }); - expect(document.querySelectorAll(".sleep-hour-axis > span")).toHaveLength( - 25, - ); + const ticks = Array.from(document.querySelectorAll(".sleep-hour-axis > span")); + expect(ticks.map((tick) => [tick.textContent, tick.style.left])).toEqual([ + ["18:00", "0%"], ["00:00", "25%"], ["06:00", "50%"], + ["12:00", "75%"], ["18:00", "100%"], + ]); }); it("isolates an empty editor night while retaining the period agenda", async () => { diff --git a/web/src/routes/SleepDiaryWorkspace.tsx b/web/src/routes/SleepDiaryWorkspace.tsx index ad9f481..f95013e 100644 --- a/web/src/routes/SleepDiaryWorkspace.tsx +++ b/web/src/routes/SleepDiaryWorkspace.tsx @@ -717,12 +717,12 @@ export function SleepDiaryWorkspace({