diff --git a/CHANGELOG.md b/CHANGELOG.md index 1653c87..893dba4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/current_state.md b/docs/current_state.md index f8ce1ae..5d572d9 100644 --- a/docs/current_state.md +++ b/docs/current_state.md @@ -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) | diff --git a/docs/reviews/evidence/sleep-diary-v1-final-firefox.png b/docs/reviews/evidence/sleep-diary-v1-final-firefox.png new file mode 100644 index 0000000..0dc1012 Binary files /dev/null and b/docs/reviews/evidence/sleep-diary-v1-final-firefox.png differ diff --git a/docs/reviews/sleep_diary_capture_ux_v1_evidence.md b/docs/reviews/sleep_diary_capture_ux_v1_evidence.md new file mode 100644 index 0000000..de0979d --- /dev/null +++ b/docs/reviews/sleep_diary_capture_ux_v1_evidence.md @@ -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. diff --git a/docs/tests.md b/docs/tests.md index 55b1c68..08b2474 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -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 diff --git a/web/src/api/sleepDiary.test.ts b/web/src/api/sleepDiary.test.ts index 99b100c..d32f2de 100644 --- a/web/src/api/sleepDiary.test.ts +++ b/web/src/api/sleepDiary.test.ts @@ -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(); + }); +}); diff --git a/web/src/api/sleepDiary.ts b/web/src/api/sleepDiary.ts index 08205f8..66ea7af 100644 --- a/web/src/api/sleepDiary.ts +++ b/web/src/api/sleepDiary.ts @@ -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 & { - 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 => - 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 { - 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 { + 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 { - 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 { + 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, +) => + await mutation("DELETE", { + entry_id: entry.entry_id, + expected_revision: entry.revision_id, + deleted_at: sleepTimestamp(), + }); export async function fetchSleepMedications(): Promise { - 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 & { - expected_revision: string | null; created_at: string; updated_at: string -}): Promise { - 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 & { + expected_revision: string | null; + created_at: string; + updated_at: string; + }, +): Promise { + 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"); } diff --git a/web/src/components/SectionErrorBoundary.test.tsx b/web/src/components/SectionErrorBoundary.test.tsx new file mode 100644 index 0000000..f3512d4 --- /dev/null +++ b/web/src/components/SectionErrorBoundary.test.tsx @@ -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( +
+
Trainlog
+ + + + +
, + ); + 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(); +}); diff --git a/web/src/components/SectionErrorBoundary.tsx b/web/src/components/SectionErrorBoundary.tsx new file mode 100644 index 0000000..521bb49 --- /dev/null +++ b/web/src/components/SectionErrorBoundary.tsx @@ -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 ( +
+

{this.props.fallbackTitle}

+ +
+ ); + } + return this.props.children; + } +} diff --git a/web/src/routes/AnalysisPage.tsx b/web/src/routes/AnalysisPage.tsx index ada9020..dda9e8e 100644 --- a/web/src/routes/AnalysisPage.tsx +++ b/web/src/routes/AnalysisPage.tsx @@ -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() { )} {section === "sleep" && ( - + + + )} )} diff --git a/web/src/routes/SleepDiaryWorkspace.test.tsx b/web/src/routes/SleepDiaryWorkspace.test.tsx new file mode 100644 index 0000000..ee5139d --- /dev/null +++ b/web/src/routes/SleepDiaryWorkspace.test.tsx @@ -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(); + 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(); + 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(); + 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(); + 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(); + }); +}); diff --git a/web/src/routes/SleepDiaryWorkspace.tsx b/web/src/routes/SleepDiaryWorkspace.tsx index 52fc9a0..6582c26 100644 --- a/web/src/routes/SleepDiaryWorkspace.tsx +++ b/web/src/routes/SleepDiaryWorkspace.tsx @@ -1,164 +1,1256 @@ -import { useEffect, useMemo, useRef, useState } from 'react' -import { deleteSleepEntry, fetchSleepDiary, fetchSleepMedications, saveSleepEntry, saveSleepMedication, validateSleepEntry, +import { useEffect, useMemo, useRef, useState } from "react"; +import { + deleteSleepEntry, + fetchSleepDiary, + fetchSleepMedications, + newSleepId, + saveSleepEntry, + saveSleepMedication, + validateSleepEntry, sleepTimestamp, - type MedicationIntake, type SleepEntry, type SleepEntryInput, type SleepEvent, type SleepEventType, - type SleepMedication, type SleepQuality } from '../api/sleepDiary' -import type { AnalysisPeriod } from '../api/analysis' -import { buildSleepDiaryPdf, presentSleepDiaryPdf } from '../report/sleepDiaryPdf' + type MedicationIntake, + type SleepEntry, + type SleepEntryInput, + type SleepEvent, + type SleepEventType, + type SleepMedication, + type SleepQuality, +} from "../api/sleepDiary"; +import type { AnalysisPeriod } from "../api/analysis"; +import { + buildSleepDiaryPdf, + presentSleepDiaryPdf, +} from "../report/sleepDiaryPdf"; -type Language = 'fr' | 'en' -const q: readonly SleepQuality[] = ['TB', 'B', 'Moy', 'M', 'TM'] -const pointTypes: readonly SleepEventType[] = ['bed_time', 'final_get_up', 'night_get_up', 'daytime_sleepiness'] +type Language = "fr" | "en"; +const q: readonly SleepQuality[] = ["TB", "B", "Moy", "M", "TM"]; +const pointTypes: readonly SleepEventType[] = [ + "bed_time", + "final_get_up", + "night_get_up", + "daytime_sleepiness", +]; const copy = { - fr: { agenda: 'Agenda', edit: 'Saisie et édition', morning: 'Matin', day: 'Journée / Soir', night: 'Nuit du', - bed_time: 'Mise au lit', final_get_up: 'Lever', night_get_up: 'Lever nocturne', sleep: 'Sommeil', nap: 'Sieste', - long_awake: 'Long réveil', half_sleep: 'Demi-sommeil', daytime_sleepiness: 'Somnolence', sleepQuality: 'Qualité du sommeil', - wakeQuality: 'Qualité du réveil', dayForm: 'Forme de la journée', notes: 'Traitement et remarques', add: 'Ajouter', - save: 'Enregistrer', remove: 'Supprimer', observations: 'Observations', summary: 'Synthèse factuelle', empty: 'Aucune nuit enregistrée.', - preview: 'Prévisualiser', export: 'Exporter PDF', medications: 'Médicaments', addMedication: 'Ajouter un médicament', - medication: 'Médicament', usualDose: 'Dosage habituel', unit: 'Unité', deactivate: 'Désactiver', intake: 'Ajouter une prise', - time: 'Heure', start: 'Début', end: 'Fin', dose: 'Dosage', modify: 'Modifier', validate: 'Valider la journée', continue: 'Continuer', - saved: 'Enregistré localement', saving: 'Enregistrement…', registeredMedications: 'Médicaments enregistrés', timeline: 'Chronologie', - statuses: { draft: 'Brouillon', ready: 'Prête à synchroniser', synchronized: 'Synchronisée', modified: 'Modifiée depuis la synchronisation' }, - qualities: ['Très bon', 'Bon', 'Moyen', 'Mauvais', 'Très mauvais'] }, - en: { agenda: 'Diary', edit: 'Entry editor', morning: 'Morning', day: 'Day / Evening', night: 'Night from', - bed_time: 'Bedtime', final_get_up: 'Final get-up', night_get_up: 'Night get-up', sleep: 'Sleep', nap: 'Nap', - long_awake: 'Long awakening', half_sleep: 'Half-sleep', daytime_sleepiness: 'Sleepiness', sleepQuality: 'Sleep quality', - wakeQuality: 'Wake quality', dayForm: 'Day form', notes: 'Treatment and notes', add: 'Add', save: 'Save', remove: 'Delete', - observations: 'Observations', summary: 'Factual summary', empty: 'No recorded nights.', preview: 'Preview', export: 'Export PDF', - medications: 'Medications', addMedication: 'Add a medication', medication: 'Medication', usualDose: 'Usual dose', - unit: 'Unit', deactivate: 'Deactivate', intake: 'Add an intake', time: 'Time', start: 'Start', end: 'End', dose: 'Dose', modify: 'Edit', - saved: 'Saved locally', saving: 'Saving…', registeredMedications: 'Saved medications', timeline: 'Timeline', - validate: 'Validate day', continue: 'Continue', statuses: { draft: 'Draft', ready: 'Ready to sync', - synchronized: 'Synchronized', modified: 'Modified since synchronization' }, - qualities: ['Very good', 'Good', 'Average', 'Bad', 'Very bad'] }, -} as const + fr: { + agenda: "Agenda", + edit: "Saisie et édition", + morning: "Matin", + day: "Journée / Soir", + night: "Nuit du", + bed_time: "Mise au lit", + final_get_up: "Lever", + night_get_up: "Lever nocturne", + sleep: "Sommeil", + nap: "Sieste", + long_awake: "Long réveil", + half_sleep: "Demi-sommeil", + daytime_sleepiness: "Somnolence", + sleepQuality: "Qualité du sommeil", + wakeQuality: "Qualité du réveil", + dayForm: "Forme de la journée", + notes: "Traitement et remarques", + add: "Ajouter", + save: "Enregistrer", + remove: "Supprimer", + observations: "Observations", + summary: "Synthèse factuelle", + empty: "Aucune nuit enregistrée.", + preview: "Prévisualiser", + export: "Exporter PDF", + medications: "Médicaments", + addMedication: "Ajouter un médicament", + medication: "Médicament", + usualDose: "Dosage habituel", + unit: "Unité", + deactivate: "Désactiver", + intake: "Ajouter une prise", + time: "Heure", + start: "Début", + end: "Fin", + dose: "Dosage", + modify: "Modifier", + validate: "Valider la journée", + continue: "Continuer", + saved: "Enregistré localement", + saving: "Enregistrement…", + registeredMedications: "Médicaments enregistrés", + timeline: "Chronologie", + addEventHeading: "Ajouter un événement", + emptyEvents: "Aucun événement enregistré pour cette nuit.", + addIntakeHeading: "Ajouter une prise", + cancel: "Annuler", + deleteEntry: "Supprimer la fiche", + medicationEmpty: "Aucun médicament enregistré.", + notesPlaceholder: "Notes utiles pour cette nuit…", + statuses: { + draft: "Brouillon", + ready: "Prête à synchroniser", + synchronized: "Synchronisée", + modified: "Modifiée depuis la synchronisation", + }, + qualities: ["Très bon", "Bon", "Moyen", "Mauvais", "Très mauvais"], + }, + en: { + agenda: "Diary", + edit: "Entry editor", + morning: "Morning", + day: "Day / Evening", + night: "Night from", + bed_time: "Bedtime", + final_get_up: "Final get-up", + night_get_up: "Night get-up", + sleep: "Sleep", + nap: "Nap", + long_awake: "Long awakening", + half_sleep: "Half-sleep", + daytime_sleepiness: "Sleepiness", + sleepQuality: "Sleep quality", + wakeQuality: "Wake quality", + dayForm: "Day form", + notes: "Treatment and notes", + add: "Add", + save: "Save", + remove: "Delete", + observations: "Observations", + summary: "Factual summary", + empty: "No recorded nights.", + preview: "Preview", + export: "Export PDF", + medications: "Medications", + addMedication: "Add a medication", + medication: "Medication", + usualDose: "Usual dose", + unit: "Unit", + deactivate: "Deactivate", + intake: "Add an intake", + time: "Time", + start: "Start", + end: "End", + dose: "Dose", + modify: "Edit", + saved: "Saved locally", + saving: "Saving…", + registeredMedications: "Saved medications", + timeline: "Timeline", + addEventHeading: "Add an event", + emptyEvents: "No events recorded for this night.", + addIntakeHeading: "Add an intake", + cancel: "Cancel", + deleteEntry: "Delete entry", + medicationEmpty: "No saved medications.", + notesPlaceholder: "Useful notes about this night…", + validate: "Validate day", + continue: "Continue", + statuses: { + draft: "Draft", + ready: "Ready to sync", + synchronized: "Synchronized", + modified: "Modified since synchronization", + }, + qualities: ["Very good", "Good", "Average", "Bad", "Very bad"], + }, +} as const; -const clock = (iso: string) => iso ? iso.slice(11, 16) : '' +const clock = (iso: string) => (iso ? iso.slice(11, 16) : ""); const atClock = (value: string, startDate: string, endDate: string) => { - if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(value)) return '' - const hour = Number(value.slice(0, 2)); const date = hour >= 18 ? startDate : endDate - const local = new Date(`${date}T${value}:00`) - const offsetMinutes = -local.getTimezoneOffset() - const sign = offsetMinutes >= 0 ? '+' : '-' - const absoluteOffset = Math.abs(offsetMinutes) - const offsetHours = String(Math.floor(absoluteOffset / 60)).padStart(2, '0') - const offsetRemainder = String(absoluteOffset % 60).padStart(2, '0') - return `${date}T${value}:00${sign}${offsetHours}:${offsetRemainder}` -} + if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(value)) return ""; + const hour = Number(value.slice(0, 2)); + const date = hour >= 18 ? startDate : endDate; + const local = new Date(`${date}T${value}:00`); + const offsetMinutes = -local.getTimezoneOffset(); + const sign = offsetMinutes >= 0 ? "+" : "-"; + const absoluteOffset = Math.abs(offsetMinutes); + const offsetHours = String(Math.floor(absoluteOffset / 60)).padStart(2, "0"); + const offsetRemainder = String(absoluteOffset % 60).padStart(2, "0"); + return `${date}T${value}:00${sign}${offsetHours}:${offsetRemainder}`; +}; const offset = (iso: string, startDate: string) => { - const start = new Date(`${startDate}T18:00:00`) - return Math.max(0, Math.min(100, ((new Date(iso).getTime() - start.getTime()) / 86_400_000) * 100)) -} + const start = new Date(`${startDate}T18:00:00`); + return Math.max( + 0, + Math.min( + 100, + ((new Date(iso).getTime() - start.getTime()) / 86_400_000) * 100, + ), + ); +}; const blank = (): SleepEntryInput => { - const now = new Date(); const start = new Date(now); if (now.getHours() < 18) start.setDate(start.getDate() - 1) - const end = new Date(start); end.setDate(end.getDate() + 1) - const date = (value: Date) => value.toLocaleDateString('en-CA') - return { entry_id: '', expected_revision: null, night_start_date: date(start), night_end_date: date(end), - created_at: sleepTimestamp(now), updated_at: sleepTimestamp(now), sleep_quality: null, wake_quality: null, - day_form: null, treatment_and_notes: '', events: [], intakes: [] } + const now = new Date(); + const start = new Date(now); + if (now.getHours() < 18) start.setDate(start.getDate() - 1); + const end = new Date(start); + end.setDate(end.getDate() + 1); + const date = (value: Date) => value.toLocaleDateString("en-CA"); + return { + entry_id: "", + expected_revision: null, + night_start_date: date(start), + night_end_date: date(end), + created_at: sleepTimestamp(now), + updated_at: sleepTimestamp(now), + sleep_quality: null, + wake_quality: null, + day_form: null, + treatment_and_notes: "", + events: [], + intakes: [], + }; +}; + +function QualityPicker({ + label, + value, + onChange, + language, +}: { + label: string; + value: SleepQuality | null; + onChange: (value: SleepQuality | null) => void; + language: Language; +}) { + return ( +
+ {label} + {q.map((item, index) => ( + + ))} +
+ ); } -function QualityPicker({ label, value, onChange, language }: { label: string; value: SleepQuality | null; - onChange: (value: SleepQuality | null) => void; language: Language }) { - return
{label}{q.map((item, index) => - )}
-} +export function SleepDiaryWorkspace({ + period, + language, +}: { + period: AnalysisPeriod; + language: Language; +}) { + const t = copy[language]; + const [snapshot, setSnapshot] = useState + > | null>(null); + const entries = snapshot?.entries ?? []; + const [draft, setDraft] = useState(blank); + const [eventType, setEventType] = useState("sleep"); + const [pending, setPending] = useState(false); + const [error, setError] = useState(""); + const [eventStart, setEventStart] = useState("22:30"); + const [eventEnd, setEventEnd] = useState("23:30"); + const [publicationStatus, setPublicationStatus] = + useState("draft"); + const [saveState, setSaveState] = useState<"saved" | "saving" | "error">( + "saved", + ); + const [medications, setMedications] = useState([]); + const [medName, setMedName] = useState(""); + const [medDose, setMedDose] = useState(""); + const [medUnit, setMedUnit] = useState("mg"); + const [editingMedication, setEditingMedication] = + useState(null); + const [medicationFormOpen, setMedicationFormOpen] = useState(false); + const [intakeMedication, setIntakeMedication] = useState(""); + const [intakeTime, setIntakeTime] = useState("22:00"); + const [intakeDose, setIntakeDose] = useState(""); + const [intakeUnit, setIntakeUnit] = useState("mg"); + const identity = useRef<{ entry_id: string; revision_id: string } | null>( + null, + ); + const persistence = useRef>(Promise.resolve()); + const range = useMemo(() => { + if (period === "all") return {}; + const days = Number(period.slice(0, -1)); + const end = new Date(); + const start = new Date(); + start.setDate(end.getDate() - days + 1); + return { + startDate: start.toLocaleDateString("en-CA"), + endDate: end.toLocaleDateString("en-CA"), + }; + }, [period]); + const reload = () => + Promise.all([ + fetchSleepDiary(range.startDate, range.endDate), + fetchSleepMedications(), + ]) + .then(([diary, catalog]) => { + setSnapshot(diary); + setMedications(catalog); + if (identity.current === null) { + const resume = + diary.entries.find( + (entry) => + entry.publication_status === "draft" || + entry.publication_status === "modified", + ) ?? diary.entries[0]; + if (resume) { + identity.current = { + entry_id: resume.entry_id, + revision_id: resume.revision_id, + }; + setPublicationStatus(resume.publication_status); + setDraft({ + ...resume, + expected_revision: resume.revision_id, + events: resume.events.map((event) => ({ ...event })), + intakes: resume.intakes.map((intake) => ({ ...intake })), + }); + } + } + }) + .catch(() => setError("sleep_diary_unavailable")); + useEffect(() => { + void reload(); + }, [period]); + const edit = (entry: SleepEntry) => { + identity.current = { + entry_id: entry.entry_id, + revision_id: entry.revision_id, + }; + setPublicationStatus(entry.publication_status); + setDraft({ + ...entry, + expected_revision: entry.revision_id, + events: entry.events.map((event) => ({ ...event })), + intakes: entry.intakes.map((intake) => ({ ...intake })), + }); + }; + const persist = (next: SleepEntryInput) => { + setSaveState("saving"); + persistence.current = persistence.current + .then(async () => { + const current = identity.current; + const result = await saveSleepEntry({ + ...next, + entry_id: current?.entry_id ?? next.entry_id, + expected_revision: current?.revision_id ?? next.expected_revision, + updated_at: sleepTimestamp(), + }); + identity.current = result; + setPublicationStatus((status) => + status === "synchronized" || status === "modified" + ? "modified" + : "draft", + ); + setSaveState("saved"); + setDraft((value) => ({ + ...value, + entry_id: result.entry_id, + expected_revision: result.revision_id, + })); + }) + .catch((reason: unknown) => { + setSaveState("error"); + setError( + reason instanceof Error + ? reason.message + : "sleep_diary_mutation_failed", + ); + }); + }; + const mutate = (transform: (current: SleepEntryInput) => SleepEntryInput) => + setDraft((current) => { + const next = transform(current); + persist(next); + return next; + }); + const change = ( + key: K, + value: SleepEntryInput[K], + ) => mutate((current) => ({ ...current, [key]: value })); + const addEvent = () => { + setError(""); + const start = atClock( + eventStart, + draft.night_start_date, + draft.night_end_date, + ); + const end = pointTypes.includes(eventType) + ? null + : atClock(eventEnd, draft.night_start_date, draft.night_end_date); + if (!start || (end !== null && new Date(end) <= new Date(start))) { + setError("sleep_diary_invalid_time"); + return; + } + mutate((current) => ({ + ...current, + events: [ + ...current.events, + { + event_id: newSleepId("sle"), + type: eventType, + start_at: start, + end_at: end, + }, + ].sort((a, b) => a.start_at.localeCompare(b.start_at)), + })); + }; + const changeEvent = (index: number, patch: Partial) => + mutate((current) => ({ + ...current, + events: current.events.map((event, currentIndex) => + currentIndex === index ? { ...event, ...patch } : event, + ), + })); + const selectMedication = (id: string) => { + setIntakeMedication(id); + const item = medications.find((med) => med.medication_id === id); + setIntakeDose(item?.default_dose_value?.toString() ?? ""); + setIntakeUnit(item?.default_dose_unit ?? "mg"); + }; + const addIntake = () => { + setError(""); + const medication = medications.find( + (item) => item.medication_id === intakeMedication, + ); + const takenAt = atClock( + intakeTime, + draft.night_start_date, + draft.night_end_date, + ); + if (!medication || !takenAt) { + setError("sleep_diary_invalid_intake"); + return; + } + const dose = intakeDose === "" ? null : Number(intakeDose); + if (dose !== null && !(dose > 0)) { + setError("sleep_diary_invalid_intake"); + return; + } + const intake: MedicationIntake = { + intake_id: newSleepId("mdi"), + medication_id: medication.medication_id, + medication_name: medication.name, + taken_at: takenAt, + dose_value: dose, + dose_unit: dose === null ? null : intakeUnit, + note: "", + created_at: sleepTimestamp(), + }; + mutate((current) => ({ + ...current, + intakes: [...current.intakes, intake].sort((a, b) => + a.taken_at.localeCompare(b.taken_at), + ), + })); + }; + const addMedication = async () => { + if (!medName.trim()) return; + const now = sleepTimestamp(); + const savedName = medName.trim(); + setPending(true); + setError(""); + try { + await saveSleepMedication({ + medication_id: editingMedication?.medication_id ?? "", + expected_revision: editingMedication?.revision_id ?? null, + created_at: now, + updated_at: now, + name: medName.trim(), + default_dose_value: medDose === "" ? null : Number(medDose), + default_dose_unit: medDose === "" ? null : medUnit, + form: editingMedication?.form ?? "", + note: editingMedication?.note ?? "", + active: editingMedication?.active ?? true, + }); + const catalog = await fetchSleepMedications(); + setMedications(catalog); + const saved = + catalog.find( + (item) => item.medication_id === editingMedication?.medication_id, + ) ?? catalog.find((item) => item.name === savedName); + if (saved) { + setIntakeMedication(saved.medication_id); + setIntakeDose(saved.default_dose_value?.toString() ?? ""); + setIntakeUnit(saved.default_dose_unit ?? "mg"); + } + setMedName(""); + setMedDose(""); + setEditingMedication(null); + setMedicationFormOpen(false); + } catch (reason) { + setError( + reason instanceof Error + ? reason.message + : "sleep_medication_mutation_failed", + ); + } finally { + setPending(false); + } + }; + const save = async () => { + setPending(true); + setError(""); + try { + persist(draft); + await persistence.current; + await reload(); + } catch (reason) { + setError( + reason instanceof Error + ? reason.message + : "sleep_diary_mutation_failed", + ); + } finally { + setPending(false); + } + }; + const validate = async () => { + setPending(true); + setError(""); + try { + persist(draft); + await persistence.current; + const current = identity.current; + if (current) + await validateSleepEntry(current.entry_id, current.revision_id); + setPublicationStatus("ready"); + await reload(); + } catch (reason) { + setError( + reason instanceof Error + ? reason.message + : "sleep_diary_validation_failed", + ); + } finally { + setPending(false); + } + }; + const removeEntry = async () => { + const current = identity.current; + if (!current) return; + setPending(true); + setError(""); + try { + await deleteSleepEntry({ + entry_id: current.entry_id, + revision_id: current.revision_id, + }); + identity.current = null; + setDraft(blank()); + await reload(); + } catch (reason) { + setError( + reason instanceof Error + ? reason.message + : "sleep_diary_mutation_failed", + ); + } finally { + setPending(false); + } + }; + const timeline = [ + ...draft.events.map((event, index) => ({ + kind: "event" as const, + at: event.start_at, + event, + index, + })), + ...draft.intakes.map((intake, index) => ({ + kind: "intake" as const, + at: intake.taken_at, + intake, + index, + })), + ].sort((a, b) => a.at.localeCompare(b.at) || a.kind.localeCompare(b.kind)); + return ( +
+
+
+

{t.agenda}

+
+ + +
+
+ {entries.length === 0 ? ( +

{t.empty}

+ ) : ( +
+
+ + {entries.map((entry) => ( + + ))} +
+
+ )} +
-export function SleepDiaryWorkspace({ period, language }: { period: AnalysisPeriod; language: Language }) { - const t = copy[language]; const [snapshot, setSnapshot] = useState> | null>(null); const entries = snapshot?.entries ?? []; const [draft, setDraft] = useState(blank) - const [eventType, setEventType] = useState('sleep'); const [pending, setPending] = useState(false); const [error, setError] = useState('') - const [eventStart, setEventStart] = useState('22:30'); const [eventEnd, setEventEnd] = useState('23:30') - const [publicationStatus, setPublicationStatus] = useState('draft') - const [saveState, setSaveState] = useState<'saved' | 'saving' | 'error'>('saved') - const [medications, setMedications] = useState([]) - const [medName, setMedName] = useState(''); const [medDose, setMedDose] = useState(''); const [medUnit, setMedUnit] = useState('mg') - const [editingMedication, setEditingMedication] = useState(null) - const [intakeMedication, setIntakeMedication] = useState(''); const [intakeTime, setIntakeTime] = useState('22:00') - const [intakeDose, setIntakeDose] = useState(''); const [intakeUnit, setIntakeUnit] = useState('mg') - const identity = useRef<{ entry_id: string; revision_id: string } | null>(null) - const persistence = useRef>(Promise.resolve()) - const range = useMemo(() => { if (period === 'all') return {}; const days = Number(period.slice(0, -1)); const end = new Date(); const start = new Date(); start.setDate(end.getDate() - days + 1) - return { startDate: start.toLocaleDateString('en-CA'), endDate: end.toLocaleDateString('en-CA') } }, [period]) - const reload = () => Promise.all([fetchSleepDiary(range.startDate, range.endDate), fetchSleepMedications()]) - .then(([diary, catalog]) => { setSnapshot(diary); setMedications(catalog) - if (identity.current === null) { const resume = diary.entries.find((entry) => entry.publication_status === 'draft' || entry.publication_status === 'modified') ?? diary.entries[0] - if (resume) { identity.current = { entry_id: resume.entry_id, revision_id: resume.revision_id }; setPublicationStatus(resume.publication_status) - setDraft({ ...resume, expected_revision: resume.revision_id, events: resume.events.map((event) => ({ ...event })), intakes: resume.intakes.map((intake) => ({ ...intake })) }) } } - }).catch(() => setError('sleep_diary_unavailable')) - useEffect(() => { void reload() }, [period]) - const edit = (entry: SleepEntry) => { identity.current = { entry_id: entry.entry_id, revision_id: entry.revision_id }; setPublicationStatus(entry.publication_status) - setDraft({ ...entry, expected_revision: entry.revision_id, - events: entry.events.map((event) => ({ ...event })), intakes: entry.intakes.map((intake) => ({ ...intake })) }) } - const persist = (next: SleepEntryInput) => { setSaveState('saving'); persistence.current = persistence.current.then(async () => { - const current = identity.current; const result = await saveSleepEntry({ ...next, - entry_id: current?.entry_id ?? next.entry_id, expected_revision: current?.revision_id ?? next.expected_revision, - updated_at: sleepTimestamp() }) - identity.current = result - setPublicationStatus((status) => status === 'synchronized' || status === 'modified' ? 'modified' : 'draft') - setSaveState('saved') - setDraft((value) => ({ ...value, entry_id: result.entry_id, expected_revision: result.revision_id })) - }).catch((reason: unknown) => { setSaveState('error'); setError(reason instanceof Error ? reason.message : 'sleep_diary_mutation_failed') }) } - const mutate = (transform: (current: SleepEntryInput) => SleepEntryInput) => setDraft((current) => { - const next = transform(current); persist(next); return next - }) - const change = (key: K, value: SleepEntryInput[K]) => - mutate((current) => ({ ...current, [key]: value })) - const addEvent = () => { const start = atClock(eventStart, draft.night_start_date, draft.night_end_date) - const end = pointTypes.includes(eventType) ? null : atClock(eventEnd, draft.night_start_date, draft.night_end_date) - if (!start || end !== null && new Date(end) <= new Date(start)) { setError('sleep_diary_invalid_time'); return } - mutate((current) => ({ ...current, events: [...current.events, { event_id: `sle_${crypto.randomUUID()}`, type: eventType, - start_at: start, end_at: end }].sort((a, b) => a.start_at.localeCompare(b.start_at)) })) } - const changeEvent = (index: number, patch: Partial) => mutate((current) => ({ ...current, - events: current.events.map((event, currentIndex) => currentIndex === index ? { ...event, ...patch } : event) })) - const selectMedication = (id: string) => { setIntakeMedication(id); const item = medications.find((med) => med.medication_id === id) - setIntakeDose(item?.default_dose_value?.toString() ?? ''); setIntakeUnit(item?.default_dose_unit ?? 'mg') } - const addIntake = () => { const medication = medications.find((item) => item.medication_id === intakeMedication); const takenAt = atClock(intakeTime, draft.night_start_date, draft.night_end_date); if (!medication || !takenAt) return - const dose = intakeDose === '' ? null : Number(intakeDose); if (dose !== null && !(dose > 0)) return - const intake: MedicationIntake = { intake_id: `mdi_${crypto.randomUUID()}`, medication_id: medication.medication_id, - medication_name: medication.name, taken_at: takenAt, dose_value: dose, - dose_unit: dose === null ? null : intakeUnit, note: '', created_at: sleepTimestamp() } - change('intakes', [...draft.intakes, intake].sort((a, b) => a.taken_at.localeCompare(b.taken_at))) } - const addMedication = async () => { if (!medName.trim()) return; const now = sleepTimestamp(); const savedName = medName.trim() - await saveSleepMedication({ medication_id: editingMedication?.medication_id ?? '', - expected_revision: editingMedication?.revision_id ?? null, created_at: now, updated_at: now, - name: medName.trim(), default_dose_value: medDose === '' ? null : Number(medDose), - default_dose_unit: medDose === '' ? null : medUnit, form: editingMedication?.form ?? '', - note: editingMedication?.note ?? '', active: editingMedication?.active ?? true }); - const catalog = await fetchSleepMedications(); setMedications(catalog) - const saved = catalog.find((item) => item.medication_id === editingMedication?.medication_id) ?? catalog.find((item) => item.name === savedName) - if (saved) { setIntakeMedication(saved.medication_id); setIntakeDose(saved.default_dose_value?.toString() ?? '') - setIntakeUnit(saved.default_dose_unit ?? 'mg') } - setMedName(''); setEditingMedication(null) } - const save = async () => { setPending(true); setError(''); persist(draft); await persistence.current; await reload(); setPending(false) } - const validate = async () => { setPending(true); setError(''); persist(draft); await persistence.current - const current = identity.current; if (current) await validateSleepEntry(current.entry_id, current.revision_id) - setPublicationStatus('ready'); await reload(); setPending(false) } - const timeline = [...draft.events.map((event, index) => ({ kind: 'event' as const, at: event.start_at, event, index })), - ...draft.intakes.map((intake, index) => ({ kind: 'intake' as const, at: intake.taken_at, intake, index }))] - .sort((a, b) => a.at.localeCompare(b.at) || a.kind.localeCompare(b.kind)) - return
-

{t.agenda}

- {entries.length === 0 ?

{t.empty}

:
- - {entries.map((entry) => )}
} -
-

{t.edit}

{t.statuses[publicationStatus]}
{saveState === 'saving' ? t.saving : saveState === 'saved' ? t.saved : error}
-

{t.morning}

change('sleep_quality', value)} language={language}/> change('wake_quality', value)} language={language}/>
-

{t.day}

change('day_form', value)} language={language}/>
-
{!pointTypes.includes(eventType) && }
-

{t.medications}

-

{t.timeline}

{timeline.length === 0 ?

{t.empty}

:
    {timeline.map((item) => item.kind === 'event' ?
  • {t[item.event.type]}{item.event.end_at && }
  • :
  • {item.intake.medication_name}{item.intake.dose_unit ?? ''}
  • )}
}
-