diff --git a/tui/src/web_sleep.c b/tui/src/web_sleep.c index 8e01e2f..2347fcb 100644 --- a/tui/src/web_sleep.c +++ b/tui/src/web_sleep.c @@ -449,11 +449,11 @@ static bool parse_entry(const char *body, copy_string( value, "created_at", intake->created_at, sizeof(intake->created_at), false) && ((yyjson_is_null(dose_value) && yyjson_is_null(dose_unit)) || - (yyjson_is_num(dose_value) && yyjson_get_real(dose_value) > 0.0 && + (yyjson_is_num(dose_value) && yyjson_get_num(dose_value) > 0.0 && copy_string( value, "dose_unit", intake->dose_unit, sizeof(intake->dose_unit), false))); intake->has_dose = yyjson_is_num(dose_value); - intake->dose_value = intake->has_dose ? yyjson_get_real(dose_value) : 0.0; + intake->dose_value = intake->has_dose ? yyjson_get_num(dose_value) : 0.0; if (valid && intake->intake_id[0] == '\0') { valid = trainlog_id_generate("mdi", intake->intake_id, sizeof(intake->intake_id)) == TRAINLOG_STATUS_OK; @@ -716,7 +716,7 @@ TrainlogStatus trainlog_web_medication_save_json(TrainlogDatabase *database, medication.default_dose_unit, sizeof(medication.default_dose_unit), true)) || - (yyjson_is_num(dose) && yyjson_get_real(dose) > 0.0 && + (yyjson_is_num(dose) && yyjson_get_num(dose) > 0.0 && copy_string(root, "default_dose_unit", medication.default_dose_unit, @@ -726,7 +726,7 @@ TrainlogStatus trainlog_web_medication_save_json(TrainlogDatabase *database, return TRAINLOG_STATUS_INVALID_ARGUMENT; } medication.has_default_dose = yyjson_is_num(dose); - medication.default_dose_value = medication.has_default_dose ? yyjson_get_real(dose) : 0.0; + medication.default_dose_value = medication.has_default_dose ? yyjson_get_num(dose) : 0.0; medication.active = yyjson_get_bool(active); if (expected[0] == '\0') { status = trainlog_medication_create(database, &medication); diff --git a/tui/tests/test_sleep_diary.c b/tui/tests/test_sleep_diary.c index 4ab275b..641b78d 100644 --- a/tui/tests/test_sleep_diary.c +++ b/tui/tests/test_sleep_diary.c @@ -68,6 +68,26 @@ static TrainlogStatus medication_count_visitor(void *context, return TRAINLOG_STATUS_OK; } +static int check_integer_medication_json(void) { + static const char request[] = "{\"medication_id\":null,\"expected_revision\":null," + "\"created_at\":\"2026-10-24T18:00:00+02:00\"," + "\"updated_at\":\"2026-10-24T18:00:00+02:00\"," + "\"name\":\"Synthetic integer dose\",\"default_dose_value\":5," + "\"default_dose_unit\":\"mg\",\"form\":\"\",\"note\":\"\"," + "\"active\":true}"; + TrainlogDatabase *database = NULL; + char *json = NULL; + size_t json_size = 0U; + + CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK); + CHECK(trainlog_web_medication_save_json( + database, request, strlen(request), &json, &json_size) == TRAINLOG_STATUS_OK); + CHECK(json != NULL && json_size > 0U && strstr(json, "\"medication_id\":\"med_") != NULL); + free(json); + trainlog_database_close(database); + return 0; +} + int main(void) { TrainlogDatabase *database = NULL; TrainlogSleepDiaryEntry entry = sample(); @@ -82,6 +102,8 @@ int main(void) { size_t medication_count = 0U; TrainlogMedication medication = {0}; + CHECK(check_integer_medication_json() == 0); + (void)snprintf( medication.created_at, sizeof(medication.created_at), "2026-10-24T18:00:00+02:00"); (void)snprintf( diff --git a/web/src/api/sleepDiary.test.ts b/web/src/api/sleepDiary.test.ts new file mode 100644 index 0000000..99b100c --- /dev/null +++ b/web/src/api/sleepDiary.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from 'vitest' +import { 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') + }) +}) diff --git a/web/src/api/sleepDiary.ts b/web/src/api/sleepDiary.ts index 599d0dc..08205f8 100644 --- a/web/src/api/sleepDiary.ts +++ b/web/src/api/sleepDiary.ts @@ -26,6 +26,11 @@ export type SleepEntryInput = Omit + date.toISOString().replace(/\.\d{3}Z$/, 'Z') + const object = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value) const quality = (value: unknown): value is SleepQuality | null => @@ -83,12 +88,12 @@ export async function validateSleepEntry(entryId: string, revisionId: string): P 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: new Date().toISOString() }), + 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: new Date().toISOString(), + entry_id: entry.entry_id, expected_revision: entry.revision_id, deleted_at: sleepTimestamp(), }) export async function fetchSleepMedications(): Promise { diff --git a/web/src/routes/SleepDiaryWorkspace.tsx b/web/src/routes/SleepDiaryWorkspace.tsx index 852d7b9..52fc9a0 100644 --- a/web/src/routes/SleepDiaryWorkspace.tsx +++ b/web/src/routes/SleepDiaryWorkspace.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { deleteSleepEntry, fetchSleepDiary, fetchSleepMedications, 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' @@ -16,7 +17,8 @@ const copy = { 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', dose: 'Dosage', modify: 'Modifier', validate: 'Valider la journée', continue: 'Continuer', + 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', @@ -25,14 +27,25 @@ const copy = { 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', dose: 'Dose', modify: 'Edit', + 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 -const localInput = (iso: string) => iso ? iso.slice(0, 16) : '' -const absolute = (value: string) => value ? new Date(value).toISOString() : '' +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}` +} 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)) @@ -42,7 +55,7 @@ const blank = (): SleepEntryInput => { 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: now.toISOString(), updated_at: now.toISOString(), sleep_quality: null, wake_quality: null, + created_at: sleepTimestamp(now), updated_at: sleepTimestamp(now), sleep_quality: null, wake_quality: null, day_form: null, treatment_and_notes: '', events: [], intakes: [] } } @@ -56,57 +69,76 @@ function QualityPicker({ label, value, onChange, language }: { label: string; va 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('') + 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) }).catch(() => setError('sleep_diary_unavailable')) + .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 } + 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) => { persistence.current = persistence.current.then(async () => { + 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: new Date().toISOString() }) + 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) => setError(reason instanceof Error ? reason.message : 'sleep_diary_mutation_failed')) } + }).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 = () => mutate((current) => ({ ...current, events: [...current.events, { event_id: `sle_${crypto.randomUUID()}`, type: eventType, - start_at: new Date().toISOString(), end_at: pointTypes.includes(eventType) ? null : new Date(Date.now() + 3_600_000).toISOString() }] })) + 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); if (!medication || !intakeTime) return + 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: absolute(intakeTime), dose_value: dose, - dose_unit: dose === null ? null : intakeUnit, note: '', created_at: new Date().toISOString() } + 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 = new Date().toISOString() + 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 }); - setMedName(''); setEditingMedication(null); await reload() } + 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) - await reload(); setPending(false) } - return
+ 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}

:
@@ -117,17 +149,16 @@ export function SleepDiaryWorkspace({ period, language }: { period: AnalysisPeri title={`${t[event.type]} ${event.start_at}${event.end_at ? ` – ${event.end_at}` : ''}`}>{event.type === 'bed_time' ? '↓' : event.type === 'final_get_up' || event.type === 'night_get_up' ? '↑' : event.type === 'daytime_sleepiness' ? 'S' : ''}})}{entry.intakes.map((intake) => M)} {entry.sleep_quality ?? '—'} / {entry.wake_quality ?? '—'} / {entry.day_form ?? '—'})}
}
-

{t.edit}

+

{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}/>
-
-
    {draft.events.map((event, index) =>
  • {t[event.type]}{event.end_at !== null && }
  • )}
-

{t.medications}

-
    {draft.intakes.map((intake, index) =>
  • {intake.medication_name}
  • )}
+
{!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 ?? ''}
  • )}
}