mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +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
+3
-2
@@ -26,8 +26,9 @@ an earlier fold computed from part of a day.
|
||||
- **Timeline module (#613).** Opt-in. One SVG time axis from 768 px with
|
||||
lanes for life events, conditions, allergies, medications, vaccinations,
|
||||
visits and procedures, labs and documents, a year, month or week grid and
|
||||
up to three monthly-mean value lines; a chronicle, newest first, on a
|
||||
phone. Switching the module on opens a readiness inventory that rates
|
||||
up to six value lines of quarterly, monthly or weekly means by zoom (a
|
||||
short gap dashed, a longer one left open, a mean from fewer than three
|
||||
readings drawn hollow); a chronicle, newest first, on a phone. Switching the module on opens a readiness inventory that rates
|
||||
each lane and links each gap; it stays available as Data coverage.
|
||||
- **Life events.** Title, kind (family, home, work, loss, other), a start
|
||||
at day, month or year precision, optional end and note; title and note
|
||||
|
||||
+23
-10
@@ -23467,9 +23467,11 @@ paths:
|
||||
- in: query
|
||||
name: values
|
||||
schema:
|
||||
description: Comma-separated value series to include, as `MeasurementType` names or `MOOD`.
|
||||
description: Comma-separated value series to include, as `MeasurementType` names or `MOOD`, at most 6. More, or an
|
||||
unknown name, answers 422.
|
||||
type: string
|
||||
description: Comma-separated value series to include, as `MeasurementType` names or `MOOD`.
|
||||
description: Comma-separated value series to include, as `MeasurementType` names or `MOOD`, at most 6. More, or an
|
||||
unknown name, answers 422.
|
||||
responses:
|
||||
"200":
|
||||
description: The timeline for the requested zoom and window.
|
||||
@@ -81553,6 +81555,14 @@ components:
|
||||
items:
|
||||
$ref: "#/components/schemas/TimelineStandingItem"
|
||||
description: "Things without a start: shown once as standing."
|
||||
bucket:
|
||||
type: string
|
||||
enum:
|
||||
- quarter
|
||||
- month
|
||||
- week
|
||||
description: "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:
|
||||
type: array
|
||||
items:
|
||||
@@ -81581,6 +81591,7 @@ components:
|
||||
- range
|
||||
- lanes
|
||||
- standing
|
||||
- bucket
|
||||
- series
|
||||
- notable
|
||||
additionalProperties: false
|
||||
@@ -81695,12 +81706,6 @@ components:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
granularity:
|
||||
type: string
|
||||
enum:
|
||||
- month
|
||||
- week
|
||||
- day
|
||||
points:
|
||||
type: array
|
||||
items:
|
||||
@@ -81708,17 +81713,25 @@ components:
|
||||
properties:
|
||||
t:
|
||||
type: string
|
||||
description: A local calendar date, `YYYY-MM-DD`.
|
||||
description: The bucket's first day.
|
||||
mean:
|
||||
type: number
|
||||
description: 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:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 9007199254740991
|
||||
description: Readings behind the mean. Clients draw a point from fewer than three as thin.
|
||||
required:
|
||||
- t
|
||||
- mean
|
||||
- count
|
||||
additionalProperties: false
|
||||
description: Buckets with at least one reading, oldest first. A bucket without one is absent, never interpolated.
|
||||
required:
|
||||
- key
|
||||
- unit
|
||||
- granularity
|
||||
- points
|
||||
additionalProperties: false
|
||||
TimelineReadinessEnvelope:
|
||||
|
||||
+80
-6
@@ -10,8 +10,11 @@
|
||||
* day.
|
||||
* 4. A life event is entered through the sheet and posted in the
|
||||
* contract's shape.
|
||||
* 5. On a phone the chronicle replaces the chart, says its empty months
|
||||
* out loud, and a row opens its day.
|
||||
* 5. The value lines: quarterly means over the years, a missing quarter
|
||||
* left out, a short gap bridged dashed, a thin quarter hollow, and up
|
||||
* to six lines.
|
||||
* 6. On a phone the chronicle replaces the chart, says its empty months
|
||||
* out loud, names each quarter's means once, and a row opens its day.
|
||||
*
|
||||
* The timeline's server routes are mocked with the contract's shapes
|
||||
* (`utils/mock-timeline.ts`); the module switch is the real one. Every
|
||||
@@ -22,7 +25,7 @@ import type { Page } from "@playwright/test";
|
||||
|
||||
import { TIMELINE_STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
import { expect, test } from "./setup/test";
|
||||
import { mockTimeline } from "./utils/mock-timeline";
|
||||
import { SYS_MISSING, SYS_THIN, mockTimeline } from "./utils/mock-timeline";
|
||||
|
||||
test.use({ storageState: TIMELINE_STORAGE_STATE_PATH });
|
||||
test.describe.configure({ mode: "serial" });
|
||||
@@ -119,11 +122,10 @@ test("the timeline hands the selected day to the day view through ?day=", async
|
||||
await expect(page.locator('svg [data-lane="illness"]')).toHaveCount(1);
|
||||
await expect(
|
||||
page.locator('[data-slot="timeline-legend-causality"]'),
|
||||
).toBeVisible();
|
||||
// At most three value lines are asked for.
|
||||
).toHaveCount(0);
|
||||
const asked = log.timelineQueries.at(-1)?.get("values")?.split(",") ?? [];
|
||||
expect(asked.length).toBeGreaterThan(0);
|
||||
expect(asked.length).toBeLessThanOrEqual(3);
|
||||
expect(asked.length).toBeLessThanOrEqual(6);
|
||||
|
||||
// The keyboard: one step back and Enter opens that day.
|
||||
const before = await page
|
||||
@@ -157,6 +159,69 @@ test("the timeline hands the selected day to the day view through ?day=", async
|
||||
await expect(page).toHaveURL(/[?&]day=\d{4}-\d{2}-\d{2}/);
|
||||
});
|
||||
|
||||
test("value lines: quarterly means, gaps kept, short ones bridged, up to six lines", async ({
|
||||
page,
|
||||
}) => {
|
||||
await setTimelineModuleOn(page);
|
||||
const log = await mockTimeline(page, "full", TODAY);
|
||||
await page.goto("/timeline");
|
||||
const sys = page.locator('svg [data-series="BLOOD_PRESSURE_SYS"]');
|
||||
await expect(sys).toBeVisible({ timeout: 20_000 });
|
||||
await expect(
|
||||
page.locator('[data-slot="timeline-legend-mean"]'),
|
||||
).toHaveAttribute("data-bucket", "quarter");
|
||||
|
||||
const point = (t: string) =>
|
||||
sys.locator(`[data-slot="timeline-series-point"][data-t="${t}"]`);
|
||||
// A missing quarter stays missing: no point is drawn for it.
|
||||
for (const t of SYS_MISSING) await expect(point(t)).toHaveCount(0);
|
||||
// The quarters around Q2 2023 are joined by a dashed bridge; the three
|
||||
// missing quarters of 2021 are not (one bridge in the whole line).
|
||||
await expect(sys.locator('[data-slot="timeline-series-bridge"]')).toHaveCount(
|
||||
1,
|
||||
);
|
||||
await expect(point("2023-01-01")).toHaveCount(1);
|
||||
await expect(point("2023-07-01")).toHaveCount(1);
|
||||
// Two readings: drawn hollow; four and more: filled.
|
||||
await expect(point(SYS_THIN)).toHaveAttribute("data-thin", "true");
|
||||
await expect(point("2023-01-01")).toHaveAttribute("data-thin", "false");
|
||||
|
||||
// The value table keeps the missing quarter as a row of its own.
|
||||
await expect(
|
||||
page.locator(
|
||||
'[data-slot="timeline-series-table"] [data-bucket="2023-04-01"]',
|
||||
),
|
||||
).toBeAttached();
|
||||
|
||||
// Six value lines can be chosen; a seventh cannot.
|
||||
await page.locator('[data-slot="timeline-values-trigger"]').click();
|
||||
const options = page.locator('[data-slot="timeline-values-option"]');
|
||||
await expect(options.first()).toBeVisible();
|
||||
const count = await options.count();
|
||||
for (let i = 0; i < count; i++) {
|
||||
const checked = page.locator(
|
||||
'[data-slot="timeline-values-option"][aria-checked="true"]',
|
||||
);
|
||||
if ((await checked.count()) >= 6) break;
|
||||
const option = options.nth(i);
|
||||
if ((await option.getAttribute("aria-checked")) === "true") continue;
|
||||
await option.click();
|
||||
}
|
||||
await expect(
|
||||
page.locator('[data-slot="timeline-values-option"][aria-checked="true"]'),
|
||||
).toHaveCount(6);
|
||||
await expect(
|
||||
page.locator(
|
||||
'[data-slot="timeline-values-option"][aria-checked="false"][data-disabled]',
|
||||
),
|
||||
).toHaveCount(count - 6);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect
|
||||
.poll(() => log.timelineQueries.at(-1)?.get("values")?.split(",").length)
|
||||
.toBe(6);
|
||||
await expect(page.locator("svg [data-series]")).toHaveCount(6);
|
||||
});
|
||||
|
||||
async function setTimelineModuleOn(page: Page) {
|
||||
await page.goto("/settings/modules");
|
||||
await setTimelineModule(page, true);
|
||||
@@ -218,6 +283,15 @@ test("on a phone the chronicle replaces the chart and opens a day per row", asyn
|
||||
await expect(
|
||||
page.locator('[data-slot="timeline-chronicle-gap"]').first(),
|
||||
).toBeVisible();
|
||||
// Quarterly means, each quarter named once, at its newest listed month.
|
||||
const means = page.locator('[data-slot="timeline-chronicle-means"]');
|
||||
await expect(means.first()).toBeVisible();
|
||||
const buckets = await means.evaluateAll((els) =>
|
||||
els.map((el) => el.getAttribute("data-bucket") ?? ""),
|
||||
);
|
||||
expect(buckets.length).toBeGreaterThan(0);
|
||||
expect(new Set(buckets).size).toBe(buckets.length);
|
||||
for (const b of buckets) expect(b).toMatch(/^\d{4}-(01|04|07|10)-01$/);
|
||||
|
||||
await page
|
||||
.locator(
|
||||
|
||||
+115
-33
@@ -46,76 +46,142 @@ function rng(seed: number) {
|
||||
};
|
||||
}
|
||||
|
||||
function monthly(
|
||||
from: string,
|
||||
to: string,
|
||||
value: (i: number, monthKey: string) => number,
|
||||
) {
|
||||
const out: Array<{ t: string; mean: number }> = [];
|
||||
type Bucket = "quarter" | "month" | "week";
|
||||
|
||||
/** The bucket the server picks for a zoom (`timelineBucket`). */
|
||||
export function bucketFor(zoom: string): Bucket {
|
||||
return zoom === "quarter" ? "week" : zoom === "year" ? "month" : "quarter";
|
||||
}
|
||||
|
||||
function dayNum(key: string): number {
|
||||
const [y, m, d] = key.split("-").map(Number);
|
||||
return Math.round(Date.UTC(y, m - 1, d) / 86_400_000);
|
||||
}
|
||||
|
||||
function dayStr(n: number): string {
|
||||
return new Date(n * 86_400_000).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** Bucket starts from the one holding `from` to the one holding `to`. */
|
||||
function bucketStarts(from: string, to: string, bucket: Bucket): string[] {
|
||||
const out: string[] = [];
|
||||
if (bucket === "week") {
|
||||
const n = dayNum(from);
|
||||
// 1970-01-01 was a Thursday: day 4 is the first Monday.
|
||||
for (let d = n - ((((n - 4) % 7) + 7) % 7); d <= dayNum(to); d += 7) {
|
||||
out.push(dayStr(d));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
const step = bucket === "quarter" ? 3 : 1;
|
||||
let [y, m] = from.split("-").map(Number);
|
||||
m -= (m - 1) % step;
|
||||
const [ty, tm] = to.split("-").map(Number);
|
||||
let i = 0;
|
||||
while (y < ty || (y === ty && m <= tm)) {
|
||||
const key = `${y}-${String(m).padStart(2, "0")}-01`;
|
||||
out.push({ t: key, mean: Math.round(value(i, key) * 10) / 10 });
|
||||
i++;
|
||||
m++;
|
||||
out.push(`${y}-${String(m).padStart(2, "0")}-01`);
|
||||
m += step;
|
||||
if (m > 12) {
|
||||
m = 1;
|
||||
m -= 12;
|
||||
y++;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function series(dataFrom: string, today: string) {
|
||||
/**
|
||||
* Systolic pressure leaves gaps the chart has to show honestly: in the
|
||||
* quarterly view one missing quarter (Q2 2023, bridged dashed) and three
|
||||
* (2021, the line breaks). Q1 2022 rests on two readings in every series.
|
||||
*/
|
||||
export const SYS_MISSING = new Set([
|
||||
"2021-01-01",
|
||||
"2021-04-01",
|
||||
"2021-07-01",
|
||||
"2023-04-01",
|
||||
]);
|
||||
export const SYS_THIN = "2022-01-01";
|
||||
|
||||
function points(
|
||||
from: string,
|
||||
to: string,
|
||||
bucket: Bucket,
|
||||
value: (i: number, key: string) => number,
|
||||
skip: (key: string) => boolean = () => false,
|
||||
) {
|
||||
const starts = bucketStarts(from, to, bucket);
|
||||
return starts
|
||||
.map((key, i) => ({
|
||||
t: key,
|
||||
mean: Math.round(value(i / starts.length, key) * 10) / 10,
|
||||
count: key === SYS_THIN ? 2 : 4 + (i % 9),
|
||||
}))
|
||||
.filter((p) => !skip(p.t));
|
||||
}
|
||||
|
||||
function series(dataFrom: string, today: string, bucket: Bucket) {
|
||||
const r = rng(11);
|
||||
const months = monthly(dataFrom, today, () => 0).length;
|
||||
return [
|
||||
{
|
||||
key: "BLOOD_PRESSURE_SYS",
|
||||
unit: "mmHg",
|
||||
granularity: "month",
|
||||
points: monthly(
|
||||
points: points(
|
||||
dataFrom,
|
||||
today,
|
||||
(i) => 147 - (18 * i) / months + (r() - 0.5) * 4,
|
||||
bucket,
|
||||
(f) => 147 - 18 * f + (r() - 0.5) * 4,
|
||||
(t) => bucket === "quarter" && SYS_MISSING.has(t),
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "BLOOD_PRESSURE_DIA",
|
||||
unit: "mmHg",
|
||||
granularity: "month",
|
||||
points: monthly(
|
||||
points: points(
|
||||
dataFrom,
|
||||
today,
|
||||
(i) => 92 - (10 * i) / months + (r() - 0.5) * 3,
|
||||
bucket,
|
||||
(f) => 92 - 10 * f + (r() - 0.5) * 3,
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "WEIGHT",
|
||||
unit: "kg",
|
||||
granularity: "month",
|
||||
points: monthly(
|
||||
points: points(
|
||||
dataFrom,
|
||||
today,
|
||||
(i) => 89 - (6.5 * i) / months + (r() - 0.5) * 0.6,
|
||||
bucket,
|
||||
(f) => 89 - 6.5 * f + (r() - 0.5) * 0.6,
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "RESTING_HEART_RATE",
|
||||
unit: "bpm",
|
||||
granularity: "month",
|
||||
points: monthly(dataFrom, today, (i, k) => {
|
||||
points: points(dataFrom, today, bucket, (f, k) => {
|
||||
const month = Number(k.slice(5, 7));
|
||||
return (
|
||||
68 -
|
||||
(5 * i) / months +
|
||||
Math.sin((month / 12) * 6.28) * 1.2 +
|
||||
(r() - 0.5) * 1.2
|
||||
68 - 5 * f + Math.sin((month / 12) * 6.28) * 1.2 + (r() - 0.5) * 1.2
|
||||
);
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: "PULSE",
|
||||
unit: "bpm",
|
||||
points: points(dataFrom, today, bucket, (f) => 74 - 4 * f + r()),
|
||||
},
|
||||
{
|
||||
key: "BODY_FAT",
|
||||
unit: "%",
|
||||
points: points(dataFrom, today, bucket, (f) => 24 - 3 * f + r() * 0.4),
|
||||
},
|
||||
{
|
||||
key: "ACTIVITY_STEPS",
|
||||
unit: "steps",
|
||||
points: points(
|
||||
dataFrom,
|
||||
today,
|
||||
bucket,
|
||||
(f) => 7200 + 900 * f + r() * 400,
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -350,7 +416,8 @@ export function fullTimeline(today: string) {
|
||||
},
|
||||
],
|
||||
standing: [],
|
||||
series: series("2019-01-01", today),
|
||||
bucket: "quarter" as Bucket,
|
||||
series: series("2019-01-01", today, "quarter"),
|
||||
notable: [{ date: "2026-01-04", kind: "extremeHigh" }],
|
||||
};
|
||||
}
|
||||
@@ -468,7 +535,8 @@ export function readyTimeline(today: string) {
|
||||
},
|
||||
],
|
||||
standing: [],
|
||||
series: series("2024-05-01", today).filter(
|
||||
bucket: "quarter" as Bucket,
|
||||
series: series("2024-05-01", today, "quarter").filter(
|
||||
(s) => s.key !== "RESTING_HEART_RATE",
|
||||
),
|
||||
notable: [],
|
||||
@@ -673,10 +741,24 @@ export async function mockTimeline(
|
||||
const values = (url.searchParams.get("values") ?? "")
|
||||
.split(",")
|
||||
.filter(Boolean);
|
||||
const zoom = url.searchParams.get("zoom") ?? "all";
|
||||
const bucket = bucketFor(zoom);
|
||||
const dataFrom = state === "full" ? "2019-01-01" : "2024-05-01";
|
||||
const all =
|
||||
bucket === body.bucket ? body.series : series(dataFrom, today, bucket);
|
||||
return json(route, 200, {
|
||||
...body,
|
||||
zoom: url.searchParams.get("zoom") ?? "all",
|
||||
series: body.series.filter((s) => values.includes(s.key)),
|
||||
zoom,
|
||||
bucket,
|
||||
// Any other value the menu offers answers a flat line of its own.
|
||||
series: values.map(
|
||||
(key) =>
|
||||
all.find((s) => s.key === key) ?? {
|
||||
key,
|
||||
unit: null,
|
||||
points: points(dataFrom, today, bucket, (f) => 50 + 10 * f),
|
||||
},
|
||||
),
|
||||
});
|
||||
});
|
||||
await page.route(/\/api\/life-events(\/[^/?]+)?(\?|$)/, async (route) => {
|
||||
|
||||
+14
-5
@@ -12284,7 +12284,6 @@
|
||||
"timeline": {
|
||||
"title": "Zeitleiste",
|
||||
"subtitle": "Was wann war, neben deinen Werten.",
|
||||
"legend": "Untereinander heißt gleichzeitig, nicht ursächlich.",
|
||||
"standing": "Dauerhaft",
|
||||
"startMissing": "Startdatum fehlt",
|
||||
"openDay": "{date} öffnen",
|
||||
@@ -12406,7 +12405,10 @@
|
||||
},
|
||||
"legendSpan": "Zeitraum",
|
||||
"legendEvent": "Ereignis",
|
||||
"legendMean": "Monatsmittel",
|
||||
"legendMeanQuarter": "Quartalsmittel",
|
||||
"legendMeanMonth": "Monatsmittel",
|
||||
"legendMeanWeek": "Wochenmittel",
|
||||
"legendThin": "Weniger als 3 Messungen",
|
||||
"today": "Heute",
|
||||
"noDataYet": "Noch keine Daten",
|
||||
"layers": "Ebenen",
|
||||
@@ -12420,20 +12422,27 @@
|
||||
"description": "Sobald es Einträge mit Datum gibt, erscheinen sie hier."
|
||||
},
|
||||
"values": {
|
||||
"menuLabel": "Bis zu drei Werte",
|
||||
"menuLabel": "Bis zu sechs Werte",
|
||||
"none": "Keine Werte",
|
||||
"noneInWindow": "Keine Werte in diesem Zeitraum",
|
||||
"series": {
|
||||
"BLOOD_PRESSURE_SYS": "Blutdruck sys.",
|
||||
"BLOOD_PRESSURE_DIA": "Blutdruck dia."
|
||||
}
|
||||
},
|
||||
"noValue": "kein Wert",
|
||||
"bloodPressure": "Blutdruck",
|
||||
"readingsOne": "1 Messung",
|
||||
"readingsFew": "Schnitt aus {count} Messungen",
|
||||
"readingsOther": "Schnitt aus {count} Messungen"
|
||||
},
|
||||
"table": {
|
||||
"caption": "Einträge der Zeitleiste",
|
||||
"lane": "Spur",
|
||||
"entry": "Eintrag",
|
||||
"start": "Beginn",
|
||||
"end": "Ende"
|
||||
"end": "Ende",
|
||||
"seriesCaption": "Mittelwerte nach Zeitraum",
|
||||
"period": "Zeitraum"
|
||||
},
|
||||
"selection": {
|
||||
"empty": "Keine Einträge in diesem Monat",
|
||||
|
||||
+14
-5
@@ -12284,7 +12284,6 @@
|
||||
"timeline": {
|
||||
"title": "Timeline",
|
||||
"subtitle": "What happened when, beside your values.",
|
||||
"legend": "Stacked means at the same time, not cause and effect.",
|
||||
"standing": "Ongoing",
|
||||
"startMissing": "Start date missing",
|
||||
"openDay": "Open {date}",
|
||||
@@ -12406,7 +12405,10 @@
|
||||
},
|
||||
"legendSpan": "Period",
|
||||
"legendEvent": "Event",
|
||||
"legendMean": "Monthly mean",
|
||||
"legendMeanQuarter": "Quarterly mean",
|
||||
"legendMeanMonth": "Monthly mean",
|
||||
"legendMeanWeek": "Weekly mean",
|
||||
"legendThin": "Fewer than 3 readings",
|
||||
"today": "Today",
|
||||
"noDataYet": "No data yet",
|
||||
"layers": "Layers",
|
||||
@@ -12420,20 +12422,27 @@
|
||||
"description": "Dated entries appear here as soon as there are some."
|
||||
},
|
||||
"values": {
|
||||
"menuLabel": "Up to three values",
|
||||
"menuLabel": "Up to six values",
|
||||
"none": "No values",
|
||||
"noneInWindow": "No values in this range",
|
||||
"series": {
|
||||
"BLOOD_PRESSURE_SYS": "Blood pressure sys.",
|
||||
"BLOOD_PRESSURE_DIA": "Blood pressure dia."
|
||||
}
|
||||
},
|
||||
"noValue": "no value",
|
||||
"bloodPressure": "Blood pressure",
|
||||
"readingsOne": "1 reading",
|
||||
"readingsFew": "mean of {count} readings",
|
||||
"readingsOther": "mean of {count} readings"
|
||||
},
|
||||
"table": {
|
||||
"caption": "Timeline entries",
|
||||
"lane": "Lane",
|
||||
"entry": "Entry",
|
||||
"start": "Start",
|
||||
"end": "End"
|
||||
"end": "End",
|
||||
"seriesCaption": "Value means by period",
|
||||
"period": "Period"
|
||||
},
|
||||
"selection": {
|
||||
"empty": "No entries this month",
|
||||
|
||||
+14
-5
@@ -12284,7 +12284,6 @@
|
||||
"timeline": {
|
||||
"title": "Cronología",
|
||||
"subtitle": "Qué pasó y cuándo, junto a tus valores.",
|
||||
"legend": "Uno debajo de otro significa al mismo tiempo, no causa y efecto.",
|
||||
"standing": "Permanente",
|
||||
"startMissing": "Falta la fecha de inicio",
|
||||
"openDay": "Abrir {date}",
|
||||
@@ -12406,7 +12405,10 @@
|
||||
},
|
||||
"legendSpan": "Periodo",
|
||||
"legendEvent": "Evento",
|
||||
"legendMean": "Media mensual",
|
||||
"legendMeanQuarter": "Media trimestral",
|
||||
"legendMeanMonth": "Media mensual",
|
||||
"legendMeanWeek": "Media semanal",
|
||||
"legendThin": "Menos de 3 mediciones",
|
||||
"today": "Hoy",
|
||||
"noDataYet": "Aún sin datos",
|
||||
"layers": "Capas",
|
||||
@@ -12420,20 +12422,27 @@
|
||||
"description": "Las entradas con fecha aparecerán aquí en cuanto haya alguna."
|
||||
},
|
||||
"values": {
|
||||
"menuLabel": "Hasta tres valores",
|
||||
"menuLabel": "Hasta seis valores",
|
||||
"none": "Sin valores",
|
||||
"noneInWindow": "Sin valores en este periodo",
|
||||
"series": {
|
||||
"BLOOD_PRESSURE_SYS": "Presión sistólica",
|
||||
"BLOOD_PRESSURE_DIA": "Presión diastólica"
|
||||
}
|
||||
},
|
||||
"noValue": "sin valor",
|
||||
"bloodPressure": "Presión arterial",
|
||||
"readingsOne": "1 medición",
|
||||
"readingsFew": "media de {count} mediciones",
|
||||
"readingsOther": "media de {count} mediciones"
|
||||
},
|
||||
"table": {
|
||||
"caption": "Entradas de la cronología",
|
||||
"lane": "Pista",
|
||||
"entry": "Entrada",
|
||||
"start": "Inicio",
|
||||
"end": "Fin"
|
||||
"end": "Fin",
|
||||
"seriesCaption": "Medias por periodo",
|
||||
"period": "Periodo"
|
||||
},
|
||||
"selection": {
|
||||
"empty": "Sin entradas este mes",
|
||||
|
||||
+14
-5
@@ -12284,7 +12284,6 @@
|
||||
"timeline": {
|
||||
"title": "Chronologie",
|
||||
"subtitle": "Ce qui s’est passé et quand, à côté de vos valeurs.",
|
||||
"legend": "Superposé signifie simultané, pas cause et effet.",
|
||||
"standing": "Permanent",
|
||||
"startMissing": "Date de début manquante",
|
||||
"openDay": "Ouvrir le {date}",
|
||||
@@ -12406,7 +12405,10 @@
|
||||
},
|
||||
"legendSpan": "Période",
|
||||
"legendEvent": "Événement",
|
||||
"legendMean": "Moyenne mensuelle",
|
||||
"legendMeanQuarter": "Moyenne trimestrielle",
|
||||
"legendMeanMonth": "Moyenne mensuelle",
|
||||
"legendMeanWeek": "Moyenne hebdomadaire",
|
||||
"legendThin": "Moins de 3 mesures",
|
||||
"today": "Aujourd’hui",
|
||||
"noDataYet": "Pas encore de données",
|
||||
"layers": "Calques",
|
||||
@@ -12420,20 +12422,27 @@
|
||||
"description": "Les entrées datées apparaîtront ici dès qu’il y en aura."
|
||||
},
|
||||
"values": {
|
||||
"menuLabel": "Jusqu’à trois valeurs",
|
||||
"menuLabel": "Jusqu’à six valeurs",
|
||||
"none": "Aucune valeur",
|
||||
"noneInWindow": "Aucune valeur sur cette période",
|
||||
"series": {
|
||||
"BLOOD_PRESSURE_SYS": "Tension systolique",
|
||||
"BLOOD_PRESSURE_DIA": "Tension diastolique"
|
||||
}
|
||||
},
|
||||
"noValue": "aucune valeur",
|
||||
"bloodPressure": "Tension artérielle",
|
||||
"readingsOne": "1 mesure",
|
||||
"readingsFew": "moyenne de {count} mesures",
|
||||
"readingsOther": "moyenne de {count} mesures"
|
||||
},
|
||||
"table": {
|
||||
"caption": "Entrées de la chronologie",
|
||||
"lane": "Piste",
|
||||
"entry": "Entrée",
|
||||
"start": "Début",
|
||||
"end": "Fin"
|
||||
"end": "Fin",
|
||||
"seriesCaption": "Moyennes par période",
|
||||
"period": "Période"
|
||||
},
|
||||
"selection": {
|
||||
"empty": "Aucune entrée ce mois-ci",
|
||||
|
||||
+14
-5
@@ -12284,7 +12284,6 @@
|
||||
"timeline": {
|
||||
"title": "Cronologia",
|
||||
"subtitle": "Cosa è successo e quando, accanto ai tuoi valori.",
|
||||
"legend": "Uno sotto l’altro significa nello stesso momento, non causa ed effetto.",
|
||||
"standing": "Permanente",
|
||||
"startMissing": "Data di inizio mancante",
|
||||
"openDay": "Apri {date}",
|
||||
@@ -12406,7 +12405,10 @@
|
||||
},
|
||||
"legendSpan": "Periodo",
|
||||
"legendEvent": "Evento",
|
||||
"legendMean": "Media mensile",
|
||||
"legendMeanQuarter": "Media trimestrale",
|
||||
"legendMeanMonth": "Media mensile",
|
||||
"legendMeanWeek": "Media settimanale",
|
||||
"legendThin": "Meno di 3 misurazioni",
|
||||
"today": "Oggi",
|
||||
"noDataYet": "Ancora nessun dato",
|
||||
"layers": "Livelli",
|
||||
@@ -12420,20 +12422,27 @@
|
||||
"description": "Le voci con data compariranno qui appena ce ne saranno."
|
||||
},
|
||||
"values": {
|
||||
"menuLabel": "Fino a tre valori",
|
||||
"menuLabel": "Fino a sei valori",
|
||||
"none": "Nessun valore",
|
||||
"noneInWindow": "Nessun valore in questo periodo",
|
||||
"series": {
|
||||
"BLOOD_PRESSURE_SYS": "Pressione sistolica",
|
||||
"BLOOD_PRESSURE_DIA": "Pressione diastolica"
|
||||
}
|
||||
},
|
||||
"noValue": "nessun valore",
|
||||
"bloodPressure": "Pressione arteriosa",
|
||||
"readingsOne": "1 misurazione",
|
||||
"readingsFew": "media di {count} misurazioni",
|
||||
"readingsOther": "media di {count} misurazioni"
|
||||
},
|
||||
"table": {
|
||||
"caption": "Voci della cronologia",
|
||||
"lane": "Corsia",
|
||||
"entry": "Voce",
|
||||
"start": "Inizio",
|
||||
"end": "Fine"
|
||||
"end": "Fine",
|
||||
"seriesCaption": "Medie per periodo",
|
||||
"period": "Periodo"
|
||||
},
|
||||
"selection": {
|
||||
"empty": "Nessuna voce in questo mese",
|
||||
|
||||
+14
-5
@@ -12284,7 +12284,6 @@
|
||||
"timeline": {
|
||||
"title": "타임라인",
|
||||
"subtitle": "언제 무엇이 있었는지, 내 수치와 나란히.",
|
||||
"legend": "위아래로 놓인 것은 같은 시기라는 뜻이지, 원인과 결과가 아니에요.",
|
||||
"standing": "지속",
|
||||
"startMissing": "시작일 없음",
|
||||
"openDay": "{date} 열기",
|
||||
@@ -12406,7 +12405,10 @@
|
||||
},
|
||||
"legendSpan": "기간",
|
||||
"legendEvent": "사건",
|
||||
"legendMean": "월평균",
|
||||
"legendMeanQuarter": "분기 평균",
|
||||
"legendMeanMonth": "월평균",
|
||||
"legendMeanWeek": "주간 평균",
|
||||
"legendThin": "측정 3회 미만",
|
||||
"today": "오늘",
|
||||
"noDataYet": "아직 데이터 없음",
|
||||
"layers": "레이어",
|
||||
@@ -12420,20 +12422,27 @@
|
||||
"description": "날짜가 있는 기록이 생기면 여기에 나타나요."
|
||||
},
|
||||
"values": {
|
||||
"menuLabel": "최대 세 가지 수치",
|
||||
"menuLabel": "최대 여섯 가지 수치",
|
||||
"none": "수치 없음",
|
||||
"noneInWindow": "이 기간에 수치 없음",
|
||||
"series": {
|
||||
"BLOOD_PRESSURE_SYS": "수축기 혈압",
|
||||
"BLOOD_PRESSURE_DIA": "이완기 혈압"
|
||||
}
|
||||
},
|
||||
"noValue": "값 없음",
|
||||
"bloodPressure": "혈압",
|
||||
"readingsOne": "측정 1회",
|
||||
"readingsFew": "측정 {count}회 평균",
|
||||
"readingsOther": "측정 {count}회 평균"
|
||||
},
|
||||
"table": {
|
||||
"caption": "타임라인 기록",
|
||||
"lane": "줄",
|
||||
"entry": "기록",
|
||||
"start": "시작",
|
||||
"end": "끝"
|
||||
"end": "끝",
|
||||
"seriesCaption": "기간별 평균",
|
||||
"period": "기간"
|
||||
},
|
||||
"selection": {
|
||||
"empty": "이번 달 기록 없음",
|
||||
|
||||
+14
-5
@@ -12284,7 +12284,6 @@
|
||||
"timeline": {
|
||||
"title": "Oś czasu",
|
||||
"subtitle": "Co i kiedy się działo, obok Twoich wartości.",
|
||||
"legend": "Jedno pod drugim oznacza ten sam czas, nie przyczynę i skutek.",
|
||||
"standing": "Stałe",
|
||||
"startMissing": "Brak daty rozpoczęcia",
|
||||
"openDay": "Otwórz {date}",
|
||||
@@ -12406,7 +12405,10 @@
|
||||
},
|
||||
"legendSpan": "Okres",
|
||||
"legendEvent": "Zdarzenie",
|
||||
"legendMean": "Średnia miesięczna",
|
||||
"legendMeanQuarter": "Średnia kwartalna",
|
||||
"legendMeanMonth": "Średnia miesięczna",
|
||||
"legendMeanWeek": "Średnia tygodniowa",
|
||||
"legendThin": "Mniej niż 3 pomiary",
|
||||
"today": "Dziś",
|
||||
"noDataYet": "Jeszcze brak danych",
|
||||
"layers": "Warstwy",
|
||||
@@ -12420,20 +12422,27 @@
|
||||
"description": "Wpisy z datą pojawią się tutaj, gdy tylko jakieś będą."
|
||||
},
|
||||
"values": {
|
||||
"menuLabel": "Do trzech wartości",
|
||||
"menuLabel": "Do sześciu wartości",
|
||||
"none": "Brak wartości",
|
||||
"noneInWindow": "Brak wartości w tym okresie",
|
||||
"series": {
|
||||
"BLOOD_PRESSURE_SYS": "Ciśnienie skurczowe",
|
||||
"BLOOD_PRESSURE_DIA": "Ciśnienie rozkurczowe"
|
||||
}
|
||||
},
|
||||
"noValue": "brak wartości",
|
||||
"bloodPressure": "Ciśnienie krwi",
|
||||
"readingsOne": "1 pomiar",
|
||||
"readingsFew": "średnia z {count} pomiarów",
|
||||
"readingsOther": "średnia z {count} pomiarów"
|
||||
},
|
||||
"table": {
|
||||
"caption": "Wpisy na osi czasu",
|
||||
"lane": "Ścieżka",
|
||||
"entry": "Wpis",
|
||||
"start": "Początek",
|
||||
"end": "Koniec"
|
||||
"end": "Koniec",
|
||||
"seriesCaption": "Średnie według okresu",
|
||||
"period": "Okres"
|
||||
},
|
||||
"selection": {
|
||||
"empty": "Brak wpisów w tym miesiącu",
|
||||
|
||||
@@ -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[]> {
|
||||
|
||||
@@ -874,6 +874,125 @@ describe("timeline", () => {
|
||||
expect(byKey.values.status).toBe("empty");
|
||||
expect(inventory.verdict).toBe("thin");
|
||||
});
|
||||
|
||||
it("averages value series per bucket, counts the readings and leaves gaps out", async () => {
|
||||
const db = getPrismaClient();
|
||||
const owner = await makeUser("tl-values", { timeline: true });
|
||||
const reading = (at: string, value: number) => ({
|
||||
userId: owner.id,
|
||||
type: "BLOOD_PRESSURE_SYS" as const,
|
||||
value,
|
||||
unit: "mmHg",
|
||||
measuredAt: new Date(at),
|
||||
});
|
||||
await db.measurement.createMany({
|
||||
data: [
|
||||
// Two readings on one January day, one on another: three readings,
|
||||
// two days, each day weighing one.
|
||||
reading("2026-01-10T07:00:00.000Z", 120),
|
||||
reading("2026-01-10T19:00:00.000Z", 130),
|
||||
reading("2026-01-20T07:00:00.000Z", 135),
|
||||
// Nothing in February.
|
||||
reading("2026-03-05T07:00:00.000Z", 128),
|
||||
],
|
||||
});
|
||||
await signIn(owner.id);
|
||||
|
||||
const { GET } = await import("@/app/api/timeline/route");
|
||||
const timeline = await json<import("@/lib/day/contract").TimelineResponse>(
|
||||
await call(
|
||||
GET as Handler,
|
||||
"GET",
|
||||
"/api/timeline?zoom=year&to=2026-03-31&values=BLOOD_PRESSURE_SYS",
|
||||
),
|
||||
);
|
||||
expect(timeline.bucket).toBe("month");
|
||||
expect(timeline.series).toEqual([
|
||||
{
|
||||
key: "BLOOD_PRESSURE_SYS",
|
||||
unit: "mmHg",
|
||||
points: [
|
||||
{ t: "2026-01-01", mean: 130, count: 3 },
|
||||
{ t: "2026-03-01", mean: 128, count: 1 },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const weekly = await json<import("@/lib/day/contract").TimelineResponse>(
|
||||
await call(
|
||||
GET as Handler,
|
||||
"GET",
|
||||
"/api/timeline?zoom=quarter&to=2026-03-31&values=BLOOD_PRESSURE_SYS",
|
||||
),
|
||||
);
|
||||
expect(weekly.bucket).toBe("week");
|
||||
expect(weekly.series[0].points.map((p) => [p.t, p.count])).toEqual([
|
||||
["2026-01-05", 2],
|
||||
["2026-01-19", 1],
|
||||
["2026-03-02", 1],
|
||||
]);
|
||||
|
||||
const six =
|
||||
"BLOOD_PRESSURE_SYS,BLOOD_PRESSURE_DIA,WEIGHT,PULSE,BODY_FAT,MOOD";
|
||||
expect(
|
||||
(await call(GET as Handler, "GET", `/api/timeline?values=${six}`)).status,
|
||||
).toBe(200);
|
||||
expect(
|
||||
(
|
||||
await call(
|
||||
GET as Handler,
|
||||
"GET",
|
||||
`/api/timeline?values=${six},BLOOD_GLUCOSE`,
|
||||
)
|
||||
).status,
|
||||
).toBe(422);
|
||||
});
|
||||
|
||||
it("averages quarters over the years, live before the fold window and rolled up after it", async () => {
|
||||
const db = getPrismaClient();
|
||||
const owner = await makeUser("tl-quarters", { timeline: true });
|
||||
const reading = (at: string, value: number) => ({
|
||||
userId: owner.id,
|
||||
type: "WEIGHT" as const,
|
||||
value,
|
||||
unit: "kg",
|
||||
measuredAt: new Date(at),
|
||||
});
|
||||
await db.measurement.createMany({
|
||||
data: [
|
||||
// Before the five-year fold window: read live.
|
||||
reading("2018-02-10T07:00:00.000Z", 90),
|
||||
reading("2018-03-10T07:00:00.000Z", 88),
|
||||
// Inside it: read from the rollups. January has three days,
|
||||
// February one; the quarter's mean weighs the four days alike.
|
||||
reading("2024-01-10T07:00:00.000Z", 80),
|
||||
reading("2024-01-11T07:00:00.000Z", 80),
|
||||
reading("2024-01-12T07:00:00.000Z", 80),
|
||||
reading("2024-02-10T07:00:00.000Z", 84),
|
||||
// Nothing from April 2024 to March 2025.
|
||||
reading("2025-05-10T07:00:00.000Z", 79),
|
||||
],
|
||||
});
|
||||
const { recomputeUserRollups } =
|
||||
await import("@/lib/rollups/measurement-rollups");
|
||||
await recomputeUserRollups(owner.id, {
|
||||
types: ["WEIGHT"],
|
||||
from: new Date("2018-01-01T00:00:00.000Z"),
|
||||
to: new Date(),
|
||||
});
|
||||
await signIn(owner.id);
|
||||
|
||||
const { GET } = await import("@/app/api/timeline/route");
|
||||
const timeline = await json<import("@/lib/day/contract").TimelineResponse>(
|
||||
await call(GET as Handler, "GET", "/api/timeline?zoom=all&values=WEIGHT"),
|
||||
);
|
||||
expect(timeline.bucket).toBe("quarter");
|
||||
expect(timeline.series[0].points).toEqual([
|
||||
{ t: "2018-01-01", mean: 89, count: 2 },
|
||||
{ t: "2024-01-01", mean: 81, count: 4 },
|
||||
{ t: "2025-04-01", mean: 79, count: 1 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("performance smoke", () => {
|
||||
|
||||
Reference in new issue
Block a user