mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
26 files changed
+920
-142
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -12906,7 +12906,7 @@
|
||||
},
|
||||
"episode": {
|
||||
"title": "Erfasste Symptome",
|
||||
"filed": "zu dieser Erkrankung erfasst"
|
||||
"during": "bei {illness}"
|
||||
}
|
||||
},
|
||||
"day": {
|
||||
|
||||
+1
-1
@@ -12906,7 +12906,7 @@
|
||||
},
|
||||
"episode": {
|
||||
"title": "Symptoms logged",
|
||||
"filed": "logged with this illness"
|
||||
"during": "during {illness}"
|
||||
}
|
||||
},
|
||||
"day": {
|
||||
|
||||
+1
-1
@@ -12906,7 +12906,7 @@
|
||||
},
|
||||
"episode": {
|
||||
"title": "Síntomas registrados",
|
||||
"filed": "registrado con esta enfermedad"
|
||||
"during": "durante {illness}"
|
||||
}
|
||||
},
|
||||
"day": {
|
||||
|
||||
+1
-1
@@ -12906,7 +12906,7 @@
|
||||
},
|
||||
"episode": {
|
||||
"title": "Symptômes notés",
|
||||
"filed": "noté avec cette maladie"
|
||||
"during": "pendant {illness}"
|
||||
}
|
||||
},
|
||||
"day": {
|
||||
|
||||
+1
-1
@@ -12906,7 +12906,7 @@
|
||||
},
|
||||
"episode": {
|
||||
"title": "Sintomi registrati",
|
||||
"filed": "registrato con questa malattia"
|
||||
"during": "durante {illness}"
|
||||
}
|
||||
},
|
||||
"day": {
|
||||
|
||||
+1
-1
@@ -12906,7 +12906,7 @@
|
||||
},
|
||||
"episode": {
|
||||
"title": "기록된 증상",
|
||||
"filed": "이 질병과 함께 기록됨"
|
||||
"during": "{illness} 중"
|
||||
}
|
||||
},
|
||||
"day": {
|
||||
|
||||
+1
-1
@@ -12906,7 +12906,7 @@
|
||||
},
|
||||
"episode": {
|
||||
"title": "Zapisane objawy",
|
||||
"filed": "zapisano przy tej chorobie"
|
||||
"during": "podczas: {illness}"
|
||||
}
|
||||
},
|
||||
"day": {
|
||||
|
||||
+1
-1
@@ -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
@@ -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/*`
|
||||
|
||||
@@ -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,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={
|
||||
|
||||
@@ -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`}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
),
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in new issue
Block a user