From df67e285de20919aec7dc0717d421ec755550156 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 9 Oct 2026 16:25:48 +0200 Subject: [PATCH] fix(timeline): names that fit, links to each value's page, a calmer selection bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Labels are placed without collisions: a name keeps a full gap before the next mark on its line, is cut with an ellipsis when only part of it fits, and is left out when not even a few characters do. A name above an open bar ends before the bar's arrow head. All names are drawn after the today and selection lines, on a halo of the card colour, so a line passes behind a name instead of through it. A value line's name wraps onto a second line instead of reading "Blood pressure sy…", in every bundle, and it leads to the metric's page, as do the value tiles in the day view. Both read one map from a kind of value to its page (metricPageHref); a kind without a page stays plain text. The links carry a 44 px target, a hover and a focus ring. A life event's category is no longer run on after its title ("Parental leave Family"): the chart names the event alone, the selection bar shows the category as a small chip inside the entry, and the phone chronicle puts it on the muted line below. The selection bar shows each mean as name and value; the readings behind it ("mean of 19 readings") are a hover title and a screen-reader detail. The lone "Open " link at its end is gone; a day opens from its chips, Enter on the chart or a double click. In the day view an event's time keeps to one line in every locale. --- .../day/__tests__/day-view.test.tsx | 20 +++ src/components/day/day-sections.tsx | 52 +++++-- .../__tests__/timeline-components.test.tsx | 67 +++++++-- .../__tests__/timeline-geometry.test.ts | 114 ++++++++++++++- src/components/timeline/item-words.ts | 11 +- src/components/timeline/selection-bar.tsx | 33 +++-- src/components/timeline/series-format.ts | 12 +- src/components/timeline/timeline-chart.tsx | 121 +++++++++++++--- .../timeline/timeline-chronicle.tsx | 8 +- src/components/timeline/timeline-geometry.ts | 133 +++++++++++++++--- .../insights/__tests__/metric-page.test.ts | 25 ++++ src/lib/insights/metric-page.ts | 24 ++++ 12 files changed, 538 insertions(+), 82 deletions(-) create mode 100644 src/lib/insights/__tests__/metric-page.test.ts create mode 100644 src/lib/insights/metric-page.ts diff --git a/src/components/day/__tests__/day-view.test.tsx b/src/components/day/__tests__/day-view.test.tsx index f4823a790..442b35fbd 100644 --- a/src/components/day/__tests__/day-view.test.tsx +++ b/src/components/day/__tests__/day-view.test.tsx @@ -266,6 +266,26 @@ describe("", () => { expect(weightTile.split("")[0]).not.toContain("day-number-line"); }); + it("leads each value tile with a page of its own to that page", async () => { + const html = await render(); + expect(html).toMatch( + /data-type="WEIGHT"[^>]*>\s*]*href="\/insights\/weight"[^>]*data-slot="day-value-link"|data-type="WEIGHT"[^>]*>]*href="\/insights\/weight"/, + ); + expect(html).toMatch( + /data-type="BLOOD_PRESSURE"[^>]*>]*href="\/insights\/blood-pressure"/, + ); + // A 44 px target and a visible focus ring, like every other link here. + expect(html).toMatch(/data-slot="day-value-link"[^>]*min-h-11/); + expect(html).toMatch(/data-slot="day-value-link"[^>]*focus-visible:ring/); + }); + + it("keeps an event's time on one line", async () => { + const html = await render(); + const times = html.match(/data-slot="day-event-time"[^>]*>/g) ?? []; + expect(times.length).toBeGreaterThan(0); + for (const tag of times) expect(tag).toContain("whitespace-nowrap"); + }); + it("lists what happened, each entry linking to its source", async () => { const html = await render(); expect(html).toContain("What happened"); diff --git a/src/components/day/day-sections.tsx b/src/components/day/day-sections.tsx index 943da6d86..6f9993464 100644 --- a/src/components/day/day-sections.tsx +++ b/src/components/day/day-sections.tsx @@ -41,6 +41,7 @@ import { } from "@/components/timeline/label-keys"; import { resolveIntlLocale } from "@/lib/format-locale"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import { metricPageHref } from "@/lib/insights/metric-page"; import { MOOD_LABEL_KEYS } from "@/lib/mood/labels"; import { cn } from "@/lib/utils"; @@ -439,16 +440,11 @@ function ValueTile({ const shown = formatTile(tile); // The line plots the first row: systolic for a blood pressure. const lead = tile.values[0]; - return ( -
  • + // Like a score tile, a value tile leads to its metric's page; a kind + // without a page of its own stays a plain tile. + const href = metricPageHref(tile.key); + const body = ( + <> {shown.label} @@ -480,6 +476,33 @@ function ValueTile({ ) : null} ) : null} + + ); + const frame = cn( + "bg-muted/60 flex min-h-11 min-w-0 flex-col rounded-lg px-3 py-2.5", + selected && "ring-foreground ring-[1.5px] ring-inset", + ); + return ( +
  • + {href ? ( + + {body} + + ) : ( +
    {body}
    + )}
  • ); } @@ -585,11 +608,16 @@ export function DayEvents({ events }: { events: readonly DayEvent[] }) { data-slot="day-event" data-kind={event.kind} className={cn( - "relative grid grid-cols-[2.75rem_1.25rem_minmax(0,1fr)_1rem] items-start gap-x-2.5 py-2.5", + // The time column fits "08:10 AM" and "오전 08:10" on one + // line; the time itself never wraps. + "relative grid grid-cols-[3.75rem_1.25rem_minmax(0,1fr)_1rem] items-start gap-x-2.5 py-2.5", event.href && "hover:bg-muted/40 rounded-md", )} > - + {event.at ? fmt.time(event.at) : ""} ); describe("selection bar", () => { - it("lists the month's entries and offers the selected day", () => { + it("lists the month's entries, and no separate link to open the day", () => { const html = bar("2026-01-03"); expect(html).toContain("Januar 2026"); expect(html).toContain("Erkältung, 31. Dez. bis 8. Jan."); @@ -320,8 +325,10 @@ describe("selection bar", () => { expect(html).toContain("Vitamin D (Winter), 1. Nov. bis 31. März"); expect(html).not.toContain("laufend"); expect(html).not.toContain("Bluthochdruck"); - expect(html).toContain("3. Jan. öffnen"); - expect(html).toContain('data-date="2026-01-03"'); + // The day opens from its chips and the chart; the lone "Open 3 Jan." + // link at the end of the bar is gone. + expect(html).not.toContain("3. Jan. öffnen"); + expect(html).not.toContain('data-slot="timeline-open-day"'); }); it("names a pause as a pause", () => { @@ -329,12 +336,25 @@ describe("selection bar", () => { expect(html).toContain("Ramipril pausiert, 1. Juni bis 11. Juni"); }); - it("names the bucket's means with the readings behind each", () => { - expect(text(bar("2026-01-03"))).toContain( - "Ø Blutdruck 129/82 mmHg (Schnitt aus 4 Messungen), Gewicht 82,6 kg (Schnitt aus 3 Messungen)", + it("names the bucket's means, the readings behind each as a hover and for a screen reader", () => { + const html = bar("2026-01-03"); + // What the line shows: the name and the value, nothing more. + const visible = text( + html.replace( + /]*data-slot="timeline-mean-detail"[^>]*>[^<]*<\/span>/g, + "", + ), + ); + expect(visible).toContain("Ø Blutdruck 129/82 mmHg, Gewicht 82,6 kg"); + expect(visible).not.toContain("Schnitt aus"); + // The readings: a title on the value, and the same words read aloud. + expect(html).toContain('title="Schnitt aus 4 Messungen"'); + expect(html).toContain('title="Schnitt aus 3 Messungen"'); + expect(text(html)).toContain( + "Ø Blutdruck 129/82 mmHg, Schnitt aus 4 Messungen, Gewicht 82,6 kg, Schnitt aus 3 Messungen", ); expect(text(bar("2026-07-15"))).toContain( - "Ø Blutdruck sys. 131 mmHg (1 Messung), Blutdruck dia. kein Wert, Gewicht kein Wert", + "Ø Blutdruck sys. 131 mmHg, 1 Messung, Blutdruck dia. kein Wert, Gewicht kein Wert", ); }); @@ -437,7 +457,7 @@ describe("selection bar head and hint", () => { ], }; const html = text(bar("2026-01-03", timeline)); - expect(html).toContain("9 Std. 16 Min. (Schnitt aus 31 Messungen)"); + expect(html).toContain("9 Std. 16 Min., Schnitt aus 31 Messungen"); expect(html).not.toContain("556"); }); }); @@ -552,3 +572,32 @@ describe("life events in the capture picker", () => { ).not.toContain("lifeEvent"); }); }); + +describe("value-line names", () => { + it("lead to their metric's page, with a 44 px target, and none for a kind without one", () => { + const tl = fullTimeline(); + const layout = layoutTimeline({ + width: 1200, + window: { from: "2025-10-01", to: "2026-10-31" }, + lanes: tl.lanes, + series: [...tl.series, { key: "NOT_A_TYPE", unit: null, points: [] }], + bucket: tl.bucket, + words: wordsIn("de"), + startMissing: "?", + today: TODAY, + }); + const html = render( + `name ${k}`} />, + ); + expect(html).toMatch( + /data-slot="timeline-series-link"[^>]*data-series="WEIGHT"|href="\/insights\/weight"[^>]*data-slot="timeline-series-link"/, + ); + expect(html).toContain('href="/insights/weight"'); + expect(html).toContain('href="/insights/blood-pressure"'); + expect(html).toContain('aria-label="name WEIGHT"'); + expect(html).not.toContain('data-series="NOT_A_TYPE"'); + for (const height of html.match(/height:(\d+)px/g) ?? []) { + expect(Number(height.replace(/\D/g, ""))).toBeGreaterThanOrEqual(44); + } + }); +}); diff --git a/src/components/timeline/__tests__/timeline-geometry.test.ts b/src/components/timeline/__tests__/timeline-geometry.test.ts index 8b9fe4e26..0ea5ad030 100644 --- a/src/components/timeline/__tests__/timeline-geometry.test.ts +++ b/src/components/timeline/__tests__/timeline-geometry.test.ts @@ -7,6 +7,10 @@ import { describe, expect, it } from "vitest"; import { + LineOccupancy, + NEIGHBOUR_GAP, + placeText, + seriesNameLines, createScale, dateAtPointer, estimateTextWidth, @@ -408,7 +412,10 @@ describe("layoutLane", () => { ]); for (const label of layout.labels) { const point = layout.points.find((p) => p.item.id === label.itemId)!; - expect(label.x).toBeGreaterThan(point.x + 7); + // Right of the glyph, or left of it when the next mark is too near. + const right = label.x > point.x + 7; + const left = label.x + estimateTextWidth(label.text) < point.x - 7; + expect(right || left).toBe(true); expect(label.strong).toBe(true); } }); @@ -736,3 +743,108 @@ describe("interaction", () => { ).toBeGreaterThan(0); }); }); + +describe("label room (v1.42 final)", () => { + it("puts a long value-line name on two lines instead of cutting it", () => { + const width = 164 - 12 - 16; + expect(seriesNameLines("Gewicht", width, 13)).toEqual(["Gewicht"]); + const lines = seriesNameLines("Blood pressure sys.", width, 13); + expect(lines).toEqual(["Blood pressure", "sys."]); + for (const line of lines) { + expect(estimateTextWidth(line, 13)).toBeLessThanOrEqual(width); + } + // Two lines are the most; the second is cut when even two do not hold it. + const long = seriesNameLines( + "Walking heart rate average over the whole day", + width, + 13, + ); + expect(long).toHaveLength(2); + expect(long[1].endsWith("…")).toBe(true); + }); + + it("keeps a gap before the next mark, cuts a name short, or leaves it out", () => { + const occ = new LineOccupancy(); + occ.add("0:on", 200, 210); + // Whole: ends a full gap before the mark at 200. + expect(placeText(occ, "0:on", 100, "abcd", 1000)).toBe("abcd"); + const occ2 = new LineOccupancy(); + occ2.add("0:on", 200, 210); + const cut = placeText(occ2, "0:on", 100, "a long label that runs on", 1000); + expect(cut?.endsWith("…")).toBe(true); + expect(100 + estimateTextWidth(cut!)).toBeLessThanOrEqual( + 200 - NEIGHBOUR_GAP, + ); + // No room for even a few characters: no label at all. + const occ3 = new LineOccupancy(); + occ3.add("0:on", 120, 130); + expect(placeText(occ3, "0:on", 100, "a long label", 1000)).toBeNull(); + }); + + it("keeps a medication's name above its bar clear of the bar's arrow", () => { + const window = { from: "2026-07-11", to: "2026-10-09" }; + const scale = createScale(window, 164, 700); + const lane = layoutLane( + { + lane: { + key: "medications", + items: [ + item({ + id: "mg", + group: "mg", + kind: "medication", + // Started late in the window: a short bar near today. + start: "2026-09-20", + open: true, + startKnown: false, + label: "Magnesium", + sub: "400mg", + }), + ], + }, + top: 0, + }, + window, + scale, + { words: WORDS, startMissing: "Startdatum fehlt", today: "2026-10-09" }, + ); + const bar = lane.spans[0]; + expect(lane.labels.length).toBeGreaterThan(0); + for (const label of lane.labels) { + const right = label.x + estimateTextWidth(label.text); + // Above the bar it ends before the arrow head; beside it, it starts + // after it. + if (label.y < bar.y) expect(right).toBeLessThanOrEqual(bar.xEnd - 2); + else expect(label.x >= bar.xEnd + 7 || right <= bar.xStart).toBe(true); + } + }); + + it("never lets two names of one lane line overlap or crowd each other", () => { + for (const width of [390, 700, 1100, 1600]) { + const layout = layoutAt(width); + for (const lane of layout.lanes) { + const byLine = new Map>(); + for (const box of boxes(lane)) { + const list = byLine.get(box.y) ?? []; + list.push(box); + byLine.set(box.y, list); + } + for (const list of byLine.values()) { + list.sort((p, q) => p.a - q.a); + for (let i = 1; i < list.length; i++) { + expect(list[i].a).toBeGreaterThanOrEqual(list[i - 1].b); + } + } + } + } + }); + + it("names a life event without its category glued on", () => { + const life = fullTimeline().lanes.find((l) => l.key === "life")!; + for (const entry of life.items) { + const words = WORDS(entry); + expect(words.sub).toBeNull(); + expect(itemLine(entry, WORDS, "")).toBe(entry.label); + } + }); +}); diff --git a/src/components/timeline/item-words.ts b/src/components/timeline/item-words.ts index dafa94dcb..4767d830e 100644 --- a/src/components/timeline/item-words.ts +++ b/src/components/timeline/item-words.ts @@ -36,8 +36,14 @@ type TranslateCount = ( export interface ItemWords { /** What the item is called. */ label: string; - /** Its second line (a category, a dose, a count), or null when none. */ + /** Its second line (a dose, a count), or null when none. */ sub: string | null; + /** + * A category set apart from the name (a life event's "Home"): shown as a + * chip or on its own muted line, never run on after the name, where + * "Moved into a bigger flat Home" read as one title. + */ + tag?: string | null; } export type ItemWordsFn = (item: TimelineItem) => ItemWords; @@ -80,7 +86,8 @@ export function makeItemWords( case "lifeEvent": return { label: item.label, - sub: worded(t, LIFE_EVENT_CATEGORY_KEY, item.sub), + sub: null, + tag: worded(t, LIFE_EVENT_CATEGORY_KEY, item.sub), }; case "travel": return { label: t("day.travel"), sub: null }; diff --git a/src/components/timeline/selection-bar.tsx b/src/components/timeline/selection-bar.tsx index a1b717684..5686606ff 100644 --- a/src/components/timeline/selection-bar.tsx +++ b/src/components/timeline/selection-bar.tsx @@ -7,8 +7,11 @@ * 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. + * each ("no value" where there is none; the readings behind a mean are a + * hover and a screen-reader detail, not a second line). The day itself opens + * from a chip, from Enter on the chart or a double click; a separate "Open + * 3 Jan." link sat apart from everything else and is gone. 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`). @@ -19,8 +22,6 @@ * medication is one entry however many of its items touch the bucket * (`medicationPeriodItem`). */ -import { ArrowRight } from "lucide-react"; - import type { TimelineResponse } from "@/lib/day/contract"; import { resolveIntlLocale } from "@/lib/format-locale"; import { useTranslations } from "@/lib/i18n/context"; @@ -147,9 +148,19 @@ export function SelectionBar({ to: at(item.end), }) : at(item.start); - const { label, sub } = words(item); + const { label, sub, tag } = words(item); const name = [label, sub].filter(Boolean).join(" "); const text = `${name}, ${when}`; + // A life event's category is a chip of its own inside the entry, + // never run on after its title. + const category = tag ? ( + + {tag} + + ) : null; const editable = item.kind === "lifeEvent" && onEditLifeEvent; if (!canOpenDay && !editable) { return ( @@ -163,6 +174,7 @@ export function SelectionBar({ aria-hidden="true" /> {text} + {category} ); } @@ -183,6 +195,7 @@ export function SelectionBar({ aria-hidden="true" /> {text} + {category} ); })} @@ -205,16 +218,6 @@ export function SelectionBar({ {meanAfter} )} - ); } diff --git a/src/components/timeline/series-format.ts b/src/components/timeline/series-format.ts index 9470f3a2b..728c22a91 100644 --- a/src/components/timeline/series-format.ts +++ b/src/components/timeline/series-format.ts @@ -126,7 +126,13 @@ function pressure( /** One value of a means line, with the series whose colour marks it. */ export interface MeanPart { key: string; + /** What is read at a glance: the name and the value. */ text: string; + /** + * What stands behind it ("mean of 19 readings"): a hover, a screen reader + * and a long press reach it, the line does not show it. + */ + detail?: string; } /** @@ -189,7 +195,8 @@ export function labelledMeanParts( if (fold && m.key === "BLOOD_PRESSURE_SYS") { parts.push({ key: m.key, - text: `${words.bloodPressure} ${pressure(sys, dia!, format)} (${words.readings(sys.count ?? 0)})`, + text: `${words.bloodPressure} ${pressure(sys, dia!, format)}`, + detail: words.readings(sys.count ?? 0), }); continue; } @@ -198,7 +205,8 @@ export function labelledMeanParts( text: m.mean === null ? `${words.label(m.key)} ${words.noValue}` - : `${words.label(m.key)} ${formatSeriesValue(m.key, m.mean, m.unit, format)} (${words.readings(m.count ?? 0)})`, + : `${words.label(m.key)} ${formatSeriesValue(m.key, m.mean, m.unit, format)}`, + ...(m.mean === null ? {} : { detail: words.readings(m.count ?? 0) }), }); } return parts; diff --git a/src/components/timeline/timeline-chart.tsx b/src/components/timeline/timeline-chart.tsx index e516a3611..4d6245fc4 100644 --- a/src/components/timeline/timeline-chart.tsx +++ b/src/components/timeline/timeline-chart.tsx @@ -22,6 +22,8 @@ * the card re-runs the layout: labels that no longer fit are left out, never * overlapped. */ +import { metricPageHref } from "@/lib/insights/metric-page"; +import Link from "next/link"; import { useEffect, useMemo, @@ -68,7 +70,9 @@ import { LANE_COLOR, dateAtPointer, estimateTextWidth, + SERIES_HEIGHT, fitText, + seriesNameLines, gridTicks, isSpan, todayLabelFits, @@ -225,7 +229,7 @@ export function TimelineChart({ onKeyDown={handleKey} data-slot="timeline-chart" data-width={width} - className="focus-visible:ring-ring/50 rounded-lg outline-none focus-visible:ring-2" + className="focus-visible:ring-ring/50 relative rounded-lg outline-none focus-visible:ring-2" >

    {t("timeline.keyboardHint")} @@ -264,6 +268,9 @@ export function TimelineChart({ ) : (

    )} + {layout ? ( + + ) : null}
    >; + seriesLabel: (key: string) => string; +}) { + return ( + <> + {layout.series.map((series) => { + const href = metricPageHref(series.key); + if (!href) return null; + return ( + event.stopPropagation()} + onClick={(event) => event.stopPropagation()} + className="hover:bg-foreground/5 focus-visible:ring-ring/50 absolute left-0 rounded-md outline-none focus-visible:ring-2" + style={{ + top: series.top - 4, + width: layout.scale.x0 - 8, + height: Math.max(44, SERIES_HEIGHT - 12), + }} + /> + ); + })} + + ); +} + interface ChartBodyProps { layout: NonNullable>; window: TimeWindow; @@ -447,19 +496,6 @@ function ChartBody({ intl={intl} /> ))} - {lane.labels.map((label, i) => ( - - {label.text} - - ))} ); })} @@ -524,6 +560,33 @@ function ChartBody({ /> )} + + {/* The names last, over the today and selection lines, each on a + halo of the card's colour: a line passing a name runs behind it + and never through its letters. */} + + {layout.lanes.flatMap((lane) => + lane.labels.map((label, i) => ( + + {label.text} + + )), + )} + ); } @@ -558,6 +621,8 @@ export function SeriesLines({ }) { const { tCount } = useTranslations(); const { scale, seriesTop } = layout; + const nameLines = (key: string) => + seriesNameLines(seriesLabel(key), scale.x0 - 12 - SERIES_TEXT_X, 13); return ( <> {layout.series.length > 0 && ( @@ -589,21 +654,22 @@ export function SeriesLines({ strokeLinecap="round" /> - {fitText( - seriesLabel(series.key), - scale.x0 - 12 - SERIES_TEXT_X, - 13, - )} + {nameLines(series.key).map((line, i) => ( + + {line} + + ))} @@ -691,8 +757,10 @@ function spanTitle( words: ItemWordsFn, ): string { const { item, segment } = span; - const { label, sub } = words(item); - const name = [label, segment ? segment.dose : sub].filter(Boolean).join(" "); + const { label, sub, tag } = words(item); + const name = [label, segment ? segment.dose : sub, tag ? `(${tag})` : null] + .filter(Boolean) + .join(" "); const start = segment?.start ?? item.start; const end = segment ? segment.end : item.open ? null : item.end; const from = formatAtPrecision(start, item.precision, intl); @@ -1045,6 +1113,10 @@ export function MeanPartsLine({ 0 ? "ml-3" : undefined} > {i > 0 ? , : null} @@ -1055,6 +1127,11 @@ export function MeanPartsLine({ aria-hidden="true" /> {part.text} + {part.detail ? ( + + {`, ${part.detail}`} + + ) : null} )); } diff --git a/src/components/timeline/timeline-chronicle.tsx b/src/components/timeline/timeline-chronicle.tsx index 0a6ec190a..ec315bfea 100644 --- a/src/components/timeline/timeline-chronicle.tsx +++ b/src/components/timeline/timeline-chronicle.tsx @@ -191,7 +191,7 @@ export function TimelineChronicle({ }; } const { item, role } = entry; - const { label, sub } = words(item); + const { label, sub, tag } = words(item); if (role === "end") { // A pause that ends is the medication taken up again, not ended. return { @@ -204,7 +204,11 @@ export function TimelineChronicle({ : null, }; } - const meta = [sub, item.startKnown ? null : t("timeline.startUnknown")] + // A life event's category sits on the muted line under its title. + const meta = [ + sub ?? tag ?? null, + item.startKnown ? null : t("timeline.startUnknown"), + ] .filter(Boolean) .join(", "); return { title: label, meta: meta || null }; diff --git a/src/components/timeline/timeline-geometry.ts b/src/components/timeline/timeline-geometry.ts index 86fffc96a..e79fd8554 100644 --- a/src/components/timeline/timeline-geometry.ts +++ b/src/components/timeline/timeline-geometry.ts @@ -239,6 +239,57 @@ export class LineOccupancy { this.add(line, a, b); return true; } + + /** + * How far right a text starting at `a` may run on `line`: up to `margin` + * before the next thing there. `a` itself when `a` is already too close to + * something, Infinity when nothing lies to its right. + */ + room(line: string, a: number, margin = LABEL_MARGIN): number { + let limit = Infinity; + for (const [c, d] of this.lines.get(line) ?? []) { + if (a < d + margin && a > c - margin) return a; + if (c - margin >= a) limit = Math.min(limit, c - margin); + } + return limit; + } +} + +/** Air between a label and the next mark or label to its right. */ +export const NEIGHBOUR_GAP = 12; + +/** A label cut shorter than this many characters is left out instead. */ +export const MIN_LABEL_CHARS = 6; + +/** + * Put `text` on `line` from `a`: whole when it keeps {@link NEIGHBOUR_GAP} + * from the next thing to its right and `margin` from everything else, else + * cut with an ellipsis to the room before that next thing, else not at all + * (when even {@link MIN_LABEL_CHARS} characters would not fit). Returns the + * text placed, or null. + */ +export function placeText( + occ: LineOccupancy, + line: string, + a: number, + text: string, + right: number, + margin = LABEL_MARGIN, +): string | null { + if (!occ.fits(line, a, a, margin)) return null; + const room = Math.min(right, occ.room(line, a, NEIGHBOUR_GAP)); + const width = estimateTextWidth(text); + if (a + width <= room && occ.fits(line, a, a + width, margin)) { + occ.add(line, a, a + width); + return text; + } + const available = room - a; + if (available < estimateTextWidth("x".repeat(MIN_LABEL_CHARS))) return null; + const cut = fitText(text, available); + const cutWidth = estimateTextWidth(cut); + if (!occ.fits(line, a, a + cutWidth, margin)) return null; + occ.add(line, a, a + cutWidth); + return cut; } /** @@ -283,6 +334,32 @@ export function wrapToWidth( return lines; } +/** + * A value line's name in the label column: one line when it fits, else two, + * the second cut with an ellipsis if even two do not hold it. "Blood + * pressure sys." read "Blood pressure sy…" on one line; on two it reads whole + * in every bundle, and the value under it moves down one line. + */ +export function seriesNameLines( + text: string, + maxWidth: number, + fontPx = LABEL_FONT_PX, +): string[] { + if (estimateTextWidth(text, fontPx) <= maxWidth) return [text]; + const words = text.split(/\s+/); + let first = ""; + let i = 0; + for (; i < words.length; i++) { + const next = first ? `${first} ${words[i]}` : words[i]; + if (estimateTextWidth(next, fontPx) > maxWidth) break; + first = next; + } + // A first word longer than the column: one cut line, as before. + if (first === "") return [fitText(text, maxWidth, fontPx)]; + const rest = words.slice(i).join(" "); + return rest ? [first, fitText(rest, maxWidth, fontPx)] : [first]; +} + /* ─── Grid ────────────────────────────────────────────────────────────────── */ export interface GridTick { @@ -712,13 +789,14 @@ function placeMedicationLabels( s.segment?.first && !s.item.startKnown ? UNKNOWN_START_LEAD : 0; const arrow = s.item.open && (s.segment?.last ?? true) ? 7 : 0; occ.add(line(s.row, "on"), s.xStart - lead, s.xEnd + arrow); + // A name above the bar ends before the bar's own arrow head. + if (arrow) occ.add(line(s.row, "above"), s.xEnd - 2, s.xEnd + 9); } for (const p of points) { const half = GLYPH_HALF_WIDTH[p.shape]; occ.add(line(p.row, "on"), p.x - half, p.x + half); } const labels: PlacedLabel[] = []; - const fits = (a: number, b: number) => a >= scale.x0 && b <= scale.x1 - 2; const place = ( id: string, row: number, @@ -728,13 +806,19 @@ function placeMedicationLabels( text: string, margin = LABEL_MARGIN, ) => { - const b = a + estimateTextWidth(text); - if (!fits(a, b) || !occ.tryAdd(line(row, where), a, b, margin)) { - return false; - } + if (a < scale.x0) return false; + const placed = placeText( + occ, + line(row, where), + a, + text, + scale.x1 - 2, + margin, + ); + if (placed === null) return false; labels.push({ itemId: id, - text, + text: placed, x: a, y: where === "above" ? y - 7 : y + 4, strong: false, @@ -835,8 +919,10 @@ function placeLabels( const line = (row: number, where: "on" | "above") => `${row}:${where}`; for (const s of spans) { const lead = s.item.startKnown ? 0 : UNKNOWN_START_LEAD; - // An open period ends in a small arrow head past its last day. + // An open period ends in a small arrow head past its last day; a name + // above the bar keeps clear of it too. occ.add(line(s.row, "on"), s.xStart - lead, s.xEnd + (s.item.open ? 7 : 0)); + if (s.item.open) occ.add(line(s.row, "above"), s.xEnd - 2, s.xEnd + 9); } for (const p of points) { const half = GLYPH_HALF_WIDTH[p.shape]; @@ -853,10 +939,16 @@ function placeLabels( const aboveLabel = (s: PlacedSpan, text: string) => { const a = s.xStart + (s.clippedLeft ? 26 : 2); - const b = a + estimateTextWidth(text); - if (!fitsCard(a, b)) return false; - if (!occ.tryAdd(line(s.row, "above"), a, b)) return false; - labels.push({ itemId: s.item.id, text, x: a, y: s.y - 7, strong: false }); + if (a < scale.x0) return false; + const placed = placeText(occ, line(s.row, "above"), a, text, scale.x1 - 2); + if (placed === null) return false; + labels.push({ + itemId: s.item.id, + text: placed, + x: a, + y: s.y - 7, + strong: false, + }); return true; }; const rightLabel = ( @@ -868,13 +960,20 @@ function placeLabels( strong: boolean, ) => { const a = x; - const b = a + estimateTextWidth(text); - if (!fitsCard(a, b)) return false; + if (a < scale.x0) return false; // The label already keeps its own distance from the mark it names, so a - // smaller margin here; the full margin still separates it from the next - // mark because that mark's own box is wider than this gap. - if (!occ.tryAdd(line(row, "on"), a, b, BESIDE_MARGIN)) return false; - labels.push({ itemId: id, text, x: a, y: y + 4, strong }); + // smaller margin behind it; ahead of it, it keeps a full gap from the + // next mark, or is cut short, or left out. + const placed = placeText( + occ, + line(row, "on"), + a, + text, + scale.x1 - 2, + BESIDE_MARGIN, + ); + if (placed === null) return false; + labels.push({ itemId: id, text: placed, x: a, y: y + 4, strong }); return true; }; diff --git a/src/lib/insights/__tests__/metric-page.test.ts b/src/lib/insights/__tests__/metric-page.test.ts new file mode 100644 index 000000000..5f32c59bf --- /dev/null +++ b/src/lib/insights/__tests__/metric-page.test.ts @@ -0,0 +1,25 @@ +/** + * One map from a kind of value to its page, read by the day view's value + * tiles and the timeline's value-line names alike. + */ +import { describe, expect, it } from "vitest"; + +import { metricPageHref } from "../metric-page"; + +describe("metricPageHref", () => { + it("leads a value with a page of its own to that page", () => { + expect(metricPageHref("WEIGHT")).toBe("/insights/weight"); + expect(metricPageHref("PULSE")).toBe("/insights/pulse"); + expect(metricPageHref("SLEEP_DURATION")).toBe("/insights/sleep"); + expect(metricPageHref("BODY_FAT")).toMatch(/^\/insights\/body-fat/); + expect(metricPageHref("BLOOD_PRESSURE")).toBe("/insights/blood-pressure"); + expect(metricPageHref("BLOOD_PRESSURE_SYS")).toBe( + "/insights/blood-pressure", + ); + expect(metricPageHref("MOOD")).toBe("/insights/mood"); + }); + + it("gives a kind without a page no link", () => { + expect(metricPageHref("NOT_A_TYPE")).toBeNull(); + }); +}); diff --git a/src/lib/insights/metric-page.ts b/src/lib/insights/metric-page.ts new file mode 100644 index 000000000..1b1925e1e --- /dev/null +++ b/src/lib/insights/metric-page.ts @@ -0,0 +1,24 @@ +/** + * The page a value's name leads to: one answer for every surface that lists + * values by kind (the day view's value tiles, the timeline's value lines). + * + * A measurement type goes to the insights sub-page that focuses it + * (`subPageSlugForType`); the day view's blood-pressure tile, keyed + * `BLOOD_PRESSURE`, goes to the blood-pressure page; the mood score goes to + * the mood page. A kind without a page of its own has no link (`null`), and + * its name stays plain text: a link to a filtered list would be a different + * promise than "this value's page". + * + * Pure and client-safe. + */ +import { subPageSlugForType } from "@/lib/insights/sub-page-metric"; + +/** The mood score's key on the timeline (`MOOD_SERIES_KEY` on the server). */ +const MOOD_KEY = "MOOD"; + +export function metricPageHref(key: string): string | null { + if (key === MOOD_KEY) return "/insights/mood"; + if (key === "BLOOD_PRESSURE") return "/insights/blood-pressure"; + const slug = subPageSlugForType(key); + return slug ? `/insights/${slug}` : null; +}