feat(illness): date-less day-log list route + full historical detail timeline

This commit is contained in:
Marc-André Bombeck committed 2026-06-17 14:07:49 +02:00
commit c5fd21b8a6
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 ─────────────────────────────────── */
/**