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; +}