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 },