Merge pull request #1238

Release v1.43.4
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-10-17 12:07:08 +02:00
commit 9db0f09ba5
26 files changed
+920 -142

No files matched your search

+13
View File
@@ -1,5 +1,18 @@
# Changelog
## [1.43.4] — 2026-10-17
### Fixed
- **An illness shows only the symptoms filed under it.** The illness detail
view merged the symptom events linked to the illness with every event inside
its date range, so overlapping illnesses showed each other's symptoms. It now
shows linked events only, and every symptom row on the illness page names its
illness. The symptom edit sheet has the illness field, with the current link
preselected; an event can be filed under an ongoing illness or an ended one
whose dates contain it, and POST and PATCH accept the same set. The day view
no longer links a symptom to a deleted illness (Refs #1237).
## [1.43.3] — 2026-10-17
### Fixed
+5 -3
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.43.3
version: 1.43.4
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
@@ -35570,7 +35570,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:
@@ -35594,7 +35595,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:
+1 -1
View File
@@ -12906,7 +12906,7 @@
},
"episode": {
"title": "Erfasste Symptome",
"filed": "zu dieser Erkrankung erfasst"
"during": "bei {illness}"
}
},
"day": {
+1 -1
View File
@@ -12906,7 +12906,7 @@
},
"episode": {
"title": "Symptoms logged",
"filed": "logged with this illness"
"during": "during {illness}"
}
},
"day": {
+1 -1
View File
@@ -12906,7 +12906,7 @@
},
"episode": {
"title": "Síntomas registrados",
"filed": "registrado con esta enfermedad"
"during": "durante {illness}"
}
},
"day": {
+1 -1
View File
@@ -12906,7 +12906,7 @@
},
"episode": {
"title": "Symptômes notés",
"filed": "noté avec cette maladie"
"during": "pendant {illness}"
}
},
"day": {
+1 -1
View File
@@ -12906,7 +12906,7 @@
},
"episode": {
"title": "Sintomi registrati",
"filed": "registrato con questa malattia"
"during": "durante {illness}"
}
},
"day": {
+1 -1
View File
@@ -12906,7 +12906,7 @@
},
"episode": {
"title": "기록된 증상",
"filed": "이 질병과 함께 기록됨"
"during": "{illness} 중"
}
},
"day": {
+1 -1
View File
@@ -12906,7 +12906,7 @@
},
"episode": {
"title": "Zapisane objawy",
"filed": "zapisano przy tej chorobie"
"during": "podczas: {illness}"
}
},
"day": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.43.3",
"version": "1.43.4",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
+1 -1
View File
@@ -36,7 +36,7 @@ try {
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
/* @sw-version-fallback */ "v1.43.3";
/* @sw-version-fallback */ "v1.43.4";
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
+11 -4
View File
@@ -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",
});
}
+8 -4
View File
@@ -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<Response> {
});
}
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<Response> {
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,
@@ -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(
<I18nProvider initialLocale="en">
<EpisodeSymptomsCard episode={{ id: "migraine" }} />
</I18nProvider>,
);
}
beforeEach(() => {
state.calls = [];
});
describe("<EpisodeSymptomsCard>", () => {
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("");
});
});
@@ -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 `<EpisodeLinkField>` 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 }) => (
<div data-slot="mock-responsive-sheet">{children}</div>
),
}));
// 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;
}) => <div data-selected={value}>{children}</div>,
SelectTrigger: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
SelectValue: () => null,
SelectContent: ({ children }: { children: React.ReactNode }) => (
<ul>{children}</ul>
),
SelectItem: ({
value,
children,
}: {
value: string;
children: React.ReactNode;
}) => <li data-option={value}>{children}</li>,
}));
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(
<I18nProvider initialLocale="en">
<EditSymptomEventSheet event={event} label="Aura" onClose={() => {}} />
</I18nProvider>,
);
}
describe("<EditSymptomEventSheet> 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();
});
});
@@ -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(
<I18nProvider initialLocale="en">
<SymptomEntryForm />
<SymptomEntryForm defaultDate={defaultDate} />
</I18nProvider>,
);
}
@@ -116,17 +120,42 @@ describe("<SymptomEntryForm>", () => {
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 = [];
@@ -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<T extends LinkableEpisode>(
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 (
<FieldGroup
htmlFor={id}
label={
<>
{t("symptoms.entry.during")}
<span className="text-muted-foreground ml-1 font-normal">
({t("common.optional")})
</span>
</>
}
>
<Select
value={value ?? NO_EPISODE}
onValueChange={(next) => onChange(next === NO_EPISODE ? null : next)}
>
<SelectTrigger
id={id}
className="w-full"
data-testid="symptom-episode-select"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={NO_EPISODE}>
{t("symptoms.entry.duringNone")}
</SelectItem>
{options.map((episode) => (
<SelectItem key={episode.id} value={episode.id}>
{episode.label}
</SelectItem>
))}
</SelectContent>
</Select>
</FieldGroup>
);
}
@@ -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<string, SymptomEventDTO>();
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 (
<QueryErrorCard
title={t("symptoms.section.loadError")}
onRetry={() => {
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 (
<Card data-testid="episode-symptoms-card">
@@ -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 ? (
<SymptomEventMenu event={event} label={label} />
@@ -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<number>(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<string | null>(event.episodeId);
const [error, setError] = useState<string | null>(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
/>
</FieldGroup>
<EpisodeLinkField
id="symptom-event-episode"
value={linkedEpisodeId}
onChange={setEpisodeId}
options={episodeOptions}
/>
<FieldGroup
htmlFor="symptom-event-note"
label={
+23 -48
View File
@@ -2,8 +2,9 @@
/**
* Log one occurrence of a symptom the person defined (v1.40): which one, how
* strong (0-10, the scale the pain score uses), when, an optional note, and,
* only while an illness episode is open, which episode it happened during.
* strong (0-10, the scale the pain score uses), when, an optional note, and
* which illness episode it belongs to, offered whenever one ran at that time
* (`EpisodeLinkField`).
*
* Built like the measurement and mood quick-entry forms it sits beside: the
* `FieldGroup` rhythm, `SliderField` for the bounded scale, `DateTimeField`
@@ -29,13 +30,6 @@ import { FieldGroup } from "@/components/ui/field-group";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { SliderField } from "@/components/ui/slider";
import {
useActiveRecordName,
@@ -51,11 +45,15 @@ import { cn } from "@/lib/utils";
import { useIllnessEpisodes } from "@/components/illness/use-illness";
import { AddSymptomChip } from "./add-symptom-chip";
import {
effectiveEpisodeId,
EpisodeLinkField,
linkableEpisodes,
} from "./episode-link-field";
import { SymptomIcon } from "./symptom-icons";
import { useLogSymptomEvent, useSymptomDefinitions } from "./use-symptoms";
const NOTE_MAX_LENGTH = 500;
const NO_EPISODE = "none";
function nowLocalValue(): string {
const now = new Date();
@@ -86,7 +84,9 @@ export function SymptomEntryForm({
const { canManageDomain } = useRecordCapabilities();
const canDefine = canManageDomain("illness");
const definitions = useSymptomDefinitions(false);
const episodes = useIllnessEpisodes(false);
// Resolved ones too: a backdated occurrence may belong to an illness that
// has since ended, as long as it happened while that one ran.
const episodes = useIllnessEpisodes(true);
const log = useLogSymptomEvent();
const [definitionId, setDefinitionId] = useState<string | null>(null);
@@ -95,7 +95,7 @@ export function SymptomEntryForm({
localDateTimeValue(defaultDate),
);
const [note, setNote] = useState("");
const [episodeId, setEpisodeId] = useState<string>(NO_EPISODE);
const [episodeId, setEpisodeId] = useState<string | null>(null);
const [symptomError, setSymptomError] = useState<string | null>(null);
const [intensityError, setIntensityError] = useState<string | null>(null);
const [error, setError] = useState<string | null>(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({
/>
</FieldGroup>
{openEpisodes.length > 0 ? (
<FieldGroup
htmlFor={`${formId}-episode`}
label={
<>
{t("symptoms.entry.during")}
<span className="text-muted-foreground ml-1 font-normal">
({t("common.optional")})
</span>
</>
}
>
<Select value={episodeId} onValueChange={setEpisodeId}>
<SelectTrigger id={`${formId}-episode`} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={NO_EPISODE}>
{t("symptoms.entry.duringNone")}
</SelectItem>
{openEpisodes.map((episode) => (
<SelectItem key={episode.id} value={episode.id}>
{episode.label}
</SelectItem>
))}
</SelectContent>
</Select>
</FieldGroup>
) : null}
<EpisodeLinkField
id={`${formId}-episode`}
value={linkedEpisodeId}
onChange={setEpisodeId}
options={episodeOptions}
/>
<FieldGroup
htmlFor={`${formId}-note`}
+16 -1
View File
@@ -45,6 +45,7 @@ import {
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { TagChip } from "@/components/ui/tag-chip";
import { QuickEntrySheets } from "@/components/dashboard/quick-entry-sheets";
import { useIllnessEpisodes } from "@/components/illness/use-illness";
import { useAuth } from "@/hooks/use-auth";
import { useDeepLinkedRecord } from "@/hooks/use-deep-linked-record";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
@@ -93,7 +94,7 @@ export function SymptomEventRow({
}: {
event: SymptomEventDTO;
definition: SymptomDefinitionDTO | undefined;
/** A short muted tag after the time, e.g. "filed with this episode". */
/** A short muted tag after the time, e.g. the illness it is filed with. */
annotation?: string;
actions?: React.ReactNode;
}) {
@@ -360,6 +361,12 @@ export function SymptomsSection() {
const range = useThirtyDayWindow();
const definitions = useSymptomDefinitions(true);
const events = useSymptomEvents(range.from, range.to);
// Which illness each occurrence is filed against, named on its row, so the
// link the sheets write is visible where the occurrences are listed.
const episodes = useIllnessEpisodes(true);
const episodeLabelById = new Map(
(episodes.data ?? []).map((episode) => [episode.id, episode.label]),
);
const [logOpen, setLogOpen] = useState(false);
const [editingDefinition, setEditingDefinition] =
useState<SymptomDefinitionDTO | null>(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 (
<SymptomEventRow
key={event.id}
event={event}
definition={definition}
annotation={
illness
? t("symptoms.episode.during", { illness })
: undefined
}
actions={
canManage ? (
<SymptomEventMenu event={event} label={label} />
+7 -1
View File
@@ -537,6 +537,9 @@ async function symptomsPart(frame: DayFrame): Promise<SectionPart> {
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<SectionPart> {
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",
}),
),
};
+3 -3
View File
@@ -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
+13 -8
View File
@@ -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<boolean> {
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);
}
+29 -1
View File
@@ -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(),
});
+261 -4
View File
@@ -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:<id>` 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<Envelope<{ episodeId: string }>>(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<string, string> = {};
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<Envelope<{ id: string }>>(response)).data.id;
}
const { GET } = await import("@/app/api/symptoms/events/route");
const listFor = async (episodeId: string) =>
(
await json<Envelope<{ events: Array<{ id: string }> }>>(
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<Envelope<{ id: string }>>(created)).data.id;
const { PATCH } = await import("@/app/api/symptoms/events/[id]/route");
const patch = (body: Record<string, unknown>) =>
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<Envelope<{ id: string }>>(created)).data.id;
const { PATCH } = await import("@/app/api/symptoms/events/[id]/route");
const patch = (body: Record<string, unknown>) =>
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<Envelope<null>>(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 },