fix(web): stabilize and refine sleep diary capture

This commit is contained in:
fy59 2026-09-21 15:38:51 +02:00
parent 47ee128807
commit 915728cec4
13 changed files with 2287 additions and 302 deletions

View file

@ -2,6 +2,16 @@
## Unreleased — 0.1.4
- Fixed the Sleep Diary Web workspace becoming entirely blank after adding an
event or medication intake on the loopback-routed HTTP origin. Sleep event
and intake UUIDv4 identities now use secure random bytes without depending
on the secure-context-only `crypto.randomUUID()` API, and a section-local
error boundary preserves the Trainlog shell if an unrelated render failure
occurs. The capture workspace now reuses Trainlog cards, controls, status,
primary/secondary/danger actions and responsive spacing for a structured
night header, appreciations, event timeline, medication catalog and intake
editor. Domain data, schemas, revisions, synchronization and PDF semantics
are unchanged.
- Fixed equipment creation from the Android active-session exercise editor.
The selector now exposes the complete catalogue in a bounded scrollable
list, reuses the catalogue creation route, returns to the same stable

View file

@ -138,7 +138,7 @@ parallel implementation of its rules.
| Web Dashboard tiles | `WEB_DASHBOARD_TILES_V1=PASS/FROZEN` |
| Web Dashboard visualizations | `WEB_DASHBOARD_VISUALIZATIONS_V1=PASS/FROZEN` |
| Web Dashboard V1 | `WEB_DASHBOARD_V1=PASS/FROZEN` |
| Sleep Diary V1 | Implemented on the 0.1.4 review branch: timestamped causal diary, Web/Android capture, full-generation companion and local vector PDF |
| Sleep Diary V1 | Implemented on the 0.1.4 review branch: timestamped causal diary, stable Web/Android capture, full-generation companion and local vector PDF; the Web capture workspace has real-Firefox mutation/reload coverage and a section-local error boundary |
| Web Sessions V1 | `TRAINLOG_WEB_SESSIONS_V1=PASS/FROZEN` (controlled desktop/Android deployment validated) |
| Web Sessions deletion and Programs V1 | `TRAINLOG_WEB_SESSIONS_DELETE_AND_PROGRAMS_V1=PASS` (private grouped rollout validated) |
| Programs presentation, Android projection, and deletion | `TRAINLOG_PROGRAMS_PRESENTATION_ANDROID_DELETE_V1=PASS` (private coordinated deployment and restart/replay validated) |

Binary file not shown.

After

Width:  |  Height:  |  Size: 315 KiB

View file

@ -0,0 +1,61 @@
# Sleep Diary capture UX and black-screen correction evidence
## Scope
This record covers the focused 0.1.4 Web correction and presentation tranche.
It does not change Sleep Diary domain semantics, desktop schema v29, Android
schema v26, synchronization, publication lifecycle, PDF semantics or any
frozen exchange format.
## Runtime diagnosis
The deployed bundle was run through `http://trainlog.perf/analyse?section=sleep`
in Firefox 156 with an isolated synthetic XDG data directory. The browser
reported `isSecureContext === false` and did not expose
`crypto.randomUUID`. Clicking **Ajouter** for a bedtime event raised:
```text
TypeError: crypto.randomUUID is not a function
```
The stack entered the `addEvent` state updater in
`web/src/routes/SleepDiaryWorkspace.tsx`. React then unmounted the application
tree, leaving an empty body. The router remained on the Sleep Analyse URL.
There was no mutation request, HTTP status or response body because the
exception happened while constructing the optimistic event. No unhandled
promise rejection was present. Medication intake identity construction used
the same unsupported API and had the same latent failure.
## Correction
Sleep event and medication-intake identities now use `crypto.getRandomValues`,
set the UUIDv4 version and RFC 4122 variant bits explicitly, and never fall back
to `Math.random`. A section-local React error boundary keeps the Trainlog shell,
header and navigation mounted and offers a retry card for unexpected future
render errors. This boundary is defense in depth; it is not the black-screen
fix.
The capture presentation now uses the existing Trainlog panel, quiet-action,
primary-action and danger-action vocabulary. Dates, lifecycle badge and local
save state form one night header. Morning/day appreciations, event creation,
timeline, intake creation, notes and actions have explicit hierarchy. The
medication catalog is a compact list whose creation form opens on demand and
whose saved item becomes immediately selectable for an intake.
## Validation evidence
- Web TypeScript, all Vitest tests and the Vite production build pass.
- Component regressions cover bedtime, sleep, long awakening, final get-up,
nap and sleepiness, stable entry identity, advanced revisions, medication
creation, intake creation, edit and delete.
- Real Firefox against the production C server created two medications, two
intakes and five events, then reloaded the same durable entry and revision.
- Every captured mutation returned HTTP 200 with its expected JSON identity or
revision response. The console error and unhandled-rejection collections
remained empty.
- The responsive layout had no document-level horizontal overflow.
- The retained post-reload screenshot is
[`evidence/sleep-diary-v1-final-firefox.png`](evidence/sleep-diary-v1-final-firefox.png).
Deployment-specific commit, bundle and process evidence is added only after the
authorized private service switch.

View file

@ -7,6 +7,14 @@ construction/pagination for 7, 14, 21 and 30 synthetic days.
Lifecycle regressions additionally cover durable drafts excluded from export,
guarded validation, reopen, post-validation correction, exact
generation-to-revision capture and ACK-only acknowledgement.
Web component regressions add each point/interval capture type, verify that a
successful mutation rerenders without losing the workspace, preserve one entry
identity while advancing revisions, and exercise medication creation, immediate
intake availability, event/intake editing and deletion. The UUID regression
removes `crypto.randomUUID()` and proves the HTTP-origin-compatible UUIDv4
path. A real Firefox run through the production C server and `trainlog.perf`
creates events, medications and intakes, reloads their durable state, checks a
responsive viewport and captures console errors and unhandled rejections.
`web_analysis` covers empty history, every 7/30/90/all period selector,
loaded and unloaded repetitions, continuous duration/distance/speed, explicit

View file

@ -1,9 +1,24 @@
import { describe, expect, it } from 'vitest'
import { sleepTimestamp } from './sleepDiary'
import { describe, expect, it, vi } from "vitest";
import { newSleepId, sleepTimestamp } from "./sleepDiary";
describe('sleepTimestamp', () => {
it('serializes browser timestamps at the C17 contract precision', () => {
expect(sleepTimestamp(new Date('2026-09-21T05:51:41.140Z')))
.toBe('2026-09-21T05:51:41Z')
})
})
describe("sleepTimestamp", () => {
it("serializes browser timestamps at the C17 contract precision", () => {
expect(sleepTimestamp(new Date("2026-09-21T05:51:41.140Z"))).toBe(
"2026-09-21T05:51:41Z",
);
});
});
describe("newSleepId", () => {
it("creates UUIDv4 sleep identities without randomUUID", () => {
vi.stubGlobal("crypto", {
getRandomValues: (bytes: Uint8Array) => {
bytes.set(Array.from({ length: 16 }, (_, index) => index));
return bytes;
},
});
expect(newSleepId("sle")).toBe("sle_00010203-0405-4607-8809-0a0b0c0d0e0f");
expect(newSleepId("mdi")).toBe("mdi_00010203-0405-4607-8809-0a0b0c0d0e0f");
vi.unstubAllGlobals();
});
});

View file

@ -1,121 +1,299 @@
import { mutationCsrfToken } from './sync'
import { mutationCsrfToken } from "./sync";
export type SleepQuality = 'TB' | 'B' | 'Moy' | 'M' | 'TM'
export type SleepEventType = 'bed_time' | 'final_get_up' | 'night_get_up' | 'sleep' |
'nap' | 'long_awake' | 'half_sleep' | 'daytime_sleepiness'
export type SleepQuality = "TB" | "B" | "Moy" | "M" | "TM";
export type SleepEventType =
| "bed_time"
| "final_get_up"
| "night_get_up"
| "sleep"
| "nap"
| "long_awake"
| "half_sleep"
| "daytime_sleepiness";
export interface SleepEvent { event_id: string; type: SleepEventType; start_at: string; end_at: string | null }
export interface MedicationIntake { intake_id: string; medication_id: string; medication_name: string
taken_at: string; dose_value: number | null; dose_unit: string | null; note: string; created_at: string }
export interface SleepMedication { medication_id: string; revision_id: string; name: string
default_dose_value: number | null; default_dose_unit: string | null; form: string; note: string; active: boolean }
export interface SleepEvent {
event_id: string;
type: SleepEventType;
start_at: string;
end_at: string | null;
}
export interface MedicationIntake {
intake_id: string;
medication_id: string;
medication_name: string;
taken_at: string;
dose_value: number | null;
dose_unit: string | null;
note: string;
created_at: string;
}
export interface SleepMedication {
medication_id: string;
revision_id: string;
name: string;
default_dose_value: number | null;
default_dose_unit: string | null;
form: string;
note: string;
active: boolean;
}
export interface SleepEntry {
entry_id: string; night_start_date: string; night_end_date: string
created_at: string; updated_at: string; revision_id: string
sleep_quality: SleepQuality | null; wake_quality: SleepQuality | null; day_form: SleepQuality | null
treatment_and_notes: string; events: SleepEvent[]; intakes: MedicationIntake[]
publication_status: 'draft' | 'ready' | 'synchronized' | 'modified'
entry_id: string;
night_start_date: string;
night_end_date: string;
created_at: string;
updated_at: string;
revision_id: string;
sleep_quality: SleepQuality | null;
wake_quality: SleepQuality | null;
day_form: SleepQuality | null;
treatment_and_notes: string;
events: SleepEvent[];
intakes: MedicationIntake[];
publication_status: "draft" | "ready" | "synchronized" | "modified";
}
export interface SleepSnapshot {
api_version: 1; entries: SleepEntry[]
summary: { nights: number; long_awake_count: number; nap_count: number; sleepiness_count: number; intake_count: number
sleep_duration_seconds: number; long_awake_duration_seconds: number; nap_duration_seconds: number
average_bed_minute: number | null; average_get_up_minute: number | null }
}
export type SleepEntryInput = Omit<SleepEntry, 'revision_id' | 'publication_status'> & {
expected_revision: string | null
api_version: 1;
entries: SleepEntry[];
summary: {
nights: number;
long_awake_count: number;
nap_count: number;
sleepiness_count: number;
intake_count: number;
sleep_duration_seconds: number;
long_awake_duration_seconds: number;
nap_duration_seconds: number;
average_bed_minute: number | null;
average_get_up_minute: number | null;
};
}
export type SleepEntryInput = Omit<
SleepEntry,
"revision_id" | "publication_status"
> & {
expected_revision: string | null;
};
// The C17 timestamp parser intentionally accepts second-precision RFC 3339.
// Keep browser-created causal timestamps on that public boundary.
export const sleepTimestamp = (date = new Date()): string =>
date.toISOString().replace(/\.\d{3}Z$/, 'Z')
date.toISOString().replace(/\.\d{3}Z$/, "Z");
export function newSleepId(prefix: "sle" | "mdi"): string {
/*
* WHY: trainlog.perf is intentionally served over loopback HTTP and is not
* a browser secure context, so crypto.randomUUID() is unavailable there.
* CONTRACT: getRandomValues() supplies all entropy; Trainlog sets the UUIDv4
* version and RFC 4122 variant bits and never falls back to Math.random().
*/
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
const hexadecimal = Array.from(bytes, (value) =>
value.toString(16).padStart(2, "0"),
);
const uuid = [
hexadecimal.slice(0, 4).join(""),
hexadecimal.slice(4, 6).join(""),
hexadecimal.slice(6, 8).join(""),
hexadecimal.slice(8, 10).join(""),
hexadecimal.slice(10, 16).join(""),
].join("-");
return `${prefix}_${uuid}`;
}
const object = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value)
typeof value === "object" && value !== null && !Array.isArray(value);
const quality = (value: unknown): value is SleepQuality | null =>
value === null || ['TB', 'B', 'Moy', 'M', 'TM'].includes(String(value))
value === null || ["TB", "B", "Moy", "M", "TM"].includes(String(value));
const eventType = (value: unknown): value is SleepEventType =>
['bed_time', 'final_get_up', 'night_get_up', 'sleep', 'nap', 'long_awake', 'half_sleep', 'daytime_sleepiness'].includes(String(value))
[
"bed_time",
"final_get_up",
"night_get_up",
"sleep",
"nap",
"long_awake",
"half_sleep",
"daytime_sleepiness",
].includes(String(value));
function validEntry(value: unknown): value is SleepEntry {
return object(value) && typeof value.entry_id === 'string' && typeof value.night_start_date === 'string' &&
typeof value.night_end_date === 'string' && typeof value.created_at === 'string' &&
typeof value.updated_at === 'string' && typeof value.revision_id === 'string' &&
['draft', 'ready', 'synchronized', 'modified'].includes(String(value.publication_status)) &&
quality(value.sleep_quality) && quality(value.wake_quality) && quality(value.day_form) &&
typeof value.treatment_and_notes === 'string' && Array.isArray(value.events) && value.events.length <= 64 &&
value.events.every((event) => object(event) && typeof event.event_id === 'string' &&
eventType(event.type) && typeof event.start_at === 'string' &&
(event.end_at === null || typeof event.end_at === 'string')) && Array.isArray(value.intakes) &&
value.intakes.length <= 32 && value.intakes.every((intake) => object(intake) &&
typeof intake.intake_id === 'string' && typeof intake.medication_id === 'string' &&
typeof intake.medication_name === 'string' && typeof intake.taken_at === 'string' &&
(intake.dose_value === null || typeof intake.dose_value === 'number') &&
(intake.dose_unit === null || typeof intake.dose_unit === 'string') &&
typeof intake.note === 'string' && typeof intake.created_at === 'string')
return (
object(value) &&
typeof value.entry_id === "string" &&
typeof value.night_start_date === "string" &&
typeof value.night_end_date === "string" &&
typeof value.created_at === "string" &&
typeof value.updated_at === "string" &&
typeof value.revision_id === "string" &&
["draft", "ready", "synchronized", "modified"].includes(
String(value.publication_status),
) &&
quality(value.sleep_quality) &&
quality(value.wake_quality) &&
quality(value.day_form) &&
typeof value.treatment_and_notes === "string" &&
Array.isArray(value.events) &&
value.events.length <= 64 &&
value.events.every(
(event) =>
object(event) &&
typeof event.event_id === "string" &&
eventType(event.type) &&
typeof event.start_at === "string" &&
(event.end_at === null || typeof event.end_at === "string"),
) &&
Array.isArray(value.intakes) &&
value.intakes.length <= 32 &&
value.intakes.every(
(intake) =>
object(intake) &&
typeof intake.intake_id === "string" &&
typeof intake.medication_id === "string" &&
typeof intake.medication_name === "string" &&
typeof intake.taken_at === "string" &&
(intake.dose_value === null || typeof intake.dose_value === "number") &&
(intake.dose_unit === null || typeof intake.dose_unit === "string") &&
typeof intake.note === "string" &&
typeof intake.created_at === "string",
)
);
}
export async function fetchSleepDiary(startDate?: string, endDate?: string,
signal?: AbortSignal): Promise<SleepSnapshot> {
const query = new URLSearchParams({ limit: '3660' })
if (startDate) query.set('start_date', startDate)
if (endDate) query.set('end_date', endDate)
const response = await fetch(`/api/v1/sleep-diary?${query}`, { headers: { Accept: 'application/json' }, signal })
const value: unknown = await response.json()
if (!response.ok || !object(value) || value.api_version !== 1 || !Array.isArray(value.entries) ||
!value.entries.every(validEntry) || !object(value.summary)) throw new TypeError('sleep_diary_invalid')
return value as unknown as SleepSnapshot
export async function fetchSleepDiary(
startDate?: string,
endDate?: string,
signal?: AbortSignal,
): Promise<SleepSnapshot> {
const query = new URLSearchParams({ limit: "3660" });
if (startDate) query.set("start_date", startDate);
if (endDate) query.set("end_date", endDate);
const response = await fetch(`/api/v1/sleep-diary?${query}`, {
headers: { Accept: "application/json" },
signal,
});
const value: unknown = await response.json();
if (
!response.ok ||
!object(value) ||
value.api_version !== 1 ||
!Array.isArray(value.entries) ||
!value.entries.every(validEntry) ||
!object(value.summary)
)
throw new TypeError("sleep_diary_invalid");
return value as unknown as SleepSnapshot;
}
async function mutation(method: 'POST' | 'DELETE', body: unknown): Promise<{ entry_id: string; revision_id: string }> {
const csrf = await mutationCsrfToken()
const response = await fetch('/api/v1/sleep-diary', {
async function mutation(
method: "POST" | "DELETE",
body: unknown,
): Promise<{ entry_id: string; revision_id: string }> {
const csrf = await mutationCsrfToken();
const response = await fetch("/api/v1/sleep-diary", {
method,
headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'X-Trainlog-CSRF-Token': csrf },
headers: {
Accept: "application/json",
"Content-Type": "application/json",
"X-Trainlog-CSRF-Token": csrf,
},
body: JSON.stringify(body),
})
const value: unknown = await response.json()
if (!response.ok || !object(value) || typeof value.entry_id !== 'string' || typeof value.revision_id !== 'string') {
throw new Error(object(value) && typeof value.error === 'string' ? value.error : 'sleep_diary_mutation_failed')
});
const value: unknown = await response.json();
if (
!response.ok ||
!object(value) ||
typeof value.entry_id !== "string" ||
typeof value.revision_id !== "string"
) {
throw new Error(
object(value) && typeof value.error === "string"
? value.error
: "sleep_diary_mutation_failed",
);
}
return value as { entry_id: string; revision_id: string }
return value as { entry_id: string; revision_id: string };
}
export const saveSleepEntry = async (entry: SleepEntryInput) => await mutation('POST', entry)
export async function validateSleepEntry(entryId: string, revisionId: string): Promise<void> {
const csrf = await mutationCsrfToken()
const response = await fetch('/api/v1/sleep-diary/validate', {
method: 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/json',
'X-Trainlog-CSRF-Token': csrf }, body: JSON.stringify({ entry_id: entryId,
expected_revision: revisionId, validated_at: sleepTimestamp() }),
})
if (!response.ok) throw new Error('sleep_diary_validation_failed')
export const saveSleepEntry = async (entry: SleepEntryInput) =>
await mutation("POST", entry);
export async function validateSleepEntry(
entryId: string,
revisionId: string,
): Promise<void> {
const csrf = await mutationCsrfToken();
const response = await fetch("/api/v1/sleep-diary/validate", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
"X-Trainlog-CSRF-Token": csrf,
},
body: JSON.stringify({
entry_id: entryId,
expected_revision: revisionId,
validated_at: sleepTimestamp(),
}),
});
if (!response.ok) throw new Error("sleep_diary_validation_failed");
}
export const deleteSleepEntry = async (entry: SleepEntry) => await mutation('DELETE', {
entry_id: entry.entry_id, expected_revision: entry.revision_id, deleted_at: sleepTimestamp(),
})
export const deleteSleepEntry = async (
entry: Pick<SleepEntry, "entry_id" | "revision_id">,
) =>
await mutation("DELETE", {
entry_id: entry.entry_id,
expected_revision: entry.revision_id,
deleted_at: sleepTimestamp(),
});
export async function fetchSleepMedications(): Promise<SleepMedication[]> {
const response = await fetch('/api/v1/sleep-medications', { headers: { Accept: 'application/json' } })
const value: unknown = await response.json()
if (!response.ok || !object(value) || value.api_version !== 1 || !Array.isArray(value.medications) ||
!value.medications.every((item) => object(item) && typeof item.medication_id === 'string' &&
typeof item.revision_id === 'string' && typeof item.name === 'string' &&
(item.default_dose_value === null || typeof item.default_dose_value === 'number') &&
(item.default_dose_unit === null || typeof item.default_dose_unit === 'string') &&
typeof item.form === 'string' && typeof item.note === 'string' && typeof item.active === 'boolean'))
throw new TypeError('sleep_medications_invalid')
return value.medications as SleepMedication[]
const response = await fetch("/api/v1/sleep-medications", {
headers: { Accept: "application/json" },
});
const value: unknown = await response.json();
if (
!response.ok ||
!object(value) ||
value.api_version !== 1 ||
!Array.isArray(value.medications) ||
!value.medications.every(
(item) =>
object(item) &&
typeof item.medication_id === "string" &&
typeof item.revision_id === "string" &&
typeof item.name === "string" &&
(item.default_dose_value === null ||
typeof item.default_dose_value === "number") &&
(item.default_dose_unit === null ||
typeof item.default_dose_unit === "string") &&
typeof item.form === "string" &&
typeof item.note === "string" &&
typeof item.active === "boolean",
)
)
throw new TypeError("sleep_medications_invalid");
return value.medications as SleepMedication[];
}
export async function saveSleepMedication(input: Omit<SleepMedication, 'revision_id'> & {
expected_revision: string | null; created_at: string; updated_at: string
}): Promise<void> {
const csrf = await mutationCsrfToken()
const response = await fetch('/api/v1/sleep-medications', {
method: 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/json',
'X-Trainlog-CSRF-Token': csrf }, body: JSON.stringify(input),
})
if (!response.ok) throw new Error('sleep_medication_mutation_failed')
export async function saveSleepMedication(
input: Omit<SleepMedication, "revision_id"> & {
expected_revision: string | null;
created_at: string;
updated_at: string;
},
): Promise<void> {
const csrf = await mutationCsrfToken();
const response = await fetch("/api/v1/sleep-medications", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
"X-Trainlog-CSRF-Token": csrf,
},
body: JSON.stringify(input),
});
if (!response.ok) throw new Error("sleep_medication_mutation_failed");
}

View file

@ -0,0 +1,35 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { expect, it, vi } from "vitest";
import { SectionErrorBoundary } from "./SectionErrorBoundary";
function BrokenSection(): never {
throw new Error("render failure");
}
it("keeps the surrounding shell mounted and offers a local retry", () => {
const consoleError = vi
.spyOn(console, "error")
.mockImplementation(() => undefined);
render(
<div data-testid="shell">
<header>Trainlog</header>
<SectionErrorBoundary
fallbackTitle="Impossible d’afficher cette section."
retryLabel="Réessayer"
>
<BrokenSection />
</SectionErrorBoundary>
<nav>Navigation</nav>
</div>,
);
expect(screen.getByTestId("shell")).toBeInTheDocument();
expect(screen.getByText("Trainlog")).toBeInTheDocument();
expect(screen.getByText("Navigation")).toBeInTheDocument();
expect(screen.getByRole("alert")).toHaveTextContent(
"Impossible d’afficher cette section.",
);
fireEvent.click(screen.getByRole("button", { name: "Réessayer" }));
expect(screen.getByTestId("shell")).toBeInTheDocument();
expect(consoleError).toHaveBeenCalled();
consoleError.mockRestore();
});

View file

@ -0,0 +1,49 @@
import { Component, type ErrorInfo, type ReactNode } from "react";
interface SectionErrorBoundaryProps {
children: ReactNode;
fallbackTitle: string;
retryLabel: string;
}
interface SectionErrorBoundaryState {
failed: boolean;
}
export class SectionErrorBoundary extends Component<
SectionErrorBoundaryProps,
SectionErrorBoundaryState
> {
state: SectionErrorBoundaryState = { failed: false };
static getDerivedStateFromError(): SectionErrorBoundaryState {
return { failed: true };
}
componentDidCatch(error: Error, information: ErrorInfo) {
// CONTRACT: retain the surrounding Trainlog shell while preserving enough
// browser diagnostics to investigate an unexpected section render failure.
console.error("Trainlog section render failed", error, information);
}
render() {
if (this.state.failed) {
return (
<article
className="analysis-panel analysis-wide section-error"
role="alert"
>
<h2>{this.props.fallbackTitle}</h2>
<button
type="button"
className="quiet-action"
onClick={() => this.setState({ failed: false })}
>
{this.props.retryLabel}
</button>
</article>
);
}
return this.props.children;
}
}

View file

@ -13,10 +13,22 @@ import { analysisMessages, type AnalysisLanguage } from "../analysis/messages";
import { BodyZoneFigure } from "../dashboard/BodyZoneFigure";
import { formatDuration } from "../dashboard/dashboardFormat";
import { SleepDiaryWorkspace } from "./SleepDiaryWorkspace";
import { SectionErrorBoundary } from "../components/SectionErrorBoundary";
type AnalysisSection = "overview" | "exercise" | "body-zones" | "measurements" | "sleep";
type AnalysisSection =
| "overview"
| "exercise"
| "body-zones"
| "measurements"
| "sleep";
type ExerciseMetric =
"duration" | "distance" | "speed" | "load" | "reps" | "volume" | "max";
| "duration"
| "distance"
| "speed"
| "load"
| "reps"
| "volume"
| "max";
interface ChartPoint {
timestamp: string;
value: number;
@ -875,7 +887,16 @@ export function AnalysisPage() {
</article>
)}
{section === "sleep" && (
<SleepDiaryWorkspace period={period} language={language} />
<SectionErrorBoundary
fallbackTitle={
language === "fr"
? "Impossible d’afficher cette section."
: "Unable to display this section."
}
retryLabel={language === "fr" ? "Réessayer" : "Try again"}
>
<SleepDiaryWorkspace period={period} language={language} />
</SectionErrorBoundary>
)}
</>
)}

View file

@ -0,0 +1,188 @@
import {
fireEvent,
render,
screen,
waitFor,
within,
} from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { SleepMedication, SleepSnapshot } from "../api/sleepDiary";
import { SleepDiaryWorkspace } from "./SleepDiaryWorkspace";
const api = vi.hoisted(() => ({
deleteSleepEntry: vi.fn(),
fetchSleepDiary: vi.fn(),
fetchSleepMedications: vi.fn(),
saveSleepEntry: vi.fn(),
saveSleepMedication: vi.fn(),
validateSleepEntry: vi.fn(),
nextId: 0,
}));
vi.mock("../api/sleepDiary", async (importOriginal) => {
const original = await importOriginal<typeof import("../api/sleepDiary")>();
return {
...original,
deleteSleepEntry: api.deleteSleepEntry,
fetchSleepDiary: api.fetchSleepDiary,
fetchSleepMedications: api.fetchSleepMedications,
saveSleepEntry: api.saveSleepEntry,
saveSleepMedication: api.saveSleepMedication,
validateSleepEntry: api.validateSleepEntry,
newSleepId: (prefix: "sle" | "mdi") =>
`${prefix}_00000000-0000-4000-8000-${String(++api.nextId).padStart(12, "0")}`,
};
});
const emptySnapshot: SleepSnapshot = {
api_version: 1,
entries: [],
summary: {
nights: 0,
long_awake_count: 0,
nap_count: 0,
sleepiness_count: 0,
intake_count: 0,
sleep_duration_seconds: 0,
long_awake_duration_seconds: 0,
nap_duration_seconds: 0,
average_bed_minute: null,
average_get_up_minute: null,
},
};
const medication: SleepMedication = {
medication_id: "med_00000000-0000-4000-8000-000000000001",
revision_id: "mr_00000000-0000-4000-8000-000000000001",
name: "TestMed",
default_dose_value: 5,
default_dose_unit: "mg",
form: "",
note: "",
active: true,
};
describe("SleepDiaryWorkspace", () => {
beforeEach(() => {
vi.clearAllMocks();
api.nextId = 0;
api.fetchSleepDiary.mockResolvedValue(emptySnapshot);
api.fetchSleepMedications.mockResolvedValue([]);
let revision = 0;
api.saveSleepEntry.mockImplementation(async (input) => ({
entry_id: input.entry_id || "sd_00000000-0000-4000-8000-000000000001",
revision_id: `sdr_00000000-0000-4000-8000-${String(++revision).padStart(12, "0")}`,
}));
});
it.each([
["bed_time", "Mise au lit", "22:30", null],
["sleep", "Sommeil", "23:00", "06:30"],
["long_awake", "Long réveil", "03:00", "03:30"],
["final_get_up", "Lever", "07:00", null],
["nap", "Sieste", "14:00", "14:30"],
["daytime_sleepiness", "Somnolence", "15:00", null],
])(
"keeps the workspace mounted after adding %s and advances the same entry revision",
async (type, label, start, end) => {
render(<SleepDiaryWorkspace period="30d" language="fr" />);
await screen.findByTestId("sleep-workspace");
fireEvent.change(screen.getByTestId("sleep-event-type"), {
target: { value: type },
});
fireEvent.change(screen.getByTestId("sleep-event-start"), {
target: { value: start },
});
if (end !== null)
fireEvent.change(screen.getByTestId("sleep-event-end"), {
target: { value: end },
});
fireEvent.click(screen.getByTestId("sleep-add-event"));
expect(
await screen.findByTestId(`sleep-event-${type}`),
).toHaveTextContent(label);
expect(screen.getByTestId("sleep-workspace")).toBeInTheDocument();
expect(screen.getByTestId("sleep-add-event")).toBeEnabled();
await waitFor(() => expect(api.saveSleepEntry).toHaveBeenCalledTimes(1));
expect(api.saveSleepEntry.mock.calls[0][0]).toMatchObject({
entry_id: "",
expected_revision: null,
});
fireEvent.click(screen.getByTestId("sleep-add-event"));
await waitFor(() => expect(api.saveSleepEntry).toHaveBeenCalledTimes(2));
expect(api.saveSleepEntry.mock.calls[1][0]).toMatchObject({
entry_id: "sd_00000000-0000-4000-8000-000000000001",
expected_revision: "sdr_00000000-0000-4000-8000-000000000001",
});
expect(
screen.queryByText("Impossible d’afficher cette section."),
).not.toBeInTheDocument();
},
);
it("adds a medication, exposes it immediately for an intake, and supports edit and delete mutations", async () => {
api.fetchSleepMedications
.mockResolvedValueOnce([])
.mockResolvedValue([medication]);
render(<SleepDiaryWorkspace period="30d" language="fr" />);
await screen.findByText("Aucun médicament enregistré.");
fireEvent.click(
screen.getByRole("button", { name: "+ Ajouter un médicament" }),
);
fireEvent.change(screen.getByTestId("sleep-medication-name"), {
target: { value: "TestMed" },
});
fireEvent.change(screen.getByTestId("sleep-medication-dose"), {
target: { value: "5" },
});
fireEvent.click(screen.getByTestId("sleep-add-medication"));
const medicationList = await screen.findByTestId("sleep-medication-list");
expect(within(medicationList).getByText("TestMed")).toBeInTheDocument();
expect(screen.getByTestId("sleep-intake-medication")).toHaveTextContent(
"TestMed",
);
fireEvent.change(screen.getByTestId("sleep-intake-medication"), {
target: { value: medication.medication_id },
});
fireEvent.click(screen.getByTestId("sleep-add-intake"));
const intake = await screen.findByTestId("sleep-intake-row");
expect(intake).toHaveTextContent("TestMed");
expect(screen.getByTestId("sleep-workspace")).toBeInTheDocument();
fireEvent.change(within(intake).getByLabelText("Heure"), {
target: { value: "22:15" },
});
await waitFor(() => expect(api.saveSleepEntry).toHaveBeenCalledTimes(2));
fireEvent.click(
within(intake).getByRole("button", { name: "Supprimer TestMed" }),
);
await waitFor(() => expect(api.saveSleepEntry).toHaveBeenCalledTimes(3));
expect(screen.queryByTestId("sleep-intake-row")).not.toBeInTheDocument();
});
it("modifies and removes an event without losing the workspace", async () => {
render(<SleepDiaryWorkspace period="30d" language="fr" />);
await screen.findByTestId("sleep-workspace");
fireEvent.change(screen.getByTestId("sleep-event-type"), {
target: { value: "sleep" },
});
fireEvent.click(screen.getByTestId("sleep-add-event"));
const row = await screen.findByTestId("sleep-event-sleep");
fireEvent.change(within(row).getByLabelText("Début"), {
target: { value: "23:00" },
});
fireEvent.click(within(row).getByRole("button", { name: "Modifier" }));
fireEvent.click(
within(row).getByRole("button", { name: "Supprimer Sommeil" }),
);
await waitFor(() =>
expect(screen.queryByTestId("sleep-event-sleep")).not.toBeInTheDocument(),
);
expect(screen.getByTestId("sleep-workspace")).toBeInTheDocument();
expect(screen.getByTestId("sleep-add-event")).toBeEnabled();
});
});

File diff suppressed because it is too large Load diff

View file

@ -244,7 +244,7 @@
.eyebrow {
margin: 0 0 0.5rem;
color: var(--lavender);
font: 700 var(--font-size-xs)/1.25 var(--font-mono);
font: 700 var(--font-size-xs) / 1.25 var(--font-mono);
letter-spacing: 0.12em;
text-transform: uppercase;
}
@ -601,47 +601,375 @@ h2 {
cursor: pointer;
}
.sleep-workspace { display: grid; gap: 1rem; }
.sleep-heading, .sleep-actions, .sleep-event-add, .sleep-dates { display: flex; gap: .75rem; align-items: center; justify-content: space-between; }
.sleep-heading > div { display: flex; gap: .5rem; align-items: center; flex-wrap: wrap; }
.sleep-status { display: inline-flex; align-items: center; min-height: 1.75rem; padding: 0 .55rem; border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); color: var(--text-secondary); font-size: .78rem; }
.sleep-status-ready, .sleep-status-synchronized { border-color: var(--lavender); color: var(--lavender); }
.sleep-status-modified { border-color: var(--warning); color: var(--warning); }
.sleep-save-state { color: var(--text-muted); font-size: .78rem; }
.sleep-save-saving { color: var(--lavender); }
.sleep-save-error { color: var(--danger); }
.sleep-agenda-scroll { overflow-x: auto; max-width: 100%; }
.sleep-agenda { min-width: 900px; display: grid; gap: .25rem; }
.sleep-hours { display: grid; grid-template-columns: 13rem repeat(25, 1fr) 9rem; color: var(--text-muted); font-size: .72rem; }
.sleep-hours::before { content: ''; }
.sleep-row { display: grid; grid-template-columns: 13rem 1fr 9rem; align-items: center; gap: .5rem; min-height: 2.6rem; text-align: left; border-radius: var(--radius-md); }
.sleep-track { position: relative; height: 1.65rem; background: color-mix(in srgb, var(--surface-raised) 80%, transparent); border: 1px solid var(--border-subtle); }
.sleep-event { position: absolute; top: .15rem; bottom: .15rem; min-width: .3rem; overflow: visible; font-style: normal; text-align: center; color: var(--text-primary); }
.sleep-sleep, .sleep-nap { background: repeating-linear-gradient(135deg, var(--accent) 0 3px, transparent 3px 6px); }
.sleep-long_awake { border: 2px solid var(--warning); background: transparent; }
.sleep-half_sleep { background: repeating-linear-gradient(90deg, var(--lavender) 0 2px, transparent 2px 5px); }
.sleep-editor-columns { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; }
.sleep-quality { border: 0; padding: .25rem 0; display: flex; flex-wrap: wrap; gap: .35rem; }
.sleep-quality button[aria-pressed="true"] { border-color: var(--lavender); color: var(--lavender); background: var(--surface-raised); }
.sleep-event-list { display: grid; gap: .5rem; padding: 0; list-style: none; }
.sleep-event-list li { display: grid; grid-template-columns: 10rem 1fr 1fr auto; gap: .5rem; align-items: end; padding: .65rem; background: var(--surface-raised); border-radius: var(--radius-md); }
.sleep-event-list label, .sleep-notes { display: grid; gap: .25rem; }
.sleep-timeline-editor { margin-top: 1rem; }
.sleep-timeline-editor .sleep-event-list li { grid-template-columns: 5rem minmax(8rem, 1fr) repeat(2, minmax(6rem, .7fr)) auto auto; align-items: center; }
.sleep-timeline-editor time { color: var(--lavender); font-variant-numeric: tabular-nums; font-weight: 700; }
.sleep-medication-head { display: grid; grid-template-columns: 2fr 1fr 1fr 10rem; gap: .5rem; padding: .75rem .65rem .25rem; color: var(--text-muted); font-size: .75rem; text-transform: uppercase; letter-spacing: .04em; }
.sleep-notes textarea { min-height: 6rem; resize: vertical; }
.sleep-workspace {
display: grid;
gap: 1rem;
}
.sleep-heading,
.sleep-actions,
.sleep-entry-header {
display: flex;
gap: 0.75rem;
align-items: center;
justify-content: space-between;
}
.sleep-heading > div,
.sleep-medication-list li > div {
display: flex;
gap: 0.5rem;
align-items: center;
flex-wrap: wrap;
}
.sleep-entry-card {
display: grid;
gap: 1rem;
}
.sleep-entry-header {
padding-bottom: 1rem;
border-bottom: 1px solid var(--surface-1);
}
.sleep-night-block {
display: grid;
gap: 0.35rem;
color: var(--subtext-0);
font-size: var(--font-size-sm);
}
.sleep-entry-state {
display: grid;
justify-items: end;
gap: 0.35rem;
}
.sleep-dates {
display: flex;
gap: 0.55rem;
align-items: center;
color: var(--lavender);
}
.sleep-status {
display: inline-flex;
align-items: center;
min-height: 1.75rem;
padding: 0 0.55rem;
border: 1px solid var(--surface-2);
border-radius: 0.45rem;
color: var(--subtext-0);
font-size: 0.78rem;
}
.sleep-status-ready,
.sleep-status-synchronized {
border-color: var(--lavender);
color: var(--lavender);
}
.sleep-status-modified {
border-color: var(--peach);
color: var(--peach);
}
.sleep-save-state {
color: var(--subtext-0);
font-size: 0.78rem;
}
.sleep-save-saving {
color: var(--lavender);
}
.sleep-save-error {
color: var(--red);
}
.sleep-agenda-scroll {
overflow-x: auto;
max-width: 100%;
}
.sleep-agenda {
min-width: 900px;
display: grid;
gap: 0.35rem;
}
.sleep-hours {
display: grid;
grid-template-columns: 13rem repeat(25, 1fr) 9rem;
color: var(--subtext-0);
font-size: 0.72rem;
}
.sleep-hours::before {
content: "";
}
.sleep-row {
display: grid;
grid-template-columns: 13rem 1fr 9rem;
align-items: center;
gap: 0.6rem;
min-height: 3rem;
padding: 0.3rem 0.45rem;
color: var(--text);
text-align: left;
background: var(--base);
border: 1px solid transparent;
border-radius: 0.55rem;
}
.sleep-row:hover,
.sleep-row:focus-visible {
border-color: var(--lavender);
}
.sleep-row strong {
display: grid;
gap: 0.15rem;
}
.sleep-row small {
color: var(--subtext-0);
font-weight: 500;
}
.sleep-track {
position: relative;
height: 1.75rem;
background: var(--surface-0);
border: 1px solid var(--surface-2);
border-radius: 0.35rem;
}
.sleep-event {
position: absolute;
top: 0.15rem;
bottom: 0.15rem;
min-width: 0.3rem;
overflow: visible;
font-style: normal;
text-align: center;
color: var(--text);
}
.sleep-sleep,
.sleep-nap {
background: repeating-linear-gradient(
135deg,
var(--lavender) 0 3px,
transparent 3px 6px
);
}
.sleep-long_awake {
border: 2px solid var(--peach);
background: transparent;
}
.sleep-half_sleep {
background: repeating-linear-gradient(
90deg,
var(--mauve) 0 2px,
transparent 2px 5px
);
}
.sleep-medication {
background: var(--green);
}
.sleep-editor-columns {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
.sleep-subcard {
min-width: 0;
padding: 1rem;
background: var(--base);
border: 1px solid var(--surface-1);
border-radius: 0.65rem;
}
.sleep-subcard h3 {
color: var(--subtext-0);
font-size: var(--font-size-sm);
letter-spacing: 0.05em;
text-transform: uppercase;
}
.sleep-quality {
margin: 0 0 0.8rem;
border: 0;
padding: 0.25rem 0;
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.sleep-quality legend {
margin-bottom: 0.3rem;
color: var(--subtext-0);
font-size: var(--font-size-xs);
}
.sleep-quality button {
min-height: 2.5rem;
min-width: 3.2rem;
padding: 0.5rem 0.7rem;
color: var(--text);
background: var(--mantle);
border: 1px solid var(--surface-2);
border-radius: 0.5rem;
cursor: pointer;
}
.sleep-quality button[aria-pressed="true"] {
border-color: var(--lavender);
color: var(--crust);
background: var(--lavender);
font-weight: 700;
}
.sleep-quality button:focus-visible,
.sleep-control:focus-visible {
outline: 3px solid var(--lavender);
outline-offset: 2px;
}
.sleep-event-add {
display: flex;
gap: 0.75rem;
align-items: end;
}
.sleep-event-add label,
.sleep-medication-form label,
.sleep-event-list label,
.sleep-notes {
display: grid;
min-width: 0;
gap: 0.3rem;
color: var(--subtext-0);
font-size: var(--font-size-xs);
}
.sleep-event-add label:first-child {
flex: 2 1 15rem;
}
.sleep-event-add label:not(:first-child) {
flex: 1 1 7rem;
}
.sleep-event-add button {
flex: 0 0 auto;
}
.sleep-entry-card input,
.sleep-entry-card select,
.sleep-entry-card textarea,
.sleep-medication-form input {
width: 100%;
min-height: 2.7rem;
padding: 0.6rem 0.7rem;
color: var(--text);
background: var(--mantle);
border: 1px solid var(--surface-2);
border-radius: 0.5rem;
font: inherit;
}
.sleep-entry-card input:focus-visible,
.sleep-entry-card select:focus-visible,
.sleep-entry-card textarea:focus-visible,
.sleep-medication-form input:focus-visible {
outline: 3px solid var(--lavender);
outline-offset: 1px;
border-color: var(--lavender);
}
.sleep-event-list {
display: grid;
gap: 0.55rem;
padding: 0;
list-style: none;
}
.sleep-event-list li {
display: grid;
gap: 0.55rem;
align-items: center;
padding: 0.7rem;
background: var(--mantle);
border-left: 3px solid var(--lavender);
border-radius: 0.5rem;
}
.sleep-timeline-editor .sleep-event-list li {
grid-template-columns: 6.5rem minmax(8rem, 1.4fr) repeat(
2,
minmax(6rem, 0.75fr)
) auto auto;
}
.sleep-timeline-editor time {
color: var(--lavender);
font-variant-numeric: tabular-nums;
font-weight: 700;
}
.sleep-notes textarea {
min-height: 5rem;
resize: vertical;
}
.sleep-actions {
padding-top: 0.25rem;
}
.sleep-actions .danger-action {
margin-left: auto;
}
.sleep-medication-form {
display: grid;
grid-template-columns: 2fr 1fr 1fr auto auto;
gap: 0.75rem;
align-items: end;
margin-bottom: 1rem;
}
.sleep-medication-list {
display: grid;
gap: 0.6rem;
margin: 1rem 0 0;
padding: 0;
list-style: none;
}
.sleep-medication-list li {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
padding: 0.8rem;
background: var(--base);
border: 1px solid var(--surface-1);
border-radius: 0.55rem;
}
.sleep-medication-list li > div:first-child {
display: grid;
gap: 0.2rem;
}
.sleep-medication-list span {
color: var(--subtext-0);
font-size: var(--font-size-sm);
}
.section-error {
display: grid;
justify-items: start;
gap: 0.75rem;
}
@media (max-width: 760px) {
.sleep-editor-columns { grid-template-columns: 1fr; }
.sleep-event-list li { grid-template-columns: 1fr; }
.sleep-timeline-editor .sleep-event-list li, .sleep-medication-head { grid-template-columns: 1fr; }
.sleep-medication-head { display: none; }
.sleep-heading { align-items: flex-start; flex-direction: column; }
.sleep-event-add, .sleep-actions, .sleep-dates { flex-wrap: wrap; justify-content: flex-start; }
.sleep-event-add > label { flex: 1 1 8rem; min-width: 0; }
.sleep-event-add input, .sleep-event-add select { width: 100%; min-width: 0; }
.sleep-event-add > button { flex: 1 1 100%; }
.sleep-timeline-editor .sleep-event-list button { width: 100%; }
.sleep-editor-columns {
grid-template-columns: 1fr;
}
.sleep-timeline-editor .sleep-event-list li,
.sleep-medication-form {
grid-template-columns: 1fr;
}
.sleep-heading,
.sleep-entry-header {
align-items: flex-start;
flex-direction: column;
}
.sleep-entry-state {
justify-items: start;
}
.sleep-event-add,
.sleep-actions,
.sleep-dates {
flex-wrap: wrap;
justify-content: flex-start;
}
.sleep-event-add > label {
flex: 1 1 8rem;
min-width: 0;
}
.sleep-event-add input,
.sleep-event-add select {
width: 100%;
min-width: 0;
}
.sleep-event-add > button {
flex: 1 1 100%;
}
.sleep-timeline-editor .sleep-event-list button {
width: 100%;
}
.sleep-medication-list li {
align-items: flex-start;
flex-direction: column;
}
.sleep-actions .danger-action {
margin-left: 0;
}
}
.analysis-delta dd {
color: var(--lavender);
@ -1585,7 +1913,7 @@ h2 {
max-height: 32rem;
overflow: auto;
color: var(--subtext-0);
font: var(--font-size-sm)/1.6 var(--font-mono);
font: var(--font-size-sm) / 1.6 var(--font-mono);
white-space: pre-wrap;
}
@media (max-width: 52rem) {
@ -1668,7 +1996,7 @@ h2 {
.layout-breakpoint-note {
margin: 0 auto 0 0;
color: var(--subtext-0);
font: 550 var(--font-size-xs)/1.4 var(--font-mono);
font: 550 var(--font-size-xs) / 1.4 var(--font-mono);
}
.layout-breakpoint-note {
flex-basis: 100%;
@ -1828,7 +2156,7 @@ h2 {
.tile-state {
flex: none;
color: var(--subtext-0);
font: 650 var(--font-size-xs)/1 var(--font-mono);
font: 650 var(--font-size-xs) / 1 var(--font-mono);
letter-spacing: 0.06em;
padding: 0.48rem 0.62rem;
background: var(--surface-0);
@ -2039,7 +2367,7 @@ h2 {
}
.fact-list dt {
color: var(--overlay-1);
font: 650 var(--font-size-xs)/1.25 var(--font-mono);
font: 650 var(--font-size-xs) / 1.25 var(--font-mono);
letter-spacing: 0.035em;
text-transform: uppercase;
}
@ -2356,7 +2684,7 @@ h2 {
}
.status-label {
color: var(--overlay-1);
font: 700 var(--font-size-xs)/1.15 var(--font-mono);
font: 700 var(--font-size-xs) / 1.15 var(--font-mono);
letter-spacing: 0.08em;
}
.status-value {
@ -2372,7 +2700,7 @@ h2 {
align-items: center;
gap: 0.42rem;
color: var(--green);
font: 650 var(--font-size-xs)/1.2 var(--font-mono);
font: 650 var(--font-size-xs) / 1.2 var(--font-mono);
white-space: nowrap;
}
.backend-state.is-error {