mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
feat(illness): date-less day-log list route + full historical detail timeline
This commit is contained in:
commit
c5fd21b8a6
16 files changed
+482
-104
No files matched your search
+57
-7
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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, {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() });
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ─────────────────────────────────── */
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user