Merge the Coach header, timeline colours, selection bar and range into release/v1.42.0

This commit is contained in:
Marc-André Bombeck committed 2026-10-09 10:18:23 +02:00
commit c40b21de3d
39 files changed
+2185 -543

No files matched your search

+9 -1
View File
@@ -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
View File
@@ -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");
+130
View File
@@ -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(
+30 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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",
+1 -1
View File
@@ -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");
});
});
+78
View File
@@ -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>
);
}
+112 -36
View File
@@ -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),
});
}
+70
View File
@@ -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;
}
+81 -61
View File
@@ -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;
}
+225 -86
View File
@@ -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] };
+20 -10
View File
@@ -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>
+16
View File
@@ -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") {
+78 -14
View File
@@ -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>
);
+97
View File
@@ -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}` : "";
}
+132 -27
View File
@@ -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
View File
@@ -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
View File
@@ -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(
+24 -1
View File
@@ -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", () => {
+30 -3
View File
@@ -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)
+9 -2
View File
@@ -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.
+37
View File
@@ -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(