diff --git a/CHANGELOG.md b/CHANGELOG.md index b388887f3..e7317c1f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index abbfb3407..c9ca05c2e 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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: diff --git a/messages/de.json b/messages/de.json index 7b3ec3201..7cac0883a 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index acc413ff5..1f42141f5 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index 6b52dc580..abfe65977 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index ce784865e..358419bb8 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index ffa5817d9..773c0a809 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/pl.json b/messages/pl.json index b2f7a9f2c..e316f5b1f 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/package.json b/package.json index eb865ff70..29042abc3 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/sw.js b/public/sw.js index 86e4cbc41..3a83a3e39 100644 --- a/public/sw.js +++ b/public/sw.js @@ -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; 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 57a5daca1..fd4e9bb17 100644 --- a/src/app/api/__tests__/module-route-gate-inventory.test.ts +++ b/src/app/api/__tests__/module-route-gate-inventory.test.ts @@ -78,12 +78,12 @@ const MODULE_ROUTE_TREES: ReadonlyArray = [ "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. diff --git a/src/app/api/biomarkers/route.ts b/src/app/api/biomarkers/route.ts index a9c2a7ff5..b30c84be6 100644 --- a/src/app/api/biomarkers/route.ts +++ b/src/app/api/biomarkers/route.ts @@ -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 diff --git a/src/app/api/illness/episodes/[id]/day-logs/__tests__/route.test.ts b/src/app/api/illness/episodes/[id]/day-logs/__tests__/route.test.ts new file mode 100644 index 000000000..659d66159 --- /dev/null +++ b/src/app/api/illness/episodes/[id]/day-logs/__tests__/route.test.ts @@ -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 = {}) { + 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(); + }); +}); diff --git a/src/app/api/illness/episodes/[id]/day-logs/route.ts b/src/app/api/illness/episodes/[id]/day-logs/route.ts index 3d5b70de8..7dc3660e5 100644 --- a/src/app/api/illness/episodes/[id]/day-logs/route.ts +++ b/src/app/api/illness/episodes/[id]/day-logs/route.ts @@ -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, { diff --git a/src/app/api/illness/episodes/[id]/restore/__tests__/route.test.ts b/src/app/api/illness/episodes/[id]/restore/__tests__/route.test.ts new file mode 100644 index 000000000..a58531548 --- /dev/null +++ b/src/app/api/illness/episodes/[id]/restore/__tests__/route.test.ts @@ -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: + (fn: (...args: Args) => Promise) => + (...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 = {}) { + 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(); + }); +}); diff --git a/src/app/api/illness/episodes/[id]/restore/route.ts b/src/app/api/illness/episodes/[id]/restore/route.ts new file mode 100644 index 000000000..ce17f9773 --- /dev/null +++ b/src/app/api/illness/episodes/[id]/restore/route.ts @@ -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)); + }), +); diff --git a/src/app/api/labs/route.ts b/src/app/api/labs/route.ts index 5afb9ac69..dce2e5a93 100644 --- a/src/app/api/labs/route.ts +++ b/src/app/api/labs/route.ts @@ -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 diff --git a/src/components/illness/episode-menu.tsx b/src/components/illness/episode-menu.tsx index 6fe24c37e..e938f9336 100644 --- a/src/components/illness/episode-menu.tsx +++ b/src/components/illness/episode-menu.tsx @@ -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. } diff --git a/src/components/illness/illness-day-timeline.tsx b/src/components/illness/illness-day-timeline.tsx index 291ddb2bf..83e9f9d73 100644 --- a/src/components/illness/illness-day-timeline.tsx +++ b/src/components/illness/illness-day-timeline.tsx @@ -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 ( +
+

+ {fmt.dateShort(`${log.date}T12:00:00`)} +

+ + {log.symptoms.length > 0 ? ( +
+ {log.symptoms.map((s) => { + const labelKey = symptomLabelKey(s.key); + if (!labelKey) return null; + return ( + + {t(labelKey)} + {typeof s.severity === "number" + ? ` · ${t("illness.timeline.severity", { + severity: s.severity, + })}` + : ""} + + ); + })} +
+ ) : ( +

+ {t("illness.timeline.noSymptoms")} +

+ )} + + {log.functionalImpact !== null ? ( +

+ {t("illness.timeline.impact", { + impact: t(`illness.impact.${log.functionalImpact}`), + })} +

+ ) : null} + + {log.feverC !== null ? ( +

+ {t("illness.timeline.fever", { value: fmt.number(log.feverC, 1) })} +

+ ) : null} + + {log.note ? ( +

+ {log.note} +

+ ) : null} +
+ ); +} + 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 ( @@ -54,10 +112,10 @@ export function IllnessDayTimeline({

{t("illness.timeline.error")}

- ) : !data ? ( + ) : dayLogs.length === 0 ? (

- {t("illness.timeline.emptyToday")} + {t("illness.timeline.empty")}

) : ( -
-

- {t("illness.timeline.today", { - date: fmt.dateShort(`${data.date}T12:00:00`), - })} -

- - {data.symptoms.length > 0 ? ( -
- {data.symptoms.map((s) => { - const labelKey = symptomLabelKey(s.key); - if (!labelKey) return null; - return ( - - {t(labelKey)} - {typeof s.severity === "number" - ? ` · ${t("illness.timeline.severity", { - severity: s.severity, - })}` - : ""} - - ); - })} -
- ) : ( -

- {t("illness.timeline.noSymptoms")} -

- )} - - {data.functionalImpact !== null ? ( -

- {t("illness.timeline.impact", { - impact: t(`illness.impact.${data.functionalImpact}`), - })} -

- ) : null} - - {data.feverC !== null ? ( -

- {t("illness.timeline.fever", { - value: fmt.number(data.feverC, 1), - })} -

- ) : null} - - {data.note ? ( -

- {data.note} -

- ) : null} +
+ {dayLogs.map((log) => ( + + ))}
)} diff --git a/src/components/illness/illness-episode-detail.tsx b/src/components/illness/illness-episode-detail.tsx index e4262edb6..79706cc8a 100644 --- a/src/components/illness/illness-episode-detail.tsx +++ b/src/components/illness/illness-episode-detail.tsx @@ -121,7 +121,6 @@ export function IllnessEpisodeDetail({ episodeId }: { episodeId: string }) { {episode ? ( setLogOpen(true)} /> ) : null} diff --git a/src/components/illness/types.ts b/src/components/illness/types.ts index 04ee34c1a..6526f22e4 100644 --- a/src/components/illness/types.ts +++ b/src/components/illness/types.ts @@ -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; diff --git a/src/components/illness/use-illness.ts b/src/components/illness/use-illness.ts index 613734720..bf135726e 100644 --- a/src/components/illness/use-illness.ts +++ b/src/components/illness/use-illness.ts @@ -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( + `/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(`/api/illness/episodes/${id}/restore`, {}), + onSuccess: invalidate, + }); +} + /** Upsert one day-log on an episode. */ export function useUpsertDayLog(episodeId: string) { const invalidate = useInvalidateIllness(); diff --git a/src/components/medications/sections/inventory-section.tsx b/src/components/medications/sections/inventory-section.tsx index b36e11b92..28a769047 100644 --- a/src/components/medications/sections/inventory-section.tsx +++ b/src/components/medications/sections/inventory-section.tsx @@ -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. */} - {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), + })} + + )} = 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"), })}

diff --git a/src/lib/doctor-report-data.ts b/src/lib/doctor-report-data.ts index 647f0ae27..6e6b13c33 100644 --- a/src/lib/doctor-report-data.ts +++ b/src/lib/doctor-report-data.ts @@ -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 diff --git a/src/lib/illness/gate.ts b/src/lib/illness/gate.ts index 280a1c8c6..16195cdf3 100644 --- a/src/lib/illness/gate.ts +++ b/src/lib/illness/gate.ts @@ -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 { @@ -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. */ diff --git a/src/lib/medications/inventory/__tests__/service.test.ts b/src/lib/medications/inventory/__tests__/service.test.ts index 9ac65a5dd..20509e5ae 100644 --- a/src/lib/medications/inventory/__tests__/service.test.ts +++ b/src/lib/medications/inventory/__tests__/service.test.ts @@ -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" }); + }); +}); diff --git a/src/lib/medications/inventory/__tests__/summary.test.ts b/src/lib/medications/inventory/__tests__/summary.test.ts index 480fee16b..816810317 100644 --- a/src/lib/medications/inventory/__tests__/summary.test.ts +++ b/src/lib/medications/inventory/__tests__/summary.test.ts @@ -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"); }); diff --git a/src/lib/medications/inventory/service.ts b/src/lib/medications/inventory/service.ts index f46d8cf60..dd4e8810d 100644 --- a/src/lib/medications/inventory/service.ts +++ b/src/lib/medications/inventory/service.ts @@ -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 & { - 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), }; } diff --git a/src/lib/modules/__tests__/gate.test.ts b/src/lib/modules/__tests__/gate.test.ts index b4f72b2cf..83eab2394 100644 --- a/src/lib/modules/__tests__/gate.test.ts +++ b/src/lib/modules/__tests__/gate.test.ts @@ -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); }); diff --git a/src/lib/modules/gate.ts b/src/lib/modules/gate.ts index 3e1da491b..34a4797a9 100644 --- a/src/lib/modules/gate.ts +++ b/src/lib/modules/gate.ts @@ -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; } diff --git a/src/lib/modules/registry.ts b/src/lib/modules/registry.ts index 90f276002..704962a2f 100644 --- a/src/lib/modules/registry.ts +++ b/src/lib/modules/registry.ts @@ -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> = 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; -} diff --git a/src/lib/openapi/routes/illness.ts b/src/lib/openapi/routes/illness.ts index 784e2dd6b..e058fbd80 100644 --- a/src/lib/openapi/routes/illness.ts +++ b/src/lib/openapi/routes/illness.ts @@ -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 = { 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 = { 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 = { }, }, }, + "/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 = { "/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 = { tags: ["Illness"], summary: "Per-episode retrospective correlation (v1.18.1)", description: - "Returns the coverage-gated `Derived` 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` 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": { diff --git a/src/lib/openapi/routes/medications.ts b/src/lib/openapi/routes/medications.ts index 396d48b0c..33ad8265c 100644 --- a/src/lib/openapi/routes/medications.ts +++ b/src/lib/openapi/routes/medications.ts @@ -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 }) diff --git a/src/lib/query-keys/illness.ts b/src/lib/query-keys/illness.ts index 1b1d9ee80..a020407bb 100644 --- a/src/lib/query-keys/illness.ts +++ b/src/lib/query-keys/illness.ts @@ -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 diff --git a/src/lib/validations/illness.ts b/src/lib/validations/illness.ts index 83b61e870..fa52e8e01 100644 --- a/src/lib/validations/illness.ts +++ b/src/lib/validations/illness.ts @@ -162,6 +162,24 @@ export type IllnessDayLogInput = z.infer; /** 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 ─────────────────────────────────── */ /**