From b079431a8d4e7732b02830da85ba23645d7fe7fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 16 Jun 2026 21:09:35 +0200 Subject: [PATCH] feat(illness): add the condition-journal MVP behind a born-gated module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a retrospective illness/condition journal: episodes with a lifecycle (acute / chronic-ongoing / recurring / flare) and a daily symptom timeline forked structurally from the cycle log-day surface. The journal is a record, not a medical device — no prediction or diagnosis UI. - Register `illness` as a born-gated (opt-in / default-off) module. The gate resolver flips the disabled-allowlist default to off for born-gated keys, so the surface stays dark until the account opts in from the Modules hub. `isIllnessEnabled` + `requireIllnessEnabled` delegate to the module foundation (one source of truth) and re-stamp an illness errorCode. - Episode CRUD under /api/illness: list / create / get / edit / resolve / delete, plus a per-episode-day day-log upsert on (episodeId, date). Every route wraps apiHandler, Zod-validates with returnAllZodIssues, narrows userId from auth, builds data field-by-field, and gates the module. Free-text notes encrypt at rest through the shared Bytes codec. - Log-day sheet (cycle fork): ~8 Jackson/WURSS symptom chips 0-3 + a functional-impact group + fever + an encrypted note, one row per day. - Episode history page + a born-gated nav entry, neutral palette, status via a neutral Badge, retrospective-only copy. i18n across all six locales. - Cover the new routes in the module-route-gate inventory; unit tests for the day-log upsert, the gate enforcement, and the born-gated resolver. The /api/auth/me module map now projects the `illness` flag (openapi.yaml regenerated). --- docs/api/openapi.yaml | 6 + messages/de.json | 81 ++++- messages/en.json | 81 ++++- messages/es.json | 81 ++++- messages/fr.json | 81 ++++- messages/it.json | 81 ++++- messages/pl.json | 81 ++++- .../module-route-gate-inventory.test.ts | 17 + .../illness/episodes/[id]/day-logs/route.ts | 140 +++++++ .../illness/episodes/[id]/resolve/route.ts | 87 +++++ src/app/api/illness/episodes/[id]/route.ts | 178 +++++++++ src/app/api/illness/episodes/route.ts | 138 +++++++ src/app/illness/page.tsx | 46 +++ src/components/illness/illness-view.tsx | 184 ++++++++++ src/components/illness/log-day-sheet.tsx | 342 ++++++++++++++++++ src/components/illness/new-episode-sheet.tsx | 196 ++++++++++ src/components/illness/symptom-catalog.ts | 48 +++ src/components/illness/types.ts | 64 ++++ src/components/illness/use-illness.ts | 92 +++++ src/components/layout/nav-model.ts | 12 + src/components/settings/modules-section.tsx | 2 + src/lib/dashboard/__tests__/snapshot.test.ts | 1 + .../illness/__tests__/day-log-write.test.ts | 158 ++++++++ src/lib/illness/__tests__/gate.test.ts | 55 +++ src/lib/illness/day-log-write.ts | 155 ++++++++ src/lib/illness/dto.ts | 99 +++++ src/lib/illness/gate.ts | 54 +++ src/lib/modules/__tests__/gate.test.ts | 48 +++ src/lib/modules/gate.ts | 8 + src/lib/modules/registry.ts | 30 ++ src/lib/query-keys/illness.ts | 21 ++ src/lib/query-keys/index.ts | 2 + src/lib/validations/illness.ts | 135 +++++++ .../doctor-report-sections.test.ts | 1 + 34 files changed, 2799 insertions(+), 6 deletions(-) create mode 100644 src/app/api/illness/episodes/[id]/day-logs/route.ts create mode 100644 src/app/api/illness/episodes/[id]/resolve/route.ts create mode 100644 src/app/api/illness/episodes/[id]/route.ts create mode 100644 src/app/api/illness/episodes/route.ts create mode 100644 src/app/illness/page.tsx create mode 100644 src/components/illness/illness-view.tsx create mode 100644 src/components/illness/log-day-sheet.tsx create mode 100644 src/components/illness/new-episode-sheet.tsx create mode 100644 src/components/illness/symptom-catalog.ts create mode 100644 src/components/illness/types.ts create mode 100644 src/components/illness/use-illness.ts create mode 100644 src/lib/illness/__tests__/day-log-write.test.ts create mode 100644 src/lib/illness/__tests__/gate.test.ts create mode 100644 src/lib/illness/day-log-write.ts create mode 100644 src/lib/illness/dto.ts create mode 100644 src/lib/illness/gate.ts create mode 100644 src/lib/query-keys/illness.ts create mode 100644 src/lib/validations/illness.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 9485410cb..e5d920192 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -6834,6 +6834,8 @@ components: type: boolean labs: type: boolean + illness: + type: boolean achievements: type: boolean insights: @@ -12404,6 +12406,9 @@ components: labs: type: boolean description: Whether the "labs" module is enabled for this account. + illness: + type: boolean + description: Whether the "illness" module is enabled for this account. achievements: type: boolean description: Whether the "achievements" module is enabled for this account. @@ -12424,6 +12429,7 @@ components: - workouts - recovery - labs + - illness - achievements - coach - insights diff --git a/messages/de.json b/messages/de.json index 35c813012..2e4967845 100644 --- a/messages/de.json +++ b/messages/de.json @@ -266,7 +266,8 @@ "cycle": "Zyklus", "labs": "Laborwerte", "recovery": "Erholung", - "vorsorge": "Vorsorge" + "vorsorge": "Vorsorge", + "illness": "Krankheit" }, "auth": { "login": "Anmelden", @@ -6606,6 +6607,84 @@ "medications": { "label": "Medikamente", "description": "Pläne, Einnahmen und Compliance." + }, + "illness": { + "label": "Krankheitstagebuch", + "description": "Krankheits- und Beschwerde-Episoden mit täglichem Symptomverlauf festhalten." + } + }, + "illness": { + "title": "Krankheitstagebuch", + "subtitle": "Ein rückblickendes Protokoll deiner Krankheits- und Beschwerde-Episoden.", + "disclaimer": "Ein persönliches Tagebuch — kein Medizinprodukt. Es stellt keine Diagnose und keine Prognose.", + "newEpisode": "Neue Episode", + "logDay": "Tag erfassen", + "markRecovered": "Als genesen markieren", + "onsetOn": "Begonnen am {date}", + "recoveredOn": "genesen {date}", + "status": { + "active": "Aktiv", + "ongoing": "Andauernd", + "recovered": "Genesen" + }, + "empty": { + "title": "Noch keine Episoden", + "body": "Beginne eine Episode, um einen täglichen Symptomverlauf zu führen und nachzusehen, wie sie verlief." + }, + "type": { + "INFECTION": "Infektion", + "ALLERGY": "Allergie", + "INJURY": "Verletzung", + "MENTAL_HEALTH": "Psychische Gesundheit", + "AUTOIMMUNE": "Autoimmun", + "CHRONIC": "Chronisch", + "OTHER": "Sonstiges" + }, + "lifecycle": { + "ACUTE": "Akut", + "CHRONIC_ONGOING": "Chronisch / andauernd", + "RECURRING": "Wiederkehrend", + "FLARE": "Schub" + }, + "new": { + "title": "Neue Episode", + "description": "Erfasse eine Krankheit oder Beschwerde. Tägliche Symptome kannst du danach festhalten.", + "label": "Bezeichnung", + "labelPlaceholder": "z. B. Erkältung", + "type": "Art", + "lifecycle": "Verlauf", + "onset": "Begonnen am", + "note": "Notiz", + "notePlaceholder": "Was erwähnenswert ist (verschlüsselt).", + "saveError": "Die Episode konnte nicht gespeichert werden. Bitte erneut versuchen." + }, + "sheet": { + "title": "{date} erfassen", + "description": "Halte die heutigen Symptome, deine Belastbarkeit und ggf. Fieber fest.", + "symptoms": "Symptome", + "functionalImpact": "Belastbarkeit", + "fever": "Temperatur", + "note": "Notiz", + "noteSet": "Gesetzt", + "notePlaceholder": "Wie sich der Tag anfühlte (verschlüsselt).", + "severityFor": "Stärke für {symptom}", + "saveError": "Der Tag konnte nicht gespeichert werden. Bitte erneut versuchen." + }, + "impact": { + "0": "Voll belastbar", + "1": "Leicht eingeschränkt", + "2": "Überwiegend ruhend", + "3": "Bettlägerig" + }, + "symptom": { + "runnyNose": "Laufende Nase", + "stuffyNose": "Verstopfte Nase", + "sneezing": "Niesen", + "soreThroat": "Halsschmerzen", + "cough": "Husten", + "headache": "Kopfschmerzen", + "bodyAches": "Gliederschmerzen", + "fatigue": "Erschöpfung" } } } diff --git a/messages/en.json b/messages/en.json index ce511ebb8..9ae9374d1 100644 --- a/messages/en.json +++ b/messages/en.json @@ -266,7 +266,8 @@ "cycle": "Cycle", "labs": "Labs", "recovery": "Recovery", - "vorsorge": "Preventive Care" + "vorsorge": "Preventive Care", + "illness": "Illness" }, "auth": { "login": "Sign in", @@ -6606,6 +6607,84 @@ "medications": { "label": "Medications", "description": "Schedules, intakes, and compliance." + }, + "illness": { + "label": "Illness journal", + "description": "Track illness and condition episodes with a daily symptom timeline." + } + }, + "illness": { + "title": "Illness journal", + "subtitle": "A retrospective log of your illness and condition episodes.", + "disclaimer": "A personal journal — not a medical device. It does not diagnose or predict.", + "newEpisode": "New episode", + "logDay": "Log a day", + "markRecovered": "Mark recovered", + "onsetOn": "Started {date}", + "recoveredOn": "recovered {date}", + "status": { + "active": "Active", + "ongoing": "Ongoing", + "recovered": "Recovered" + }, + "empty": { + "title": "No episodes yet", + "body": "Start an episode to keep a daily symptom timeline and look back on how it unfolded." + }, + "type": { + "INFECTION": "Infection", + "ALLERGY": "Allergy", + "INJURY": "Injury", + "MENTAL_HEALTH": "Mental health", + "AUTOIMMUNE": "Autoimmune", + "CHRONIC": "Chronic", + "OTHER": "Other" + }, + "lifecycle": { + "ACUTE": "Acute", + "CHRONIC_ONGOING": "Chronic / ongoing", + "RECURRING": "Recurring", + "FLARE": "Flare" + }, + "new": { + "title": "New episode", + "description": "Record an illness or condition. You can log daily symptoms afterwards.", + "label": "Name", + "labelPlaceholder": "e.g. Common cold", + "type": "Type", + "lifecycle": "Course", + "onset": "Started on", + "note": "Note", + "notePlaceholder": "Anything worth remembering (encrypted).", + "saveError": "Could not save the episode. Please try again." + }, + "sheet": { + "title": "Log {date}", + "description": "Record today's symptoms, how you function, and any fever.", + "symptoms": "Symptoms", + "functionalImpact": "How you function", + "fever": "Temperature", + "note": "Note", + "noteSet": "Set", + "notePlaceholder": "How the day felt (encrypted).", + "severityFor": "Severity for {symptom}", + "saveError": "Could not save the day. Please try again." + }, + "impact": { + "0": "Fully functional", + "1": "Slightly limited", + "2": "Mostly resting", + "3": "Bedbound" + }, + "symptom": { + "runnyNose": "Runny nose", + "stuffyNose": "Stuffy nose", + "sneezing": "Sneezing", + "soreThroat": "Sore throat", + "cough": "Cough", + "headache": "Headache", + "bodyAches": "Body aches", + "fatigue": "Fatigue" } } } diff --git a/messages/es.json b/messages/es.json index 083fee1bc..9dec9c8d7 100644 --- a/messages/es.json +++ b/messages/es.json @@ -266,7 +266,8 @@ "cycle": "Ciclo", "labs": "Análisis", "recovery": "Recuperación", - "vorsorge": "Prevención" + "vorsorge": "Prevención", + "illness": "Enfermedad" }, "auth": { "login": "Iniciar sesión", @@ -6606,6 +6607,84 @@ "medications": { "label": "Medicamentos", "description": "Pautas, tomas y cumplimiento." + }, + "illness": { + "label": "Diario de enfermedad", + "description": "Registra episodios de enfermedad y dolencias con un historial diario de síntomas." + } + }, + "illness": { + "title": "Diario de enfermedad", + "subtitle": "Un registro retrospectivo de tus episodios de enfermedad y dolencias.", + "disclaimer": "Un diario personal, no un dispositivo médico. No diagnostica ni predice.", + "newEpisode": "Nuevo episodio", + "logDay": "Registrar un día", + "markRecovered": "Marcar como recuperado", + "onsetOn": "Comenzó el {date}", + "recoveredOn": "recuperado {date}", + "status": { + "active": "Activo", + "ongoing": "En curso", + "recovered": "Recuperado" + }, + "empty": { + "title": "Aún no hay episodios", + "body": "Inicia un episodio para llevar un historial diario de síntomas y revisar cómo evolucionó." + }, + "type": { + "INFECTION": "Infección", + "ALLERGY": "Alergia", + "INJURY": "Lesión", + "MENTAL_HEALTH": "Salud mental", + "AUTOIMMUNE": "Autoinmune", + "CHRONIC": "Crónica", + "OTHER": "Otro" + }, + "lifecycle": { + "ACUTE": "Aguda", + "CHRONIC_ONGOING": "Crónica / en curso", + "RECURRING": "Recurrente", + "FLARE": "Brote" + }, + "new": { + "title": "Nuevo episodio", + "description": "Registra una enfermedad o dolencia. Luego podrás anotar síntomas diarios.", + "label": "Nombre", + "labelPlaceholder": "p. ej. Resfriado común", + "type": "Tipo", + "lifecycle": "Curso", + "onset": "Comenzó el", + "note": "Nota", + "notePlaceholder": "Algo que valga la pena recordar (cifrado).", + "saveError": "No se pudo guardar el episodio. Inténtalo de nuevo." + }, + "sheet": { + "title": "Registrar {date}", + "description": "Anota los síntomas de hoy, cómo funcionas y la fiebre, si la hay.", + "symptoms": "Síntomas", + "functionalImpact": "Cómo funcionas", + "fever": "Temperatura", + "note": "Nota", + "noteSet": "Definida", + "notePlaceholder": "Cómo te sentiste hoy (cifrado).", + "severityFor": "Gravedad de {symptom}", + "saveError": "No se pudo guardar el día. Inténtalo de nuevo." + }, + "impact": { + "0": "Plenamente funcional", + "1": "Algo limitado", + "2": "Mayormente en reposo", + "3": "En cama" + }, + "symptom": { + "runnyNose": "Goteo nasal", + "stuffyNose": "Congestión nasal", + "sneezing": "Estornudos", + "soreThroat": "Dolor de garganta", + "cough": "Tos", + "headache": "Dolor de cabeza", + "bodyAches": "Dolores musculares", + "fatigue": "Fatiga" } } } diff --git a/messages/fr.json b/messages/fr.json index 9b837a2c6..5198c05db 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -266,7 +266,8 @@ "cycle": "Cycle", "labs": "Analyses", "recovery": "Récupération", - "vorsorge": "Prévention" + "vorsorge": "Prévention", + "illness": "Maladie" }, "auth": { "login": "Se connecter", @@ -6606,6 +6607,84 @@ "medications": { "label": "Médicaments", "description": "Schémas, prises et observance." + }, + "illness": { + "label": "Journal de maladie", + "description": "Suivez les épisodes de maladie et d'affections avec un historique quotidien des symptômes." + } + }, + "illness": { + "title": "Journal de maladie", + "subtitle": "Un suivi rétrospectif de vos épisodes de maladie et d'affections.", + "disclaimer": "Un journal personnel, pas un dispositif médical. Il ne diagnostique ni ne prédit.", + "newEpisode": "Nouvel épisode", + "logDay": "Enregistrer un jour", + "markRecovered": "Marquer comme rétabli", + "onsetOn": "Débuté le {date}", + "recoveredOn": "rétabli {date}", + "status": { + "active": "En cours", + "ongoing": "Persistant", + "recovered": "Rétabli" + }, + "empty": { + "title": "Aucun épisode pour l'instant", + "body": "Démarrez un épisode pour tenir un historique quotidien des symptômes et revoir son évolution." + }, + "type": { + "INFECTION": "Infection", + "ALLERGY": "Allergie", + "INJURY": "Blessure", + "MENTAL_HEALTH": "Santé mentale", + "AUTOIMMUNE": "Auto-immune", + "CHRONIC": "Chronique", + "OTHER": "Autre" + }, + "lifecycle": { + "ACUTE": "Aiguë", + "CHRONIC_ONGOING": "Chronique / persistante", + "RECURRING": "Récurrente", + "FLARE": "Poussée" + }, + "new": { + "title": "Nouvel épisode", + "description": "Enregistrez une maladie ou une affection. Vous pourrez ensuite noter les symptômes au quotidien.", + "label": "Nom", + "labelPlaceholder": "p. ex. Rhume", + "type": "Type", + "lifecycle": "Évolution", + "onset": "Débuté le", + "note": "Note", + "notePlaceholder": "Ce qui mérite d'être noté (chiffré).", + "saveError": "Impossible d'enregistrer l'épisode. Veuillez réessayer." + }, + "sheet": { + "title": "Enregistrer {date}", + "description": "Notez les symptômes du jour, votre forme et une éventuelle fièvre.", + "symptoms": "Symptômes", + "functionalImpact": "Votre forme", + "fever": "Température", + "note": "Note", + "noteSet": "Définie", + "notePlaceholder": "Comment s'est passée la journée (chiffré).", + "severityFor": "Intensité de {symptom}", + "saveError": "Impossible d'enregistrer la journée. Veuillez réessayer." + }, + "impact": { + "0": "Pleinement fonctionnel", + "1": "Légèrement limité", + "2": "Surtout au repos", + "3": "Alité" + }, + "symptom": { + "runnyNose": "Nez qui coule", + "stuffyNose": "Nez bouché", + "sneezing": "Éternuements", + "soreThroat": "Mal de gorge", + "cough": "Toux", + "headache": "Mal de tête", + "bodyAches": "Courbatures", + "fatigue": "Fatigue" } } } diff --git a/messages/it.json b/messages/it.json index 23305e7da..9c7d78e1c 100644 --- a/messages/it.json +++ b/messages/it.json @@ -266,7 +266,8 @@ "cycle": "Ciclo", "labs": "Esami", "recovery": "Recupero", - "vorsorge": "Prevenzione" + "vorsorge": "Prevenzione", + "illness": "Malattia" }, "auth": { "login": "Accedi", @@ -6606,6 +6607,84 @@ "medications": { "label": "Farmaci", "description": "Schemi, assunzioni e aderenza." + }, + "illness": { + "label": "Diario delle malattie", + "description": "Registra episodi di malattia e disturbi con una cronologia giornaliera dei sintomi." + } + }, + "illness": { + "title": "Diario delle malattie", + "subtitle": "Un registro retrospettivo dei tuoi episodi di malattia e disturbi.", + "disclaimer": "Un diario personale, non un dispositivo medico. Non diagnostica né prevede.", + "newEpisode": "Nuovo episodio", + "logDay": "Registra un giorno", + "markRecovered": "Segna come guarito", + "onsetOn": "Iniziato il {date}", + "recoveredOn": "guarito {date}", + "status": { + "active": "Attivo", + "ongoing": "In corso", + "recovered": "Guarito" + }, + "empty": { + "title": "Ancora nessun episodio", + "body": "Avvia un episodio per tenere una cronologia giornaliera dei sintomi e rivedere come si è evoluto." + }, + "type": { + "INFECTION": "Infezione", + "ALLERGY": "Allergia", + "INJURY": "Infortunio", + "MENTAL_HEALTH": "Salute mentale", + "AUTOIMMUNE": "Autoimmune", + "CHRONIC": "Cronica", + "OTHER": "Altro" + }, + "lifecycle": { + "ACUTE": "Acuta", + "CHRONIC_ONGOING": "Cronica / in corso", + "RECURRING": "Ricorrente", + "FLARE": "Riacutizzazione" + }, + "new": { + "title": "Nuovo episodio", + "description": "Registra una malattia o un disturbo. Potrai annotare i sintomi giornalieri in seguito.", + "label": "Nome", + "labelPlaceholder": "es. Raffreddore", + "type": "Tipo", + "lifecycle": "Decorso", + "onset": "Iniziato il", + "note": "Nota", + "notePlaceholder": "Qualcosa da ricordare (cifrato).", + "saveError": "Impossibile salvare l'episodio. Riprova." + }, + "sheet": { + "title": "Registra {date}", + "description": "Annota i sintomi di oggi, come funzioni e l'eventuale febbre.", + "symptoms": "Sintomi", + "functionalImpact": "Come funzioni", + "fever": "Temperatura", + "note": "Nota", + "noteSet": "Impostata", + "notePlaceholder": "Com'è andata la giornata (cifrato).", + "severityFor": "Intensità di {symptom}", + "saveError": "Impossibile salvare la giornata. Riprova." + }, + "impact": { + "0": "Pienamente funzionale", + "1": "Leggermente limitato", + "2": "Per lo più a riposo", + "3": "A letto" + }, + "symptom": { + "runnyNose": "Naso che cola", + "stuffyNose": "Naso chiuso", + "sneezing": "Starnuti", + "soreThroat": "Mal di gola", + "cough": "Tosse", + "headache": "Mal di testa", + "bodyAches": "Dolori muscolari", + "fatigue": "Affaticamento" } } } diff --git a/messages/pl.json b/messages/pl.json index 8e1ff3360..97a41f456 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -266,7 +266,8 @@ "cycle": "Cykl", "labs": "Wyniki badań", "recovery": "Regeneracja", - "vorsorge": "Profilaktyka" + "vorsorge": "Profilaktyka", + "illness": "Choroba" }, "auth": { "login": "Zaloguj się", @@ -6606,6 +6607,84 @@ "medications": { "label": "Leki", "description": "Schematy, przyjęcia i zgodność." + }, + "illness": { + "label": "Dziennik chorób", + "description": "Zapisuj epizody chorób i dolegliwości z dziennym przebiegiem objawów." + } + }, + "illness": { + "title": "Dziennik chorób", + "subtitle": "Retrospektywny zapis Twoich epizodów chorób i dolegliwości.", + "disclaimer": "Osobisty dziennik — nie wyrób medyczny. Nie diagnozuje ani nie przewiduje.", + "newEpisode": "Nowy epizod", + "logDay": "Zapisz dzień", + "markRecovered": "Oznacz jako wyleczone", + "onsetOn": "Początek {date}", + "recoveredOn": "wyleczono {date}", + "status": { + "active": "Aktywny", + "ongoing": "Trwający", + "recovered": "Wyleczony" + }, + "empty": { + "title": "Brak epizodów", + "body": "Rozpocznij epizod, aby prowadzić dzienny przebieg objawów i prześledzić, jak się rozwijał." + }, + "type": { + "INFECTION": "Infekcja", + "ALLERGY": "Alergia", + "INJURY": "Uraz", + "MENTAL_HEALTH": "Zdrowie psychiczne", + "AUTOIMMUNE": "Autoimmunologiczna", + "CHRONIC": "Przewlekła", + "OTHER": "Inne" + }, + "lifecycle": { + "ACUTE": "Ostra", + "CHRONIC_ONGOING": "Przewlekła / trwająca", + "RECURRING": "Nawracająca", + "FLARE": "Zaostrzenie" + }, + "new": { + "title": "Nowy epizod", + "description": "Zapisz chorobę lub dolegliwość. Codzienne objawy możesz notować później.", + "label": "Nazwa", + "labelPlaceholder": "np. Przeziębienie", + "type": "Typ", + "lifecycle": "Przebieg", + "onset": "Początek", + "note": "Notatka", + "notePlaceholder": "Cokolwiek wartego zapamiętania (zaszyfrowane).", + "saveError": "Nie udało się zapisać epizodu. Spróbuj ponownie." + }, + "sheet": { + "title": "Zapisz {date}", + "description": "Zanotuj dzisiejsze objawy, swoją sprawność i ewentualną gorączkę.", + "symptoms": "Objawy", + "functionalImpact": "Sprawność", + "fever": "Temperatura", + "note": "Notatka", + "noteSet": "Ustawiona", + "notePlaceholder": "Jak minął dzień (zaszyfrowane).", + "severityFor": "Nasilenie: {symptom}", + "saveError": "Nie udało się zapisać dnia. Spróbuj ponownie." + }, + "impact": { + "0": "Pełna sprawność", + "1": "Lekko ograniczona", + "2": "Głównie odpoczynek", + "3": "W łóżku" + }, + "symptom": { + "runnyNose": "Katar", + "stuffyNose": "Zatkany nos", + "sneezing": "Kichanie", + "soreThroat": "Ból gardła", + "cough": "Kaszel", + "headache": "Ból głowy", + "bodyAches": "Bóle ciała", + "fatigue": "Zmęczenie" } } } diff --git a/src/app/api/__tests__/module-route-gate-inventory.test.ts b/src/app/api/__tests__/module-route-gate-inventory.test.ts index 91447908d..9e6a3c83d 100644 --- a/src/app/api/__tests__/module-route-gate-inventory.test.ts +++ b/src/app/api/__tests__/module-route-gate-inventory.test.ts @@ -75,6 +75,14 @@ const MODULE_ROUTE_TREES: ReadonlyArray = [ "src/app/api/workouts", "src/app/api/labs", "src/app/api/cycle", + // v1.18.1 (W-B) — the illness/condition journal. Every `/api/illness/*` + // route gates on the born-gated `illness` module via the thin + // `requireIllnessEnabled(...)` wrapper (which re-stamps the + // illness-specific errorCode over `requireModuleEnabled("illness")`), + // recognised below as a delegated gate. Walking the tree means a NEW + // ungated illness route fails this test BY NAME rather than leaking the + // surface over a Bearer token when the account never opted in. + "src/app/api/illness", "src/app/api/gamification", // v1.18.0 B3 — the legacy `/api/doctor-report` tree (JSON + server-PDF + // availability probe) was orphaned dead code (no production caller) and @@ -168,6 +176,12 @@ const EXEMPT_ROUTES: ReadonlyArray = [ const MODULE_GATE_NEEDLE = "requireModuleEnabled("; const CYCLE_GATE_NEEDLE = "requireCycleEnabled("; const COACH_GATE_NEEDLE = 'requireAssistantSurface("coach")'; +// v1.18.1 — the illness journal's thin gate wrapper. `requireIllnessEnabled` +// delegates to `requireModuleEnabled(userId, "illness")` and re-stamps the +// illness-specific errorCode, exactly mirroring how `cycle` delegates to +// `requireCycleEnabled`. Recognised as a delegated gate so illness routes +// are not flagged as ungated. +const ILLNESS_GATE_NEEDLE = "requireIllnessEnabled("; // Builder aggregators that resolve `resolveModuleMap` once and exclude // disabled-module sections/resources at the build boundary. const BUILDER_GATE_NEEDLES: ReadonlyArray = [ @@ -251,6 +265,7 @@ describe("module API route gate inventory", () => { "workouts", "recovery", "labs", + "illness", "achievements", "cycle", "coach", @@ -276,6 +291,7 @@ describe("module API route gate inventory", () => { if (fileHasCall(text, MODULE_GATE_NEEDLE)) continue; if (fileHasCall(text, CYCLE_GATE_NEEDLE)) continue; if (fileHasCall(text, COACH_GATE_NEEDLE)) continue; + if (fileHasCall(text, ILLNESS_GATE_NEEDLE)) continue; if (BUILDER_GATE_NEEDLES.some((n) => fileHasCall(text, n))) continue; if (exempt.has(path)) continue; @@ -346,6 +362,7 @@ describe("module API route gate inventory", () => { fileHasCall(text, MODULE_GATE_NEEDLE) || fileHasCall(text, CYCLE_GATE_NEEDLE) || fileHasCall(text, COACH_GATE_NEEDLE) || + fileHasCall(text, ILLNESS_GATE_NEEDLE) || BUILDER_GATE_NEEDLES.some((n) => fileHasCall(text, n)) ) { stillGated.push(path); diff --git a/src/app/api/illness/episodes/[id]/day-logs/route.ts b/src/app/api/illness/episodes/[id]/day-logs/route.ts new file mode 100644 index 000000000..ad0f82437 --- /dev/null +++ b/src/app/api/illness/episodes/[id]/day-logs/route.ts @@ -0,0 +1,140 @@ +/** + * `GET /api/illness/episodes/{id}/day-logs?date=YYYY-MM-DD` — the one + * day-log for the episode + day, or `null` when nothing is logged + * (lets the log-day sheet pre-fill). + * `POST /api/illness/episodes/{id}/day-logs` — upsert the day's symptom / + * functional-impact / fever timeline row. Upserts on + * `(episodeId, date)`: 201 on insert, 200 on update. + * + * Born-gated + owner-scoped (the episode must be owned + live). `note` is + * encrypted at rest; `userId` is narrowed from auth, never a body field. + */ +import { NextRequest } from "next/server"; + +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { + apiSuccess, + apiError, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { requireIllnessEnabled } from "@/lib/illness/gate"; +import { + illnessDayLogInputSchema, + illnessDayLogQuerySchema, +} from "@/lib/validations/illness"; +import { upsertIllnessDayLog } from "@/lib/illness/day-log-write"; +import { toIllnessDayLogDTO, dayLogSymptomInclude } from "@/lib/illness/dto"; +import { DEFAULT_TIMEZONE } from "@/lib/mood/date-key"; + +type RouteParams = { params: Promise<{ id: string }> }; + +/** Resolve the episode, 404ing unless it is owned + live. */ +async function loadOwnedEpisode( + episodeId: string, + userId: string, +): Promise<{ ok: true } | { ok: false; response: Response }> { + const episode = await prisma.illnessEpisode.findUnique({ + where: { id: episodeId }, + select: { userId: true, deletedAt: true }, + }); + if (!episode || episode.userId !== userId || episode.deletedAt !== null) { + return { ok: false, response: apiError("Episode not found", 404) }; + } + return { ok: true }; +} + +export const GET = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + + const gate = await requireIllnessEnabled(user.id); + if (!gate.enabled) return gate.response; + + const { id } = await params; + const owned = await loadOwnedEpisode(id, user.id); + if (!owned.ok) return owned.response; + + const parsed = illnessDayLogQuerySchema.safeParse({ + date: new URL(request.url).searchParams.get("date"), + }); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422, { + errorCode: "illness.day-log.invalid", + }); + } + + const row = await prisma.illnessDayLog.findFirst({ + where: { episodeId: id, date: parsed.data.date, deletedAt: null }, + include: dayLogSymptomInclude, + }); + + annotate({ + action: { + name: "illness.day-log.read", + entity_type: "illness_day_log", + }, + meta: { found: row !== null }, + }); + + return apiSuccess(row ? toIllnessDayLogDTO(row) : null); + }, +); + +export const POST = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + + const gate = await requireIllnessEnabled(user.id); + if (!gate.enabled) return gate.response; + + const { id } = await params; + const owned = await loadOwnedEpisode(id, user.id); + if (!owned.ok) return owned.response; + + const { data: rawBody, error: jsonError } = await safeJson(request, { + maxBytes: 16 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = illnessDayLogInputSchema.safeParse(rawBody); + if (!parsed.success) { + annotate({ + action: { name: "illness.day-log.validation-failed" }, + meta: { issue_count: parsed.error.issues.length }, + }); + return returnAllZodIssues(parsed.error, 422, { + errorCode: "illness.day-log.invalid", + }); + } + + const tz = user.timezone ?? DEFAULT_TIMEZONE; + const result = await upsertIllnessDayLog(user.id, id, parsed.data, tz); + + const row = await prisma.illnessDayLog.findUniqueOrThrow({ + where: { id: result.id }, + include: dayLogSymptomInclude, + }); + + await auditLog("illness.day-log.upsert", { + userId: user.id, + ipAddress: getClientIp(request), + details: { dayLogId: result.id, episodeId: id, existed: result.existed }, + }); + + annotate({ + action: { + name: "illness.day-log.upsert", + entity_type: "illness_day_log", + entity_id: result.id, + }, + meta: { existed: result.existed }, + }); + + return apiSuccess(toIllnessDayLogDTO(row), result.existed ? 200 : 201); + }, +); diff --git a/src/app/api/illness/episodes/[id]/resolve/route.ts b/src/app/api/illness/episodes/[id]/resolve/route.ts new file mode 100644 index 000000000..bcae413c3 --- /dev/null +++ b/src/app/api/illness/episodes/[id]/resolve/route.ts @@ -0,0 +1,87 @@ +/** + * `PATCH /api/illness/episodes/{id}/resolve` — mark an episode recovered. + * + * Sets `resolvedAt` (defaults to "now" when the body omits it). A + * dedicated verb so the history surface's one-tap "mark recovered" never + * has to send the full edit body. Born-gated + owner-scoped. A + * CHRONIC_ONGOING episode has no "recovered" date by design — resolving + * one is refused. + */ +import { NextRequest } from "next/server"; + +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { + apiSuccess, + apiError, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { requireIllnessEnabled } from "@/lib/illness/gate"; +import { illnessEpisodeResolveSchema } from "@/lib/validations/illness"; +import { toIllnessEpisodeDTO } from "@/lib/illness/dto"; + +type RouteParams = { params: Promise<{ id: string }> }; + +export const PATCH = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + + const gate = await requireIllnessEnabled(user.id); + if (!gate.enabled) return gate.response; + + const { id } = await params; + const existing = await prisma.illnessEpisode.findUnique({ + where: { id }, + select: { id: true, userId: true, deletedAt: true, lifecycle: true }, + }); + if (!existing || existing.userId !== user.id || existing.deletedAt !== null) { + return apiError("Episode not found", 404); + } + if (existing.lifecycle === "CHRONIC_ONGOING") { + return apiError("A chronic-ongoing condition has no recovery date", 422, { + errorCode: "illness.episode.chronic-no-resolve", + }); + } + + const { data: rawBody, error: jsonError } = await safeJson(request, { + maxBytes: 4 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = illnessEpisodeResolveSchema.safeParse(rawBody ?? {}); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422, { + errorCode: "illness.episode.invalid", + }); + } + + const resolvedAt = parsed.data.resolvedAt + ? new Date(parsed.data.resolvedAt) + : new Date(); + + const updated = await prisma.illnessEpisode.update({ + where: { id }, + data: { resolvedAt }, + }); + + await auditLog("illness.episode.resolve", { + userId: user.id, + ipAddress: getClientIp(request), + details: { episodeId: id }, + }); + + annotate({ + action: { + name: "illness.episode.resolve", + entity_type: "illness_episode", + entity_id: id, + }, + }); + + return apiSuccess(toIllnessEpisodeDTO(updated)); + }, +); diff --git a/src/app/api/illness/episodes/[id]/route.ts b/src/app/api/illness/episodes/[id]/route.ts new file mode 100644 index 000000000..fcf29fa78 --- /dev/null +++ b/src/app/api/illness/episodes/[id]/route.ts @@ -0,0 +1,178 @@ +/** + * `GET /api/illness/episodes/{id}` — one owned, live episode. + * `PATCH /api/illness/episodes/{id}` — edit it (partial; field-by-field). + * `DELETE /api/illness/episodes/{id}` — soft-delete it (idempotent, 204). + * + * Born-gated + owner-scoped. `userId` is narrowed from auth and fed to the + * Prisma `where`; the body never carries it. `note` is encrypted at rest. + */ +import { NextRequest } from "next/server"; + +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { + apiSuccess, + apiError, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { requireIllnessEnabled } from "@/lib/illness/gate"; +import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; +import { illnessEpisodeUpdateSchema } from "@/lib/validations/illness"; +import { toIllnessEpisodeDTO } from "@/lib/illness/dto"; +import type { Prisma } from "@/generated/prisma/client"; + +type RouteParams = { params: Promise<{ id: string }> }; + +export const GET = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + + const gate = await requireIllnessEnabled(user.id); + if (!gate.enabled) return gate.response; + + const { id } = await params; + const row = await prisma.illnessEpisode.findUnique({ where: { id } }); + if (!row || row.userId !== user.id || row.deletedAt !== null) { + return apiError("Episode not found", 404); + } + + annotate({ + action: { + name: "illness.episode.read", + entity_type: "illness_episode", + entity_id: id, + }, + }); + + return apiSuccess(toIllnessEpisodeDTO(row)); + }, +); + +export const PATCH = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + + const gate = await requireIllnessEnabled(user.id); + if (!gate.enabled) return gate.response; + + const { id } = await params; + const existing = await prisma.illnessEpisode.findUnique({ + where: { id }, + select: { id: true, userId: true, deletedAt: true }, + }); + if (!existing || existing.userId !== user.id || existing.deletedAt !== null) { + return apiError("Episode not found", 404); + } + + const { data: rawBody, error: jsonError } = await safeJson(request, { + maxBytes: 16 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = illnessEpisodeUpdateSchema.safeParse(rawBody); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422, { + errorCode: "illness.episode.invalid", + }); + } + const entry = parsed.data; + + // A re-parent must point at an owned, live episode. + if (entry.parentConditionId) { + const parent = await prisma.illnessEpisode.findUnique({ + where: { id: entry.parentConditionId }, + select: { userId: true, deletedAt: true }, + }); + if ( + entry.parentConditionId === id || + !parent || + parent.userId !== user.id || + parent.deletedAt !== null + ) { + return apiError("Parent condition not found", 404, { + errorCode: "illness.episode.parent-not-found", + }); + } + } + + // Field-by-field — only fields actually present in the body are written. + const data: Prisma.IllnessEpisodeUpdateInput = {}; + if (entry.label !== undefined) data.label = entry.label; + if (entry.type !== undefined) data.type = entry.type; + if (entry.lifecycle !== undefined) data.lifecycle = entry.lifecycle; + if (entry.onsetAt !== undefined) data.onsetAt = new Date(entry.onsetAt); + if (entry.resolvedAt !== undefined) { + data.resolvedAt = entry.resolvedAt ? new Date(entry.resolvedAt) : null; + } + if (entry.parentConditionId !== undefined) { + data.parent = entry.parentConditionId + ? { connect: { id: entry.parentConditionId } } + : { disconnect: true }; + } + if (entry.note !== undefined) { + data.noteEncrypted = entry.note ? encryptToBytes(entry.note) : null; + } + + const updated = await prisma.illnessEpisode.update({ where: { id }, data }); + + await auditLog("illness.episode.update", { + userId: user.id, + ipAddress: getClientIp(request), + details: { episodeId: id }, + }); + + annotate({ + action: { + name: "illness.episode.update", + entity_type: "illness_episode", + entity_id: id, + }, + }); + + return apiSuccess(toIllnessEpisodeDTO(updated)); + }, +); + +export const DELETE = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + + const gate = await requireIllnessEnabled(user.id); + if (!gate.enabled) return gate.response; + + const { id } = await params; + const existing = await prisma.illnessEpisode.findUnique({ + where: { id }, + select: { id: true, userId: true, deletedAt: true }, + }); + if (!existing || existing.userId !== user.id) { + return apiError("Episode not found", 404); + } + + if (existing.deletedAt === null) { + await prisma.illnessEpisode.update({ + where: { id }, + data: { deletedAt: new Date() }, + }); + await auditLog("illness.episode.delete", { + userId: user.id, + ipAddress: getClientIp(request), + details: { episodeId: id }, + }); + } + + annotate({ + action: { + name: "illness.episode.delete", + entity_type: "illness_episode", + entity_id: id, + }, + }); + + return new Response(null, { status: 204 }); + }, +); diff --git a/src/app/api/illness/episodes/route.ts b/src/app/api/illness/episodes/route.ts new file mode 100644 index 000000000..690465643 --- /dev/null +++ b/src/app/api/illness/episodes/route.ts @@ -0,0 +1,138 @@ +/** + * `GET /api/illness/episodes` — newest-first list of the account's + * illness/condition episodes. + * `POST /api/illness/episodes` — create one episode. + * + * The illness journal is a CONDITION journal, retrospective-only — NEVER a + * predictor/diagnoser. Born-gated: a non-opted-in account (or an + * operator-disabled instance) 403s with `errorCode:"illness.disabled"` + * even with a valid Bearer token. `userId` is narrowed from auth and fed + * to the Prisma `where`; it is never a body field. The `data` object is + * built field-by-field (no mass assignment); `note` is encrypted at rest. + */ +import { NextRequest } from "next/server"; + +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { + apiSuccess, + apiError, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { requireIllnessEnabled } from "@/lib/illness/gate"; +import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; +import { + illnessEpisodeCreateSchema, + illnessEpisodeListQuerySchema, +} from "@/lib/validations/illness"; +import { toIllnessEpisodeDTO } from "@/lib/illness/dto"; + +export const GET = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + + const gate = await requireIllnessEnabled(user.id); + if (!gate.enabled) return gate.response; + + const params = new URL(request.url).searchParams; + const parsed = illnessEpisodeListQuerySchema.safeParse({ + limit: params.get("limit") ?? undefined, + includeResolved: params.get("includeResolved") ?? undefined, + }); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422, { + errorCode: "illness.episode.invalid", + }); + } + + const limit = parsed.data.limit ?? 50; + const rows = await prisma.illnessEpisode.findMany({ + where: { + userId: user.id, + deletedAt: null, + ...(parsed.data.includeResolved === "false" ? { resolvedAt: null } : {}), + }, + orderBy: { onsetAt: "desc" }, + take: limit, + }); + + annotate({ + action: { name: "illness.episode.list", entity_type: "illness_episode" }, + meta: { count: rows.length }, + }); + + return apiSuccess(rows.map(toIllnessEpisodeDTO)); +}); + +export const POST = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + + const gate = await requireIllnessEnabled(user.id); + if (!gate.enabled) return gate.response; + + const { data: rawBody, error: jsonError } = await safeJson(request, { + maxBytes: 16 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = illnessEpisodeCreateSchema.safeParse(rawBody); + if (!parsed.success) { + annotate({ + action: { name: "illness.episode.validation-failed" }, + meta: { issue_count: parsed.error.issues.length }, + }); + return returnAllZodIssues(parsed.error, 422, { + errorCode: "illness.episode.invalid", + }); + } + + const entry = parsed.data; + + // A FLARE/RECURRING bout may hang off a parent condition — verify the + // parent is owned + live before threading. + if (entry.parentConditionId) { + const parent = await prisma.illnessEpisode.findUnique({ + where: { id: entry.parentConditionId }, + select: { userId: true, deletedAt: true }, + }); + if (!parent || parent.userId !== user.id || parent.deletedAt !== null) { + return apiError("Parent condition not found", 404, { + errorCode: "illness.episode.parent-not-found", + }); + } + } + + // Field-by-field — never spread the parsed object whole. + const created = await prisma.illnessEpisode.create({ + data: { + userId: user.id, + label: entry.label, + type: entry.type, + lifecycle: entry.lifecycle, + onsetAt: entry.onsetAt ? new Date(entry.onsetAt) : new Date(), + resolvedAt: entry.resolvedAt ? new Date(entry.resolvedAt) : null, + parentConditionId: entry.parentConditionId ?? null, + noteEncrypted: entry.note ? encryptToBytes(entry.note) : null, + }, + }); + + await auditLog("illness.episode.create", { + userId: user.id, + ipAddress: getClientIp(request), + details: { episodeId: created.id, type: created.type }, + }); + + annotate({ + action: { + name: "illness.episode.create", + entity_type: "illness_episode", + entity_id: created.id, + }, + meta: { type: created.type, lifecycle: created.lifecycle }, + }); + + return apiSuccess(toIllnessEpisodeDTO(created), 201); +}); diff --git a/src/app/illness/page.tsx b/src/app/illness/page.tsx new file mode 100644 index 000000000..ec16578b3 --- /dev/null +++ b/src/app/illness/page.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { Loader2 } from "lucide-react"; + +import { useAuth } from "@/hooks/use-auth"; +import { IllnessView } from "@/components/illness/illness-view"; + +/** + * v1.18.1 — the illness / condition-journal entry. + * + * Born-gated on the resolved `modules.illness` flag from `GET /api/auth/me` + * (opt-in / default-off; the per-user opt-in AND the operator server-wide + * kill-switch). An unauthenticated visitor is bounced to login; an + * authenticated account without the module opted in is bounced home (the + * nav entry is already hidden for them, so this only catches a direct URL + * hit). Every `/api/illness/*` route also enforces the gate server-side, + * so this is a UX redirect, not the security boundary. + */ +export default function IllnessPage() { + const { user, isLoading, isAuthenticated } = useAuth(); + const router = useRouter(); + + // Born-gated: enabled ONLY when the resolved map says `true`. + const enabled = user?.modules?.illness === true; + + useEffect(() => { + if (isLoading) return; + if (!isAuthenticated) { + router.push("/auth/login"); + } else if (!enabled) { + router.push("/"); + } + }, [isLoading, isAuthenticated, enabled, router]); + + if (isLoading || !isAuthenticated || !enabled) { + return ( +
+ +
+ ); + } + + return ; +} diff --git a/src/components/illness/illness-view.tsx b/src/components/illness/illness-view.tsx new file mode 100644 index 000000000..deaa3c10d --- /dev/null +++ b/src/components/illness/illness-view.tsx @@ -0,0 +1,184 @@ +"use client"; + +/** + * v1.18.1 — the illness / condition-journal surface. + * + * A calm, retrospective journal: the episode history (newest first) with a + * neutral status Badge (active / recovered — never an alarming colour, the + * med-card rule generalised), a "log a day" entry into the day-log sheet, + * and a "mark recovered" one-tap. Retrospective-only copy: this is a + * journal, not a medical device, and it does not diagnose. No prediction + * UI by design. + */ +import { useState } from "react"; +import { Plus, Stethoscope } from "lucide-react"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { useTranslations } from "@/lib/i18n/context"; +import { useFormatters } from "@/lib/i18n/context"; + +import { LogDaySheet } from "./log-day-sheet"; +import { NewEpisodeSheet } from "./new-episode-sheet"; +import { + useDeleteEpisode, + useIllnessEpisodes, + useResolveEpisode, +} from "./use-illness"; +import type { IllnessEpisodeDTO } from "./types"; + +/** Today as a YYYY-MM-DD string in the viewer's local calendar. */ +function todayLocal(): string { + const now = new Date(); + const y = now.getFullYear(); + const m = String(now.getMonth() + 1).padStart(2, "0"); + const d = String(now.getDate()).padStart(2, "0"); + return `${y}-${m}-${d}`; +} + +function EpisodeRow({ + episode, + onLogDay, + onResolve, + onDelete, + resolving, +}: { + episode: IllnessEpisodeDTO; + onLogDay: (id: string) => void; + onResolve: (id: string) => void; + onDelete: (id: string) => void; + resolving: boolean; +}) { + const { t } = useTranslations(); + const fmt = useFormatters(); + const active = episode.resolvedAt === null; + const isChronic = episode.lifecycle === "CHRONIC_ONGOING"; + + return ( + +
+
+ {episode.label} + {t(`illness.type.${episode.type}`)} + + {active + ? isChronic + ? t("illness.status.ongoing") + : t("illness.status.active") + : t("illness.status.recovered")} + +
+

+ {t("illness.onsetOn", { date: fmt.dateShort(new Date(episode.onsetAt)) })} + {episode.resolvedAt + ? ` · ${t("illness.recoveredOn", { + date: fmt.dateShort(new Date(episode.resolvedAt)), + })}` + : ""} +

+
+
+ + {active && !isChronic ? ( + + ) : null} + +
+
+ ); +} + +export function IllnessView() { + const { t } = useTranslations(); + const { data: episodes, isLoading } = useIllnessEpisodes(true); + const resolve = useResolveEpisode(); + const del = useDeleteEpisode(); + + const today = todayLocal(); + const [newOpen, setNewOpen] = useState(false); + const [logEpisodeId, setLogEpisodeId] = useState(null); + + return ( +
+
+
+

+ {t("illness.title")} +

+

+ {t("illness.subtitle")} +

+
+ +
+ +

{t("illness.disclaimer")}

+ + {isLoading ? ( +

{t("common.loading")}

+ ) : episodes && episodes.length > 0 ? ( +
+ {episodes.map((episode) => ( + setLogEpisodeId(id)} + onResolve={(id) => resolve.mutate(id)} + onDelete={(id) => del.mutate(id)} + /> + ))} +
+ ) : ( + + +
+

{t("illness.empty.title")}

+

+ {t("illness.empty.body")} +

+
+ +
+ )} + + + {logEpisodeId ? ( + { + if (!open) setLogEpisodeId(null); + }} + episodeId={logEpisodeId} + date={today} + /> + ) : null} +
+ ); +} diff --git a/src/components/illness/log-day-sheet.tsx b/src/components/illness/log-day-sheet.tsx new file mode 100644 index 000000000..d2d309f0c --- /dev/null +++ b/src/components/illness/log-day-sheet.tsx @@ -0,0 +1,342 @@ +"use client"; + +/** + * v1.18.1 — illness day-log sheet. The daily symptom timeline for one + * episode-day: ~8 Jackson/WURSS symptom chips with a 0–3 severity, a + * coarse functional-impact chip group (0 = fully functional … 3 = + * bedbound), an optional fever reading, and an encrypted free-text note. + * + * Structurally a fork of the cycle `log-day-sheet`: the shared + * `ResponsiveSheet` + `SheetSection` primitives, a `Chip`/`SymptomChip` + * vocabulary, the envelope-aware pre-fill, and the `["illness"]`-prefix + * invalidation on write. Neutral palette throughout — no alarming colour; + * status reads via discreet text, never a red tint (the med-card rule + * generalised). + */ +import { useMemo, useState } from "react"; +import { Activity, NotebookPen, Thermometer } from "lucide-react"; + +import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; +import { + SheetSection, + SheetSectionCount, +} from "@/components/ui/sheet-section"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { cn } from "@/lib/utils"; +import { useTranslations } from "@/lib/i18n/context"; + +import { ILLNESS_SYMPTOM_CATALOG } from "./symptom-catalog"; +import { useIllnessDayLog, useUpsertDayLog } from "./use-illness"; +import type { IllnessDayLogInput } from "./types"; + +interface LogDaySheetProps { + open: boolean; + onOpenChange: (open: boolean) => void; + episodeId: string; + date: string; +} + +/** Mini toggle chip — the shared selection vocabulary. */ +function Chip({ + active, + onClick, + children, +}: { + active: boolean; + onClick: () => void; + children: React.ReactNode; +}) { + return ( + + ); +} + +/** A symptom chip with an inline 0–3 severity selector once active. */ +function SymptomChip({ + label, + icon: Icon, + active, + severity, + onToggle, + onSeverity, +}: { + label: string; + icon: React.ComponentType<{ className?: string }>; + active: boolean; + severity: number | null; + onToggle: () => void; + onSeverity: (value: number) => void; +}) { + const { t } = useTranslations(); + return ( +
+ + {active ? ( +
+ {[1, 2, 3].map((value) => ( + + ))} +
+ ) : null} +
+ ); +} + +export function LogDaySheet({ + open, + onOpenChange, + episodeId, + date, +}: LogDaySheetProps) { + const { t } = useTranslations(); + const dayLog = useIllnessDayLog(open ? episodeId : null, date); + const upsert = useUpsertDayLog(episodeId); + + const [symptoms, setSymptoms] = useState>( + new Map(), + ); + const [functionalImpact, setFunctionalImpact] = useState(null); + const [fever, setFever] = useState(""); + const [note, setNote] = useState(""); + + // Hydrate the form from the fetched DTO once per (open, date, fetched-row) + // tuple — React's recommended "adjust state during render keyed on a prop" + // alternative to a setState-in-effect (the cycle log-day-sheet precedent). + // `dto === null` (nothing logged) resets to blank. + const dto = open ? (dayLog.data ?? null) : undefined; + const formKey = open + ? `${episodeId}:${date}:${dayLog.isSuccess ? "loaded" : "loading"}` + : null; + const [lastFormKey, setLastFormKey] = useState(null); + if (open && formKey !== lastFormKey && !dayLog.isLoading) { + setLastFormKey(formKey); + setSymptoms( + new Map((dto?.symptoms ?? []).map((s) => [s.key, s.severity ?? null])), + ); + setFunctionalImpact(dto?.functionalImpact ?? null); + setFever(dto?.feverC != null ? String(dto.feverC) : ""); + setNote(dto?.note ?? ""); + } else if (!open && lastFormKey !== null) { + setLastFormKey(null); + } + + const symptomCount = symptoms.size; + + function toggleSymptom(key: string) { + setSymptoms((prev) => { + const next = new Map(prev); + if (next.has(key)) next.delete(key); + else next.set(key, null); + return next; + }); + } + + function setSeverity(key: string, value: number) { + setSymptoms((prev) => { + const next = new Map(prev); + next.set(key, prev.get(key) === value ? null : value); + return next; + }); + } + + const feverValue = useMemo(() => { + if (fever.trim() === "") return null; + const parsed = Number.parseFloat(fever.replace(",", ".")); + return Number.isFinite(parsed) ? parsed : null; + }, [fever]); + + async function handleSave() { + const input: IllnessDayLogInput = { + date, + functionalImpact, + feverC: feverValue, + symptoms: Array.from(symptoms.entries()).map(([key, severity]) => ({ + key, + severity: severity ?? undefined, + })), + note: note.trim() === "" ? null : note, + }; + try { + await upsert.mutateAsync(input); + onOpenChange(false); + } catch { + // Keep the sheet open; the error strip below reports it. + } + } + + const impactLabels = [ + t("illness.impact.0"), + t("illness.impact.1"), + t("illness.impact.2"), + t("illness.impact.3"), + ]; + + return ( + + + + + } + > +
+ } + summary={} + defaultOpen + > +
+ {ILLNESS_SYMPTOM_CATALOG.map((s) => ( + toggleSymptom(s.key)} + onSeverity={(value) => setSeverity(s.key, value)} + /> + ))} +
+
+ + } + summary={ + functionalImpact != null ? ( + + {impactLabels[functionalImpact]} + + ) : null + } + > +
+ {[0, 1, 2, 3].map((value) => ( + + setFunctionalImpact( + functionalImpact === value ? null : value, + ) + } + > + {impactLabels[value]} + + ))} +
+
+ + } + summary={ + feverValue != null ? ( + + {feverValue.toFixed(1)} °C + + ) : null + } + > +
+ + setFever(e.target.value)} + className="max-w-32" + /> +
+
+ + } + summary={ + note.trim() !== "" ? ( + + {t("illness.sheet.noteSet")} + + ) : null + } + > +
+