mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Merge the separator sweep into the day and Coach polish
This commit is contained in:
commit
da92dabb55
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"');
|
||||
});
|
||||
|
||||
@@ -188,8 +188,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"');
|
||||
});
|
||||
|
||||
@@ -228,10 +232,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