mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge pull request #338 from MBombeck/release/v1.18.3
v1.18.3 — condition timeline + restore, illness default-on, inventory unknown
This commit is contained in:
35 files changed
+1036
-210
No files matched your search
@@ -2,6 +2,24 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.18.3] — 2026-06-17 — the condition journal you can scroll back through, and a clearer view of what's tracked
|
||||
|
||||
A small follow-up that finishes a few rough edges from the previous releases. The condition journal's day-by-day timeline now scrolls through the whole illness, not just today; deleting a condition can be undone; the condition journal is on by default like every other area; and medication inventory that was never set now reads as "unknown" instead of a misleading zero. No breaking changes; no migration.
|
||||
|
||||
### Added
|
||||
|
||||
- **Restore a deleted condition.** Deleting a condition now offers an Undo — its day-by-day log and note come back exactly as they were, nothing is re-created from scratch.
|
||||
- **The full condition timeline.** A condition's detail page now lists every day you logged, newest first, instead of only today.
|
||||
|
||||
### Changed
|
||||
|
||||
- **The condition journal is on by default.** It now behaves like every other optional area — present unless you switch it off under "What you track" — rather than starting hidden. Self-hosters can still make it unavailable instance-wide.
|
||||
- **Unknown medication stock reads as "unknown".** A medication whose inventory was never set now shows "unknown" rather than 0, so it can't be counted down into a confusing negative. A genuine zero still shows as zero, and low-stock reminders ignore the unknown case.
|
||||
|
||||
### Operator note
|
||||
|
||||
- UI- and API-only; no schema change and no migration. The companion-app contract gains a date-less day-log list endpoint, a condition restore route, and nullable medication-inventory fields (`null` = unknown) — all additive.
|
||||
|
||||
## [1.18.2] — 2026-06-17 — preventive care, the same as your medications
|
||||
|
||||
This release brings preventive care into line with how medications already work, and clears up the condition journal. Each preventive-care item is now its own card in the same style as a medication, and marking one done behaves the way you'd expect: a check-up you plan for yourself is a simple "done", while a reminder tied to a measurement opens the actual entry form — completing a blood-pressure reminder records the reading rather than just ticking a box. Items take a first-due date and a custom interval, the page gained a settings control like the labs and condition pages, and preventive care can now sit on the dashboard as its own tile. The condition journal was reorganised so it reads at a glance. No breaking changes; no migration.
|
||||
|
||||
+116
-16
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: HealthLog API
|
||||
version: 1.18.2
|
||||
version: 1.18.3
|
||||
description: >-
|
||||
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
|
||||
|
||||
@@ -6357,7 +6357,7 @@ paths:
|
||||
- Illness
|
||||
summary: Edit an illness episode (v1.18.1)
|
||||
description: Partial edit; omitted fields are left untouched. A re-parent must point at an owned, live episode (and
|
||||
never the episode itself). Audits as `illness.episode.update`. Owner-scoped + born-gated.
|
||||
never the episode itself). Audits as `illness.episode.update`. Owner-scoped + module-gated.
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
@@ -6430,7 +6430,7 @@ paths:
|
||||
- Illness
|
||||
summary: Soft-delete an illness episode (v1.18.1)
|
||||
description: "Stamps `deletedAt` (tombstone). Idempotent — a re-delete is a no-op. Returns the `{ deleted: true }`
|
||||
envelope (the cross-module DELETE shape). Audits as `illness.episode.delete`. Owner-scoped + born-gated."
|
||||
envelope (the cross-module DELETE shape). Audits as `illness.episode.delete`. Owner-scoped + module-gated."
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
@@ -6448,6 +6448,32 @@ paths:
|
||||
"404": *a11
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/illness/episodes/{id}/restore:
|
||||
post:
|
||||
tags:
|
||||
- Illness
|
||||
summary: Restore a soft-deleted illness episode (v1.18.3)
|
||||
description: Clears `deletedAt`, bringing the episode and its preserved day-logs + encrypted note back into every read.
|
||||
A soft-delete keeps the row and children intact, so restore is a pure flag flip — nothing is re-created (the
|
||||
delete-Undo affordance). Idempotent — restoring a live episode is a no-op. Audits as `illness.episode.restore`.
|
||||
Owner-scoped + born-gated.
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
schema:
|
||||
type: string
|
||||
required: true
|
||||
responses:
|
||||
"200":
|
||||
description: Episode restored.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/RestoreIllnessEpisodeEnvelope"
|
||||
"401": *a1
|
||||
"404": *a11
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/illness/episodes/{id}/resolve:
|
||||
patch:
|
||||
tags:
|
||||
@@ -6455,7 +6481,7 @@ paths:
|
||||
summary: Mark an illness episode recovered (v1.18.1)
|
||||
description: Stamps `resolvedAt` (defaults to 'now' when the body omits it). A CHRONIC_ONGOING episode has no recovery
|
||||
date and 422s with `errorCode:"illness.episode.chronic-no-resolve"`. Audits as `illness.episode.resolve`.
|
||||
Owner-scoped + born-gated.
|
||||
Owner-scoped + module-gated.
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
@@ -6488,9 +6514,11 @@ paths:
|
||||
get:
|
||||
tags:
|
||||
- Illness
|
||||
summary: Read one day-log for an episode (v1.18.1)
|
||||
description: Returns the day-log for the episode + `date`, or `null` when nothing is logged that day (lets the log-day
|
||||
sheet pre-fill). The parent episode must be owned + live. Born-gated.
|
||||
summary: Read an episode's day-log(s) (v1.18.1, list v1.18.3)
|
||||
description: "Two modes on one path, picked by the `date` param. With `date=YYYY-MM-DD`: the single day-log for that
|
||||
day, or `null` when nothing is logged (lets the log-day sheet pre-fill). Without `date`: the date-less LIST —
|
||||
the episode's day-logs newest-first (override with `sortDir`), paged via `limit`/`offset` with `meta.total`. The
|
||||
parent episode must be owned + live. Born-gated."
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
@@ -6502,10 +6530,28 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
pattern: ^\d{4}-\d{2}-\d{2}$
|
||||
required: true
|
||||
- in: query
|
||||
name: limit
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 200
|
||||
- in: query
|
||||
name: offset
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 9007199254740991
|
||||
- in: query
|
||||
name: sortDir
|
||||
schema:
|
||||
type: string
|
||||
enum:
|
||||
- asc
|
||||
- desc
|
||||
responses:
|
||||
"200":
|
||||
description: The day-log, or null.
|
||||
description: "With `date`: the day-log or null. Without `date`: the paged day-log list."
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -6602,7 +6648,7 @@ paths:
|
||||
nadir, per-vital physiological returns, the headline recovery-gap, and any red flags. The findings derive from
|
||||
the user's OWN baseline (median ± MAD) over a contamination-guarded window — never a population constant.
|
||||
`status:\"insufficient\"` carries coverage + a reason. Retrospective ONLY — never a predictor or diagnoser.
|
||||
Owner-scoped + born-gated."
|
||||
Owner-scoped + module-gated."
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
@@ -11972,14 +12018,21 @@ components:
|
||||
description: Kind of physical container (PEN / AMPOULE / BLISTER / INHALER / BOTTLE / OTHER). Display-level
|
||||
classification; defaults to OTHER.
|
||||
unitsTotal:
|
||||
type: number
|
||||
anyOf:
|
||||
- type: number
|
||||
- type: "null"
|
||||
description: Units the container shipped with (tablets / ampoules / puffs; 1–1000). v1.16.12 — fractional, so a
|
||||
split-pill remainder reads cleanly. Dose-derived readouts divide by the medication's `unitsPerDose`.
|
||||
split-pill remainder reads cleanly. Dose-derived readouts divide by the medication's `unitsPerDose`. v1.18.3
|
||||
(iOS#31) — NULL when the unit count is unknown (a corrupt or legacy row); the client renders "unknown",
|
||||
never a fabricated 0.
|
||||
unitsRemaining:
|
||||
type: number
|
||||
anyOf:
|
||||
- type: number
|
||||
- type: "null"
|
||||
description: Units left in the container. v1.16.12 — fractional (a ½-tablet dose leaves 29.5 of 30). Decremented by the
|
||||
intake consumption hook (FEFO with spillover across containers); refunded when a taken dose is skipped,
|
||||
edited away, or deleted.
|
||||
edited away, or deleted. v1.18.3 (iOS#31) — NULL when the count is unknown (a corrupt or legacy row); the
|
||||
client renders "unknown", never a fabricated 0 it could decrement into negatives.
|
||||
firstUseAt:
|
||||
anyOf:
|
||||
- type: string
|
||||
@@ -18518,6 +18571,23 @@ components:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
RestoreIllnessEpisodeEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/IllnessEpisode"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
ResolveIllnessEpisodeEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
@@ -18540,8 +18610,10 @@ components:
|
||||
properties:
|
||||
data:
|
||||
anyOf:
|
||||
- $ref: "#/components/schemas/IllnessDayLog"
|
||||
- type: "null"
|
||||
- anyOf:
|
||||
- $ref: "#/components/schemas/IllnessDayLog"
|
||||
- type: "null"
|
||||
- $ref: "#/components/schemas/IllnessDayLogList"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
@@ -18612,6 +18684,34 @@ components:
|
||||
additionalProperties: false
|
||||
description: "A symptom link on a day-log: the catalog `key` plus an optional 1–3 graded severity (`null` = a plain
|
||||
presence link; the link's presence already means 'present', so 0 is not a distinct state)."
|
||||
IllnessDayLogList:
|
||||
type: object
|
||||
properties:
|
||||
dayLogs:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/IllnessDayLog"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
total:
|
||||
type: number
|
||||
limit:
|
||||
type: number
|
||||
offset:
|
||||
type: number
|
||||
required:
|
||||
- total
|
||||
- limit
|
||||
- offset
|
||||
additionalProperties: false
|
||||
required:
|
||||
- dayLogs
|
||||
- meta
|
||||
additionalProperties: false
|
||||
description: "The date-less day-log LIST (v1.18.3): the episode's day-logs newest-first (or oldest-first under
|
||||
`sortDir:'asc'`), with `meta.total` for paging the full history. Powers the detail timeline scroll + iOS
|
||||
healthlog-iOS#30."
|
||||
UpsertIllnessDayLogUpdatedEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
+6
-4
@@ -1667,6 +1667,7 @@
|
||||
"summary": "{remaining} von {total} Dosen übrig",
|
||||
"itemsTitle": "Packungen & Behälter",
|
||||
"doses": "{remaining} / {total} Dosen",
|
||||
"unknown": "Unbekannt",
|
||||
"state": {
|
||||
"ACTIVE": "Ungeöffnet",
|
||||
"IN_USE": "In Gebrauch",
|
||||
@@ -6716,6 +6717,8 @@
|
||||
"newEpisode": "Neue Episode",
|
||||
"logDay": "Tag erfassen",
|
||||
"markRecovered": "Als genesen markieren",
|
||||
"deletedToast": "Eintrag gelöscht",
|
||||
"restoreError": "Eintrag konnte nicht wiederhergestellt werden",
|
||||
"onsetOn": "Begonnen am {date}",
|
||||
"recoveredOn": "genesen {date}",
|
||||
"status": {
|
||||
@@ -6823,13 +6826,12 @@
|
||||
},
|
||||
"timeline": {
|
||||
"title": "Tagesverlauf",
|
||||
"error": "Der heutige Eintrag konnte nicht geladen werden.",
|
||||
"emptyToday": "Heute noch nichts erfasst.",
|
||||
"today": "Heute · {date}",
|
||||
"error": "Der Verlauf konnte nicht geladen werden.",
|
||||
"severity": "Stärke {severity}",
|
||||
"noSymptoms": "Keine Symptome erfasst.",
|
||||
"impact": "Befinden: {impact}",
|
||||
"fever": "Temperatur: {value} °C"
|
||||
"fever": "Temperatur: {value} °C",
|
||||
"empty": "Noch keine Tage erfasst."
|
||||
},
|
||||
"loadError": "Could not load this episode.",
|
||||
"backToList": "Back to journal",
|
||||
|
||||
+6
-4
@@ -1667,6 +1667,7 @@
|
||||
"summary": "{remaining} of {total} doses remaining",
|
||||
"itemsTitle": "Packs & containers",
|
||||
"doses": "{remaining} / {total} doses",
|
||||
"unknown": "Unknown",
|
||||
"state": {
|
||||
"ACTIVE": "Unopened",
|
||||
"IN_USE": "In use",
|
||||
@@ -6716,6 +6717,8 @@
|
||||
"newEpisode": "New episode",
|
||||
"logDay": "Log a day",
|
||||
"markRecovered": "Mark recovered",
|
||||
"deletedToast": "Episode deleted",
|
||||
"restoreError": "Could not restore the episode",
|
||||
"onsetOn": "Started {date}",
|
||||
"recoveredOn": "recovered {date}",
|
||||
"status": {
|
||||
@@ -6823,13 +6826,12 @@
|
||||
},
|
||||
"timeline": {
|
||||
"title": "Day timeline",
|
||||
"error": "Could not load today's entry.",
|
||||
"emptyToday": "Nothing logged today yet.",
|
||||
"today": "Today · {date}",
|
||||
"error": "Could not load the timeline.",
|
||||
"severity": "Severity {severity}",
|
||||
"noSymptoms": "No symptoms logged.",
|
||||
"impact": "How you functioned: {impact}",
|
||||
"fever": "Temperature: {value} °C"
|
||||
"fever": "Temperature: {value} °C",
|
||||
"empty": "No days logged yet."
|
||||
},
|
||||
"loadError": "Could not load this episode.",
|
||||
"backToList": "Back to journal",
|
||||
|
||||
+6
-4
@@ -1667,6 +1667,7 @@
|
||||
"summary": "{remaining} de {total} dosis restantes",
|
||||
"itemsTitle": "Envases y recipientes",
|
||||
"doses": "{remaining} / {total} dosis",
|
||||
"unknown": "Desconocido",
|
||||
"state": {
|
||||
"ACTIVE": "Sin abrir",
|
||||
"IN_USE": "En uso",
|
||||
@@ -6716,6 +6717,8 @@
|
||||
"newEpisode": "Nuevo episodio",
|
||||
"logDay": "Registrar un día",
|
||||
"markRecovered": "Marcar como recuperado",
|
||||
"deletedToast": "Episodio eliminado",
|
||||
"restoreError": "No se pudo restaurar el episodio",
|
||||
"onsetOn": "Comenzó el {date}",
|
||||
"recoveredOn": "recuperado {date}",
|
||||
"status": {
|
||||
@@ -6823,13 +6826,12 @@
|
||||
},
|
||||
"timeline": {
|
||||
"title": "Cronología del día",
|
||||
"error": "No se pudo cargar el registro de hoy.",
|
||||
"emptyToday": "Aún no hay nada registrado hoy.",
|
||||
"today": "Hoy · {date}",
|
||||
"error": "No se pudo cargar la cronología.",
|
||||
"severity": "Intensidad {severity}",
|
||||
"noSymptoms": "Sin síntomas registrados.",
|
||||
"impact": "Cómo funcionaste: {impact}",
|
||||
"fever": "Temperatura: {value} °C"
|
||||
"fever": "Temperatura: {value} °C",
|
||||
"empty": "Todavía no hay días registrados."
|
||||
},
|
||||
"loadError": "Could not load this episode.",
|
||||
"backToList": "Back to journal",
|
||||
|
||||
+6
-4
@@ -1667,6 +1667,7 @@
|
||||
"summary": "{remaining} doses restantes sur {total}",
|
||||
"itemsTitle": "Boîtes et contenants",
|
||||
"doses": "{remaining} / {total} doses",
|
||||
"unknown": "Inconnu",
|
||||
"state": {
|
||||
"ACTIVE": "Non ouvert",
|
||||
"IN_USE": "En cours",
|
||||
@@ -6716,6 +6717,8 @@
|
||||
"newEpisode": "Nouvel épisode",
|
||||
"logDay": "Enregistrer un jour",
|
||||
"markRecovered": "Marquer comme rétabli",
|
||||
"deletedToast": "Épisode supprimé",
|
||||
"restoreError": "Impossible de restaurer l'épisode",
|
||||
"onsetOn": "Débuté le {date}",
|
||||
"recoveredOn": "rétabli {date}",
|
||||
"status": {
|
||||
@@ -6823,13 +6826,12 @@
|
||||
},
|
||||
"timeline": {
|
||||
"title": "Chronologie du jour",
|
||||
"error": "Impossible de charger l'entrée du jour.",
|
||||
"emptyToday": "Rien d'enregistré aujourd'hui pour l'instant.",
|
||||
"today": "Aujourd'hui · {date}",
|
||||
"error": "Impossible de charger la chronologie.",
|
||||
"severity": "Intensité {severity}",
|
||||
"noSymptoms": "Aucun symptôme enregistré.",
|
||||
"impact": "Votre forme : {impact}",
|
||||
"fever": "Température : {value} °C"
|
||||
"fever": "Température : {value} °C",
|
||||
"empty": "Aucun jour enregistré pour l’instant."
|
||||
},
|
||||
"loadError": "Could not load this episode.",
|
||||
"backToList": "Back to journal",
|
||||
|
||||
+6
-4
@@ -1667,6 +1667,7 @@
|
||||
"summary": "{remaining} dosi rimanenti su {total}",
|
||||
"itemsTitle": "Confezioni e contenitori",
|
||||
"doses": "{remaining} / {total} dosi",
|
||||
"unknown": "Sconosciuto",
|
||||
"state": {
|
||||
"ACTIVE": "Non aperto",
|
||||
"IN_USE": "In uso",
|
||||
@@ -6716,6 +6717,8 @@
|
||||
"newEpisode": "Nuovo episodio",
|
||||
"logDay": "Registra un giorno",
|
||||
"markRecovered": "Segna come guarito",
|
||||
"deletedToast": "Episodio eliminato",
|
||||
"restoreError": "Impossibile ripristinare l'episodio",
|
||||
"onsetOn": "Iniziato il {date}",
|
||||
"recoveredOn": "guarito {date}",
|
||||
"status": {
|
||||
@@ -6823,13 +6826,12 @@
|
||||
},
|
||||
"timeline": {
|
||||
"title": "Cronologia del giorno",
|
||||
"error": "Impossibile caricare il diario di oggi.",
|
||||
"emptyToday": "Oggi non hai ancora registrato nulla.",
|
||||
"today": "Oggi · {date}",
|
||||
"error": "Impossibile caricare la cronologia.",
|
||||
"severity": "Intensità {severity}",
|
||||
"noSymptoms": "Nessun sintomo registrato.",
|
||||
"impact": "Come ti sei sentito: {impact}",
|
||||
"fever": "Temperatura: {value} °C"
|
||||
"fever": "Temperatura: {value} °C",
|
||||
"empty": "Nessun giorno registrato finora."
|
||||
},
|
||||
"loadError": "Could not load this episode.",
|
||||
"backToList": "Back to journal",
|
||||
|
||||
+6
-4
@@ -1667,6 +1667,7 @@
|
||||
"summary": "Pozostało {remaining} z {total} dawek",
|
||||
"itemsTitle": "Opakowania i pojemniki",
|
||||
"doses": "{remaining} / {total} dawek",
|
||||
"unknown": "Nieznane",
|
||||
"state": {
|
||||
"ACTIVE": "Nieotwarte",
|
||||
"IN_USE": "W użyciu",
|
||||
@@ -6716,6 +6717,8 @@
|
||||
"newEpisode": "Nowy epizod",
|
||||
"logDay": "Zapisz dzień",
|
||||
"markRecovered": "Oznacz jako wyleczone",
|
||||
"deletedToast": "Wpis usunięty",
|
||||
"restoreError": "Nie udało się przywrócić wpisu",
|
||||
"onsetOn": "Początek {date}",
|
||||
"recoveredOn": "wyleczono {date}",
|
||||
"status": {
|
||||
@@ -6823,13 +6826,12 @@
|
||||
},
|
||||
"timeline": {
|
||||
"title": "Oś dnia",
|
||||
"error": "Nie udało się wczytać dzisiejszego wpisu.",
|
||||
"emptyToday": "Dziś nic jeszcze nie zapisano.",
|
||||
"today": "Dziś · {date}",
|
||||
"error": "Nie udało się wczytać osi.",
|
||||
"severity": "Nasilenie {severity}",
|
||||
"noSymptoms": "Nie zapisano objawów.",
|
||||
"impact": "Jak funkcjonowałeś: {impact}",
|
||||
"fever": "Temperatura: {value} °C"
|
||||
"fever": "Temperatura: {value} °C",
|
||||
"empty": "Nie zapisano jeszcze żadnych dni."
|
||||
},
|
||||
"loadError": "Could not load this episode.",
|
||||
"backToList": "Back to journal",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.18.2",
|
||||
"version": "1.18.3",
|
||||
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"homepage": "https://healthlog.dev",
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ try {
|
||||
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
|
||||
const CACHE_VERSION =
|
||||
(typeof self !== "undefined" && self.__APP_VERSION__) ||
|
||||
/* @sw-version-fallback */ "v1.18.1";
|
||||
/* @sw-version-fallback */ "v1.18.2";
|
||||
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
|
||||
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
|
||||
const MAX_STATIC_ENTRIES = 150;
|
||||
|
||||
@@ -78,12 +78,12 @@ const MODULE_ROUTE_TREES: ReadonlyArray<string> = [
|
||||
"src/app/api/biomarkers",
|
||||
"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
|
||||
// route gates on the `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.
|
||||
// surface over a Bearer token when the account turned the module off.
|
||||
"src/app/api/illness",
|
||||
"src/app/api/gamification",
|
||||
// v1.18.1 (D3) — medications graduated from CORE to a toggleable module.
|
||||
|
||||
@@ -26,7 +26,7 @@ import { createBiomarkerSchema } from "@/lib/validations/biomarkers";
|
||||
* The Labs module toggle is a UX-only nav preference; the data is owner-scoped
|
||||
* and read by cross-feature surfaces (Vorsorge reminder resolution, the
|
||||
* doctor-report PDF) regardless of the toggle. Deliberate opt-out, in contrast
|
||||
* to the born-gated `/api/illness/*` routes.
|
||||
* to the gated `/api/illness/*` routes.
|
||||
*
|
||||
* The catalog is the Labs feature's primary object: a marker is defined ONCE
|
||||
* (name, unit, reference bounds, optional context) and every later reading
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* v1.18.3 — GET /api/illness/episodes/{id}/day-logs.
|
||||
*
|
||||
* Covers the date-less LIST mode (paged, newest-first, `meta.total`), the
|
||||
* cursor/offset paging params, and that the legacy single-`date` read still
|
||||
* resolves one row (back-compat). The episode is owned + live and the module
|
||||
* gate is enabled in every case below.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
illnessEpisode: { findUnique: vi.fn() },
|
||||
illnessDayLog: { findMany: vi.fn(), findFirst: vi.fn(), count: vi.fn() },
|
||||
auditLog: { create: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/illness/gate", () => ({
|
||||
requireIllnessEnabled: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { requireIllnessEnabled } from "@/lib/illness/gate";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "tester", role: "USER" as const },
|
||||
};
|
||||
|
||||
const PARAMS = { params: Promise.resolve({ id: "ep-1" }) };
|
||||
|
||||
function makeRow(date: string, overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: `dl-${date}`,
|
||||
episodeId: "ep-1",
|
||||
date,
|
||||
functionalImpact: 1,
|
||||
feverC: null,
|
||||
noteEncrypted: null,
|
||||
updatedAt: new Date("2026-06-15T00:00:00.000Z"),
|
||||
symptomLinks: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function getReq(qs: string): NextRequest {
|
||||
const suffix = qs ? `?${qs}` : "";
|
||||
return new NextRequest(
|
||||
`http://localhost/api/illness/episodes/ep-1/day-logs${suffix}`,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(requireIllnessEnabled).mockResolvedValue({ enabled: true });
|
||||
vi.mocked(prisma.illnessEpisode.findUnique).mockResolvedValue({
|
||||
userId: "user-1",
|
||||
deletedAt: null,
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("GET /api/illness/episodes/{id}/day-logs — date-less list", () => {
|
||||
it("returns paged day-logs newest-first with meta.total", async () => {
|
||||
vi.mocked(prisma.illnessDayLog.findMany).mockResolvedValue([
|
||||
makeRow("2026-06-15"),
|
||||
makeRow("2026-06-14"),
|
||||
] as never);
|
||||
vi.mocked(prisma.illnessDayLog.count).mockResolvedValue(5 as never);
|
||||
|
||||
const res = await GET(getReq(""), PARAMS);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
dayLogs: Array<{ id: string; date: string }>;
|
||||
meta: { total: number; limit: number; offset: number };
|
||||
};
|
||||
};
|
||||
expect(body.data.dayLogs).toHaveLength(2);
|
||||
expect(body.data.dayLogs[0].date).toBe("2026-06-15");
|
||||
expect(body.data.meta).toEqual({ total: 5, limit: 60, offset: 0 });
|
||||
|
||||
// Default sort is newest-first; the list query never carries a `date`.
|
||||
const findArgs = vi.mocked(prisma.illnessDayLog.findMany).mock.calls[0][0];
|
||||
expect(findArgs).toMatchObject({
|
||||
where: { episodeId: "ep-1", deletedAt: null },
|
||||
orderBy: { date: "desc" },
|
||||
take: 60,
|
||||
skip: 0,
|
||||
});
|
||||
expect(vi.mocked(prisma.illnessDayLog.findFirst)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("honours limit/offset/sortDir paging params", async () => {
|
||||
vi.mocked(prisma.illnessDayLog.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.illnessDayLog.count).mockResolvedValue(0 as never);
|
||||
|
||||
const res = await GET(getReq("limit=10&offset=20&sortDir=asc"), PARAMS);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const body = (await res.json()) as {
|
||||
data: { meta: { total: number; limit: number; offset: number } };
|
||||
};
|
||||
expect(body.data.meta).toEqual({ total: 0, limit: 10, offset: 20 });
|
||||
|
||||
const findArgs = vi.mocked(prisma.illnessDayLog.findMany).mock.calls[0][0];
|
||||
expect(findArgs).toMatchObject({
|
||||
orderBy: { date: "asc" },
|
||||
take: 10,
|
||||
skip: 20,
|
||||
});
|
||||
});
|
||||
|
||||
it("422s on an out-of-range limit", async () => {
|
||||
const res = await GET(getReq("limit=9999"), PARAMS);
|
||||
expect(res.status).toBe(422);
|
||||
expect(vi.mocked(prisma.illnessDayLog.findMany)).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/illness/episodes/{id}/day-logs — single-day (back-compat)", () => {
|
||||
it("returns one day-log for an explicit date", async () => {
|
||||
vi.mocked(prisma.illnessDayLog.findFirst).mockResolvedValue(
|
||||
makeRow("2026-06-10") as never,
|
||||
);
|
||||
|
||||
const res = await GET(getReq("date=2026-06-10"), PARAMS);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const body = (await res.json()) as {
|
||||
data: { id: string; date: string } | null;
|
||||
};
|
||||
expect(body.data?.date).toBe("2026-06-10");
|
||||
|
||||
const findArgs = vi.mocked(prisma.illnessDayLog.findFirst).mock.calls[0][0];
|
||||
expect(findArgs).toMatchObject({
|
||||
where: { episodeId: "ep-1", date: "2026-06-10", deletedAt: null },
|
||||
});
|
||||
expect(vi.mocked(prisma.illnessDayLog.findMany)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns null when nothing is logged that day", async () => {
|
||||
vi.mocked(prisma.illnessDayLog.findFirst).mockResolvedValue(null as never);
|
||||
|
||||
const res = await GET(getReq("date=2026-06-09"), PARAMS);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: unknown };
|
||||
expect(body.data).toBeNull();
|
||||
});
|
||||
|
||||
it("422s on a malformed date", async () => {
|
||||
const res = await GET(getReq("date=06-2026"), PARAMS);
|
||||
expect(res.status).toBe(422);
|
||||
expect(vi.mocked(prisma.illnessDayLog.findFirst)).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,10 @@
|
||||
* `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).
|
||||
* `GET /api/illness/episodes/{id}/day-logs` (no `date`) — the date-less
|
||||
* LIST: the episode's day-logs newest-first, paginated
|
||||
* (`limit`/`offset`/`sortDir` + `meta.total`). Powers the detail
|
||||
* timeline's full historical scroll + iOS (healthlog-iOS#30).
|
||||
* `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.
|
||||
@@ -25,6 +29,7 @@ import {
|
||||
import { requireIllnessEnabled } from "@/lib/illness/gate";
|
||||
import {
|
||||
illnessDayLogInputSchema,
|
||||
illnessDayLogListQuerySchema,
|
||||
illnessDayLogQuerySchema,
|
||||
} from "@/lib/validations/illness";
|
||||
import { upsertIllnessDayLog } from "@/lib/illness/day-log-write";
|
||||
@@ -59,8 +64,54 @@ export const GET = apiHandler(
|
||||
const owned = await loadOwnedEpisode(id, user.id);
|
||||
if (!owned.ok) return owned.response;
|
||||
|
||||
const searchParams = new URL(request.url).searchParams;
|
||||
|
||||
// No `date` → the date-less LIST: the episode's day-logs newest-first,
|
||||
// paginated, with `meta.total`. A `date` keeps the single-day read intact
|
||||
// (back-compat). The two modes are mutually exclusive by the param's
|
||||
// presence, so a list caller never trips the single-day Zod gate.
|
||||
if (!searchParams.has("date")) {
|
||||
const parsedList = illnessDayLogListQuerySchema.safeParse({
|
||||
limit: searchParams.get("limit") ?? undefined,
|
||||
offset: searchParams.get("offset") ?? undefined,
|
||||
sortDir: searchParams.get("sortDir") ?? undefined,
|
||||
});
|
||||
if (!parsedList.success) {
|
||||
return returnAllZodIssues(parsedList.error, 422, {
|
||||
errorCode: "illness.day-log.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
const { limit, offset, sortDir } = parsedList.data;
|
||||
const where = { episodeId: id, deletedAt: null };
|
||||
|
||||
const [rows, total] = await Promise.all([
|
||||
prisma.illnessDayLog.findMany({
|
||||
where,
|
||||
orderBy: { date: sortDir },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
include: dayLogSymptomInclude,
|
||||
}),
|
||||
prisma.illnessDayLog.count({ where }),
|
||||
]);
|
||||
|
||||
annotate({
|
||||
action: {
|
||||
name: "illness.day-log.list",
|
||||
entity_type: "illness_day_log",
|
||||
},
|
||||
meta: { total, limit, offset },
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
dayLogs: rows.map(toIllnessDayLogDTO),
|
||||
meta: { total, limit, offset },
|
||||
});
|
||||
}
|
||||
|
||||
const parsed = illnessDayLogQuerySchema.safeParse({
|
||||
date: new URL(request.url).searchParams.get("date"),
|
||||
date: searchParams.get("date"),
|
||||
});
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* v1.18.3 — POST /api/illness/episodes/{id}/restore.
|
||||
*
|
||||
* The delete-Undo affordance: clears `deletedAt` so the episode and its
|
||||
* preserved day-logs re-surface. Owner-scoped (a foreign id 404s, never a
|
||||
* write), born-gated, and idempotent (restoring a live episode is a no-op).
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
illnessEpisode: {
|
||||
findUnique: vi.fn(),
|
||||
findUniqueOrThrow: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
auditLog: { create: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/illness/gate", () => ({ requireIllnessEnabled: vi.fn() }));
|
||||
vi.mock("@/lib/idempotency", () => ({
|
||||
withIdempotency:
|
||||
<Args extends unknown[]>(fn: (...args: Args) => Promise<Response>) =>
|
||||
(...args: Args) =>
|
||||
fn(...args),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { requireIllnessEnabled } from "@/lib/illness/gate";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "tester", role: "USER" as const },
|
||||
};
|
||||
|
||||
const PARAMS = { params: Promise.resolve({ id: "ep-1" }) };
|
||||
|
||||
function liveRow(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "ep-1",
|
||||
userId: "user-1",
|
||||
label: "Erkältung",
|
||||
type: "INFECTION",
|
||||
lifecycle: "ACUTE",
|
||||
onsetAt: new Date("2026-06-01T00:00:00.000Z"),
|
||||
resolvedAt: null,
|
||||
parentConditionId: null,
|
||||
noteEncrypted: null,
|
||||
createdAt: new Date("2026-06-01T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-06-01T00:00:00.000Z"),
|
||||
deletedAt: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function req(): NextRequest {
|
||||
return new NextRequest(
|
||||
"http://localhost/api/illness/episodes/ep-1/restore",
|
||||
{ method: "POST" },
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(requireIllnessEnabled).mockResolvedValue({ enabled: true });
|
||||
});
|
||||
|
||||
describe("POST /api/illness/episodes/{id}/restore", () => {
|
||||
it("clears deletedAt on a tombstoned owned episode and returns it", async () => {
|
||||
vi.mocked(prisma.illnessEpisode.findUnique).mockResolvedValue({
|
||||
id: "ep-1",
|
||||
userId: "user-1",
|
||||
deletedAt: new Date("2026-06-16T00:00:00.000Z"),
|
||||
} as never);
|
||||
vi.mocked(prisma.illnessEpisode.update).mockResolvedValue(
|
||||
liveRow() as never,
|
||||
);
|
||||
vi.mocked(prisma.illnessEpisode.findUniqueOrThrow).mockResolvedValue(
|
||||
liveRow() as never,
|
||||
);
|
||||
|
||||
const res = await POST(req(), PARAMS);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const body = (await res.json()) as { data: { id: string } };
|
||||
expect(body.data.id).toBe("ep-1");
|
||||
|
||||
// The flag flip clears deletedAt; the row (and its day-logs) come back.
|
||||
expect(vi.mocked(prisma.illnessEpisode.update)).toHaveBeenCalledWith({
|
||||
where: { id: "ep-1" },
|
||||
data: { deletedAt: null },
|
||||
});
|
||||
expect(vi.mocked(auditLog)).toHaveBeenCalledWith(
|
||||
"illness.episode.restore",
|
||||
expect.objectContaining({ userId: "user-1" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("is idempotent — restoring a live episode writes nothing", async () => {
|
||||
vi.mocked(prisma.illnessEpisode.findUnique).mockResolvedValue({
|
||||
id: "ep-1",
|
||||
userId: "user-1",
|
||||
deletedAt: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.illnessEpisode.findUniqueOrThrow).mockResolvedValue(
|
||||
liveRow() as never,
|
||||
);
|
||||
|
||||
const res = await POST(req(), PARAMS);
|
||||
expect(res.status).toBe(200);
|
||||
expect(vi.mocked(prisma.illnessEpisode.update)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(auditLog)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("404s for a foreign episode without writing", async () => {
|
||||
vi.mocked(prisma.illnessEpisode.findUnique).mockResolvedValue({
|
||||
id: "ep-1",
|
||||
userId: "someone-else",
|
||||
deletedAt: new Date(),
|
||||
} as never);
|
||||
|
||||
const res = await POST(req(), PARAMS);
|
||||
expect(res.status).toBe(404);
|
||||
expect(vi.mocked(prisma.illnessEpisode.update)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("404s for an unknown episode", async () => {
|
||||
vi.mocked(prisma.illnessEpisode.findUnique).mockResolvedValue(
|
||||
null as never,
|
||||
);
|
||||
|
||||
const res = await POST(req(), PARAMS);
|
||||
expect(res.status).toBe(404);
|
||||
expect(vi.mocked(prisma.illnessEpisode.update)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses when the illness module is disabled", async () => {
|
||||
vi.mocked(requireIllnessEnabled).mockResolvedValue({
|
||||
enabled: false,
|
||||
response: new Response("nope", { status: 403 }),
|
||||
} as never);
|
||||
|
||||
const res = await POST(req(), PARAMS);
|
||||
expect(res.status).toBe(403);
|
||||
expect(vi.mocked(prisma.illnessEpisode.findUnique)).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* `POST /api/illness/episodes/{id}/restore` — un-tombstone a soft-deleted
|
||||
* episode (the delete-Undo affordance, v1.18.3).
|
||||
*
|
||||
* Clears `deletedAt`, so the episode — and its preserved day-logs + encrypted
|
||||
* note — re-surface in every read. A soft-delete keeps the row and its
|
||||
* children intact, so restore is a pure flag flip; nothing is re-created.
|
||||
*
|
||||
* Born-gated + owner-scoped + idempotent: restoring a live episode is a
|
||||
* no-op, and a foreign / unknown id seals existence as 404. This answers the
|
||||
* iOS restore-vs-re-create question (healthlog-iOS#30) — restore wins.
|
||||
*/
|
||||
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 } from "@/lib/api-response";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { requireIllnessEnabled } from "@/lib/illness/gate";
|
||||
import { toIllnessEpisodeDTO } from "@/lib/illness/dto";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
export const POST = apiHandler(
|
||||
withIdempotency<[NextRequest, RouteParams]>(async (request, { params }) => {
|
||||
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 },
|
||||
});
|
||||
// Note: no `deletedAt` filter on the existence check — the row we are
|
||||
// restoring is precisely a tombstoned one. Owner-scoped 404 either way.
|
||||
if (!existing || existing.userId !== user.id) {
|
||||
return apiError("Episode not found", 404);
|
||||
}
|
||||
|
||||
// Idempotent: only flip a currently-tombstoned row. Restoring a live
|
||||
// episode is a no-op (no audit, no write).
|
||||
if (existing.deletedAt !== null) {
|
||||
await prisma.illnessEpisode.update({
|
||||
where: { id },
|
||||
data: { deletedAt: null },
|
||||
});
|
||||
await auditLog("illness.episode.restore", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { episodeId: id },
|
||||
});
|
||||
}
|
||||
|
||||
const restored = await prisma.illnessEpisode.findUniqueOrThrow({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: {
|
||||
name: "illness.episode.restore",
|
||||
entity_type: "illness_episode",
|
||||
entity_id: id,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess(toIllnessEpisodeDTO(restored));
|
||||
}),
|
||||
);
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
/**
|
||||
* v1.17.1 — structured lab-result store (`/api/labs`).
|
||||
*
|
||||
* Module gate: unlike `/api/illness/*` (born-gated, every route 403s when the
|
||||
* Module gate: unlike `/api/illness/*` (gated, every route 403s when the
|
||||
* module is off), Labs is intentionally NOT server-gated. The Labs module
|
||||
* toggle is a UX-only nav/visibility preference — the data is always
|
||||
* owner-scoped and safe to read/write, and the Vorsorge lab-panel reminder
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { MoreVertical, Pencil, CheckCircle2, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -30,7 +31,7 @@ import {
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
import { useDeleteEpisode } from "./use-illness";
|
||||
import { useDeleteEpisode, useRestoreEpisode } from "./use-illness";
|
||||
import type { IllnessEpisodeDTO } from "./types";
|
||||
|
||||
export interface EpisodeMenuProps {
|
||||
@@ -52,6 +53,7 @@ export function EpisodeMenu({
|
||||
}: EpisodeMenuProps) {
|
||||
const { t } = useTranslations();
|
||||
const del = useDeleteEpisode();
|
||||
const restore = useRestoreEpisode();
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
|
||||
async function handleConfirmDelete() {
|
||||
@@ -59,6 +61,19 @@ export function EpisodeMenu({
|
||||
await del.mutateAsync(episode.id);
|
||||
setConfirmOpen(false);
|
||||
onDeleted?.();
|
||||
// Soft-delete keeps the episode + its day-logs intact, so the delete is
|
||||
// reversible. Surface an Undo toast wired to the restore route (the labs
|
||||
// delete+undo UX).
|
||||
toast.success(t("illness.deletedToast"), {
|
||||
action: {
|
||||
label: t("common.undo"),
|
||||
onClick: () => {
|
||||
restore.mutate(episode.id, {
|
||||
onError: () => toast.error(t("illness.restoreError")),
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// Keep the dialog open; the list will surface any error on next read.
|
||||
}
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.18.2 — the per-day timeline anchor, hosted exclusively on the episode
|
||||
* detail surface. It surfaces today's logged day (symptoms with their 0–3
|
||||
* severity, functional impact, fever) so the detail page owns the day-log
|
||||
* content the list rows no longer carry. Retrospective only — a record of
|
||||
* what was logged, never a forecast. Neutral palette throughout.
|
||||
* v1.18.2 → v1.18.3 — the per-day timeline anchor, hosted exclusively on the
|
||||
* episode detail surface. It surfaces the episode's logged days (symptoms with
|
||||
* their 0–3 severity, functional impact, fever) newest-first as a full
|
||||
* historical scroll, so the detail page owns the day-log content the list rows
|
||||
* no longer carry. Retrospective only — a record of what was logged, never a
|
||||
* forecast. Neutral palette throughout.
|
||||
*
|
||||
* The read uses the single-day `useIllnessDayLog` hook (the only day-log read
|
||||
* the API exposes); when nothing is logged for the day it renders a calm
|
||||
* prompt to log it.
|
||||
* The read uses the date-less `useIllnessDayLogList` hook (the episode's whole
|
||||
* day-log history, paginated server-side); when nothing has been logged it
|
||||
* renders a calm prompt to log a day.
|
||||
*/
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -18,24 +19,81 @@ import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
|
||||
import { ILLNESS_SYMPTOM_CATALOG } from "./symptom-catalog";
|
||||
import { useIllnessDayLog } from "./use-illness";
|
||||
import { useIllnessDayLogList } from "./use-illness";
|
||||
import type { IllnessDayLogDTO } from "./types";
|
||||
|
||||
function symptomLabelKey(key: string): string | null {
|
||||
return ILLNESS_SYMPTOM_CATALOG.find((s) => s.key === key)?.labelKey ?? null;
|
||||
}
|
||||
|
||||
/** One logged day rendered as a calm, retrospective block. */
|
||||
function DayLogRow({ log }: { log: IllnessDayLogDTO }) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
|
||||
return (
|
||||
<div className="space-y-2 border-border/60 border-l-2 pl-3">
|
||||
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">
|
||||
{fmt.dateShort(`${log.date}T12:00:00`)}
|
||||
</p>
|
||||
|
||||
{log.symptoms.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{log.symptoms.map((s) => {
|
||||
const labelKey = symptomLabelKey(s.key);
|
||||
if (!labelKey) return null;
|
||||
return (
|
||||
<Badge key={s.key} variant="secondary">
|
||||
{t(labelKey)}
|
||||
{typeof s.severity === "number"
|
||||
? ` · ${t("illness.timeline.severity", {
|
||||
severity: s.severity,
|
||||
})}`
|
||||
: ""}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("illness.timeline.noSymptoms")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{log.functionalImpact !== null ? (
|
||||
<p className="text-foreground text-sm">
|
||||
{t("illness.timeline.impact", {
|
||||
impact: t(`illness.impact.${log.functionalImpact}`),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{log.feverC !== null ? (
|
||||
<p className="text-foreground text-sm">
|
||||
{t("illness.timeline.fever", { value: fmt.number(log.feverC, 1) })}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{log.note ? (
|
||||
<p className="text-muted-foreground text-sm whitespace-pre-wrap">
|
||||
{log.note}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function IllnessDayTimeline({
|
||||
episodeId,
|
||||
date,
|
||||
onLogDay,
|
||||
}: {
|
||||
episodeId: string;
|
||||
date: string;
|
||||
onLogDay: () => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const { data, isLoading, isError } = useIllnessDayLog(episodeId, date);
|
||||
const { data, isLoading, isError } = useIllnessDayLogList(episodeId, "desc");
|
||||
|
||||
const dayLogs = data?.dayLogs ?? [];
|
||||
|
||||
return (
|
||||
<Card>
|
||||
@@ -54,10 +112,10 @@ export function IllnessDayTimeline({
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("illness.timeline.error")}
|
||||
</p>
|
||||
) : !data ? (
|
||||
) : dayLogs.length === 0 ? (
|
||||
<div className="space-y-3">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("illness.timeline.emptyToday")}
|
||||
{t("illness.timeline.empty")}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -68,57 +126,10 @@ export function IllnessDayTimeline({
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2.5 text-sm">
|
||||
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">
|
||||
{t("illness.timeline.today", {
|
||||
date: fmt.dateShort(`${data.date}T12:00:00`),
|
||||
})}
|
||||
</p>
|
||||
|
||||
{data.symptoms.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{data.symptoms.map((s) => {
|
||||
const labelKey = symptomLabelKey(s.key);
|
||||
if (!labelKey) return null;
|
||||
return (
|
||||
<Badge key={s.key} variant="secondary">
|
||||
{t(labelKey)}
|
||||
{typeof s.severity === "number"
|
||||
? ` · ${t("illness.timeline.severity", {
|
||||
severity: s.severity,
|
||||
})}`
|
||||
: ""}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground">
|
||||
{t("illness.timeline.noSymptoms")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{data.functionalImpact !== null ? (
|
||||
<p className="text-foreground">
|
||||
{t("illness.timeline.impact", {
|
||||
impact: t(`illness.impact.${data.functionalImpact}`),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{data.feverC !== null ? (
|
||||
<p className="text-foreground">
|
||||
{t("illness.timeline.fever", {
|
||||
value: fmt.number(data.feverC, 1),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{data.note ? (
|
||||
<p className="text-muted-foreground whitespace-pre-wrap">
|
||||
{data.note}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="space-y-4">
|
||||
{dayLogs.map((log) => (
|
||||
<DayLogRow key={log.id} log={log} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
@@ -121,7 +121,6 @@ export function IllnessEpisodeDetail({ episodeId }: { episodeId: string }) {
|
||||
{episode ? (
|
||||
<IllnessDayTimeline
|
||||
episodeId={episode.id}
|
||||
date={today}
|
||||
onLogDay={() => setLogOpen(true)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -47,6 +47,16 @@ export interface IllnessDayLogDTO {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.18.3 — the date-less day-log LIST response (`GET .../day-logs` with no
|
||||
* `date`). Server-authoritative; iOS (healthlog-iOS#30) renders the rows +
|
||||
* pages on `meta.total`. Mirrors the Labs list envelope shape.
|
||||
*/
|
||||
export interface IllnessDayLogListResponse {
|
||||
dayLogs: IllnessDayLogDTO[];
|
||||
meta: { total: number; limit: number; offset: number };
|
||||
}
|
||||
|
||||
export interface IllnessEpisodeCreateInput {
|
||||
label: string;
|
||||
type: IllnessType;
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
IllnessCorrelationResponse,
|
||||
IllnessDayLogDTO,
|
||||
IllnessDayLogInput,
|
||||
IllnessDayLogListResponse,
|
||||
IllnessEpisodeCreateInput,
|
||||
IllnessEpisodeDTO,
|
||||
IllnessEpisodeUpdateInput,
|
||||
@@ -83,6 +84,25 @@ export function useIllnessDayLog(episodeId: string | null, date: string) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.18.3 — the episode's full day-log history, newest-first (date-less list).
|
||||
* Powers the detail timeline's historical scroll instead of anchoring on
|
||||
* today. Server-authoritative; the response carries `meta.total` for paging.
|
||||
*/
|
||||
export function useIllnessDayLogList(
|
||||
episodeId: string | null,
|
||||
sortDir: "asc" | "desc" = "desc",
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.illnessDayLogList(episodeId ?? "none", sortDir),
|
||||
enabled: episodeId !== null,
|
||||
queryFn: () =>
|
||||
apiGet<IllnessDayLogListResponse>(
|
||||
`/api/illness/episodes/${episodeId}/day-logs?sortDir=${sortDir}`,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function useInvalidateIllness() {
|
||||
const qc = useQueryClient();
|
||||
return () => qc.invalidateQueries({ queryKey: queryKeys.illness() });
|
||||
@@ -128,6 +148,20 @@ export function useDeleteEpisode() {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore a soft-deleted episode (the delete-Undo affordance). Clears
|
||||
* `deletedAt` server-side, bringing the episode and its preserved day-logs
|
||||
* back. Idempotent + owner-scoped.
|
||||
*/
|
||||
export function useRestoreEpisode() {
|
||||
const invalidate = useInvalidateIllness();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
apiPost<IllnessEpisodeDTO>(`/api/illness/episodes/${id}/restore`, {}),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
|
||||
/** Upsert one day-log on an episode. */
|
||||
export function useUpsertDayLog(episodeId: string) {
|
||||
const invalidate = useInvalidateIllness();
|
||||
|
||||
@@ -90,8 +90,10 @@ interface InventoryItem {
|
||||
id: string;
|
||||
state: InventoryState;
|
||||
containerType: ContainerType;
|
||||
unitsTotal: number;
|
||||
unitsRemaining: number;
|
||||
// v1.18.3 (iOS#31) — null = unknown unit count (corrupt / legacy row);
|
||||
// the UI renders "—" instead of a fabricated 0.
|
||||
unitsTotal: number | null;
|
||||
unitsRemaining: number | null;
|
||||
}
|
||||
|
||||
interface InventoryResponse {
|
||||
@@ -203,13 +205,23 @@ export function InventorySection({
|
||||
// payload and the GLP-1 endpoint on one predicate. Expired stock is
|
||||
// never available; it renders as a separate muted suffix.
|
||||
// Dose-derived headline (floor — a partial dose is not a dose).
|
||||
// v1.18.3 (iOS#31) — an unknown-units row (null) contributes nothing to
|
||||
// the available-supply math; it shows "—" in its own row but cannot pad
|
||||
// the pooled headline. Coalesce null → 0 only for the summary inputs.
|
||||
const {
|
||||
unitsRemaining: remainingUnits,
|
||||
unitsTotal: totalUnits,
|
||||
dosesRemaining: remaining,
|
||||
dosesTotal: total,
|
||||
expiredUnits,
|
||||
} = summariseSupply(items, perDose);
|
||||
} = summariseSupply(
|
||||
items.map((item) => ({
|
||||
state: item.state,
|
||||
unitsTotal: item.unitsTotal ?? 0,
|
||||
unitsRemaining: item.unitsRemaining ?? 0,
|
||||
})),
|
||||
perDose,
|
||||
);
|
||||
|
||||
const dialogs = (
|
||||
<>
|
||||
@@ -370,19 +382,25 @@ export function InventorySection({
|
||||
inline at meta-text size — read-only, never a control. */}
|
||||
<span className="text-muted-foreground flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-xs">
|
||||
<span>
|
||||
{t("medications.detail.bestand.doses", {
|
||||
remaining: Math.floor(item.unitsRemaining / perDose),
|
||||
total: Math.floor(item.unitsTotal / perDose),
|
||||
})}
|
||||
{perDose !== 1 && (
|
||||
<>
|
||||
{" · "}
|
||||
{t("medications.detail.bestand.unitsDetail", {
|
||||
remaining: formatUnitCount(item.unitsRemaining),
|
||||
total: formatUnitCount(item.unitsTotal),
|
||||
{/* v1.18.3 (iOS#31) — an unknown unit count (null) renders
|
||||
"—" rather than a fabricated 0-dose figure. */}
|
||||
{item.unitsRemaining == null || item.unitsTotal == null
|
||||
? t("medications.detail.bestand.unknown")
|
||||
: t("medications.detail.bestand.doses", {
|
||||
remaining: Math.floor(item.unitsRemaining / perDose),
|
||||
total: Math.floor(item.unitsTotal / perDose),
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
{perDose !== 1 &&
|
||||
item.unitsRemaining != null &&
|
||||
item.unitsTotal != null && (
|
||||
<>
|
||||
{" · "}
|
||||
{t("medications.detail.bestand.unitsDetail", {
|
||||
remaining: formatUnitCount(item.unitsRemaining),
|
||||
total: formatUnitCount(item.unitsTotal),
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
<Badge
|
||||
variant={STATE_BADGE[item.state]}
|
||||
@@ -651,13 +669,21 @@ function AdjustInventoryDialog({
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const [value, setValue] = useState(String(item.unitsRemaining));
|
||||
// v1.18.3 (iOS#31) — an unknown remaining count (null) pre-fills empty,
|
||||
// not the literal "null"; the operator types the corrected figure.
|
||||
const [value, setValue] = useState(
|
||||
item.unitsRemaining == null ? "" : String(item.unitsRemaining),
|
||||
);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const parsed = Number(value);
|
||||
// v1.16.12 — fractional remaining allowed (a ½-tablet dose leaves 29.5).
|
||||
// v1.18.3 — when capacity is unknown (null) the only ceiling is finiteness.
|
||||
const valid =
|
||||
Number.isFinite(parsed) && parsed >= 0 && parsed <= item.unitsTotal;
|
||||
value.trim() !== "" &&
|
||||
Number.isFinite(parsed) &&
|
||||
parsed >= 0 &&
|
||||
(item.unitsTotal == null || parsed <= item.unitsTotal);
|
||||
|
||||
async function submit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
@@ -704,7 +730,7 @@ function AdjustInventoryDialog({
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
min={0}
|
||||
max={item.unitsTotal}
|
||||
max={item.unitsTotal ?? undefined}
|
||||
step="any"
|
||||
required
|
||||
autoComplete="off"
|
||||
@@ -717,7 +743,9 @@ function AdjustInventoryDialog({
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{t("medications.detail.bestand.adjustHelper", {
|
||||
total: item.unitsTotal,
|
||||
total:
|
||||
item.unitsTotal ??
|
||||
t("medications.detail.bestand.unknown"),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -289,9 +289,9 @@ export interface DoctorReportData {
|
||||
}> | null;
|
||||
/**
|
||||
* v1.18.1 P4 — illness / condition episodes overlapping the report window.
|
||||
* Populated ONLY when the `illness` module is enabled (born-gated opt-in);
|
||||
* the user turned the journal on and is sharing it deliberately, so the
|
||||
* privacy stance matches labs (ON when the module is on), not mood / cycle.
|
||||
* Populated ONLY when the `illness` module is enabled (default-on, opt-out);
|
||||
* the journal is on and shared deliberately, so the privacy stance matches
|
||||
* labs (ON when the module is on), not mood / cycle.
|
||||
* Statistics + labels only — the free-text note is NEVER read here, so no
|
||||
* encrypted plaintext can leak into the report. Each entry feeds a FHIR
|
||||
* `Condition` (+ `Encounter`) and a PDF table row. Null/absent when the
|
||||
@@ -1348,7 +1348,7 @@ export async function collectDoctorReportData(
|
||||
}
|
||||
|
||||
// v1.18.1 P4 — illness / condition episodes overlapping the window. Gated
|
||||
// on the illness module (born-gated opt-in): when off, no read, no section.
|
||||
// on the illness module (default-on, opt-out): when off, no read, no section.
|
||||
// An episode overlaps when it began on or before the window end AND is
|
||||
// either still ongoing or resolved on or after the window start. Labels +
|
||||
// lifecycle + dates only — the encrypted note is never selected, so no
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
/**
|
||||
* Illness / condition-journal feature gate (v1.18.1).
|
||||
*
|
||||
* The illness journal is BORN-GATED (opt-in / default-off) and resolves
|
||||
* its enabled-state through the module foundation — there is no per-domain
|
||||
* profile row and no second source of truth. `isIllnessEnabled` and the
|
||||
* `requireIllnessEnabled` 403 guard both delegate to the module gate's
|
||||
* `isModuleEnabled(userId, "illness")`, which (for a born-gated key) reads
|
||||
* an explicit `modulePreferencesJson.illness === true` AND the operator
|
||||
* The illness journal is a standard optional module (default-on, opt-out)
|
||||
* and resolves its enabled-state through the module foundation — there is
|
||||
* no per-domain profile row and no second source of truth. `isIllnessEnabled`
|
||||
* and the `requireIllnessEnabled` 403 guard both delegate to the module
|
||||
* gate's `isModuleEnabled(userId, "illness")`, which reads the disabled
|
||||
* allowlist (`modulePreferencesJson.illness !== false`) AND the operator
|
||||
* server-wide availability. This mirrors `src/lib/cycle/gate.ts` so every
|
||||
* `/api/illness/*` route gates identically.
|
||||
*/
|
||||
@@ -17,7 +17,7 @@ import { isModuleEnabled, requireModuleEnabled } from "@/lib/modules/gate";
|
||||
export const ILLNESS_DISABLED_ERROR_CODE = "illness.disabled";
|
||||
|
||||
/**
|
||||
* Fully-resolved illness availability for an account: the born-gated opt-in
|
||||
* Fully-resolved illness availability for an account: the per-user opt-out
|
||||
* AND the operator server-wide kill-switch, via the module foundation.
|
||||
*/
|
||||
export function isIllnessEnabled(userId: string): Promise<boolean> {
|
||||
@@ -34,8 +34,8 @@ export type IllnessGateResult =
|
||||
|
||||
/**
|
||||
* Enforce the gate for an `/api/illness/*` route. Returns a 403
|
||||
* `illness.disabled` envelope when the account has not opted in (or the
|
||||
* operator turned the module off server-wide) — even with a valid Bearer
|
||||
* `illness.disabled` envelope when the account has turned the module off
|
||||
* (or the operator turned it off server-wide) — even with a valid Bearer
|
||||
* token. Delegates to `requireModuleEnabled` but re-stamps the
|
||||
* illness-specific `errorCode` so the iOS classifier branches cleanly.
|
||||
*/
|
||||
|
||||
@@ -15,6 +15,7 @@ import { prisma } from "@/lib/db";
|
||||
import {
|
||||
buildCreateInventoryInput,
|
||||
expireStaleInUseItems,
|
||||
serializeInventoryItem,
|
||||
} from "../service";
|
||||
|
||||
const NOW = new Date("2026-05-14T12:00:00Z");
|
||||
@@ -104,3 +105,55 @@ describe("buildCreateInventoryInput", () => {
|
||||
expect(input.expiresAt).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("serializeInventoryItem (iOS#31)", () => {
|
||||
it("serialises a Decimal-as-string unit count to a JSON number", () => {
|
||||
const out = serializeInventoryItem({
|
||||
id: "i1",
|
||||
unitsTotal: "30",
|
||||
unitsRemaining: "29.5",
|
||||
});
|
||||
expect(out.unitsTotal).toBe(30);
|
||||
expect(out.unitsRemaining).toBe(29.5);
|
||||
});
|
||||
|
||||
it("keeps a genuine tracked zero as 0, not null", () => {
|
||||
const out = serializeInventoryItem({
|
||||
id: "i2",
|
||||
unitsTotal: 4,
|
||||
unitsRemaining: 0,
|
||||
});
|
||||
expect(out.unitsRemaining).toBe(0);
|
||||
expect(out.unitsTotal).toBe(4);
|
||||
});
|
||||
|
||||
it("serialises an unknown (null) unit count as null, not a fabricated 0", () => {
|
||||
const out = serializeInventoryItem({
|
||||
id: "i3",
|
||||
unitsTotal: null,
|
||||
unitsRemaining: null,
|
||||
});
|
||||
expect(out.unitsTotal).toBeNull();
|
||||
expect(out.unitsRemaining).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects non-finite corrupt values (NaN / Infinity) to null", () => {
|
||||
const out = serializeInventoryItem({
|
||||
id: "i4",
|
||||
unitsTotal: "not-a-number",
|
||||
unitsRemaining: Infinity,
|
||||
});
|
||||
expect(out.unitsTotal).toBeNull();
|
||||
expect(out.unitsRemaining).toBeNull();
|
||||
});
|
||||
|
||||
it("preserves every other field unchanged", () => {
|
||||
const out = serializeInventoryItem({
|
||||
id: "i5",
|
||||
state: "ACTIVE",
|
||||
unitsTotal: "10",
|
||||
unitsRemaining: "10",
|
||||
});
|
||||
expect(out).toMatchObject({ id: "i5", state: "ACTIVE" });
|
||||
});
|
||||
});
|
||||
@@ -108,7 +108,10 @@ describe("supply surfaces — both detail-page readouts ride the shared helper (
|
||||
"src/components/medications/sections/inventory-section.tsx",
|
||||
);
|
||||
expect(src).toContain('from "@/lib/medications/inventory/summary"');
|
||||
expect(src).toMatch(/summariseSupply\(items, perDose\)/);
|
||||
// v1.18.3 (iOS#31) — the items are mapped through a nullable→0 coalesce
|
||||
// (an unknown-units row contributes nothing to the available headline)
|
||||
// before the shared helper, but the summary still rides summariseSupply.
|
||||
expect(src).toMatch(/summariseSupply\(\s*items\.map\(/);
|
||||
expect(src).not.toContain('state !== "USED_UP"');
|
||||
expect(src).toContain("medications.detail.bestand.expiredSuffix");
|
||||
});
|
||||
|
||||
@@ -56,19 +56,36 @@ export async function expireStaleInUseItems(input: {
|
||||
* `unitsTotal` / `unitsRemaining` as numbers. Convert at every response
|
||||
* boundary that returns an item, so a fractional remainder (29.5 after a
|
||||
* half-tablet dose) lands as a JSON number, never `"29.5"`.
|
||||
*
|
||||
* v1.18.3 (iOS#31) — serialise a genuinely-unknown unit count as `null`,
|
||||
* not `0`. A well-formed row always carries both Decimals, but a corrupt
|
||||
* or legacy row could hold null / NaN / Infinity; `Number(null) === 0`
|
||||
* (and `Number("x") === NaN`) would otherwise fabricate a misleading `0`
|
||||
* that the client decrements into negatives. `null` lets the client
|
||||
* render "unbekannt" instead. A real tracked `0` stays `0`.
|
||||
*/
|
||||
function toFiniteUnit(value: unknown): number | null {
|
||||
// `Number(null) === 0` and `Number("") === 0`, so reject the empty /
|
||||
// nullish cases up front — a genuinely-absent count must read as null,
|
||||
// not a fabricated 0. Anything else goes through the finiteness gate
|
||||
// (NaN / Infinity from a corrupt string also fall to null).
|
||||
if (value === null || value === undefined || value === "") return null;
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
export function serializeInventoryItem<
|
||||
T extends { unitsTotal: unknown; unitsRemaining: unknown },
|
||||
>(
|
||||
item: T,
|
||||
): Omit<T, "unitsTotal" | "unitsRemaining"> & {
|
||||
unitsTotal: number;
|
||||
unitsRemaining: number;
|
||||
unitsTotal: number | null;
|
||||
unitsRemaining: number | null;
|
||||
} {
|
||||
return {
|
||||
...item,
|
||||
unitsTotal: Number(item.unitsTotal),
|
||||
unitsRemaining: Number(item.unitsRemaining),
|
||||
unitsTotal: toFiniteUnit(item.unitsTotal),
|
||||
unitsRemaining: toFiniteUnit(item.unitsRemaining),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -78,14 +78,14 @@ describe("resolveModuleEnabled — disabled allowlist (default-on)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveModuleEnabled — born-gated module (illness, default-off)", () => {
|
||||
it("is OFF when no preference is recorded (opt-in, not default-on)", () => {
|
||||
describe("resolveModuleEnabled — illness module (default-on, opt-out)", () => {
|
||||
it("is ON when no preference is recorded (default-on like its siblings)", () => {
|
||||
expect(resolveModuleEnabled("illness", inputs(), true, ALL_AVAILABLE)).toBe(
|
||||
false,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("is ON only on an explicit true", () => {
|
||||
it("stays ON on an explicit true", () => {
|
||||
expect(
|
||||
resolveModuleEnabled(
|
||||
"illness",
|
||||
@@ -96,7 +96,7 @@ describe("resolveModuleEnabled — born-gated module (illness, default-off)", ()
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("stays OFF on an explicit false", () => {
|
||||
it("is OFF only on an explicit false (opt-out)", () => {
|
||||
expect(
|
||||
resolveModuleEnabled(
|
||||
"illness",
|
||||
@@ -107,7 +107,7 @@ describe("resolveModuleEnabled — born-gated module (illness, default-off)", ()
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("operator-off short-circuits even when the user opted in", () => {
|
||||
it("operator-off short-circuits even when the user enabled it", () => {
|
||||
expect(
|
||||
resolveModuleEnabled(
|
||||
"illness",
|
||||
@@ -118,9 +118,9 @@ describe("resolveModuleEnabled — born-gated module (illness, default-off)", ()
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not affect sibling default-on modules", () => {
|
||||
it("matches its sibling default-on modules", () => {
|
||||
const i = inputs({ modulePreferences: {} });
|
||||
expect(resolveModuleEnabled("illness", i, true, ALL_AVAILABLE)).toBe(false);
|
||||
expect(resolveModuleEnabled("illness", i, true, ALL_AVAILABLE)).toBe(true);
|
||||
expect(resolveModuleEnabled("mood", i, true, ALL_AVAILABLE)).toBe(true);
|
||||
expect(resolveModuleEnabled("labs", i, true, ALL_AVAILABLE)).toBe(true);
|
||||
});
|
||||
|
||||
+1
-13
@@ -53,12 +53,7 @@ import {
|
||||
getOperatorModuleAvailability,
|
||||
type OperatorModuleAvailability,
|
||||
} from "./operator-availability";
|
||||
import {
|
||||
MODULE_KEYS,
|
||||
isBornGatedModule,
|
||||
moduleDelegatesTo,
|
||||
type ModuleKey,
|
||||
} from "./registry";
|
||||
import { MODULE_KEYS, moduleDelegatesTo, type ModuleKey } from "./registry";
|
||||
|
||||
export {
|
||||
getOperatorModuleAvailability,
|
||||
@@ -147,13 +142,6 @@ export function resolveModuleEnabled(
|
||||
return assistantCoach && !inputs.disableCoach;
|
||||
}
|
||||
|
||||
if (isBornGatedModule(key)) {
|
||||
// BORN-GATED (opt-in / default-off): inverse of the disabled allowlist.
|
||||
// The module stays OFF until the account explicitly enables it — only a
|
||||
// literal `true` turns it on, so the surface never appears unbidden.
|
||||
return inputs.modulePreferences[key] === true;
|
||||
}
|
||||
|
||||
// Disabled allowlist: only an explicit `false` turns the module off.
|
||||
return inputs.modulePreferences[key] !== false;
|
||||
}
|
||||
|
||||
@@ -73,17 +73,6 @@ export interface ModuleDefinition {
|
||||
* link; `labelKey` names the destination ("Account", "Coach settings").
|
||||
*/
|
||||
managedAt?: { href: string; labelKey: string };
|
||||
/**
|
||||
* v1.18.1 — BORN-GATED (opt-in / default-off). A born-gated module is
|
||||
* the inverse of the standard disabled-allowlist: it stays OFF until the
|
||||
* account explicitly enables it (`modulePreferencesJson[key] === true`).
|
||||
* Used for new optional verticals (the illness/condition journal) that
|
||||
* ship dark and only appear once the user opts in from the Modules hub.
|
||||
* The gate's `false`-disables contract still holds — an explicit `false`
|
||||
* (or simply the absent default) keeps it off — but here ONLY an explicit
|
||||
* `true` turns it on, so the surface never appears unbidden.
|
||||
*/
|
||||
bornGated?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -196,9 +185,6 @@ export const MODULE_REGISTRY: Readonly<Record<ModuleKey, ModuleDefinition>> =
|
||||
labelKey: "modules.illness.label",
|
||||
descriptionKey: "modules.illness.description",
|
||||
category: "tracking",
|
||||
// Ships dark — only appears once the account opts in from the
|
||||
// Modules hub. Default-off, no nag, retrospective-only.
|
||||
bornGated: true,
|
||||
},
|
||||
sleep: {
|
||||
key: "sleep",
|
||||
@@ -267,12 +253,3 @@ export function isModuleKey(key: string): key is ModuleKey {
|
||||
export function moduleDelegatesTo(key: ModuleKey): ModuleDelegation | undefined {
|
||||
return MODULE_REGISTRY[key].delegatesTo;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.18.1 — true for a born-gated (opt-in / default-off) module. The gate
|
||||
* flips its default from on to off for these keys: they require an explicit
|
||||
* `modulePreferencesJson[key] === true` to enable.
|
||||
*/
|
||||
export function isBornGatedModule(key: ModuleKey): boolean {
|
||||
return MODULE_REGISTRY[key].bornGated === true;
|
||||
}
|
||||
@@ -9,9 +9,9 @@
|
||||
* carry; iOS renders the DTO, it never recomputes it).
|
||||
*
|
||||
* The journal is a CONDITION journal, retrospective-only — never a
|
||||
* predictor/diagnoser. Every route is born-gated: a non-opted-in account (or
|
||||
* an operator-disabled instance) 403s with `errorCode:"illness.disabled"`
|
||||
* even with a valid Bearer token.
|
||||
* predictor/diagnoser. Every route is module-gated: an account that turned
|
||||
* the module off (or an operator-disabled instance) 403s with
|
||||
* `errorCode:"illness.disabled"` even with a valid Bearer token.
|
||||
*/
|
||||
import type { ZodOpenApiObject } from "zod-openapi";
|
||||
import { z } from "zod/v4";
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
illnessEpisodeListQuerySchema,
|
||||
illnessDayLogInputSchema,
|
||||
illnessDayLogQuerySchema,
|
||||
illnessDayLogListQuerySchema,
|
||||
illnessInsightsQuerySchema,
|
||||
illnessTypeEnum,
|
||||
illnessLifecycleEnum,
|
||||
@@ -66,6 +67,29 @@ illnessDayLogQuerySchema.meta({
|
||||
"Single-day read query: `date` is a `YYYY-MM-DD` day. Returns the matching day-log or `null` when nothing is logged for that day.",
|
||||
});
|
||||
|
||||
illnessDayLogListQuerySchema.meta({
|
||||
id: "ListIllnessDayLogsQuery",
|
||||
description:
|
||||
"Date-less LIST query (v1.18.3): omit `date` to page the episode's whole day-log history. `limit` (1–200, default 60) + `offset` (default 0) + `sortDir` ('asc' | 'desc', default 'desc') with `meta.total`. Mutually exclusive with the single-day `date` read — presence of `date` selects the single-day mode.",
|
||||
});
|
||||
|
||||
/**
|
||||
* The documented query for `GET .../day-logs`: a `date` (single-day read) OR
|
||||
* the list pagination params (date-less list). Both are optional on the wire;
|
||||
* the route picks the mode by whether `date` is present.
|
||||
*/
|
||||
const illnessDayLogGetQuery = z
|
||||
.object({
|
||||
date: z
|
||||
.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
||||
.optional(),
|
||||
limit: z.coerce.number().int().min(1).max(200).optional(),
|
||||
offset: z.coerce.number().int().min(0).optional(),
|
||||
sortDir: z.enum(["asc", "desc"]).optional(),
|
||||
})
|
||||
.meta({ id: "GetIllnessDayLogsQuery" });
|
||||
|
||||
illnessInsightsQuerySchema.meta({
|
||||
id: "IllnessInsightsQuery",
|
||||
description:
|
||||
@@ -119,6 +143,21 @@ const illnessDayLog = z
|
||||
"A stored day-log for an episode. `date` is the `YYYY-MM-DD` it covers. `functionalImpact` (0–3) and `feverC` are plaintext; `symptoms` flattens the link rows; `note` is the decrypted free-text (or null).",
|
||||
});
|
||||
|
||||
const illnessDayLogList = z
|
||||
.object({
|
||||
dayLogs: z.array(illnessDayLog),
|
||||
meta: z.object({
|
||||
total: z.number(),
|
||||
limit: z.number(),
|
||||
offset: z.number(),
|
||||
}),
|
||||
})
|
||||
.meta({
|
||||
id: "IllnessDayLogList",
|
||||
description:
|
||||
"The date-less day-log LIST (v1.18.3): the episode's day-logs newest-first (or oldest-first under `sortDir:'asc'`), with `meta.total` for paging the full history. Powers the detail timeline scroll + iOS healthlog-iOS#30.",
|
||||
});
|
||||
|
||||
/* ── P3 retrospective correlation + cross-episode insights DTOs ───────── */
|
||||
|
||||
const illnessVitalDeviation = z
|
||||
@@ -303,7 +342,7 @@ export const illnessPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Illness"],
|
||||
summary: "Edit an illness episode (v1.18.1)",
|
||||
description:
|
||||
"Partial edit; omitted fields are left untouched. A re-parent must point at an owned, live episode (and never the episode itself). Audits as `illness.episode.update`. Owner-scoped + born-gated.",
|
||||
"Partial edit; omitted fields are left untouched. A re-parent must point at an owned, live episode (and never the episode itself). Audits as `illness.episode.update`. Owner-scoped + module-gated.",
|
||||
requestParams: { path: z.object({ id: z.string() }) },
|
||||
requestBody: {
|
||||
required: true,
|
||||
@@ -331,7 +370,7 @@ export const illnessPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Illness"],
|
||||
summary: "Soft-delete an illness episode (v1.18.1)",
|
||||
description:
|
||||
"Stamps `deletedAt` (tombstone). Idempotent — a re-delete is a no-op. Returns the `{ deleted: true }` envelope (the cross-module DELETE shape). Audits as `illness.episode.delete`. Owner-scoped + born-gated.",
|
||||
"Stamps `deletedAt` (tombstone). Idempotent — a re-delete is a no-op. Returns the `{ deleted: true }` envelope (the cross-module DELETE shape). Audits as `illness.episode.delete`. Owner-scoped + module-gated.",
|
||||
requestParams: { path: z.object({ id: z.string() }) },
|
||||
responses: {
|
||||
"200": {
|
||||
@@ -350,12 +389,36 @@ export const illnessPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/illness/episodes/{id}/restore": {
|
||||
post: {
|
||||
tags: ["Illness"],
|
||||
summary: "Restore a soft-deleted illness episode (v1.18.3)",
|
||||
description:
|
||||
"Clears `deletedAt`, bringing the episode and its preserved day-logs + encrypted note back into every read. A soft-delete keeps the row and children intact, so restore is a pure flag flip — nothing is re-created (the delete-Undo affordance). Idempotent — restoring a live episode is a no-op. Audits as `illness.episode.restore`. Owner-scoped + born-gated.",
|
||||
requestParams: { path: z.object({ id: z.string() }) },
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Episode restored.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
illnessEpisode,
|
||||
"RestoreIllnessEpisodeEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...episodeNotFound,
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/illness/episodes/{id}/resolve": {
|
||||
patch: {
|
||||
tags: ["Illness"],
|
||||
summary: "Mark an illness episode recovered (v1.18.1)",
|
||||
description:
|
||||
"Stamps `resolvedAt` (defaults to 'now' when the body omits it). A CHRONIC_ONGOING episode has no recovery date and 422s with `errorCode:\"illness.episode.chronic-no-resolve\"`. Audits as `illness.episode.resolve`. Owner-scoped + born-gated.",
|
||||
"Stamps `resolvedAt` (defaults to 'now' when the body omits it). A CHRONIC_ONGOING episode has no recovery date and 422s with `errorCode:\"illness.episode.chronic-no-resolve\"`. Audits as `illness.episode.resolve`. Owner-scoped + module-gated.",
|
||||
requestParams: { path: z.object({ id: z.string() }) },
|
||||
requestBody: {
|
||||
required: false,
|
||||
@@ -383,20 +446,21 @@ export const illnessPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"/api/illness/episodes/{id}/day-logs": {
|
||||
get: {
|
||||
tags: ["Illness"],
|
||||
summary: "Read one day-log for an episode (v1.18.1)",
|
||||
summary: "Read an episode's day-log(s) (v1.18.1, list v1.18.3)",
|
||||
description:
|
||||
"Returns the day-log for the episode + `date`, or `null` when nothing is logged that day (lets the log-day sheet pre-fill). The parent episode must be owned + live. Born-gated.",
|
||||
"Two modes on one path, picked by the `date` param. With `date=YYYY-MM-DD`: the single day-log for that day, or `null` when nothing is logged (lets the log-day sheet pre-fill). Without `date`: the date-less LIST — the episode's day-logs newest-first (override with `sortDir`), paged via `limit`/`offset` with `meta.total`. The parent episode must be owned + live. Born-gated.",
|
||||
requestParams: {
|
||||
path: z.object({ id: z.string() }),
|
||||
query: illnessDayLogQuerySchema,
|
||||
query: illnessDayLogGetQuery,
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The day-log, or null.",
|
||||
description:
|
||||
"With `date`: the day-log or null. Without `date`: the paged day-log list.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
illnessDayLog.nullable(),
|
||||
z.union([illnessDayLog.nullable(), illnessDayLogList]),
|
||||
"GetIllnessDayLogEnvelope",
|
||||
),
|
||||
},
|
||||
@@ -451,7 +515,7 @@ export const illnessPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Illness"],
|
||||
summary: "Per-episode retrospective correlation (v1.18.1)",
|
||||
description:
|
||||
"Returns the coverage-gated `Derived<T>` correlation for one episode: the pre-onset anomaly scan, the nadir, per-vital physiological returns, the headline recovery-gap, and any red flags. The findings derive from the user's OWN baseline (median ± MAD) over a contamination-guarded window — never a population constant. `status:\"insufficient\"` carries coverage + a reason. Retrospective ONLY — never a predictor or diagnoser. Owner-scoped + born-gated.",
|
||||
"Returns the coverage-gated `Derived<T>` correlation for one episode: the pre-onset anomaly scan, the nadir, per-vital physiological returns, the headline recovery-gap, and any red flags. The findings derive from the user's OWN baseline (median ± MAD) over a contamination-guarded window — never a population constant. `status:\"insufficient\"` carries coverage + a reason. Retrospective ONLY — never a predictor or diagnoser. Owner-scoped + module-gated.",
|
||||
requestParams: { path: z.object({ id: z.string() }) },
|
||||
responses: {
|
||||
"200": {
|
||||
|
||||
@@ -296,13 +296,15 @@ const medicationInventoryItemResource = z
|
||||
),
|
||||
unitsTotal: z
|
||||
.number()
|
||||
.nullable()
|
||||
.describe(
|
||||
"Units the container shipped with (tablets / ampoules / puffs; 1–1000). v1.16.12 — fractional, so a split-pill remainder reads cleanly. Dose-derived readouts divide by the medication's `unitsPerDose`.",
|
||||
"Units the container shipped with (tablets / ampoules / puffs; 1–1000). v1.16.12 — fractional, so a split-pill remainder reads cleanly. Dose-derived readouts divide by the medication's `unitsPerDose`. v1.18.3 (iOS#31) — NULL when the unit count is unknown (a corrupt or legacy row); the client renders \"unknown\", never a fabricated 0.",
|
||||
),
|
||||
unitsRemaining: z
|
||||
.number()
|
||||
.nullable()
|
||||
.describe(
|
||||
"Units left in the container. v1.16.12 — fractional (a ½-tablet dose leaves 29.5 of 30). Decremented by the intake consumption hook (FEFO with spillover across containers); refunded when a taken dose is skipped, edited away, or deleted.",
|
||||
"Units left in the container. v1.16.12 — fractional (a ½-tablet dose leaves 29.5 of 30). Decremented by the intake consumption hook (FEFO with spillover across containers); refunded when a taken dose is skipped, edited away, or deleted. v1.18.3 (iOS#31) — NULL when the count is unknown (a corrupt or legacy row); the client renders \"unknown\", never a fabricated 0 it could decrement into negatives.",
|
||||
),
|
||||
firstUseAt: z.iso
|
||||
.datetime({ offset: true })
|
||||
|
||||
@@ -18,6 +18,14 @@ export const illnessKeys = {
|
||||
illnessEpisode: (id: string) => ["illness", "episode", id] as const,
|
||||
illnessDayLog: (episodeId: string, date: string) =>
|
||||
["illness", "day-log", episodeId, date] as const,
|
||||
/**
|
||||
* v1.18.3 — the date-less day-log LIST for an episode (full historical
|
||||
* scroll on the detail timeline + iOS healthlog-iOS#30). Distinct slot from
|
||||
* the single-day read above; an illness write evicts the whole `["illness"]`
|
||||
* prefix so both repaint together.
|
||||
*/
|
||||
illnessDayLogList: (episodeId: string, sortDir: string) =>
|
||||
["illness", "day-log-list", episodeId, sortDir] as const,
|
||||
/**
|
||||
* v1.18.1 P3 — the per-episode retrospective correlation findings
|
||||
* (pre-onset scan, nadir, recovery-gap). Server-authoritative; the read
|
||||
|
||||
@@ -162,6 +162,24 @@ export type IllnessDayLogInput = z.infer<typeof illnessDayLogInputSchema>;
|
||||
/** The single-day read query: `GET .../day-logs?date=YYYY-MM-DD`. */
|
||||
export const illnessDayLogQuerySchema = z.object({ date: dateString });
|
||||
|
||||
/**
|
||||
* The date-less LIST query: `GET .../day-logs` with NO `date` param returns
|
||||
* the episode's day-logs newest-first, paginated. Mirrors the Labs
|
||||
* limit/offset/sortDir + `meta.total` pattern so iOS (healthlog-iOS#30) can
|
||||
* page a full history without anchoring on today. `date` and the list params
|
||||
* are mutually exclusive at the route: a `date` triggers the single-day read,
|
||||
* its absence triggers the list.
|
||||
*/
|
||||
export const illnessDayLogListQuerySchema = z.object({
|
||||
limit: z.coerce.number().int().min(1).max(200).default(60),
|
||||
offset: z.coerce.number().int().min(0).default(0),
|
||||
sortDir: z.enum(["asc", "desc"]).default("desc"),
|
||||
});
|
||||
|
||||
export type IllnessDayLogListQuery = z.infer<
|
||||
typeof illnessDayLogListQuerySchema
|
||||
>;
|
||||
|
||||
/* ── P3 retrospective insight window ─────────────────────────────────── */
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user