mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
Merge the timeline buckets, gaps and series limit into release/v1.42.0
This commit is contained in:
commit
f6f0969da9
30 files changed
+1477
-308
No files matched your search
@@ -45,7 +45,7 @@ export const GET = apiHandler(async (request: Request) => {
|
||||
}
|
||||
const seriesKeys = parseSeriesKeys(parsed.data.values);
|
||||
if (seriesKeys === null) {
|
||||
return apiError("Unknown value series", 422, {
|
||||
return apiError("Unknown value series, or more than six", 422, {
|
||||
errorCode: "timeline.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { ReadinessCard } from "../readiness-card";
|
||||
import { ReadinessInventory, VerdictMeter } from "../readiness-inventory";
|
||||
import { SelectionBar } from "../selection-bar";
|
||||
import { TimelineChart } from "../timeline-chart";
|
||||
import { TimelineChronicle } from "../timeline-chronicle";
|
||||
import { TODAY, fullTimeline, readiness } from "./timeline-fixture";
|
||||
|
||||
@@ -121,25 +122,109 @@ describe("chronicle", () => {
|
||||
);
|
||||
expect(html).toContain("129/82 mmHg · 82,6 kg");
|
||||
});
|
||||
|
||||
it("names a quarter once, at its newest month, with its span", () => {
|
||||
const timeline = {
|
||||
...fullTimeline(),
|
||||
bucket: "quarter" as const,
|
||||
series: [
|
||||
{
|
||||
key: "WEIGHT",
|
||||
unit: "kg",
|
||||
points: [{ t: "2026-01-01", mean: 82.6, count: 9 }],
|
||||
},
|
||||
],
|
||||
};
|
||||
const html = render(
|
||||
<TimelineChronicle
|
||||
timeline={timeline}
|
||||
today={TODAY}
|
||||
grouping="month"
|
||||
selected={null}
|
||||
onOpenDay={() => undefined}
|
||||
onEditLifeEvent={null}
|
||||
/>,
|
||||
);
|
||||
expect(html.match(/data-slot="timeline-chronicle-means"/g)).toHaveLength(1);
|
||||
expect(html).toContain("Jan bis März 2026: 82,6 kg");
|
||||
});
|
||||
});
|
||||
|
||||
const LABELS: Record<string, string> = {
|
||||
BLOOD_PRESSURE_SYS: "Blutdruck sys.",
|
||||
BLOOD_PRESSURE_DIA: "Blutdruck dia.",
|
||||
WEIGHT: "Gewicht",
|
||||
};
|
||||
const seriesLabel = (key: string) => LABELS[key] ?? key;
|
||||
|
||||
describe("selection bar", () => {
|
||||
it("lists the month's entries and offers the selected day", () => {
|
||||
const html = render(
|
||||
const bar = (selected: string) =>
|
||||
render(
|
||||
<SelectionBar
|
||||
timeline={fullTimeline()}
|
||||
selected="2026-01-03"
|
||||
selected={selected}
|
||||
today={TODAY}
|
||||
seriesLabel={seriesLabel}
|
||||
onOpenDay={() => undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
it("lists the month's entries and offers the selected day", () => {
|
||||
const html = bar("2026-01-03");
|
||||
expect(html).toContain("Januar 2026");
|
||||
expect(html).toContain("Erkältung · 31. Dez. bis 8. Jan.");
|
||||
expect(html).toContain("Vitamin D (Winter) · laufend");
|
||||
expect(html).not.toContain("Bluthochdruck");
|
||||
expect(html).toContain("3. Jan. öffnen");
|
||||
expect(html).toContain('data-date="2026-01-03"');
|
||||
expect(html).toContain("Ø 129/82 mmHg · 82,6 kg");
|
||||
});
|
||||
|
||||
it("names the bucket's means with the readings behind each", () => {
|
||||
expect(bar("2026-01-03")).toContain(
|
||||
"Ø Januar 2026: Blutdruck 129/82 mmHg (Schnitt aus 4 Messungen) · Gewicht 82,6 kg (Schnitt aus 3 Messungen)",
|
||||
);
|
||||
expect(bar("2026-07-15")).toContain(
|
||||
"Ø Juli 2026: Blutdruck sys. 131 mmHg (1 Messung) · Blutdruck dia. kein Wert · Gewicht kein Wert",
|
||||
);
|
||||
});
|
||||
|
||||
it("says a missing month has no value instead of interpolating one", () => {
|
||||
const html = bar("2026-02-10");
|
||||
expect(html).toContain(
|
||||
"Ø Februar 2026: Blutdruck sys. kein Wert · Blutdruck dia. kein Wert · Gewicht kein Wert",
|
||||
);
|
||||
// January's 129 and March's 133 would make 131 between them.
|
||||
expect(html).not.toMatch(/\d mmHg/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("value table for a screen reader", () => {
|
||||
it("lists every month between the first and the last reading, gaps as gaps", () => {
|
||||
const html = render(
|
||||
<TimelineChart
|
||||
timeline={fullTimeline()}
|
||||
window={{ from: "2025-10-01", to: "2026-10-31" }}
|
||||
zoom="year"
|
||||
today={TODAY}
|
||||
selected={null}
|
||||
hiddenLanes={new Set()}
|
||||
seriesLabel={seriesLabel}
|
||||
onSelect={() => undefined}
|
||||
onOpenDay={() => undefined}
|
||||
/>,
|
||||
);
|
||||
const table = html.slice(html.indexOf('data-slot="timeline-series-table"'));
|
||||
// October 2025 to July 2026: ten months, newest first.
|
||||
expect(table.match(/data-bucket="/g)).toHaveLength(10);
|
||||
const row = (bucket: string) => {
|
||||
const start = table.indexOf(`data-bucket="${bucket}"`);
|
||||
return table.slice(start, table.indexOf("</tr>", start));
|
||||
};
|
||||
expect(row("2026-02-01")).toContain("Februar 2026");
|
||||
expect(row("2026-02-01").match(/kein Wert/g)).toHaveLength(3);
|
||||
expect(row("2026-01-01")).toContain("129 mmHg, Schnitt aus 4 Messungen");
|
||||
expect(row("2026-07-01")).toContain("131 mmHg, 1 Messung");
|
||||
expect(row("2026-05-01")).not.toMatch(/mmHg|kg/);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -235,31 +235,31 @@ export function fullTimeline(): TimelineResponse {
|
||||
},
|
||||
],
|
||||
standing: [],
|
||||
bucket: "month",
|
||||
series: [
|
||||
{
|
||||
key: "BLOOD_PRESSURE_SYS",
|
||||
unit: "mmHg",
|
||||
granularity: "month",
|
||||
points: [
|
||||
{ t: "2025-10-01", mean: 127 },
|
||||
{ t: "2025-11-01", mean: 128 },
|
||||
{ t: "2025-12-01", mean: 130 },
|
||||
{ t: "2026-01-01", mean: 129 },
|
||||
// Nothing for February to June.
|
||||
{ t: "2026-07-01", mean: 131 },
|
||||
{ t: "2025-10-01", mean: 127, count: 6 },
|
||||
{ t: "2025-11-01", mean: 128, count: 5 },
|
||||
{ t: "2025-12-01", mean: 130, count: 4 },
|
||||
{ t: "2026-01-01", mean: 129, count: 4 },
|
||||
// Nothing in February: one missing month, bridged.
|
||||
{ t: "2026-03-01", mean: 133, count: 2 },
|
||||
// Nothing from April to June: three missing months, broken.
|
||||
{ t: "2026-07-01", mean: 131, count: 1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "BLOOD_PRESSURE_DIA",
|
||||
unit: "mmHg",
|
||||
granularity: "month",
|
||||
points: [{ t: "2026-01-01", mean: 82 }],
|
||||
points: [{ t: "2026-01-01", mean: 82, count: 4 }],
|
||||
},
|
||||
{
|
||||
key: "WEIGHT",
|
||||
unit: "kg",
|
||||
granularity: "month",
|
||||
points: [{ t: "2026-01-01", mean: 82.6 }],
|
||||
points: [{ t: "2026-01-01", mean: 82.6, count: 3 }],
|
||||
},
|
||||
],
|
||||
notable: [{ date: "2026-01-04", kind: "extremeHigh" }],
|
||||
|
||||
@@ -40,6 +40,7 @@ function layoutAt(width: number): TimelineLayout {
|
||||
window: windowFor("all", TODAY, null, tl.range),
|
||||
lanes: tl.lanes,
|
||||
series: tl.series,
|
||||
bucket: tl.bucket,
|
||||
startMissing: "Startdatum fehlt",
|
||||
today: TODAY,
|
||||
});
|
||||
@@ -345,30 +346,136 @@ describe("layoutTimeline", () => {
|
||||
});
|
||||
|
||||
describe("layoutSeries", () => {
|
||||
it("breaks the line across missing months instead of bridging them", () => {
|
||||
const window = { from: "2025-10-01", to: "2026-10-31" };
|
||||
const scale = createScale(window, 164, 1200);
|
||||
const [bp] = layoutSeries(
|
||||
fullTimeline().series.slice(0, 1),
|
||||
0,
|
||||
window,
|
||||
scale,
|
||||
);
|
||||
expect(bp.path.match(/M/g)).toHaveLength(2);
|
||||
const window = { from: "2025-10-01", to: "2026-10-31" };
|
||||
const scale = createScale(window, 164, 1200);
|
||||
const [bp] = layoutSeries(
|
||||
fullTimeline().series.slice(0, 1),
|
||||
0,
|
||||
window,
|
||||
scale,
|
||||
"month",
|
||||
);
|
||||
|
||||
it("keeps every month with a reading as a point, and invents none", () => {
|
||||
expect(bp.points.map((p) => p.t)).toEqual([
|
||||
"2025-10-01",
|
||||
"2025-11-01",
|
||||
"2025-12-01",
|
||||
"2026-01-01",
|
||||
"2026-03-01",
|
||||
"2026-07-01",
|
||||
]);
|
||||
expect(bp.points.map((p) => p.mean)).toEqual([
|
||||
127, 128, 130, 129, 133, 131,
|
||||
]);
|
||||
expect(bp.latest).toBe(131);
|
||||
});
|
||||
|
||||
it("keeps an empty series as a row without a line", () => {
|
||||
const window = { from: "2019-01-01", to: "2019-12-31" };
|
||||
const scale = createScale(window, 164, 1200);
|
||||
const [bp] = layoutSeries(
|
||||
fullTimeline().series.slice(0, 1),
|
||||
it("draws the run solid, bridges one missing month dashed, and breaks at three", () => {
|
||||
const [oct, , , jan, mar, jul] = bp.points;
|
||||
const at = (p: { x: number; y: number }) =>
|
||||
`${p.x.toFixed(1)} ${p.y.toFixed(1)}`;
|
||||
// One solid run, October to January.
|
||||
expect(bp.path.match(/M/g)).toHaveLength(1);
|
||||
expect(bp.path.startsWith(`M${at(oct)}`)).toBe(true);
|
||||
expect(bp.path.endsWith(`L${at(jan)}`)).toBe(true);
|
||||
// February is missing: one dashed stroke from January to March, with no
|
||||
// vertex between them.
|
||||
expect(bp.bridges).toBe(`M${at(jan)}L${at(mar)}`);
|
||||
// April to June are missing: July joins nothing.
|
||||
expect(bp.path).not.toContain(at(jul));
|
||||
expect(bp.bridges).not.toContain(at(jul));
|
||||
});
|
||||
|
||||
it("draws a bucket of fewer than three readings hollow", () => {
|
||||
expect(bp.points.map((p) => [p.t, p.thin])).toEqual([
|
||||
["2025-10-01", false],
|
||||
["2025-11-01", false],
|
||||
["2025-12-01", false],
|
||||
["2026-01-01", false],
|
||||
["2026-03-01", true],
|
||||
["2026-07-01", true],
|
||||
]);
|
||||
});
|
||||
|
||||
it("bridges at most one missing quarter and four missing weeks", () => {
|
||||
const quarter = (points: Array<[string, number]>) =>
|
||||
layoutSeries(
|
||||
[
|
||||
{
|
||||
key: "WEIGHT",
|
||||
unit: "kg",
|
||||
points: points.map(([t, mean]) => ({ t, mean, count: 5 })),
|
||||
},
|
||||
],
|
||||
0,
|
||||
{ from: "2020-01-01", to: "2026-12-31" },
|
||||
createScale({ from: "2020-01-01", to: "2026-12-31" }, 164, 1200),
|
||||
"quarter",
|
||||
)[0];
|
||||
// Q1, then Q3: one missing quarter, bridged.
|
||||
expect(
|
||||
quarter([
|
||||
["2024-01-01", 80],
|
||||
["2024-07-01", 81],
|
||||
]).bridges,
|
||||
).not.toBe("");
|
||||
// Q1, then Q4: two missing quarters, broken; each point still drawn.
|
||||
const broken = quarter([
|
||||
["2024-01-01", 80],
|
||||
["2024-10-01", 81],
|
||||
]);
|
||||
expect(broken.bridges).toBe("");
|
||||
expect(broken.path).toBe("");
|
||||
expect(broken.points).toHaveLength(2);
|
||||
|
||||
const weeks = (second: string) =>
|
||||
layoutSeries(
|
||||
[
|
||||
{
|
||||
key: "WEIGHT",
|
||||
unit: "kg",
|
||||
points: [
|
||||
{ t: "2026-01-05", mean: 80, count: 3 },
|
||||
{ t: second, mean: 81, count: 3 },
|
||||
],
|
||||
},
|
||||
],
|
||||
0,
|
||||
{ from: "2026-01-01", to: "2026-03-31" },
|
||||
createScale({ from: "2026-01-01", to: "2026-03-31" }, 164, 1200),
|
||||
"week",
|
||||
)[0];
|
||||
// Mondays: four missing weeks bridged, five broken.
|
||||
expect(weeks("2026-02-09").bridges).not.toBe("");
|
||||
expect(weeks("2026-02-16").bridges).toBe("");
|
||||
});
|
||||
|
||||
it("keeps a lone point visible", () => {
|
||||
const [weight] = layoutSeries(
|
||||
fullTimeline().series.slice(2, 3),
|
||||
0,
|
||||
window,
|
||||
scale,
|
||||
"month",
|
||||
);
|
||||
expect(bp.path).toBe("");
|
||||
expect(bp.latest).toBeNull();
|
||||
expect(weight.path).toBe("");
|
||||
expect(weight.bridges).toBe("");
|
||||
expect(weight.points).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps an empty series as a row without a line", () => {
|
||||
const early = { from: "2019-01-01", to: "2019-12-31" };
|
||||
const [empty] = layoutSeries(
|
||||
fullTimeline().series.slice(0, 1),
|
||||
0,
|
||||
early,
|
||||
createScale(early, 164, 1200),
|
||||
"month",
|
||||
);
|
||||
expect(empty.path).toBe("");
|
||||
expect(empty.points).toEqual([]);
|
||||
expect(empty.latest).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
/**
|
||||
* The bar under the chart (v1.42, #613): the selected month, every entry
|
||||
* that touches it as a chip (each one a way into its day), the month's
|
||||
* means of the chosen values, and "Open 3 Jan." for the selected day.
|
||||
* that touches it as a chip (each one a way into its day), the means of the
|
||||
* chosen values in the bucket that holds the selected day (with the readings
|
||||
* behind each, and "no value" where there is none), and "Open 3 Jan." for
|
||||
* the selected day.
|
||||
*
|
||||
* It is also where a label the chart had to leave out is read in full, and
|
||||
* where each keyboard step on the chart is announced (`aria-live`).
|
||||
@@ -23,45 +25,31 @@ import {
|
||||
startOfMonth,
|
||||
} from "./timeline-dates";
|
||||
import { itemsInMonth } from "./timeline-geometry";
|
||||
import { laneDotStyle } from "./timeline-chart";
|
||||
import { formatMonthMeans } from "./series-format";
|
||||
import { bucketText, laneDotStyle } from "./timeline-chart";
|
||||
import { bucketValues, formatLabelledMeans } from "./series-format";
|
||||
|
||||
export interface SelectionBarProps {
|
||||
timeline: TimelineResponse;
|
||||
selected: string;
|
||||
today: string;
|
||||
seriesLabel: (key: string) => string;
|
||||
onOpenDay: (date: string) => void;
|
||||
onEditLifeEvent?: (id: string) => void;
|
||||
}
|
||||
|
||||
/** The month means of each series for the month of `date`. */
|
||||
export function monthMeans(
|
||||
series: TimelineResponse["series"],
|
||||
date: string,
|
||||
): Array<{ key: string; mean: number; unit: string | null }> {
|
||||
const month = monthOf(date);
|
||||
const out: Array<{ key: string; mean: number; unit: string | null }> = [];
|
||||
for (const s of series) {
|
||||
const inMonth = s.points.filter((p) => monthOf(p.t) === month);
|
||||
if (inMonth.length === 0) continue;
|
||||
const mean = inMonth.reduce((sum, p) => sum + p.mean, 0) / inMonth.length;
|
||||
out.push({ key: s.key, mean, unit: s.unit });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function SelectionBar({
|
||||
timeline,
|
||||
selected,
|
||||
today,
|
||||
seriesLabel,
|
||||
onOpenDay,
|
||||
onEditLifeEvent,
|
||||
}: SelectionBarProps) {
|
||||
const { t, locale } = useTranslations();
|
||||
const { t, tCount, locale } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const intl = resolveIntlLocale(locale);
|
||||
const entries = itemsInMonth(timeline.lanes, selected, today);
|
||||
const means = monthMeans(timeline.series, selected);
|
||||
const means = bucketValues(timeline.series, timeline.bucket, selected);
|
||||
const monthFrom = startOfMonth(selected);
|
||||
const monthTo = endOfMonth(selected);
|
||||
|
||||
@@ -134,10 +122,23 @@ export function SelectionBar({
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{means.length > 0 && (
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
{means.values.length > 0 && (
|
||||
<span
|
||||
className="text-muted-foreground text-xs tabular-nums"
|
||||
data-slot="timeline-selection-means"
|
||||
data-bucket={means.start}
|
||||
>
|
||||
{t("timeline.selection.mean", {
|
||||
values: formatMonthMeans(means, fmt),
|
||||
values: `${bucketText(means.start, timeline.bucket, intl, t)}: ${formatLabelledMeans(
|
||||
means.values,
|
||||
fmt,
|
||||
{
|
||||
label: seriesLabel,
|
||||
bloodPressure: t("timeline.values.bloodPressure"),
|
||||
noValue: t("timeline.values.noValue"),
|
||||
readings: (n) => tCount("timeline.values.readings", n),
|
||||
},
|
||||
)}`,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -3,8 +3,11 @@
|
||||
* pulse and counts, one decimal for the rest, sleep in hours. The unit is the
|
||||
* server's, already in the person's display unit.
|
||||
*/
|
||||
import type { TimelineBucket, TimelineResponse } from "@/lib/day/contract";
|
||||
import type { Formatters } from "@/lib/format-locale";
|
||||
|
||||
import { addMonths, bucketAfter, bucketStart } from "./timeline-dates";
|
||||
|
||||
const WHOLE_NUMBER_KEYS: ReadonlySet<string> = new Set([
|
||||
"BLOOD_PRESSURE_SYS",
|
||||
"BLOOD_PRESSURE_DIA",
|
||||
@@ -43,18 +46,92 @@ export function formatSeriesValue(
|
||||
return out.unit ? `${out.value} ${out.unit}` : out.value;
|
||||
}
|
||||
|
||||
/** One series in one bucket: its mean, or nothing when it has no reading. */
|
||||
export interface BucketValue {
|
||||
key: string;
|
||||
unit: string | null;
|
||||
mean: number | null;
|
||||
count: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The month means in one short line: systolic and diastolic fold into
|
||||
* "129/82 mmHg", the rest follow as "82,6 kg".
|
||||
* Every series in the bucket that holds `date`. A series without a reading
|
||||
* in that bucket is listed with `mean: null`: the gap stays a gap.
|
||||
*/
|
||||
export function formatMonthMeans(
|
||||
means: ReadonlyArray<{ key: string; mean: number; unit: string | null }>,
|
||||
export function bucketValues(
|
||||
series: TimelineResponse["series"],
|
||||
bucket: TimelineBucket,
|
||||
date: string,
|
||||
): { start: string; values: BucketValue[] } {
|
||||
const start = bucketStart(date, bucket);
|
||||
return {
|
||||
start,
|
||||
values: series.map((s) => {
|
||||
const point = s.points.find((p) => p.t === start);
|
||||
return {
|
||||
key: s.key,
|
||||
unit: s.unit,
|
||||
mean: point?.mean ?? null,
|
||||
count: point?.count ?? null,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The bucket means each chronicle month shows (`months` newest first, as the
|
||||
* chronicle lists them). A bucket is named once, at the newest listed month
|
||||
* it overlaps: a quarter rides on its latest month with entries, never on
|
||||
* all three. Where two buckets of one series meet at one month (weeks), the
|
||||
* newer stands. Only buckets with a reading appear; a month without one
|
||||
* shows nothing rather than a number.
|
||||
*/
|
||||
export function chronicleMeans(
|
||||
series: TimelineResponse["series"],
|
||||
bucket: TimelineBucket,
|
||||
months: readonly string[],
|
||||
): Map<string, { start: string; values: BucketValue[] }> {
|
||||
const out = new Map<string, { start: string; values: BucketValue[] }>();
|
||||
for (const s of series) {
|
||||
const points = [...s.points].sort((a, b) => (a.t < b.t ? 1 : -1));
|
||||
const taken = new Set<string>();
|
||||
for (const p of points) {
|
||||
const end = bucketAfter(p.t, bucket);
|
||||
const month = months.find(
|
||||
(m) => m < end && addMonths(m, 1) > p.t && !taken.has(m),
|
||||
);
|
||||
if (!month) continue;
|
||||
taken.add(month);
|
||||
const slot = out.get(month) ?? { start: p.t, values: [] };
|
||||
if (p.t > slot.start) slot.start = p.t;
|
||||
slot.values.push({
|
||||
key: s.key,
|
||||
unit: s.unit,
|
||||
mean: p.mean,
|
||||
count: p.count,
|
||||
});
|
||||
out.set(month, slot);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The means in one short line: systolic and diastolic fold into
|
||||
* "129/82 mmHg", the rest follow as "82,6 kg". Series without a value are
|
||||
* left out; the line is for a glance, the selection bar names the gaps.
|
||||
*/
|
||||
export function formatMeans(
|
||||
means: readonly BucketValue[],
|
||||
fmt: Pick<Formatters, "number">,
|
||||
): string {
|
||||
const sys = means.find((m) => m.key === "BLOOD_PRESSURE_SYS");
|
||||
const dia = means.find((m) => m.key === "BLOOD_PRESSURE_DIA");
|
||||
const present = means.filter(
|
||||
(m): m is BucketValue & { mean: number } => m.mean !== null,
|
||||
);
|
||||
const sys = present.find((m) => m.key === "BLOOD_PRESSURE_SYS");
|
||||
const dia = present.find((m) => m.key === "BLOOD_PRESSURE_DIA");
|
||||
const parts: string[] = [];
|
||||
for (const m of means) {
|
||||
for (const m of present) {
|
||||
if (sys && dia && m.key === "BLOOD_PRESSURE_DIA") continue;
|
||||
if (sys && dia && m.key === "BLOOD_PRESSURE_SYS") {
|
||||
parts.push(
|
||||
@@ -66,3 +143,43 @@ export function formatMonthMeans(
|
||||
}
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
/**
|
||||
* The means of one bucket with their names, readings and gaps, for the
|
||||
* selection bar: "Blood pressure 129/82 mmHg (mean of 4 readings) · Weight
|
||||
* no value". Systolic and diastolic fold when both have a value.
|
||||
*/
|
||||
export function formatLabelledMeans(
|
||||
means: readonly BucketValue[],
|
||||
fmt: Pick<Formatters, "number">,
|
||||
words: {
|
||||
label: (key: string) => string;
|
||||
bloodPressure: string;
|
||||
noValue: string;
|
||||
readings: (count: number) => string;
|
||||
},
|
||||
): string {
|
||||
const sys = means.find(
|
||||
(m) => m.key === "BLOOD_PRESSURE_SYS" && m.mean !== null,
|
||||
);
|
||||
const dia = means.find(
|
||||
(m) => m.key === "BLOOD_PRESSURE_DIA" && m.mean !== null,
|
||||
);
|
||||
const fold = sys && dia;
|
||||
const parts: string[] = [];
|
||||
for (const m of means) {
|
||||
if (fold && m.key === "BLOOD_PRESSURE_DIA") continue;
|
||||
if (fold && m.key === "BLOOD_PRESSURE_SYS") {
|
||||
parts.push(
|
||||
`${words.bloodPressure} ${fmt.number(sys.mean!, 0)}/${fmt.number(dia.mean!, 0)}${sys.unit ? ` ${sys.unit}` : ""} (${words.readings(sys.count ?? 0)})`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
parts.push(
|
||||
m.mean === null
|
||||
? `${words.label(m.key)} ${words.noValue}`
|
||||
: `${words.label(m.key)} ${formatSeriesValue(m.key, m.mean, m.unit, fmt)} (${words.readings(m.count ?? 0)})`,
|
||||
);
|
||||
}
|
||||
return parts.join(" · ");
|
||||
}
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
} from "lucide-react";
|
||||
|
||||
import type {
|
||||
TimelineBucket,
|
||||
TimelineLaneKey,
|
||||
TimelineResponse,
|
||||
TimelineZoom,
|
||||
@@ -51,7 +52,10 @@ import { resolveIntlLocale } from "@/lib/format-locale";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
import {
|
||||
bucketAfter,
|
||||
bucketStart,
|
||||
formatAtPrecision,
|
||||
formatBucket,
|
||||
formatMonthShort,
|
||||
formatMonthYear,
|
||||
dayNumber,
|
||||
@@ -145,11 +149,12 @@ export function TimelineChart({
|
||||
window,
|
||||
lanes,
|
||||
series: timeline.series,
|
||||
bucket: timeline.bucket,
|
||||
startMissing: t("timeline.startMissing"),
|
||||
today,
|
||||
})
|
||||
: null,
|
||||
[width, window, lanes, timeline.series, t, today],
|
||||
[width, window, lanes, timeline.series, timeline.bucket, t, today],
|
||||
);
|
||||
|
||||
const floor = timeline.range.dataFrom ?? window.from;
|
||||
@@ -228,6 +233,7 @@ export function TimelineChart({
|
||||
today={today}
|
||||
selected={selected}
|
||||
dataFrom={timeline.range.dataFrom}
|
||||
bucket={timeline.bucket}
|
||||
seriesLabel={seriesLabel}
|
||||
intl={intl}
|
||||
t={t}
|
||||
@@ -243,6 +249,12 @@ export function TimelineChart({
|
||||
hiddenLanes={hiddenLanes}
|
||||
intl={intl}
|
||||
/>
|
||||
<SeriesTable
|
||||
timeline={timeline}
|
||||
window={window}
|
||||
seriesLabel={seriesLabel}
|
||||
intl={intl}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -254,6 +266,7 @@ interface ChartBodyProps {
|
||||
today: string;
|
||||
selected: string | null;
|
||||
dataFrom: string | null;
|
||||
bucket: TimelineBucket;
|
||||
seriesLabel: (key: string) => string;
|
||||
intl: string;
|
||||
t: ReturnType<typeof useTranslations>["t"];
|
||||
@@ -267,11 +280,13 @@ function ChartBody({
|
||||
today,
|
||||
selected,
|
||||
dataFrom,
|
||||
bucket,
|
||||
seriesLabel,
|
||||
intl,
|
||||
t,
|
||||
fmt,
|
||||
}: ChartBodyProps) {
|
||||
const { tCount } = useTranslations();
|
||||
const { scale, height, width } = layout;
|
||||
const todayInWindow = today >= window.from && today <= window.to;
|
||||
const todayX = scale.xMid(today);
|
||||
@@ -461,14 +476,37 @@ function ChartBody({
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
)}
|
||||
{series.points.length === 1 && (
|
||||
<circle
|
||||
cx={series.points[0].x}
|
||||
cy={series.points[0].y}
|
||||
r={2.5}
|
||||
fill="var(--foreground)"
|
||||
{series.bridges && (
|
||||
<path
|
||||
d={series.bridges}
|
||||
data-slot="timeline-series-bridge"
|
||||
fill="none"
|
||||
stroke="var(--foreground)"
|
||||
strokeWidth={1.2}
|
||||
strokeOpacity={0.6}
|
||||
strokeDasharray="3 3"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
)}
|
||||
{series.points.map((point) => (
|
||||
<circle
|
||||
key={point.t}
|
||||
data-slot="timeline-series-point"
|
||||
data-t={point.t}
|
||||
data-count={point.count}
|
||||
data-thin={point.thin ? "true" : "false"}
|
||||
cx={point.x}
|
||||
cy={point.y}
|
||||
r={point.thin ? 2.75 : 2.5}
|
||||
fill={point.thin ? "var(--card)" : "var(--foreground)"}
|
||||
stroke="var(--foreground)"
|
||||
strokeWidth={point.thin ? 1.4 : 0}
|
||||
>
|
||||
<title>
|
||||
{`${bucketText(point.t, bucket, intl, t)}: ${formatSeriesValue(series.key, point.mean, series.unit, fmt)}, ${tCount("timeline.values.readings", point.count)}`}
|
||||
</title>
|
||||
</circle>
|
||||
))}
|
||||
</g>
|
||||
))}
|
||||
|
||||
@@ -761,6 +799,90 @@ function TimelineTable({
|
||||
);
|
||||
}
|
||||
|
||||
/** "Januar 2026", "Jan bis März 2026", "5. Jan. bis 11. Jan. 2026". */
|
||||
export function bucketText(
|
||||
start: string,
|
||||
bucket: TimelineBucket,
|
||||
intl: string,
|
||||
t: ReturnType<typeof useTranslations>["t"],
|
||||
): string {
|
||||
const label = formatBucket(start, bucket, intl);
|
||||
return typeof label === "string"
|
||||
? label
|
||||
: t("timeline.selection.range", label);
|
||||
}
|
||||
|
||||
/**
|
||||
* The value lines as a table, for a screen reader: one row per bucket from
|
||||
* the first to the last with a reading in the window, one column per
|
||||
* series. A bucket without a reading says so; nothing is filled in.
|
||||
*/
|
||||
function SeriesTable({
|
||||
timeline,
|
||||
window,
|
||||
seriesLabel,
|
||||
intl,
|
||||
}: {
|
||||
timeline: TimelineResponse;
|
||||
window: { from: string; to: string };
|
||||
seriesLabel: (key: string) => string;
|
||||
intl: string;
|
||||
}) {
|
||||
const { t, tCount } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const { bucket, series } = timeline;
|
||||
const inWindow = (start: string) =>
|
||||
start <= window.to && bucketAfter(start, bucket) > window.from;
|
||||
const starts = series
|
||||
.flatMap((s) => s.points.map((p) => p.t))
|
||||
.filter(inWindow)
|
||||
.sort();
|
||||
if (starts.length === 0) return null;
|
||||
const rows: string[] = [];
|
||||
for (
|
||||
let start = bucketStart(starts[0], bucket);
|
||||
start <= starts[starts.length - 1];
|
||||
start = bucketAfter(start, bucket)
|
||||
) {
|
||||
rows.push(start);
|
||||
}
|
||||
const byKey = new Map(
|
||||
series.map((s) => [s.key, new Map(s.points.map((p) => [p.t, p]))]),
|
||||
);
|
||||
return (
|
||||
<table className="sr-only" data-slot="timeline-series-table">
|
||||
<caption>{t("timeline.table.seriesCaption")}</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{t("timeline.table.period")}</th>
|
||||
{series.map((s) => (
|
||||
<th key={s.key} scope="col">
|
||||
{seriesLabel(s.key)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.reverse().map((start) => (
|
||||
<tr key={start} data-bucket={start}>
|
||||
<th scope="row">{bucketText(start, bucket, intl, t)}</th>
|
||||
{series.map((s) => {
|
||||
const point = byKey.get(s.key)?.get(start);
|
||||
return (
|
||||
<td key={s.key}>
|
||||
{point
|
||||
? `${formatSeriesValue(s.key, point.mean, s.unit, fmt)}, ${tCount("timeline.values.readings", point.count)}`
|
||||
: t("timeline.values.noValue")}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
/** Inline style for a lane colour dot in HTML (chips, chronicle rails). */
|
||||
export function laneDotStyle(lane: TimelineLaneKey): React.CSSProperties {
|
||||
return { background: LANE_COLOR[lane] };
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
/**
|
||||
* The timeline on a phone (v1.42, #613): an "Ongoing" block that folds away,
|
||||
* then the chronicle, newest first, with the month's means beside each
|
||||
* month, thin rails beside the rows an episode or a course spans, and empty
|
||||
* stretches named rather than skipped. Built from `chronicle-model.ts`.
|
||||
* then the chronicle, newest first, with the bucket means beside the month
|
||||
* headers (a quarter named once, at its newest month, with its span), thin
|
||||
* rails beside the rows an episode or a course spans, and empty stretches
|
||||
* named rather than skipped. Built from `chronicle-model.ts`.
|
||||
*
|
||||
* A dated row opens its day (the same `?day=` layer the chart hands off to);
|
||||
* a life event opens its edit sheet instead, because the event is the
|
||||
@@ -47,9 +48,8 @@ import {
|
||||
formatMonthYear,
|
||||
} from "./timeline-dates";
|
||||
import { LANE_COLOR } from "./timeline-geometry";
|
||||
import { laneDotStyle } from "./timeline-chart";
|
||||
import { monthMeans } from "./selection-bar";
|
||||
import { formatMonthMeans } from "./series-format";
|
||||
import { bucketText, laneDotStyle } from "./timeline-chart";
|
||||
import { chronicleMeans, formatMeans } from "./series-format";
|
||||
|
||||
const KIND_ICON: Partial<Record<TimelineItemKind, LucideIcon>> = {
|
||||
lifeEvent: Flag,
|
||||
@@ -141,6 +141,16 @@ export function TimelineChronicle({
|
||||
const [standingOpen, setStandingOpen] = useState(true);
|
||||
const chips = standingChips(timeline);
|
||||
const rows = buildChronicle(timeline, today, grouping);
|
||||
// The means ride on the month headers, each bucket on the newest month it
|
||||
// overlaps; the year view lists none.
|
||||
const means =
|
||||
grouping === "month"
|
||||
? chronicleMeans(
|
||||
timeline.series,
|
||||
timeline.bucket,
|
||||
rows.flatMap((r) => (r.type === "header" ? [r.group] : [])),
|
||||
)
|
||||
: new Map<string, never>();
|
||||
|
||||
const groupLabel = (group: string) =>
|
||||
grouping === "month" ? formatMonthYear(group, intl) : group.slice(0, 4);
|
||||
@@ -244,10 +254,10 @@ export function TimelineChronicle({
|
||||
);
|
||||
}
|
||||
if (row.type === "header") {
|
||||
const means =
|
||||
grouping === "month"
|
||||
? monthMeans(timeline.series, row.group)
|
||||
: [];
|
||||
const bucketMeans = means.get(row.group);
|
||||
const line = bucketMeans
|
||||
? formatMeans(bucketMeans.values, fmt)
|
||||
: "";
|
||||
return (
|
||||
<li
|
||||
key={`h-${row.group}`}
|
||||
@@ -259,9 +269,15 @@ export function TimelineChronicle({
|
||||
<h3 className="text-sm font-semibold">
|
||||
{groupLabel(row.group)}
|
||||
</h3>
|
||||
{means.length > 0 && (
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
{formatMonthMeans(means, fmt)}
|
||||
{bucketMeans && line && (
|
||||
<span
|
||||
className="text-muted-foreground text-xs tabular-nums"
|
||||
data-slot="timeline-chronicle-means"
|
||||
data-bucket={bucketMeans.start}
|
||||
>
|
||||
{timeline.bucket === "month"
|
||||
? line
|
||||
: `${bucketText(bucketMeans.start, timeline.bucket, intl, t)}: ${line}`}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
* Formatting reads the same UTC instant back with `timeZone: "UTC"`, so a
|
||||
* date never moves by a day for a reader east or west of Greenwich.
|
||||
*/
|
||||
import type { TimelineBucket } from "@/lib/day/contract";
|
||||
|
||||
const MS_PER_DAY = 86_400_000;
|
||||
|
||||
/** Days since 1970-01-01 for a `YYYY-MM-DD` key. */
|
||||
@@ -55,6 +57,38 @@ export function addMonths(key: string, months: number): string {
|
||||
return target.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/* ─── Series buckets ──────────────────────────────────────────────────────── */
|
||||
|
||||
/** The first day of the bucket `key` falls in. Weeks start on Monday. */
|
||||
export function bucketStart(key: string, bucket: TimelineBucket): string {
|
||||
if (bucket === "month") return startOfMonth(key);
|
||||
if (bucket === "quarter") {
|
||||
const month = Number(key.slice(5, 7));
|
||||
const first = month - ((month - 1) % 3);
|
||||
return `${key.slice(0, 4)}-${String(first).padStart(2, "0")}-01`;
|
||||
}
|
||||
const day = dayNumber(key);
|
||||
// 1970-01-01 was a Thursday: day 4 is the first Monday.
|
||||
return dayKey(day - ((((day - 4) % 7) + 7) % 7));
|
||||
}
|
||||
|
||||
/** The first day after the bucket that starts on `start`. */
|
||||
export function bucketAfter(start: string, bucket: TimelineBucket): string {
|
||||
if (bucket === "month") return addMonths(start, 1);
|
||||
if (bucket === "quarter") return addMonths(start, 3);
|
||||
return dayKey(dayNumber(start) + 7);
|
||||
}
|
||||
|
||||
/**
|
||||
* A running number for the bucket that starts on `start`: neighbouring
|
||||
* buckets differ by one, so a difference counts the buckets between.
|
||||
*/
|
||||
export function bucketIndex(start: string, bucket: TimelineBucket): number {
|
||||
if (bucket === "week") return Math.floor((dayNumber(start) - 4) / 7);
|
||||
const months = Number(start.slice(0, 4)) * 12 + Number(start.slice(5, 7)) - 1;
|
||||
return bucket === "quarter" ? Math.floor(months / 3) : months;
|
||||
}
|
||||
|
||||
/** Every `YYYY-MM-01` from the month of `from` through the month of `to`. */
|
||||
export function monthsBetween(from: string, to: string): string[] {
|
||||
const out: string[] = [];
|
||||
@@ -148,3 +182,27 @@ export function formatAtPrecision(
|
||||
if (precision === "MONTH") return formatMonthYearShort(key, intlLocale);
|
||||
return formatDayMonthYear(key, intlLocale);
|
||||
}
|
||||
|
||||
/**
|
||||
* How a series bucket reads: "Januar 2026" for a month, otherwise its first
|
||||
* and last day for the caller's "{from} to {to}" ("Jan to März 2026",
|
||||
* "5. Jan. to 11. Jan. 2026").
|
||||
*/
|
||||
export function formatBucket(
|
||||
start: string,
|
||||
bucket: TimelineBucket,
|
||||
intlLocale: string,
|
||||
): string | { from: string; to: string } {
|
||||
if (bucket === "month") return formatMonthYear(start, intlLocale);
|
||||
const last = dayKey(dayNumber(bucketAfter(start, bucket)) - 1);
|
||||
if (bucket === "quarter") {
|
||||
return {
|
||||
from: formatMonthShort(start, intlLocale),
|
||||
to: formatMonthYearShort(last, intlLocale),
|
||||
};
|
||||
}
|
||||
return {
|
||||
from: formatDayMonth(start, intlLocale),
|
||||
to: formatDayMonthYear(last, intlLocale),
|
||||
};
|
||||
}
|
||||
@@ -17,6 +17,7 @@
|
||||
* month in full.
|
||||
*/
|
||||
import type {
|
||||
TimelineBucket,
|
||||
TimelineItem,
|
||||
TimelineItemKind,
|
||||
TimelineLane,
|
||||
@@ -27,6 +28,8 @@ import type {
|
||||
|
||||
import {
|
||||
addMonths,
|
||||
bucketAfter,
|
||||
bucketIndex,
|
||||
dayKey,
|
||||
dayNumber,
|
||||
endOfMonth,
|
||||
@@ -688,42 +691,74 @@ function placeLabels(
|
||||
|
||||
/* ─── Value lines ─────────────────────────────────────────────────────────── */
|
||||
|
||||
export interface SeriesPointLayout {
|
||||
/** The bucket's first day. */
|
||||
t: string;
|
||||
mean: number;
|
||||
/** Readings behind the mean. */
|
||||
count: number;
|
||||
x: number;
|
||||
y: number;
|
||||
/** Fewer than {@link THIN_BUCKET_COUNT} readings: drawn hollow. */
|
||||
thin: boolean;
|
||||
}
|
||||
|
||||
export interface SeriesLayout {
|
||||
key: string;
|
||||
unit: string | null;
|
||||
top: number;
|
||||
height: number;
|
||||
/** SVG path data; empty when the window holds no point. */
|
||||
/** SVG path data of the solid runs; empty when no two points touch. */
|
||||
path: string;
|
||||
/** SVG path data of the dashed bridges over short gaps; may be empty. */
|
||||
bridges: string;
|
||||
/** The most recent mean inside the window, or null. */
|
||||
latest: number | null;
|
||||
points: Array<{ t: string; mean: number; x: number; y: number }>;
|
||||
points: SeriesPointLayout[];
|
||||
}
|
||||
|
||||
const BUCKET_DAYS: Record<TimelineSeries["granularity"], number> = {
|
||||
month: 30,
|
||||
week: 7,
|
||||
day: 1,
|
||||
/** A bucket mean from fewer readings than this is drawn hollow. */
|
||||
export const THIN_BUCKET_COUNT = 3;
|
||||
|
||||
/**
|
||||
* The most missing buckets a dashed bridge spans. Beyond it the line stops
|
||||
* and starts again, so a long silence reads as one and not as a trend.
|
||||
*
|
||||
* The rule is one length, about six months between the two points a bridge
|
||||
* joins, never more, and never more than a third of what the zoom shows:
|
||||
* one missing quarter (the points are six months apart), two missing months
|
||||
* in a year (three months apart, a sixth of the window) and four missing
|
||||
* weeks in three months (five weeks apart, the window's third). A longer
|
||||
* bridge would draw a slope through a stretch where nothing was measured.
|
||||
*/
|
||||
export const MAX_BRIDGED_GAP: Readonly<Record<TimelineBucket, number>> = {
|
||||
quarter: 1,
|
||||
month: 2,
|
||||
week: 4,
|
||||
};
|
||||
|
||||
/**
|
||||
* One neutral line per series, scaled to its own range inside the window.
|
||||
* A point sits in the middle of its bucket. Missing buckets break the line
|
||||
* rather than bridging the gap: a straight stroke across eight empty months
|
||||
* would claim values nobody measured.
|
||||
* 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
|
||||
* ({@link MAX_BRIDGED_GAP}) that joins the two real points and invents none
|
||||
* between them, a longer one breaks the line.
|
||||
*/
|
||||
export function layoutSeries(
|
||||
series: readonly TimelineSeries[],
|
||||
top: number,
|
||||
window: TimeWindow,
|
||||
scale: Scale,
|
||||
bucket: TimelineBucket,
|
||||
): SeriesLayout[] {
|
||||
return series.map((s, index) => {
|
||||
const rowTop = top + index * SERIES_HEIGHT + 6;
|
||||
const height = SERIES_HEIGHT - 16;
|
||||
const bucket = BUCKET_DAYS[s.granularity];
|
||||
// A bucket that starts before the window still belongs to it while any
|
||||
// of its days lie inside.
|
||||
const inside = s.points
|
||||
.filter((p) => p.t >= startOfMonthIf(window.from, s) && p.t <= window.to)
|
||||
.filter((p) => p.t <= window.to && bucketAfter(p.t, bucket) > window.from)
|
||||
.sort((a, b) => (a.t < b.t ? -1 : 1));
|
||||
if (inside.length === 0) {
|
||||
return {
|
||||
@@ -732,6 +767,7 @@ export function layoutSeries(
|
||||
top: rowTop,
|
||||
height,
|
||||
path: "",
|
||||
bridges: "",
|
||||
latest: null,
|
||||
points: [],
|
||||
};
|
||||
@@ -742,18 +778,34 @@ export function layoutSeries(
|
||||
lo -= pad;
|
||||
hi += pad;
|
||||
const y = (v: number) => rowTop + height * (1 - (v - lo) / (hi - lo));
|
||||
const placed = inside.map((p) => {
|
||||
const mid = dayNumber(p.t) + bucket / 2;
|
||||
const points = inside.map((p) => {
|
||||
const mid = (dayNumber(p.t) + dayNumber(bucketAfter(p.t, bucket))) / 2;
|
||||
const x = Math.min(scale.x1, Math.max(scale.x0, scale.x(mid)));
|
||||
return { t: p.t, mean: p.mean, x, y: y(p.mean) };
|
||||
return {
|
||||
t: p.t,
|
||||
mean: p.mean,
|
||||
count: p.count,
|
||||
x,
|
||||
y: y(p.mean),
|
||||
thin: p.count < THIN_BUCKET_COUNT,
|
||||
};
|
||||
});
|
||||
const xy = (p: SeriesPointLayout) => `${p.x.toFixed(1)} ${p.y.toFixed(1)}`;
|
||||
let path = "";
|
||||
let prevDay: number | null = null;
|
||||
for (const p of placed) {
|
||||
const day = dayNumber(p.t);
|
||||
const broken = prevDay === null || day - prevDay > bucket * 1.6;
|
||||
path += `${broken ? "M" : "L"}${p.x.toFixed(1)} ${p.y.toFixed(1)}`;
|
||||
prevDay = day;
|
||||
let bridges = "";
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
const prev = points[i - 1];
|
||||
const cur = points[i];
|
||||
const missing =
|
||||
bucketIndex(cur.t, bucket) - bucketIndex(prev.t, bucket) - 1;
|
||||
if (missing === 0) {
|
||||
// Continue the run, or open a new one at the previous point.
|
||||
path += path.endsWith(xy(prev))
|
||||
? `L${xy(cur)}`
|
||||
: `M${xy(prev)}L${xy(cur)}`;
|
||||
} else if (missing <= MAX_BRIDGED_GAP[bucket]) {
|
||||
bridges += `M${xy(prev)}L${xy(cur)}`;
|
||||
}
|
||||
}
|
||||
return {
|
||||
key: s.key,
|
||||
@@ -761,18 +813,13 @@ export function layoutSeries(
|
||||
top: rowTop,
|
||||
height,
|
||||
path,
|
||||
bridges,
|
||||
latest: inside[inside.length - 1].mean,
|
||||
points: placed,
|
||||
points,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function startOfMonthIf(from: string, s: TimelineSeries): string {
|
||||
// A month bucket keyed on the 1st still belongs to a window that starts
|
||||
// mid-month.
|
||||
return s.granularity === "month" ? startOfMonth(from) : from;
|
||||
}
|
||||
|
||||
/* ─── Whole chart ─────────────────────────────────────────────────────────── */
|
||||
|
||||
export interface TimelineLayout {
|
||||
@@ -794,6 +841,7 @@ export function layoutTimeline(input: {
|
||||
window: TimeWindow;
|
||||
lanes: readonly TimelineLane[];
|
||||
series: readonly TimelineSeries[];
|
||||
bucket: TimelineBucket;
|
||||
startMissing: string;
|
||||
today: string;
|
||||
}): TimelineLayout {
|
||||
@@ -814,7 +862,13 @@ export function layoutTimeline(input: {
|
||||
top += layout.height;
|
||||
}
|
||||
const seriesTop = top + (input.series.length > 0 ? SERIES_GAP : 0);
|
||||
const series = layoutSeries(input.series, seriesTop, input.window, scale);
|
||||
const series = layoutSeries(
|
||||
input.series,
|
||||
seriesTop,
|
||||
input.window,
|
||||
scale,
|
||||
input.bucket,
|
||||
);
|
||||
const height = seriesTop + series.length * SERIES_HEIGHT + 4;
|
||||
return { width: input.width, height, scale, lanes, seriesTop, series };
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
/**
|
||||
* The two menus above the chart (v1.42, #613): which value lines run under
|
||||
* the lanes (at most three), and which lanes show, with the way back into
|
||||
* the lanes (at most six, the server's cap), and which lanes show, with the way back into
|
||||
* the readiness inventory ("Data coverage").
|
||||
*/
|
||||
import { ChartLine, ChevronDown, Layers } from "lucide-react";
|
||||
@@ -17,11 +17,11 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import type { TimelineLaneKey } from "@/lib/day/contract";
|
||||
import { TIMELINE_MAX_SERIES, type TimelineLaneKey } from "@/lib/day/contract";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/** The most value lines the chart runs under its lanes. */
|
||||
export const MAX_VALUE_SERIES = 3;
|
||||
export const MAX_VALUE_SERIES = TIMELINE_MAX_SERIES;
|
||||
|
||||
export function ValueSeriesMenu({
|
||||
options,
|
||||
|
||||
@@ -3,9 +3,8 @@
|
||||
/**
|
||||
* `/timeline` (v1.42, #613): years at a glance. Conditions, allergies,
|
||||
* medications, vaccinations, visits, documents and the person's own life
|
||||
* events in lanes, up to three neutral value lines below them, all on one
|
||||
* time axis. "Stacked means at the same time, not cause and effect" is said
|
||||
* once, in the legend, and nothing on the page draws a connection.
|
||||
* events in lanes, up to six neutral value lines below them, all on one
|
||||
* time axis. Nothing on the page draws a connection between them.
|
||||
*
|
||||
* The page hands off to the day view through `?day=`: "Open 3 Jan." (and
|
||||
* Enter on the chart, a double click, a chip, a chronicle row) pushes the
|
||||
@@ -35,6 +34,7 @@ import {
|
||||
DAY_QUERY_PARAM,
|
||||
TIMELINE_ZOOMS,
|
||||
type LifeEventDTO,
|
||||
type TimelineBucket,
|
||||
type TimelineLaneKey,
|
||||
type TimelineZoom,
|
||||
} from "@/lib/day/contract";
|
||||
@@ -385,10 +385,14 @@ export function TimelineView() {
|
||||
timeline={data}
|
||||
selected={effectiveSelected}
|
||||
today={today}
|
||||
seriesLabel={seriesLabel}
|
||||
onOpenDay={openDay}
|
||||
onEditLifeEvent={canAddLifeEvent ? editLifeEvent : undefined}
|
||||
/>
|
||||
<Legend />
|
||||
<Legend
|
||||
bucket={data.bucket}
|
||||
hasSeries={data.series.length > 0}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -436,7 +440,19 @@ export function TimelineView() {
|
||||
);
|
||||
}
|
||||
|
||||
function Legend() {
|
||||
const LEGEND_MEAN_KEY: Readonly<Record<TimelineBucket, string>> = {
|
||||
quarter: "timeline.legendMeanQuarter",
|
||||
month: "timeline.legendMeanMonth",
|
||||
week: "timeline.legendMeanWeek",
|
||||
};
|
||||
|
||||
function Legend({
|
||||
bucket,
|
||||
hasSeries,
|
||||
}: {
|
||||
bucket: TimelineBucket;
|
||||
hasSeries: boolean;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<div
|
||||
@@ -457,13 +473,25 @@ function Legend() {
|
||||
/>
|
||||
{t("timeline.legendEvent")}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="bg-foreground h-0.5 w-4" aria-hidden="true" />
|
||||
{t("timeline.legendMean")}
|
||||
</span>
|
||||
<span className="sm:ml-auto" data-slot="timeline-legend-causality">
|
||||
{t("timeline.legend")}
|
||||
</span>
|
||||
{hasSeries && (
|
||||
<>
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5"
|
||||
data-slot="timeline-legend-mean"
|
||||
data-bucket={bucket}
|
||||
>
|
||||
<span className="bg-foreground h-0.5 w-4" aria-hidden="true" />
|
||||
{t(LEGEND_MEAN_KEY[bucket])}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
className="border-foreground bg-card size-2 rounded-full border"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{t("timeline.legendThin")}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -216,6 +216,22 @@ export const TIMELINE_ZOOMS = ["all", "year", "quarter"] as const;
|
||||
|
||||
export type TimelineZoom = (typeof TIMELINE_ZOOMS)[number];
|
||||
|
||||
/**
|
||||
* The span one value-series point averages. The server picks it per zoom
|
||||
* (`timelineBucket` in `src/lib/timeline/load-timeline.ts`): quarters for a
|
||||
* multi-year `all`, months for `year` and a short `all`, weeks for `quarter`.
|
||||
*/
|
||||
export const TIMELINE_BUCKETS = ["quarter", "month", "week"] as const;
|
||||
|
||||
export type TimelineBucket = (typeof TIMELINE_BUCKETS)[number];
|
||||
|
||||
/**
|
||||
* The most value series one request may name. Each series is its own row
|
||||
* under the lanes, drawn in neutral ink and labelled at the left, so the cap
|
||||
* is the chart's height, not a palette: six rows add 360 px under the lanes.
|
||||
*/
|
||||
export const TIMELINE_MAX_SERIES = 6;
|
||||
|
||||
/**
|
||||
* The lanes, top to bottom. An empty lane is not sent. Each lane a module
|
||||
* owns is a surface (`timeline-lane:<key>`) and disappears with it.
|
||||
|
||||
@@ -30,6 +30,15 @@ import { collapseRollupRowsBySource } from "@/lib/rollups/measurement-read";
|
||||
/** Day key → the day's value, per type. */
|
||||
export type DailyStats = Map<MeasurementType, Map<string, number>>;
|
||||
|
||||
/** A day's value with the readings behind it (the picked source's). */
|
||||
export interface DayCell {
|
||||
value: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
/** Day key → the day's value and reading count, per type. */
|
||||
export type DailyCells = Map<MeasurementType, Map<string, DayCell>>;
|
||||
|
||||
interface StatRow {
|
||||
type: MeasurementType;
|
||||
source: MeasurementSource;
|
||||
@@ -60,7 +69,22 @@ export async function readLocalDailyStats(args: {
|
||||
tz: string;
|
||||
priorityJson: unknown;
|
||||
}): Promise<DailyStats> {
|
||||
const out: DailyStats = new Map();
|
||||
return valuesOnly(await readLocalDailyCells(args));
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link readLocalDailyStats} with each day's reading count beside its
|
||||
* value: the timeline marks a mean that rests on few readings.
|
||||
*/
|
||||
export async function readLocalDailyCells(args: {
|
||||
userId: string;
|
||||
types: readonly MeasurementType[];
|
||||
from: Date;
|
||||
to: Date;
|
||||
tz: string;
|
||||
priorityJson: unknown;
|
||||
}): Promise<DailyCells> {
|
||||
const out: DailyCells = new Map();
|
||||
if (args.types.length === 0 || args.from >= args.to) return out;
|
||||
const hourlyTypes = args.types.filter((t) => HOURLY_MEAN_DAY_TYPES.has(t));
|
||||
// Every value is parameter-bound; the enum casts are literal SQL. The hour
|
||||
@@ -87,7 +111,17 @@ export async function readLocalDailyStats(args: {
|
||||
AND "measured_at" < ${args.to}
|
||||
GROUP BY 1, 2, 3, 4
|
||||
`;
|
||||
return foldDailyStats(rows, args.priorityJson);
|
||||
return foldDailyCells(rows, args.priorityJson);
|
||||
}
|
||||
|
||||
function valuesOnly(cells: DailyCells): DailyStats {
|
||||
const out: DailyStats = new Map();
|
||||
for (const [type, days] of cells) {
|
||||
const perDay = new Map<string, number>();
|
||||
for (const [day, cell] of days) perDay.set(day, cell.value);
|
||||
out.set(type, perDay);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -99,6 +133,14 @@ export function foldDailyStats(
|
||||
rows: readonly StatRow[],
|
||||
priorityJson: unknown,
|
||||
): DailyStats {
|
||||
return valuesOnly(foldDailyCells(rows, priorityJson));
|
||||
}
|
||||
|
||||
/** {@link foldDailyStats} with the picked source's reading count per day. */
|
||||
export function foldDailyCells(
|
||||
rows: readonly StatRow[],
|
||||
priorityJson: unknown,
|
||||
): DailyCells {
|
||||
// type → day → source → hour cells
|
||||
const nested = new Map<
|
||||
MeasurementType,
|
||||
@@ -114,7 +156,7 @@ export function foldDailyStats(
|
||||
cells.push({ total: Number(row.total), count: Number(row.count) });
|
||||
}
|
||||
|
||||
const out: DailyStats = new Map();
|
||||
const out: DailyCells = new Map();
|
||||
for (const [type, days] of nested) {
|
||||
const cumulative = isCumulativeDaySumType(type);
|
||||
const hourly = HOURLY_MEAN_DAY_TYPES.has(type);
|
||||
@@ -141,9 +183,12 @@ export function foldDailyStats(
|
||||
}
|
||||
}
|
||||
const picked = collapseRollupRowsBySource(cells, type, priorityJson);
|
||||
const perDay = new Map<string, number>();
|
||||
const perDay = new Map<string, DayCell>();
|
||||
for (const cell of picked) {
|
||||
perDay.set(dateOnlyKey(cell.bucketStart), cell.value);
|
||||
perDay.set(dateOnlyKey(cell.bucketStart), {
|
||||
value: cell.value,
|
||||
count: cell.count,
|
||||
});
|
||||
}
|
||||
out.set(type, perDay);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
LIFE_EVENT_NOTE_MAX,
|
||||
LIFE_EVENT_PRECISIONS,
|
||||
LIFE_EVENT_TITLE_MAX,
|
||||
TIMELINE_BUCKETS,
|
||||
TIMELINE_ITEM_KINDS,
|
||||
TIMELINE_LANE_KEYS,
|
||||
TIMELINE_READINESS_KEYS,
|
||||
@@ -273,7 +274,7 @@ export const timelineQuerySchema = z.object({
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"Comma-separated value series to include, as `MeasurementType` names or `MOOD`.",
|
||||
"Comma-separated value series to include, as `MeasurementType` names or `MOOD`, at most 6. More, or an unknown name, answers 422.",
|
||||
),
|
||||
});
|
||||
|
||||
@@ -310,8 +311,27 @@ const timelineSeries = z
|
||||
.object({
|
||||
key: z.string().describe("A `MeasurementType` or `MOOD`."),
|
||||
unit: z.string().nullable(),
|
||||
granularity: z.enum(["month", "week", "day"]),
|
||||
points: z.array(z.object({ t: dateKeySchema, mean: z.number() })),
|
||||
points: z
|
||||
.array(
|
||||
z.object({
|
||||
t: dateKeySchema.describe("The bucket's first day."),
|
||||
mean: z
|
||||
.number()
|
||||
.describe(
|
||||
"The mean of the bucket's day values, each day weighing one (a cumulative type's days are its day totals, a pulse-like type's its hourly-mean day value).",
|
||||
),
|
||||
count: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.describe(
|
||||
"Readings behind the mean. Clients draw a point from fewer than three as thin.",
|
||||
),
|
||||
}),
|
||||
)
|
||||
.describe(
|
||||
"Buckets with at least one reading, oldest first. A bucket without one is absent, never interpolated.",
|
||||
),
|
||||
})
|
||||
.meta({ id: "TimelineSeries" });
|
||||
|
||||
@@ -334,6 +354,11 @@ export const timelineResponseSchema = z
|
||||
standing: z
|
||||
.array(timelineStanding)
|
||||
.describe("Things without a start: shown once as standing."),
|
||||
bucket: z
|
||||
.enum(TIMELINE_BUCKETS)
|
||||
.describe(
|
||||
"The span every series point averages: `quarter` for a multi-year `all`, `month` for `year` and an `all` under two years, `week` for `quarter`.",
|
||||
),
|
||||
series: z.array(timelineSeries),
|
||||
notable: z.array(
|
||||
z.object({ date: dateKeySchema, kind: z.enum(DAY_NOTABLE_KINDS) }),
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { overlaps, parseSeriesKeys } from "../load-timeline";
|
||||
import { overlaps, parseSeriesKeys, timelineBucket } from "../load-timeline";
|
||||
import { coveredWeeks, READINESS_WEEKS } from "../readiness";
|
||||
import { bucketKey, foldDays } from "../series";
|
||||
import { bucketKey, foldDays, foldMonths } from "../series";
|
||||
|
||||
describe("buckets", () => {
|
||||
it("names a bucket by its first local day, weeks from Monday", () => {
|
||||
@@ -13,23 +13,65 @@ describe("buckets", () => {
|
||||
// 2026-03-29 is a Sunday; its week began on Monday the 23rd.
|
||||
expect(bucketKey("2026-03-29", "week")).toBe("2026-03-23");
|
||||
expect(bucketKey("2026-03-23", "week")).toBe("2026-03-23");
|
||||
expect(bucketKey("2026-03-29", "day")).toBe("2026-03-29");
|
||||
expect(bucketKey("2026-03-29", "quarter")).toBe("2026-01-01");
|
||||
expect(bucketKey("2026-04-01", "quarter")).toBe("2026-04-01");
|
||||
expect(bucketKey("2026-12-31", "quarter")).toBe("2026-10-01");
|
||||
});
|
||||
|
||||
it("weighs each day once in a bucket", () => {
|
||||
it("weighs each day once in a bucket and counts the readings behind it", () => {
|
||||
const points = foldDays(
|
||||
new Map([
|
||||
["2026-03-01", 100],
|
||||
["2026-03-02", 110],
|
||||
["2026-04-01", 90],
|
||||
["2026-03-01", { value: 100, count: 3 }],
|
||||
["2026-03-02", { value: 110, count: 1 }],
|
||||
["2026-04-01", { value: 90, count: 1 }],
|
||||
]),
|
||||
"month",
|
||||
);
|
||||
expect(points).toEqual([
|
||||
{ t: "2026-03-01", mean: 105 },
|
||||
{ t: "2026-04-01", mean: 90 },
|
||||
{ t: "2026-03-01", mean: 105, count: 4 },
|
||||
{ t: "2026-04-01", mean: 90, count: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves a bucket without a reading out rather than filling it", () => {
|
||||
const points = foldDays(
|
||||
new Map([
|
||||
["2026-02-10", { value: 120, count: 2 }],
|
||||
["2026-06-10", { value: 130, count: 2 }],
|
||||
["2026-08-10", { value: 125, count: 1 }],
|
||||
]),
|
||||
"month",
|
||||
);
|
||||
expect(points.map((p) => p.t)).toEqual([
|
||||
"2026-02-01",
|
||||
"2026-06-01",
|
||||
"2026-08-01",
|
||||
]);
|
||||
});
|
||||
|
||||
it("folds rolled-up months into quarters, each month weighing its days", () => {
|
||||
expect(
|
||||
foldMonths(
|
||||
[
|
||||
{ t: "2026-01-01", mean: 100, days: 30, count: 30 },
|
||||
{ t: "2026-02-01", mean: 130, days: 10, count: 12 },
|
||||
{ t: "2026-04-01", mean: 90, days: 2, count: 2 },
|
||||
],
|
||||
"quarter",
|
||||
),
|
||||
).toEqual([
|
||||
// (100·30 + 130·10) / 40, not the plain mean of the two months (115).
|
||||
{ t: "2026-01-01", mean: 107.5, count: 42 },
|
||||
{ t: "2026-04-01", mean: 90, count: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("picks the bucket by zoom, quarters only for a long `all`", () => {
|
||||
expect(timelineBucket("quarter", "2026-01-01", "2026-03-31")).toBe("week");
|
||||
expect(timelineBucket("year", "2025-04-01", "2026-03-31")).toBe("month");
|
||||
expect(timelineBucket("all", "2025-01-01", "2026-03-31")).toBe("month");
|
||||
expect(timelineBucket("all", "2019-01-01", "2026-03-31")).toBe("quarter");
|
||||
});
|
||||
});
|
||||
|
||||
describe("window", () => {
|
||||
@@ -55,6 +97,19 @@ describe("window", () => {
|
||||
expect(parseSeriesKeys("WEIGHT,MOOD,WEIGHT")).toEqual(["WEIGHT", "MOOD"]);
|
||||
expect(parseSeriesKeys("WEIGHT,DROP TABLE")).toBeNull();
|
||||
});
|
||||
|
||||
it("accepts up to six series and refuses a seventh", () => {
|
||||
const six = [
|
||||
"BLOOD_PRESSURE_SYS",
|
||||
"BLOOD_PRESSURE_DIA",
|
||||
"WEIGHT",
|
||||
"PULSE",
|
||||
"MOOD",
|
||||
"BODY_FAT",
|
||||
];
|
||||
expect(parseSeriesKeys(six.join(","))).toEqual(six);
|
||||
expect(parseSeriesKeys([...six, "BLOOD_GLUCOSE"].join(","))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("readiness weeks", () => {
|
||||
|
||||
@@ -18,6 +18,8 @@ import type { MeasurementType } from "@/generated/prisma/enums";
|
||||
import {
|
||||
DAY_NOTABLE_MAX_SPAN_DAYS,
|
||||
TIMELINE_LANE_KEYS,
|
||||
TIMELINE_MAX_SERIES,
|
||||
type TimelineBucket,
|
||||
type TimelineItem,
|
||||
type TimelineQuery,
|
||||
type TimelineResponse,
|
||||
@@ -33,12 +35,8 @@ import {
|
||||
type StandingItem,
|
||||
type TimelineAccess,
|
||||
} from "@/lib/timeline/lanes";
|
||||
import {
|
||||
loadTimelineSeries,
|
||||
MOOD_SERIES_KEY,
|
||||
type SeriesGranularity,
|
||||
} from "@/lib/timeline/series";
|
||||
import { shiftDateKey, userDayKey } from "@/lib/tz/format";
|
||||
import { loadTimelineSeries, MOOD_SERIES_KEY } from "@/lib/timeline/series";
|
||||
import { daysBetweenDateKeys, shiftDateKey, userDayKey } from "@/lib/tz/format";
|
||||
|
||||
/** The series sent when the request names none. */
|
||||
export const DEFAULT_TIMELINE_SERIES: readonly MeasurementType[] = [
|
||||
@@ -47,11 +45,27 @@ export const DEFAULT_TIMELINE_SERIES: readonly MeasurementType[] = [
|
||||
"WEIGHT",
|
||||
];
|
||||
|
||||
const ZOOM_GRANULARITY: Readonly<Record<TimelineZoom, SeriesGranularity>> = {
|
||||
all: "month",
|
||||
year: "week",
|
||||
quarter: "day",
|
||||
};
|
||||
/** An `all` window longer than this many days averages quarters. */
|
||||
const QUARTER_BUCKET_MIN_DAYS = 2 * 365;
|
||||
|
||||
/**
|
||||
* The span one series point averages. A point per day or per week over
|
||||
* years drew a line of fragments wherever readings came in bursts, so the
|
||||
* bucket grows with the window: weeks in three months (thirteen points),
|
||||
* months in a year (twelve), quarters once `all` reaches back more than two
|
||||
* years. An `all` window shorter than that keeps months, so a young record
|
||||
* does not shrink to a handful of quarters.
|
||||
*/
|
||||
export function timelineBucket(
|
||||
zoom: TimelineZoom,
|
||||
from: string,
|
||||
to: string,
|
||||
): TimelineBucket {
|
||||
if (zoom === "quarter") return "week";
|
||||
if (zoom === "year") return "month";
|
||||
const days = daysBetweenDateKeys(from, to);
|
||||
return days > QUARTER_BUCKET_MIN_DAYS ? "quarter" : "month";
|
||||
}
|
||||
|
||||
const ZOOM_DAYS: Readonly<Record<Exclude<TimelineZoom, "all">, number>> = {
|
||||
year: 365,
|
||||
@@ -64,7 +78,8 @@ const MEASUREMENT_TYPE_SET: ReadonlySet<string> = new Set(
|
||||
|
||||
/**
|
||||
* The series keys a `values` parameter names, or null when one is not a
|
||||
* series this API knows (the route answers 422).
|
||||
* series this API knows or there are more than {@link TIMELINE_MAX_SERIES}
|
||||
* (the route answers 422).
|
||||
*/
|
||||
export function parseSeriesKeys(raw: string | undefined): string[] | null {
|
||||
if (raw === undefined) return [...DEFAULT_TIMELINE_SERIES];
|
||||
@@ -76,7 +91,7 @@ export function parseSeriesKeys(raw: string | undefined): string[] | null {
|
||||
.filter((k) => k.length > 0),
|
||||
),
|
||||
];
|
||||
if (keys.length > 8) return null;
|
||||
if (keys.length > TIMELINE_MAX_SERIES) return null;
|
||||
for (const key of keys) {
|
||||
if (key !== MOOD_SERIES_KEY && !MEASUREMENT_TYPE_SET.has(key)) return null;
|
||||
}
|
||||
@@ -158,6 +173,7 @@ export async function loadTimeline(args: {
|
||||
shiftDateKey(to, -(DAY_NOTABLE_MAX_SPAN_DAYS - 1)) > from
|
||||
? shiftDateKey(to, -(DAY_NOTABLE_MAX_SPAN_DAYS - 1))
|
||||
: from;
|
||||
const bucket = timelineBucket(query.zoom, from, to);
|
||||
const [series, notable] = await Promise.all([
|
||||
loadTimelineSeries({
|
||||
userId: recordId,
|
||||
@@ -165,7 +181,7 @@ export async function loadTimeline(args: {
|
||||
from,
|
||||
to,
|
||||
tz,
|
||||
granularity: ZOOM_GRANULARITY[query.zoom],
|
||||
bucket,
|
||||
priorityJson,
|
||||
now,
|
||||
}),
|
||||
@@ -187,6 +203,7 @@ export async function loadTimeline(args: {
|
||||
range: { from, to, dataFrom },
|
||||
lanes,
|
||||
standing,
|
||||
bucket,
|
||||
series,
|
||||
notable: notable.map(({ date, kind }) => ({ date, kind })),
|
||||
};
|
||||
|
||||
+134
-63
@@ -1,76 +1,137 @@
|
||||
/**
|
||||
* Value series for the timeline: monthly, weekly or daily means beside the
|
||||
* lanes (v1.42, #613).
|
||||
* Value series for the timeline: quarterly, monthly or weekly means beside
|
||||
* the lanes (v1.42, #613).
|
||||
*
|
||||
* Three ways in, by zoom:
|
||||
* The bucket comes with the zoom (`timelineBucket` in `load-timeline.ts`),
|
||||
* and the source with the window's length:
|
||||
*
|
||||
* - **all (month)**: the months of the last five years come from the rollup
|
||||
* tier, MONTH buckets folded from the canonical-source DAY rows. Those
|
||||
* buckets cut at UTC midnight, so a reading in the first or last hours of
|
||||
* a month can sit in the neighbouring month. For a monthly mean over
|
||||
* - **longer than a year** (`all`): the last five years come from the
|
||||
* rollup tier, MONTH buckets folded from the canonical-source DAY rows
|
||||
* and, for quarters, folded once more here, each month weighing its days.
|
||||
* Those buckets cut at UTC midnight, so a reading in the first or last
|
||||
* hours of a month can sit in the neighbouring month. For a mean over
|
||||
* dozens of days that is a rounding matter, and it is accepted here; the
|
||||
* day view, where it is not, never reads the rollups. The rollups fold
|
||||
* only five years (`ROLLUP_FOLD_WINDOW_MS`); the months before that come
|
||||
* only five years (`ROLLUP_FOLD_WINDOW_MS`); the buckets before that come
|
||||
* from one live query over the record's own local days, cached per record
|
||||
* in the analytics bucket, which every measurement write marks stale.
|
||||
* - **year (week)** and **quarter (day)**: live over the record's local
|
||||
* days, a year at most.
|
||||
* - **a year or less** (`year`, `quarter`, a short `all`): live over the
|
||||
* record's local days.
|
||||
*
|
||||
* Every value is the mean of the bucket's days, each day weighing one (a
|
||||
* cumulative type's days are its day totals), matching the charts. A series
|
||||
* cumulative type's days are its day totals, a pulse-like type's day is the
|
||||
* mean of its hourly means, `daily-stats.ts`), matching the charts. Each
|
||||
* point carries the readings behind it, so the chart can mark a mean that
|
||||
* rests on one or two. A bucket without a reading is not sent, and a series
|
||||
* without a point is not sent. `MOOD` is the mood score, read from the
|
||||
* entries' own dates.
|
||||
* entries' own dates; its count is the entries.
|
||||
*/
|
||||
import type { MeasurementType } from "@/generated/prisma/enums";
|
||||
|
||||
import { caches, cached } from "@/lib/cache/server-cache";
|
||||
import { readLocalDailyStats } from "@/lib/day/daily-stats";
|
||||
import type { TimelineSeries } from "@/lib/day/contract";
|
||||
import { readLocalDailyCells, type DayCell } from "@/lib/day/daily-stats";
|
||||
import type { TimelineBucket, TimelineSeries } from "@/lib/day/contract";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { isCumulativeDaySumType } from "@/lib/measurements/cumulative-day-sum";
|
||||
import { readCanonicalRollupBuckets } from "@/lib/rollups/measurement-read";
|
||||
import { ROLLUP_FOLD_WINDOW_MS } from "@/lib/rollups/measurement-rollups";
|
||||
import { dateOnlyKey } from "@/lib/tz/date-only";
|
||||
import { shiftDateKey, userDayKey, weekdayOfDateKey } from "@/lib/tz/format";
|
||||
import {
|
||||
daysBetweenDateKeys,
|
||||
shiftDateKey,
|
||||
userDayKey,
|
||||
weekdayOfDateKey,
|
||||
} from "@/lib/tz/format";
|
||||
import { startOfLocalDayKey } from "@/lib/tz/local-day";
|
||||
|
||||
export type SeriesGranularity = TimelineSeries["granularity"];
|
||||
export type SeriesPoint = TimelineSeries["points"][number];
|
||||
|
||||
/**
|
||||
* How long the months before the fold window stay cached. They change only
|
||||
* How long the buckets before the fold window stay cached. They change only
|
||||
* when an old reading is imported or deleted, and either write marks the
|
||||
* entry stale at once.
|
||||
*/
|
||||
const PRE_FOLD_TTL_MS = 6 * 3_600_000;
|
||||
|
||||
/** Windows longer than this read the rollup tier rather than live days. */
|
||||
const LIVE_WINDOW_MAX_DAYS = 366;
|
||||
|
||||
/** The series key for the mood score. */
|
||||
export const MOOD_SERIES_KEY = "MOOD";
|
||||
|
||||
/** The bucket a local day belongs to, named by its first day. */
|
||||
export function bucketKey(day: string, granularity: SeriesGranularity): string {
|
||||
if (granularity === "day") return day;
|
||||
if (granularity === "month") return `${day.slice(0, 7)}-01`;
|
||||
export function bucketKey(day: string, bucket: TimelineBucket): string {
|
||||
if (bucket === "month") return `${day.slice(0, 7)}-01`;
|
||||
if (bucket === "quarter") {
|
||||
const month = Number(day.slice(5, 7));
|
||||
const first = month - ((month - 1) % 3);
|
||||
return `${day.slice(0, 4)}-${String(first).padStart(2, "0")}-01`;
|
||||
}
|
||||
// Weeks start on Monday.
|
||||
const offset = (weekdayOfDateKey(day) + 6) % 7;
|
||||
return shiftDateKey(day, -offset);
|
||||
}
|
||||
|
||||
/** Fold a day series into buckets: the mean of the bucket's days. */
|
||||
const round2 = (v: number) => Math.round(v * 100) / 100;
|
||||
|
||||
/**
|
||||
* Fold a day series into buckets: the mean of the bucket's days, and the
|
||||
* readings behind them.
|
||||
*/
|
||||
export function foldDays(
|
||||
days: ReadonlyMap<string, number>,
|
||||
granularity: SeriesGranularity,
|
||||
): Array<{ t: string; mean: number }> {
|
||||
const buckets = new Map<string, { sum: number; n: number }>();
|
||||
for (const [day, value] of days) {
|
||||
const key = bucketKey(day, granularity);
|
||||
const slot = buckets.get(key) ?? { sum: 0, n: 0 };
|
||||
slot.sum += value;
|
||||
days: ReadonlyMap<string, DayCell>,
|
||||
bucket: TimelineBucket,
|
||||
): SeriesPoint[] {
|
||||
const buckets = new Map<string, { sum: number; n: number; count: number }>();
|
||||
for (const [day, cell] of days) {
|
||||
const key = bucketKey(day, bucket);
|
||||
const slot = buckets.get(key) ?? { sum: 0, n: 0, count: 0 };
|
||||
slot.sum += cell.value;
|
||||
slot.n += 1;
|
||||
slot.count += cell.count;
|
||||
buckets.set(key, slot);
|
||||
}
|
||||
return [...buckets]
|
||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||
.map(([t, { sum, n }]) => ({ t, mean: Math.round((sum / n) * 100) / 100 }));
|
||||
.map(([t, { sum, n, count }]) => ({ t, mean: round2(sum / n), count }));
|
||||
}
|
||||
|
||||
/** A month from the rollup tier: its day mean, its days, its readings. */
|
||||
export interface RolledMonth {
|
||||
t: string;
|
||||
mean: number;
|
||||
days: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold rolled-up months into `bucket`: each month weighs its days, so a
|
||||
* quarter's mean is the mean of its days exactly as a live fold makes it.
|
||||
*/
|
||||
export function foldMonths(
|
||||
months: readonly RolledMonth[],
|
||||
bucket: Exclude<TimelineBucket, "week">,
|
||||
): SeriesPoint[] {
|
||||
const buckets = new Map<
|
||||
string,
|
||||
{ sum: number; days: number; count: number }
|
||||
>();
|
||||
for (const m of months) {
|
||||
const key = bucketKey(m.t, bucket);
|
||||
const slot = buckets.get(key) ?? { sum: 0, days: 0, count: 0 };
|
||||
slot.sum += m.mean * m.days;
|
||||
slot.days += m.days;
|
||||
slot.count += m.count;
|
||||
buckets.set(key, slot);
|
||||
}
|
||||
return [...buckets]
|
||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||
.filter(([, b]) => b.days > 0 && b.count > 0)
|
||||
.map(([t, { sum, days, count }]) => ({
|
||||
t,
|
||||
mean: round2(sum / days),
|
||||
count,
|
||||
}));
|
||||
}
|
||||
|
||||
async function latestUnit(
|
||||
@@ -85,24 +146,25 @@ async function latestUnit(
|
||||
return row?.unit ?? null;
|
||||
}
|
||||
|
||||
/** Months older than the rollup fold window, live and cached. */
|
||||
async function preFoldMonths(args: {
|
||||
/** Buckets older than the rollup fold window, live and cached. */
|
||||
async function preFoldBuckets(args: {
|
||||
userId: string;
|
||||
type: MeasurementType;
|
||||
tz: string;
|
||||
from: Date;
|
||||
boundary: Date;
|
||||
bucket: TimelineBucket;
|
||||
priorityJson: unknown;
|
||||
}): Promise<Array<{ t: string; mean: number }>> {
|
||||
}): Promise<SeriesPoint[]> {
|
||||
if (args.from >= args.boundary) return [];
|
||||
// Keyed under the record's `${userId}|` prefix: every measurement write
|
||||
// marks it stale with the rest of the analytics bucket.
|
||||
const key = `${args.userId}|timeline-pre-fold|${args.type}|${args.tz}|${dateOnlyKey(args.from)}|${dateOnlyKey(args.boundary)}`;
|
||||
const key = `${args.userId}|timeline-pre-fold|${args.type}|${args.bucket}|${args.tz}|${dateOnlyKey(args.from)}|${dateOnlyKey(args.boundary)}`;
|
||||
return cached(
|
||||
caches.analytics,
|
||||
key,
|
||||
async () => {
|
||||
const stats = await readLocalDailyStats({
|
||||
const cells = await readLocalDailyCells({
|
||||
userId: args.userId,
|
||||
types: [args.type],
|
||||
from: args.from,
|
||||
@@ -110,20 +172,20 @@ async function preFoldMonths(args: {
|
||||
tz: args.tz,
|
||||
priorityJson: args.priorityJson,
|
||||
});
|
||||
return foldDays(stats.get(args.type) ?? new Map(), "month");
|
||||
return foldDays(cells.get(args.type) ?? new Map(), args.bucket);
|
||||
},
|
||||
undefined,
|
||||
PRE_FOLD_TTL_MS,
|
||||
) as Promise<Array<{ t: string; mean: number }>>;
|
||||
) as Promise<SeriesPoint[]>;
|
||||
}
|
||||
|
||||
async function monthlyFromRollups(args: {
|
||||
async function monthsFromRollups(args: {
|
||||
userId: string;
|
||||
type: MeasurementType;
|
||||
from: Date;
|
||||
to: Date;
|
||||
priorityJson: unknown;
|
||||
}): Promise<Array<{ t: string; mean: number }>> {
|
||||
}): Promise<RolledMonth[]> {
|
||||
const rows = await readCanonicalRollupBuckets({
|
||||
userId: args.userId,
|
||||
type: args.type,
|
||||
@@ -142,59 +204,67 @@ async function monthlyFromRollups(args: {
|
||||
: r.mean;
|
||||
return {
|
||||
t: `${dateOnlyKey(r.bucketStart).slice(0, 7)}-01`,
|
||||
mean: Math.round(value * 100) / 100,
|
||||
mean: value,
|
||||
days: Math.max(1, r.days),
|
||||
count: r.count,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Whether `[from, to]` is long enough to read the rollup tier. */
|
||||
function readsRollups(from: string, to: string): boolean {
|
||||
const days = daysBetweenDateKeys(from, to);
|
||||
return days >= LIVE_WINDOW_MAX_DAYS;
|
||||
}
|
||||
|
||||
async function measurementSeries(args: {
|
||||
userId: string;
|
||||
type: MeasurementType;
|
||||
from: string;
|
||||
to: string;
|
||||
tz: string;
|
||||
granularity: SeriesGranularity;
|
||||
bucket: TimelineBucket;
|
||||
priorityJson: unknown;
|
||||
now: Date;
|
||||
}): Promise<TimelineSeries | null> {
|
||||
const start = startOfLocalDayKey(args.from, args.tz);
|
||||
const end = startOfLocalDayKey(shiftDateKey(args.to, 1), args.tz);
|
||||
let points: Array<{ t: string; mean: number }>;
|
||||
if (args.granularity === "month") {
|
||||
// The fold boundary rounded up to a month start, so no month is half
|
||||
let points: SeriesPoint[];
|
||||
if (args.bucket !== "week" && readsRollups(args.from, args.to)) {
|
||||
const bucket = args.bucket;
|
||||
// The fold boundary rounded up to a bucket start, so no bucket is half
|
||||
// live and half rolled up.
|
||||
const raw = new Date(args.now.getTime() - ROLLUP_FOLD_WINDOW_MS);
|
||||
const boundaryKey = bucketKey(
|
||||
shiftDateKey(userDayKey(raw, args.tz), 31),
|
||||
"month",
|
||||
shiftDateKey(userDayKey(raw, args.tz), bucket === "quarter" ? 92 : 31),
|
||||
bucket,
|
||||
);
|
||||
const boundary = startOfLocalDayKey(boundaryKey, args.tz);
|
||||
const [older, recent] = await Promise.all([
|
||||
preFoldMonths({
|
||||
preFoldBuckets({
|
||||
userId: args.userId,
|
||||
type: args.type,
|
||||
tz: args.tz,
|
||||
from: start,
|
||||
boundary: boundary < end ? boundary : end,
|
||||
bucket,
|
||||
priorityJson: args.priorityJson,
|
||||
}),
|
||||
end > boundary
|
||||
? monthlyFromRollups({
|
||||
? monthsFromRollups({
|
||||
userId: args.userId,
|
||||
type: args.type,
|
||||
from: boundary > start ? boundary : start,
|
||||
to: end,
|
||||
priorityJson: args.priorityJson,
|
||||
})
|
||||
}).then((months) => foldMonths(months, bucket))
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
const merged = new Map<string, number>();
|
||||
for (const p of [...older, ...recent]) merged.set(p.t, p.mean);
|
||||
points = [...merged]
|
||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||
.map(([t, mean]) => ({ t, mean }));
|
||||
const merged = new Map<string, SeriesPoint>();
|
||||
for (const p of [...older, ...recent]) merged.set(p.t, p);
|
||||
points = [...merged.values()].sort((a, b) => a.t.localeCompare(b.t));
|
||||
} else {
|
||||
const stats = await readLocalDailyStats({
|
||||
const cells = await readLocalDailyCells({
|
||||
userId: args.userId,
|
||||
types: [args.type],
|
||||
from: start,
|
||||
@@ -202,13 +272,12 @@ async function measurementSeries(args: {
|
||||
tz: args.tz,
|
||||
priorityJson: args.priorityJson,
|
||||
});
|
||||
points = foldDays(stats.get(args.type) ?? new Map(), args.granularity);
|
||||
points = foldDays(cells.get(args.type) ?? new Map(), args.bucket);
|
||||
}
|
||||
if (points.length === 0) return null;
|
||||
return {
|
||||
key: args.type,
|
||||
unit: await latestUnit(args.userId, args.type),
|
||||
granularity: args.granularity,
|
||||
points,
|
||||
};
|
||||
}
|
||||
@@ -217,7 +286,7 @@ async function moodSeries(args: {
|
||||
userId: string;
|
||||
from: string;
|
||||
to: string;
|
||||
granularity: SeriesGranularity;
|
||||
bucket: TimelineBucket;
|
||||
}): Promise<TimelineSeries | null> {
|
||||
const rows = await prisma.moodEntry.groupBy({
|
||||
by: ["date"],
|
||||
@@ -227,17 +296,19 @@ async function moodSeries(args: {
|
||||
date: { gte: args.from, lte: args.to },
|
||||
},
|
||||
_avg: { score: true },
|
||||
_count: { score: true },
|
||||
});
|
||||
const days = new Map<string, number>();
|
||||
const days = new Map<string, DayCell>();
|
||||
for (const row of rows) {
|
||||
if (row._avg.score !== null) days.set(row.date, row._avg.score);
|
||||
if (row._avg.score !== null && row._count.score > 0) {
|
||||
days.set(row.date, { value: row._avg.score, count: row._count.score });
|
||||
}
|
||||
}
|
||||
const points = foldDays(days, args.granularity);
|
||||
const points = foldDays(days, args.bucket);
|
||||
if (points.length === 0) return null;
|
||||
return {
|
||||
key: MOOD_SERIES_KEY,
|
||||
unit: null,
|
||||
granularity: args.granularity,
|
||||
points,
|
||||
};
|
||||
}
|
||||
@@ -248,7 +319,7 @@ export async function loadTimelineSeries(args: {
|
||||
from: string;
|
||||
to: string;
|
||||
tz: string;
|
||||
granularity: SeriesGranularity;
|
||||
bucket: TimelineBucket;
|
||||
priorityJson: unknown;
|
||||
now?: Date;
|
||||
}): Promise<TimelineSeries[]> {
|
||||
|
||||
Reference in new issue
Block a user