diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index abbfb3407..84cabb506 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -6488,9 +6488,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 +6504,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: @@ -18540,8 +18560,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 +18634,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..0021ef660 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6823,13 +6823,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..cbf3de315 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6823,13 +6823,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..4fcfb42c7 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6823,13 +6823,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..c2bdaa342 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6823,13 +6823,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..140054cc0 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6823,13 +6823,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..d799fb13b 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6823,13 +6823,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/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/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..850136b3f 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() }); diff --git a/src/lib/openapi/routes/illness.ts b/src/lib/openapi/routes/illness.ts index 784e2dd6b..1c9c9d62c 100644 --- a/src/lib/openapi/routes/illness.ts +++ b/src/lib/openapi/routes/illness.ts @@ -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 @@ -383,20 +422,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", ), }, 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 ─────────────────────────────────── */ /**