mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +00:00
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 <date>" 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.
154 lines
4.6 KiB
TypeScript
154 lines
4.6 KiB
TypeScript
/**
|
|
* The words of one timeline item, in the reader's language (v1.42, #613).
|
|
*
|
|
* The server sends what the record holds: a life event's category, a visit's
|
|
* kind and a document's kind as their codes, a lab day's analyte count as a
|
|
* number, and a sentinel label for a trip or a cycle that has no name of its
|
|
* own. Every surface that shows an item (the lanes, their hover titles, the
|
|
* screen-reader table, the selection bar, the phone chronicle) reads it
|
|
* through here, so a code is worded once and never reaches the screen raw.
|
|
*
|
|
* A code the bundle cannot word is left out rather than shown: an unknown
|
|
* category reads as no category, not as `SOMETHING_NEW`.
|
|
*/
|
|
import { useMemo } from "react";
|
|
|
|
import { encounterKindText } from "@/components/encounters/encounter-labels";
|
|
import type { TimelineItem } from "@/lib/day/contract";
|
|
import { useTranslations } from "@/lib/i18n/context";
|
|
|
|
import {
|
|
DOCUMENT_KIND_KEY,
|
|
LIFE_EVENT_CATEGORY_KEY,
|
|
keyOf,
|
|
} from "./label-keys";
|
|
|
|
type Translate = (
|
|
key: string,
|
|
params?: Record<string, string | number>,
|
|
) => string;
|
|
type TranslateCount = (
|
|
baseKey: string,
|
|
count: number,
|
|
params?: Record<string, string | number>,
|
|
) => string;
|
|
|
|
export interface ItemWords {
|
|
/** What the item is called. */
|
|
label: string;
|
|
/** 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;
|
|
|
|
/** A code worded through `map`, or null for none or a code it lacks. */
|
|
function worded<K extends string>(
|
|
t: Translate,
|
|
map: Readonly<Record<K, string>>,
|
|
code: string | null,
|
|
): string | null {
|
|
const key = code === null ? undefined : keyOf(map, code);
|
|
return key ? t(key) : null;
|
|
}
|
|
|
|
const ENCOUNTER_KIND_CODES: ReadonlySet<string> = new Set([
|
|
"ROUTINE",
|
|
"ACUTE",
|
|
"SPECIALIST",
|
|
"PREVENTIVE",
|
|
"EMERGENCY",
|
|
"HOSPITAL",
|
|
"THERAPY",
|
|
"OTHER",
|
|
"PROCEDURE",
|
|
]);
|
|
|
|
/** A visit's kind code, worded; null for a code the bundle does not know. */
|
|
export function visitKindWords(t: Translate, code: string): string | null {
|
|
return ENCOUNTER_KIND_CODES.has(code)
|
|
? encounterKindText(t, code as Parameters<typeof encounterKindText>[1])
|
|
: null;
|
|
}
|
|
|
|
export function makeItemWords(
|
|
t: Translate,
|
|
tCount: TranslateCount,
|
|
): ItemWordsFn {
|
|
return (item) => {
|
|
switch (item.kind) {
|
|
case "lifeEvent":
|
|
return {
|
|
label: item.label,
|
|
sub: null,
|
|
tag: worded(t, LIFE_EVENT_CATEGORY_KEY, item.sub),
|
|
};
|
|
case "travel":
|
|
return { label: t("day.travel"), sub: null };
|
|
case "cycle":
|
|
return { label: t("nav.cycle"), sub: null };
|
|
case "pause":
|
|
return {
|
|
label: t("day.event.paused", { label: item.label }),
|
|
sub: null,
|
|
};
|
|
case "visit":
|
|
case "procedure": {
|
|
const kind = item.sub ? visitKindWords(t, item.sub) : null;
|
|
// A visit without a reason or a practitioner is named by its kind.
|
|
return item.label
|
|
? { label: item.label, sub: kind }
|
|
: { label: kind ?? "", sub: null };
|
|
}
|
|
case "labDay": {
|
|
const count = Number(item.sub);
|
|
return {
|
|
label: item.label,
|
|
sub:
|
|
Number.isInteger(count) && count > 0
|
|
? tCount("timeline.item.labValues", count)
|
|
: null,
|
|
};
|
|
}
|
|
case "document":
|
|
return {
|
|
label: item.label,
|
|
sub: worded(t, DOCUMENT_KIND_KEY, item.sub),
|
|
};
|
|
default:
|
|
// A dose, a dose change, a name: the record's own text.
|
|
return { label: item.label, sub: item.sub };
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
export function itemLine(
|
|
item: TimelineItem,
|
|
words: ItemWordsFn,
|
|
startMissing: string,
|
|
): string {
|
|
const { label, sub } = words(item);
|
|
const base = [label, item.kind === "doseChange" ? null : sub]
|
|
.filter(Boolean)
|
|
.join(" ");
|
|
return item.startKnown || !startMissing ? base : `${base} (${startMissing})`;
|
|
}
|
|
|
|
/** {@link makeItemWords} with the bundle in hand. */
|
|
export function useItemWords(): ItemWordsFn {
|
|
const { t, tCount } = useTranslations();
|
|
return useMemo(() => makeItemWords(t, tCount), [t, tCount]);
|
|
}
|