mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
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:
1 parent
be7a65bdb9
commit
74e691cecd
39 files changed
+1871
-132
No files matched your search
+51
-10
@@ -428,6 +428,10 @@ export type TimelineSeries = TimelineResponse["series"][number];
|
||||
export type TimelineLabSeries = TimelineResponse["labSeries"][number];
|
||||
export type TimelineLabPoint = TimelineLabSeries["points"][number];
|
||||
export type TimelineLabMarker = TimelineResponse["availableLabs"][number];
|
||||
export type TimelineSymptomSeries = TimelineResponse["symptomSeries"][number];
|
||||
export type TimelineSymptomPoint = TimelineSymptomSeries["points"][number];
|
||||
export type TimelineSymptomOption =
|
||||
TimelineResponse["availableSymptoms"][number];
|
||||
|
||||
/**
|
||||
* A lab marker's line is named in `values` as `lab:<biomarkerId>` (#1299),
|
||||
@@ -450,18 +454,55 @@ export function timelineLabMarkerId(key: string): string | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* `keys` with every lab marker past the first `TIMELINE_LAB_SERIES_MAX` left
|
||||
* out, everything else in its place. The server applies it to what it is
|
||||
* asked for and the page to what it asks, so a stored list longer than the
|
||||
* cap (another device, an older build) loads its first markers instead of
|
||||
* failing the page.
|
||||
* A person-defined symptom's line is named in `values` as
|
||||
* `symptom:<definitionId>` (#981), beside the lab markers and on the same
|
||||
* terms: checked for its shape only, capped, and left out without an error
|
||||
* when the record does not have it.
|
||||
*/
|
||||
export function capTimelineLabKeys(keys: readonly string[]): string[] {
|
||||
export const TIMELINE_SYMPTOM_KEY_PREFIX = "symptom:";
|
||||
|
||||
/** The most symptoms one request draws. Definition ids are not a closed set. */
|
||||
export const TIMELINE_SYMPTOM_SERIES_MAX = 24;
|
||||
|
||||
/**
|
||||
* The scale a symptom line is drawn on: the intensity a symptom is logged
|
||||
* with, 0 to 10, whatever the window holds, so two symptoms (and one symptom
|
||||
* in two windows) read against the same marks.
|
||||
*/
|
||||
export const TIMELINE_SYMPTOM_SCALE = { min: 0, max: 10 } as const;
|
||||
|
||||
/** The definition id a `values` entry names, or null when it names none. */
|
||||
export function timelineSymptomDefinitionId(key: string): string | null {
|
||||
if (!key.startsWith(TIMELINE_SYMPTOM_KEY_PREFIX)) return null;
|
||||
const id = key.slice(TIMELINE_SYMPTOM_KEY_PREFIX.length);
|
||||
return /^[A-Za-z0-9_-]{1,64}$/.test(id) ? id : null;
|
||||
}
|
||||
|
||||
/** The `values` entry for a symptom definition. */
|
||||
export function timelineSymptomKey(definitionId: string): string {
|
||||
return `${TIMELINE_SYMPTOM_KEY_PREFIX}${definitionId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* `keys` with every lab marker past the first `TIMELINE_LAB_SERIES_MAX` and
|
||||
* every symptom past the first `TIMELINE_SYMPTOM_SERIES_MAX` left out,
|
||||
* everything else in its place. The server applies it to what it is asked
|
||||
* for and the page to what it asks, so a stored list longer than a cap
|
||||
* (another device, an older build) loads its first lines instead of failing
|
||||
* the page.
|
||||
*/
|
||||
export function capTimelineSeriesKeys(keys: readonly string[]): string[] {
|
||||
let labs = 0;
|
||||
return keys.filter(
|
||||
(key) =>
|
||||
timelineLabMarkerId(key) === null || ++labs <= TIMELINE_LAB_SERIES_MAX,
|
||||
);
|
||||
let symptoms = 0;
|
||||
return keys.filter((key) => {
|
||||
if (timelineLabMarkerId(key) !== null) {
|
||||
return ++labs <= TIMELINE_LAB_SERIES_MAX;
|
||||
}
|
||||
if (timelineSymptomDefinitionId(key) !== null) {
|
||||
return ++symptoms <= TIMELINE_SYMPTOM_SERIES_MAX;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/** The `values` entry for a lab marker. */
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* The local calendar date every day-view and timeline schema speaks in. Its
|
||||
* own module so the schema files beside `./wire-schemas.ts` can share it.
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { isCalendarDateKey } from "@/lib/tz/date-only";
|
||||
|
||||
export const dateKeySchema = z
|
||||
.string()
|
||||
.refine(isCalendarDateKey, { message: "Expected a YYYY-MM-DD calendar date" })
|
||||
.describe("A local calendar date, `YYYY-MM-DD`.");
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* The person-defined symptom lines of `GET /api/timeline` (#981): one
|
||||
* symptom's points, bucket means of its day peaks over the days it was
|
||||
* logged, and the symptoms the record offers. Server and OpenAPI side only,
|
||||
* like `./wire-schemas.ts`, which places them in the timeline's response.
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { TIMELINE_SYMPTOM_SCALE } from "./contract";
|
||||
import { dateKeySchema } from "./date-key-schema";
|
||||
|
||||
const timelineSymptomPoint = z
|
||||
.object({
|
||||
t: dateKeySchema.describe("The bucket's first day."),
|
||||
mean: z
|
||||
.number()
|
||||
.min(TIMELINE_SYMPTOM_SCALE.min)
|
||||
.max(TIMELINE_SYMPTOM_SCALE.max)
|
||||
.describe(
|
||||
"The mean of the bucket's day values over the days the symptom was logged. A day's value is the highest intensity (0 to 10) logged on it; a day without an entry is not a zero and does not count.",
|
||||
),
|
||||
count: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.describe(
|
||||
"Days the symptom was logged in the bucket, the days behind the mean. Clients draw a point from fewer than three as thin.",
|
||||
),
|
||||
})
|
||||
.meta({ id: "TimelineSymptomPoint" });
|
||||
|
||||
export const timelineSymptomSeries = z
|
||||
.object({
|
||||
key: z.string().describe("`symptom:<definitionId>`, as named in `values`."),
|
||||
definitionId: z.string(),
|
||||
label: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe(
|
||||
"The person's own name for the symptom. Null when it cannot be read; the client words a stand-in.",
|
||||
),
|
||||
icon: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe("The symptom's icon name; null for the default glyph."),
|
||||
isActive: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"False for a hidden symptom: no longer offered for entry, its history kept and drawn.",
|
||||
),
|
||||
scale: z
|
||||
.object({
|
||||
min: z.literal(TIMELINE_SYMPTOM_SCALE.min),
|
||||
max: z.literal(TIMELINE_SYMPTOM_SCALE.max),
|
||||
})
|
||||
.describe(
|
||||
"The fixed scale the line is drawn on, the intensity scale, whatever the window holds.",
|
||||
),
|
||||
points: z
|
||||
.array(timelineSymptomPoint)
|
||||
.describe(
|
||||
"Buckets of the response's `bucket` with at least one logged day, oldest first. A bucket without one is absent, never zero and never interpolated.",
|
||||
),
|
||||
})
|
||||
.meta({ id: "TimelineSymptomSeries" });
|
||||
|
||||
export const timelineSymptomOption = z
|
||||
.object({
|
||||
key: z.string().describe("`symptom:<definitionId>`, to name in `values`."),
|
||||
definitionId: z.string(),
|
||||
label: z.string().nullable(),
|
||||
icon: z.string().nullable(),
|
||||
isActive: z.boolean(),
|
||||
})
|
||||
.meta({ id: "TimelineSymptomOption" });
|
||||
@@ -9,7 +9,7 @@
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { dateOnlyAtNoonUtc, isCalendarDateKey } from "@/lib/tz/date-only";
|
||||
import { dateOnlyAtNoonUtc } from "@/lib/tz/date-only";
|
||||
|
||||
import {
|
||||
DAY_EVENT_KINDS,
|
||||
@@ -34,12 +34,13 @@ import {
|
||||
} from "./contract";
|
||||
import { isoDateTime } from "@/lib/validations/datetime";
|
||||
|
||||
/* ─── Shared pieces ───────────────────────────────────────────────────────── */
|
||||
import { dateKeySchema } from "./date-key-schema";
|
||||
import {
|
||||
timelineSymptomOption,
|
||||
timelineSymptomSeries,
|
||||
} from "./timeline-symptom-schemas";
|
||||
|
||||
export const dateKeySchema = z
|
||||
.string()
|
||||
.refine(isCalendarDateKey, { message: "Expected a YYYY-MM-DD calendar date" })
|
||||
.describe("A local calendar date, `YYYY-MM-DD`.");
|
||||
/* ─── Shared pieces ───────────────────────────────────────────────────────── */
|
||||
|
||||
const instant = isoDateTime();
|
||||
|
||||
@@ -401,7 +402,7 @@ export const timelineQuerySchema = withWindowRules(
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"Comma-separated value series to include, as `MeasurementType` names, `MOOD`, or a lab marker as `lab:<biomarkerId>` (see `availableLabs`). A name given twice counts once, so the list is bounded by the names that exist; an unknown measurement name or a malformed marker key answers 422. At most 24 lab markers are drawn (ids, unlike names, are not a closed set): the first 24 are kept and any further ones left out without an error. A lab marker that is not in the record (deleted, or another record's) is likewise left out of `labSeries`, so a stored choice never breaks the page.",
|
||||
"Comma-separated value series to include, as `MeasurementType` names, `MOOD`, a lab marker as `lab:<biomarkerId>` (see `availableLabs`), or a person-defined symptom as `symptom:<definitionId>` (see `availableSymptoms`). A name given twice counts once, so the list is bounded by the names that exist; an unknown measurement name or a malformed marker or symptom key answers 422. At most 24 lab markers and 24 symptoms are drawn (ids, unlike names, are not a closed set): the first 24 of each are kept and any further ones left out without an error. A lab marker or symptom that is not in the record (deleted, or another record's) is likewise left out of `labSeries` / `symptomSeries`, so a stored choice never breaks the page.",
|
||||
),
|
||||
}),
|
||||
);
|
||||
@@ -616,6 +617,16 @@ export const timelineResponseSchema = z
|
||||
.describe(
|
||||
"Every lab marker this record has a numeric reading for, whatever the window, when the caller may see labs. Each may be named in `values` by its `key`.",
|
||||
),
|
||||
symptomSeries: z
|
||||
.array(timelineSymptomSeries)
|
||||
.describe(
|
||||
"The person-defined symptoms named in `values`, in the order named, each averaged per `bucket` over the days it was logged. A symptom named but not found in the record is left out.",
|
||||
),
|
||||
availableSymptoms: z
|
||||
.array(timelineSymptomOption)
|
||||
.describe(
|
||||
"Every person-defined symptom this record has logged at least once, hidden ones included (their history is kept), in the record's own order, when the caller may see illness data. Each may be named in `values` by its `key`.",
|
||||
),
|
||||
notable: z.array(
|
||||
z.object({ date: dateKeySchema, kind: z.enum(DAY_NOTABLE_KINDS) }),
|
||||
),
|
||||
|
||||
@@ -15,8 +15,8 @@ import type { ZodOpenApiObject } from "zod-openapi";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { LAB_UNIT_DESCRIPTION } from "@/lib/labs/dimensionless";
|
||||
import { dateKeySchema } from "@/lib/day/date-key-schema";
|
||||
import {
|
||||
dateKeySchema,
|
||||
labSeriesQuerySchema,
|
||||
timelineLabSeries,
|
||||
} from "@/lib/day/wire-schemas";
|
||||
|
||||
@@ -129,7 +129,7 @@ export const dayPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Records"],
|
||||
summary: "The record over the years",
|
||||
description:
|
||||
"Lanes of conditions, allergies, medications, vaccinations, visits, lab days, documents and life events, the standing items without a start, and monthly (or finer, by zoom) value series. Empty lanes are not sent; lanes of a switched-off module never are. The `life` lane (life events and travel periods) is the owner's only and is never sent to a delegate. Requires the opt-in `timeline` module.",
|
||||
"Lanes of conditions, allergies, medications, vaccinations, visits, lab days, documents and life events, the standing items without a start, and monthly (or finer, by zoom) value series, among them the lab markers (`labSeries`, single readings) and the person-defined symptoms (`symptomSeries`, the day's highest intensity averaged over logged days, on a fixed 0 to 10 scale; read only with the illness module on and the illness section visible) named in `values`. Empty lanes are not sent; lanes of a switched-off module never are. The `life` lane (life events and travel periods) is the owner's only and is never sent to a delegate. Requires the opt-in `timeline` module.",
|
||||
requestParams: { query: timelineQuerySchema },
|
||||
responses: {
|
||||
"200": {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 })),
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
Reference in new issue
Block a user