mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +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
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user