fix(web): stabilize and refine sleep diary capture
This commit is contained in:
parent
47ee128807
commit
915728cec4
13 changed files with 2287 additions and 302 deletions
10
CHANGELOG.md
10
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
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
|
|
|
|||
BIN
docs/reviews/evidence/sleep-diary-v1-final-firefox.png
Normal file
BIN
docs/reviews/evidence/sleep-diary-v1-final-firefox.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 315 KiB |
61
docs/reviews/sleep_diary_capture_ux_v1_evidence.md
Normal file
61
docs/reviews/sleep_diary_capture_ux_v1_evidence.md
Normal 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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
35
web/src/components/SectionErrorBoundary.test.tsx
Normal file
35
web/src/components/SectionErrorBoundary.test.tsx
Normal 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();
|
||||
});
|
||||
49
web/src/components/SectionErrorBoundary.tsx
Normal file
49
web/src/components/SectionErrorBoundary.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
188
web/src/routes/SleepDiaryWorkspace.test.tsx
Normal file
188
web/src/routes/SleepDiaryWorkspace.test.tsx
Normal 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
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue