feat(timeline): draw person-defined symptoms as value lines

A custom symptom could feed pattern discovery and the Coach but could not
be looked at over time. The timeline now offers every symptom the record
has logged, hidden ones included, beside the measurements and lab markers,
and draws a chosen one as a value line.

`values` takes `symptom:<definitionId>`, checked for its shape and capped
like a lab marker; an id the record does not hold draws nothing. A day's
value is its highest intensity on the record's local day; a point is the
mean of the bucket's day values over the days logged, with the number of
those days, and a day without an entry is not a zero. The line sits on a
fixed 0 to 10 scale. Symptoms are read under the illness lane's gate, the
module and sharing domain the symptom routes use. An `all` window reaches
back to a chosen symptom's first entry.

The response carries `symptomSeries` and `availableSymptoms`; the OpenAPI
contract is regenerated. The page words a point's count as days and the
value out of ten. The date-key schema moves to its own module so the
symptom schemas can share it.
This commit is contained in:
Marc-André Bombeck committed 2026-11-07 00:38:53 +01:00
1 parent be7a65bdb9
commit 74e691cecd
39 files changed
+1871 -132

No files matched your search

@@ -0,0 +1,205 @@
/**
* Person-defined symptoms as lines on the timeline (#981): the day value,
* the bucket mean over logged days, and the `values` entries that name one.
*
* A day's value is its highest intensity; a bucket's point is the mean of
* its logged days' values, with the number of those days; a day without an
* entry is not a zero. The day is the record's local day, so a late-evening
* entry and an entry on a clock-change day land where the person lived them.
* All data invented.
*/
import { describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({ prisma: {} }));
import {
capTimelineSeriesKeys,
TIMELINE_LAB_SERIES_MAX,
TIMELINE_SYMPTOM_SERIES_MAX,
timelineSymptomDefinitionId,
timelineSymptomKey,
} from "@/lib/day/contract";
import { parseSeriesKeys } from "../load-timeline";
import {
foldSymptomSeries,
symptomDayPeaks,
type SymptomOccurrence,
} from "../symptom-series";
const at = (iso: string, intensity: number): SymptomOccurrence => ({
occurredAt: new Date(iso),
intensity,
});
const BERLIN = { from: "2026-10-01", to: "2026-11-30", tz: "Europe/Berlin" };
describe("symptomDayPeaks", () => {
it("takes the highest intensity of each local day", () => {
const peaks = symptomDayPeaks(
[
at("2026-10-05T07:00:00.000Z", 3),
at("2026-10-05T15:00:00.000Z", 7),
at("2026-10-05T18:00:00.000Z", 5),
at("2026-10-06T09:00:00.000Z", 0),
],
BERLIN,
);
expect([...peaks]).toEqual([
["2026-10-05", 7],
["2026-10-06", 0],
]);
});
it("puts a late-evening entry on the local day, not the UTC one", () => {
// 22:30 UTC on 25 Oct is 23:30 in Berlin, an hour after the clocks went
// back; 23:30 UTC is already 26 Oct there.
const peaks = symptomDayPeaks(
[at("2026-10-25T22:30:00.000Z", 4), at("2026-10-25T23:30:00.000Z", 6)],
BERLIN,
);
expect([...peaks]).toEqual([
["2026-10-25", 4],
["2026-10-26", 6],
]);
// West of UTC the cut runs the other way: 03:30 UTC on 9 March is still
// 8 March in New York, the evening the clocks went forward.
const ny = symptomDayPeaks([at("2026-03-09T03:30:00.000Z", 8)], {
from: "2026-03-01",
to: "2026-03-31",
tz: "America/New_York",
});
expect([...ny]).toEqual([["2026-03-08", 8]]);
});
it("holds a 23-hour clock-change day whole", () => {
// 29 March 2026 in Berlin runs from 23:00 UTC on the 28th to 22:00 UTC.
const peaks = symptomDayPeaks(
[
at("2026-03-28T23:05:00.000Z", 2),
at("2026-03-29T21:55:00.000Z", 5),
at("2026-03-29T22:05:00.000Z", 9),
],
{ from: "2026-03-01", to: "2026-03-31", tz: "Europe/Berlin" },
);
expect([...peaks]).toEqual([
["2026-03-29", 5],
["2026-03-30", 9],
]);
});
it("leaves out entries outside the window", () => {
const peaks = symptomDayPeaks(
[at("2026-09-30T12:00:00.000Z", 9), at("2026-12-01T12:00:00.000Z", 9)],
BERLIN,
);
expect(peaks.size).toBe(0);
});
});
describe("foldSymptomSeries", () => {
it("averages the day peaks over the logged days only, never counting a silent day as zero", () => {
const points = foldSymptomSeries(
[
// Week of 5 Oct: two logged days, peaks 7 and 4.
at("2026-10-05T07:00:00.000Z", 3),
at("2026-10-05T15:00:00.000Z", 7),
at("2026-10-08T15:00:00.000Z", 4),
// Week of 19 Oct: one day.
at("2026-10-21T10:00:00.000Z", 2),
],
BERLIN,
"week",
);
expect(points).toEqual([
{ t: "2026-10-05", mean: 5.5, count: 2 },
{ t: "2026-10-19", mean: 2, count: 1 },
]);
});
it("sends no point for a bucket without an entry", () => {
const points = foldSymptomSeries(
[at("2026-10-02T10:00:00.000Z", 6), at("2026-11-20T10:00:00.000Z", 3)],
BERLIN,
"week",
);
expect(points.map((p) => p.t)).toEqual(["2026-09-28", "2026-11-16"]);
});
it("buckets a late-evening entry by its local day, across a week boundary and the clock change", () => {
// Sunday 25 Oct 23:30 in Berlin closes the week of 19 Oct; half an hour
// later it is Monday and the next week.
const points = foldSymptomSeries(
[at("2026-10-25T22:30:00.000Z", 4), at("2026-10-25T23:30:00.000Z", 6)],
BERLIN,
"week",
);
expect(points).toEqual([
{ t: "2026-10-19", mean: 4, count: 1 },
{ t: "2026-10-26", mean: 6, count: 1 },
]);
});
it("follows the month, quarter and day buckets", () => {
const rows = [
at("2026-10-05T10:00:00.000Z", 8),
at("2026-10-05T12:00:00.000Z", 2),
at("2026-10-06T10:00:00.000Z", 3),
at("2026-11-02T10:00:00.000Z", 4),
];
expect(foldSymptomSeries(rows, BERLIN, "month")).toEqual([
{ t: "2026-10-01", mean: 5.5, count: 2 },
{ t: "2026-11-01", mean: 4, count: 1 },
]);
expect(foldSymptomSeries(rows, BERLIN, "quarter")).toEqual([
{ t: "2026-10-01", mean: 5, count: 3 },
]);
expect(foldSymptomSeries(rows, BERLIN, "day")).toEqual([
{ t: "2026-10-05", mean: 8, count: 1 },
{ t: "2026-10-06", mean: 3, count: 1 },
{ t: "2026-11-02", mean: 4, count: 1 },
]);
});
it("keeps a logged zero as a value", () => {
expect(
foldSymptomSeries([at("2026-10-05T10:00:00.000Z", 0)], BERLIN, "month"),
).toEqual([{ t: "2026-10-01", mean: 0, count: 1 }]);
});
});
describe("symptom keys in `values`", () => {
it("round-trips a definition id and refuses a malformed one", () => {
expect(timelineSymptomKey("sd1")).toBe("symptom:sd1");
expect(timelineSymptomDefinitionId("symptom:sd1")).toBe("sd1");
expect(timelineSymptomDefinitionId("symptom:")).toBeNull();
expect(timelineSymptomDefinitionId("symptom:../x")).toBeNull();
expect(timelineSymptomDefinitionId("lab:sd1")).toBeNull();
});
it("rides beside measurement names and lab markers, and a malformed one refuses", () => {
expect(
parseSeriesKeys("WEIGHT,symptom:sd1,lab:bm1,symptom:sd1,MOOD"),
).toEqual(["WEIGHT", "symptom:sd1", "lab:bm1", "MOOD"]);
expect(parseSeriesKeys("symptom:../etc")).toBeNull();
});
it("caps symptoms and lab markers each on their own", () => {
const symptoms = Array.from(
{ length: TIMELINE_SYMPTOM_SERIES_MAX + 3 },
(_, i) => `symptom:s${i}`,
);
const labs = Array.from(
{ length: TIMELINE_LAB_SERIES_MAX + 2 },
(_, i) => `lab:m${i}`,
);
const capped = capTimelineSeriesKeys(["WEIGHT", ...symptoms, ...labs]);
expect(capped.filter((k) => k.startsWith("symptom:"))).toEqual(
symptoms.slice(0, TIMELINE_SYMPTOM_SERIES_MAX),
);
expect(capped.filter((k) => k.startsWith("lab:"))).toEqual(
labs.slice(0, TIMELINE_LAB_SERIES_MAX),
);
expect(capped[0]).toBe("WEIGHT");
});
});
+72 -37
View File
@@ -17,10 +17,11 @@ import { MeasurementType as MeasurementTypes } from "@/generated/prisma/enums";
import type { MeasurementType } from "@/generated/prisma/enums";
import {
capTimelineLabKeys,
capTimelineSeriesKeys,
DAY_NOTABLE_MAX_SPAN_DAYS,
TIMELINE_LANE_KEYS,
timelineLabMarkerId,
timelineSymptomDefinitionId,
type TimelineBucket,
type TimelineItem,
type TimelineQuery,
@@ -43,6 +44,11 @@ import {
loadLabSeries,
} from "@/lib/timeline/lab-series";
import { loadTimelineSeries, MOOD_SERIES_KEY } from "@/lib/timeline/series";
import {
firstSymptomDay,
loadAvailableSymptoms,
loadSymptomSeries,
} from "@/lib/timeline/symptom-series";
import { daysBetweenDateKeys, shiftDateKey, userDayKey } from "@/lib/tz/format";
import { EVENT_MEASUREMENT_TYPES } from "@/lib/validations/measurement";
@@ -141,9 +147,11 @@ const MEASUREMENT_TYPE_SET: ReadonlySet<string> = new Set(
* grows past the names that exist, and each series is its own row under the
* lanes. A lab marker (`lab:<biomarkerId>`, #1299) is checked for its shape
* only: whether the record has it is the loader's question, and the answer
* to a stale one is no line rather than an error. Marker ids are not a
* closed set, so their count is capped: the first
* `TIMELINE_LAB_SERIES_MAX` are kept and the rest left out. A refusal would
* to a stale one is no line rather than an error. A person-defined symptom
* (`symptom:<definitionId>`, #981) is checked the same way. Marker and
* definition ids are not a closed set, so their counts are capped: the first
* `TIMELINE_LAB_SERIES_MAX` markers and `TIMELINE_SYMPTOM_SERIES_MAX`
* symptoms are kept and the rest left out. A refusal would
* be stored with the choice and fail the page on every visit, with the menu
* that could undo it never drawn.
*/
@@ -159,9 +167,10 @@ export function parseSeriesKeys(raw: string | undefined): string[] | null {
];
for (const key of keys) {
if (timelineLabMarkerId(key) !== null) continue;
if (timelineSymptomDefinitionId(key) !== null) continue;
if (key !== MOOD_SERIES_KEY && !MEASUREMENT_TYPE_SET.has(key)) return null;
}
return capTimelineLabKeys(keys);
return capTimelineSeriesKeys(keys);
}
/**
@@ -250,7 +259,13 @@ export async function loadTimeline(args: {
laneVisible(key, access),
);
const measurementsVisible = access.domainVisible("measurements");
const [reads, firstReading] = await Promise.all([
const labIds = args.seriesKeys
.map(timelineLabMarkerId)
.filter((id): id is string => id !== null);
const symptomIds = args.seriesKeys
.map(timelineSymptomDefinitionId)
.filter((id): id is string => id !== null);
const [reads, firstReading, firstSymptom] = await Promise.all([
Promise.all(visibleLanes.map((key) => LANE_READERS[key](frame))),
measurementsVisible
? prisma.measurement.findFirst({
@@ -259,10 +274,13 @@ export async function loadTimeline(args: {
select: { measuredAt: true },
})
: Promise.resolve(null),
// A chosen symptom has no lane of its own to carry its first day.
firstSymptomDay({ recordId, ids: symptomIds, tz, access }),
]);
const starts = reads.flatMap((r) => r.items.map((i) => i.start));
if (firstReading) starts.push(userDayKey(firstReading.measuredAt, tz));
if (firstSymptom) starts.push(firstSymptom);
const dataFrom = starts.length > 0 ? starts.sort()[0] : null;
const { from, to } = resolveTimelineWindow(query, today, dataFrom);
@@ -277,11 +295,9 @@ export async function loadTimeline(args: {
standing.push(...reads[i].standing);
});
const labIds = args.seriesKeys
.map(timelineLabMarkerId)
.filter((id): id is string => id !== null);
const seriesKeys = args.seriesKeys.filter((key) =>
timelineLabMarkerId(key) !== null
timelineLabMarkerId(key) !== null ||
timelineSymptomDefinitionId(key) !== null
? false
: key === MOOD_SERIES_KEY
? access.modules.mood !== false && access.domainVisible("mind")
@@ -296,33 +312,50 @@ export async function loadTimeline(args: {
? shiftDateKey(to, -(DAY_NOTABLE_MAX_SPAN_DAYS - 1))
: from;
const bucket = timelineBucket(query.zoom, from, to);
const [series, notable, availableSeries, labSeries, availableLabs] =
await Promise.all([
loadTimelineSeries({
userId: recordId,
keys: seriesKeys,
from,
to,
tz,
bucket,
priorityJson,
now,
}),
measurementsVisible
? loadNotableRange({
userId: recordId,
from: notableFrom,
to,
tz,
priorityJson,
typeVisible: (t) => measurementTypeVisible(t, access.modules),
gaps: false,
})
: Promise.resolve([]),
loadAvailableSeries({ recordId, access }),
loadLabSeries({ recordId, ids: labIds, from, to, tz, access }),
loadAvailableLabMarkers({ recordId, access }),
]);
const [
series,
notable,
availableSeries,
labSeries,
availableLabs,
symptomSeries,
availableSymptoms,
] = await Promise.all([
loadTimelineSeries({
userId: recordId,
keys: seriesKeys,
from,
to,
tz,
bucket,
priorityJson,
now,
}),
measurementsVisible
? loadNotableRange({
userId: recordId,
from: notableFrom,
to,
tz,
priorityJson,
typeVisible: (t) => measurementTypeVisible(t, access.modules),
gaps: false,
})
: Promise.resolve([]),
loadAvailableSeries({ recordId, access }),
loadLabSeries({ recordId, ids: labIds, from, to, tz, access }),
loadAvailableLabMarkers({ recordId, access }),
loadSymptomSeries({
recordId,
ids: symptomIds,
from,
to,
tz,
bucket,
access,
}),
loadAvailableSymptoms({ recordId, access }),
]);
return {
zoom: query.zoom,
@@ -334,6 +367,8 @@ export async function loadTimeline(args: {
availableSeries,
labSeries,
availableLabs,
symptomSeries,
availableSymptoms,
notable: notable.map(({ date, kind }) => ({ date, kind })),
};
}
+196
View File
@@ -0,0 +1,196 @@
/**
* Person-defined symptoms as lines on the timeline (#981).
*
* A symptom is logged as occurrences, each with an intensity from 0 to 10.
* A day's value is the highest intensity logged on it: three entries of a
* headache on one afternoon are one bad day, not three. The day is the
* record's local day of `occurredAt`, the same cut the day view and every
* lane make, so a point opens the days that list it.
*
* The line follows the zoom's bucket, as a measurement line does: a point is
* the mean of the bucket's day values over the days the symptom was logged,
* and carries how many days that was, so the chart can mark a mean resting
* on one or two days. A day without an entry is not a zero: absence says
* nothing was written down, not that nothing was felt. A bucket without an
* entry is not sent.
*
* Symptoms ride the illness module and the `illness` sharing domain, as the
* symptom routes, the day view's `symptoms` section and the illness lane do,
* so the lines and the list of symptoms are read under the illness lane's
* gate (`laneVisible("illness", ...)`). Labels are decrypted here and never
* logged.
*/
import {
TIMELINE_SYMPTOM_SCALE,
timelineSymptomKey,
type TimelineBucket,
type TimelineSymptomOption,
type TimelineSymptomPoint,
type TimelineSymptomSeries,
} from "@/lib/day/contract";
import type { DayCell } from "@/lib/day/daily-stats";
import { prisma } from "@/lib/db";
import { decryptSymptomText } from "@/lib/symptoms/server";
import { shiftDateKey, userDayKey } from "@/lib/tz/format";
import { startOfLocalDayKey } from "@/lib/tz/local-day";
import { laneVisible, type TimelineAccess } from "./lanes";
import { foldDays } from "./series";
/** One logged occurrence, as the fold reads it. */
export interface SymptomOccurrence {
occurredAt: Date;
intensity: number;
}
/**
* The peak intensity of every local day in `[from, to]` that has an entry.
* Occurrences outside the window are left out.
*/
export function symptomDayPeaks(
rows: readonly SymptomOccurrence[],
window: { from: string; to: string; tz: string },
): Map<string, number> {
const peaks = new Map<string, number>();
for (const row of rows) {
const day = userDayKey(row.occurredAt, window.tz);
if (day < window.from || day > window.to) continue;
const prev = peaks.get(day);
if (prev === undefined || row.intensity > prev)
peaks.set(day, row.intensity);
}
return peaks;
}
/**
* A symptom's points over `[from, to]` in `bucket`: the mean of each
* bucket's day peaks over its logged days, and the number of those days.
*/
export function foldSymptomSeries(
rows: readonly SymptomOccurrence[],
window: { from: string; to: string; tz: string },
bucket: TimelineBucket,
): TimelineSymptomPoint[] {
const days = new Map<string, DayCell>();
for (const [day, peak] of symptomDayPeaks(rows, window)) {
// Each logged day weighs one, so `count` sums to the logged days.
days.set(day, { value: peak, count: 1 });
}
return foldDays(days, bucket);
}
const DEFINITION_SELECT = {
id: true,
labelEncrypted: true,
icon: true,
isActive: true,
} as const;
/**
* The lines of the symptoms `ids` names, in that order, over `[from, to]`.
* Every read is narrowed to the record: an id of another record's symptom
* finds nothing and is left out, as is one deleted since it was chosen. A
* symptom found but not logged in the window keeps its (empty) line, so the
* page can still name and untick it.
*/
export async function loadSymptomSeries(args: {
recordId: string;
ids: readonly string[];
from: string;
to: string;
tz: string;
bucket: TimelineBucket;
access: TimelineAccess;
}): Promise<TimelineSymptomSeries[]> {
const { recordId, ids, from, to, tz, bucket, access } = args;
if (ids.length === 0 || !laneVisible("illness", access)) return [];
const definitions = await prisma.symptomDefinition.findMany({
where: { userId: recordId, id: { in: [...ids] } },
select: DEFINITION_SELECT,
});
if (definitions.length === 0) return [];
const rows = await prisma.symptomEvent.findMany({
where: {
userId: recordId,
definitionId: { in: definitions.map((d) => d.id) },
occurredAt: {
gte: startOfLocalDayKey(from, tz),
lt: startOfLocalDayKey(shiftDateKey(to, 1), tz),
},
},
select: { definitionId: true, occurredAt: true, intensity: true },
});
const byDefinition = new Map<string, SymptomOccurrence[]>();
for (const row of rows) {
const list = byDefinition.get(row.definitionId) ?? [];
list.push(row);
byDefinition.set(row.definitionId, list);
}
const found = new Map(definitions.map((d) => [d.id, d]));
return ids.flatMap((id) => {
const definition = found.get(id);
if (!definition) return [];
return [
{
key: timelineSymptomKey(definition.id),
definitionId: definition.id,
label: decryptSymptomText(definition.labelEncrypted, "label"),
icon: definition.icon,
isActive: definition.isActive,
scale: { ...TIMELINE_SYMPTOM_SCALE },
points: foldSymptomSeries(
byDefinition.get(id) ?? [],
{ from, to, tz },
bucket,
),
},
];
});
}
/**
* The earliest local day any of the symptoms `ids` names was logged, or
* null. An `all` window reaches back to it, so a chosen symptom logged
* before anything else in the record is drawn whole.
*/
export async function firstSymptomDay(args: {
recordId: string;
ids: readonly string[];
tz: string;
access: TimelineAccess;
}): Promise<string | null> {
const { recordId, ids, tz, access } = args;
if (ids.length === 0 || !laneVisible("illness", access)) return null;
const first = await prisma.symptomEvent.findFirst({
where: { userId: recordId, definitionId: { in: [...ids] } },
orderBy: { occurredAt: "asc" },
select: { occurredAt: true },
});
return first ? userDayKey(first.occurredAt, tz) : null;
}
/**
* Every symptom the record has logged at least once, whatever the window,
* in the record's own order. A hidden symptom is offered too: hiding takes
* it out of the quick entry, and its history is kept, so it can still be
* looked at here.
*/
export async function loadAvailableSymptoms(args: {
recordId: string;
access: TimelineAccess;
}): Promise<TimelineSymptomOption[]> {
const { recordId, access } = args;
if (!laneVisible("illness", access)) return [];
const definitions = await prisma.symptomDefinition.findMany({
where: { userId: recordId, events: { some: {} } },
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }, { id: "asc" }],
select: DEFINITION_SELECT,
});
return definitions.map((d) => ({
key: timelineSymptomKey(d.id),
definitionId: d.id,
label: decryptSymptomText(d.labelEncrypted, "label"),
icon: d.icon,
isActive: d.isActive,
}));
}