mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +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
@@ -286,6 +286,8 @@ function everyKind(): TimelineResponse {
|
||||
},
|
||||
],
|
||||
availableLabs: [],
|
||||
symptomSeries: [],
|
||||
availableSymptoms: [],
|
||||
notable: [],
|
||||
};
|
||||
// The fixture itself must reach every kind, or the guard proves less.
|
||||
|
||||
@@ -286,6 +286,8 @@ export function fullTimeline(): TimelineResponse {
|
||||
availableSeries: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA", "WEIGHT"],
|
||||
labSeries: [],
|
||||
availableLabs: [],
|
||||
symptomSeries: [],
|
||||
availableSymptoms: [],
|
||||
notable: [{ date: "2026-01-04", kind: "extremeHigh" }],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -854,3 +854,46 @@ describe("label room (v1.42 final)", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("layoutSeries, a symptom line (#981)", () => {
|
||||
const window = { from: "2026-01-01", to: "2026-06-30" };
|
||||
const scale = createScale(window, 164, 1200);
|
||||
const lay = (points: Array<{ t: string; mean: number; count: number }>) =>
|
||||
layoutSeries(
|
||||
[{ key: "symptom:sd1", unit: null, points }],
|
||||
0,
|
||||
window,
|
||||
scale,
|
||||
"month",
|
||||
)[0];
|
||||
|
||||
it("sits on the fixed 0 to 10 scale, whatever the window holds", () => {
|
||||
const quiet = lay([
|
||||
{ t: "2026-01-01", mean: 2, count: 4 },
|
||||
{ t: "2026-02-01", mean: 3, count: 1 },
|
||||
]);
|
||||
const loud = lay([
|
||||
{ t: "2026-01-01", mean: 2, count: 4 },
|
||||
{ t: "2026-02-01", mean: 9, count: 5 },
|
||||
]);
|
||||
// The same mean lands on the same height in both rows.
|
||||
expect(quiet.points[0].y).toBe(loud.points[0].y);
|
||||
// 0 and 10 are inside the row, 5 halfway between them.
|
||||
const mid = lay([{ t: "2026-01-01", mean: 5, count: 3 }]).points[0].y;
|
||||
const top = lay([{ t: "2026-01-01", mean: 10, count: 3 }]).points[0].y;
|
||||
const bottom = lay([{ t: "2026-01-01", mean: 0, count: 3 }]).points[0].y;
|
||||
expect(top).toBeLessThan(mid);
|
||||
expect(mid).toBeLessThan(bottom);
|
||||
expect(mid).toBeCloseTo((top + bottom) / 2, 5);
|
||||
expect(top).toBeGreaterThan(quiet.top);
|
||||
expect(bottom).toBeLessThan(quiet.top + quiet.height);
|
||||
});
|
||||
|
||||
it("draws a mean resting on fewer than three days hollow", () => {
|
||||
const line = lay([
|
||||
{ t: "2026-01-01", mean: 4, count: 2 },
|
||||
{ t: "2026-02-01", mean: 4, count: 3 },
|
||||
]);
|
||||
expect(line.points.map((p) => p.thin)).toEqual([true, false]);
|
||||
});
|
||||
});
|
||||
@@ -138,6 +138,8 @@ function timeline(values: readonly string[]): TimelineResponse {
|
||||
unit: "ng/mL",
|
||||
},
|
||||
],
|
||||
symptomSeries: [],
|
||||
availableSymptoms: [],
|
||||
notable: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -149,6 +149,8 @@ function twoMedications(): TimelineResponse {
|
||||
availableSeries: [],
|
||||
labSeries: [],
|
||||
availableLabs: [],
|
||||
symptomSeries: [],
|
||||
availableSymptoms: [],
|
||||
notable: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* The person's own symptoms on the timeline page (#981), in a real browser:
|
||||
* the page as mounted, its reads answered by a stubbed network.
|
||||
*
|
||||
* - A stored choice that names a symptom reaches the request, and the
|
||||
* symptom is drawn as a value line on the fixed 0 to 10 scale, a point
|
||||
* resting on fewer than three days drawn hollow.
|
||||
* - The values menu offers the record's logged symptoms, a hidden one
|
||||
* marked as such; choosing one adds it to the stored list and to the
|
||||
* next request.
|
||||
* - With the illness module off, no symptom is offered or requested.
|
||||
* - A point reads on its own by keyboard: the bucket, the value out of 10
|
||||
* and the days behind it.
|
||||
*
|
||||
* All data invented.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { page } from "vitest/browser";
|
||||
|
||||
import type { TimelineResponse } from "@/lib/day/contract";
|
||||
|
||||
import {
|
||||
stubFetch,
|
||||
type FetchStub,
|
||||
} from "../../../../tests/browser/fetch-stub";
|
||||
import { renderWithApp } from "../../../../tests/browser/render";
|
||||
import { setSearch } from "../../../../tests/browser/url";
|
||||
import { TimelineView } from "../timeline-view";
|
||||
|
||||
const VALUES_KEY = "healthlog.timeline.values";
|
||||
|
||||
const HEADACHE = "symptom:sd-headache";
|
||||
const NAUSEA = "symptom:sd-nausea";
|
||||
|
||||
function timeline(values: readonly string[]): TimelineResponse {
|
||||
const line = (
|
||||
key: string,
|
||||
label: string,
|
||||
isActive: boolean,
|
||||
points: Array<{ t: string; mean: number; count: number }>,
|
||||
) => ({
|
||||
key,
|
||||
definitionId: key.slice("symptom:".length),
|
||||
label,
|
||||
icon: null,
|
||||
isActive,
|
||||
scale: { min: 0 as const, max: 10 as const },
|
||||
points,
|
||||
});
|
||||
const lines = [
|
||||
line(HEADACHE, "Headache", true, [
|
||||
{ t: "2025-10-01", mean: 6.5, count: 4 },
|
||||
{ t: "2026-01-01", mean: 3, count: 1 },
|
||||
]),
|
||||
line(NAUSEA, "Nausea", false, [{ t: "2025-04-01", mean: 2, count: 2 }]),
|
||||
];
|
||||
return {
|
||||
zoom: "all",
|
||||
range: { from: "2024-01-01", to: "2026-06-30", dataFrom: "2024-01-01" },
|
||||
bucket: "quarter",
|
||||
lanes: [
|
||||
{
|
||||
key: "illness",
|
||||
items: [
|
||||
{
|
||||
id: "episode-1",
|
||||
kind: "episode",
|
||||
start: "2025-10-12",
|
||||
end: "2025-10-20",
|
||||
open: false,
|
||||
precision: "DAY",
|
||||
startKnown: true,
|
||||
label: "Flu",
|
||||
sub: null,
|
||||
href: null,
|
||||
group: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
standing: [],
|
||||
series: [],
|
||||
availableSeries: ["WEIGHT"],
|
||||
labSeries: [],
|
||||
availableLabs: [],
|
||||
symptomSeries: lines.filter((l) => values.includes(l.key)),
|
||||
availableSymptoms: lines.map(
|
||||
({ key, definitionId, label, icon, isActive }) => ({
|
||||
key,
|
||||
definitionId,
|
||||
label,
|
||||
icon,
|
||||
isActive,
|
||||
}),
|
||||
),
|
||||
notable: [],
|
||||
};
|
||||
}
|
||||
|
||||
/** The `values` a request named, as a list. */
|
||||
function valuesOf(path: string): string[] {
|
||||
const raw = new URL(path, window.location.origin).searchParams.get("values");
|
||||
return raw ? raw.split(",") : [];
|
||||
}
|
||||
|
||||
function stubTimeline(): FetchStub {
|
||||
return stubFetch()
|
||||
.on("GET", "/api/timeline", (request) => ({
|
||||
data: timeline(valuesOf(request.path)),
|
||||
}))
|
||||
.on("GET", "/api/timeline/readiness", {
|
||||
data: { verdict: "carries", since: "2024-01-01", lanes: [] },
|
||||
})
|
||||
.on("GET", "/api/life-events", { data: { events: [] } });
|
||||
}
|
||||
|
||||
async function mount(
|
||||
stored: string[] | null,
|
||||
modules: Record<string, boolean> = { timeline: true, illness: true },
|
||||
) {
|
||||
if (stored) window.localStorage.setItem(VALUES_KEY, JSON.stringify(stored));
|
||||
setSearch("?zoom=all");
|
||||
const net = stubTimeline();
|
||||
const { screen } = await renderWithApp(<TimelineView />, {
|
||||
user: { timezone: "Europe/Berlin", modules },
|
||||
});
|
||||
return { net, screen };
|
||||
}
|
||||
|
||||
function lastTimelineValues(net: FetchStub): string[] {
|
||||
const calls = net.calls("GET", "/api/timeline");
|
||||
return valuesOf(calls[calls.length - 1]?.path ?? "");
|
||||
}
|
||||
|
||||
describe("symptom lines on the timeline page", () => {
|
||||
it("asks for a stored symptom and draws it as a value line on the fixed scale", async () => {
|
||||
await page.viewport(1280, 900);
|
||||
const { net } = await mount([HEADACHE]);
|
||||
const points = () =>
|
||||
document.querySelectorAll<SVGCircleElement>(
|
||||
`[data-series="${HEADACHE}"] [data-slot="timeline-series-point"]`,
|
||||
);
|
||||
await expect.poll(() => points().length).toBe(2);
|
||||
expect(lastTimelineValues(net)).toEqual([HEADACHE]);
|
||||
// The mean of four days is a full point, the one of a single day hollow.
|
||||
const [autumn, winter] = [...points()];
|
||||
expect(autumn.dataset.count).toBe("4");
|
||||
expect(autumn.dataset.thin).toBe("false");
|
||||
expect(winter.dataset.thin).toBe("true");
|
||||
// Fixed scale: 6.5 sits higher than 3, and both well inside the row,
|
||||
// where a self-fitted row would push the two towards its edges.
|
||||
const y = (el: SVGCircleElement) => Number(el.getAttribute("cy"));
|
||||
expect(y(autumn)).toBeLessThan(y(winter));
|
||||
const span = y(winter) - y(autumn);
|
||||
// 3.5 of 11 scale units across a 44 px row: about 14 px.
|
||||
expect(span).toBeGreaterThan(10);
|
||||
expect(span).toBeLessThan(20);
|
||||
// The latest value reads out of 10, and the legend says what a point is.
|
||||
expect(
|
||||
document.querySelector(`g[data-series="${HEADACHE}"]`)?.textContent,
|
||||
).toContain("3/10");
|
||||
expect(
|
||||
document.querySelector('[data-slot="timeline-legend-symptom"]'),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it("reads a point on its own with the days behind it", async () => {
|
||||
await page.viewport(1280, 900);
|
||||
await mount([HEADACHE]);
|
||||
const target = (t: string) =>
|
||||
document.querySelector<HTMLButtonElement>(
|
||||
`[data-slot="timeline-point-target"][data-series="${HEADACHE}"][data-t="${t}"]`,
|
||||
);
|
||||
await expect.poll(() => target("2025-10-01") !== null).toBe(true);
|
||||
const label = target("2025-10-01")!.getAttribute("aria-label") ?? "";
|
||||
expect(label).toContain("Headache");
|
||||
expect(label).toContain("6.5/10");
|
||||
expect(label).toContain("mean of 4 days");
|
||||
expect(target("2026-01-01")!.getAttribute("aria-label")).toContain(
|
||||
"on 1 day",
|
||||
);
|
||||
});
|
||||
|
||||
it("offers the record's symptoms, marks a hidden one, and a choice persists", async () => {
|
||||
await page.viewport(1280, 900);
|
||||
const { net, screen } = await mount(["WEIGHT"]);
|
||||
const trigger = screen.getByRole("button", { name: /Values \(1\)/ });
|
||||
await expect.element(trigger).toBeVisible();
|
||||
await trigger.click();
|
||||
await expect.element(screen.getByText("Symptoms")).toBeVisible();
|
||||
await expect
|
||||
.element(screen.getByRole("menuitemcheckbox", { name: /Nausea.*hidden/ }))
|
||||
.toBeVisible();
|
||||
const headache = screen.getByRole("menuitemcheckbox", {
|
||||
name: "Headache",
|
||||
});
|
||||
await expect.element(headache).toHaveAttribute("aria-checked", "false");
|
||||
await headache.click();
|
||||
await expect.element(headache).toHaveAttribute("aria-checked", "true");
|
||||
expect(JSON.parse(window.localStorage.getItem(VALUES_KEY)!)).toEqual([
|
||||
"WEIGHT",
|
||||
HEADACHE,
|
||||
]);
|
||||
await expect
|
||||
.poll(() => lastTimelineValues(net))
|
||||
.toEqual(["WEIGHT", HEADACHE]);
|
||||
});
|
||||
|
||||
it("neither offers nor asks for a symptom with the illness module off", async () => {
|
||||
await page.viewport(1280, 900);
|
||||
const { net, screen } = await mount(["WEIGHT", HEADACHE], {
|
||||
timeline: true,
|
||||
illness: false,
|
||||
});
|
||||
const trigger = screen.getByRole("button", { name: /Values \(1\)/ });
|
||||
await expect.element(trigger).toBeVisible();
|
||||
expect(lastTimelineValues(net)).toEqual(["WEIGHT"]);
|
||||
await trigger.click();
|
||||
await expect
|
||||
.element(screen.getByRole("menuitemcheckbox", { name: "Weight" }))
|
||||
.toBeVisible();
|
||||
expect(
|
||||
document.querySelector('[data-slot="timeline-values-symptoms"]'),
|
||||
).toBe(null);
|
||||
});
|
||||
|
||||
it("fits a phone and names the symptom's means in the chronicle", async () => {
|
||||
await page.viewport(390, 844);
|
||||
const { screen } = await mount([HEADACHE]);
|
||||
await expect
|
||||
.element(screen.getByRole("button", { name: "Values (1)" }))
|
||||
.toBeVisible();
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
document.querySelectorAll(
|
||||
`[data-slot="timeline-mean-part"][data-series="${HEADACHE}"]`,
|
||||
).length,
|
||||
)
|
||||
.toBeGreaterThan(0);
|
||||
const root = document.documentElement;
|
||||
expect(root.scrollWidth).toBeLessThanOrEqual(root.clientWidth);
|
||||
});
|
||||
});
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
import { useItemWords } from "./item-words";
|
||||
import { LabReadingsLine, labReadingsIn, useLabValueText } from "./lab-lines";
|
||||
import { itemsInPeriod } from "./timeline-geometry";
|
||||
import { useSeriesCountText } from "./use-series-value-format";
|
||||
import { MEAN_PARTS_ROW, MeanPartsLine, laneDotStyle } from "./timeline-chart";
|
||||
import {
|
||||
bucketValues,
|
||||
@@ -78,7 +79,8 @@ export function SelectionBar({
|
||||
onOpenDay,
|
||||
onEditLifeEvent,
|
||||
}: SelectionBarProps) {
|
||||
const { t, tCount, locale } = useTranslations();
|
||||
const { t, locale } = useTranslations();
|
||||
const countText = useSeriesCountText();
|
||||
const words = useItemWords();
|
||||
const intl = resolveIntlLocale(locale);
|
||||
const bucket = timeline.bucket;
|
||||
@@ -218,7 +220,7 @@ export function SelectionBar({
|
||||
label: seriesLabel,
|
||||
bloodPressure: t("timeline.values.bloodPressure"),
|
||||
noValue: t("timeline.values.noValue"),
|
||||
readings: (n) => tCount("timeline.values.readings", n),
|
||||
readings: countText,
|
||||
})}
|
||||
seriesColor={seriesColor}
|
||||
/>
|
||||
|
||||
@@ -179,7 +179,8 @@ export function labelledMeanParts(
|
||||
label: (key: string) => string;
|
||||
bloodPressure: string;
|
||||
noValue: string;
|
||||
readings: (count: number) => string;
|
||||
/** The readings (a symptom: the days) behind a series' mean. */
|
||||
readings: (key: string, count: number) => string;
|
||||
},
|
||||
): MeanPart[] {
|
||||
const sys = means.find(
|
||||
@@ -196,7 +197,7 @@ export function labelledMeanParts(
|
||||
parts.push({
|
||||
key: m.key,
|
||||
text: `${words.bloodPressure} ${pressure(sys, dia!, format)}`,
|
||||
detail: words.readings(sys.count ?? 0),
|
||||
detail: words.readings(sys.key, sys.count ?? 0),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -206,8 +207,34 @@ export function labelledMeanParts(
|
||||
m.mean === null
|
||||
? `${words.label(m.key)} ${words.noValue}`
|
||||
: `${words.label(m.key)} ${formatSeriesValue(m.key, m.mean, m.unit, format)}`,
|
||||
...(m.mean === null ? {} : { detail: words.readings(m.count ?? 0) }),
|
||||
...(m.mean === null
|
||||
? {}
|
||||
: { detail: words.readings(m.key, m.count ?? 0) }),
|
||||
});
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
/**
|
||||
* The timeline with its symptom lines (#981) folded into the value lines.
|
||||
* A symptom line is a bucket mean like any value line, so the chart, the
|
||||
* selection bar, the tables and the chronicle draw and word it as one: on
|
||||
* its fixed scale (`layoutSeries`), with the days behind each mean
|
||||
* (`useSeriesCountText`). The server's points are kept as they are.
|
||||
*/
|
||||
export function withSymptomLines<
|
||||
T extends Pick<TimelineResponse, "series" | "symptomSeries">,
|
||||
>(timeline: T): T {
|
||||
if (timeline.symptomSeries.length === 0) return timeline;
|
||||
return {
|
||||
...timeline,
|
||||
series: [
|
||||
...timeline.series,
|
||||
...timeline.symptomSeries.map((s) => ({
|
||||
key: s.key,
|
||||
unit: null,
|
||||
points: s.points,
|
||||
})),
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -3,11 +3,13 @@
|
||||
* geometry (v1.42, #613; lab lines #1299). `timeline-geometry.ts` stacks
|
||||
* them under the lanes; each row is laid out here on its own scale.
|
||||
*/
|
||||
import type {
|
||||
TimelineBucket,
|
||||
TimelineLabPoint,
|
||||
TimelineLabSeries,
|
||||
TimelineSeries,
|
||||
import {
|
||||
TIMELINE_SYMPTOM_SCALE,
|
||||
timelineSymptomDefinitionId,
|
||||
type TimelineBucket,
|
||||
type TimelineLabPoint,
|
||||
type TimelineLabSeries,
|
||||
type TimelineSeries,
|
||||
} from "@/lib/day/contract";
|
||||
|
||||
import { bucketAfter, bucketIndex, dayNumber } from "./timeline-dates";
|
||||
@@ -70,7 +72,8 @@ export const MAX_BRIDGED_GAP: Readonly<Record<TimelineBucket, number>> = {
|
||||
};
|
||||
|
||||
/**
|
||||
* One neutral line per series, scaled to its own range inside the window.
|
||||
* One neutral line per series, scaled to its own range inside the window (a
|
||||
* symptom's to the fixed intensity scale, `TIMELINE_SYMPTOM_SCALE`).
|
||||
* A point sits in the middle of its bucket, and every bucket with a reading
|
||||
* gets its point, so one month with no neighbours stays visible. Missing
|
||||
* buckets stay missing: a short gap is bridged by a dashed stroke
|
||||
@@ -104,9 +107,17 @@ export function layoutSeries(
|
||||
points: [],
|
||||
};
|
||||
}
|
||||
let lo = Math.min(...inside.map((p) => p.mean));
|
||||
let hi = Math.max(...inside.map((p) => p.mean));
|
||||
const pad = Math.max((hi - lo) * 0.12, Math.abs(hi) * 0.01, 0.5);
|
||||
// A symptom line sits on the intensity scale whatever the window
|
||||
// holds (#981): a 3 reads as a 3, not as the top of a quiet stretch.
|
||||
const fixed =
|
||||
timelineSymptomDefinitionId(s.key) !== null
|
||||
? TIMELINE_SYMPTOM_SCALE
|
||||
: null;
|
||||
let lo = fixed ? fixed.min : Math.min(...inside.map((p) => p.mean));
|
||||
let hi = fixed ? fixed.max : Math.max(...inside.map((p) => p.mean));
|
||||
const pad = fixed
|
||||
? (hi - lo) * 0.05
|
||||
: Math.max((hi - lo) * 0.12, Math.abs(hi) * 0.01, 0.5);
|
||||
lo -= pad;
|
||||
hi += pad;
|
||||
const y = (v: number) => rowTop + height * (1 - (v - lo) / (hi - lo));
|
||||
|
||||
@@ -53,6 +53,7 @@ import { useLabReference, useLabValueText, useLabVerdict } from "./lab-lines";
|
||||
import { formatSeriesValue, type SeriesValueFormat } from "./series-format";
|
||||
import { formatDayMonthYear } from "./timeline-dates";
|
||||
import type { TimelineLayout } from "./timeline-geometry";
|
||||
import { useSeriesCountText } from "./use-series-value-format";
|
||||
|
||||
/** Hit target across, in px: bigger than the dot, a comfortable aim. */
|
||||
export const POINT_TARGET = 24;
|
||||
@@ -401,7 +402,8 @@ function useTipLines({
|
||||
fmt: SeriesValueFormat;
|
||||
bucketLabel: (t: string) => string;
|
||||
}): Array<{ key: string; points: TipPoint[] }> {
|
||||
const { t: tr, tCount, locale } = useTranslations();
|
||||
const { t: tr, locale } = useTranslations();
|
||||
const countText = useSeriesCountText();
|
||||
const intl = resolveIntlLocale(locale);
|
||||
const verdict = useLabVerdict();
|
||||
const labValueText = useLabValueText();
|
||||
@@ -420,13 +422,13 @@ function useTipLines({
|
||||
x: p.x,
|
||||
y: p.y,
|
||||
day: dayOf(p.t),
|
||||
ariaLabel: `${words(s.key, p.t, p.mean, s.unit)}, ${tCount("timeline.values.readings", p.count)}`,
|
||||
ariaLabel: `${words(s.key, p.t, p.mean, s.unit)}, ${countText(s.key, p.count)}`,
|
||||
label: bucketLabel(p.t),
|
||||
row: {
|
||||
name: seriesLabel(s.key),
|
||||
value: formatSeriesValue(s.key, p.mean, s.unit, fmt),
|
||||
color: seriesColor(s.key),
|
||||
delta: tCount("timeline.values.readings", p.count),
|
||||
delta: countText(s.key, p.count),
|
||||
},
|
||||
report: null,
|
||||
})),
|
||||
|
||||
@@ -19,11 +19,17 @@ import { LabExcludedNote } from "./lab-lines";
|
||||
export function TimelineLegend({
|
||||
bucket,
|
||||
hasSeries,
|
||||
hasSymptoms = false,
|
||||
labSeries = [],
|
||||
label = (key) => key,
|
||||
}: {
|
||||
bucket: TimelineBucket;
|
||||
hasSeries: boolean;
|
||||
/**
|
||||
* A symptom line is drawn (#981): what its points are is said once, since
|
||||
* its mean is of day peaks over logged days, not of readings.
|
||||
*/
|
||||
hasSymptoms?: boolean;
|
||||
/**
|
||||
* The lab lines drawn: their three marks are explained, and what a line
|
||||
* left out is said here, where the marks are (#1299).
|
||||
@@ -113,6 +119,14 @@ export function TimelineLegend({
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{hasSymptoms && (
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5"
|
||||
data-slot="timeline-legend-symptom"
|
||||
>
|
||||
{t("timeline.legendSymptom")}
|
||||
</span>
|
||||
)}
|
||||
{hasLabs && (
|
||||
<>
|
||||
<span
|
||||
|
||||
@@ -12,6 +12,9 @@
|
||||
* list sorts them into; a lab line is chosen and stored like any other, up
|
||||
* to `TIMELINE_LAB_SERIES_MAX` at once: past it the unchosen markers are
|
||||
* held back with a line that says why, and a chosen one can always go.
|
||||
* Between the two it offers the person's own symptoms (#981) that have been
|
||||
* logged, hidden ones included and marked as such, on the same terms up to
|
||||
* `TIMELINE_SYMPTOM_SERIES_MAX`.
|
||||
*/
|
||||
import { ChartLine, ChevronDown, Layers } from "lucide-react";
|
||||
|
||||
@@ -28,7 +31,9 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
TIMELINE_LAB_SERIES_MAX,
|
||||
TIMELINE_SYMPTOM_SERIES_MAX,
|
||||
timelineLabMarkerId,
|
||||
timelineSymptomDefinitionId,
|
||||
type TimelineLaneKey,
|
||||
type TimelineResponse,
|
||||
} from "@/lib/day/contract";
|
||||
@@ -43,8 +48,15 @@ export interface LabOptionGroup {
|
||||
keys: readonly string[];
|
||||
}
|
||||
|
||||
/** A symptom the menu offers: its key, and whether it is hidden from entry. */
|
||||
export interface SymptomOption {
|
||||
key: string;
|
||||
hidden: boolean;
|
||||
}
|
||||
|
||||
export function ValueSeriesMenu({
|
||||
options,
|
||||
symptoms = [],
|
||||
labGroups = [],
|
||||
selected,
|
||||
label,
|
||||
@@ -53,6 +65,8 @@ export function ValueSeriesMenu({
|
||||
iconOnly = false,
|
||||
}: {
|
||||
options: readonly string[];
|
||||
/** The record's logged symptoms, in the record's own order. */
|
||||
symptoms?: readonly SymptomOption[];
|
||||
/** The record's lab markers, grouped as the labs list groups them. */
|
||||
labGroups?: readonly LabOptionGroup[];
|
||||
selected: readonly string[];
|
||||
@@ -67,13 +81,20 @@ export function ValueSeriesMenu({
|
||||
const labsFull =
|
||||
selected.filter((key) => timelineLabMarkerId(key) !== null).length >=
|
||||
TIMELINE_LAB_SERIES_MAX;
|
||||
const option = (key: string) => {
|
||||
const symptomsFull =
|
||||
selected.filter((key) => timelineSymptomDefinitionId(key) !== null)
|
||||
.length >= TIMELINE_SYMPTOM_SERIES_MAX;
|
||||
const option = (key: string, hint?: string) => {
|
||||
const checked = selected.includes(key);
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={key}
|
||||
checked={checked}
|
||||
disabled={labsFull && !checked && timelineLabMarkerId(key) !== null}
|
||||
disabled={
|
||||
!checked &&
|
||||
((labsFull && timelineLabMarkerId(key) !== null) ||
|
||||
(symptomsFull && timelineSymptomDefinitionId(key) !== null))
|
||||
}
|
||||
data-slot="timeline-values-option"
|
||||
data-key={key}
|
||||
onSelect={(event) => event.preventDefault()}
|
||||
@@ -92,6 +113,9 @@ export function ValueSeriesMenu({
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{label(key)}
|
||||
{hint ? (
|
||||
<span className="text-muted-foreground ml-auto text-xs">{hint}</span>
|
||||
) : null}
|
||||
</DropdownMenuCheckboxItem>
|
||||
);
|
||||
};
|
||||
@@ -121,7 +145,33 @@ export function ValueSeriesMenu({
|
||||
data-slot="timeline-values-menu"
|
||||
>
|
||||
<DropdownMenuLabel>{t("timeline.values.menuTitle")}</DropdownMenuLabel>
|
||||
{options.map(option)}
|
||||
{options.map((key) => option(key))}
|
||||
{symptoms.length > 0 && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel data-slot="timeline-values-symptoms">
|
||||
{t("timeline.symptoms.menuTitle")}
|
||||
</DropdownMenuLabel>
|
||||
{symptomsFull && (
|
||||
<p
|
||||
data-slot="timeline-values-symptoms-full"
|
||||
className="text-muted-foreground px-2 pb-1 text-xs"
|
||||
>
|
||||
{t("timeline.symptoms.full", {
|
||||
max: TIMELINE_SYMPTOM_SERIES_MAX,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
<DropdownMenuGroup data-slot="timeline-values-symptom-group">
|
||||
{symptoms.map((s) =>
|
||||
option(
|
||||
s.key,
|
||||
s.hidden ? t("timeline.symptoms.hidden") : undefined,
|
||||
),
|
||||
)}
|
||||
</DropdownMenuGroup>
|
||||
</>
|
||||
)}
|
||||
{labGroups.length > 0 && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
@@ -146,7 +196,7 @@ export function ValueSeriesMenu({
|
||||
{group.panel ?? t("timeline.labs.otherPanel")}
|
||||
</DropdownMenuLabel>
|
||||
)}
|
||||
{group.keys.map(option)}
|
||||
{group.keys.map((key) => option(key))}
|
||||
</DropdownMenuGroup>
|
||||
))}
|
||||
</>
|
||||
@@ -251,10 +301,26 @@ export function labMarkerNames(
|
||||
);
|
||||
}
|
||||
|
||||
/** Each symptom's name by its key, from what the server sent. */
|
||||
export function symptomNames(
|
||||
timeline:
|
||||
Pick<TimelineResponse, "availableSymptoms" | "symptomSeries"> | undefined,
|
||||
): Map<string, string> {
|
||||
const out = new Map<string, string>();
|
||||
for (const s of [
|
||||
...(timeline?.availableSymptoms ?? []),
|
||||
...(timeline?.symptomSeries ?? []),
|
||||
]) {
|
||||
if (s.label !== null) out.set(s.key, s.label);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The values menu as the page holds it: every series the record has, the
|
||||
* chosen ones kept even when they have nothing yet, in `rank`'s order and
|
||||
* then the record's; then the record's lab markers by group. It counts and
|
||||
* then the record's; then the record's logged symptoms in its own order;
|
||||
* then the record's lab markers by group. It counts and
|
||||
* toggles the lines it can name, so a stored marker the record no longer
|
||||
* has drops out with the next change.
|
||||
*/
|
||||
@@ -264,6 +330,7 @@ export function TimelineValuesMenu({
|
||||
allowed,
|
||||
rank,
|
||||
labsOn,
|
||||
symptomsOn,
|
||||
label,
|
||||
color,
|
||||
onChange,
|
||||
@@ -274,6 +341,7 @@ export function TimelineValuesMenu({
|
||||
allowed: (key: string) => boolean;
|
||||
rank: readonly string[];
|
||||
labsOn: boolean;
|
||||
symptomsOn: boolean;
|
||||
label: (key: string) => string;
|
||||
color: (key: string) => string;
|
||||
onChange: (next: string[]) => void;
|
||||
@@ -283,7 +351,11 @@ export function TimelineValuesMenu({
|
||||
const options = [
|
||||
...new Set([
|
||||
...(timeline?.availableSeries ?? []).filter(allowed),
|
||||
...values.filter((key) => timelineLabMarkerId(key) === null),
|
||||
...values.filter(
|
||||
(key) =>
|
||||
timelineLabMarkerId(key) === null &&
|
||||
timelineSymptomDefinitionId(key) === null,
|
||||
),
|
||||
]),
|
||||
].sort((a, b) => rank.indexOf(a) - rank.indexOf(b));
|
||||
const markers = new Map(
|
||||
@@ -291,15 +363,29 @@ export function TimelineValuesMenu({
|
||||
(m) => [m.key, m],
|
||||
),
|
||||
);
|
||||
// A chosen symptom is offered even when the list no longer holds it (a
|
||||
// line sent for it names it), so it can be unticked.
|
||||
const symptomMap = new Map(
|
||||
[
|
||||
...(timeline?.availableSymptoms ?? []),
|
||||
...(timeline?.symptomSeries ?? []),
|
||||
].map((s) => [s.key, { key: s.key, hidden: !s.isActive }]),
|
||||
);
|
||||
const symptoms = symptomsOn ? [...symptomMap.values()] : [];
|
||||
const labGroups = labsOn
|
||||
? labOptionGroups([...markers.values()], resolveIntlLocale(locale))
|
||||
: [];
|
||||
return (
|
||||
<ValueSeriesMenu
|
||||
options={options}
|
||||
symptoms={symptoms}
|
||||
labGroups={labGroups}
|
||||
selected={values.filter(
|
||||
(key) => timelineLabMarkerId(key) === null || markers.has(key),
|
||||
selected={values.filter((key) =>
|
||||
timelineLabMarkerId(key) !== null
|
||||
? markers.has(key)
|
||||
: timelineSymptomDefinitionId(key) !== null
|
||||
? symptomMap.has(key)
|
||||
: true,
|
||||
)}
|
||||
label={label}
|
||||
color={color}
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
formatBucket,
|
||||
} from "./timeline-dates";
|
||||
import { isSpan } from "./timeline-geometry";
|
||||
import { useSeriesCountText } from "./use-series-value-format";
|
||||
|
||||
/**
|
||||
* The chart as a table, for a screen reader: every entry with its lane and
|
||||
@@ -116,7 +117,8 @@ export function SeriesTable({
|
||||
fmt: SeriesValueFormat;
|
||||
intl: string;
|
||||
}) {
|
||||
const { t, tCount } = useTranslations();
|
||||
const { t } = useTranslations();
|
||||
const countText = useSeriesCountText();
|
||||
const { bucket, series } = timeline;
|
||||
const inWindow = (start: string) =>
|
||||
start <= window.to && bucketAfter(start, bucket) > window.from;
|
||||
@@ -163,7 +165,7 @@ export function SeriesTable({
|
||||
return (
|
||||
<td key={s.key}>
|
||||
{point
|
||||
? `${formatSeriesValue(s.key, point.mean, s.unit, fmt)}, ${tCount("timeline.values.readings", point.count)}`
|
||||
? `${formatSeriesValue(s.key, point.mean, s.unit, fmt)}, ${countText(s.key, point.count)}`
|
||||
: t("timeline.values.noValue")}
|
||||
</td>
|
||||
);
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
* Lab markers the record has values for are offered beside the measurements
|
||||
* (#1299): a chosen marker is stored and requested like any other line, as
|
||||
* `lab:<biomarkerId>`, and drawn as its single readings, never as means.
|
||||
* The person's own symptoms (#981) are offered the same way, as
|
||||
* `symptom:<definitionId>`, and drawn as value lines on the fixed intensity
|
||||
* scale: each point the mean of the day peaks the server averaged.
|
||||
*
|
||||
* At 768 px and up the chart; below it the chronicle, which is also the
|
||||
* list form of the chart. The readiness card sits on top while a lane is
|
||||
@@ -37,10 +40,11 @@ import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import {
|
||||
capTimelineLabKeys,
|
||||
capTimelineSeriesKeys,
|
||||
DAY_QUERY_PARAM,
|
||||
TIMELINE_ZOOMS,
|
||||
timelineLabMarkerId,
|
||||
timelineSymptomDefinitionId,
|
||||
type LifeEventDTO,
|
||||
type TimelineLaneKey,
|
||||
type TimelineResponse,
|
||||
@@ -88,7 +92,9 @@ import {
|
||||
LayersMenu,
|
||||
TimelineValuesMenu,
|
||||
labMarkerNames,
|
||||
symptomNames,
|
||||
} from "./timeline-menus";
|
||||
import { withSymptomLines } from "./series-format";
|
||||
import {
|
||||
isBoolean,
|
||||
isStringArray,
|
||||
@@ -151,7 +157,8 @@ function timelineIsEmpty(data: TimelineResponse): boolean {
|
||||
data.lanes.every((l) => l.items.length === 0) &&
|
||||
data.standing.length === 0 &&
|
||||
data.series.every((s) => s.points.length === 0) &&
|
||||
data.labSeries.every((s) => s.points.length === 0)
|
||||
data.labSeries.every((s) => s.points.length === 0) &&
|
||||
data.symptomSeries.every((s) => s.points.length === 0)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -178,6 +185,10 @@ function useValueLines(
|
||||
// A lab line belongs to the labs module; whether the record has the
|
||||
// marker is the server's to say.
|
||||
if (timelineLabMarkerId(key) !== null) return modules?.labs !== false;
|
||||
// A symptom line rides the illness module, as symptoms do everywhere.
|
||||
if (timelineSymptomDefinitionId(key) !== null) {
|
||||
return modules?.illness !== false;
|
||||
}
|
||||
if (!KNOWN_SERIES.has(key)) return false;
|
||||
const owner: ModuleKey | null | undefined =
|
||||
VALUE_OPTION_MODULE[key as keyof typeof VALUE_OPTION_MODULE];
|
||||
@@ -185,10 +196,10 @@ function useValueLines(
|
||||
},
|
||||
[modules],
|
||||
);
|
||||
// Lab markers past the cap are not asked for (the server would leave
|
||||
// them out); the menu holds further ones back.
|
||||
// Lab markers and symptoms past their caps are not asked for (the server
|
||||
// would leave them out); the menu holds further ones back.
|
||||
const values = useMemo(
|
||||
() => capTimelineLabKeys(storedValues.filter(allowed)),
|
||||
() => capTimelineSeriesKeys(storedValues.filter(allowed)),
|
||||
[storedValues, allowed],
|
||||
);
|
||||
// One colour per chosen line, shared by the chart, the selection bar, the
|
||||
@@ -202,6 +213,39 @@ function useValueLines(
|
||||
return { values, allowed, setStoredValues, seriesColor };
|
||||
}
|
||||
|
||||
/**
|
||||
* The timeline as the page draws it: symptom lines (#981) are drawn and
|
||||
* worded as value lines from here on.
|
||||
*/
|
||||
function useWithSymptomLines(
|
||||
raw: TimelineResponse | undefined,
|
||||
): TimelineResponse | undefined {
|
||||
return useMemo(() => (raw ? withSymptomLines(raw) : undefined), [raw]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Each value line's name. Blood pressure reads as its own name here; the
|
||||
* list labels ("Sys", "Dia") lean on a column header the chart does not
|
||||
* have. A lab marker reads by its own name, as on the labs list, and a
|
||||
* symptom by the name the person gave it.
|
||||
*/
|
||||
function useSeriesLabel(
|
||||
data: TimelineResponse | undefined,
|
||||
): (key: string) => string {
|
||||
const { t } = useTranslations();
|
||||
const labNames = labMarkerNames(data);
|
||||
const symptomLabels = symptomNames(data);
|
||||
return (key: string) =>
|
||||
key === "BLOOD_PRESSURE_SYS" || key === "BLOOD_PRESSURE_DIA"
|
||||
? t(TIMELINE_BLOOD_PRESSURE_SERIES_KEY[key])
|
||||
: key === "MOOD"
|
||||
? t("timeline.readiness.lanes.mood")
|
||||
: timelineSymptomDefinitionId(key) !== null
|
||||
? (symptomLabels.get(key) ?? t("timeline.symptoms.unnamed"))
|
||||
: (labNames.get(key) ??
|
||||
t(MEASUREMENT_TYPE_LABEL_KEYS[key as MeasurementType] ?? key));
|
||||
}
|
||||
|
||||
export function TimelineView() {
|
||||
const { t } = useTranslations();
|
||||
const { user } = useAuth();
|
||||
@@ -318,7 +362,7 @@ export function TimelineView() {
|
||||
}
|
||||
}
|
||||
|
||||
const data = timeline.data;
|
||||
const data = useWithSymptomLines(timeline.data);
|
||||
// A pick outside a newly chosen range no longer counts as one.
|
||||
const chosen =
|
||||
selected !== null &&
|
||||
@@ -362,17 +406,7 @@ export function TimelineView() {
|
||||
setLifeEventOpen(true);
|
||||
}
|
||||
|
||||
const labNames = labMarkerNames(data);
|
||||
// Blood pressure reads as its own name here; the list labels ("Sys",
|
||||
// "Dia") lean on a column header the chart does not have. A lab marker
|
||||
// reads by its own name, as on the labs list.
|
||||
const seriesLabel = (key: string) =>
|
||||
key === "BLOOD_PRESSURE_SYS" || key === "BLOOD_PRESSURE_DIA"
|
||||
? t(TIMELINE_BLOOD_PRESSURE_SERIES_KEY[key])
|
||||
: key === "MOOD"
|
||||
? t("timeline.readiness.lanes.mood")
|
||||
: (labNames.get(key) ??
|
||||
t(MEASUREMENT_TYPE_LABEL_KEYS[key as MeasurementType] ?? key));
|
||||
const seriesLabel = useSeriesLabel(data);
|
||||
|
||||
const shownWindow = data
|
||||
? windowFor(
|
||||
@@ -405,6 +439,7 @@ export function TimelineView() {
|
||||
allowed={allowed}
|
||||
rank={SERIES_RANK}
|
||||
labsOn={modules?.labs !== false}
|
||||
symptomsOn={modules?.illness !== false}
|
||||
label={seriesLabel}
|
||||
color={seriesColor}
|
||||
onChange={setStoredValues}
|
||||
@@ -603,6 +638,7 @@ export function TimelineView() {
|
||||
<TimelineLegend
|
||||
bucket={data.bucket}
|
||||
hasSeries={data.series.length > 0}
|
||||
hasSymptoms={data.symptomSeries.length > 0}
|
||||
labSeries={data.labSeries}
|
||||
label={seriesLabel}
|
||||
/>
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
|
||||
import { useDayValueFormat } from "@/components/day/use-day-value-format";
|
||||
import { useFormatters } from "@/lib/i18n/context";
|
||||
import {
|
||||
TIMELINE_SYMPTOM_SCALE,
|
||||
timelineSymptomDefinitionId,
|
||||
} from "@/lib/day/contract";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
import { plainSeriesFormat, type SeriesValueFormat } from "./series-format";
|
||||
|
||||
@@ -14,18 +18,26 @@ const MOOD_SERIES_KEY = "MOOD";
|
||||
* The timeline's value format: the day view's formatter for every
|
||||
* measurement series (unit preference, whole numbers for pressure and
|
||||
* pulse, sleep as a duration), one plain decimal for mood, which has no
|
||||
* measurement type behind it.
|
||||
* measurement type behind it, and at most one decimal out of the scale's top
|
||||
* for a symptom ("6.5/10", "3/10", #981).
|
||||
*/
|
||||
export function useSeriesValueFormat(): SeriesValueFormat {
|
||||
const fmt = useFormatters();
|
||||
const { number, unitFor } = useDayValueFormat();
|
||||
return useMemo(() => {
|
||||
const plain = plainSeriesFormat(fmt);
|
||||
// At most one decimal, none for a whole intensity: "3/10", "6.5/10".
|
||||
const symptomNumber = (value: number) => {
|
||||
const rounded = Math.round(value * 10) / 10;
|
||||
return fmt.number(rounded, Number.isInteger(rounded) ? 0 : 1);
|
||||
};
|
||||
return {
|
||||
number: (key, value, unit) =>
|
||||
key === MOOD_SERIES_KEY || unit === null
|
||||
? plain.number(key, value, unit)
|
||||
: number({ type: key, unit }, value),
|
||||
timelineSymptomDefinitionId(key) !== null
|
||||
? `${symptomNumber(value)}/${TIMELINE_SYMPTOM_SCALE.max}`
|
||||
: key === MOOD_SERIES_KEY || unit === null
|
||||
? plain.number(key, value, unit)
|
||||
: number({ type: key, unit }, value),
|
||||
unit: (key, unit) =>
|
||||
key === MOOD_SERIES_KEY || unit === null
|
||||
? plain.unit(key, unit)
|
||||
@@ -33,3 +45,19 @@ export function useSeriesValueFormat(): SeriesValueFormat {
|
||||
};
|
||||
}, [fmt, number, unitFor]);
|
||||
}
|
||||
|
||||
/**
|
||||
* What stands behind one point of a value line: "mean of 4 readings" for a
|
||||
* measurement or mood, "on 3 days" for a symptom, whose count is the days
|
||||
* it was logged (#981).
|
||||
*/
|
||||
export function useSeriesCountText(): (key: string, count: number) => string {
|
||||
const { tCount } = useTranslations();
|
||||
return useCallback(
|
||||
(key, count) =>
|
||||
timelineSymptomDefinitionId(key) !== null
|
||||
? tCount("timeline.symptoms.days", count)
|
||||
: tCount("timeline.values.readings", count),
|
||||
[tCount],
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user