fix(day,timeline): put an intake's dose in its name and set a lab day's count apart

Under "What happened" an intake now reads "Ramipril 5mg" on one line, as
the same medication does under Running that day, instead of the name over a
muted dose line. A lab day's analyte count moves from the run-on second part
to the entry's tag: a chip of its own in the selection bar, brackets in the
screen-reader line and the hover title, the muted line in the chronicle.
This commit is contained in:
Marc-André Bombeck committed 2026-10-09 18:55:34 +02:00
1 parent 96ee0c7821
commit d068d6c9e6
6 files changed
+197 -20

No files matched your search

@@ -0,0 +1,63 @@
/**
* An intake under "What happened" reads as its name and the dose taken on
* one line, "Ramipril 5mg", as the same medication does under Running that
* day. The dose on a muted line of its own under the name read as
* "Ramipril / 5mg".
*/
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import type { DayEvent } from "@/lib/day/contract";
import { DayEvents } from "../day-sections";
function event(
kind: DayEvent["kind"],
title: string,
meta: string | null,
): DayEvent {
return {
at: null,
kind,
section: "medications",
id: `${kind}-1`,
title,
meta,
note: null,
docs: [],
href: null,
};
}
/** The text of each line under the event's time and icon: its <p>s. */
function lines(events: DayEvent[]): string[] {
const html = renderToStaticMarkup(
<QueryClientProvider client={new QueryClient()}>
<I18nProvider initialLocale="en">
<DayEvents events={events} />
</I18nProvider>
</QueryClientProvider>,
);
return [...html.matchAll(/<p\b[^>]*>(.*?)<\/p>/g)].map((m) => m[1]);
}
describe("DayEvents dose", () => {
it("names an intake with its dose on one line", () => {
expect(lines([event("intake", "Ramipril", "5mg")])).toEqual([
"Ramipril 5mg",
]);
});
it("names an intake without a dose by its name alone", () => {
expect(lines([event("intake", "Ramipril", null)])).toEqual(["Ramipril"]);
});
it("keeps a dose change's new dose on its own line", () => {
expect(lines([event("doseChange", "Ramipril", "10 mg")])).toEqual([
"Ramipril",
"10 mg",
]);
});
});
+17 -7
View File
@@ -589,6 +589,13 @@ const EVENT_ICON: Record<DayEventKind, LucideIcon> = {
lifeEvent: Flag,
};
/**
* Event kinds whose meta is the dose taken. It reads as part of the name,
* "Ramipril 5 mg", as under Running that day, not as a second line of its
* own under the name.
*/
const DOSE_META_EVENT_KINDS: ReadonlySet<DayEventKind> = new Set(["intake"]);
export function DayEvents({ events }: { events: readonly DayEvent[] }) {
const { t } = useTranslations();
const fmt = useFormatters();
@@ -599,9 +606,14 @@ export function DayEvents({ events }: { events: readonly DayEvent[] }) {
<ul className="divide-border divide-y">
{events.map((event) => {
const Icon = EVENT_ICON[event.kind];
const meta = event.meta
? dayMeta(event.kind, event.meta, t, event.title)
: null;
const doseInName =
!!event.meta && DOSE_META_EVENT_KINDS.has(event.kind);
const title = dayTitle(event.kind, event.title, t);
const name = doseInName ? `${title} ${event.meta}` : title;
const meta =
event.meta && !doseInName
? dayMeta(event.kind, event.meta, t, event.title)
: null;
return (
<li
key={`${event.kind}-${event.id}`}
@@ -633,12 +645,10 @@ export function DayEvents({ events }: { events: readonly DayEvent[] }) {
data-slot="day-event-link"
className="focus-visible:ring-ring/50 block text-sm leading-5 font-medium after:absolute after:inset-0 after:rounded-md after:content-[''] focus-visible:outline-none focus-visible:after:ring-[3px] focus-visible:after:ring-inherit"
>
{dayTitle(event.kind, event.title, t)}
{name}
</Link>
) : (
<p className="text-sm leading-5 font-medium">
{dayTitle(event.kind, event.title, t)}
</p>
<p className="text-sm leading-5 font-medium">{name}</p>
)}
{meta ? (
<p className="text-muted-foreground mt-0.5 text-xs">{meta}</p>
@@ -0,0 +1,96 @@
/**
* A lab day's chip in the selection bar names its analytes and sets the
* count apart as a chip of its own, as a life event's category is. Run on
* after the names, "Hemoglobin, Total Cholesterol, LDL 5 lab values" read
* as one more analyte.
*/
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { TimelineResponse } from "@/lib/day/contract";
import { I18nProvider } from "@/lib/i18n/context";
import { itemLine } from "../item-words";
import { SelectionBar } from "../selection-bar";
import type { SeriesValueFormat } from "../series-format";
import { useSeriesValueFormat } from "../use-series-value-format";
import { TODAY, item, wordsIn } from "./timeline-fixture";
const LAB_DAY = item({
id: "lab-day:2026-06-26",
kind: "labDay",
start: "2026-06-26",
label: "Hemoglobin, Total Cholesterol, LDL",
sub: "5",
});
function timeline(): TimelineResponse {
return {
zoom: "all",
range: { from: "2026-01-01", to: "2026-12-31", dataFrom: "2026-01-01" },
bucket: "day",
lanes: [{ key: "labs", items: [LAB_DAY] }],
standing: [],
series: [],
availableSeries: [],
notable: [],
} as unknown as TimelineResponse;
}
function WithFormat({
build,
}: {
build: (format: SeriesValueFormat) => React.ReactNode;
}) {
return <>{build(useSeriesValueFormat())}</>;
}
function renderBar(): string {
return renderToStaticMarkup(
<QueryClientProvider client={new QueryClient()}>
<I18nProvider initialLocale="en">
<WithFormat
build={(format) => (
<SelectionBar
timeline={timeline()}
selected="2026-06-26"
today={TODAY}
seriesLabel={(key) => key}
seriesColor={() => "var(--chart-1)"}
seriesFormat={format}
onOpenDay={() => undefined}
/>
)}
/>
</I18nProvider>
</QueryClientProvider>,
);
}
/** The text a reader sees, tags dropped. */
const textOf = (html: string) => html.replace(/<[^>]*>/g, "");
describe("lab day in the selection bar", () => {
it("sets the count apart from the analyte names", () => {
const html = renderBar();
const chip = html.match(
/<button[^>]*data-slot="timeline-selection-chip"[^>]*>(.*?)<\/button>/,
)?.[1];
expect(chip).toBeDefined();
const tag = chip!.match(
/<span[^>]*data-slot="timeline-selection-tag"[^>]*>(.*?)<\/span>/,
);
expect(tag?.[1]).toBe("5 lab values");
expect(textOf(chip!.replace(tag![0], ""))).toBe(
"Hemoglobin, Total Cholesterol, LDL, Jun 26",
);
expect(textOf(chip!)).not.toContain("LDL 5 lab values");
});
it("brackets the count in the screen-reader line", () => {
expect(itemLine(LAB_DAY, wordsIn("en"), "")).toBe(
"Hemoglobin, Total Cholesterol, LDL (5 lab values)",
);
});
});
+17 -9
View File
@@ -39,9 +39,10 @@ export interface ItemWords {
/** 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.
* A category or a count set apart from the name (a life event's "Home", a
* lab day's "5 lab values"): shown as a chip, on its own muted line or in
* brackets, never run on after the name, where "Moved into a bigger flat
* Home" and "Hemoglobin, LDL 5 lab values" read as one title.
*/
tag?: string | null;
}
@@ -108,9 +109,11 @@ export function makeItemWords(
}
case "labDay": {
const count = Number(item.sub);
// The label lists analyte names; the count is set apart from them.
return {
label: item.label,
sub:
sub: null,
tag:
Number.isInteger(count) && count > 0
? tCount("timeline.item.labValues", count)
: null,
@@ -130,17 +133,22 @@ export function makeItemWords(
/**
* The line an item reads as: its label, its second line (a dose change's
* dose is drawn as its own mark, so it is left out here) and the
* unknown-start note in brackets: "Ramipril 5 mg (start unknown)". The
* parts are set apart by a space, never by a separator glyph.
* dose is drawn as its own mark, so it is left out here), its tag and the
* unknown-start note in brackets: "Ramipril 5 mg (start unknown)",
* "Hemoglobin, LDL (5 lab values)". The parts are set apart by a space,
* never by a separator glyph.
*/
export function itemLine(
item: TimelineItem,
words: ItemWordsFn,
startMissing: string,
): string {
const { label, sub } = words(item);
const base = [label, item.kind === "doseChange" ? null : sub]
const { label, sub, tag } = words(item);
const base = [
label,
item.kind === "doseChange" ? null : sub,
tag ? `(${tag})` : null,
]
.filter(Boolean)
.join(" ");
return item.startKnown || !startMissing ? base : `${base} (${startMissing})`;
+2 -2
View File
@@ -151,8 +151,8 @@ export function SelectionBar({
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.
// A life event's category and a lab day's count are a chip of
// their own inside the entry, never run on after its title.
const category = tag ? (
<span
data-slot="timeline-selection-tag"
+2 -2
View File
@@ -875,8 +875,8 @@ export function PointMark({
intl: string;
}) {
const { x, y, item, shape } = point;
const { label, sub } = useItemWords()(item);
const title = `${[label, sub].filter(Boolean).join(" ")}: ${formatAtPrecision(item.start, item.precision, intl)}`;
const { label, sub, tag } = useItemWords()(item);
const title = `${[label, sub, tag ? `(${tag})` : null].filter(Boolean).join(" ")}: ${formatAtPrecision(item.start, item.precision, intl)}`;
let glyph: React.ReactNode;
switch (shape) {
case "diamond":