diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 54127a1dd..b1e6eeb4b 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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: diff --git a/e2e/coach-layout.spec.ts b/e2e/coach-layout.spec.ts index 67b455d96..b3b27ed17 100644 --- a/e2e/coach-layout.spec.ts +++ b/e2e/coach-layout.spec.ts @@ -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"); diff --git a/e2e/timeline.spec.ts b/e2e/timeline.spec.ts index 8fcdc3bb9..857d6e83f 100644 --- a/e2e/timeline.spec.ts +++ b/e2e/timeline.spec.ts @@ -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( diff --git a/e2e/utils/mock-timeline.ts b/e2e/utils/mock-timeline.ts index f2f2542ce..f43a4f72a 100644 --- a/e2e/utils/mock-timeline.ts +++ b/e2e/utils/mock-timeline.ts @@ -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) => diff --git a/messages/de.json b/messages/de.json index 595f87d0b..018ff5319 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index 452504d86..b965c2afe 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index 4eeead65f..aa5917aa9 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index f7e73f1f6..50a5b0c48 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index 8f609b89a..4db7ce4b9 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/ko.json b/messages/ko.json index d5d5b337d..95e1b73b4 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -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": "삶의 사건", diff --git a/messages/pl.json b/messages/pl.json index 395396400..3cfb2d900 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/src/components/day/use-day-value-format.ts b/src/components/day/use-day-value-format.ts index 70dca643c..13f03251d 100644 --- a/src/components/day/use-day-value-format.ts +++ b/src/components/day/use-day-value-format.ts @@ -134,5 +134,5 @@ export function useDayValueFormat() { [labelFor, number, unitFor], ); - return { labelFor, formatTile }; + return { labelFor, formatTile, number, unitFor }; } diff --git a/src/components/insights/coach-panel/conversations-panel.tsx b/src/components/insights/coach-panel/conversations-panel.tsx index ec10a47c5..c8bb33cd3 100644 --- a/src/components/insights/coach-panel/conversations-panel.tsx +++ b/src/components/insights/coach-panel/conversations-panel.tsx @@ -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), )} > + + + + + + {t("insights.coach.newChat")} + + {inSheet ? ( {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. */} - - - - - - {t("insights.coach.newChat")} - - ); diff --git a/src/components/insights/coach-panel/history-rail.tsx b/src/components/insights/coach-panel/history-rail.tsx index 1590b56cc..3ecaa120f 100644 --- a/src/components/insights/coach-panel/history-rail.tsx +++ b/src/components/insights/coach-panel/history-rail.tsx @@ -149,11 +149,7 @@ export function HistoryRail({
{isLoading && visible.length === 0 ? (

= { WAIST_TO_HEIGHT: Scale, }; -export const MEASUREMENT_TYPE_COLORS: Record = { - 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", diff --git a/src/components/measurements/measurement-type-colors.ts b/src/components/measurements/measurement-type-colors.ts new file mode 100644 index 000000000..5f3386b6c --- /dev/null +++ b/src/components/measurements/measurement-type-colors.ts @@ -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 = { + 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", +}; diff --git a/src/components/timeline/__tests__/range-fields.test.tsx b/src/components/timeline/__tests__/range-fields.test.tsx new file mode 100644 index 000000000..7c12cdbd7 --- /dev/null +++ b/src/components/timeline/__tests__/range-fields.test.tsx @@ -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 ( + + ); + }, +})); + +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( + + + , + ); + const [from, to] = fields; + return { html, from, to }; +} + +describe("", () => { + 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(); + }); +}); diff --git a/src/components/timeline/__tests__/series-colors.test.ts b/src/components/timeline/__tests__/series-colors.test.ts new file mode 100644 index 000000000..d14355803 --- /dev/null +++ b/src/components/timeline/__tests__/series-colors.test.ts @@ -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)"); + }); +}); diff --git a/src/components/timeline/__tests__/timeline-components.test.tsx b/src/components/timeline/__tests__/timeline-components.test.tsx index 3aef7cb9a..5a1ffdfb8 100644 --- a/src/components/timeline/__tests__/timeline-components.test.tsx +++ b/src/components/timeline/__tests__/timeline-components.test.tsx @@ -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( - {node}, + + {node} + , ); } +/** 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["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(); @@ -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( - undefined} - onEditLifeEvent={null} + ( + 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( - undefined} - onEditLifeEvent={null} + ( + 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( - undefined} - onEditLifeEvent={null} + ( + 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 = { }; const seriesLabel = (key: string) => LABELS[key] ?? key; -describe("selection bar", () => { - const bar = (selected: string) => - render( - undefined} - />, - ); +/** The selection bar with the real value format. */ +const bar = (selected: string, timeline = fullTimeline(), showHint = false) => + render( + ( + 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( - undefined} - onOpenDay={() => undefined} + ( + 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( + 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( + ( + + key} + fmt={format} + /> + + )} + />, + ); + const group = (key: string) => { + const start = html.indexOf(`data-series="${key}"`); + const end = html.indexOf("", start); + return html.slice(start, end); + }; + const colours = new Set(); + 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(/]*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( + , + ); + for (const key of ["BLOOD_PRESSURE_SYS", "WEIGHT"]) { + const part = html.slice(html.indexOf(`data-series="${key}"`)); + expect(part.slice(0, part.indexOf(""))).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", () => { diff --git a/src/components/timeline/__tests__/timeline-geometry.test.ts b/src/components/timeline/__tests__/timeline-geometry.test.ts index 6bd8cc4cf..d011716ae 100644 --- a/src/components/timeline/__tests__/timeline-geometry.test.ts +++ b/src/components/timeline/__tests__/timeline-geometry.test.ts @@ -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); + }); }); diff --git a/src/components/timeline/__tests__/timeline-url.test.ts b/src/components/timeline/__tests__/timeline-url.test.ts new file mode 100644 index 000000000..14fb1709a --- /dev/null +++ b/src/components/timeline/__tests__/timeline-url.test.ts @@ -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"); + }); +}); diff --git a/src/components/timeline/range-fields.tsx b/src/components/timeline/range-fields.tsx new file mode 100644 index 000000000..dd2964686 --- /dev/null +++ b/src/components/timeline/range-fields.tsx @@ -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 ( +

+
+ {/* The field's own input carries the name; this is its caption. */} + + { + if (from) commit({ from, to: range.to }); + }} + data-testid="timeline-range-from" + /> +
+
+ {/* The field's own input carries the name; this is its caption. */} + + { + if (to) commit({ from: range.from, to }); + }} + data-testid="timeline-range-to" + /> +
+
+ ); +} diff --git a/src/components/timeline/selection-bar.tsx b/src/components/timeline/selection-bar.tsx index 901e6032e..de5bbeaf0 100644 --- a/src/components/timeline/selection-bar.tsx +++ b/src/components/timeline/selection-bar.tsx @@ -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 (
- - {formatMonthYear(selected, intl)} + {showHint && ( + // One line, worded for the input in hand: a pointer clicks, a + // finger taps. +

+ + {t("timeline.selection.hintClick")} + + + {t("timeline.selection.hintTap")} + +

+ )} + + {bucketTitle(periodFrom, bucket, intl, t)} - {entries.length === 0 && ( - + {entries.length === 0 && !hasValue && ( + {t("timeline.selection.empty")} )} {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} + tCount("timeline.values.readings", n), + })} + seriesColor={seriesColor} + /> + {meanAfter} )}
@@ -262,15 +279,16 @@ export function TimelineChart({ interface ChartBodyProps { layout: NonNullable>; window: TimeWindow; - zoom: TimelineZoom; + zoom: Exclude; today: string; selected: string | null; dataFrom: string | null; bucket: TimelineBucket; seriesLabel: (key: string) => string; + seriesColor: (key: string) => string; intl: string; t: ReturnType["t"]; - fmt: ReturnType; + 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 ( @@ -434,81 +457,20 @@ function ChartBody({ ); })} - {/* Value lines: neutral, one scale each, never coloured by meaning. */} - {layout.series.length > 0 && ( - - )} - {layout.series.map((series) => ( - - - {fitText(seriesLabel(series.key), scale.x0 - 12, 13)} - - - {series.latest === null - ? t("timeline.values.noneInWindow") - : formatSeriesValue(series.key, series.latest, series.unit, fmt)} - - {series.path && ( - - )} - {series.bridges && ( - - )} - {series.points.map((point) => ( - - - {`${bucketText(point.t, bucket, intl, t)}: ${formatSeriesValue(series.key, point.mean, series.unit, fmt)}, ${tCount("timeline.values.readings", point.count)}`} - - - ))} - - ))} + {/* 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. */} + {/* 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>; + width: number; + selectedBucket: string | null; + bucket: TimelineBucket; + seriesLabel: (key: string) => string; + seriesColor: (key: string) => string; + intl: string; + t: ReturnType["t"]; + fmt: SeriesValueFormat; +}) { + const { tCount } = useTranslations(); + const { scale, seriesTop } = layout; + return ( + <> + {layout.series.length > 0 && ( + + )} + {layout.series.map((series) => { + const color = seriesColor(series.key); + return ( + + + + {fitText( + seriesLabel(series.key), + scale.x0 - 12 - SERIES_TEXT_X, + 13, + )} + + + {series.latest === null + ? t("timeline.values.noneInWindow") + : formatSeriesValue( + series.key, + series.latest, + series.unit, + fmt, + )} + + {series.path && ( + + )} + {series.bridges && ( + + )} + {series.points.map((point) => ( + + + {`${seriesLabel(series.key)}, ${bucketText(point.t, bucket, intl, t)}: ${formatSeriesValue(series.key, point.mean, series.unit, fmt)}, ${tCount("timeline.values.readings", point.count)}`} + + + ))} + {series.points + .filter((point) => point.t === selectedBucket) + .map((point) => ( + + ))} + + ); + })} + + ); +} + 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) => ( + + {i > 0 ? " · " : null} + + )); +} + /** Inline style for a lane colour dot in HTML (chips, chronicle rails). */ export function laneDotStyle(lane: TimelineLaneKey): React.CSSProperties { return { background: LANE_COLOR[lane] }; diff --git a/src/components/timeline/timeline-chronicle.tsx b/src/components/timeline/timeline-chronicle.tsx index 42ed5aab4..9e453240b 100644 --- a/src/components/timeline/timeline-chronicle.tsx +++ b/src/components/timeline/timeline-chronicle.tsx @@ -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> = { 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 (
  • {groupLabel(row.group)} - {bucketMeans && line && ( + {bucketMeans && parts.length > 0 && ( {timeline.bucket === "month" - ? line - : `${bucketText(bucketMeans.start, timeline.bucket, intl, t)}: ${line}`} + ? null + : `${bucketText(bucketMeans.start, timeline.bucket, intl, t)}: `} + )}
  • diff --git a/src/components/timeline/timeline-dates.ts b/src/components/timeline/timeline-dates.ts index 9869875c8..703583aee 100644 --- a/src/components/timeline/timeline-dates.ts +++ b/src/components/timeline/timeline-dates.ts @@ -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") { diff --git a/src/components/timeline/timeline-geometry.ts b/src/components/timeline/timeline-geometry.ts index 3195e221d..b3407c175 100644 --- a/src/components/timeline/timeline-geometry.ts +++ b/src/components/timeline/timeline-geometry.ts @@ -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 { + 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, scale: Scale, fmt: GridFormat, ): GridTick[] { @@ -735,6 +752,11 @@ export const MAX_BRIDGED_GAP: Readonly> = { 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, 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; +} diff --git a/src/components/timeline/timeline-menus.tsx b/src/components/timeline/timeline-menus.tsx index c4878a0d4..b94cdd571 100644 --- a/src/components/timeline/timeline-menus.tsx +++ b/src/components/timeline/timeline-menus.tsx @@ -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. */} +
    + {rangeControls} @@ -407,8 +495,22 @@ export function TimelineView() { label={t("timeline.chronicle.groupingLabel")} slot="timeline-grouping" /> - {layers(true)} +
    + + {layers(true)} +
    + {rangeControls} @@ -444,6 +548,7 @@ const LEGEND_MEAN_KEY: Readonly> = { quarter: "timeline.legendMeanQuarter", month: "timeline.legendMeanMonth", week: "timeline.legendMeanWeek", + day: "timeline.legendMeanDay", }; function Legend({ diff --git a/src/components/timeline/use-series-value-format.ts b/src/components/timeline/use-series-value-format.ts new file mode 100644 index 000000000..c96e35f50 --- /dev/null +++ b/src/components/timeline/use-series-value-format.ts @@ -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]); +} diff --git a/src/lib/day/contract.ts b/src/lib/day/contract.ts index ffb3ee430..505bcb7aa 100644 --- a/src/lib/day/contract.ts +++ b/src/lib/day/contract.ts @@ -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]; diff --git a/src/lib/day/wire-schemas.ts b/src/lib/day/wire-schemas.ts index 0bbc1892f..a3dbd24eb 100644 --- a/src/lib/day/wire-schemas.ts +++ b/src/lib/day/wire-schemas.ts @@ -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( diff --git a/src/lib/timeline/__tests__/series.test.ts b/src/lib/timeline/__tests__/series.test.ts index b9aa6d4c4..f3b985d54 100644 --- a/src/lib/timeline/__tests__/series.test.ts +++ b/src/lib/timeline/__tests__/series.test.ts @@ -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", () => { diff --git a/src/lib/timeline/load-timeline.ts b/src/lib/timeline/load-timeline.ts index bc97b723d..a24ef1a97 100644 --- a/src/lib/timeline/load-timeline.ts +++ b/src/lib/timeline/load-timeline.ts @@ -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, 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, number>> = { +const ZOOM_DAYS: Readonly< + Record, 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) diff --git a/src/lib/timeline/series.ts b/src/lib/timeline/series.ts index 9dd6a56b7..4a6be0a76 100644 --- a/src/lib/timeline/series.ts +++ b/src/lib/timeline/series.ts @@ -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, + bucket: Exclude, ): 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. diff --git a/tests/integration/day-view.test.ts b/tests/integration/day-view.test.ts index 4bbb9a28f..bac08bd06 100644 --- a/tests/integration/day-view.test.ts +++ b/tests/integration/day-view.test.ts @@ -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( + 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( + 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(