From c1df60693b7c9fb1910f997bfc9791aa32490437 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 17 Oct 2026 11:00:02 +0200 Subject: [PATCH] fix(symptoms): assign an occurrence to an illness by its link, not its time The episode detail listed every symptom occurrence that fell inside the episode's onset-to-recovery span as well as the ones filed against it, so a long-running condition showed the symptoms of every illness overlapping it, and picking an illness in the entry sheet changed nothing on the page. The occurrence edit sheet carried no illness link at all, so once logged the assignment could be neither seen nor changed. - The episode card reads only the occurrences filed against the episode (`SymptomEvent.episodeId`), with a guard against any other. - The edit sheet loads the link and edits it like the time and intensity; an unchanged link is not re-sent, so a link the list cannot show stays. - One rule decides which episodes may take an occurrence (`isEpisodeLinkableAt`): an open one, or a resolved one whose span holds the occurrence's time. The server checks it against the time after the edit, and the entry and edit sheets offer exactly what it admits, so a backdated occurrence can be filed with an illness that has since ended. - The illness page names the illness on each occurrence row. - The day view opens a symptom's illness only while that episode exists. The stored links were always written correctly, so no data repair is needed. Refs #1237 --- docs/api/openapi.yaml | 6 +- messages/de.json | 2 +- messages/en.json | 2 +- messages/es.json | 2 +- messages/fr.json | 2 +- messages/it.json | 2 +- messages/ko.json | 2 +- messages/pl.json | 2 +- src/app/api/symptoms/events/[id]/route.ts | 15 +- src/app/api/symptoms/events/route.ts | 12 +- .../__tests__/episode-symptoms-card.test.tsx | 139 +++++++++ .../symptom-edit-sheet-episode.test.tsx | 168 +++++++++++ .../__tests__/symptom-entry-form.test.tsx | 45 ++- .../symptoms/episode-link-field.tsx | 116 ++++++++ .../symptoms/episode-symptoms-card.tsx | 73 ++--- .../symptoms/symptom-edit-sheets.tsx | 37 ++- .../symptoms/symptom-entry-form.tsx | 71 ++--- src/components/symptoms/symptoms-section.tsx | 17 +- src/lib/day/records.ts | 8 +- src/lib/openapi/routes/symptoms.ts | 6 +- src/lib/symptoms/server.ts | 21 +- src/lib/symptoms/shared.ts | 30 +- tests/integration/symptoms.test.ts | 265 +++++++++++++++++- 23 files changed, 904 insertions(+), 139 deletions(-) create mode 100644 src/components/symptoms/__tests__/episode-symptoms-card.test.tsx create mode 100644 src/components/symptoms/__tests__/symptom-edit-sheet-episode.test.tsx create mode 100644 src/components/symptoms/episode-link-field.tsx diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 85801ca8e..cd06107ca 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -35563,7 +35563,8 @@ components: - intensity description: Log one occurrence. `intensity` is 0-10 (0 none, 10 the worst imaginable). `occurredAt` defaults to now and may not lie in the future beyond five minutes. `episodeId`, when given, must name one of the record's own - illness episodes that is still open (422 `symptoms.episode.notOpen` otherwise). The note is encrypted at rest. + illness episodes that is still open, or a resolved one whose onset-to-recovery span holds `occurredAt` (422 + `symptoms.episode.notOpen` otherwise). The note is encrypted at rest. UpdateSymptomEventRequest: type: object properties: @@ -35587,7 +35588,8 @@ components: maxLength: 40 - type: "null" description: "Partial edit of one occurrence. `note: null` clears the note, `episodeId: null` drops the link. A changed - `episodeId` must name an open episode; an unchanged link to an episode that has since resolved is kept." + `episodeId` must name an open episode or a resolved one whose span holds the occurrence's time after the edit; + an unchanged link to an episode that has since resolved is kept." CreateLifeEventRequest: type: object properties: diff --git a/messages/de.json b/messages/de.json index d59a91674..33b598818 100644 --- a/messages/de.json +++ b/messages/de.json @@ -12905,7 +12905,7 @@ }, "episode": { "title": "Erfasste Symptome", - "filed": "zu dieser Erkrankung erfasst" + "during": "bei {illness}" } }, "day": { diff --git a/messages/en.json b/messages/en.json index cd32491df..c73ac80af 100644 --- a/messages/en.json +++ b/messages/en.json @@ -12905,7 +12905,7 @@ }, "episode": { "title": "Symptoms logged", - "filed": "logged with this illness" + "during": "during {illness}" } }, "day": { diff --git a/messages/es.json b/messages/es.json index 56ddea9d6..9c6d9f3f2 100644 --- a/messages/es.json +++ b/messages/es.json @@ -12905,7 +12905,7 @@ }, "episode": { "title": "Síntomas registrados", - "filed": "registrado con esta enfermedad" + "during": "durante {illness}" } }, "day": { diff --git a/messages/fr.json b/messages/fr.json index 788760a93..67aa372b2 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -12905,7 +12905,7 @@ }, "episode": { "title": "Symptômes notés", - "filed": "noté avec cette maladie" + "during": "pendant {illness}" } }, "day": { diff --git a/messages/it.json b/messages/it.json index 40ddbeffa..aaf0cd3e1 100644 --- a/messages/it.json +++ b/messages/it.json @@ -12905,7 +12905,7 @@ }, "episode": { "title": "Sintomi registrati", - "filed": "registrato con questa malattia" + "during": "durante {illness}" } }, "day": { diff --git a/messages/ko.json b/messages/ko.json index 4259bb70a..4458937a3 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -12905,7 +12905,7 @@ }, "episode": { "title": "기록된 증상", - "filed": "이 질병과 함께 기록됨" + "during": "{illness} 중" } }, "day": { diff --git a/messages/pl.json b/messages/pl.json index d071630d4..4158a7a51 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -12905,7 +12905,7 @@ }, "episode": { "title": "Zapisane objawy", - "filed": "zapisano przy tej chorobie" + "during": "podczas: {illness}" } }, "day": { diff --git a/src/app/api/symptoms/events/[id]/route.ts b/src/app/api/symptoms/events/[id]/route.ts index 82928b38a..f5a031c37 100644 --- a/src/app/api/symptoms/events/[id]/route.ts +++ b/src/app/api/symptoms/events/[id]/route.ts @@ -2,8 +2,9 @@ * One occurrence of a person-defined symptom (v1.40). * * PATCH /api/symptoms/events/{id} — change the time, intensity, note or - * episode link. A new link must name an open episode of the record; - * an unchanged link to an episode that has since resolved is kept. + * episode link. A new link must name an episode of the record that + * is open or ran over the occurrence's time; an unchanged link to an + * episode that has since resolved is kept. * DELETE /api/symptoms/events/{id} — remove it. * * Owner-scoped (another account's id is a 404), module-gated on the record, @@ -76,12 +77,18 @@ export const PATCH = apiHandler( }); if (!existing) return notFound(); + // A new link is checked against the time the occurrence will have after + // this edit, so moving it and filing it in one save is judged as a whole. if ( entry.episodeId && entry.episodeId !== existing.episodeId && - !(await isLinkableEpisode(user.id, entry.episodeId)) + !(await isLinkableEpisode( + user.id, + entry.episodeId, + entry.occurredAt ?? existing.occurredAt, + )) ) { - return apiError("Episode is not open", 422, { + return apiError("Episode did not run at that time", 422, { errorCode: "symptoms.episode.notOpen", }); } diff --git a/src/app/api/symptoms/events/route.ts b/src/app/api/symptoms/events/route.ts index 447502a0b..a23e997a4 100644 --- a/src/app/api/symptoms/events/route.ts +++ b/src/app/api/symptoms/events/route.ts @@ -8,7 +8,7 @@ * occurredAt?, note?, episodeId? }`). `occurredAt` defaults to now and * may not lie in the future beyond the shared five-minute skew; * `episodeId` must name one of the record's own episodes that is still - * open. The note is encrypted at rest. + * open or that ran over `occurredAt`. The note is encrypted at rest. * * Module-gated on the record (`illness.disabled`). `userId` comes from auth * and is never a body field. @@ -124,8 +124,12 @@ async function postEvent(request: NextRequest): Promise { }); } - if (entry.episodeId && !(await isLinkableEpisode(user.id, entry.episodeId))) { - return apiError("Episode is not open", 422, { + const occurredAt = entry.occurredAt ?? new Date(); + if ( + entry.episodeId && + !(await isLinkableEpisode(user.id, entry.episodeId, occurredAt)) + ) { + return apiError("Episode did not run at that time", 422, { errorCode: "symptoms.episode.notOpen", }); } @@ -134,7 +138,7 @@ async function postEvent(request: NextRequest): Promise { data: { userId: user.id, definitionId: definition.id, - occurredAt: entry.occurredAt ?? new Date(), + occurredAt, intensity: entry.intensity, noteEncrypted: entry.note ? encryptSymptomText(entry.note) : null, episodeId: entry.episodeId ?? null, diff --git a/src/components/symptoms/__tests__/episode-symptoms-card.test.tsx b/src/components/symptoms/__tests__/episode-symptoms-card.test.tsx new file mode 100644 index 000000000..0b3b68f67 --- /dev/null +++ b/src/components/symptoms/__tests__/episode-symptoms-card.test.tsx @@ -0,0 +1,139 @@ +/** + * The "Symptoms logged" card on an illness episode's detail page (#1237). + * + * An occurrence belongs to an episode when it is filed against it + * (`SymptomEvent.episodeId`), not when it merely happened while the episode + * ran. Before the fix the card also read every occurrence inside the + * episode's onset-to-recovery span, so a long-running condition listed the + * symptoms of every illness overlapping it and the person's choice in the + * "during" selector changed nothing on the page. + * + * Mutation checks (each run, each seen red): + * - read the span as well and merge it in (the pre-fix card) → "lists only + * the occurrences filed against this episode" goes red (the unrelated + * headache shows); + * - drop the `episodeId === episode.id` guard → "never shows another + * episode's occurrence" goes red. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import type { SymptomEventDTO } from "@/lib/symptoms/shared"; + +const state = vi.hoisted(() => ({ + calls: [] as Array<{ from: string; to: string; episodeId: string | null }>, + filed: [] as SymptomEventDTO[], + everything: [] as SymptomEventDTO[], +})); + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ user: { timezone: "Europe/Berlin" } }), +})); + +vi.mock("@/hooks/use-record-capabilities", () => ({ + useRecordCapabilities: () => ({ canManageDomain: () => false }), +})); + +vi.mock("../use-symptoms", () => ({ + useSymptomDefinitions: () => ({ + isError: false, + refetch: vi.fn(), + data: { + limit: 8, + definitions: [ + { id: "aura", label: "Aura", icon: "Zap", isActive: true }, + { id: "headache", label: "Headache", icon: "Brain", isActive: true }, + ], + }, + }), + // The route narrows by `episodeId` when one is given; without one it + // answers everything inside the window, like the real one. + useSymptomEvents: ( + from: string, + to: string, + options: { episodeId?: string | null } = {}, + ) => { + const episodeId = options.episodeId ?? null; + state.calls.push({ from, to, episodeId }); + return { + isError: false, + refetch: vi.fn(), + data: { + events: episodeId ? state.filed : state.everything, + limit: 500, + hasMore: false, + }, + }; + }, + useDeleteSymptomEvent: () => ({ mutate: vi.fn(), isPending: false }), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { EpisodeSymptomsCard } from "../episode-symptoms-card"; + +function event( + id: string, + definitionId: string, + episodeId: string | null, +): SymptomEventDTO { + return { + id, + definitionId, + occurredAt: "2026-10-10T09:00:00.000Z", + intensity: 5, + note: null, + episodeId, + createdAt: "2026-10-10T09:00:00.000Z", + updatedAt: "2026-10-10T09:00:00.000Z", + }; +} + +function render(): string { + return renderToStaticMarkup( + + + , + ); +} + +beforeEach(() => { + state.calls = []; +}); + +describe("", () => { + it("lists only the occurrences filed against this episode", () => { + // A chronic migraine runs all year; a cold overlaps it. The aura is filed + // with the migraine, the headache with the cold, on the same morning. + const aura = event("e-aura", "aura", "migraine"); + const headache = event("e-headache", "headache", "cold"); + state.filed = [aura]; + state.everything = [aura, headache]; + + const html = render(); + expect(html).toContain("Aura"); + expect(html).not.toContain("Headache"); + // Every read the card makes is narrowed to the link. + expect(state.calls.length).toBeGreaterThan(0); + expect(state.calls.every((c) => c.episodeId === "migraine")).toBe(true); + }); + + it("never shows another episode's occurrence, even if the answer holds one", () => { + state.filed = [ + event("e-aura", "aura", "migraine"), + event("e-headache", "headache", "cold"), + ]; + state.everything = []; + const html = render(); + expect(html).toContain("Aura"); + expect(html).not.toContain("Headache"); + }); + + it("renders nothing when nothing is filed, however much happened meanwhile", () => { + state.filed = []; + state.everything = [ + event("e-headache", "headache", null), + event("e-aura", "aura", "cold"), + ]; + expect(render()).toBe(""); + }); +}); diff --git a/src/components/symptoms/__tests__/symptom-edit-sheet-episode.test.tsx b/src/components/symptoms/__tests__/symptom-edit-sheet-episode.test.tsx new file mode 100644 index 000000000..5f00a7c95 --- /dev/null +++ b/src/components/symptoms/__tests__/symptom-edit-sheet-episode.test.tsx @@ -0,0 +1,168 @@ +/** + * The occurrence edit sheet carries the illness link (#1237). + * + * Before the fix the sheet held the time, the intensity and the note, and no + * link at all: an occurrence filed against an illness opened without any sign + * of it, and the link could not be changed once logged. + * + * Mutation checks (each run, each seen red): + * - remove `` from the sheet → "opens with the illness the + * occurrence is filed against" goes red; + * - drop `keepId` from `linkableEpisodes` → "keeps offering the episode it is + * filed against" goes red (the link vanishes once the time no longer fits); + * - offer only open episodes → "offers a resolved episode the occurrence + * happened during" goes red. + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import type { SymptomEventDTO } from "@/lib/symptoms/shared"; + +const state = vi.hoisted(() => ({ + episodes: [] as Array<{ + id: string; + label: string; + onsetAt: string; + resolvedAt: string | null; + }>, +})); + +vi.mock("@/components/illness/use-illness", () => ({ + useIllnessEpisodes: () => ({ data: state.episodes, isLoading: false }), +})); + +vi.mock("../use-symptoms", () => ({ + useUpdateSymptomEvent: () => ({ mutateAsync: vi.fn(), isPending: false }), + useUpdateSymptomDefinition: () => ({ + mutateAsync: vi.fn(), + isPending: false, + }), +})); + +// The sheet portals; render its body inline. +vi.mock("@/components/ui/responsive-sheet", () => ({ + ResponsiveSheet: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +// Radix Select shows its value only after mount; a plain stand-in exposes +// the chosen value and the offered items to static markup. +vi.mock("@/components/ui/select", () => ({ + Select: ({ + value, + children, + }: { + value: string; + children: React.ReactNode; + }) =>
{children}
, + SelectTrigger: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + SelectValue: () => null, + SelectContent: ({ children }: { children: React.ReactNode }) => ( +
    {children}
+ ), + SelectItem: ({ + value, + children, + }: { + value: string; + children: React.ReactNode; + }) =>
  • {children}
  • , +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { EditSymptomEventSheet } from "../symptom-edit-sheets"; +import { effectiveEpisodeId, linkableEpisodes } from "../episode-link-field"; + +const COLD = { + id: "cold", + label: "Cold", + onsetAt: "2026-08-01T00:00:00.000Z", + resolvedAt: "2026-08-08T23:00:00.000Z", +}; +const MIGRAINE = { + id: "migraine", + label: "Migraine", + onsetAt: "2026-01-01T00:00:00.000Z", + resolvedAt: null, +}; +const FLU = { + id: "flu", + label: "Flu", + onsetAt: "2026-03-01T00:00:00.000Z", + resolvedAt: "2026-03-10T00:00:00.000Z", +}; + +function occurrence( + occurredAt: string, + episodeId: string | null, +): SymptomEventDTO { + return { + id: "ev1", + definitionId: "aura", + occurredAt, + intensity: 4, + note: null, + episodeId, + createdAt: occurredAt, + updatedAt: occurredAt, + }; +} + +function render(event: SymptomEventDTO): string { + return renderToStaticMarkup( + + {}} /> + , + ); +} + +describe(" illness link", () => { + it("opens with the illness the occurrence is filed against", () => { + state.episodes = [MIGRAINE, COLD]; + const html = render(occurrence("2026-08-05T09:00:00.000Z", "cold")); + expect(html).toContain('for="symptom-event-episode"'); + expect(html).toContain('data-selected="cold"'); + expect(html).toContain('data-option="none"'); + }); + + it("offers a resolved episode the occurrence happened during, and not one it did not", () => { + state.episodes = [MIGRAINE, COLD, FLU]; + const html = render(occurrence("2026-08-05T09:00:00.000Z", null)); + expect(html).toContain('data-selected="none"'); + expect(html).toContain('data-option="cold"'); + expect(html).toContain('data-option="migraine"'); + expect(html).not.toContain('data-option="flu"'); + }); + + it("keeps offering the episode it is filed against, even outside its span", () => { + // Filed with the flu, then the flu's recovery date was moved earlier: the + // server keeps the unchanged link, so the sheet shows it. + state.episodes = [MIGRAINE, FLU]; + const html = render(occurrence("2026-03-20T09:00:00.000Z", "flu")); + expect(html).toContain('data-selected="flu"'); + expect(html).toContain('data-option="flu"'); + }); +}); + +describe("linkableEpisodes / effectiveEpisodeId", () => { + it("admits open episodes and resolved ones whose span holds the time", () => { + const ids = (at: string, keep: string | null = null) => + linkableEpisodes([MIGRAINE, COLD, FLU], at, keep).map((e) => e.id); + expect(ids("2026-08-05T09:00")).toEqual(["migraine", "cold"]); + expect(ids("2026-09-01T09:00")).toEqual(["migraine"]); + // Before the cold began: not the cold's. + expect(ids("2026-07-31T09:00")).toEqual(["migraine"]); + expect(ids("2026-09-01T09:00", "flu")).toEqual(["migraine", "flu"]); + expect(ids("not a time")).toEqual([]); + }); + + it("drops a choice the time has moved away from", () => { + const options = linkableEpisodes([MIGRAINE, COLD], "2026-09-01T09:00"); + expect(effectiveEpisodeId("cold", options)).toBeNull(); + expect(effectiveEpisodeId("migraine", options)).toBe("migraine"); + expect(effectiveEpisodeId(null, options)).toBeNull(); + }); +}); diff --git a/src/components/symptoms/__tests__/symptom-entry-form.test.tsx b/src/components/symptoms/__tests__/symptom-entry-form.test.tsx index a5f67d51f..0cf421310 100644 --- a/src/components/symptoms/__tests__/symptom-entry-form.test.tsx +++ b/src/components/symptoms/__tests__/symptom-entry-form.test.tsx @@ -1,11 +1,14 @@ /** * The symptom quick-entry form (v1.40): the person's symptoms as chips with * the inline "add" chip beside them, the 0-10 slider, and the "during" episode - * selector only while an episode is open. + * selector whenever an episode ran at the chosen time. * * Mutation checks (each run, each seen red): - * - drop the `resolvedAt === null` filter on the episodes → "offers no - * episode selector when every episode is resolved" goes red; + * - offer every episode instead of `linkableEpisodes(...)` → "offers no + * episode selector when every episode ended before the chosen time" goes + * red; + * - offer only open episodes (the pre-#1237 filter) → "offers a resolved + * episode that ran on the chosen day" goes red; * - render the add chip unconditionally → "a delegate who may log but not * manage gets no add chip" goes red; * - drop the `definitions.isError` branch → "a failed read shows the error @@ -20,6 +23,7 @@ const state = vi.hoisted(() => ({ episodes: [] as Array<{ id: string; label: string; + onsetAt: string; resolvedAt: string | null; }>, })); @@ -86,10 +90,10 @@ vi.mock("../use-symptoms", () => ({ import { I18nProvider } from "@/lib/i18n/context"; import { SymptomEntryForm } from "../symptom-entry-form"; -function render(): string { +function render(defaultDate?: string): string { return renderToStaticMarkup( - + , ); } @@ -116,17 +120,42 @@ describe("", () => { it("offers the episode selector only while an episode is open", () => { state.canManage = true; - state.episodes = [{ id: "e1", label: "Migraine", resolvedAt: null }]; + state.episodes = [ + { + id: "e1", + label: "Migraine", + onsetAt: "2026-01-01T00:00:00.000Z", + resolvedAt: null, + }, + ]; expect(render()).toContain("-episode"); }); - it("offers no episode selector when every episode is resolved", () => { + it("offers no episode selector when every episode ended before the chosen time", () => { state.episodes = [ - { id: "e1", label: "Cold", resolvedAt: "2026-08-08T00:00:00.000Z" }, + { + id: "e1", + label: "Cold", + onsetAt: "2026-08-01T00:00:00.000Z", + resolvedAt: "2026-08-08T00:00:00.000Z", + }, ]; expect(render()).not.toContain("-episode"); }); + it("offers a resolved episode that ran on the chosen day", () => { + // Opened from a day inside the episode: what happened then belongs to it. + state.episodes = [ + { + id: "e1", + label: "Cold", + onsetAt: "2026-08-01T00:00:00.000Z", + resolvedAt: "2026-08-08T23:00:00.000Z", + }, + ]; + expect(render("2026-08-05")).toContain("-episode"); + }); + it("a failed read shows the error row, not the empty hint", () => { state.canManage = true; state.episodes = []; diff --git a/src/components/symptoms/episode-link-field.tsx b/src/components/symptoms/episode-link-field.tsx new file mode 100644 index 000000000..b8fd329f8 --- /dev/null +++ b/src/components/symptoms/episode-link-field.tsx @@ -0,0 +1,116 @@ +"use client"; + +/** + * The "during an illness" selector of the symptom entry and edit sheets: which + * illness episode an occurrence is filed against. The link it writes + * (`SymptomEvent.episodeId`) is the only thing that assigns an occurrence to + * an illness; the episode detail lists exactly the occurrences filed there. + * + * It offers the episodes the server admits at the occurrence's time + * (`isEpisodeLinkableAt`: open, or resolved with the time inside its span), + * plus, in the edit sheet, the episode the occurrence is already filed + * against, because the server keeps an unchanged link even after that + * episode resolved. Moving the time so the chosen episode no longer fits + * falls back to "not linked" rather than sending a link the server refuses. + */ +import { FieldGroup } from "@/components/ui/field-group"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import type { IllnessEpisodeDTO } from "@/components/illness/types"; +import { useTranslations } from "@/lib/i18n/context"; +import { isEpisodeLinkableAt } from "@/lib/symptoms/shared"; + +const NO_EPISODE = "none"; + +type LinkableEpisode = Pick< + IllnessEpisodeDTO, + "id" | "label" | "onsetAt" | "resolvedAt" +>; + +/** + * The episodes an occurrence at `occurredAt` (a `DateTimeField` value or an + * ISO instant) may be filed against, plus `keepId` when it names one of them. + */ +export function linkableEpisodes( + episodes: readonly T[], + occurredAt: string, + keepId: string | null = null, +): T[] { + const at = new Date(occurredAt); + return episodes.filter( + (episode) => + episode.id === keepId || + (!Number.isNaN(at.getTime()) && isEpisodeLinkableAt(episode, at)), + ); +} + +/** + * The link to send: `chosen` when it is still among `options`, else none. A + * choice the time has since moved away from is dropped, not sent. + */ +export function effectiveEpisodeId( + chosen: string | null, + options: readonly LinkableEpisode[], +): string | null { + return chosen !== null && options.some((episode) => episode.id === chosen) + ? chosen + : null; +} + +export function EpisodeLinkField({ + id, + value, + onChange, + options, +}: { + id: string; + /** The effective link (`effectiveEpisodeId`), null for none. */ + value: string | null; + onChange: (episodeId: string | null) => void; + /** From `linkableEpisodes`. Renders nothing when empty. */ + options: readonly LinkableEpisode[]; +}) { + const { t } = useTranslations(); + if (options.length === 0) return null; + return ( + + {t("symptoms.entry.during")} + + ({t("common.optional")}) + + + } + > + + + ); +} diff --git a/src/components/symptoms/episode-symptoms-card.tsx b/src/components/symptoms/episode-symptoms-card.tsx index 3a6acb468..b81d081f6 100644 --- a/src/components/symptoms/episode-symptoms-card.tsx +++ b/src/components/symptoms/episode-symptoms-card.tsx @@ -2,15 +2,18 @@ /** * "Symptoms logged" on an episode's detail page (v1.40): the person's own - * symptom occurrences that belong to this episode, either because they were - * filed against it or because they happened while it ran (onset to recovery, - * or to now while it is open). The filed ones say so. Renders nothing when - * there are none, so an episode without them looks exactly as before. + * symptom occurrences filed against this episode, and only those. Renders + * nothing when there are none, so an episode without them looks exactly as + * before. * - * This is the reader of `SymptomEvent.episodeId`; the quick-entry sheet's - * "during" selector is its writer. + * Filed means `SymptomEvent.episodeId`, the link the "during" selector of the + * entry and edit sheets writes. Until v1.43.4 this card also listed every + * occurrence that merely fell inside the episode's span, so a long-running + * condition showed the symptoms of every other illness overlapping it, and + * choosing an illness in the sheet changed nothing visible. The time span + * decides which episodes the selector offers; it never assigns. */ -import { useMemo, useState } from "react"; +import { useMemo } from "react"; import { Activity } from "lucide-react"; import { TileHeader } from "@/components/insights/tile-header"; @@ -19,7 +22,6 @@ import { QueryErrorCard } from "@/components/ui/query-error-card"; import { useAuth } from "@/hooks/use-auth"; import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; -import type { SymptomEventDTO } from "@/lib/symptoms/shared"; import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; import { @@ -29,11 +31,11 @@ import { } from "./symptoms-section"; import { useSymptomDefinitions, useSymptomEvents } from "./use-symptoms"; -export function EpisodeSymptomsCard({ - episode, -}: { - episode: { id: string; onsetAt: string; resolvedAt: string | null }; -}) { +/** The whole history: a filed occurrence may lie anywhere in time. */ +const FILED_FROM = "1900-01-01T00:00:00.000Z"; +const FILED_TO = "9999-12-31T23:59:59.999Z"; + +export function EpisodeSymptomsCard({ episode }: { episode: { id: string } }) { const { t } = useTranslations(); const fmt = useFormatters(); const { user } = useAuth(); @@ -41,52 +43,38 @@ export function EpisodeSymptomsCard({ const canManage = canManageDomain("illness"); const timezone = user?.timezone ?? DEFAULT_TIMEZONE; - // The span is fixed per episode state, so the key stays stable while the - // page is open; an open episode reads up to a few minutes past mount. - const [openUntil] = useState(() => - new Date(Date.now() + 10 * 60 * 1000).toISOString(), - ); - const range = { - from: episode.onsetAt, - to: episode.resolvedAt ?? openUntil, - }; const definitions = useSymptomDefinitions(true); - const inSpan = useSymptomEvents(range.from, range.to); - const filed = useSymptomEvents("1900-01-01T00:00:00.000Z", range.to, { + const filed = useSymptomEvents(FILED_FROM, FILED_TO, { episodeId: episode.id, }); - const merged = useMemo(() => { - const byId = new Map(); - for (const event of [ - ...(inSpan.data?.events ?? []), - ...(filed.data?.events ?? []), - ]) { - byId.set(event.id, event); - } - return [...byId.values()].sort((a, b) => - a.occurredAt < b.occurredAt ? 1 : a.occurredAt > b.occurredAt ? -1 : 0, - ); - }, [inSpan.data, filed.data]); + // The route already narrows to the link; the guard keeps a stale or wider + // answer from ever showing another episode's occurrence here. + const events = useMemo( + () => + (filed.data?.events ?? []).filter( + (event) => event.episodeId === episode.id, + ), + [filed.data, episode.id], + ); - if (definitions.isError || inSpan.isError || filed.isError) { + if (definitions.isError || filed.isError) { return ( { void definitions.refetch(); - void inSpan.refetch(); void filed.refetch(); }} /> ); } - if (merged.length === 0) return null; + if (events.length === 0) return null; const byId = new Map( (definitions.data?.definitions ?? []).map((d) => [d.id, d]), ); - const days = groupEventsByDay(merged, timezone); + const days = groupEventsByDay(events, timezone); return ( @@ -113,11 +101,6 @@ export function EpisodeSymptomsCard({ key={event.id} event={event} definition={definition} - annotation={ - event.episodeId === episode.id - ? t("symptoms.episode.filed") - : undefined - } actions={ canManage ? ( diff --git a/src/components/symptoms/symptom-edit-sheets.tsx b/src/components/symptoms/symptom-edit-sheets.tsx index 9a542c6f4..fe2a44bbc 100644 --- a/src/components/symptoms/symptom-edit-sheets.tsx +++ b/src/components/symptoms/symptom-edit-sheets.tsx @@ -2,9 +2,10 @@ /** * The two edit sheets of the symptoms section (v1.40): one occurrence (how - * strong, when, the note) and one symptom (its name and icon). Same - * `ResponsiveSheet` + `FieldGroup` + `SliderField` vocabulary as the entry - * form, with Cancel + Save in the sheet footer like the illness day sheet. + * strong, when, the note, the illness it is filed against) and one symptom + * (its name and icon). Same `ResponsiveSheet` + `FieldGroup` + `SliderField` + * vocabulary as the entry form, with Cancel + Save in the sheet footer like + * the illness day sheet. */ import { useState } from "react"; @@ -23,7 +24,13 @@ import { type SymptomDefinitionDTO, type SymptomEventDTO, } from "@/lib/symptoms/shared"; +import { useIllnessEpisodes } from "@/components/illness/use-illness"; +import { + effectiveEpisodeId, + EpisodeLinkField, + linkableEpisodes, +} from "./episode-link-field"; import { SymptomIconPicker } from "./symptom-icon-picker"; import { useUpdateSymptomDefinition, @@ -55,7 +62,22 @@ export function EditSymptomEventSheet({ const [intensity, setIntensity] = useState(event.intensity); const [occurredAt, setOccurredAt] = useState(toLocalValue(event.occurredAt)); const [note, setNote] = useState(event.note ?? ""); + // The occurrence's illness link, loaded with it and editable like the rest + // (#1237: the sheet used to carry no link at all, so an occurrence's + // illness could be neither seen nor changed once logged). + const [episodeId, setEpisodeId] = useState(event.episodeId); const [error, setError] = useState(null); + const episodes = useIllnessEpisodes(true); + const episodeOptions = linkableEpisodes( + episodes.data ?? [], + occurredAt, + event.episodeId, + ); + const linkedEpisodeId = effectiveEpisodeId(episodeId, episodeOptions); + // What the sheet showed when it opened. Only a change from that is sent, so + // a link the list cannot show (a deleted episode, the list still loading) + // is left alone rather than silently dropped. + const initialEpisodeId = effectiveEpisodeId(event.episodeId, episodeOptions); const low = t("symptoms.entry.anchorLow"); const high = t("symptoms.entry.anchorHigh"); @@ -68,6 +90,9 @@ export function EditSymptomEventSheet({ intensity, occurredAt: new Date(occurredAt).toISOString(), note: note.trim() ? note.trim() : null, + ...(linkedEpisodeId !== initialEpisodeId + ? { episodeId: linkedEpisodeId } + : {}), }, }); onClose(); @@ -124,6 +149,12 @@ export function EditSymptomEventSheet({ required /> + (null); @@ -95,7 +95,7 @@ export function SymptomEntryForm({ localDateTimeValue(defaultDate), ); const [note, setNote] = useState(""); - const [episodeId, setEpisodeId] = useState(NO_EPISODE); + const [episodeId, setEpisodeId] = useState(null); const [symptomError, setSymptomError] = useState(null); const [intensityError, setIntensityError] = useState(null); const [error, setError] = useState(null); @@ -106,18 +106,16 @@ export function SymptomEntryForm({ const list = definitions.data?.definitions ?? []; const limit = definitions.data?.limit ?? 0; const atLimit = limit > 0 && list.length >= limit; - // Only an open episode can take a new occurrence; the server refuses the - // rest, so the selector never offers them. - const openEpisodes = (episodes.data ?? []).filter( - (episode) => episode.resolvedAt === null, - ); + // Exactly the episodes the server admits at the chosen time. + const episodeOptions = linkableEpisodes(episodes.data ?? [], occurredAt); + const linkedEpisodeId = effectiveEpisodeId(episodeId, episodeOptions); function resetForm() { setDefinitionId(null); setIntensity(null); setOccurredAt(localDateTimeValue(defaultDate)); setNote(""); - setEpisodeId(NO_EPISODE); + setEpisodeId(null); setSymptomError(null); setIntensityError(null); setError(null); @@ -142,7 +140,7 @@ export function SymptomEntryForm({ intensity, occurredAt: new Date(occurredAt).toISOString(), note: note.trim() ? note.trim() : undefined, - episodeId: episodeId === NO_EPISODE ? undefined : episodeId, + episodeId: linkedEpisodeId ?? undefined, }); toast.success( t("common.saved"), @@ -342,35 +340,12 @@ export function SymptomEntryForm({ /> - {openEpisodes.length > 0 ? ( - - {t("symptoms.entry.during")} - - ({t("common.optional")}) - - - } - > - - - ) : null} + [episode.id, episode.label]), + ); const [logOpen, setLogOpen] = useState(false); const [editingDefinition, setEditingDefinition] = useState(null); @@ -499,11 +506,19 @@ export function SymptomsSection() { const definition = byId.get(event.definitionId); const label = definition?.label ?? t("symptoms.unreadableLabel"); + const illness = event.episodeId + ? episodeLabelById.get(event.episodeId) + : undefined; return ( diff --git a/src/lib/day/records.ts b/src/lib/day/records.ts index 4386287ee..2d8d6cd11 100644 --- a/src/lib/day/records.ts +++ b/src/lib/day/records.ts @@ -537,6 +537,9 @@ async function symptomsPart(frame: DayFrame): Promise { intensity: true, noteEncrypted: true, episodeId: true, + // The link opens its episode only while that episode exists; a link to + // a deleted one opens the illness page like an unfiled occurrence. + episode: { select: { deletedAt: true } }, definition: { select: { labelEncrypted: true } }, }, orderBy: { occurredAt: "asc" }, @@ -552,7 +555,10 @@ async function symptomsPart(frame: DayFrame): Promise { title: openText(row.definition.labelEncrypted, "symptom label") ?? "", meta: String(row.intensity), note: openText(row.noteEncrypted, "symptom note"), - href: row.episodeId ? `/illness/${row.episodeId}` : "/illness", + href: + row.episodeId && row.episode && row.episode.deletedAt === null + ? `/illness/${row.episodeId}` + : "/illness", }), ), }; diff --git a/src/lib/openapi/routes/symptoms.ts b/src/lib/openapi/routes/symptoms.ts index 8f3df7bf4..3ca724052 100644 --- a/src/lib/openapi/routes/symptoms.ts +++ b/src/lib/openapi/routes/symptoms.ts @@ -48,19 +48,19 @@ const updateSymptomDefinitionRequest = symptomDefinitionUpdateSchema.meta({ const createSymptomEventRequest = symptomEventCreateSchema.meta({ id: "CreateSymptomEventRequest", description: - "Log one occurrence. `intensity` is 0-10 (0 none, 10 the worst imaginable). `occurredAt` defaults to now and may not lie in the future beyond five minutes. `episodeId`, when given, must name one of the record's own illness episodes that is still open (422 `symptoms.episode.notOpen` otherwise). The note is encrypted at rest.", + "Log one occurrence. `intensity` is 0-10 (0 none, 10 the worst imaginable). `occurredAt` defaults to now and may not lie in the future beyond five minutes. `episodeId`, when given, must name one of the record's own illness episodes that is still open, or a resolved one whose onset-to-recovery span holds `occurredAt` (422 `symptoms.episode.notOpen` otherwise). The note is encrypted at rest.", }); const updateSymptomEventRequest = symptomEventUpdateSchema.meta({ id: "UpdateSymptomEventRequest", description: - "Partial edit of one occurrence. `note: null` clears the note, `episodeId: null` drops the link. A changed `episodeId` must name an open episode; an unchanged link to an episode that has since resolved is kept.", + "Partial edit of one occurrence. `note: null` clears the note, `episodeId: null` drops the link. A changed `episodeId` must name an open episode or a resolved one whose span holds the occurrence's time after the edit; an unchanged link to an episode that has since resolved is kept.", }); const listSymptomEventsQuery = symptomEventListQuerySchema.meta({ id: "ListSymptomEventsQuery", description: - "Optional `from` / `to` (ISO instants, inclusive), `definitionId`, `episodeId`, and `limit` (1-500, default 200). Newest first.", + "Optional `from` / `to` (ISO instants, inclusive), `definitionId`, `episodeId` (only the occurrences filed against that episode: the link, not the time span, is what assigns an occurrence to an illness), and `limit` (1-500, default 200). Newest first.", }); const symptomDefinition = z diff --git a/src/lib/symptoms/server.ts b/src/lib/symptoms/server.ts index fbf46feae..4ee625396 100644 --- a/src/lib/symptoms/server.ts +++ b/src/lib/symptoms/server.ts @@ -16,7 +16,11 @@ import type { SymptomEvent, } from "@/generated/prisma/client"; -import type { SymptomDefinitionDTO, SymptomEventDTO } from "./shared"; +import { + isEpisodeLinkableAt, + type SymptomDefinitionDTO, + type SymptomEventDTO, +} from "./shared"; export * from "./shared"; @@ -137,18 +141,19 @@ export async function listSymptomDefinitions( } /** - * Whether `episodeId` names an episode an event of `userId` may be filed - * against: the caller's own, not deleted, and still open. A resolved episode - * is history; filing a new occurrence into it would rewrite what the person - * recorded about how it ended. + * Whether `episodeId` names an episode an event of `userId` at `occurredAt` + * may be filed against: the caller's own, not deleted, and linkable at that + * instant by the shared rule (`isEpisodeLinkableAt`): still open, or resolved + * after an onset the occurrence falls between. */ export async function isLinkableEpisode( userId: string, episodeId: string, + occurredAt: Date, ): Promise { const episode = await prisma.illnessEpisode.findFirst({ - where: { id: episodeId, userId, deletedAt: null, resolvedAt: null }, - select: { id: true }, + where: { id: episodeId, userId, deletedAt: null }, + select: { onsetAt: true, resolvedAt: true }, }); - return episode !== null; + return episode !== null && isEpisodeLinkableAt(episode, occurredAt); } diff --git a/src/lib/symptoms/shared.ts b/src/lib/symptoms/shared.ts index 5738d3467..1ea03afec 100644 --- a/src/lib/symptoms/shared.ts +++ b/src/lib/symptoms/shared.ts @@ -70,6 +70,31 @@ export interface SymptomEventDTO { updatedAt: string; } +/** + * Whether an occurrence at `occurredAt` may be filed against `episode`: one + * that is still open, or one that had begun by then and was not yet over. A + * resolved episode takes only what happened while it ran, so filing + * something into it never rewrites how it ended; an open one also takes what + * came just before its recorded onset, the way a cold starts before anyone + * names it. + * + * The one rule. The server checks it before writing a new link + * (`isLinkableEpisode`), and the entry and edit sheets offer exactly the + * episodes it admits, so the selector never offers what the server refuses. + */ +export function isEpisodeLinkableAt( + episode: { onsetAt: Date | string; resolvedAt: Date | string | null }, + occurredAt: Date | string, +): boolean { + if (episode.resolvedAt === null) return true; + const at = new Date(occurredAt).getTime(); + if (Number.isNaN(at)) return false; + return ( + new Date(episode.onsetAt).getTime() <= at && + at <= new Date(episode.resolvedAt).getTime() + ); +} + const labelSchema = z .string() .trim() @@ -120,7 +145,10 @@ export const symptomEventCreateSchema = z.object({ occurredAt: occurredAtSchema.optional(), intensity: intensitySchema, note: z.string().trim().max(500).nullish(), - /** Must be one of the caller's own episodes that is still open. */ + /** + * One of the caller's own episodes that is open, or that ran over + * `occurredAt` (`isEpisodeLinkableAt`). + */ episodeId: z.string().min(1).max(40).nullish(), }); diff --git a/tests/integration/symptoms.test.ts b/tests/integration/symptoms.test.ts index 330d0dfbe..1c22323de 100644 --- a/tests/integration/symptoms.test.ts +++ b/tests/integration/symptoms.test.ts @@ -8,8 +8,12 @@ * the intensity CHECK holds; * - the eight-definition cap counts active definitions only, and showing a * hidden one again re-checks it; - * - an occurrence can be filed against the record's own OPEN episode and - * against nothing else: not a resolved one, not another account's; + * - an occurrence can be filed against the record's own episode while it is + * open, or a resolved one it happened during, and against nothing else: + * not a resolved one it fell outside of, not another account's; + * - the link is what assigns (#1237): an episode's list holds what is filed + * against it and nothing that merely overlaps it, the edit re-files and + * clears the link, and the day view opens a link only to a live episode; * - each active definition reaches the discovery matrix as its own * `SYMPTOM:` channel, labelled with its name, and leaves it when the * illness module is off; @@ -18,8 +22,12 @@ * does not carry. * * Mutation checks (each run, each seen red): - * - drop `resolvedAt: null` from `isLinkableEpisode` → "refuses a resolved - * episode" goes red (the occurrence is linked to a closed episode); + * - make `isEpisodeLinkableAt` admit every episode → "refuses a resolved + * episode" and "refuses a time outside a resolved episode" go red; + * - check a PATCH's new link against the stored time instead of the edited + * one → "judges a moved time and a new link together" goes red; + * - drop the live-episode condition from the day view's symptom link → + * "opens a link from the day view only to a live episode" goes red; * - drop `userId` from the same lookup → "refuses another account's episode" * goes red; * - remove the cap check in the definitions POST → "holds eight active @@ -396,6 +404,59 @@ describe("symptom occurrences", () => { ).toBe(0); }); + it("files an occurrence with a resolved episode it happened during", async () => { + const id = await defineSymptom("Aura"); + const cold = await getPrismaClient().illnessEpisode.create({ + data: { + userId: OWNER_ID, + label: "Cold", + type: "INFECTION", + onsetAt: new Date("2026-08-01T00:00:00.000Z"), + resolvedAt: new Date("2026-08-08T00:00:00.000Z"), + }, + }); + const inside = await logEvent({ + definitionId: id, + intensity: 3, + occurredAt: "2026-08-04T09:00:00.000Z", + episodeId: cold.id, + }); + expect(inside.status).toBe(201); + expect( + (await json>(inside)).data.episodeId, + ).toBe(cold.id); + }); + + it("refuses a time outside a resolved episode, before or after it", async () => { + const id = await defineSymptom("Aura"); + const cold = await getPrismaClient().illnessEpisode.create({ + data: { + userId: OWNER_ID, + label: "Cold", + type: "INFECTION", + onsetAt: new Date("2026-08-01T00:00:00.000Z"), + resolvedAt: new Date("2026-08-08T00:00:00.000Z"), + }, + }); + for (const occurredAt of [ + "2026-07-31T23:00:00.000Z", + "2026-08-08T00:01:00.000Z", + ]) { + const response = await logEvent({ + definitionId: id, + intensity: 3, + occurredAt, + episodeId: cold.id, + }); + expect(response.status, occurredAt).toBe(422); + } + expect( + await getPrismaClient().symptomEvent.count({ + where: { episodeId: cold.id }, + }), + ).toBe(0); + }); + it("refuses another account's episode, and nothing is written", async () => { const id = await defineSymptom("Aura"); const foreign = await getPrismaClient().illnessEpisode.create({ @@ -419,6 +480,202 @@ describe("symptom occurrences", () => { ).toBe(0); }); + it("lists for an episode only what is filed with it, not what overlaps it", async () => { + const aura = await defineSymptom("Aura"); + const headache = await defineSymptom("Headache"); + const db = getPrismaClient(); + // A chronic migraine runs all year; a cold overlaps it in October. + const migraine = await db.illnessEpisode.create({ + data: { + userId: OWNER_ID, + label: "Migraine", + type: "CHRONIC", + lifecycle: "CHRONIC_ONGOING", + onsetAt: new Date("2026-01-01T00:00:00.000Z"), + }, + }); + const cold = await db.illnessEpisode.create({ + data: { + userId: OWNER_ID, + label: "Cold", + type: "INFECTION", + onsetAt: new Date("2026-10-01T00:00:00.000Z"), + }, + }); + const ids: Record = {}; + for (const [name, definitionId, episodeId] of [ + ["aura", aura, migraine.id], + ["headache", headache, cold.id], + ["loose", headache, null], + ] as const) { + const response = await logEvent({ + definitionId, + intensity: 5, + occurredAt: "2026-10-03T09:00:00.000Z", + ...(episodeId ? { episodeId } : {}), + }); + expect(response.status, name).toBe(201); + ids[name] = (await json>(response)).data.id; + } + + const { GET } = await import("@/app/api/symptoms/events/route"); + const listFor = async (episodeId: string) => + ( + await json }>>( + await call( + GET, + "GET", + `/api/symptoms/events?from=1900-01-01T00:00:00.000Z&to=9999-12-31T23:59:59.999Z&episodeId=${episodeId}`, + ), + ) + ).data.events.map((e) => e.id); + expect(await listFor(migraine.id)).toEqual([ids.aura]); + expect(await listFor(cold.id)).toEqual([ids.headache]); + }); + + it("re-files an occurrence on edit and clears its link", async () => { + const id = await defineSymptom("Aura"); + const db = getPrismaClient(); + const migraine = await db.illnessEpisode.create({ + data: { + userId: OWNER_ID, + label: "Migraine", + type: "CHRONIC", + lifecycle: "CHRONIC_ONGOING", + onsetAt: new Date("2026-01-01T00:00:00.000Z"), + }, + }); + const cold = await db.illnessEpisode.create({ + data: { + userId: OWNER_ID, + label: "Cold", + type: "INFECTION", + onsetAt: new Date("2026-10-01T00:00:00.000Z"), + resolvedAt: new Date("2026-10-08T00:00:00.000Z"), + }, + }); + const created = await logEvent({ + definitionId: id, + intensity: 5, + occurredAt: "2026-10-03T09:00:00.000Z", + episodeId: migraine.id, + }); + const eventId = (await json>(created)).data.id; + + const { PATCH } = await import("@/app/api/symptoms/events/[id]/route"); + const patch = (body: Record) => + call(PATCH, "PATCH", `/api/symptoms/events/${eventId}`, body, { + id: eventId, + }); + const linked = async () => + (await db.symptomEvent.findUniqueOrThrow({ where: { id: eventId } })) + .episodeId; + + // Into the cold, which has since resolved but ran that morning. + expect((await patch({ episodeId: cold.id })).status).toBe(200); + expect(await linked()).toBe(cold.id); + // An edit that leaves the link out keeps it. + expect((await patch({ intensity: 6 })).status).toBe(200); + expect(await linked()).toBe(cold.id); + // Cleared. + expect((await patch({ episodeId: null })).status).toBe(200); + expect(await linked()).toBeNull(); + }); + + it("judges a moved time and a new link together", async () => { + const id = await defineSymptom("Aura"); + const db = getPrismaClient(); + const cold = await db.illnessEpisode.create({ + data: { + userId: OWNER_ID, + label: "Cold", + type: "INFECTION", + onsetAt: new Date("2026-10-01T00:00:00.000Z"), + resolvedAt: new Date("2026-10-08T00:00:00.000Z"), + }, + }); + const created = await logEvent({ + definitionId: id, + intensity: 5, + occurredAt: "2026-10-12T09:00:00.000Z", + }); + const eventId = (await json>(created)).data.id; + const { PATCH } = await import("@/app/api/symptoms/events/[id]/route"); + const patch = (body: Record) => + call(PATCH, "PATCH", `/api/symptoms/events/${eventId}`, body, { + id: eventId, + }); + + // At its stored time it lies after the cold: refused. + const refused = await patch({ episodeId: cold.id }); + expect(refused.status).toBe(422); + expect((await json>(refused)).meta?.errorCode).toBe( + "symptoms.episode.notOpen", + ); + // Moved into the cold in the same save: admitted. + const moved = await patch({ + occurredAt: "2026-10-05T09:00:00.000Z", + episodeId: cold.id, + }); + expect(moved.status).toBe(200); + expect( + (await db.symptomEvent.findUniqueOrThrow({ where: { id: eventId } })) + .episodeId, + ).toBe(cold.id); + }); + + it("opens a link from the day view only to a live episode", async () => { + const id = await defineSymptom("Aura"); + const db = getPrismaClient(); + const live = await db.illnessEpisode.create({ + data: { + userId: OWNER_ID, + label: "Migraine", + type: "CHRONIC", + lifecycle: "CHRONIC_ONGOING", + onsetAt: new Date("2026-01-01T00:00:00.000Z"), + }, + }); + const gone = await db.illnessEpisode.create({ + data: { + userId: OWNER_ID, + label: "Cold", + type: "INFECTION", + onsetAt: new Date("2026-10-01T00:00:00.000Z"), + }, + }); + for (const [hour, episodeId] of [ + ["08", live.id], + ["10", gone.id], + ] as const) { + const response = await logEvent({ + definitionId: id, + intensity: 4, + occurredAt: `2026-10-03T${hour}:00:00.000Z`, + episodeId, + }); + expect(response.status).toBe(201); + } + await db.illnessEpisode.update({ + where: { id: gone.id }, + data: { deletedAt: new Date() }, + }); + + const { GET } = await import("@/app/api/day/[date]/route"); + const day = await json< + Envelope<{ events: Array<{ kind: string; at: string; href?: string }> }> + >( + await call(GET, "GET", "/api/day/2026-10-03", undefined, { + date: "2026-10-03", + }), + ); + const hrefs = day.data.events + .filter((e) => e.kind === "symptom") + .sort((a, b) => a.at.localeCompare(b.at)) + .map((e) => e.href); + expect(hrefs).toEqual([`/illness/${live.id}`, "/illness"]); + }); + it("answers illness.disabled with the module off", async () => { await getPrismaClient().user.update({ where: { id: OWNER_ID },