mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
Merge the Coach header, timeline colours, selection bar and range into release/v1.42.0
This commit is contained in:
commit
c40b21de3d
39 files changed
+2185
-543
No files matched your search
@@ -23449,11 +23449,16 @@ paths:
|
||||
name: zoom
|
||||
schema:
|
||||
default: all
|
||||
description: "`range` is a chosen stretch: it requires `from` and `to`, at most 15 years apart, and its value series
|
||||
average quarters, months, weeks or days by its length."
|
||||
type: string
|
||||
enum:
|
||||
- all
|
||||
- year
|
||||
- quarter
|
||||
- range
|
||||
description: "`range` is a chosen stretch: it requires `from` and `to`, at most 15 years apart, and its value series
|
||||
average quarters, months, weeks or days by its length."
|
||||
- in: query
|
||||
name: from
|
||||
schema:
|
||||
@@ -81542,6 +81547,7 @@ components:
|
||||
- all
|
||||
- year
|
||||
- quarter
|
||||
- range
|
||||
range:
|
||||
type: object
|
||||
properties:
|
||||
@@ -81598,8 +81604,10 @@ components:
|
||||
- quarter
|
||||
- month
|
||||
- week
|
||||
- day
|
||||
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`."
|
||||
under two years, `week` for `quarter`, and for a `range` by its length: `quarter` from two years, `month`
|
||||
from 120 days, `week` from 42 days, `day` below."
|
||||
series:
|
||||
type: array
|
||||
items:
|
||||
|
||||
+53
-40
@@ -7,8 +7,8 @@ import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities";
|
||||
|
||||
/**
|
||||
* The Coach page frame: the conversations panel on the right, its toggle in
|
||||
* the top bar, the settings gear in the panel header, and New chat at the
|
||||
* panel's bottom right.
|
||||
* the top bar, the settings gear in the panel header, and New chat opening
|
||||
* that header row.
|
||||
*
|
||||
* Contracts under test, at 1440, 1280 and 390 px:
|
||||
*
|
||||
@@ -20,7 +20,8 @@ import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities";
|
||||
* top bar ends at its left edge and the panel's header border is the
|
||||
* top bar's. The toggle is the bar's last item, within 16 px of its
|
||||
* right edge, open and shut; shut, no pixel of the panel shows and the
|
||||
* bar runs to the window's edge. New chat is a round 48 px plus.
|
||||
* bar runs to the window's edge. New chat is the header row's first
|
||||
* control, left of the title, and never overlaps the list.
|
||||
* 2. Escape inside the docked panel closes it and returns focus to the
|
||||
* toggle.
|
||||
* 3. A row's menu renames by keyboard (Escape cancels without closing the
|
||||
@@ -31,8 +32,8 @@ import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities";
|
||||
* can see" and drops the param from the URL.
|
||||
* 5. axe finds nothing on the open panel or the open settings, in the
|
||||
* light and the dark theme.
|
||||
* 6. At 390 px the panel is a dialog sheet with New chat 16 px from the
|
||||
* right edge (24 px in the docked panel); Escape closes it and focus returns to the toggle. The gear
|
||||
* 6. At 390 px the panel is a dialog sheet with New chat first in its
|
||||
* header and the title beside it; Escape closes it and focus returns to the toggle. The gear
|
||||
* opens a bottom sheet that closes on Escape back to the gear. Nothing
|
||||
* scrolls sideways.
|
||||
*
|
||||
@@ -316,19 +317,38 @@ async function expectToggleAtBarEnd(page: Page) {
|
||||
expect(at.gap).toBeLessThanOrEqual(16);
|
||||
}
|
||||
|
||||
/** New chat: a round 48 px button with the plus glyph. */
|
||||
async function expectFab(page: Page) {
|
||||
const fab = panel(page).locator('[data-slot="coach-panel-new-chat"]');
|
||||
const box = (await fab.boundingBox())!;
|
||||
expect([Math.round(box.width), Math.round(box.height)]).toEqual([48, 48]);
|
||||
/**
|
||||
* New chat opens the panel header: the row's first control, left of the
|
||||
* title, a plus glyph with an accessible name. It sits above the list, so no
|
||||
* row ever slides under it.
|
||||
*/
|
||||
async function expectNewChatInHeader(scope: Locator) {
|
||||
const header = scope.locator(
|
||||
'[data-slot="coach-conversations-panel-header"]',
|
||||
);
|
||||
const button = header.locator('[data-slot="coach-panel-new-chat"]');
|
||||
await expect(button).toHaveCount(1);
|
||||
await expect(button.locator("svg.lucide-plus")).toHaveCount(1);
|
||||
await expect(button).toHaveAttribute("aria-label", "New chat");
|
||||
const order = await header.evaluate((el) => {
|
||||
const first = el.firstElementChild as HTMLElement;
|
||||
const heading = el.querySelector("h2") as HTMLElement;
|
||||
return {
|
||||
first: first.dataset.slot,
|
||||
buttonRight: first.getBoundingClientRect().right,
|
||||
headingLeft: heading.getBoundingClientRect().left,
|
||||
};
|
||||
});
|
||||
expect(order.first).toBe("coach-panel-new-chat");
|
||||
expect(order.buttonRight).toBeLessThanOrEqual(order.headingLeft);
|
||||
const buttonBox = (await button.boundingBox())!;
|
||||
const listBox = (await scope
|
||||
.locator('[data-slot="coach-history-list"]')
|
||||
.boundingBox())!;
|
||||
expect(
|
||||
await fab.evaluate((el) =>
|
||||
parseFloat(getComputedStyle(el).borderTopLeftRadius),
|
||||
),
|
||||
"round",
|
||||
).toBeGreaterThanOrEqual(24);
|
||||
await expect(fab.locator("svg.lucide-plus")).toHaveCount(1);
|
||||
await expect(fab).toHaveAttribute("aria-label", "New chat");
|
||||
buttonBox.y + buttonBox.height,
|
||||
"the button ends above the list",
|
||||
).toBeLessThanOrEqual(listBox.y);
|
||||
}
|
||||
|
||||
/** Rendered heights of the docked panel's header buttons and first row. */
|
||||
@@ -341,6 +361,7 @@ async function panelControlHeights(page: Page) {
|
||||
.boundingBox())!.height,
|
||||
);
|
||||
return {
|
||||
newChat: await height("coach-panel-new-chat"),
|
||||
plans: await height("coach-panel-plans"),
|
||||
gear: await height("coach-settings"),
|
||||
row: await height("coach-history-select"),
|
||||
@@ -439,7 +460,7 @@ test.describe("Coach page frame", () => {
|
||||
await expectPanelFlush(page, width, 900);
|
||||
// Open, the toggle is the top bar's last item, against the panel.
|
||||
await expectToggleAtBarEnd(page);
|
||||
await expectFab(page);
|
||||
await expectNewChatInHeader(panel(page));
|
||||
// The bar says where the reader is: the page, then the conversation.
|
||||
await expect(
|
||||
page.locator('[data-slot="coach-top-bar-trail"]'),
|
||||
@@ -505,6 +526,7 @@ test.describe("Coach page frame", () => {
|
||||
await mockCoach(page);
|
||||
await openCoach(page);
|
||||
expect(await panelControlHeights(page)).toEqual({
|
||||
newChat: 28,
|
||||
plans: 28,
|
||||
gear: 28,
|
||||
row: 36,
|
||||
@@ -524,6 +546,7 @@ test.describe("Coach page frame", () => {
|
||||
await openCoach(page);
|
||||
await expect(panel(page)).toHaveAttribute("data-state", "open");
|
||||
expect(await panelControlHeights(page)).toEqual({
|
||||
newChat: 44,
|
||||
plans: 44,
|
||||
gear: 44,
|
||||
row: 44,
|
||||
@@ -552,18 +575,8 @@ test.describe("Coach page frame", () => {
|
||||
await expect(page).toHaveURL(/\/coach\?c=frame-sleep$/);
|
||||
await expect(rows.nth(1)).toHaveAttribute("aria-current", "true");
|
||||
|
||||
const fab = panel(page).locator('[data-slot="coach-panel-new-chat"]');
|
||||
const fabBox = (await fab.boundingBox())!;
|
||||
const asideBox = (await panel(page).boundingBox())!;
|
||||
// 24 px in from the panel's corner from `md`, 16 px on a phone.
|
||||
expect(asideBox.x + asideBox.width - (fabBox.x + fabBox.width)).toBeCloseTo(
|
||||
24,
|
||||
0,
|
||||
);
|
||||
expect(
|
||||
asideBox.y + asideBox.height - (fabBox.y + fabBox.height),
|
||||
).toBeCloseTo(24, 0);
|
||||
await fab.click();
|
||||
const newChat = panel(page).locator('[data-slot="coach-panel-new-chat"]');
|
||||
await newChat.click();
|
||||
await expect(page).toHaveURL(/\/coach$/);
|
||||
await expect(
|
||||
page.locator('[data-slot="coach-input-textarea"]'),
|
||||
@@ -715,6 +728,7 @@ test.describe("Coach page frame", () => {
|
||||
new RegExp(`(^|\\s)${theme}(\\s|$)`),
|
||||
);
|
||||
await expect(panel(page)).toHaveAttribute("data-state", "open");
|
||||
await shot(page, testInfo, `coach-frame-1440-${theme}`);
|
||||
|
||||
const scan = async (selector: string, label: string) => {
|
||||
const result = await new AxeBuilder({ page })
|
||||
@@ -788,16 +802,15 @@ test.describe("Coach page frame", () => {
|
||||
const sheet = page.getByRole("dialog", { name: "Conversations" });
|
||||
await expect(sheet).toBeVisible();
|
||||
await expect(toggle(page)).toHaveAttribute("aria-expanded", "true");
|
||||
const fab = sheet.locator('[data-slot="coach-panel-new-chat"]');
|
||||
// Measured once the sheet has slid in.
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const box = (await fab.boundingBox())!;
|
||||
return Math.round(390 - (box.x + box.width));
|
||||
})
|
||||
.toBe(16);
|
||||
expect((await fab.boundingBox())!.width).toBe(48);
|
||||
await expect(fab.locator("svg.lucide-plus")).toHaveCount(1);
|
||||
await expectNewChatInHeader(sheet);
|
||||
// The title keeps its whole word beside four header controls.
|
||||
const title = sheet.locator(
|
||||
'[data-slot="coach-conversations-panel-header"] h2',
|
||||
);
|
||||
expect(
|
||||
await title.evaluate((el) => el.scrollWidth <= el.clientWidth),
|
||||
"the sheet title is not truncated",
|
||||
).toBe(true);
|
||||
await shot(page, testInfo, "coach-frame-390-panel");
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
@@ -127,6 +127,21 @@ test("the timeline hands the selected day to the day view through ?day=", async
|
||||
expect(asked.length).toBeGreaterThan(0);
|
||||
expect(asked.length).toBeLessThanOrEqual(6);
|
||||
|
||||
// Before anyone picks a day the bar opens on the newest bucket it has
|
||||
// something for, says so in its head, and says how to pick another.
|
||||
const bar = page.locator('[data-slot="timeline-selection-bar"]');
|
||||
await expect(bar).toHaveAttribute("data-bucket", "quarter");
|
||||
await expect(bar).toHaveAttribute(
|
||||
"data-period-from",
|
||||
/^\d{4}-(01|04|07|10)-01$/,
|
||||
);
|
||||
await expect(
|
||||
page.locator('[data-slot="timeline-selection-empty"]'),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
page.locator('[data-slot="timeline-selection-hint"]'),
|
||||
).toBeVisible();
|
||||
|
||||
// The keyboard: one step back and Enter opens that day.
|
||||
const before = await page
|
||||
.locator('[data-slot="timeline-selection"]')
|
||||
@@ -137,6 +152,10 @@ test("the timeline hands the selected day to the day view through ?day=", async
|
||||
.locator('[data-slot="timeline-selection"]')
|
||||
.getAttribute("data-date");
|
||||
expect(after).not.toBe(before);
|
||||
// A day is picked now: the hint has done its job.
|
||||
await expect(
|
||||
page.locator('[data-slot="timeline-selection-hint"]'),
|
||||
).toHaveCount(0);
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page).toHaveURL(new RegExp(`[?&]day=${after}`));
|
||||
|
||||
@@ -220,6 +239,113 @@ test("value lines: quarterly means, gaps kept, short ones bridged, up to six lin
|
||||
.poll(() => log.timelineQueries.at(-1)?.get("values")?.split(",").length)
|
||||
.toBe(6);
|
||||
await expect(page.locator("svg [data-series]")).toHaveCount(6);
|
||||
// Each line in its own colour, and every mark of a line in that colour.
|
||||
const colours = await page
|
||||
.locator("svg [data-series]")
|
||||
.evaluateAll((groups) =>
|
||||
groups.map((g) => ({
|
||||
colour: getComputedStyle(g).color,
|
||||
marks: [
|
||||
...g.querySelectorAll(
|
||||
'[data-slot="timeline-series-line"], [data-slot="timeline-series-swatch"]',
|
||||
),
|
||||
].map((m) => getComputedStyle(m).stroke),
|
||||
})),
|
||||
);
|
||||
expect(new Set(colours.map((c) => c.colour)).size).toBe(6);
|
||||
for (const { colour, marks } of colours) {
|
||||
expect(marks.length).toBeGreaterThan(0);
|
||||
for (const stroke of marks) expect(stroke).toBe(colour);
|
||||
}
|
||||
// The menu marks each chosen line with the same colour.
|
||||
const trigger = page.locator('[data-slot="timeline-values-trigger"]');
|
||||
await expect(async () => {
|
||||
if ((await trigger.getAttribute("data-state")) !== "open") {
|
||||
await trigger.click();
|
||||
}
|
||||
await expect(trigger).toHaveAttribute("data-state", "open", {
|
||||
timeout: 1_000,
|
||||
});
|
||||
}).toPass();
|
||||
const chosenDots = page.locator(
|
||||
'[data-slot="timeline-values-option"][aria-checked="true"] [data-slot="timeline-values-dot"]',
|
||||
);
|
||||
await expect(chosenDots).toHaveCount(6);
|
||||
const dots = await chosenDots.evaluateAll((els) =>
|
||||
els.map((el) => getComputedStyle(el).backgroundColor),
|
||||
);
|
||||
expect(new Set(dots)).toEqual(new Set(colours.map((c) => c.colour)));
|
||||
await page.keyboard.press("Escape");
|
||||
});
|
||||
|
||||
/** `days` calendar days before `key`. */
|
||||
function daysBefore(key: string, days: number): string {
|
||||
const [y, m, d] = key.split("-").map(Number);
|
||||
return new Date(Date.UTC(y, m - 1, d - days)).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
test("a chosen range lives in the URL: reload keeps it, Back returns to it", async ({
|
||||
page,
|
||||
}) => {
|
||||
await setTimelineModuleOn(page);
|
||||
const log = await mockTimeline(page, "full", TODAY);
|
||||
await page.goto("/timeline");
|
||||
const chart = page.locator('[data-slot="timeline-chart"]');
|
||||
await expect(chart).toBeVisible({ timeout: 20_000 });
|
||||
const desktop = page.locator('[data-slot="timeline-desktop"]');
|
||||
const zoom = (value: string) =>
|
||||
page.locator(`[data-slot="timeline-zoom"] [data-value="${value}"]`);
|
||||
|
||||
// Choosing "Range" starts from what is on screen and writes it down.
|
||||
await zoom("range").click();
|
||||
await expect(page).toHaveURL(
|
||||
/[?&]zoom=range&from=\d{4}-\d{2}-\d{2}&to=\d{4}-\d{2}-\d{2}/,
|
||||
);
|
||||
const fields = desktop.locator('[data-slot="timeline-range"]');
|
||||
await expect(fields).toBeVisible();
|
||||
|
||||
// Three weeks: daily means, the bar headed by one day.
|
||||
const from = daysBefore(TODAY, 20);
|
||||
const start = desktop.locator('[data-testid="timeline-range-from"]');
|
||||
await page.goto(`/timeline?zoom=range&from=${from}&to=${TODAY}`);
|
||||
await expect(fields).toHaveAttribute("data-from", from, { timeout: 20_000 });
|
||||
await expect(fields).toHaveAttribute("data-to", TODAY);
|
||||
await expect(zoom("range")).toHaveAttribute("aria-checked", "true");
|
||||
await expect(
|
||||
page.locator('[data-slot="timeline-legend-mean"]'),
|
||||
).toHaveAttribute("data-bucket", "day");
|
||||
await expect(
|
||||
page.locator('[data-slot="timeline-selection-bar"]'),
|
||||
).toHaveAttribute("data-bucket", "day");
|
||||
const asked = log.timelineQueries.at(-1)!;
|
||||
expect(asked.get("zoom")).toBe("range");
|
||||
expect(asked.get("from")).toBe(from);
|
||||
expect(asked.get("to")).toBe(TODAY);
|
||||
|
||||
// A reload keeps it.
|
||||
await page.reload();
|
||||
await expect(fields).toHaveAttribute("data-from", from, { timeout: 20_000 });
|
||||
|
||||
// A fixed zoom replaces it; Back brings it back.
|
||||
await zoom("year").click();
|
||||
await expect(page).toHaveURL(/[?&]zoom=year$/);
|
||||
await expect(fields).toHaveCount(0);
|
||||
await page.goBack();
|
||||
await expect(page).toHaveURL(new RegExp(`zoom=range&from=${from}`));
|
||||
await expect(fields).toHaveAttribute("data-from", from);
|
||||
|
||||
// Typing a start moves the range and the URL with it.
|
||||
const later = daysBefore(TODAY, 10);
|
||||
await start.fill(later);
|
||||
await start.press("Enter");
|
||||
await expect(page).toHaveURL(new RegExp(`from=${later}&to=${TODAY}`));
|
||||
|
||||
// A parameter it cannot read opens the whole record.
|
||||
await page.goto("/timeline?zoom=range&from=nonsense&to=2026-01-01");
|
||||
await expect(zoom("all")).toHaveAttribute("aria-checked", "true", {
|
||||
timeout: 20_000,
|
||||
});
|
||||
await expect(fields).toHaveCount(0);
|
||||
});
|
||||
|
||||
async function setTimelineModuleOn(page: Page) {
|
||||
@@ -292,6 +418,10 @@ test("on a phone the chronicle replaces the chart and opens a day per row", asyn
|
||||
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$/);
|
||||
// Each mean carries its line's colour dot.
|
||||
await expect(
|
||||
means.first().locator('[data-slot="timeline-series-dot"]').first(),
|
||||
).toBeVisible();
|
||||
|
||||
await page
|
||||
.locator(
|
||||
|
||||
@@ -46,10 +46,25 @@ function rng(seed: number) {
|
||||
};
|
||||
}
|
||||
|
||||
type Bucket = "quarter" | "month" | "week";
|
||||
type Bucket = "quarter" | "month" | "week" | "day";
|
||||
|
||||
/** The bucket the server picks for a zoom (`timelineBucket`). */
|
||||
export function bucketFor(zoom: string): Bucket {
|
||||
export function bucketFor(
|
||||
zoom: string,
|
||||
from?: string | null,
|
||||
to?: string | null,
|
||||
): Bucket {
|
||||
if (zoom === "range" && from && to) {
|
||||
// `rangeBucket`: by the range's length in days.
|
||||
const days = dayNum(to) - dayNum(from) + 1;
|
||||
return days >= 730
|
||||
? "quarter"
|
||||
: days >= 120
|
||||
? "month"
|
||||
: days >= 42
|
||||
? "week"
|
||||
: "day";
|
||||
}
|
||||
return zoom === "quarter" ? "week" : zoom === "year" ? "month" : "quarter";
|
||||
}
|
||||
|
||||
@@ -65,6 +80,10 @@ function dayStr(n: number): string {
|
||||
/** 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 === "day") {
|
||||
for (let d = dayNum(from); d <= dayNum(to); d++) out.push(dayStr(d));
|
||||
return out;
|
||||
}
|
||||
if (bucket === "week") {
|
||||
const n = dayNum(from);
|
||||
// 1970-01-01 was a Thursday: day 4 is the first Monday.
|
||||
@@ -742,14 +761,21 @@ export async function mockTimeline(
|
||||
.split(",")
|
||||
.filter(Boolean);
|
||||
const zoom = url.searchParams.get("zoom") ?? "all";
|
||||
const bucket = bucketFor(zoom);
|
||||
const from = url.searchParams.get("from");
|
||||
const to = url.searchParams.get("to");
|
||||
const bucket = bucketFor(zoom, from, to);
|
||||
const dataFrom = state === "full" ? "2019-01-01" : "2024-05-01";
|
||||
// A chosen range answers its own window, as the server does.
|
||||
const chosen = zoom === "range" && from && to;
|
||||
const all =
|
||||
bucket === body.bucket ? body.series : series(dataFrom, today, bucket);
|
||||
!chosen && bucket === body.bucket
|
||||
? body.series
|
||||
: series(chosen ? from : dataFrom, chosen ? to : today, bucket);
|
||||
return json(route, 200, {
|
||||
...body,
|
||||
zoom,
|
||||
bucket,
|
||||
...(chosen ? { range: { from, to, dataFrom } } : {}),
|
||||
// Any other value the menu offers answers a flat line of its own.
|
||||
series: values.map(
|
||||
(key) =>
|
||||
|
||||
+16
-4
@@ -12295,7 +12295,8 @@
|
||||
"zoom": {
|
||||
"all": "Alles",
|
||||
"year": "12 Monate",
|
||||
"quarter": "3 Monate"
|
||||
"quarter": "3 Monate",
|
||||
"range": "Zeitraum"
|
||||
},
|
||||
"lanes": {
|
||||
"life": "Leben",
|
||||
@@ -12450,10 +12451,13 @@
|
||||
"period": "Zeitraum"
|
||||
},
|
||||
"selection": {
|
||||
"empty": "Keine Einträge in diesem Monat",
|
||||
"empty": "Keine Einträge in diesem Zeitraum",
|
||||
"ongoing": "laufend",
|
||||
"range": "{from} bis {to}",
|
||||
"mean": "Ø {values}"
|
||||
"mean": "Ø {values}",
|
||||
"week": "Woche vom {date}",
|
||||
"hintClick": "Klicke auf einen Zeitpunkt, um ihn hier zu sehen",
|
||||
"hintTap": "Tippe auf einen Zeitpunkt, um ihn hier zu sehen"
|
||||
},
|
||||
"chronicle": {
|
||||
"months": "Monate",
|
||||
@@ -12475,7 +12479,15 @@
|
||||
},
|
||||
"standingAllergiesOne": "{count} Allergie",
|
||||
"standingAllergiesFew": "{count} Allergien",
|
||||
"standingAllergiesOther": "{count} Allergien"
|
||||
"standingAllergiesOther": "{count} Allergien",
|
||||
"legendMeanDay": "Tagesmittel",
|
||||
"range": {
|
||||
"from": "Von",
|
||||
"to": "Bis",
|
||||
"label": "Gewählter Zeitraum",
|
||||
"open": "Zeitraum wählen",
|
||||
"clear": "Alles zeigen"
|
||||
}
|
||||
},
|
||||
"lifeEvents": {
|
||||
"title": "Lebensereignis",
|
||||
|
||||
+16
-4
@@ -12295,7 +12295,8 @@
|
||||
"zoom": {
|
||||
"all": "All",
|
||||
"year": "12 months",
|
||||
"quarter": "3 months"
|
||||
"quarter": "3 months",
|
||||
"range": "Range"
|
||||
},
|
||||
"lanes": {
|
||||
"life": "Life",
|
||||
@@ -12450,10 +12451,13 @@
|
||||
"period": "Period"
|
||||
},
|
||||
"selection": {
|
||||
"empty": "No entries this month",
|
||||
"empty": "No entries in this period",
|
||||
"ongoing": "ongoing",
|
||||
"range": "{from} to {to}",
|
||||
"mean": "Ø {values}"
|
||||
"mean": "Ø {values}",
|
||||
"week": "Week of {date}",
|
||||
"hintClick": "Click a point in time to see it here",
|
||||
"hintTap": "Tap a point in time to see it here"
|
||||
},
|
||||
"chronicle": {
|
||||
"months": "Months",
|
||||
@@ -12475,7 +12479,15 @@
|
||||
},
|
||||
"standingAllergiesOne": "{count} allergy",
|
||||
"standingAllergiesFew": "{count} allergies",
|
||||
"standingAllergiesOther": "{count} allergies"
|
||||
"standingAllergiesOther": "{count} allergies",
|
||||
"legendMeanDay": "Daily mean",
|
||||
"range": {
|
||||
"from": "From",
|
||||
"to": "To",
|
||||
"label": "Chosen range",
|
||||
"open": "Choose a range",
|
||||
"clear": "Show everything"
|
||||
}
|
||||
},
|
||||
"lifeEvents": {
|
||||
"title": "Life event",
|
||||
|
||||
+16
-4
@@ -12295,7 +12295,8 @@
|
||||
"zoom": {
|
||||
"all": "Todo",
|
||||
"year": "12 meses",
|
||||
"quarter": "3 meses"
|
||||
"quarter": "3 meses",
|
||||
"range": "Periodo"
|
||||
},
|
||||
"lanes": {
|
||||
"life": "Vida",
|
||||
@@ -12450,10 +12451,13 @@
|
||||
"period": "Periodo"
|
||||
},
|
||||
"selection": {
|
||||
"empty": "Sin entradas este mes",
|
||||
"empty": "No hay entradas en este periodo",
|
||||
"ongoing": "en curso",
|
||||
"range": "{from} a {to}",
|
||||
"mean": "Ø {values}"
|
||||
"mean": "Ø {values}",
|
||||
"week": "Semana del {date}",
|
||||
"hintClick": "Haz clic en un momento para verlo aquí",
|
||||
"hintTap": "Toca un momento para verlo aquí"
|
||||
},
|
||||
"chronicle": {
|
||||
"months": "Meses",
|
||||
@@ -12475,7 +12479,15 @@
|
||||
},
|
||||
"standingAllergiesOne": "{count} alergia",
|
||||
"standingAllergiesFew": "{count} alergias",
|
||||
"standingAllergiesOther": "{count} alergias"
|
||||
"standingAllergiesOther": "{count} alergias",
|
||||
"legendMeanDay": "Media diaria",
|
||||
"range": {
|
||||
"from": "Desde",
|
||||
"to": "Hasta",
|
||||
"label": "Periodo elegido",
|
||||
"open": "Elegir un periodo",
|
||||
"clear": "Mostrar todo"
|
||||
}
|
||||
},
|
||||
"lifeEvents": {
|
||||
"title": "Acontecimiento vital",
|
||||
|
||||
+16
-4
@@ -12295,7 +12295,8 @@
|
||||
"zoom": {
|
||||
"all": "Tout",
|
||||
"year": "12 mois",
|
||||
"quarter": "3 mois"
|
||||
"quarter": "3 mois",
|
||||
"range": "Période"
|
||||
},
|
||||
"lanes": {
|
||||
"life": "Vie",
|
||||
@@ -12450,10 +12451,13 @@
|
||||
"period": "Période"
|
||||
},
|
||||
"selection": {
|
||||
"empty": "Aucune entrée ce mois-ci",
|
||||
"empty": "Aucune entrée sur cette période",
|
||||
"ongoing": "en cours",
|
||||
"range": "{from} au {to}",
|
||||
"mean": "Ø {values}"
|
||||
"mean": "Ø {values}",
|
||||
"week": "Semaine du {date}",
|
||||
"hintClick": "Cliquez sur un moment pour le voir ici",
|
||||
"hintTap": "Touchez un moment pour le voir ici"
|
||||
},
|
||||
"chronicle": {
|
||||
"months": "Mois",
|
||||
@@ -12475,7 +12479,15 @@
|
||||
},
|
||||
"standingAllergiesOne": "{count} allergie",
|
||||
"standingAllergiesFew": "{count} allergies",
|
||||
"standingAllergiesOther": "{count} allergies"
|
||||
"standingAllergiesOther": "{count} allergies",
|
||||
"legendMeanDay": "Moyenne journalière",
|
||||
"range": {
|
||||
"from": "Du",
|
||||
"to": "Au",
|
||||
"label": "Période choisie",
|
||||
"open": "Choisir une période",
|
||||
"clear": "Tout afficher"
|
||||
}
|
||||
},
|
||||
"lifeEvents": {
|
||||
"title": "Événement de vie",
|
||||
|
||||
+16
-4
@@ -12295,7 +12295,8 @@
|
||||
"zoom": {
|
||||
"all": "Tutto",
|
||||
"year": "12 mesi",
|
||||
"quarter": "3 mesi"
|
||||
"quarter": "3 mesi",
|
||||
"range": "Periodo"
|
||||
},
|
||||
"lanes": {
|
||||
"life": "Vita",
|
||||
@@ -12450,10 +12451,13 @@
|
||||
"period": "Periodo"
|
||||
},
|
||||
"selection": {
|
||||
"empty": "Nessuna voce in questo mese",
|
||||
"empty": "Nessuna voce in questo periodo",
|
||||
"ongoing": "in corso",
|
||||
"range": "dal {from} al {to}",
|
||||
"mean": "Ø {values}"
|
||||
"mean": "Ø {values}",
|
||||
"week": "Settimana del {date}",
|
||||
"hintClick": "Fai clic su un momento per vederlo qui",
|
||||
"hintTap": "Tocca un momento per vederlo qui"
|
||||
},
|
||||
"chronicle": {
|
||||
"months": "Mesi",
|
||||
@@ -12475,7 +12479,15 @@
|
||||
},
|
||||
"standingAllergiesOne": "{count} allergia",
|
||||
"standingAllergiesFew": "{count} allergie",
|
||||
"standingAllergiesOther": "{count} allergie"
|
||||
"standingAllergiesOther": "{count} allergie",
|
||||
"legendMeanDay": "Media giornaliera",
|
||||
"range": {
|
||||
"from": "Dal",
|
||||
"to": "Al",
|
||||
"label": "Periodo scelto",
|
||||
"open": "Scegli un periodo",
|
||||
"clear": "Mostra tutto"
|
||||
}
|
||||
},
|
||||
"lifeEvents": {
|
||||
"title": "Evento di vita",
|
||||
|
||||
+16
-4
@@ -12295,7 +12295,8 @@
|
||||
"zoom": {
|
||||
"all": "전체",
|
||||
"year": "12개월",
|
||||
"quarter": "3개월"
|
||||
"quarter": "3개월",
|
||||
"range": "기간"
|
||||
},
|
||||
"lanes": {
|
||||
"life": "삶",
|
||||
@@ -12450,10 +12451,13 @@
|
||||
"period": "기간"
|
||||
},
|
||||
"selection": {
|
||||
"empty": "이번 달 기록 없음",
|
||||
"empty": "이 기간에는 항목이 없습니다",
|
||||
"ongoing": "진행 중",
|
||||
"range": "{from}~{to}",
|
||||
"mean": "Ø {values}"
|
||||
"mean": "Ø {values}",
|
||||
"week": "{date} 주",
|
||||
"hintClick": "시점을 클릭하면 여기에 표시됩니다",
|
||||
"hintTap": "시점을 탭하면 여기에 표시됩니다"
|
||||
},
|
||||
"chronicle": {
|
||||
"months": "월",
|
||||
@@ -12475,7 +12479,15 @@
|
||||
},
|
||||
"standingAllergiesOne": "알레르기 {count}개",
|
||||
"standingAllergiesFew": "알레르기 {count}개",
|
||||
"standingAllergiesOther": "알레르기 {count}개"
|
||||
"standingAllergiesOther": "알레르기 {count}개",
|
||||
"legendMeanDay": "일 평균",
|
||||
"range": {
|
||||
"from": "시작",
|
||||
"to": "종료",
|
||||
"label": "선택한 기간",
|
||||
"open": "기간 선택",
|
||||
"clear": "전체 보기"
|
||||
}
|
||||
},
|
||||
"lifeEvents": {
|
||||
"title": "삶의 사건",
|
||||
|
||||
+16
-4
@@ -12295,7 +12295,8 @@
|
||||
"zoom": {
|
||||
"all": "Wszystko",
|
||||
"year": "12 miesięcy",
|
||||
"quarter": "3 miesiące"
|
||||
"quarter": "3 miesiące",
|
||||
"range": "Zakres"
|
||||
},
|
||||
"lanes": {
|
||||
"life": "Życie",
|
||||
@@ -12450,10 +12451,13 @@
|
||||
"period": "Okres"
|
||||
},
|
||||
"selection": {
|
||||
"empty": "Brak wpisów w tym miesiącu",
|
||||
"empty": "Brak wpisów w tym okresie",
|
||||
"ongoing": "trwa",
|
||||
"range": "od {from} do {to}",
|
||||
"mean": "Ø {values}"
|
||||
"mean": "Ø {values}",
|
||||
"week": "Tydzień od {date}",
|
||||
"hintClick": "Kliknij punkt w czasie, aby zobaczyć go tutaj",
|
||||
"hintTap": "Dotknij punktu w czasie, aby zobaczyć go tutaj"
|
||||
},
|
||||
"chronicle": {
|
||||
"months": "Miesiące",
|
||||
@@ -12475,7 +12479,15 @@
|
||||
},
|
||||
"standingAllergiesOne": "{count} alergia",
|
||||
"standingAllergiesFew": "{count} alergie",
|
||||
"standingAllergiesOther": "{count} alergii"
|
||||
"standingAllergiesOther": "{count} alergii",
|
||||
"legendMeanDay": "Średnia dzienna",
|
||||
"range": {
|
||||
"from": "Od",
|
||||
"to": "Do",
|
||||
"label": "Wybrany zakres",
|
||||
"open": "Wybierz zakres",
|
||||
"clear": "Pokaż wszystko"
|
||||
}
|
||||
},
|
||||
"lifeEvents": {
|
||||
"title": "Wydarzenie życiowe",
|
||||
|
||||
@@ -134,5 +134,5 @@ export function useDayValueFormat() {
|
||||
[labelFor, number, unitFor],
|
||||
);
|
||||
|
||||
return { labelFor, formatTile };
|
||||
return { labelFor, formatTile, number, unitFor };
|
||||
}
|
||||
@@ -46,8 +46,9 @@ import { useDeleteCoachConversationWithUndo } from "./use-coach";
|
||||
* and the top bar runs to the window's edge. The toggle is the last item of
|
||||
* the top bar, so it sits against the open panel and at the window's edge
|
||||
* when the panel is shut. The panel header carries the title, a link to
|
||||
* Plans, the settings gear and, in the sheet, a close button. The round New
|
||||
* chat button sits at the panel's bottom right.
|
||||
* Plans, the settings gear and, in the sheet, a close button. New chat
|
||||
* opens the header row, left of the title: a fixed control that never sits
|
||||
* over the list, so the last conversation needs no reserve below it.
|
||||
*
|
||||
* Keyboard: Escape closes the docked panel when focus is inside it and
|
||||
* nothing inside (a row menu, the rename field, the settings popover) has
|
||||
@@ -214,13 +215,41 @@ export function ConversationsPanel({
|
||||
// app (`shell-safe-area` in `auth-shell.tsx`).
|
||||
// Docked, the row is the top bar's band (height and bottom border
|
||||
// from `SHELL_HEADER_BAND`), so the two borders draw one line.
|
||||
// Touch: 44 px buttons already carry their own air, so the row packs
|
||||
// them tighter to leave the title room at 390 px.
|
||||
className={cn(
|
||||
"flex shrink-0 items-center gap-2",
|
||||
"flex shrink-0 items-center gap-1 pointer-fine:gap-2",
|
||||
inSheet
|
||||
? "border-border border-b p-3"
|
||||
: cn("border-sidebar-border px-3", SHELL_HEADER_BAND),
|
||||
)}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
onNewChat();
|
||||
afterPick();
|
||||
}}
|
||||
data-slot="coach-panel-new-chat"
|
||||
aria-label={t("insights.coach.newChat")}
|
||||
// The panel's one action, so the primary wash marks it apart
|
||||
// from the muted utility buttons on the right.
|
||||
className={cn(
|
||||
PANEL_HEADER_BUTTON,
|
||||
"bg-primary/10 text-primary hover:bg-primary/15 hover:text-primary",
|
||||
)}
|
||||
>
|
||||
<Plus className={PANEL_HEADER_ICON} aria-hidden="true" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{t("insights.coach.newChat")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{inSheet ? (
|
||||
<SheetTitle className="min-w-0 flex-1 truncate text-lg leading-tight font-semibold">
|
||||
{t("insights.coach.historyTitle")}
|
||||
@@ -287,28 +316,6 @@ export function ConversationsPanel({
|
||||
onUndoDeleteActive={(id) => onSelect(id)}
|
||||
className="min-h-0 flex-1"
|
||||
/>
|
||||
{/* New chat: a round filled button floating at the panel's bottom
|
||||
right; the list pads its own scroll area so the last row clears it. */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
onNewChat();
|
||||
afterPick();
|
||||
}}
|
||||
data-slot="coach-panel-new-chat"
|
||||
aria-label={t("insights.coach.newChat")}
|
||||
className="absolute right-4 bottom-[calc(1rem+env(safe-area-inset-bottom,0px))] size-12 rounded-full shadow-lg md:right-6 md:bottom-[calc(1.5rem+env(safe-area-inset-bottom,0px))]"
|
||||
>
|
||||
<Plus className="size-6" aria-hidden="true" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
{t("insights.coach.newChat")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
);
|
||||
|
||||
|
||||
@@ -149,11 +149,7 @@ export function HistoryRail({
|
||||
<div
|
||||
ref={setListNode}
|
||||
data-slot="coach-history-list"
|
||||
className={cn(
|
||||
// `pb-20` keeps the last row clear of the panel's floating New
|
||||
// chat button; it pads this list's own scroll area, not `main`.
|
||||
"-mx-1 flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto overscroll-contain px-1 pb-20",
|
||||
)}
|
||||
className="-mx-1 flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto overscroll-contain px-1"
|
||||
>
|
||||
{isLoading && visible.length === 0 ? (
|
||||
<p
|
||||
|
||||
@@ -269,132 +269,9 @@ export const MEASUREMENT_TYPE_ICONS: Record<string, LucideIcon> = {
|
||||
WAIST_TO_HEIGHT: Scale,
|
||||
};
|
||||
|
||||
export const MEASUREMENT_TYPE_COLORS: Record<string, string> = {
|
||||
WEIGHT: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20",
|
||||
BLOOD_PRESSURE_SYS: "bg-chart-3/10 text-chart-3 dark:bg-chart-3/20",
|
||||
BLOOD_PRESSURE_DIA: "bg-chart-3/10 text-chart-3 dark:bg-chart-3/20",
|
||||
PULSE: "bg-chart-5/10 text-chart-5 dark:bg-chart-5/20",
|
||||
BODY_FAT: "bg-chart-4/10 text-chart-4 dark:bg-chart-4/20",
|
||||
SLEEP_DURATION: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
|
||||
ACTIVITY_STEPS: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
|
||||
BLOOD_GLUCOSE: "bg-chart-3/10 text-chart-3 dark:bg-chart-3/20",
|
||||
TOTAL_BODY_WATER: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
|
||||
BONE_MASS: "bg-chart-4/10 text-chart-4 dark:bg-chart-4/20",
|
||||
OXYGEN_SATURATION: "bg-chart-5/10 text-chart-5 dark:bg-chart-5/20",
|
||||
// ── v1.4.23 Apple Health additions ──
|
||||
HEART_RATE_VARIABILITY: "bg-chart-5/10 text-chart-5 dark:bg-chart-5/20",
|
||||
RESTING_HEART_RATE: "bg-chart-3/10 text-chart-3 dark:bg-chart-3/20",
|
||||
ACTIVE_ENERGY_BURNED: "bg-chart-4/10 text-chart-4 dark:bg-chart-4/20",
|
||||
FLIGHTS_CLIMBED: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
|
||||
WALKING_RUNNING_DISTANCE: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
|
||||
VO2_MAX: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20",
|
||||
BODY_TEMPERATURE: "bg-chart-4/10 text-chart-4 dark:bg-chart-4/20",
|
||||
// ── v1.4.25 W5d Withings full coverage ──
|
||||
// chart-1 (mass), chart-3 (cardio), chart-4 (fat/temp), chart-5
|
||||
// (pulse-derived) — extend the existing color-family conventions.
|
||||
FAT_FREE_MASS: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20",
|
||||
FAT_MASS: "bg-chart-4/10 text-chart-4 dark:bg-chart-4/20",
|
||||
MUSCLE_MASS: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20",
|
||||
SKIN_TEMPERATURE: "bg-chart-4/10 text-chart-4 dark:bg-chart-4/20",
|
||||
PULSE_WAVE_VELOCITY: "bg-chart-5/10 text-chart-5 dark:bg-chart-5/20",
|
||||
VASCULAR_AGE: "bg-chart-3/10 text-chart-3 dark:bg-chart-3/20",
|
||||
VISCERAL_FAT: "bg-chart-4/10 text-chart-4 dark:bg-chart-4/20",
|
||||
// ── v1.4.25 W8d Apple Health server-prep ──
|
||||
// chart-5 (pulse / sound family) carries audio exposure; chart-2
|
||||
// (activity / daylight family) carries time-in-daylight so the
|
||||
// existing palette conventions hold.
|
||||
AUDIO_EXPOSURE_ENV: "bg-chart-5/10 text-chart-5 dark:bg-chart-5/20",
|
||||
AUDIO_EXPOSURE_HEADPHONE: "bg-chart-5/10 text-chart-5 dark:bg-chart-5/20",
|
||||
TIME_IN_DAYLIGHT: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
|
||||
// ── v1.4.30 R-F T1.4 + T1.5 ──
|
||||
WALKING_STEADINESS: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
|
||||
AUDIO_EXPOSURE_EVENT: "bg-chart-5/10 text-chart-5 dark:bg-chart-5/20",
|
||||
// ── v1.5.5 iOS-coord additions ──
|
||||
// Reuse the existing palette families: chart-5 (cardio/pulse),
|
||||
// chart-1 (mass), chart-2 (activity/gait).
|
||||
RESPIRATORY_RATE: "bg-chart-5/10 text-chart-5 dark:bg-chart-5/20",
|
||||
BODY_MASS_INDEX: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20",
|
||||
LEAN_BODY_MASS: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20",
|
||||
WALKING_HEART_RATE_AVERAGE: "bg-chart-3/10 text-chart-3 dark:bg-chart-3/20",
|
||||
WALKING_ASYMMETRY: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
|
||||
WALKING_DOUBLE_SUPPORT: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
|
||||
// ── v1.5.5 iOS-coord follow-up — raw-SI gait pair ──
|
||||
// Stay in chart-2 (Dracula green) — the entire Mobility cluster
|
||||
// (steadiness + asymmetry + double-support + step length + speed)
|
||||
// shares the activity-family colour so the gait cards read as one
|
||||
// visual group on Insights.
|
||||
WALKING_STEP_LENGTH: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
|
||||
WALKING_SPEED: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
|
||||
// ── v1.10.0 — additive HealthKit signals (WX-A) ──
|
||||
// chart-3 (cardio), chart-4 (temp), chart-2 (activity/gait family),
|
||||
// chart-5 (sleep-breathing, shares the SpO2 family).
|
||||
CARDIO_RECOVERY: "bg-chart-3/10 text-chart-3 dark:bg-chart-3/20",
|
||||
WRIST_TEMPERATURE: "bg-chart-4/10 text-chart-4 dark:bg-chart-4/20",
|
||||
FALL_COUNT: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
|
||||
SIX_MINUTE_WALK_DISTANCE: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
|
||||
STAIR_ASCENT_SPEED: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
|
||||
STAIR_DESCENT_SPEED: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
|
||||
BREATHING_DISTURBANCES: "bg-chart-5/10 text-chart-5 dark:bg-chart-5/20",
|
||||
// ── v1.10.0 — categorical events (WX-B) ──
|
||||
// chart-3 (cardio family) carries the rhythm + heart-rate events;
|
||||
// chart-2 (activity/mobility) carries the steadiness event; chart-5
|
||||
// (respiratory/pulse family) carries the breathing event.
|
||||
IRREGULAR_RHYTHM_NOTIFICATION:
|
||||
"bg-chart-3/10 text-chart-3 dark:bg-chart-3/20",
|
||||
HIGH_HEART_RATE_EVENT: "bg-chart-3/10 text-chart-3 dark:bg-chart-3/20",
|
||||
LOW_HEART_RATE_EVENT: "bg-chart-3/10 text-chart-3 dark:bg-chart-3/20",
|
||||
WALKING_STEADINESS_EVENT: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
|
||||
BREATHING_DISTURBANCE_EVENT: "bg-chart-5/10 text-chart-5 dark:bg-chart-5/20",
|
||||
// ── v1.10.0 — computed scores (WX-C) ──
|
||||
// chart-1 (Dracula purple) marks the server-derived composites as their
|
||||
// own visual group, distinct from the raw-signal families above.
|
||||
RECOVERY_SCORE: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20",
|
||||
STRESS_SCORE: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20",
|
||||
STRAIN_SCORE: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20",
|
||||
// ── v1.11.0 — WHOOP-native score classes ──
|
||||
// chart-1 (Dracula purple) for the strain composites (same group as the
|
||||
// WX-C scores); chart-2 (sleep/activity family) for the sleep-quality set
|
||||
// and energy; chart-5 (cardio/pulse family) for RMSSD HRV.
|
||||
HRV_RMSSD: "bg-chart-5/10 text-chart-5 dark:bg-chart-5/20",
|
||||
DAY_STRAIN: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20",
|
||||
WORKOUT_STRAIN: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20",
|
||||
SLEEP_PERFORMANCE: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
|
||||
SLEEP_EFFICIENCY: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
|
||||
SLEEP_CONSISTENCY: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
|
||||
SLEEP_NEED: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
|
||||
ENERGY_EXPENDITURE_KJ: "bg-chart-4/10 text-chart-4 dark:bg-chart-4/20",
|
||||
// ── v1.12.8 — WHOOP cycle + sleep coverage completion ──
|
||||
// chart-3 (cardio family) for the daily-aggregate heart-rate pair;
|
||||
// chart-2 (sleep/activity family) for the per-night disturbance count.
|
||||
AVERAGE_HEART_RATE: "bg-chart-3/10 text-chart-3 dark:bg-chart-3/20",
|
||||
MAX_HEART_RATE: "bg-chart-3/10 text-chart-3 dark:bg-chart-3/20",
|
||||
SLEEP_DISTURBANCE_COUNT: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
|
||||
// ── v1.17.1 — Polar Nightly Recharge + Training Load Pro components ──
|
||||
// chart-1 (strain/score family) for both, matching DAY_STRAIN.
|
||||
ANS_CHARGE: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20",
|
||||
CARDIO_LOAD: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20",
|
||||
// ── v1.17.1 — Oura coverage completion ──
|
||||
// chart-2 (sleep family) for the Sleep Score; chart-5 (metabolic/thermal)
|
||||
// for the body-temperature deviation.
|
||||
SLEEP_SCORE: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
|
||||
BODY_TEMPERATURE_DEVIATION: "bg-chart-5/10 text-chart-5 dark:bg-chart-5/20",
|
||||
// ── v1.19.0 — Oura resilience ──
|
||||
// chart-1 (Dracula purple) marks the derived recovery / score composites as
|
||||
// their own visual group, matching RECOVERY_SCORE / DAY_STRAIN.
|
||||
RESILIENCE: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20",
|
||||
// ── v1.25 — clinical-signals wave ──
|
||||
// chart-1 (composite/score family) for the screener totals + grip strength;
|
||||
// chart-3 (cardio/alert family) for pain; chart-4 (body family) for waist.
|
||||
PHQ9_SCORE: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20",
|
||||
GAD7_SCORE: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20",
|
||||
// ── v1.27.9 — screening scores join the composite/score colour family ──
|
||||
WHO5_SCORE: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20",
|
||||
SCI_SCORE: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20",
|
||||
GRIP_STRENGTH: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20",
|
||||
PAIN_NRS: "bg-chart-3/10 text-chart-3 dark:bg-chart-3/20",
|
||||
WAIST_CIRCUMFERENCE: "bg-chart-4/10 text-chart-4 dark:bg-chart-4/20",
|
||||
WAIST_TO_HEIGHT: "bg-chart-4/10 text-chart-4 dark:bg-chart-4/20",
|
||||
};
|
||||
// The colour map lives in its own icon-free module so a surface that only
|
||||
// needs the colours (the timeline's value lines) does not pull every icon.
|
||||
export { MEASUREMENT_TYPE_COLORS } from "./measurement-type-colors";
|
||||
|
||||
/**
|
||||
* Count-shaped types whose stored unit is a source token ("count",
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* The colour each measurement type carries in the list (a `--chart-N` wash
|
||||
* and text), and that its value line carries on the timeline. Class strings
|
||||
* over the theme tokens, so both themes retune them; no icon imports here,
|
||||
* so a colour-only consumer stays light.
|
||||
*/
|
||||
export const MEASUREMENT_TYPE_COLORS: Record<string, string> = {
|
||||
WEIGHT: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20",
|
||||
BLOOD_PRESSURE_SYS: "bg-chart-3/10 text-chart-3 dark:bg-chart-3/20",
|
||||
BLOOD_PRESSURE_DIA: "bg-chart-3/10 text-chart-3 dark:bg-chart-3/20",
|
||||
PULSE: "bg-chart-5/10 text-chart-5 dark:bg-chart-5/20",
|
||||
BODY_FAT: "bg-chart-4/10 text-chart-4 dark:bg-chart-4/20",
|
||||
SLEEP_DURATION: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
|
||||
ACTIVITY_STEPS: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
|
||||
BLOOD_GLUCOSE: "bg-chart-3/10 text-chart-3 dark:bg-chart-3/20",
|
||||
TOTAL_BODY_WATER: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
|
||||
BONE_MASS: "bg-chart-4/10 text-chart-4 dark:bg-chart-4/20",
|
||||
OXYGEN_SATURATION: "bg-chart-5/10 text-chart-5 dark:bg-chart-5/20",
|
||||
// ── v1.4.23 Apple Health additions ──
|
||||
HEART_RATE_VARIABILITY: "bg-chart-5/10 text-chart-5 dark:bg-chart-5/20",
|
||||
RESTING_HEART_RATE: "bg-chart-3/10 text-chart-3 dark:bg-chart-3/20",
|
||||
ACTIVE_ENERGY_BURNED: "bg-chart-4/10 text-chart-4 dark:bg-chart-4/20",
|
||||
FLIGHTS_CLIMBED: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
|
||||
WALKING_RUNNING_DISTANCE: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
|
||||
VO2_MAX: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20",
|
||||
BODY_TEMPERATURE: "bg-chart-4/10 text-chart-4 dark:bg-chart-4/20",
|
||||
// ── v1.4.25 W5d Withings full coverage ──
|
||||
// chart-1 (mass), chart-3 (cardio), chart-4 (fat/temp), chart-5
|
||||
// (pulse-derived) — extend the existing color-family conventions.
|
||||
FAT_FREE_MASS: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20",
|
||||
FAT_MASS: "bg-chart-4/10 text-chart-4 dark:bg-chart-4/20",
|
||||
MUSCLE_MASS: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20",
|
||||
SKIN_TEMPERATURE: "bg-chart-4/10 text-chart-4 dark:bg-chart-4/20",
|
||||
PULSE_WAVE_VELOCITY: "bg-chart-5/10 text-chart-5 dark:bg-chart-5/20",
|
||||
VASCULAR_AGE: "bg-chart-3/10 text-chart-3 dark:bg-chart-3/20",
|
||||
VISCERAL_FAT: "bg-chart-4/10 text-chart-4 dark:bg-chart-4/20",
|
||||
// ── v1.4.25 W8d Apple Health server-prep ──
|
||||
// chart-5 (pulse / sound family) carries audio exposure; chart-2
|
||||
// (activity / daylight family) carries time-in-daylight so the
|
||||
// existing palette conventions hold.
|
||||
AUDIO_EXPOSURE_ENV: "bg-chart-5/10 text-chart-5 dark:bg-chart-5/20",
|
||||
AUDIO_EXPOSURE_HEADPHONE: "bg-chart-5/10 text-chart-5 dark:bg-chart-5/20",
|
||||
TIME_IN_DAYLIGHT: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
|
||||
// ── v1.4.30 R-F T1.4 + T1.5 ──
|
||||
WALKING_STEADINESS: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
|
||||
AUDIO_EXPOSURE_EVENT: "bg-chart-5/10 text-chart-5 dark:bg-chart-5/20",
|
||||
// ── v1.5.5 iOS-coord additions ──
|
||||
// Reuse the existing palette families: chart-5 (cardio/pulse),
|
||||
// chart-1 (mass), chart-2 (activity/gait).
|
||||
RESPIRATORY_RATE: "bg-chart-5/10 text-chart-5 dark:bg-chart-5/20",
|
||||
BODY_MASS_INDEX: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20",
|
||||
LEAN_BODY_MASS: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20",
|
||||
WALKING_HEART_RATE_AVERAGE: "bg-chart-3/10 text-chart-3 dark:bg-chart-3/20",
|
||||
WALKING_ASYMMETRY: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
|
||||
WALKING_DOUBLE_SUPPORT: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
|
||||
// ── v1.5.5 iOS-coord follow-up — raw-SI gait pair ──
|
||||
// Stay in chart-2 (Dracula green) — the entire Mobility cluster
|
||||
// (steadiness + asymmetry + double-support + step length + speed)
|
||||
// shares the activity-family colour so the gait cards read as one
|
||||
// visual group on Insights.
|
||||
WALKING_STEP_LENGTH: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
|
||||
WALKING_SPEED: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
|
||||
// ── v1.10.0 — additive HealthKit signals (WX-A) ──
|
||||
// chart-3 (cardio), chart-4 (temp), chart-2 (activity/gait family),
|
||||
// chart-5 (sleep-breathing, shares the SpO2 family).
|
||||
CARDIO_RECOVERY: "bg-chart-3/10 text-chart-3 dark:bg-chart-3/20",
|
||||
WRIST_TEMPERATURE: "bg-chart-4/10 text-chart-4 dark:bg-chart-4/20",
|
||||
FALL_COUNT: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
|
||||
SIX_MINUTE_WALK_DISTANCE: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
|
||||
STAIR_ASCENT_SPEED: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
|
||||
STAIR_DESCENT_SPEED: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
|
||||
BREATHING_DISTURBANCES: "bg-chart-5/10 text-chart-5 dark:bg-chart-5/20",
|
||||
// ── v1.10.0 — categorical events (WX-B) ──
|
||||
// chart-3 (cardio family) carries the rhythm + heart-rate events;
|
||||
// chart-2 (activity/mobility) carries the steadiness event; chart-5
|
||||
// (respiratory/pulse family) carries the breathing event.
|
||||
IRREGULAR_RHYTHM_NOTIFICATION:
|
||||
"bg-chart-3/10 text-chart-3 dark:bg-chart-3/20",
|
||||
HIGH_HEART_RATE_EVENT: "bg-chart-3/10 text-chart-3 dark:bg-chart-3/20",
|
||||
LOW_HEART_RATE_EVENT: "bg-chart-3/10 text-chart-3 dark:bg-chart-3/20",
|
||||
WALKING_STEADINESS_EVENT: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
|
||||
BREATHING_DISTURBANCE_EVENT: "bg-chart-5/10 text-chart-5 dark:bg-chart-5/20",
|
||||
// ── v1.10.0 — computed scores (WX-C) ──
|
||||
// chart-1 (Dracula purple) marks the server-derived composites as their
|
||||
// own visual group, distinct from the raw-signal families above.
|
||||
RECOVERY_SCORE: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20",
|
||||
STRESS_SCORE: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20",
|
||||
STRAIN_SCORE: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20",
|
||||
// ── v1.11.0 — WHOOP-native score classes ──
|
||||
// chart-1 (Dracula purple) for the strain composites (same group as the
|
||||
// WX-C scores); chart-2 (sleep/activity family) for the sleep-quality set
|
||||
// and energy; chart-5 (cardio/pulse family) for RMSSD HRV.
|
||||
HRV_RMSSD: "bg-chart-5/10 text-chart-5 dark:bg-chart-5/20",
|
||||
DAY_STRAIN: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20",
|
||||
WORKOUT_STRAIN: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20",
|
||||
SLEEP_PERFORMANCE: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
|
||||
SLEEP_EFFICIENCY: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
|
||||
SLEEP_CONSISTENCY: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
|
||||
SLEEP_NEED: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
|
||||
ENERGY_EXPENDITURE_KJ: "bg-chart-4/10 text-chart-4 dark:bg-chart-4/20",
|
||||
// ── v1.12.8 — WHOOP cycle + sleep coverage completion ──
|
||||
// chart-3 (cardio family) for the daily-aggregate heart-rate pair;
|
||||
// chart-2 (sleep/activity family) for the per-night disturbance count.
|
||||
AVERAGE_HEART_RATE: "bg-chart-3/10 text-chart-3 dark:bg-chart-3/20",
|
||||
MAX_HEART_RATE: "bg-chart-3/10 text-chart-3 dark:bg-chart-3/20",
|
||||
SLEEP_DISTURBANCE_COUNT: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
|
||||
// ── v1.17.1 — Polar Nightly Recharge + Training Load Pro components ──
|
||||
// chart-1 (strain/score family) for both, matching DAY_STRAIN.
|
||||
ANS_CHARGE: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20",
|
||||
CARDIO_LOAD: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20",
|
||||
// ── v1.17.1 — Oura coverage completion ──
|
||||
// chart-2 (sleep family) for the Sleep Score; chart-5 (metabolic/thermal)
|
||||
// for the body-temperature deviation.
|
||||
SLEEP_SCORE: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
|
||||
BODY_TEMPERATURE_DEVIATION: "bg-chart-5/10 text-chart-5 dark:bg-chart-5/20",
|
||||
// ── v1.19.0 — Oura resilience ──
|
||||
// chart-1 (Dracula purple) marks the derived recovery / score composites as
|
||||
// their own visual group, matching RECOVERY_SCORE / DAY_STRAIN.
|
||||
RESILIENCE: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20",
|
||||
// ── v1.25 — clinical-signals wave ──
|
||||
// chart-1 (composite/score family) for the screener totals + grip strength;
|
||||
// chart-3 (cardio/alert family) for pain; chart-4 (body family) for waist.
|
||||
PHQ9_SCORE: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20",
|
||||
GAD7_SCORE: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20",
|
||||
// ── v1.27.9 — screening scores join the composite/score colour family ──
|
||||
WHO5_SCORE: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20",
|
||||
SCI_SCORE: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20",
|
||||
GRIP_STRENGTH: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20",
|
||||
PAIN_NRS: "bg-chart-3/10 text-chart-3 dark:bg-chart-3/20",
|
||||
WAIST_CIRCUMFERENCE: "bg-chart-4/10 text-chart-4 dark:bg-chart-4/20",
|
||||
WAIST_TO_HEIGHT: "bg-chart-4/10 text-chart-4 dark:bg-chart-4/20",
|
||||
};
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* The chosen-range fields: two date fields with the record's limits, and
|
||||
* every change made honest before it leaves (never past today, never before
|
||||
* the first entry, never inverted).
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import type { DateFieldProps } from "@/components/ui/date-field";
|
||||
|
||||
const fields: DateFieldProps[] = [];
|
||||
vi.mock("@/components/ui/date-field", () => ({
|
||||
DateField: (props: DateFieldProps) => {
|
||||
fields.push(props);
|
||||
return (
|
||||
<input
|
||||
aria-label={props["aria-label"]}
|
||||
data-testid={props["data-testid"]}
|
||||
data-min={props.min}
|
||||
data-max={props.max}
|
||||
value={props.value}
|
||||
readOnly
|
||||
/>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
const { RangeFields } = await import("../range-fields");
|
||||
|
||||
const TODAY = "2026-10-07";
|
||||
|
||||
function render(onChange: (next: { from: string; to: string }) => void) {
|
||||
fields.length = 0;
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="de">
|
||||
<RangeFields
|
||||
range={{ from: "2026-03-01", to: "2026-04-15" }}
|
||||
today={TODAY}
|
||||
dataFrom="2019-03-10"
|
||||
onChange={onChange}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
const [from, to] = fields;
|
||||
return { html, from, to };
|
||||
}
|
||||
|
||||
describe("<RangeFields>", () => {
|
||||
it("names both ends and carries the record's limits", () => {
|
||||
const { html, from, to } = render(() => undefined);
|
||||
expect(html).toContain('data-slot="timeline-range"');
|
||||
expect(html).toContain('data-from="2026-03-01"');
|
||||
expect(html).toContain('data-to="2026-04-15"');
|
||||
expect(html).toContain('aria-label="Gewählter Zeitraum"');
|
||||
expect(from["aria-label"]).toBe("Von");
|
||||
expect(to["aria-label"]).toBe("Bis");
|
||||
expect(from.min).toBe("2019-03-10");
|
||||
expect(from.max).toBe("2026-04-15");
|
||||
expect(to.min).toBe("2026-03-01");
|
||||
expect(to.max).toBe(TODAY);
|
||||
});
|
||||
|
||||
it("passes a valid change on as it is", () => {
|
||||
const onChange = vi.fn();
|
||||
const { from, to } = render(onChange);
|
||||
from.onChange?.("2026-02-01");
|
||||
expect(onChange).toHaveBeenLastCalledWith({
|
||||
from: "2026-02-01",
|
||||
to: "2026-04-15",
|
||||
});
|
||||
to.onChange?.("2026-05-31");
|
||||
expect(onChange).toHaveBeenLastCalledWith({
|
||||
from: "2026-03-01",
|
||||
to: "2026-05-31",
|
||||
});
|
||||
});
|
||||
|
||||
it("clamps softly instead of refusing", () => {
|
||||
const onChange = vi.fn();
|
||||
const { from, to } = render(onChange);
|
||||
// Before the first entry: the first entry.
|
||||
from.onChange?.("2010-01-01");
|
||||
expect(onChange).toHaveBeenLastCalledWith({
|
||||
from: "2019-03-10",
|
||||
to: "2026-04-15",
|
||||
});
|
||||
// Past today: today.
|
||||
to.onChange?.("2027-01-01");
|
||||
expect(onChange).toHaveBeenLastCalledWith({
|
||||
from: "2026-03-01",
|
||||
to: TODAY,
|
||||
});
|
||||
// A start after the end: the end.
|
||||
from.onChange?.("2026-05-01");
|
||||
expect(onChange).toHaveBeenLastCalledWith({
|
||||
from: "2026-04-15",
|
||||
to: "2026-04-15",
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores a cleared field", () => {
|
||||
const onChange = vi.fn();
|
||||
const { from } = render(onChange);
|
||||
from.onChange?.("");
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
SERIES_FALLBACK_COLOR,
|
||||
SERIES_PALETTE,
|
||||
assignSeriesColors,
|
||||
preferredSeriesColor,
|
||||
} from "../series-colors";
|
||||
import { VALUE_OPTIONS } from "../timeline-view";
|
||||
|
||||
/**
|
||||
* A value line takes the colour its type carries in the measurement list,
|
||||
* and no two chosen lines ever share one.
|
||||
*/
|
||||
describe("assignSeriesColors", () => {
|
||||
it("gives a type on its own the colour it has in the list", () => {
|
||||
expect(assignSeriesColors(["WEIGHT"]).get("WEIGHT")).toBe("var(--chart-1)");
|
||||
expect(assignSeriesColors(["PULSE"]).get("PULSE")).toBe("var(--chart-5)");
|
||||
expect(assignSeriesColors(["SLEEP_DURATION"]).get("SLEEP_DURATION")).toBe(
|
||||
"var(--chart-2)",
|
||||
);
|
||||
for (const key of VALUE_OPTIONS) {
|
||||
const own = preferredSeriesColor(key);
|
||||
const alone = assignSeriesColors([key]).get(key);
|
||||
expect(alone).toBe(own ?? SERIES_PALETTE[0]);
|
||||
}
|
||||
});
|
||||
|
||||
it("only ever hands out data tokens, and the foreground once they run out", () => {
|
||||
for (const key of VALUE_OPTIONS) {
|
||||
const own = preferredSeriesColor(key);
|
||||
if (own) expect(SERIES_PALETTE).toContain(own);
|
||||
}
|
||||
});
|
||||
|
||||
it("never repeats a colour among six chosen lines", () => {
|
||||
// Every six-line choice from the selector's options.
|
||||
const options = [...VALUE_OPTIONS];
|
||||
const pick = (from: number, chosen: string[]): string[][] =>
|
||||
chosen.length === 6
|
||||
? [chosen]
|
||||
: options
|
||||
.slice(from)
|
||||
.flatMap((key, i) => pick(from + i + 1, [...chosen, key]));
|
||||
const choices = pick(0, []);
|
||||
expect(choices.length).toBeGreaterThan(100);
|
||||
for (const keys of choices) {
|
||||
const colours = assignSeriesColors(keys, VALUE_OPTIONS);
|
||||
expect(colours.size).toBe(6);
|
||||
expect(new Set(colours.values()).size).toBe(6);
|
||||
for (const colour of colours.values()) {
|
||||
expect([...SERIES_PALETTE, SERIES_FALLBACK_COLOR]).toContain(colour);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("is the same whatever order the lines were picked in", () => {
|
||||
const keys = [
|
||||
"BLOOD_PRESSURE_SYS",
|
||||
"WEIGHT",
|
||||
"RESTING_HEART_RATE",
|
||||
"BLOOD_PRESSURE_DIA",
|
||||
"MOOD",
|
||||
];
|
||||
const forward = assignSeriesColors(keys, VALUE_OPTIONS);
|
||||
const backward = assignSeriesColors([...keys].reverse(), VALUE_OPTIONS);
|
||||
expect(Object.fromEntries(backward)).toEqual(Object.fromEntries(forward));
|
||||
// Systolic keeps its own colour; diastolic, resting pulse and mood
|
||||
// move to free ones.
|
||||
expect(forward.get("BLOOD_PRESSURE_SYS")).toBe("var(--chart-3)");
|
||||
expect(forward.get("WEIGHT")).toBe("var(--chart-1)");
|
||||
expect(forward.get("BLOOD_PRESSURE_DIA")).not.toBe("var(--chart-3)");
|
||||
});
|
||||
});
|
||||
@@ -6,26 +6,64 @@
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import {
|
||||
CAPTURE_KIND_ORDER,
|
||||
visibleCaptureKinds,
|
||||
} from "@/components/layout/capture-picker";
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { I18nProvider, useTranslations } 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 { SelectionBar, bucketTitle } from "../selection-bar";
|
||||
import { assignSeriesColors } from "../series-colors";
|
||||
import type { SeriesValueFormat } from "../series-format";
|
||||
import { MeanPartsLine, SeriesLines, TimelineChart } from "../timeline-chart";
|
||||
import { TimelineChronicle } from "../timeline-chronicle";
|
||||
import { layoutTimeline } from "../timeline-geometry";
|
||||
import { useSeriesValueFormat } from "../use-series-value-format";
|
||||
import { TODAY, fullTimeline, readiness } from "./timeline-fixture";
|
||||
|
||||
function render(node: React.ReactElement, locale: "de" | "en" = "de") {
|
||||
// A fresh client with no account answer: the unit preference resolves to
|
||||
// its default (metric), as it does before `/api/auth/me` lands.
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
/** Renders `build` with the timeline's real value format. */
|
||||
function WithFormat({
|
||||
build,
|
||||
}: {
|
||||
build: (format: SeriesValueFormat) => React.ReactNode;
|
||||
}) {
|
||||
return <>{build(useSeriesValueFormat())}</>;
|
||||
}
|
||||
|
||||
/** Runs `run` with the bundle's `t`, for the plain wording helpers. */
|
||||
function I18nProbe({
|
||||
run,
|
||||
}: {
|
||||
run: (t: ReturnType<typeof useTranslations>["t"]) => string;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
return <>{run(t)}</>;
|
||||
}
|
||||
|
||||
/** The text a reader sees, without the markup between the runs. */
|
||||
const text = (html: string) => html.replace(/<[^>]+>/g, "");
|
||||
|
||||
const COLORS = assignSeriesColors([
|
||||
"BLOOD_PRESSURE_SYS",
|
||||
"BLOOD_PRESSURE_DIA",
|
||||
"WEIGHT",
|
||||
]);
|
||||
const seriesColor = (key: string) => COLORS.get(key) ?? "var(--foreground)";
|
||||
|
||||
describe("readiness inventory", () => {
|
||||
it("names every lane with its state and one link per gap", () => {
|
||||
const html = render(<ReadinessInventory readiness={readiness()} />);
|
||||
@@ -90,13 +128,19 @@ describe("readiness inventory", () => {
|
||||
describe("chronicle", () => {
|
||||
it("names an empty stretch instead of skipping it, and folds the ongoing items", () => {
|
||||
const html = render(
|
||||
<TimelineChronicle
|
||||
timeline={fullTimeline()}
|
||||
today={TODAY}
|
||||
grouping="month"
|
||||
selected="2026-01-03"
|
||||
onOpenDay={() => undefined}
|
||||
onEditLifeEvent={null}
|
||||
<WithFormat
|
||||
build={(format) => (
|
||||
<TimelineChronicle
|
||||
timeline={fullTimeline()}
|
||||
today={TODAY}
|
||||
grouping="month"
|
||||
selected="2026-01-03"
|
||||
seriesColor={seriesColor}
|
||||
seriesFormat={format}
|
||||
onOpenDay={() => undefined}
|
||||
onEditLifeEvent={null}
|
||||
/>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("April bis Oktober 2026 · keine Einträge");
|
||||
@@ -111,16 +155,22 @@ describe("chronicle", () => {
|
||||
|
||||
it("shows the month's means beside the month", () => {
|
||||
const html = render(
|
||||
<TimelineChronicle
|
||||
timeline={fullTimeline()}
|
||||
today={TODAY}
|
||||
grouping="month"
|
||||
selected={null}
|
||||
onOpenDay={() => undefined}
|
||||
onEditLifeEvent={null}
|
||||
<WithFormat
|
||||
build={(format) => (
|
||||
<TimelineChronicle
|
||||
timeline={fullTimeline()}
|
||||
today={TODAY}
|
||||
grouping="month"
|
||||
selected={null}
|
||||
seriesColor={seriesColor}
|
||||
seriesFormat={format}
|
||||
onOpenDay={() => undefined}
|
||||
onEditLifeEvent={null}
|
||||
/>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("129/82 mmHg · 82,6 kg");
|
||||
expect(text(html)).toContain("129/82 mmHg · 82,6 kg");
|
||||
});
|
||||
|
||||
it("names a quarter once, at its newest month, with its span", () => {
|
||||
@@ -136,17 +186,23 @@ describe("chronicle", () => {
|
||||
],
|
||||
};
|
||||
const html = render(
|
||||
<TimelineChronicle
|
||||
timeline={timeline}
|
||||
today={TODAY}
|
||||
grouping="month"
|
||||
selected={null}
|
||||
onOpenDay={() => undefined}
|
||||
onEditLifeEvent={null}
|
||||
<WithFormat
|
||||
build={(format) => (
|
||||
<TimelineChronicle
|
||||
timeline={timeline}
|
||||
today={TODAY}
|
||||
grouping="month"
|
||||
selected={null}
|
||||
seriesColor={seriesColor}
|
||||
seriesFormat={format}
|
||||
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");
|
||||
expect(text(html)).toContain("Jan bis März 2026: 82,6 kg");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -157,18 +213,26 @@ const LABELS: Record<string, string> = {
|
||||
};
|
||||
const seriesLabel = (key: string) => LABELS[key] ?? key;
|
||||
|
||||
describe("selection bar", () => {
|
||||
const bar = (selected: string) =>
|
||||
render(
|
||||
<SelectionBar
|
||||
timeline={fullTimeline()}
|
||||
selected={selected}
|
||||
today={TODAY}
|
||||
seriesLabel={seriesLabel}
|
||||
onOpenDay={() => undefined}
|
||||
/>,
|
||||
);
|
||||
/** The selection bar with the real value format. */
|
||||
const bar = (selected: string, timeline = fullTimeline(), showHint = false) =>
|
||||
render(
|
||||
<WithFormat
|
||||
build={(format) => (
|
||||
<SelectionBar
|
||||
timeline={timeline}
|
||||
selected={selected}
|
||||
showHint={showHint}
|
||||
today={TODAY}
|
||||
seriesLabel={seriesLabel}
|
||||
seriesColor={seriesColor}
|
||||
seriesFormat={format}
|
||||
onOpenDay={() => undefined}
|
||||
/>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
describe("selection bar", () => {
|
||||
it("lists the month's entries and offers the selected day", () => {
|
||||
const html = bar("2026-01-03");
|
||||
expect(html).toContain("Januar 2026");
|
||||
@@ -180,18 +244,18 @@ describe("selection bar", () => {
|
||||
});
|
||||
|
||||
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(text(bar("2026-01-03"))).toContain(
|
||||
"Ø 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",
|
||||
expect(text(bar("2026-07-15"))).toContain(
|
||||
"Ø 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",
|
||||
expect(text(html)).toContain(
|
||||
"Ø 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/);
|
||||
@@ -201,16 +265,22 @@ describe("selection bar", () => {
|
||||
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}
|
||||
<WithFormat
|
||||
build={(format) => (
|
||||
<TimelineChart
|
||||
timeline={fullTimeline()}
|
||||
window={{ from: "2025-10-01", to: "2026-10-31" }}
|
||||
zoom="year"
|
||||
today={TODAY}
|
||||
selected={null}
|
||||
hiddenLanes={new Set()}
|
||||
seriesLabel={seriesLabel}
|
||||
seriesColor={seriesColor}
|
||||
seriesFormat={format}
|
||||
onSelect={() => undefined}
|
||||
onOpenDay={() => undefined}
|
||||
/>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
const table = html.slice(html.indexOf('data-slot="timeline-series-table"'));
|
||||
@@ -228,6 +298,152 @@ describe("value table for a screen reader", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("selection bar head and hint", () => {
|
||||
it("names the bucket outright, for each bucket size", () => {
|
||||
const de = (start: string, bucket: "quarter" | "month" | "week") =>
|
||||
text(
|
||||
render(
|
||||
<I18nProbe run={(t) => bucketTitle(start, bucket, "de-DE", t)} />,
|
||||
),
|
||||
);
|
||||
expect(de("2026-07-01", "quarter")).toBe("Juli bis September 2026");
|
||||
expect(de("2026-09-01", "month")).toBe("September 2026");
|
||||
expect(de("2026-09-21", "week")).toBe("Woche vom 21. September 2026");
|
||||
});
|
||||
|
||||
it("heads the bar with the selected day's bucket and lists its entries", () => {
|
||||
const quarter = { ...fullTimeline(), bucket: "quarter" as const };
|
||||
const html = bar("2026-01-03", quarter);
|
||||
expect(html).toContain('data-period-from="2026-01-01"');
|
||||
expect(html).toContain('data-period-to="2026-03-31"');
|
||||
expect(text(html)).toContain("Januar bis März 2026");
|
||||
expect(text(html)).toContain("Erkältung · 31. Dez. bis 8. Jan.");
|
||||
});
|
||||
|
||||
it("calls a bucket empty only when it holds neither an entry nor a value", () => {
|
||||
// July 2026: no entry, but a systolic mean.
|
||||
expect(bar("2026-07-15")).not.toContain("timeline-selection-empty");
|
||||
// May 2026: no entry, no value.
|
||||
expect(text(bar("2026-05-10"))).toContain(
|
||||
"Keine Einträge in diesem Zeitraum",
|
||||
);
|
||||
});
|
||||
|
||||
it("says how to pick a day only until one is picked", () => {
|
||||
expect(bar("2026-01-03", fullTimeline(), true)).toContain(
|
||||
'data-slot="timeline-selection-hint"',
|
||||
);
|
||||
expect(text(bar("2026-01-03", fullTimeline(), true))).toContain(
|
||||
"Klicke auf einen Zeitpunkt, um ihn hier zu sehen",
|
||||
);
|
||||
expect(bar("2026-01-03")).not.toContain("timeline-selection-hint");
|
||||
});
|
||||
|
||||
it("reads sleep as a duration, not as minutes", () => {
|
||||
const timeline = {
|
||||
...fullTimeline(),
|
||||
series: [
|
||||
{
|
||||
key: "SLEEP_DURATION",
|
||||
unit: "minutes",
|
||||
points: [{ t: "2026-01-01", mean: 556, count: 31 }],
|
||||
},
|
||||
],
|
||||
};
|
||||
const html = text(bar("2026-01-03", timeline));
|
||||
expect(html).toContain("9 Std. 16 Min. (Schnitt aus 31 Messungen)");
|
||||
expect(html).not.toContain("556");
|
||||
});
|
||||
});
|
||||
|
||||
describe("value line colours", () => {
|
||||
const layout = () =>
|
||||
layoutTimeline({
|
||||
width: 900,
|
||||
window: { from: "2025-10-01", to: "2026-10-31" },
|
||||
lanes: [],
|
||||
series: fullTimeline().series,
|
||||
bucket: "month",
|
||||
startMissing: "",
|
||||
today: TODAY,
|
||||
});
|
||||
|
||||
it("draws a line's swatch, path, bridge and points in one colour", () => {
|
||||
const html = render(
|
||||
<WithFormat
|
||||
build={(format) => (
|
||||
<svg>
|
||||
<SeriesLines
|
||||
layout={layout()}
|
||||
width={900}
|
||||
selectedBucket="2026-01-01"
|
||||
bucket="month"
|
||||
seriesLabel={seriesLabel}
|
||||
seriesColor={seriesColor}
|
||||
intl="de-DE"
|
||||
t={(key) => key}
|
||||
fmt={format}
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
const group = (key: string) => {
|
||||
const start = html.indexOf(`data-series="${key}"`);
|
||||
const end = html.indexOf("</g>", start);
|
||||
return html.slice(start, end);
|
||||
};
|
||||
const colours = new Set<string>();
|
||||
for (const key of ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA", "WEIGHT"]) {
|
||||
const g = group(key);
|
||||
const colour = seriesColor(key);
|
||||
colours.add(colour);
|
||||
// The group sets the colour once; every mark inherits it.
|
||||
expect(g).toContain(`color="${colour}"`);
|
||||
expect(g).toContain('data-slot="timeline-series-swatch"');
|
||||
const strokes = [...g.matchAll(/stroke="([^"]+)"/g)].map((m) => m[1]);
|
||||
expect(strokes.length).toBeGreaterThan(0);
|
||||
expect(new Set(strokes)).toEqual(new Set(["currentColor"]));
|
||||
const fills = [...g.matchAll(/<circle[^>]*fill="([^"]+)"/g)].map(
|
||||
(m) => m[1],
|
||||
);
|
||||
for (const fill of fills) {
|
||||
expect(["currentColor", "var(--card)", "none"]).toContain(fill);
|
||||
}
|
||||
}
|
||||
// The systolic line has its bridge and its hollow point in that colour.
|
||||
expect(group("BLOOD_PRESSURE_SYS")).toContain(
|
||||
'data-slot="timeline-series-bridge"',
|
||||
);
|
||||
expect(group("BLOOD_PRESSURE_SYS")).toContain('data-thin="true"');
|
||||
// Its January point is ringed for the selection.
|
||||
expect(group("BLOOD_PRESSURE_SYS")).toContain(
|
||||
'data-slot="timeline-series-selected"',
|
||||
);
|
||||
// Three lines, three colours.
|
||||
expect(colours.size).toBe(3);
|
||||
});
|
||||
|
||||
it("marks each mean with its line's colour", () => {
|
||||
const html = render(
|
||||
<MeanPartsLine
|
||||
parts={[
|
||||
{ key: "BLOOD_PRESSURE_SYS", text: "129/82 mmHg" },
|
||||
{ key: "WEIGHT", text: "82,6 kg" },
|
||||
]}
|
||||
seriesColor={seriesColor}
|
||||
/>,
|
||||
);
|
||||
for (const key of ["BLOOD_PRESSURE_SYS", "WEIGHT"]) {
|
||||
const part = html.slice(html.indexOf(`data-series="${key}"`));
|
||||
expect(part.slice(0, part.indexOf("</span>"))).toContain(
|
||||
`style="background:${seriesColor(key)}"`,
|
||||
);
|
||||
}
|
||||
expect(text(html)).toBe("129/82 mmHg · 82,6 kg");
|
||||
});
|
||||
});
|
||||
|
||||
describe("life events in the capture picker", () => {
|
||||
const own = { inSharedRecord: false, canWriteDomain: () => true };
|
||||
it("is offered only with the timeline module on", () => {
|
||||
|
||||
@@ -13,7 +13,8 @@ import {
|
||||
fitText,
|
||||
gridTicks,
|
||||
itemLabel,
|
||||
itemsInMonth,
|
||||
itemsInPeriod,
|
||||
latestDataDate,
|
||||
layoutLane,
|
||||
layoutSeries,
|
||||
layoutTimeline,
|
||||
@@ -512,7 +513,12 @@ describe("interaction", () => {
|
||||
});
|
||||
|
||||
it("names what happens, starts, ends or runs through the month, not the backdrop", () => {
|
||||
const entries = itemsInMonth(fullTimeline().lanes, "2026-01-03", TODAY);
|
||||
const entries = itemsInPeriod(
|
||||
fullTimeline().lanes,
|
||||
"2026-01-01",
|
||||
"2026-01-31",
|
||||
TODAY,
|
||||
);
|
||||
const ids = entries.map((e) => e.item.id);
|
||||
expect(ids).toEqual(["ill-5", "course-3", "v-4"]);
|
||||
// The winter course neither starts nor ends in January: it runs through.
|
||||
@@ -522,7 +528,50 @@ describe("interaction", () => {
|
||||
expect(ids).not.toContain("ill-chronic");
|
||||
expect(ids).not.toContain("al-1");
|
||||
// An open period that starts in the month is named.
|
||||
const march2019 = itemsInMonth(fullTimeline().lanes, "2019-03-15", TODAY);
|
||||
const march2019 = itemsInPeriod(
|
||||
fullTimeline().lanes,
|
||||
"2019-03-01",
|
||||
"2019-03-31",
|
||||
TODAY,
|
||||
);
|
||||
expect(march2019.map((e) => e.item.id)).toContain("ill-chronic");
|
||||
});
|
||||
|
||||
it("selects the newest bucket the bar has something for, never a document-only one", () => {
|
||||
const timeline = fullTimeline();
|
||||
const all = timeline.range;
|
||||
// The newest value (July's systolic mean) beats the older entries.
|
||||
expect(latestDataDate(timeline, TODAY, all)).toBe("2026-07-01");
|
||||
// A later document does not count: the bar does not list documents,
|
||||
// so selecting its month would open on "No entries".
|
||||
const withDocument = {
|
||||
...timeline,
|
||||
lanes: timeline.lanes.map((lane) =>
|
||||
lane.key === "documents"
|
||||
? {
|
||||
...lane,
|
||||
items: [
|
||||
...lane.items,
|
||||
item({ id: "doc-late", kind: "document", start: "2026-08-20" }),
|
||||
],
|
||||
}
|
||||
: lane,
|
||||
),
|
||||
};
|
||||
expect(latestDataDate(withDocument, TODAY, all)).toBe("2026-07-01");
|
||||
expect(
|
||||
latestDataDate(withDocument, TODAY, {
|
||||
from: "2026-08-01",
|
||||
to: "2026-10-31",
|
||||
}),
|
||||
).toBeNull();
|
||||
// Inside a window, a closed period running past its end counts up to
|
||||
// the window's last day, and whatever is selected the bar names it.
|
||||
const window = { from: "2025-11-01", to: "2026-01-31" };
|
||||
const day = latestDataDate(timeline, TODAY, window)!;
|
||||
expect(day).toBe("2026-01-31");
|
||||
expect(
|
||||
itemsInPeriod(timeline.lanes, "2026-01-01", "2026-01-31", TODAY).length,
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { TIMELINE_RANGE_MAX_DAYS } from "@/lib/day/contract";
|
||||
|
||||
import { dayKey, dayNumber } from "../timeline-dates";
|
||||
import { clampRange, parseTimelineUrl, timelineSearch } from "../timeline-url";
|
||||
|
||||
const TODAY = "2026-10-07";
|
||||
const parse = (search: string) =>
|
||||
parseTimelineUrl(new URLSearchParams(search), TODAY);
|
||||
|
||||
describe("timeline URL", () => {
|
||||
it("reads a fixed zoom and leaves the choice open without one", () => {
|
||||
expect(parse("")).toEqual({ zoom: null, range: null });
|
||||
expect(parse("zoom=year")).toEqual({ zoom: "year", range: null });
|
||||
expect(parse("zoom=quarter&day=2026-01-03")).toEqual({
|
||||
zoom: "quarter",
|
||||
range: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("reads a chosen range with both ends", () => {
|
||||
expect(parse("zoom=range&from=2026-03-01&to=2026-04-15")).toEqual({
|
||||
zoom: "range",
|
||||
range: { from: "2026-03-01", to: "2026-04-15" },
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to all, without a word, on anything it cannot read", () => {
|
||||
for (const search of [
|
||||
"zoom=decade",
|
||||
"zoom=range",
|
||||
"zoom=range&from=2026-03-01",
|
||||
"zoom=range&from=2026-02-30&to=2026-04-15",
|
||||
"zoom=range&from=2026-05-01&to=2026-04-15",
|
||||
"zoom=range&from=2027-01-01&to=2027-02-01",
|
||||
"zoom=range&from=yesterday&to=today",
|
||||
]) {
|
||||
expect(parse(search), search).toEqual({ zoom: "all", range: null });
|
||||
}
|
||||
});
|
||||
|
||||
it("pulls a range's end back to today and its span under the cap", () => {
|
||||
expect(parse("zoom=range&from=2026-09-01&to=2027-01-01").range).toEqual({
|
||||
from: "2026-09-01",
|
||||
to: TODAY,
|
||||
});
|
||||
const long = clampRange({ from: "1990-01-01", to: TODAY }, TODAY, null);
|
||||
expect(dayNumber(long.to) - dayNumber(long.from) + 1).toBe(
|
||||
TIMELINE_RANGE_MAX_DAYS,
|
||||
);
|
||||
expect(long.from).toBe(
|
||||
dayKey(dayNumber(TODAY) - TIMELINE_RANGE_MAX_DAYS + 1),
|
||||
);
|
||||
});
|
||||
|
||||
it("writes the zoom and range and keeps every other parameter", () => {
|
||||
expect(
|
||||
timelineSearch("?day=2026-01-03", "range", {
|
||||
from: "2026-01-01",
|
||||
to: "2026-01-31",
|
||||
}),
|
||||
).toBe("?day=2026-01-03&zoom=range&from=2026-01-01&to=2026-01-31");
|
||||
expect(
|
||||
timelineSearch("?zoom=range&from=2026-01-01&to=2026-01-31", "year", null),
|
||||
).toBe("?zoom=year");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The two date fields of a chosen range (v1.42): from and to, through the
|
||||
* app's own date field (the browser's native date input reads its order
|
||||
* from the browser, not from the person's setting). Each change is made
|
||||
* honest before it leaves (`clampRange`): never past today, never before
|
||||
* the record's first entry, never inverted. The fields carry the same
|
||||
* limits, so the calendar greys out what the clamp would move anyway.
|
||||
*/
|
||||
import { DateField } from "@/components/ui/date-field";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
import { clampRange, type TimelineRange } from "./timeline-url";
|
||||
|
||||
export function RangeFields({
|
||||
range,
|
||||
today,
|
||||
dataFrom,
|
||||
onChange,
|
||||
}: {
|
||||
range: TimelineRange;
|
||||
today: string;
|
||||
/** The record's first entry, once known; the earliest the range may start. */
|
||||
dataFrom: string | null;
|
||||
onChange: (next: TimelineRange) => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const commit = (next: TimelineRange) => {
|
||||
const clamped = clampRange(next, today, dataFrom);
|
||||
if (clamped.from !== range.from || clamped.to !== range.to) {
|
||||
onChange(clamped);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-label={t("timeline.range.label")}
|
||||
data-slot="timeline-range"
|
||||
data-from={range.from}
|
||||
data-to={range.to}
|
||||
className="grid grid-cols-2 gap-3 sm:flex sm:items-end"
|
||||
>
|
||||
<div className="flex min-w-0 flex-col gap-1.5 sm:w-44">
|
||||
{/* The field's own input carries the name; this is its caption. */}
|
||||
<span className="text-xs font-medium" aria-hidden="true">
|
||||
{t("timeline.range.from")}
|
||||
</span>
|
||||
<DateField
|
||||
aria-label={t("timeline.range.from")}
|
||||
value={range.from}
|
||||
min={dataFrom ?? undefined}
|
||||
max={range.to}
|
||||
onChange={(from) => {
|
||||
if (from) commit({ from, to: range.to });
|
||||
}}
|
||||
data-testid="timeline-range-from"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col gap-1.5 sm:w-44">
|
||||
{/* The field's own input carries the name; this is its caption. */}
|
||||
<span className="text-xs font-medium" aria-hidden="true">
|
||||
{t("timeline.range.to")}
|
||||
</span>
|
||||
<DateField
|
||||
aria-label={t("timeline.range.to")}
|
||||
value={range.to}
|
||||
min={range.from}
|
||||
max={today}
|
||||
onChange={(to) => {
|
||||
if (to) commit({ from: range.from, to });
|
||||
}}
|
||||
data-testid="timeline-range-to"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 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 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.
|
||||
* The bar under the chart (v1.42, #613): a cross-section of one bucket, the
|
||||
* one that holds the selected day. Its head names the bucket outright ("July
|
||||
* to September 2026", "September 2026", "Week of 22 September 2026"), so a
|
||||
* value that changes with the zoom says why: it is the mean of a different
|
||||
* stretch. Then every entry that touches the bucket as a chip (each one a
|
||||
* way into its day), the means of the chosen values with the readings behind
|
||||
* each ("no value" where there is none), and "Open 3 Jan." for the selected
|
||||
* day. Until someone picks a day, a short line says how to.
|
||||
*
|
||||
* 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`).
|
||||
@@ -14,25 +17,40 @@ import { ArrowRight } from "lucide-react";
|
||||
|
||||
import type { TimelineResponse } from "@/lib/day/contract";
|
||||
import { resolveIntlLocale } from "@/lib/format-locale";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
import {
|
||||
endOfMonth,
|
||||
bucketAfter,
|
||||
bucketStart,
|
||||
dayKey,
|
||||
dayNumber,
|
||||
formatAtPrecision,
|
||||
formatDayMonth,
|
||||
formatDayMonthYearLong,
|
||||
formatMonthLong,
|
||||
formatMonthYear,
|
||||
monthOf,
|
||||
startOfMonth,
|
||||
} from "./timeline-dates";
|
||||
import { itemsInMonth } from "./timeline-geometry";
|
||||
import { bucketText, laneDotStyle } from "./timeline-chart";
|
||||
import { bucketValues, formatLabelledMeans } from "./series-format";
|
||||
import { itemsInPeriod } from "./timeline-geometry";
|
||||
import { MeanPartsLine, laneDotStyle } from "./timeline-chart";
|
||||
import {
|
||||
bucketValues,
|
||||
labelledMeanParts,
|
||||
type SeriesValueFormat,
|
||||
} from "./series-format";
|
||||
|
||||
const VALUES_MARK = "\u0000";
|
||||
|
||||
export interface SelectionBarProps {
|
||||
timeline: TimelineResponse;
|
||||
selected: string;
|
||||
/** Nobody has picked a day yet: the bar says how to. */
|
||||
showHint?: boolean;
|
||||
today: string;
|
||||
seriesLabel: (key: string) => string;
|
||||
/** The colour of each value line, as on the chart (`series-colors.ts`). */
|
||||
seriesColor: (key: string) => string;
|
||||
/** How a value reads (`useSeriesValueFormat`). */
|
||||
seriesFormat: SeriesValueFormat;
|
||||
onOpenDay: (date: string) => void;
|
||||
onEditLifeEvent?: (id: string) => void;
|
||||
}
|
||||
@@ -40,39 +58,74 @@ export interface SelectionBarProps {
|
||||
export function SelectionBar({
|
||||
timeline,
|
||||
selected,
|
||||
showHint = false,
|
||||
today,
|
||||
seriesLabel,
|
||||
seriesColor,
|
||||
seriesFormat,
|
||||
onOpenDay,
|
||||
onEditLifeEvent,
|
||||
}: SelectionBarProps) {
|
||||
const { t, tCount, locale } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const intl = resolveIntlLocale(locale);
|
||||
const entries = itemsInMonth(timeline.lanes, selected, today);
|
||||
const bucket = timeline.bucket;
|
||||
const periodFrom = bucketStart(selected, bucket);
|
||||
const periodTo = dayKey(dayNumber(bucketAfter(periodFrom, bucket)) - 1);
|
||||
const entries = itemsInPeriod(timeline.lanes, periodFrom, periodTo, today);
|
||||
const means = bucketValues(timeline.series, timeline.bucket, selected);
|
||||
const monthFrom = startOfMonth(selected);
|
||||
const monthTo = endOfMonth(selected);
|
||||
// A bucket with a value but no entry is not empty: the means below say
|
||||
// what it holds, so "No entries" is kept for a stretch with nothing.
|
||||
const hasValue = means.values.some((v) => v.mean !== null);
|
||||
// "Ø {values}": the words around the values, so the values can carry
|
||||
// their colour dots.
|
||||
const [meanBefore, meanAfter = ""] = t("timeline.selection.mean", {
|
||||
values: VALUES_MARK,
|
||||
}).split(VALUES_MARK);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-muted/60 flex flex-wrap items-center gap-x-3 gap-y-2 rounded-lg px-3 py-2.5"
|
||||
data-slot="timeline-selection-bar"
|
||||
data-month={monthOf(selected)}
|
||||
data-bucket={bucket}
|
||||
data-period-from={periodFrom}
|
||||
data-period-to={periodTo}
|
||||
>
|
||||
<span className="text-sm font-semibold" aria-live="polite">
|
||||
{formatMonthYear(selected, intl)}
|
||||
{showHint && (
|
||||
// One line, worded for the input in hand: a pointer clicks, a
|
||||
// finger taps.
|
||||
<p
|
||||
className="text-muted-foreground basis-full text-xs"
|
||||
data-slot="timeline-selection-hint"
|
||||
>
|
||||
<span className="pointer-coarse:hidden">
|
||||
{t("timeline.selection.hintClick")}
|
||||
</span>
|
||||
<span className="hidden pointer-coarse:inline">
|
||||
{t("timeline.selection.hintTap")}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
<span
|
||||
className="text-sm font-semibold"
|
||||
aria-live="polite"
|
||||
data-slot="timeline-selection-title"
|
||||
>
|
||||
{bucketTitle(periodFrom, bucket, intl, t)}
|
||||
</span>
|
||||
{entries.length === 0 && (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{entries.length === 0 && !hasValue && (
|
||||
<span
|
||||
className="text-muted-foreground text-xs"
|
||||
data-slot="timeline-selection-empty"
|
||||
>
|
||||
{t("timeline.selection.empty")}
|
||||
</span>
|
||||
)}
|
||||
{entries.map(({ lane, item, through }) => {
|
||||
// A chip opens the day its entry touches inside this month.
|
||||
// A chip opens the day its entry touches inside this period.
|
||||
const day =
|
||||
item.start >= monthFrom && item.start <= monthTo
|
||||
item.start >= periodFrom && item.start <= periodTo
|
||||
? item.start
|
||||
: monthFrom;
|
||||
: periodFrom;
|
||||
const canOpenDay = item.precision === "DAY" && day <= today;
|
||||
const when =
|
||||
item.open || through
|
||||
@@ -128,18 +181,17 @@ export function SelectionBar({
|
||||
data-slot="timeline-selection-means"
|
||||
data-bucket={means.start}
|
||||
>
|
||||
{t("timeline.selection.mean", {
|
||||
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),
|
||||
},
|
||||
)}`,
|
||||
})}
|
||||
{meanBefore}
|
||||
<MeanPartsLine
|
||||
parts={labelledMeanParts(means.values, seriesFormat, {
|
||||
label: seriesLabel,
|
||||
bloodPressure: t("timeline.values.bloodPressure"),
|
||||
noValue: t("timeline.values.noValue"),
|
||||
readings: (n) => tCount("timeline.values.readings", n),
|
||||
})}
|
||||
seriesColor={seriesColor}
|
||||
/>
|
||||
{meanAfter}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
@@ -155,3 +207,27 @@ export function SelectionBar({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The bucket by name, in full: "July to September 2026", "September 2026",
|
||||
* "Week of 22 September 2026", "22 September 2026".
|
||||
*/
|
||||
export function bucketTitle(
|
||||
start: string,
|
||||
bucket: TimelineResponse["bucket"],
|
||||
intl: string,
|
||||
t: ReturnType<typeof useTranslations>["t"],
|
||||
): string {
|
||||
if (bucket === "day") return formatDayMonthYearLong(start, intl);
|
||||
if (bucket === "month") return formatMonthYear(start, intl);
|
||||
if (bucket === "quarter") {
|
||||
const last = dayKey(dayNumber(bucketAfter(start, bucket)) - 1);
|
||||
return t("timeline.selection.range", {
|
||||
from: formatMonthLong(start, intl),
|
||||
to: formatMonthYear(last, intl),
|
||||
});
|
||||
}
|
||||
return t("timeline.selection.week", {
|
||||
date: formatDayMonthYearLong(start, intl),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* The colour of each value line on the timeline (v1.42).
|
||||
*
|
||||
* A line takes the colour its measurement type already carries in the list
|
||||
* (`MEASUREMENT_TYPE_COLORS`, a `--chart-N` token), so weight reads in the
|
||||
* same colour on both pages. Where two chosen lines would share a colour
|
||||
* (systolic and diastolic, or resting pulse beside blood pressure) or a type
|
||||
* has none (mood), the later one takes the first `--chart-N` still free.
|
||||
* There are five data tokens and up to six lines; a sixth line with nothing
|
||||
* free left is drawn in `--foreground`, which is still apart from the other
|
||||
* five. The order of `rank` decides who keeps a contested colour, so the
|
||||
* same set of lines is coloured the same way whatever order it was picked
|
||||
* in.
|
||||
*/
|
||||
import { MEASUREMENT_TYPE_COLORS } from "@/components/measurements/measurement-type-colors";
|
||||
|
||||
export const SERIES_PALETTE = [
|
||||
"var(--chart-1)",
|
||||
"var(--chart-2)",
|
||||
"var(--chart-3)",
|
||||
"var(--chart-4)",
|
||||
"var(--chart-5)",
|
||||
] as const;
|
||||
|
||||
/** The colour once every data token is taken. */
|
||||
export const SERIES_FALLBACK_COLOR = "var(--foreground)";
|
||||
|
||||
/** The type's own colour, as a `var(--chart-N)` string, or null. */
|
||||
export function preferredSeriesColor(key: string): string | null {
|
||||
const match = /\btext-chart-([1-5])\b/.exec(
|
||||
MEASUREMENT_TYPE_COLORS[key] ?? "",
|
||||
);
|
||||
return match ? `var(--chart-${match[1]})` : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* One distinct colour per key. `rank` orders who keeps a contested colour
|
||||
* (keys missing from it come after, in their own order).
|
||||
*/
|
||||
export function assignSeriesColors(
|
||||
keys: readonly string[],
|
||||
rank: readonly string[] = [],
|
||||
): Map<string, string> {
|
||||
const position = (key: string) => {
|
||||
const i = rank.indexOf(key);
|
||||
return i === -1 ? rank.length : i;
|
||||
};
|
||||
const ordered = [...new Set(keys)]
|
||||
.map((key, i) => ({ key, i }))
|
||||
.sort((a, b) => position(a.key) - position(b.key) || a.i - b.i)
|
||||
.map(({ key }) => key);
|
||||
|
||||
const out = new Map<string, string>();
|
||||
const taken = new Set<string>();
|
||||
for (const key of ordered) {
|
||||
const own = preferredSeriesColor(key);
|
||||
if (own && !taken.has(own)) {
|
||||
out.set(key, own);
|
||||
taken.add(own);
|
||||
}
|
||||
}
|
||||
for (const key of ordered) {
|
||||
if (out.has(key)) continue;
|
||||
const free = SERIES_PALETTE.find((c) => !taken.has(c));
|
||||
const color = free ?? SERIES_FALLBACK_COLOR;
|
||||
out.set(key, color);
|
||||
taken.add(color);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -1,49 +1,46 @@
|
||||
/**
|
||||
* How a value line's number reads (v1.42, #613): whole numbers for pressure,
|
||||
* pulse and counts, one decimal for the rest, sleep in hours. The unit is the
|
||||
* server's, already in the person's display unit.
|
||||
* How a value line's number reads (v1.42, #613). The server sends each
|
||||
* series in its stored, canonical unit (minutes for sleep, kilograms for
|
||||
* weight); the person's unit preference and the duration spelling are
|
||||
* applied here, through the same formatter the day view uses
|
||||
* (`useSeriesValueFormat`), so a night reads "9 h 16 min" on both pages.
|
||||
*/
|
||||
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",
|
||||
"PULSE",
|
||||
"RESTING_HEART_RATE",
|
||||
"HEART_RATE_VARIABILITY",
|
||||
"ACTIVITY_STEPS",
|
||||
]);
|
||||
|
||||
export function formatSeriesNumber(
|
||||
key: string,
|
||||
value: number,
|
||||
unit: string | null,
|
||||
fmt: Pick<Formatters, "number">,
|
||||
): { value: string; unit: string | null } {
|
||||
if (key === "SLEEP_DURATION" && unit === "min") {
|
||||
return { value: fmt.number(value / 60, 1), unit: "h" };
|
||||
}
|
||||
// Glucose arrives in the person's display unit: whole numbers in the
|
||||
// hundreds, one decimal for single digits.
|
||||
const digits =
|
||||
WHOLE_NUMBER_KEYS.has(key) || (key === "BLOOD_GLUCOSE" && value >= 30)
|
||||
? 0
|
||||
: 1;
|
||||
return { value: fmt.number(value, digits), unit };
|
||||
/** How one series value reads: the number, and the unit beside it. */
|
||||
export interface SeriesValueFormat {
|
||||
/** "82,6", "129", or a duration that carries its own unit ("9 h 16 min"). */
|
||||
number: (key: string, value: number, unit: string | null) => string;
|
||||
/** The unit beside the number, or "" when the number carries its own. */
|
||||
unit: (key: string, unit: string | null) => string;
|
||||
}
|
||||
|
||||
/** "129 mmHg", "82,6 kg", "7,4 h". */
|
||||
/**
|
||||
* The format for a series without a measurement type behind it (mood, a
|
||||
* score without a unit): one decimal, no unit.
|
||||
*/
|
||||
export function plainSeriesFormat(
|
||||
fmt: Pick<Formatters, "number">,
|
||||
): SeriesValueFormat {
|
||||
return {
|
||||
number: (_key, value) => fmt.number(value, 1),
|
||||
unit: (_key, unit) => unit ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
/** "129 mmHg", "82,6 kg", "9 h 16 min". */
|
||||
export function formatSeriesValue(
|
||||
key: string,
|
||||
value: number,
|
||||
unit: string | null,
|
||||
fmt: Pick<Formatters, "number">,
|
||||
format: SeriesValueFormat,
|
||||
): string {
|
||||
const out = formatSeriesNumber(key, value, unit, fmt);
|
||||
return out.unit ? `${out.value} ${out.unit}` : out.value;
|
||||
const number = format.number(key, value, unit);
|
||||
const shown = format.unit(key, unit);
|
||||
return shown ? `${number} ${shown}` : number;
|
||||
}
|
||||
|
||||
/** One series in one bucket: its mean, or nothing when it has no reading. */
|
||||
@@ -116,49 +113,69 @@ export function chronicleMeans(
|
||||
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">,
|
||||
/** Systolic over diastolic, one unit: "129/82 mmHg". */
|
||||
function pressure(
|
||||
sys: BucketValue,
|
||||
dia: BucketValue,
|
||||
format: SeriesValueFormat,
|
||||
): string {
|
||||
const unit = format.unit(sys.key, sys.unit);
|
||||
return `${format.number(sys.key, sys.mean!, sys.unit)}/${format.number(dia.key, dia.mean!, dia.unit)}${unit ? ` ${unit}` : ""}`;
|
||||
}
|
||||
|
||||
/** One value of a means line, with the series whose colour marks it. */
|
||||
export interface MeanPart {
|
||||
key: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The means of one bucket for a glance: systolic and diastolic fold into
|
||||
* "129/82 mmHg" (marked as systolic), the rest follow as "82,6 kg". Series
|
||||
* without a value are left out; the selection bar names the gaps.
|
||||
*/
|
||||
export function meanParts(
|
||||
means: readonly BucketValue[],
|
||||
format: SeriesValueFormat,
|
||||
): MeanPart[] {
|
||||
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[] = [];
|
||||
const parts: MeanPart[] = [];
|
||||
for (const m of present) {
|
||||
if (sys && dia && m.key === "BLOOD_PRESSURE_DIA") continue;
|
||||
if (sys && dia && m.key === "BLOOD_PRESSURE_SYS") {
|
||||
parts.push(
|
||||
`${fmt.number(sys.mean, 0)}/${fmt.number(dia.mean, 0)}${sys.unit ? ` ${sys.unit}` : ""}`,
|
||||
);
|
||||
parts.push({
|
||||
key: m.key,
|
||||
text: pressure(sys, dia, format),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
parts.push(formatSeriesValue(m.key, m.mean, m.unit, fmt));
|
||||
parts.push({
|
||||
key: m.key,
|
||||
text: formatSeriesValue(m.key, m.mean, m.unit, format),
|
||||
});
|
||||
}
|
||||
return parts.join(" · ");
|
||||
return parts;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* selection bar: "Blood pressure 129/82 mmHg (mean of 4 readings)", then
|
||||
* "Weight no value". Systolic and diastolic fold when both have a value.
|
||||
*/
|
||||
export function formatLabelledMeans(
|
||||
export function labelledMeanParts(
|
||||
means: readonly BucketValue[],
|
||||
fmt: Pick<Formatters, "number">,
|
||||
format: SeriesValueFormat,
|
||||
words: {
|
||||
label: (key: string) => string;
|
||||
bloodPressure: string;
|
||||
noValue: string;
|
||||
readings: (count: number) => string;
|
||||
},
|
||||
): string {
|
||||
): MeanPart[] {
|
||||
const sys = means.find(
|
||||
(m) => m.key === "BLOOD_PRESSURE_SYS" && m.mean !== null,
|
||||
);
|
||||
@@ -166,20 +183,23 @@ export function formatLabelledMeans(
|
||||
(m) => m.key === "BLOOD_PRESSURE_DIA" && m.mean !== null,
|
||||
);
|
||||
const fold = sys && dia;
|
||||
const parts: string[] = [];
|
||||
const parts: MeanPart[] = [];
|
||||
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)})`,
|
||||
);
|
||||
parts.push({
|
||||
key: m.key,
|
||||
text: `${words.bloodPressure} ${pressure(sys, dia!, format)} (${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)})`,
|
||||
);
|
||||
parts.push({
|
||||
key: m.key,
|
||||
text:
|
||||
m.mean === null
|
||||
? `${words.label(m.key)} ${words.noValue}`
|
||||
: `${words.label(m.key)} ${formatSeriesValue(m.key, m.mean, m.unit, format)} (${words.readings(m.count ?? 0)})`,
|
||||
});
|
||||
}
|
||||
return parts.join(" · ");
|
||||
return parts;
|
||||
}
|
||||
@@ -49,7 +49,7 @@ import type {
|
||||
TimelineZoom,
|
||||
} from "@/lib/day/contract";
|
||||
import { resolveIntlLocale } from "@/lib/format-locale";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
import {
|
||||
bucketAfter,
|
||||
@@ -72,11 +72,19 @@ import {
|
||||
wrapToWidth,
|
||||
layoutTimeline,
|
||||
stepSelection,
|
||||
zoomShape,
|
||||
type PlacedPoint,
|
||||
type PlacedSpan,
|
||||
type TimeWindow,
|
||||
} from "./timeline-geometry";
|
||||
import { formatSeriesValue } from "./series-format";
|
||||
import {
|
||||
formatSeriesValue,
|
||||
type MeanPart,
|
||||
type SeriesValueFormat,
|
||||
} from "./series-format";
|
||||
|
||||
/** Where a value line's name and latest value start, right of its swatch. */
|
||||
const SERIES_TEXT_X = 16;
|
||||
|
||||
export const LANE_ICON: Readonly<Record<TimelineLaneKey, LucideIcon>> = {
|
||||
life: Flag,
|
||||
@@ -98,6 +106,10 @@ export interface TimelineChartProps {
|
||||
selected: string | null;
|
||||
hiddenLanes: ReadonlySet<TimelineLaneKey>;
|
||||
seriesLabel: (key: string) => string;
|
||||
/** The colour of a value line (`series-colors.ts`), as a token string. */
|
||||
seriesColor: (key: string) => string;
|
||||
/** How a value reads (`useSeriesValueFormat`). */
|
||||
seriesFormat: SeriesValueFormat;
|
||||
onSelect: (date: string) => void;
|
||||
onOpenDay: (date: string) => void;
|
||||
}
|
||||
@@ -129,11 +141,13 @@ export function TimelineChart({
|
||||
selected,
|
||||
hiddenLanes,
|
||||
seriesLabel,
|
||||
seriesColor,
|
||||
seriesFormat,
|
||||
onSelect,
|
||||
onOpenDay,
|
||||
}: TimelineChartProps) {
|
||||
const { t, locale } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const fmt = seriesFormat;
|
||||
const intl = resolveIntlLocale(locale);
|
||||
const { ref, width } = useElementWidth<HTMLDivElement>();
|
||||
|
||||
@@ -158,14 +172,15 @@ export function TimelineChart({
|
||||
);
|
||||
|
||||
const floor = timeline.range.dataFrom ?? window.from;
|
||||
const shape = zoomShape(zoom, window);
|
||||
|
||||
function handleKey(event: KeyboardEvent<HTMLDivElement>) {
|
||||
const current = selected ?? today;
|
||||
let next: string | null = null;
|
||||
if (event.key === "ArrowLeft")
|
||||
next = stepSelection(current, -1, zoom, today, floor);
|
||||
next = stepSelection(current, -1, shape, today, floor);
|
||||
else if (event.key === "ArrowRight")
|
||||
next = stepSelection(current, 1, zoom, today, floor);
|
||||
next = stepSelection(current, 1, shape, today, floor);
|
||||
else if (event.key === "Home") next = floor;
|
||||
else if (event.key === "End") next = today;
|
||||
else if (event.key === "Enter" || event.key === " ") {
|
||||
@@ -229,12 +244,13 @@ export function TimelineChart({
|
||||
<ChartBody
|
||||
layout={layout}
|
||||
window={window}
|
||||
zoom={zoom}
|
||||
zoom={shape}
|
||||
today={today}
|
||||
selected={selected}
|
||||
dataFrom={timeline.range.dataFrom}
|
||||
bucket={timeline.bucket}
|
||||
seriesLabel={seriesLabel}
|
||||
seriesColor={seriesColor}
|
||||
intl={intl}
|
||||
t={t}
|
||||
fmt={fmt}
|
||||
@@ -253,6 +269,7 @@ export function TimelineChart({
|
||||
timeline={timeline}
|
||||
window={window}
|
||||
seriesLabel={seriesLabel}
|
||||
fmt={fmt}
|
||||
intl={intl}
|
||||
/>
|
||||
</div>
|
||||
@@ -262,15 +279,16 @@ export function TimelineChart({
|
||||
interface ChartBodyProps {
|
||||
layout: NonNullable<ReturnType<typeof layoutTimeline>>;
|
||||
window: TimeWindow;
|
||||
zoom: TimelineZoom;
|
||||
zoom: Exclude<TimelineZoom, "range">;
|
||||
today: string;
|
||||
selected: string | null;
|
||||
dataFrom: string | null;
|
||||
bucket: TimelineBucket;
|
||||
seriesLabel: (key: string) => string;
|
||||
seriesColor: (key: string) => string;
|
||||
intl: string;
|
||||
t: ReturnType<typeof useTranslations>["t"];
|
||||
fmt: ReturnType<typeof useFormatters>;
|
||||
fmt: SeriesValueFormat;
|
||||
}
|
||||
|
||||
function ChartBody({
|
||||
@@ -282,11 +300,11 @@ function ChartBody({
|
||||
dataFrom,
|
||||
bucket,
|
||||
seriesLabel,
|
||||
seriesColor,
|
||||
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);
|
||||
@@ -303,7 +321,12 @@ function ChartBody({
|
||||
dataFrom && dataFrom > window.from && dataFrom <= window.to
|
||||
? scale.x(dataFrom)
|
||||
: null;
|
||||
const seriesTop = layout.seriesTop;
|
||||
// The bucket that holds the selected day: its point on every line is
|
||||
// ringed in the line's colour, so the selection reads on each scale.
|
||||
const selectedBucket =
|
||||
selected && selected >= window.from && selected <= window.to
|
||||
? bucketStart(selected, bucket)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<g fontFamily="inherit">
|
||||
@@ -434,81 +457,20 @@ function ChartBody({
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Value lines: neutral, one scale each, never coloured by meaning. */}
|
||||
{layout.series.length > 0 && (
|
||||
<line
|
||||
x1={0}
|
||||
x2={width}
|
||||
y1={seriesTop - 18}
|
||||
y2={seriesTop - 18}
|
||||
stroke="var(--border)"
|
||||
/>
|
||||
)}
|
||||
{layout.series.map((series) => (
|
||||
<g key={series.key} data-series={series.key}>
|
||||
<text
|
||||
x={0}
|
||||
y={series.top + 12}
|
||||
fontSize={13}
|
||||
fontWeight={500}
|
||||
fill="var(--foreground)"
|
||||
>
|
||||
{fitText(seriesLabel(series.key), scale.x0 - 12, 13)}
|
||||
</text>
|
||||
<text
|
||||
x={0}
|
||||
y={series.top + 30}
|
||||
fontSize={12}
|
||||
fill="var(--muted-foreground)"
|
||||
>
|
||||
{series.latest === null
|
||||
? t("timeline.values.noneInWindow")
|
||||
: formatSeriesValue(series.key, series.latest, series.unit, fmt)}
|
||||
</text>
|
||||
{series.path && (
|
||||
<path
|
||||
d={series.path}
|
||||
fill="none"
|
||||
stroke="var(--foreground)"
|
||||
strokeWidth={1.6}
|
||||
strokeOpacity={0.85}
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
)}
|
||||
{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>
|
||||
))}
|
||||
{/* Value lines: one scale each, each in its own colour (the colour
|
||||
its type carries in the measurement list). The colour names the
|
||||
line, never a judgement of its values. */}
|
||||
<SeriesLines
|
||||
layout={layout}
|
||||
width={width}
|
||||
selectedBucket={selectedBucket}
|
||||
bucket={bucket}
|
||||
seriesLabel={seriesLabel}
|
||||
seriesColor={seriesColor}
|
||||
intl={intl}
|
||||
t={t}
|
||||
fmt={fmt}
|
||||
/>
|
||||
|
||||
{/* Today. */}
|
||||
{todayInWindow && (
|
||||
@@ -559,6 +521,157 @@ function ChartBody({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The value lines under the lanes, each in its own colour: the swatch beside
|
||||
* the name, the line, the dashed bridge over a gap, the points (hollow where
|
||||
* a mean rests on one or two readings) and the ring on the selected bucket.
|
||||
* Every mark of a line inherits one `color` from its group, so they cannot
|
||||
* drift apart.
|
||||
*/
|
||||
export function SeriesLines({
|
||||
layout,
|
||||
width,
|
||||
selectedBucket,
|
||||
bucket,
|
||||
seriesLabel,
|
||||
seriesColor,
|
||||
intl,
|
||||
t,
|
||||
fmt,
|
||||
}: {
|
||||
layout: NonNullable<ReturnType<typeof layoutTimeline>>;
|
||||
width: number;
|
||||
selectedBucket: string | null;
|
||||
bucket: TimelineBucket;
|
||||
seriesLabel: (key: string) => string;
|
||||
seriesColor: (key: string) => string;
|
||||
intl: string;
|
||||
t: ReturnType<typeof useTranslations>["t"];
|
||||
fmt: SeriesValueFormat;
|
||||
}) {
|
||||
const { tCount } = useTranslations();
|
||||
const { scale, seriesTop } = layout;
|
||||
return (
|
||||
<>
|
||||
{layout.series.length > 0 && (
|
||||
<line
|
||||
x1={0}
|
||||
x2={width}
|
||||
y1={seriesTop - 18}
|
||||
y2={seriesTop - 18}
|
||||
stroke="var(--border)"
|
||||
/>
|
||||
)}
|
||||
{layout.series.map((series) => {
|
||||
const color = seriesColor(series.key);
|
||||
return (
|
||||
<g
|
||||
key={series.key}
|
||||
data-series={series.key}
|
||||
data-color={color}
|
||||
color={color}
|
||||
>
|
||||
<line
|
||||
data-slot="timeline-series-swatch"
|
||||
x1={1.5}
|
||||
x2={10.5}
|
||||
y1={series.top + 8}
|
||||
y2={series.top + 8}
|
||||
stroke="currentColor"
|
||||
strokeWidth={3}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<text
|
||||
x={SERIES_TEXT_X}
|
||||
y={series.top + 12}
|
||||
fontSize={13}
|
||||
fontWeight={500}
|
||||
fill="var(--foreground)"
|
||||
>
|
||||
{fitText(
|
||||
seriesLabel(series.key),
|
||||
scale.x0 - 12 - SERIES_TEXT_X,
|
||||
13,
|
||||
)}
|
||||
</text>
|
||||
<text
|
||||
x={SERIES_TEXT_X}
|
||||
y={series.top + 30}
|
||||
fontSize={12}
|
||||
fill="var(--muted-foreground)"
|
||||
>
|
||||
{series.latest === null
|
||||
? t("timeline.values.noneInWindow")
|
||||
: formatSeriesValue(
|
||||
series.key,
|
||||
series.latest,
|
||||
series.unit,
|
||||
fmt,
|
||||
)}
|
||||
</text>
|
||||
{series.path && (
|
||||
<path
|
||||
d={series.path}
|
||||
data-slot="timeline-series-line"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.8}
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
)}
|
||||
{series.bridges && (
|
||||
<path
|
||||
d={series.bridges}
|
||||
data-slot="timeline-series-bridge"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.2}
|
||||
strokeOpacity={0.7}
|
||||
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)" : "currentColor"}
|
||||
stroke="currentColor"
|
||||
strokeWidth={point.thin ? 1.4 : 0}
|
||||
>
|
||||
<title>
|
||||
{`${seriesLabel(series.key)}, ${bucketText(point.t, bucket, intl, t)}: ${formatSeriesValue(series.key, point.mean, series.unit, fmt)}, ${tCount("timeline.values.readings", point.count)}`}
|
||||
</title>
|
||||
</circle>
|
||||
))}
|
||||
{series.points
|
||||
.filter((point) => point.t === selectedBucket)
|
||||
.map((point) => (
|
||||
<circle
|
||||
key={`selected-${point.t}`}
|
||||
data-slot="timeline-series-selected"
|
||||
cx={point.x}
|
||||
cy={point.y}
|
||||
r={5}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function spanTitle(
|
||||
span: PlacedSpan,
|
||||
intl: string,
|
||||
@@ -821,15 +934,16 @@ function SeriesTable({
|
||||
timeline,
|
||||
window,
|
||||
seriesLabel,
|
||||
fmt,
|
||||
intl,
|
||||
}: {
|
||||
timeline: TimelineResponse;
|
||||
window: { from: string; to: string };
|
||||
seriesLabel: (key: string) => string;
|
||||
fmt: SeriesValueFormat;
|
||||
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;
|
||||
@@ -883,6 +997,31 @@ function SeriesTable({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A means line with each value marked by its line's colour: "● 129/82 mmHg
|
||||
* · ● 82,6 kg". The dot is decoration; the text reads the same without it.
|
||||
*/
|
||||
export function MeanPartsLine({
|
||||
parts,
|
||||
seriesColor,
|
||||
}: {
|
||||
parts: readonly MeanPart[];
|
||||
seriesColor: (key: string) => string;
|
||||
}) {
|
||||
return parts.map((part, i) => (
|
||||
<span key={part.key} data-series={part.key}>
|
||||
{i > 0 ? " · " : null}
|
||||
<span
|
||||
data-slot="timeline-series-dot"
|
||||
className="mr-1 inline-block size-2 rounded-full align-middle"
|
||||
style={{ background: seriesColor(part.key) }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{part.text}
|
||||
</span>
|
||||
));
|
||||
}
|
||||
|
||||
/** Inline style for a lane colour dot in HTML (chips, chronicle rails). */
|
||||
export function laneDotStyle(lane: TimelineLaneKey): React.CSSProperties {
|
||||
return { background: LANE_COLOR[lane] };
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
import { Card } from "@/components/ui/card";
|
||||
import type { TimelineItemKind, TimelineResponse } from "@/lib/day/contract";
|
||||
import { resolveIntlLocale } from "@/lib/format-locale";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import {
|
||||
@@ -48,8 +48,12 @@ import {
|
||||
formatMonthYear,
|
||||
} from "./timeline-dates";
|
||||
import { LANE_COLOR } from "./timeline-geometry";
|
||||
import { bucketText, laneDotStyle } from "./timeline-chart";
|
||||
import { chronicleMeans, formatMeans } from "./series-format";
|
||||
import { MeanPartsLine, bucketText, laneDotStyle } from "./timeline-chart";
|
||||
import {
|
||||
chronicleMeans,
|
||||
meanParts,
|
||||
type SeriesValueFormat,
|
||||
} from "./series-format";
|
||||
|
||||
const KIND_ICON: Partial<Record<TimelineItemKind, LucideIcon>> = {
|
||||
lifeEvent: Flag,
|
||||
@@ -125,6 +129,8 @@ export function TimelineChronicle({
|
||||
today,
|
||||
grouping,
|
||||
selected,
|
||||
seriesColor,
|
||||
seriesFormat,
|
||||
onOpenDay,
|
||||
onEditLifeEvent,
|
||||
}: {
|
||||
@@ -132,11 +138,14 @@ export function TimelineChronicle({
|
||||
today: string;
|
||||
grouping: ChronicleGrouping;
|
||||
selected: string | null;
|
||||
/** The colour of each value line, as on the chart (`series-colors.ts`). */
|
||||
seriesColor: (key: string) => string;
|
||||
/** How a value reads (`useSeriesValueFormat`). */
|
||||
seriesFormat: SeriesValueFormat;
|
||||
onOpenDay: (date: string) => void;
|
||||
onEditLifeEvent: ((id: string) => void) | null;
|
||||
}) {
|
||||
const { t, tCount, locale } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const intl = resolveIntlLocale(locale);
|
||||
const [standingOpen, setStandingOpen] = useState(true);
|
||||
const chips = standingChips(timeline);
|
||||
@@ -255,9 +264,9 @@ export function TimelineChronicle({
|
||||
}
|
||||
if (row.type === "header") {
|
||||
const bucketMeans = means.get(row.group);
|
||||
const line = bucketMeans
|
||||
? formatMeans(bucketMeans.values, fmt)
|
||||
: "";
|
||||
const parts = bucketMeans
|
||||
? meanParts(bucketMeans.values, seriesFormat)
|
||||
: [];
|
||||
return (
|
||||
<li
|
||||
key={`h-${row.group}`}
|
||||
@@ -269,15 +278,16 @@ export function TimelineChronicle({
|
||||
<h3 className="text-sm font-semibold">
|
||||
{groupLabel(row.group)}
|
||||
</h3>
|
||||
{bucketMeans && line && (
|
||||
{bucketMeans && parts.length > 0 && (
|
||||
<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}`}
|
||||
? null
|
||||
: `${bucketText(bucketMeans.start, timeline.bucket, intl, t)}: `}
|
||||
<MeanPartsLine parts={parts} seriesColor={seriesColor} />
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
|
||||
@@ -61,6 +61,7 @@ export function addMonths(key: string, months: number): string {
|
||||
|
||||
/** The first day of the bucket `key` falls in. Weeks start on Monday. */
|
||||
export function bucketStart(key: string, bucket: TimelineBucket): string {
|
||||
if (bucket === "day") return key;
|
||||
if (bucket === "month") return startOfMonth(key);
|
||||
if (bucket === "quarter") {
|
||||
const month = Number(key.slice(5, 7));
|
||||
@@ -74,6 +75,7 @@ export function bucketStart(key: string, bucket: TimelineBucket): string {
|
||||
|
||||
/** The first day after the bucket that starts on `start`. */
|
||||
export function bucketAfter(start: string, bucket: TimelineBucket): string {
|
||||
if (bucket === "day") return dayKey(dayNumber(start) + 1);
|
||||
if (bucket === "month") return addMonths(start, 1);
|
||||
if (bucket === "quarter") return addMonths(start, 3);
|
||||
return dayKey(dayNumber(start) + 7);
|
||||
@@ -84,6 +86,7 @@ export function bucketAfter(start: string, bucket: TimelineBucket): string {
|
||||
* buckets differ by one, so a difference counts the buckets between.
|
||||
*/
|
||||
export function bucketIndex(start: string, bucket: TimelineBucket): number {
|
||||
if (bucket === "day") return dayNumber(start);
|
||||
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;
|
||||
@@ -158,6 +161,18 @@ export function formatDayMonthYear(key: string, intlLocale: string): string {
|
||||
});
|
||||
}
|
||||
|
||||
/** "3. Januar 2026". */
|
||||
export function formatDayMonthYearLong(
|
||||
key: string,
|
||||
intlLocale: string,
|
||||
): string {
|
||||
return format(key, intlLocale, {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
/** "Jan". Axis labels. */
|
||||
export function formatMonthShort(key: string, intlLocale: string): string {
|
||||
return format(key, intlLocale, { month: "short" });
|
||||
@@ -193,6 +208,7 @@ export function formatBucket(
|
||||
bucket: TimelineBucket,
|
||||
intlLocale: string,
|
||||
): string | { from: string; to: string } {
|
||||
if (bucket === "day") return formatDayMonthYear(start, intlLocale);
|
||||
if (bucket === "month") return formatMonthYear(start, intlLocale);
|
||||
const last = dayKey(dayNumber(bucketAfter(start, bucket)) - 1);
|
||||
if (bucket === "quarter") {
|
||||
|
||||
@@ -121,7 +121,7 @@ export interface TimeWindow {
|
||||
* fallback before an answer is the current and the previous year. `year` and
|
||||
* `quarter` are 12 and 3 whole months that keep `anchor` (the selected day)
|
||||
* in view, end no later than the month of `today`, and so never open onto an
|
||||
* empty future.
|
||||
* empty future. `range` is exactly the chosen stretch (`serverRange`).
|
||||
*/
|
||||
export function windowFor(
|
||||
zoom: TimelineZoom,
|
||||
@@ -129,7 +129,7 @@ export function windowFor(
|
||||
anchor: string | null,
|
||||
serverRange?: { from: string; to: string } | null,
|
||||
): TimeWindow {
|
||||
if (zoom === "all") {
|
||||
if (zoom === "all" || zoom === "range") {
|
||||
if (serverRange) return { from: serverRange.from, to: serverRange.to };
|
||||
const year = Number(today.slice(0, 4));
|
||||
return { from: `${year - 1}-01-01`, to: `${year}-12-31` };
|
||||
@@ -146,6 +146,23 @@ export function windowFor(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* How a window draws its grid and steps its selection: a chosen range
|
||||
* borrows the fixed zoom nearest its length (years for two years and more,
|
||||
* months from four months, days below), so a range never needs a grid of
|
||||
* its own.
|
||||
*/
|
||||
export function zoomShape(
|
||||
zoom: TimelineZoom,
|
||||
window: TimeWindow,
|
||||
): Exclude<TimelineZoom, "range"> {
|
||||
if (zoom !== "range") return zoom;
|
||||
const days = dayNumber(window.to) - dayNumber(window.from) + 1;
|
||||
if (days >= 2 * 365) return "all";
|
||||
if (days >= 120) return "year";
|
||||
return "quarter";
|
||||
}
|
||||
|
||||
/** A linear map from calendar days to x, over `[from, to + 1 day)`. */
|
||||
export interface Scale {
|
||||
readonly x0: number;
|
||||
@@ -281,7 +298,7 @@ export interface GridFormat {
|
||||
*/
|
||||
export function gridTicks(
|
||||
window: TimeWindow,
|
||||
zoom: TimelineZoom,
|
||||
zoom: Exclude<TimelineZoom, "range">,
|
||||
scale: Scale,
|
||||
fmt: GridFormat,
|
||||
): GridTick[] {
|
||||
@@ -735,6 +752,11 @@ export const MAX_BRIDGED_GAP: Readonly<Record<TimelineBucket, number>> = {
|
||||
quarter: 1,
|
||||
month: 2,
|
||||
week: 4,
|
||||
// Days come only with a chosen range under six weeks. Three missing days
|
||||
// (points four days apart) bridge a weekend away or a forgotten cuff;
|
||||
// a longer silence in a window that short is a break worth seeing, and
|
||||
// the bridge stays well under a third of even a two-week range.
|
||||
day: 3,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -915,7 +937,7 @@ export function dateAtPointer(
|
||||
export function stepSelection(
|
||||
current: string,
|
||||
direction: -1 | 1,
|
||||
zoom: TimelineZoom,
|
||||
zoom: Exclude<TimelineZoom, "range">,
|
||||
today: string,
|
||||
floor: string,
|
||||
): string {
|
||||
@@ -929,20 +951,20 @@ export function stepSelection(
|
||||
}
|
||||
|
||||
/**
|
||||
* What the selection bar names for the month of `selected`: everything that
|
||||
* happens, starts or ends in it, and every closed period that runs through
|
||||
* it. A period that has been open since before the month (a chronic
|
||||
* condition, a standing medication, an allergy) is the backdrop of every
|
||||
* month and is left out, as are documents, which the day lists with their
|
||||
* visit. Lane order kept.
|
||||
* What the selection bar names for the period `[from, to]` (the bucket that
|
||||
* holds the selected day): everything that happens, starts or ends in it,
|
||||
* and every closed period that runs through it. A period that has been open
|
||||
* since before the period (a chronic condition, a standing medication, an
|
||||
* allergy) is the backdrop of every period and is left out, as are
|
||||
* documents, which the day lists with their visit. Lane order kept. The
|
||||
* same rule holds for a week, a month and a quarter.
|
||||
*/
|
||||
export function itemsInMonth(
|
||||
export function itemsInPeriod(
|
||||
lanes: readonly TimelineLane[],
|
||||
selected: string,
|
||||
from: string,
|
||||
to: string,
|
||||
today: string,
|
||||
): Array<{ lane: TimelineLaneKey; item: TimelineItem; through: boolean }> {
|
||||
const from = startOfMonth(selected);
|
||||
const to = endOfMonth(selected);
|
||||
const out: Array<{
|
||||
lane: TimelineLaneKey;
|
||||
item: TimelineItem;
|
||||
@@ -963,3 +985,45 @@ export function itemsInMonth(
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The day to select before anyone has picked one: the latest day inside
|
||||
* `window` (and not after today) that the selection bar has something for,
|
||||
* so the bar opens on the newest bucket with data, never on an empty one.
|
||||
* A lane entry counts on the last day `itemsInPeriod` would name it (an open
|
||||
* period on its start, a closed one on its end); a value line counts on the
|
||||
* first day of its newest bucket. Null when the window holds nothing.
|
||||
*/
|
||||
export function latestDataDate(
|
||||
timeline: {
|
||||
lanes: readonly TimelineLane[];
|
||||
series: readonly TimelineSeries[];
|
||||
},
|
||||
today: string,
|
||||
window: TimeWindow,
|
||||
): string | null {
|
||||
const last = window.to < today ? window.to : today;
|
||||
let best: string | null = null;
|
||||
const consider = (day: string) => {
|
||||
if (day < window.from || day > last) return;
|
||||
if (!best || day > best) best = day;
|
||||
};
|
||||
for (const lane of timeline.lanes) {
|
||||
if (lane.key === "documents") continue;
|
||||
for (const item of lane.items) {
|
||||
if (item.start > last) continue;
|
||||
if (item.open) {
|
||||
consider(item.start);
|
||||
continue;
|
||||
}
|
||||
const end = item.end ?? item.start;
|
||||
// A closed period still running past the window's end is named in
|
||||
// every bucket up to it; its last named day here is the window's end.
|
||||
consider(end > last ? last : end);
|
||||
}
|
||||
}
|
||||
for (const series of timeline.series) {
|
||||
for (const point of series.points) consider(point.t);
|
||||
}
|
||||
return best;
|
||||
}
|
||||
@@ -27,11 +27,14 @@ export function ValueSeriesMenu({
|
||||
options,
|
||||
selected,
|
||||
label,
|
||||
color,
|
||||
onChange,
|
||||
}: {
|
||||
options: readonly string[];
|
||||
selected: readonly string[];
|
||||
label: (key: string) => string;
|
||||
/** A chosen line's colour, shown as a dot beside its name. */
|
||||
color: (key: string) => string;
|
||||
onChange: (next: string[]) => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
@@ -72,6 +75,14 @@ export function ValueSeriesMenu({
|
||||
)
|
||||
}
|
||||
>
|
||||
{/* The dot is the line's colour on the chart; an unchosen
|
||||
value has no line yet, so its place stays empty. */}
|
||||
<span
|
||||
data-slot="timeline-values-dot"
|
||||
className="size-2 shrink-0 rounded-full"
|
||||
style={checked ? { background: color(key) } : undefined}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{label(key)}
|
||||
</DropdownMenuCheckboxItem>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* The timeline's zoom and chosen range in the URL (v1.42):
|
||||
* `?zoom=year`, `?zoom=range&from=2026-03-01&to=2026-04-15`. Back and a
|
||||
* bookmark both land where the person was. Anything that does not read as
|
||||
* a zoom, or a range without two valid ends, falls back to `all` without a
|
||||
* word: a stale bookmark should open the timeline, not an error.
|
||||
*/
|
||||
import {
|
||||
TIMELINE_RANGE_MAX_DAYS,
|
||||
TIMELINE_ZOOMS,
|
||||
type TimelineZoom,
|
||||
} from "@/lib/day/contract";
|
||||
import { isCalendarDateKey } from "@/lib/tz/date-only";
|
||||
|
||||
import { dayKey, dayNumber } from "./timeline-dates";
|
||||
|
||||
export const ZOOM_PARAM = "zoom";
|
||||
export const FROM_PARAM = "from";
|
||||
export const TO_PARAM = "to";
|
||||
|
||||
export interface TimelineRange {
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
export interface TimelineUrlState {
|
||||
/** The zoom the URL names, or null when it names none (or a bad one). */
|
||||
zoom: TimelineZoom | null;
|
||||
/** The chosen range, with `zoom` `range` only. */
|
||||
range: TimelineRange | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A chosen range made honest, softly: never past today, never before the
|
||||
* record's first entry (when known), never inverted, and never longer than
|
||||
* the server answers. Each end moves only as far as it must.
|
||||
*/
|
||||
export function clampRange(
|
||||
range: TimelineRange,
|
||||
today: string,
|
||||
dataFrom: string | null,
|
||||
): TimelineRange {
|
||||
let { from, to } = range;
|
||||
if (to > today) to = today;
|
||||
if (dataFrom && dataFrom <= to && from < dataFrom) from = dataFrom;
|
||||
if (from > to) from = to;
|
||||
const earliest = dayKey(dayNumber(to) - (TIMELINE_RANGE_MAX_DAYS - 1));
|
||||
if (from < earliest) from = earliest;
|
||||
return { from, to };
|
||||
}
|
||||
|
||||
export function parseTimelineUrl(
|
||||
params: Pick<URLSearchParams, "get">,
|
||||
today: string,
|
||||
): TimelineUrlState {
|
||||
const raw = params.get(ZOOM_PARAM);
|
||||
const zoom = (TIMELINE_ZOOMS as readonly string[]).includes(raw ?? "")
|
||||
? (raw as TimelineZoom)
|
||||
: null;
|
||||
if (raw !== null && zoom === null) return { zoom: "all", range: null };
|
||||
if (zoom !== "range") return { zoom, range: null };
|
||||
const from = params.get(FROM_PARAM);
|
||||
const to = params.get(TO_PARAM);
|
||||
if (
|
||||
!from ||
|
||||
!to ||
|
||||
!isCalendarDateKey(from) ||
|
||||
!isCalendarDateKey(to) ||
|
||||
from > to ||
|
||||
from > today
|
||||
) {
|
||||
return { zoom: "all", range: null };
|
||||
}
|
||||
return { zoom, range: clampRange({ from, to }, today, null) };
|
||||
}
|
||||
|
||||
/**
|
||||
* The search string for a zoom (and, for `range`, its ends), keeping every
|
||||
* other parameter (`day`, `add`) as it is.
|
||||
*/
|
||||
export function timelineSearch(
|
||||
current: string,
|
||||
zoom: TimelineZoom,
|
||||
range: TimelineRange | null,
|
||||
): string {
|
||||
const params = new URLSearchParams(current);
|
||||
params.set(ZOOM_PARAM, zoom);
|
||||
if (zoom === "range" && range) {
|
||||
params.set(FROM_PARAM, range.from);
|
||||
params.set(TO_PARAM, range.to);
|
||||
} else {
|
||||
params.delete(FROM_PARAM);
|
||||
params.delete(TO_PARAM);
|
||||
}
|
||||
const search = params.toString();
|
||||
return search ? `?${search}` : "";
|
||||
}
|
||||
@@ -3,7 +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 six neutral value lines below them, all on one
|
||||
* events in lanes, up to six value lines below them, each in its own
|
||||
* colour (`series-colors.ts`), 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
|
||||
@@ -18,7 +19,7 @@
|
||||
*/
|
||||
import { useMemo, useState } from "react";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { Plus } from "lucide-react";
|
||||
import { CalendarRange, Plus } from "lucide-react";
|
||||
|
||||
import { openDay as openDayLayer } from "@/components/day/day-layer-controller";
|
||||
import { parseDayParam } from "@/components/day/day-url";
|
||||
@@ -52,11 +53,20 @@ import {
|
||||
} from "./readiness-model";
|
||||
import { TimelineReadinessSheet } from "./readiness-sheet";
|
||||
import { Segmented } from "./segmented";
|
||||
import { SERIES_FALLBACK_COLOR, assignSeriesColors } from "./series-colors";
|
||||
import { useSeriesValueFormat } from "./use-series-value-format";
|
||||
import { SelectionBar } from "./selection-bar";
|
||||
import { TimelineChart } from "./timeline-chart";
|
||||
import { TimelineChronicle } from "./timeline-chronicle";
|
||||
import { todayKeyIn } from "./timeline-dates";
|
||||
import { LANE_ORDER, windowFor } from "./timeline-geometry";
|
||||
import { dayKey, dayNumber, todayKeyIn } from "./timeline-dates";
|
||||
import { RangeFields } from "./range-fields";
|
||||
import {
|
||||
clampRange,
|
||||
parseTimelineUrl,
|
||||
timelineSearch,
|
||||
type TimelineRange,
|
||||
} from "./timeline-url";
|
||||
import { LANE_ORDER, latestDataDate, windowFor } from "./timeline-geometry";
|
||||
import {
|
||||
LayersMenu,
|
||||
MAX_VALUE_SERIES,
|
||||
@@ -106,24 +116,6 @@ export const DEFAULT_VALUES = [
|
||||
const VALUES_KEY = "healthlog.timeline.values";
|
||||
const HIDDEN_LANES_KEY = "healthlog.timeline.hiddenLanes";
|
||||
|
||||
/** The latest dated entry on or before today, to select on arrival. */
|
||||
function latestEntryDate(
|
||||
lanes: ReadonlyArray<{
|
||||
items: ReadonlyArray<{ start: string; end: string | null }>;
|
||||
}>,
|
||||
today: string,
|
||||
): string | null {
|
||||
let best: string | null = null;
|
||||
for (const lane of lanes) {
|
||||
for (const item of lane.items) {
|
||||
for (const d of [item.start, item.end]) {
|
||||
if (d && d <= today && (!best || d > best)) best = d;
|
||||
}
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
export function TimelineView() {
|
||||
const { t } = useTranslations();
|
||||
const { user } = useAuth();
|
||||
@@ -135,7 +127,12 @@ export function TimelineView() {
|
||||
// Life events are written in one's own record only (owner-only routes).
|
||||
const canAddLifeEvent = !caps.inSharedRecord;
|
||||
|
||||
const [zoom, setZoom] = useState<TimelineZoom>(dayParam ? "quarter" : "all");
|
||||
// The zoom and a chosen range live in the URL (`timeline-url.ts`), so Back
|
||||
// and a bookmark return to them. Without a `zoom`, an incoming `?day=`
|
||||
// opens three months around that day and anything else the whole record.
|
||||
const urlState = parseTimelineUrl(searchParams, today);
|
||||
const zoom: TimelineZoom = urlState.zoom ?? (dayParam ? "quarter" : "all");
|
||||
const range = zoom === "range" ? urlState.range : null;
|
||||
const [grouping, setGrouping] = useState<ChronicleGrouping>("month");
|
||||
// The selection follows `?day=` (Back, Forward, the day panel's previous
|
||||
// and next) and otherwise the last click or key. A pick remembers the
|
||||
@@ -185,13 +182,26 @@ export function TimelineView() {
|
||||
.slice(0, MAX_VALUE_SERIES),
|
||||
[storedValues, valueOptions],
|
||||
);
|
||||
// One colour per chosen line, shared by the chart, the selection bar, the
|
||||
// menu and the phone chronicle; contested colours go by the menu's order.
|
||||
const seriesColors = useMemo(
|
||||
() => assignSeriesColors(values, VALUE_OPTIONS),
|
||||
[values],
|
||||
);
|
||||
const seriesFormat = useSeriesValueFormat();
|
||||
const seriesColor = (key: string) =>
|
||||
seriesColors.get(key) ?? SERIES_FALLBACK_COLOR;
|
||||
const hidden = useMemo(
|
||||
() => new Set(hiddenList as TimelineLaneKey[]),
|
||||
[hiddenList],
|
||||
);
|
||||
|
||||
const requestWindow =
|
||||
zoom === "all" ? null : windowFor(zoom, today, selected ?? dayParam);
|
||||
zoom === "all"
|
||||
? null
|
||||
: zoom === "range"
|
||||
? range
|
||||
: windowFor(zoom, today, selected ?? dayParam);
|
||||
const timeline = useTimeline(
|
||||
zoom,
|
||||
requestWindow?.from ?? null,
|
||||
@@ -223,8 +233,37 @@ export function TimelineView() {
|
||||
}
|
||||
|
||||
const data = timeline.data;
|
||||
// A pick outside a newly chosen range no longer counts as one.
|
||||
const chosen =
|
||||
selected !== null &&
|
||||
(!requestWindow ||
|
||||
(selected >= requestWindow.from && selected <= requestWindow.to))
|
||||
? selected
|
||||
: null;
|
||||
// Before anyone picks a day, the newest bucket the bar has something for,
|
||||
// inside the window that was asked for.
|
||||
const effectiveSelected =
|
||||
selected ?? (data ? latestEntryDate(data.lanes, today) : null) ?? today;
|
||||
chosen ??
|
||||
(data ? latestDataDate(data, today, requestWindow ?? data.range) : null) ??
|
||||
(range ? range.to : today);
|
||||
|
||||
function navigate(nextZoom: TimelineZoom, nextRange: TimelineRange | null) {
|
||||
const search = timelineSearch(window.location.search, nextZoom, nextRange);
|
||||
window.history.pushState(null, "", `${pathname}${search}`);
|
||||
}
|
||||
|
||||
function setZoom(next: TimelineZoom) {
|
||||
if (next !== "range") {
|
||||
navigate(next, null);
|
||||
return;
|
||||
}
|
||||
// A new range starts as what is on screen, up to today.
|
||||
const shown = window_ ?? {
|
||||
from: dayKey(dayNumber(today) - 29),
|
||||
to: today,
|
||||
};
|
||||
navigate("range", clampRange(shown, today, data?.range.dataFrom ?? null));
|
||||
}
|
||||
|
||||
// The shell's day layer owns `?day=` and its history entries; this only
|
||||
// asks it to open the day, and keeps the selection on it.
|
||||
@@ -255,7 +294,7 @@ export function TimelineView() {
|
||||
zoom,
|
||||
today,
|
||||
effectiveSelected,
|
||||
zoom === "all" ? data.range : null,
|
||||
zoom === "all" || zoom === "range" ? data.range : null,
|
||||
)
|
||||
: null;
|
||||
const presentLanes = (data?.lanes ?? [])
|
||||
@@ -294,6 +333,29 @@ export function TimelineView() {
|
||||
</Button>
|
||||
) : null;
|
||||
|
||||
// The chosen range's fields, under the zoom; on a phone with a way back to
|
||||
// the whole record, since the phone has no zoom control to switch with.
|
||||
const rangeControls =
|
||||
zoom === "range" && range ? (
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<RangeFields
|
||||
range={range}
|
||||
today={today}
|
||||
dataFrom={data?.range.dataFrom ?? null}
|
||||
onChange={(next) => navigate("range", next)}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-10 md:hidden"
|
||||
onClick={() => setZoom("all")}
|
||||
data-slot="timeline-range-clear"
|
||||
>
|
||||
{t("timeline.range.clear")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
const layers = (iconOnly: boolean) => (
|
||||
<LayersMenu
|
||||
lanes={presentLanes}
|
||||
@@ -337,6 +399,25 @@ export function TimelineView() {
|
||||
description={t("timeline.loadFailed")}
|
||||
onRetry={() => void timeline.refetch()}
|
||||
/>
|
||||
) : zoom === "range" && (isEmpty || !data || !window_) ? (
|
||||
// A chosen stretch with nothing in it: the fields stay, so the next
|
||||
// choice is one step away.
|
||||
<div className="space-y-4" data-slot="timeline-range-empty">
|
||||
{rangeControls}
|
||||
<EmptyState
|
||||
title={t("timeline.selection.empty")}
|
||||
action={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => setZoom("all")}
|
||||
>
|
||||
{t("timeline.range.clear")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : isEmpty || !data || !window_ ? (
|
||||
<EmptyState
|
||||
title={t("timeline.empty.title")}
|
||||
@@ -363,11 +444,13 @@ export function TimelineView() {
|
||||
options={valueOptions}
|
||||
selected={values}
|
||||
label={seriesLabel}
|
||||
color={seriesColor}
|
||||
onChange={setStoredValues}
|
||||
/>
|
||||
{layers(false)}
|
||||
</div>
|
||||
</div>
|
||||
{rangeControls}
|
||||
<Card className="md:gap-4">
|
||||
<CardContent className="space-y-4">
|
||||
<TimelineChart
|
||||
@@ -378,14 +461,19 @@ export function TimelineView() {
|
||||
selected={effectiveSelected}
|
||||
hiddenLanes={hidden}
|
||||
seriesLabel={seriesLabel}
|
||||
seriesColor={seriesColor}
|
||||
seriesFormat={seriesFormat}
|
||||
onSelect={setSelected}
|
||||
onOpenDay={openDay}
|
||||
/>
|
||||
<SelectionBar
|
||||
timeline={data}
|
||||
selected={effectiveSelected}
|
||||
showHint={chosen === null}
|
||||
today={today}
|
||||
seriesLabel={seriesLabel}
|
||||
seriesColor={seriesColor}
|
||||
seriesFormat={seriesFormat}
|
||||
onOpenDay={openDay}
|
||||
onEditLifeEvent={canAddLifeEvent ? editLifeEvent : undefined}
|
||||
/>
|
||||
@@ -407,8 +495,22 @@ export function TimelineView() {
|
||||
label={t("timeline.chronicle.groupingLabel")}
|
||||
slot="timeline-grouping"
|
||||
/>
|
||||
{layers(true)}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant={zoom === "range" ? "secondary" : "outline"}
|
||||
size="icon"
|
||||
className="size-11"
|
||||
aria-label={t("timeline.range.open")}
|
||||
aria-pressed={zoom === "range"}
|
||||
onClick={() => setZoom(zoom === "range" ? "all" : "range")}
|
||||
data-slot="timeline-range-toggle"
|
||||
>
|
||||
<CalendarRange className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
{layers(true)}
|
||||
</div>
|
||||
</div>
|
||||
{rangeControls}
|
||||
<TimelineChronicle
|
||||
timeline={{
|
||||
...data,
|
||||
@@ -417,6 +519,8 @@ export function TimelineView() {
|
||||
today={today}
|
||||
grouping={grouping}
|
||||
selected={dayParam}
|
||||
seriesColor={seriesColor}
|
||||
seriesFormat={seriesFormat}
|
||||
onOpenDay={openDay}
|
||||
onEditLifeEvent={canAddLifeEvent ? editLifeEvent : null}
|
||||
/>
|
||||
@@ -444,6 +548,7 @@ const LEGEND_MEAN_KEY: Readonly<Record<TimelineBucket, string>> = {
|
||||
quarter: "timeline.legendMeanQuarter",
|
||||
month: "timeline.legendMeanMonth",
|
||||
week: "timeline.legendMeanWeek",
|
||||
day: "timeline.legendMeanDay",
|
||||
};
|
||||
|
||||
function Legend({
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { useDayValueFormat } from "@/components/day/use-day-value-format";
|
||||
import { useFormatters } from "@/lib/i18n/context";
|
||||
|
||||
import { plainSeriesFormat, type SeriesValueFormat } from "./series-format";
|
||||
|
||||
/** The mood score's series key (`MOOD_SERIES_KEY` on the server). */
|
||||
const MOOD_SERIES_KEY = "MOOD";
|
||||
|
||||
/**
|
||||
* The timeline's value format: the day view's formatter for every
|
||||
* measurement series (unit preference, whole numbers for pressure and
|
||||
* pulse, sleep as a duration), one plain decimal for mood, which has no
|
||||
* measurement type behind it.
|
||||
*/
|
||||
export function useSeriesValueFormat(): SeriesValueFormat {
|
||||
const fmt = useFormatters();
|
||||
const { number, unitFor } = useDayValueFormat();
|
||||
return useMemo(() => {
|
||||
const plain = plainSeriesFormat(fmt);
|
||||
return {
|
||||
number: (key, value, unit) =>
|
||||
key === MOOD_SERIES_KEY || unit === null
|
||||
? plain.number(key, value, unit)
|
||||
: number({ type: key, unit }, value),
|
||||
unit: (key, unit) =>
|
||||
key === MOOD_SERIES_KEY || unit === null
|
||||
? plain.unit(key, unit)
|
||||
: unitFor({ type: key, unit }),
|
||||
};
|
||||
}, [fmt, number, unitFor]);
|
||||
}
|
||||
+16
-4
@@ -211,17 +211,29 @@ export type DayNotableKind = (typeof DAY_NOTABLE_KINDS)[number];
|
||||
|
||||
/* ─── Timeline ────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** Zoom levels. `all` bundles hardest: no symptoms, no single intakes. */
|
||||
export const TIMELINE_ZOOMS = ["all", "year", "quarter"] as const;
|
||||
/**
|
||||
* Zoom levels. `all` bundles hardest: no symptoms, no single intakes.
|
||||
* `range` is a stretch the person chose, named by `from` and `to` (both
|
||||
* required, at most {@link TIMELINE_RANGE_MAX_DAYS} days).
|
||||
*/
|
||||
export const TIMELINE_ZOOMS = ["all", "year", "quarter", "range"] 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`.
|
||||
* multi-year `all`, months for `year` and a short `all`, weeks for `quarter`,
|
||||
* and for a `range` by its length, down to single days under six weeks.
|
||||
*/
|
||||
export const TIMELINE_BUCKETS = ["quarter", "month", "week"] as const;
|
||||
export const TIMELINE_BUCKETS = ["quarter", "month", "week", "day"] as const;
|
||||
|
||||
/**
|
||||
* The longest `range` the timeline answers: fifteen years. Longer than any
|
||||
* fixed zoom reads, and the series of such a window come from the rollup
|
||||
* tier in quarters, so the cost stays that of an `all` view.
|
||||
*/
|
||||
export const TIMELINE_RANGE_MAX_DAYS = 15 * 366;
|
||||
|
||||
export type TimelineBucket = (typeof TIMELINE_BUCKETS)[number];
|
||||
|
||||
|
||||
+39
-12
@@ -28,6 +28,7 @@ import {
|
||||
TIMELINE_READINESS_KEYS,
|
||||
TIMELINE_READINESS_STATUSES,
|
||||
TIMELINE_READINESS_VERDICTS,
|
||||
TIMELINE_RANGE_MAX_DAYS,
|
||||
TIMELINE_ZOOMS,
|
||||
} from "./contract";
|
||||
|
||||
@@ -266,17 +267,43 @@ export const dayNotableResponseSchema = z
|
||||
|
||||
/* ─── GET /api/timeline ───────────────────────────────────────────────────── */
|
||||
|
||||
export const timelineQuerySchema = z.object({
|
||||
zoom: z.enum(TIMELINE_ZOOMS).default("all"),
|
||||
from: dateKeySchema.optional(),
|
||||
to: dateKeySchema.optional(),
|
||||
values: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"Comma-separated value series to include, as `MeasurementType` names or `MOOD`, at most 6. More, or an unknown name, answers 422.",
|
||||
),
|
||||
});
|
||||
export const timelineQuerySchema = z
|
||||
.object({
|
||||
zoom: z
|
||||
.enum(TIMELINE_ZOOMS)
|
||||
.default("all")
|
||||
.describe(
|
||||
"`range` is a chosen stretch: it requires `from` and `to`, at most 15 years apart, and its value series average quarters, months, weeks or days by its length.",
|
||||
),
|
||||
from: dateKeySchema.optional(),
|
||||
to: dateKeySchema.optional(),
|
||||
values: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"Comma-separated value series to include, as `MeasurementType` names or `MOOD`, at most 6. More, or an unknown name, answers 422.",
|
||||
),
|
||||
})
|
||||
.refine((q) => q.zoom !== "range" || (q.from && q.to), {
|
||||
message: "`range` needs both `from` and `to`",
|
||||
path: ["from"],
|
||||
})
|
||||
.refine(
|
||||
(q) =>
|
||||
q.zoom !== "range" ||
|
||||
!q.from ||
|
||||
!q.to ||
|
||||
// Both ends as the same instant of their calendar day, so the
|
||||
// difference counts calendar days whatever the process zone.
|
||||
(dateOnlyAtNoonUtc(q.to).getTime() -
|
||||
dateOnlyAtNoonUtc(q.from).getTime()) /
|
||||
86_400_000 <
|
||||
TIMELINE_RANGE_MAX_DAYS,
|
||||
{
|
||||
message: `A range spans at most ${TIMELINE_RANGE_MAX_DAYS} days`,
|
||||
path: ["to"],
|
||||
},
|
||||
);
|
||||
|
||||
const timelineItem = z
|
||||
.object({
|
||||
@@ -357,7 +384,7 @@ export const timelineResponseSchema = z
|
||||
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`.",
|
||||
"The span every series point averages: `quarter` for a multi-year `all`, `month` for `year` and an `all` under two years, `week` for `quarter`, and for a `range` by its length: `quarter` from two years, `month` from 120 days, `week` from 42 days, `day` below.",
|
||||
),
|
||||
series: z.array(timelineSeries),
|
||||
notable: z.array(
|
||||
|
||||
@@ -3,7 +3,12 @@
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { overlaps, parseSeriesKeys, timelineBucket } from "../load-timeline";
|
||||
import {
|
||||
overlaps,
|
||||
parseSeriesKeys,
|
||||
rangeBucket,
|
||||
timelineBucket,
|
||||
} from "../load-timeline";
|
||||
import { coveredWeeks, READINESS_WEEKS } from "../readiness";
|
||||
import { bucketKey, foldDays, foldMonths } from "../series";
|
||||
|
||||
@@ -72,6 +77,24 @@ describe("buckets", () => {
|
||||
expect(timelineBucket("all", "2025-01-01", "2026-03-31")).toBe("month");
|
||||
expect(timelineBucket("all", "2019-01-01", "2026-03-31")).toBe("quarter");
|
||||
});
|
||||
|
||||
it("picks a chosen range's bucket by its length, down to days", () => {
|
||||
// Two years and more: quarters.
|
||||
expect(rangeBucket("2024-03-31", "2026-03-30")).toBe("quarter");
|
||||
expect(rangeBucket("2024-04-01", "2026-03-30")).toBe("month");
|
||||
// Four months (120 days) and more: months.
|
||||
expect(rangeBucket("2026-01-01", "2026-04-30")).toBe("month");
|
||||
expect(rangeBucket("2026-01-02", "2026-04-30")).toBe("week");
|
||||
// Six weeks and more: weeks.
|
||||
expect(rangeBucket("2026-03-01", "2026-04-11")).toBe("week");
|
||||
expect(rangeBucket("2026-03-02", "2026-04-11")).toBe("day");
|
||||
expect(rangeBucket("2026-03-01", "2026-03-01")).toBe("day");
|
||||
expect(timelineBucket("range", "2026-03-01", "2026-03-14")).toBe("day");
|
||||
});
|
||||
|
||||
it("names a day bucket by the day itself", () => {
|
||||
expect(bucketKey("2026-03-05", "day")).toBe("2026-03-05");
|
||||
});
|
||||
});
|
||||
|
||||
describe("window", () => {
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
* read only the window.
|
||||
*
|
||||
* Windows by zoom, when the request names none: `quarter` the last 91 days,
|
||||
* `year` the last 365, `all` from the earliest date the record carries. The
|
||||
* `year` the last 365, `all` from the earliest date the record carries. A
|
||||
* `range` always names its own (the query schema requires both ends). The
|
||||
* notable days of an `all` window are computed over its last three years at
|
||||
* most: an extreme is "the highest for at least three months", and older
|
||||
* history is what the comparison reads, not what it marks.
|
||||
@@ -48,6 +49,29 @@ export const DEFAULT_TIMELINE_SERIES: readonly MeasurementType[] = [
|
||||
/** An `all` window longer than this many days averages quarters. */
|
||||
const QUARTER_BUCKET_MIN_DAYS = 2 * 365;
|
||||
|
||||
/**
|
||||
* The bucket for a chosen range, by its length: about the point count the
|
||||
* fixed zooms draw (a dozen to a few dozen), never one point per reading.
|
||||
* Two years and more average quarters (eight and up), four months and more
|
||||
* months (four to twenty-four), six weeks and more weeks (six to
|
||||
* seventeen), and anything shorter single days (at most six weeks of them).
|
||||
*/
|
||||
export const RANGE_BUCKET_MIN_DAYS: Readonly<
|
||||
Record<Exclude<TimelineBucket, "day">, number>
|
||||
> = {
|
||||
quarter: 2 * 365,
|
||||
month: 120,
|
||||
week: 42,
|
||||
};
|
||||
|
||||
export function rangeBucket(from: string, to: string): TimelineBucket {
|
||||
const days = daysBetweenDateKeys(from, to) + 1;
|
||||
if (days >= RANGE_BUCKET_MIN_DAYS.quarter) return "quarter";
|
||||
if (days >= RANGE_BUCKET_MIN_DAYS.month) return "month";
|
||||
if (days >= RANGE_BUCKET_MIN_DAYS.week) return "week";
|
||||
return "day";
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -61,13 +85,16 @@ export function timelineBucket(
|
||||
from: string,
|
||||
to: string,
|
||||
): TimelineBucket {
|
||||
if (zoom === "range") return rangeBucket(from, to);
|
||||
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>> = {
|
||||
const ZOOM_DAYS: Readonly<
|
||||
Record<Exclude<TimelineZoom, "all" | "range">, number>
|
||||
> = {
|
||||
year: 365,
|
||||
quarter: 91,
|
||||
};
|
||||
@@ -144,7 +171,7 @@ export async function loadTimeline(args: {
|
||||
const to = query.to ?? today;
|
||||
const from =
|
||||
query.from ??
|
||||
(query.zoom === "all"
|
||||
(query.zoom === "all" || query.zoom === "range"
|
||||
? dataFrom !== null && dataFrom < to
|
||||
? dataFrom
|
||||
: shiftDateKey(to, -364)
|
||||
|
||||
@@ -61,6 +61,7 @@ export const MOOD_SERIES_KEY = "MOOD";
|
||||
|
||||
/** The bucket a local day belongs to, named by its first day. */
|
||||
export function bucketKey(day: string, bucket: TimelineBucket): string {
|
||||
if (bucket === "day") return day;
|
||||
if (bucket === "month") return `${day.slice(0, 7)}-01`;
|
||||
if (bucket === "quarter") {
|
||||
const month = Number(day.slice(5, 7));
|
||||
@@ -110,7 +111,7 @@ export interface RolledMonth {
|
||||
*/
|
||||
export function foldMonths(
|
||||
months: readonly RolledMonth[],
|
||||
bucket: Exclude<TimelineBucket, "week">,
|
||||
bucket: Exclude<TimelineBucket, "week" | "day">,
|
||||
): SeriesPoint[] {
|
||||
const buckets = new Map<
|
||||
string,
|
||||
@@ -230,7 +231,13 @@ async function measurementSeries(args: {
|
||||
const start = startOfLocalDayKey(args.from, args.tz);
|
||||
const end = startOfLocalDayKey(shiftDateKey(args.to, 1), args.tz);
|
||||
let points: SeriesPoint[];
|
||||
if (args.bucket !== "week" && readsRollups(args.from, args.to)) {
|
||||
// Weeks and days never read the rollups: their windows are short (a
|
||||
// `day` range is under six weeks), and the rollups cut at UTC midnight.
|
||||
if (
|
||||
args.bucket !== "week" &&
|
||||
args.bucket !== "day" &&
|
||||
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.
|
||||
|
||||
@@ -932,6 +932,43 @@ describe("timeline", () => {
|
||||
["2026-03-02", 1],
|
||||
]);
|
||||
|
||||
// A chosen range under six weeks averages single days; the two
|
||||
// readings of 10 January stay one day.
|
||||
const daily = await json<import("@/lib/day/contract").TimelineResponse>(
|
||||
await call(
|
||||
GET as Handler,
|
||||
"GET",
|
||||
"/api/timeline?zoom=range&from=2026-01-05&to=2026-01-25&values=BLOOD_PRESSURE_SYS",
|
||||
),
|
||||
);
|
||||
expect(daily.zoom).toBe("range");
|
||||
expect(daily.bucket).toBe("day");
|
||||
expect(daily.range).toMatchObject({ from: "2026-01-05", to: "2026-01-25" });
|
||||
expect(daily.series[0].points).toEqual([
|
||||
{ t: "2026-01-10", mean: 125, count: 2 },
|
||||
{ t: "2026-01-20", mean: 135, count: 1 },
|
||||
]);
|
||||
// Four months and more: months, as the fixed zooms draw them.
|
||||
const monthly = await json<import("@/lib/day/contract").TimelineResponse>(
|
||||
await call(
|
||||
GET as Handler,
|
||||
"GET",
|
||||
"/api/timeline?zoom=range&from=2025-12-01&to=2026-03-31&values=BLOOD_PRESSURE_SYS",
|
||||
),
|
||||
);
|
||||
expect(monthly.bucket).toBe("month");
|
||||
// A range needs both ends, and spans fifteen years at most.
|
||||
for (const query of [
|
||||
"zoom=range",
|
||||
"zoom=range&from=2026-01-01",
|
||||
"zoom=range&from=2000-01-01&to=2026-01-01",
|
||||
]) {
|
||||
expect(
|
||||
(await call(GET as Handler, "GET", `/api/timeline?${query}`)).status,
|
||||
query,
|
||||
).toBe(422);
|
||||
}
|
||||
|
||||
const six =
|
||||
"BLOOD_PRESSURE_SYS,BLOOD_PRESSURE_DIA,WEIGHT,PULSE,BODY_FAT,MOOD";
|
||||
expect(
|
||||
|
||||
Reference in new issue
Block a user