mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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)",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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})`;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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":
|
||||
|
||||
Reference in new issue
Block a user