feat(illness): add date-less day-log list and full-history timeline

The episode day-logs endpoint only accepted a single `date`, so the
detail timeline could anchor on today alone. Add a date-less LIST variant
to GET /api/illness/episodes/{id}/day-logs: when no `date` is given,
return the episode's day-logs newest-first with limit/offset/sortDir
paging and meta.total (mirrors the Labs list envelope). The single-date
read stays intact, selected by the param's presence.

Wire the detail timeline onto the list for a full historical scroll
instead of today only, keeping the neutral palette and retrospective
copy. Register the list response in the OpenAPI contract for the iOS
client.
This commit is contained in:
Marc-André Bombeck committed 2026-06-17 14:07:16 +02:00
1 parent b5c1d1fc4e
commit cdf43c3669
16 files changed
+482 -104

No files matched your search

+57 -7
View File
@@ -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:
+3 -4
View File
@@ -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",
+3 -4
View File
@@ -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",
+3 -4
View File
@@ -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",
+3 -4
View File
@@ -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",
+3 -4
View File
@@ -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",
+3 -4
View File
@@ -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",
@@ -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, {
+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;
+20
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() });
+45 -5
View File
@@ -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<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",
),
},
+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 ─────────────────────────────────── */
/**