mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
fix(i18n): drop dot and dash separators from visible strings
Every bundle value that chained facts with " · " now reads as a phrase, a comma list or a parenthesis, in all seven locales, and the em-dash separators in the day, documents, correlation, sharing, environment, vaccination and Apple Health import strings become sentences or clauses. The day view's running list names the record first, with the dose in the name and a life event's category as a chip, and puts a quiet day count below: "Day 4, since 31 December" instead of a dot chain. Document meta, location periods, vaccination rows and mood crosstab rows drop their composed dots too. A guard walks every bundle and fails on any " · " outside a reasoned allowlist, and on an allowlist entry that no longer needs its place.
This commit is contained in:
1 parent
d8a3614120
commit
94542e7f2d
39 files changed
+1027
-881
No files matched your search
@@ -0,0 +1,76 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
/**
|
||||
* No middle-dot separator in visible strings.
|
||||
*
|
||||
* A value like "Added {date} · {size}" or "Weight · body fat · muscle" reads
|
||||
* as a list of fragments rather than as the app's plain voice. Facts are
|
||||
* joined with a comma, a colon or a real phrase instead. This guard walks
|
||||
* every bundle under `messages/` and fails on any string that contains a
|
||||
* space, U+00B7 and a space. A middle dot inside a unit ("mL/(kg·min)") has
|
||||
* no surrounding spaces and is not matched.
|
||||
*
|
||||
* A key that genuinely needs the pattern goes into `ALLOWED` with the reason
|
||||
* written next to it. An allowed key that no longer contains the pattern
|
||||
* fails too, so the list cannot go stale.
|
||||
*/
|
||||
|
||||
const MESSAGES_DIR = join(__dirname, "../../messages");
|
||||
const SEPARATOR = " · ";
|
||||
|
||||
/** Key path -> why the separator is right there. Empty by intent. */
|
||||
const ALLOWED: Record<string, string> = {};
|
||||
|
||||
function flatten(
|
||||
obj: unknown,
|
||||
prefix: string,
|
||||
out: Map<string, string>,
|
||||
): Map<string, string> {
|
||||
if (obj == null || typeof obj !== "object") return out;
|
||||
for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
|
||||
const key = prefix ? `${prefix}.${k}` : k;
|
||||
if (typeof v === "string") out.set(key, v);
|
||||
else if (typeof v === "object") flatten(v, key, out);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const bundles = readdirSync(MESSAGES_DIR)
|
||||
.filter((file) => file.endsWith(".json"))
|
||||
.sort();
|
||||
|
||||
describe("message bundles carry no ' · ' separator", () => {
|
||||
it("finds the bundles", () => {
|
||||
expect(bundles.length).toBeGreaterThanOrEqual(7);
|
||||
});
|
||||
|
||||
for (const file of bundles) {
|
||||
const values = flatten(
|
||||
JSON.parse(readFileSync(join(MESSAGES_DIR, file), "utf8")),
|
||||
"",
|
||||
new Map(),
|
||||
);
|
||||
|
||||
it(`${file} walks a non-empty set of strings`, () => {
|
||||
expect(values.size).toBeGreaterThan(1000);
|
||||
});
|
||||
|
||||
it(`${file} has no dot separator outside the allowlist`, () => {
|
||||
const offenders = [...values]
|
||||
.filter(
|
||||
([key, value]) => value.includes(SEPARATOR) && !(key in ALLOWED),
|
||||
)
|
||||
.map(([key, value]) => `${key} = ${JSON.stringify(value)}`);
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it(`${file} has no stale allowlist entry`, () => {
|
||||
const stale = Object.keys(ALLOWED).filter(
|
||||
(key) => !(values.get(key) ?? "").includes(SEPARATOR),
|
||||
);
|
||||
expect(stale).toEqual([]);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -154,7 +154,7 @@ describe("<BodySitesSection>", () => {
|
||||
});
|
||||
expect(html).toContain("Procedures and visits");
|
||||
expect(html).toContain("Arthroscopy");
|
||||
expect(html).toContain("Knee · Left");
|
||||
expect(html).toContain("Knee (Left)");
|
||||
expect(html).toContain('href="/documents?doc=d1"');
|
||||
expect(html).toContain("MRI knee report");
|
||||
expect(html).toContain("Conditions");
|
||||
|
||||
@@ -116,7 +116,7 @@ describe("BD-Zielbereich tile (v1.4.28 FB-C1/C2 rewrite)", () => {
|
||||
it("renders the empty-hint placeholder instead of a percentage on thin data (v1.17 W1b)", () => {
|
||||
const html = render(
|
||||
<TrendCard
|
||||
label="BP in Target · 23d"
|
||||
label="BP in Target (23d)"
|
||||
latest={null}
|
||||
emptyHint="collecting data"
|
||||
unit="%"
|
||||
@@ -130,7 +130,7 @@ describe("BD-Zielbereich tile (v1.4.28 FB-C1/C2 rewrite)", () => {
|
||||
expect(html).toContain('data-slot="trend-card-empty-hint"');
|
||||
expect(html).toContain("collecting data");
|
||||
// The dynamic span label is shown in place of the static "· 90 T".
|
||||
expect(html).toContain("BP in Target · 23d");
|
||||
expect(html).toContain("BP in Target (23d)");
|
||||
// No numeric headline value renders when the hint takes the slot.
|
||||
expect(html).not.toContain('data-slot="trend-card-value"');
|
||||
});
|
||||
|
||||
@@ -186,8 +186,12 @@ describe("<DayView>", () => {
|
||||
const html = await render();
|
||||
expect(html).toContain("Running that day");
|
||||
expect(html).toContain("Common cold");
|
||||
expect(html).toContain("Day 4 · since");
|
||||
expect(html).toContain("Winter course · Day 64 of 151");
|
||||
// Name first, the quiet day count below; an open span names its start
|
||||
// as a date, its year left out when it is the viewed day's year.
|
||||
expect(html).toContain("Day 4, since December 31");
|
||||
expect(html).toContain("Vitamin D 1,000 IU Winter course");
|
||||
expect(html).toContain("Day 64 of 151");
|
||||
expect(html).not.toContain("ongoing");
|
||||
expect(html).toContain('href="/illness/ep1"');
|
||||
});
|
||||
|
||||
@@ -226,10 +230,11 @@ describe("<DayView>", () => {
|
||||
const html = await render();
|
||||
const rows = html.split('data-slot="day-running-item"').slice(1);
|
||||
const med = rows.find((row) => row.includes("Levothyroxine")) ?? "";
|
||||
expect(med).toContain("50 µg");
|
||||
expect(med).toContain("Levothyroxine 50 µg");
|
||||
expect(med.split("</li>")[0]).not.toMatch(/Day \d|since/);
|
||||
const leave = rows.find((row) => row.includes("Parental leave")) ?? "";
|
||||
expect(leave).toContain("Family · Day 34 of 90");
|
||||
expect(leave).toMatch(/data-slot="tag-chip"[^>]*>Family</);
|
||||
expect(leave).toContain("Day 34 of 90");
|
||||
expect(leave).not.toContain("FAMILY");
|
||||
});
|
||||
|
||||
|
||||
@@ -23,8 +23,10 @@ import {
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
import { TagChip } from "@/components/ui/tag-chip";
|
||||
import {
|
||||
LIFE_EVENT_CATEGORIES,
|
||||
type DateKey,
|
||||
type DayEvent,
|
||||
type DayEventKind,
|
||||
type DayNotable,
|
||||
@@ -32,6 +34,7 @@ import {
|
||||
type DayRunningKind,
|
||||
type DayValue,
|
||||
} from "@/lib/day/contract";
|
||||
import { resolveIntlLocale } from "@/lib/format-locale";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -138,7 +141,7 @@ export function DayNotableLines({
|
||||
<Sparkles className="mt-px size-3.5 shrink-0" aria-hidden="true" />
|
||||
<span>
|
||||
{withMetric && notable.type
|
||||
? `${labelFor(tileKeyOf(notable.type))} · `
|
||||
? `${labelFor(tileKeyOf(notable.type))}: `
|
||||
: null}
|
||||
{text(notable)}
|
||||
</span>
|
||||
@@ -200,28 +203,59 @@ function dayMeta(
|
||||
return meta;
|
||||
}
|
||||
|
||||
export function DayRunning({ items }: { items: readonly DayRunningItem[] }) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
/** Running kinds whose `sub` is a dose and reads as part of the name. */
|
||||
const DOSE_SUB_KINDS: ReadonlySet<DayRunningKind> = new Set([
|
||||
"medication",
|
||||
"medicationCourse",
|
||||
]);
|
||||
|
||||
export function DayRunning({
|
||||
items,
|
||||
date,
|
||||
}: {
|
||||
items: readonly DayRunningItem[];
|
||||
/** The viewed day; a start date in another year names its year. */
|
||||
date?: DateKey;
|
||||
}) {
|
||||
const { t, locale } = useTranslations();
|
||||
if (items.length === 0) return null;
|
||||
const viewedYear = date
|
||||
? date.slice(0, 4)
|
||||
: String(new Date().getUTCFullYear());
|
||||
const intlLocale = resolveIntlLocale(locale);
|
||||
const sinceText = (since: string): string =>
|
||||
new Intl.DateTimeFormat(intlLocale, {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
...(since.slice(0, 4) === viewedYear ? {} : { year: "numeric" }),
|
||||
timeZone: "UTC",
|
||||
}).format(new Date(`${since}T12:00:00.000Z`));
|
||||
return (
|
||||
<section className="space-y-2" data-slot="day-running">
|
||||
<h3 className={DAY_SECTION_LABEL}>{t("day.groups.running")}</h3>
|
||||
<ul>
|
||||
{items.map((item) => {
|
||||
const parts = [
|
||||
item.sub === null ? null : dayMeta(item.kind, item.sub, t),
|
||||
const title = dayTitle(item.kind, item.title, t);
|
||||
const doseInName = item.sub !== null && DOSE_SUB_KINDS.has(item.kind);
|
||||
const name = doseInName ? `${title} ${item.sub}` : title;
|
||||
const chip =
|
||||
item.sub !== null && !doseInName
|
||||
? dayMeta(item.kind, item.sub, t)
|
||||
: null;
|
||||
const since =
|
||||
item.dayCount === null && item.since !== null
|
||||
? sinceText(item.since)
|
||||
: null;
|
||||
const detail =
|
||||
item.dayIndex !== null
|
||||
? item.dayCount !== null
|
||||
? t("day.dayOf", { n: item.dayIndex, m: item.dayCount })
|
||||
: t("day.dayN", { n: item.dayIndex })
|
||||
: null,
|
||||
item.dayCount === null && item.since !== null
|
||||
? t("day.since", {
|
||||
date: fmt.dateShortSmartCalendar(item.since),
|
||||
})
|
||||
: null,
|
||||
].filter((part): part is string => !!part);
|
||||
: since !== null
|
||||
? t("day.dayNSince", { n: item.dayIndex, date: since })
|
||||
: t("day.dayN", { n: item.dayIndex })
|
||||
: since !== null
|
||||
? t("day.sinceDate", { date: since })
|
||||
: null;
|
||||
const body = (
|
||||
<>
|
||||
<span
|
||||
@@ -230,12 +264,17 @@ export function DayRunning({ items }: { items: readonly DayRunningItem[] }) {
|
||||
style={{ backgroundColor: RUNNING_COLOR[item.kind] }}
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium">
|
||||
{dayTitle(item.kind, item.title, t)}
|
||||
</span>
|
||||
<span className="text-muted-foreground block truncate text-xs">
|
||||
{parts.join(" · ")}
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className="min-w-0 truncate text-sm font-medium">
|
||||
{name}
|
||||
</span>
|
||||
{chip ? <TagChip className="shrink-0">{chip}</TagChip> : null}
|
||||
</span>
|
||||
{detail ? (
|
||||
<span className="text-muted-foreground block truncate text-xs">
|
||||
{detail}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
{item.href ? (
|
||||
<ChevronRight
|
||||
|
||||
@@ -518,13 +518,13 @@ describe("buildProcedureChoices", () => {
|
||||
{
|
||||
encounterId: "enc-knee",
|
||||
name: "Knee arthroscopy",
|
||||
detail: "Knee (left) · 2025-10-02",
|
||||
detail: "Knee (left), 2025-10-02",
|
||||
},
|
||||
{ encounterId: "enc-2", name: "Day clinic", detail: "2025-10-02" },
|
||||
{
|
||||
encounterId: "enc-3",
|
||||
name: "Procedure or surgery",
|
||||
detail: "Knee · 2025-10-02",
|
||||
detail: "Knee, 2025-10-02",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -198,8 +198,8 @@ export function DocumentCard({
|
||||
</DayLinkStated>
|
||||
) : (
|
||||
date
|
||||
)}{" "}
|
||||
· {size}
|
||||
)}
|
||||
, {size}
|
||||
</p>
|
||||
{showFilename ? (
|
||||
// Filename on its OWN muted line so it never clips the date/size
|
||||
|
||||
@@ -869,7 +869,7 @@ export function DocumentDetailSheet({
|
||||
{doc.filename ?? title}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("documents.detail.previewUnavailable")} ·{" "}
|
||||
{t("documents.detail.previewUnavailable")},{" "}
|
||||
{formatBytes(doc.byteSize, locale)}
|
||||
</p>
|
||||
<DocumentDownloadButton
|
||||
@@ -1185,8 +1185,15 @@ export function DocumentDetailSheet({
|
||||
date: format.date(doc.createdAt),
|
||||
size: formatBytes(doc.byteSize, locale),
|
||||
})}
|
||||
{doc.filename ? ` · ${doc.filename}` : ""}
|
||||
</p>
|
||||
{doc.filename ? (
|
||||
<p
|
||||
className="text-muted-foreground truncate text-xs"
|
||||
title={doc.filename}
|
||||
>
|
||||
{doc.filename}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{/* Where an imported document came from (#1038). Meta, so
|
||||
muted like the line above; the id is what the person looks
|
||||
|
||||
@@ -80,7 +80,7 @@ function factHeadline(fact: ExtractedFactDto): string {
|
||||
return (fact.data as ConditionFactData).label;
|
||||
}
|
||||
const data = fact.data as MedicationStatementFactData;
|
||||
return data.dose ? `${data.name} · ${data.dose}` : data.name;
|
||||
return data.dose ? `${data.name} ${data.dose}` : data.name;
|
||||
}
|
||||
|
||||
/** The date the fact states, if any (YYYY-MM-DD). */
|
||||
@@ -262,18 +262,16 @@ function FactRow({
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{typeLabel}
|
||||
{date ? ` · ${calendarDate(date)}` : ""}
|
||||
{isObservation &&
|
||||
(fact.data as ObservationFactData).referenceText ? (
|
||||
<>
|
||||
{" · "}
|
||||
{t("documents.review.statedRange", {
|
||||
range: (fact.data as ObservationFactData)
|
||||
.referenceText as string,
|
||||
})}
|
||||
</>
|
||||
) : null}
|
||||
{date ? `, ${calendarDate(date)}` : ""}
|
||||
</p>
|
||||
{isObservation && (fact.data as ObservationFactData).referenceText ? (
|
||||
<p className="text-muted-foreground text-xs break-words">
|
||||
{t("documents.review.statedRange", {
|
||||
range: (fact.data as ObservationFactData)
|
||||
.referenceText as string,
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
{fact.provenance.anchored && fact.provenance.sourceText ? (
|
||||
<p className="text-muted-foreground text-xs break-words italic">
|
||||
“{fact.provenance.sourceText}”
|
||||
|
||||
@@ -527,7 +527,7 @@ export function DocumentsView() {
|
||||
label: `${
|
||||
visit.practitioner?.name ??
|
||||
encounterKindText(t, visit.kind as EncounterKind)
|
||||
} · ${format.date(visit.occurredAt)}`,
|
||||
}, ${format.date(visit.occurredAt)}`,
|
||||
})),
|
||||
[visits.data, t, format],
|
||||
);
|
||||
|
||||
@@ -588,7 +588,7 @@ export function DocumentSourcePicker({
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
.join(", ");
|
||||
return (
|
||||
<li key={row.sourceId}>
|
||||
<button
|
||||
|
||||
@@ -546,7 +546,7 @@ export function buildProcedureChoices(
|
||||
procedure.reason?.trim() ||
|
||||
procedure.practitionerName?.trim() ||
|
||||
labels.kindName,
|
||||
detail: site ? `${site} · ${date}` : date,
|
||||
detail: site ? `${site}, ${date}` : date,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -138,7 +138,7 @@ describe("<ProceduresSection>", () => {
|
||||
html.indexOf('data-encounter-id="older"'),
|
||||
);
|
||||
expect(html).toContain("Meniscus repair");
|
||||
expect(html).toContain("Knee · Left");
|
||||
expect(html).toContain("Knee (Left)");
|
||||
expect(html).toContain("2 of 2");
|
||||
});
|
||||
|
||||
@@ -182,7 +182,7 @@ describe("<ProceduresSection>", () => {
|
||||
/data-slot="procedures-site-all"[^>]*aria-pressed="true"|aria-pressed="true"[^>]*data-slot="procedures-site-all"/,
|
||||
);
|
||||
expect(html).toMatch(
|
||||
/aria-pressed="false"[^>]*>Knee · Left|data-slot="procedures-site"[^>]*aria-pressed="false"/,
|
||||
/aria-pressed="false"[^>]*>Knee \(Left\)|data-slot="procedures-site"[^>]*aria-pressed="false"/,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ const okResult: CorrelationResult = {
|
||||
pValue: 0.001,
|
||||
confidenceBand: { low: -0.85, high: -0.39, label: "high" },
|
||||
interpretation:
|
||||
"Higher medication compliance lines up with lower systolic readings — a pattern worth watching.",
|
||||
"Higher medication compliance lines up with lower systolic readings, a pattern worth watching.",
|
||||
points: [
|
||||
{ x: 60, y: 140 },
|
||||
{ x: 70, y: 135 },
|
||||
|
||||
@@ -255,7 +255,7 @@ describe("<MessageThread>", () => {
|
||||
it("folds an older message's provenance into the thought-process row, never a raw key", () => {
|
||||
const html = render(<MessageThread conversation={baseConversation} />);
|
||||
expect(html).toContain('data-slot="coach-turn-steps-done"');
|
||||
expect(html).toContain("Thought process · 1 step");
|
||||
expect(html).toContain("Thought process (1 step)");
|
||||
expect(html).not.toContain('data-slot="coach-source-chips"');
|
||||
expect(html).not.toContain("insights.coach.metric.");
|
||||
expect(html).not.toContain("n=26");
|
||||
@@ -479,7 +479,7 @@ describe("<MessageThread>", () => {
|
||||
expect(html).not.toContain("What I'm looking at");
|
||||
expect(html).not.toContain("138 mmHg");
|
||||
// The areas the answer drew on stay reachable from the row.
|
||||
expect(html).toContain("Thought process · 1 step");
|
||||
expect(html).toContain("Thought process (1 step)");
|
||||
});
|
||||
|
||||
it("names the thought-process row in German under 'de'", () => {
|
||||
@@ -487,7 +487,7 @@ describe("<MessageThread>", () => {
|
||||
<MessageThread conversation={baseConversation} />,
|
||||
"de",
|
||||
);
|
||||
expect(html).toContain("Denkprozess · 1 Schritt");
|
||||
expect(html).toContain("Denkprozess (1 Schritt)");
|
||||
expect(html).not.toContain("Worauf bezieht sich das?");
|
||||
});
|
||||
|
||||
|
||||
@@ -413,7 +413,7 @@ describe("after the answer", () => {
|
||||
);
|
||||
expect(html).toContain('data-state="done"');
|
||||
expect(html).toMatch(
|
||||
/data-slot="coach-turn-steps-done"[^>]*>Thought process · 5 steps</,
|
||||
/data-slot="coach-turn-steps-done"[^>]*>Thought process \(5 steps\)</,
|
||||
);
|
||||
expect(html).not.toContain("text-shimmer");
|
||||
expect(html).not.toContain("waiting-dot");
|
||||
@@ -428,7 +428,7 @@ describe("after the answer", () => {
|
||||
<CoachTurnActivity activity={[ANSWER_DONE]} steps={[]} active={false} />,
|
||||
"de",
|
||||
);
|
||||
expect(de).toContain("Denkprozess · 1 Schritt<");
|
||||
expect(de).toContain("Denkprozess (1 Schritt)<");
|
||||
const pl = render(
|
||||
<CoachTurnActivity
|
||||
activity={[THINKING_DONE, FETCH_BP, ANSWER_DONE]}
|
||||
@@ -437,7 +437,7 @@ describe("after the answer", () => {
|
||||
/>,
|
||||
"pl",
|
||||
);
|
||||
expect(pl).toContain("Tok myślenia · 3 kroki<");
|
||||
expect(pl).toContain("Tok myślenia (3 kroki)<");
|
||||
});
|
||||
|
||||
it("an older message counts its steps, or the areas it drew on", () => {
|
||||
@@ -445,7 +445,7 @@ describe("after the answer", () => {
|
||||
render(
|
||||
<CoachTurnActivity activity={[]} steps={[BP, SLEEP]} active={false} />,
|
||||
),
|
||||
).toContain("Thought process · 2 steps<");
|
||||
).toContain("Thought process (2 steps)<");
|
||||
expect(
|
||||
render(
|
||||
<CoachTurnActivity
|
||||
@@ -455,7 +455,7 @@ describe("after the answer", () => {
|
||||
areas={["bp"]}
|
||||
/>,
|
||||
),
|
||||
).toContain("Thought process · 1 step<");
|
||||
).toContain("Thought process (1 step)<");
|
||||
});
|
||||
|
||||
it("says an answer is needed while a question waits, without shimmer", () => {
|
||||
@@ -681,7 +681,7 @@ describe("the visible row text", () => {
|
||||
),
|
||||
),
|
||||
),
|
||||
).toBe("Thought process · 5 steps");
|
||||
).toBe("Thought process (5 steps)");
|
||||
expect(
|
||||
textOf(
|
||||
rowHtml(render(<CoachTurnActivity activity={[]} steps={[]} active />)),
|
||||
|
||||
@@ -132,6 +132,10 @@ export function MoodFactorMetricCrosstab({
|
||||
const metricLabel = t(
|
||||
METRIC_LABEL_KEY[row.metricKey] ?? row.metricKey,
|
||||
);
|
||||
const pairLabel = t("insights.mood.factorCrosstab.pairLabel", {
|
||||
factor: factorLabel,
|
||||
metric: metricLabel,
|
||||
});
|
||||
// v1.32.26 — the weight ("kg") row follows the metric/imperial
|
||||
// preference: the unit symbol + the values (averages + delta, all in
|
||||
// kg, offset-free) convert together. Every other display is unitless
|
||||
@@ -168,12 +172,9 @@ export function MoodFactorMetricCrosstab({
|
||||
)}
|
||||
<span
|
||||
className="text-foreground min-w-0 flex-1 truncate"
|
||||
title={`${factorLabel} · ${metricLabel}`}
|
||||
title={pairLabel}
|
||||
>
|
||||
{t("insights.mood.factorCrosstab.pairLabel", {
|
||||
factor: factorLabel,
|
||||
metric: metricLabel,
|
||||
})}
|
||||
{pairLabel}
|
||||
</span>
|
||||
<span className="text-foreground shrink-0 text-sm font-semibold tabular-nums">
|
||||
{deltaText}
|
||||
|
||||
@@ -139,6 +139,10 @@ export function MoodTagMetricCrosstab({
|
||||
const metricLabel = t(
|
||||
METRIC_LABEL_KEY[row.metricKey] ?? row.metricKey,
|
||||
);
|
||||
const pairLabel = t("insights.mood.crosstab.pairLabel", {
|
||||
tag: tagLabel,
|
||||
metric: metricLabel,
|
||||
});
|
||||
const unit = t(UNIT_KEY[row.display]);
|
||||
const up = row.delta >= 0;
|
||||
const deltaText = `${up ? "+" : ""}${formatValue(row.delta, row.display, nf)} ${unit}`;
|
||||
@@ -160,12 +164,9 @@ export function MoodTagMetricCrosstab({
|
||||
)}
|
||||
<span
|
||||
className="text-foreground min-w-0 flex-1 truncate"
|
||||
title={`${tagLabel} · ${metricLabel}`}
|
||||
title={pairLabel}
|
||||
>
|
||||
{t("insights.mood.crosstab.pairLabel", {
|
||||
tag: tagLabel,
|
||||
metric: metricLabel,
|
||||
})}
|
||||
{pairLabel}
|
||||
</span>
|
||||
<span className="text-foreground shrink-0 text-sm font-semibold tabular-nums">
|
||||
{deltaText}
|
||||
|
||||
@@ -265,7 +265,7 @@ describe("<MedicationCard> — open overdue slot (v1.16.4)", () => {
|
||||
client,
|
||||
);
|
||||
|
||||
expect(html).toContain("Overdue ·");
|
||||
expect(html).toContain("Overdue since");
|
||||
expect(html).toContain("can still be taken");
|
||||
expect(html).toContain("text-warning");
|
||||
expect(html).not.toContain("Next intake: Tomorrow,");
|
||||
@@ -291,7 +291,7 @@ describe("<MedicationCard> — open overdue slot (v1.16.4)", () => {
|
||||
|
||||
expect(html).toContain("Next intake:");
|
||||
expect(html).toContain("Tomorrow");
|
||||
expect(html).not.toContain("Overdue ·");
|
||||
expect(html).not.toContain("Overdue since");
|
||||
expect(html).not.toContain("can still be taken");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -401,9 +401,14 @@ export function EnvironmentSection() {
|
||||
key={tr.id}
|
||||
className="flex items-center justify-between gap-2 p-2"
|
||||
>
|
||||
<span className="text-sm">
|
||||
{tr.label} · {fmt.dateShortSmartCalendar(tr.startDate)} –{" "}
|
||||
{fmt.dateShortSmartCalendar(tr.endDate)}
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-sm">{tr.label}</span>
|
||||
<span className="text-muted-foreground block text-xs">
|
||||
{t("settings.sections.environment.travel.range", {
|
||||
start: fmt.dateShortSmartCalendar(tr.startDate),
|
||||
end: fmt.dateShortSmartCalendar(tr.endDate),
|
||||
})}
|
||||
</span>
|
||||
</span>
|
||||
<ConfirmButton
|
||||
slot="travel-remove"
|
||||
|
||||
@@ -79,7 +79,7 @@ describe("the list", () => {
|
||||
);
|
||||
expect(html).toContain('data-antigen="typhoid"');
|
||||
expect(html).toContain('data-antigen="hepatitis-a"');
|
||||
expect(html.match(/· Travel combo/g)).toHaveLength(2);
|
||||
expect(html.match(/>Travel combo</g)).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ export function VaccinationDocumentSuggestion({
|
||||
: (candidate.vaccineName ?? t("vaccinations.suggestion.unnamed"));
|
||||
// A date stored at UTC midnight: render the calendar date, not the
|
||||
// instant (the previous evening west of UTC).
|
||||
return `${name} · ${formatCalendarDate(candidate.occurredAt.slice(0, 10), dateFormat, locale)}`;
|
||||
return `${name}, ${formatCalendarDate(candidate.occurredAt.slice(0, 10), dateFormat, locale)}`;
|
||||
};
|
||||
|
||||
const onPick = (candidate: VaccinationSuggestion) => {
|
||||
|
||||
@@ -209,7 +209,7 @@ function DoseRow({
|
||||
</span>
|
||||
) : null}
|
||||
{showIdentity && identity ? (
|
||||
<span className="text-muted-foreground">· {identity}</span>
|
||||
<span className="text-muted-foreground">{identity}</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="text-muted-foreground flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs">
|
||||
|
||||
@@ -757,7 +757,7 @@ describe("doctor-report surgical history section", () => {
|
||||
);
|
||||
const text = await extractText(bytes);
|
||||
expect(text).toContain("Operationen und Eingriffe");
|
||||
expect(text).toMatch(/Knee · Links/);
|
||||
expect(text).toMatch(/Knee \(Links\)/);
|
||||
});
|
||||
|
||||
it("omits the section when there is no procedure", async () => {
|
||||
@@ -819,7 +819,7 @@ describe("doctor-report condition body site", () => {
|
||||
it("prints the site with its side in the report's language", async () => {
|
||||
const text = await render(CONDITIONS, "de");
|
||||
expect(text).toContain("Körperstelle");
|
||||
expect(text).toMatch(/Knee · Links/);
|
||||
expect(text).toMatch(/Knee \(Links\)/);
|
||||
});
|
||||
|
||||
it("keeps the old table when no condition names a site", async () => {
|
||||
|
||||
@@ -100,7 +100,7 @@ describe("readCoachCorrelations", () => {
|
||||
pValue: 0.001,
|
||||
qValue: 0.02,
|
||||
interpretation:
|
||||
"Higher time in daylight tends to go with higher next-day sleep duration in your data — a pattern worth watching, not a cause.",
|
||||
"Higher time in daylight tends to go with higher next-day sleep duration in your data. It is a pattern to watch, not a cause.",
|
||||
lagDays: 1,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -345,7 +345,7 @@ describe("executeCoachTool", () => {
|
||||
lagDays: 1,
|
||||
n: 42,
|
||||
r: 0.31,
|
||||
note: "Higher time in daylight tends to go with higher next-day sleep duration in your data — a pattern worth watching, not a cause.",
|
||||
note: "Higher time in daylight tends to go with higher next-day sleep duration in your data. It is a pattern to watch, not a cause.",
|
||||
},
|
||||
],
|
||||
coincident: {
|
||||
|
||||
@@ -118,7 +118,7 @@ describe("formatRelationsBlock — cross-metric correlations, descriptive only",
|
||||
const rels: RelevantCorrelation[] = [
|
||||
{
|
||||
interpretation:
|
||||
"Higher time in daylight tends to go with higher next-day sleep duration in your data — a pattern worth watching, not a cause.",
|
||||
"Higher time in daylight tends to go with higher next-day sleep duration in your data. It is a pattern to watch, not a cause.",
|
||||
n: 42,
|
||||
r: 0.51,
|
||||
},
|
||||
|
||||
@@ -457,7 +457,7 @@ describe("generateMetricStatus — generation path", () => {
|
||||
vi.mocked(getRelevantCorrelationsForMetric).mockResolvedValueOnce([
|
||||
{
|
||||
interpretation:
|
||||
"Higher time in daylight tends to go with lower next-day resting heart rate in your data — a pattern worth watching, not a cause.",
|
||||
"Higher time in daylight tends to go with lower next-day resting heart rate in your data. It is a pattern to watch, not a cause.",
|
||||
n: 35,
|
||||
r: -0.46,
|
||||
},
|
||||
|
||||
@@ -157,8 +157,8 @@ describe("buildCoachReadStrip — the pattern line speaks the reader's language"
|
||||
// A sentence that is different but wrong is still a broken page.
|
||||
expect(note).toBe(
|
||||
"Wenn die Symptomstärke höher liegt, scheint die Schlafdauer am " +
|
||||
"Folgetag nach bisheriger Datenlage eher niedriger auszufallen — ein " +
|
||||
"Muster, das Beachtung verdient, keine Ursache.",
|
||||
"Folgetag nach bisheriger Datenlage eher niedriger auszufallen. Das ist " +
|
||||
"ein Muster, das Beachtung verdient, keine Ursache.",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -170,8 +170,8 @@ describe("buildCoachReadStrip — the pattern line speaks the reader's language"
|
||||
expect(strip.driver).not.toBeNull();
|
||||
expect(strip.driver!.note).toBe(
|
||||
"Higher symptom severity looks like it goes with lower next-day sleep " +
|
||||
"duration in your data, on the evidence so far — a pattern worth " +
|
||||
"watching, not a cause.",
|
||||
"duration in your data, on the evidence so far. It is a pattern " +
|
||||
"to watch, not a cause.",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user