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:
Marc-André Bombeck authored and GitHub committed 2026-06-17 14:26:36 +02:00
commit 4100346100
35 files changed
+1036 -210

No files matched your search

+18
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+1 -1
View File
@@ -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));
}),
);
+1 -1
View File
@@ -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
+16 -1
View File
@@ -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.
}
+77 -66
View File
@@ -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}
+10
View File
@@ -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;
+34
View File
@@ -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>
+4 -4
View File
@@ -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
+9 -9
View File
@@ -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");
});
+21 -4
View File
@@ -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),
};
}
+8 -8
View File
@@ -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
View File
@@ -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;
}
-23
View File
@@ -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;
}
+76 -12
View File
@@ -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": {
+4 -2
View File
@@ -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 })
+8
View File
@@ -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
+18
View File
@@ -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 ─────────────────────────────────── */
/**