style: apply prettier across the tree

One-shot prettier pass so the new format:check gate starts from a clean
baseline. Formatting only — no behaviour change. Generator-owned and
vendored artifacts stay untouched via .prettierignore.
This commit is contained in:
Marc-André Bombeck committed 2026-06-19 22:20:31 +02:00
1 parent b3fea2ef59
commit 1f4ba5939d
1000 files changed
+12130 -8385

No files matched your search

@@ -50,8 +50,7 @@ describe("motion-reduce coverage — every animate-spin pairs with motion-reduce
lines.forEach((line, idx) => {
if (!line.includes("animate-spin")) return;
// Skip comments and string literals that aren't classNames.
if (line.trim().startsWith("//") || line.trim().startsWith("*"))
return;
if (line.trim().startsWith("//") || line.trim().startsWith("*")) return;
if (!line.includes("motion-reduce:animate-none")) {
violations.push(`${file}:${idx + 1}: ${line.trim()}`);
}
+3 -1
View File
@@ -212,7 +212,9 @@ export function AdminShell({ active, children }: AdminShellProps) {
// with the top of the first card by construction. `/admin` overview
// (activeSlug === null) shows the console title; each section shows its own.
const headingTitle =
activeSlug === null ? t("admin.title") : t(`admin.section.${activeSlug}.title`);
activeSlug === null
? t("admin.title")
: t(`admin.section.${activeSlug}.title`);
const headingSubtitle =
activeSlug === null
? t("admin.subtitle")
+60 -60
View File
@@ -115,67 +115,67 @@ export function AiQualitySection() {
<div className="overflow-x-auto">
<table className="w-full text-sm" data-slot="ai-quality-table">
<thead>
<tr className="text-muted-foreground border-border border-b text-left text-xs tracking-wider uppercase">
<th className="py-2 pr-3 font-medium">
{t("admin.aiQuality.colSeverity")}
</th>
<th className="py-2 pr-3 font-medium">
{t("admin.aiQuality.colMetric")}
</th>
<th className="py-2 pr-3 font-medium">
{t("admin.aiQuality.colProvider")}
</th>
<th className="py-2 pr-3 font-medium">
{t("admin.aiQuality.colPromptVersion")}
</th>
<th className="py-2 pr-3 font-medium tabular-nums">
{t("admin.aiQuality.colHelpful")}
</th>
<th className="py-2 pr-3 font-medium tabular-nums">
{t("admin.aiQuality.colNotHelpful")}
</th>
<th className="py-2 font-medium tabular-nums">
{t("admin.aiQuality.colHelpfulRate")}
</th>
</tr>
</thead>
<tbody>
{buckets.map((bucket, idx) => {
const ratePct = Math.round(bucket.helpfulRate * 100);
return (
<tr
key={`${bucket.severity}-${bucket.metricSourceType}-${bucket.providerType}-${bucket.promptVersion}-${idx}`}
className="border-border/40 border-b last:border-0"
data-slot="ai-quality-bucket"
>
<td
className={`py-2 pr-3 font-medium ${
SEVERITY_TINT[bucket.severity] ?? ""
}`}
<tr className="text-muted-foreground border-border border-b text-left text-xs tracking-wider uppercase">
<th className="py-2 pr-3 font-medium">
{t("admin.aiQuality.colSeverity")}
</th>
<th className="py-2 pr-3 font-medium">
{t("admin.aiQuality.colMetric")}
</th>
<th className="py-2 pr-3 font-medium">
{t("admin.aiQuality.colProvider")}
</th>
<th className="py-2 pr-3 font-medium">
{t("admin.aiQuality.colPromptVersion")}
</th>
<th className="py-2 pr-3 font-medium tabular-nums">
{t("admin.aiQuality.colHelpful")}
</th>
<th className="py-2 pr-3 font-medium tabular-nums">
{t("admin.aiQuality.colNotHelpful")}
</th>
<th className="py-2 font-medium tabular-nums">
{t("admin.aiQuality.colHelpfulRate")}
</th>
</tr>
</thead>
<tbody>
{buckets.map((bucket, idx) => {
const ratePct = Math.round(bucket.helpfulRate * 100);
return (
<tr
key={`${bucket.severity}-${bucket.metricSourceType}-${bucket.providerType}-${bucket.promptVersion}-${idx}`}
className="border-border/40 border-b last:border-0"
data-slot="ai-quality-bucket"
>
{bucket.severity}
</td>
<td className="py-2 pr-3">{bucket.metricSourceType}</td>
<td className="py-2 pr-3">{bucket.providerType}</td>
<td className="text-muted-foreground py-2 pr-3 text-xs">
{bucket.promptVersion}
</td>
<td className="py-2 pr-3 tabular-nums">{bucket.helpful}</td>
<td className="py-2 pr-3 tabular-nums">
{bucket.notHelpful}
</td>
<td
className={`py-2 font-semibold tabular-nums ${helpfulRateColour(
bucket.helpfulRate,
)}`}
>
{ratePct}%
</td>
</tr>
);
})}
</tbody>
</table>
<td
className={`py-2 pr-3 font-medium ${
SEVERITY_TINT[bucket.severity] ?? ""
}`}
>
{bucket.severity}
</td>
<td className="py-2 pr-3">{bucket.metricSourceType}</td>
<td className="py-2 pr-3">{bucket.providerType}</td>
<td className="text-muted-foreground py-2 pr-3 text-xs">
{bucket.promptVersion}
</td>
<td className="py-2 pr-3 tabular-nums">{bucket.helpful}</td>
<td className="py-2 pr-3 tabular-nums">
{bucket.notHelpful}
</td>
<td
className={`py-2 font-semibold tabular-nums ${helpfulRateColour(
bucket.helpfulRate,
)}`}
>
{ratePct}%
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
+201 -193
View File
@@ -194,210 +194,218 @@ export function RemindersSection() {
return (
<>
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
{/* v1.4.22 D / F-58 — icon swap so the section header matches
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
{/* v1.4.22 D / F-58 — icon swap so the section header matches
the admin-shell nav tile (Bell). Clock now lives on the
timing chips inline instead of competing as the page icon. */}
<SettingsCardHeader
icon={Bell}
title={t("admin.medicationReminders")}
description={t("admin.medicationRemindersDescription")}
/>
<SettingsCardHeader
icon={Bell}
title={t("admin.medicationReminders")}
description={t("admin.medicationRemindersDescription")}
/>
<div className="mt-4 space-y-3 pl-7">
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="admin-reminder-late" className="text-xs">
{t("admin.reminderLateMinutes")}
</Label>
<p className="text-muted-foreground text-xs">
{t("admin.reminderLateMinutesDescription")}
</p>
<Input
id="admin-reminder-late"
type="number"
inputMode="numeric"
enterKeyHint="next"
min={15}
max={480}
value={reminderLateDraft ?? settings?.reminderLateMinutes ?? 120}
onChange={(e) => setReminderLateDraft(Number(e.target.value))}
autoComplete="off"
data-lpignore="true"
data-1p-ignore="true"
disabled={updateSettings.isPending}
className="w-32"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="admin-reminder-missed" className="text-xs">
{t("admin.reminderMissedMinutes")}
</Label>
<p className="text-muted-foreground text-xs">
{t("admin.reminderMissedMinutesDescription")}
</p>
<Input
id="admin-reminder-missed"
type="number"
inputMode="numeric"
enterKeyHint="done"
min={30}
max={720}
value={
reminderMissedDraft ?? settings?.reminderMissedMinutes ?? 240
}
onChange={(e) => setReminderMissedDraft(Number(e.target.value))}
autoComplete="off"
data-lpignore="true"
data-1p-ignore="true"
disabled={updateSettings.isPending}
className="w-32"
/>
<div className="mt-4 space-y-3 pl-7">
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="admin-reminder-late" className="text-xs">
{t("admin.reminderLateMinutes")}
</Label>
<p className="text-muted-foreground text-xs">
{t("admin.reminderLateMinutesDescription")}
</p>
<Input
id="admin-reminder-late"
type="number"
inputMode="numeric"
enterKeyHint="next"
min={15}
max={480}
value={
reminderLateDraft ?? settings?.reminderLateMinutes ?? 120
}
onChange={(e) => setReminderLateDraft(Number(e.target.value))}
autoComplete="off"
data-lpignore="true"
data-1p-ignore="true"
disabled={updateSettings.isPending}
className="w-32"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="admin-reminder-missed" className="text-xs">
{t("admin.reminderMissedMinutes")}
</Label>
<p className="text-muted-foreground text-xs">
{t("admin.reminderMissedMinutesDescription")}
</p>
<Input
id="admin-reminder-missed"
type="number"
inputMode="numeric"
enterKeyHint="done"
min={30}
max={720}
value={
reminderMissedDraft ?? settings?.reminderMissedMinutes ?? 240
}
onChange={(e) => setReminderMissedDraft(Number(e.target.value))}
autoComplete="off"
data-lpignore="true"
data-1p-ignore="true"
disabled={updateSettings.isPending}
className="w-32"
/>
</div>
</div>
</div>
</div>
<div className="mt-4 flex justify-end gap-2">
<Button
size="sm"
variant="outline"
onClick={() => testNotification.mutate()}
disabled={testNotification.isPending}
>
{testNotification.isPending && (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
<Bell className="h-3.5 w-3.5" />
{t("admin.notificationTestSend")}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => reminderCheck.mutate()}
disabled={reminderCheck.isPending}
>
{reminderCheck.isPending && (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
<Activity className="h-3.5 w-3.5" />
{t("admin.reminderCheckRun")}
</Button>
<Button
size="sm"
onClick={() => {
updateSettings.mutate(
{
...(reminderLateDraft != null && {
reminderLateMinutes: reminderLateDraft,
}),
...(reminderMissedDraft != null && {
reminderMissedMinutes: reminderMissedDraft,
}),
},
{
onSuccess: () => {
setReminderLateDraft(null);
setReminderMissedDraft(null);
<div className="mt-4 flex justify-end gap-2">
<Button
size="sm"
variant="outline"
onClick={() => testNotification.mutate()}
disabled={testNotification.isPending}
>
{testNotification.isPending && (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
<Bell className="h-3.5 w-3.5" />
{t("admin.notificationTestSend")}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => reminderCheck.mutate()}
disabled={reminderCheck.isPending}
>
{reminderCheck.isPending && (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
<Activity className="h-3.5 w-3.5" />
{t("admin.reminderCheckRun")}
</Button>
<Button
size="sm"
onClick={() => {
updateSettings.mutate(
{
...(reminderLateDraft != null && {
reminderLateMinutes: reminderLateDraft,
}),
...(reminderMissedDraft != null && {
reminderMissedMinutes: reminderMissedDraft,
}),
},
},
);
}}
disabled={
updateSettings.isPending ||
(reminderLateDraft == null && reminderMissedDraft == null)
}
>
{updateSettings.isPending && (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("common.save")}
</Button>
</div>
{
onSuccess: () => {
setReminderLateDraft(null);
setReminderMissedDraft(null);
},
},
);
}}
disabled={
updateSettings.isPending ||
(reminderLateDraft == null && reminderMissedDraft == null)
}
>
{updateSettings.isPending && (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("common.save")}
</Button>
</div>
{testNotification.data?.results &&
testNotification.data.results.length > 0 && (
<div className="mt-4 space-y-1">
{testNotification.data.results.map((r, i) => (
<div key={i} className="flex items-center gap-1.5 text-xs">
{r.success ? (
<CheckCircle2 className="text-success h-3.5 w-3.5 shrink-0 dark:text-green-400" />
) : (
<XCircle className="text-destructive h-3.5 w-3.5 shrink-0 dark:text-red-400" />
)}
<span className="font-medium">{r.channel}</span>
{r.error && (
<span className="text-muted-foreground">— {r.error}</span>
)}
</div>
))}
</div>
)}
{reminderCheck.data?.medications &&
reminderCheck.data.medications.length > 0 && (
<div className="mt-4 space-y-2">
{/* v1.4.33 IW9 — h4 -> h2 so the page outline stays
sequential (parent admin SectionFrame ships <h1>). */}
<h2 className="text-sm font-medium">
{t("admin.reminderCheckResults")}
</h2>
<div className="space-y-2">
{reminderCheck.data.medications.map((med, i) => (
<div key={i} className="bg-muted/50 space-y-1.5 rounded-lg p-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">
{med.name} ({med.dose})
</span>
<Badge
variant={
med.notificationsEnabled ? "default" : "secondary"
}
>
{med.notificationsEnabled
? t("admin.reminderCheckNotifOn")
: t("admin.reminderCheckNotifOff")}
</Badge>
</div>
<p className="text-muted-foreground text-xs">
{med.user} — {med.dayOfWeek} {med.localTime} —{" "}
{t("admin.reminderCheckEventsToday")}: {med.eventsToday}
</p>
{med.schedules.map((sched, j) => {
const statusColor =
sched.status === "open"
? "text-success dark:text-green-400"
: sched.status === "threshold"
? "text-warning dark:text-yellow-400"
: sched.status === "missed"
? "text-destructive dark:text-red-400"
: sched.status === "skipped"
? "text-muted-foreground"
: "";
return (
<div key={j} className="flex items-start gap-1.5 text-xs">
<span className="text-muted-foreground shrink-0">
{sched.window}
</span>
<span className="text-muted-foreground shrink-0">
[{sched.days}]
</span>
<span className={statusColor}>{sched.label}</span>
{sched.notificationSent && (
<span className="text-success flex shrink-0 items-center gap-0.5 dark:text-green-400">
<Bell className="h-3 w-3" />
{t("admin.reminderCheckNotifSent")}
</span>
)}
</div>
);
})}
{testNotification.data?.results &&
testNotification.data.results.length > 0 && (
<div className="mt-4 space-y-1">
{testNotification.data.results.map((r, i) => (
<div key={i} className="flex items-center gap-1.5 text-xs">
{r.success ? (
<CheckCircle2 className="text-success h-3.5 w-3.5 shrink-0 dark:text-green-400" />
) : (
<XCircle className="text-destructive h-3.5 w-3.5 shrink-0 dark:text-red-400" />
)}
<span className="font-medium">{r.channel}</span>
{r.error && (
<span className="text-muted-foreground">— {r.error}</span>
)}
</div>
))}
</div>
</div>
)}
</div>
<NotificationHealthPanel />
)}
{reminderCheck.data?.medications &&
reminderCheck.data.medications.length > 0 && (
<div className="mt-4 space-y-2">
{/* v1.4.33 IW9 — h4 -> h2 so the page outline stays
sequential (parent admin SectionFrame ships <h1>). */}
<h2 className="text-sm font-medium">
{t("admin.reminderCheckResults")}
</h2>
<div className="space-y-2">
{reminderCheck.data.medications.map((med, i) => (
<div
key={i}
className="bg-muted/50 space-y-1.5 rounded-lg p-3"
>
<div className="flex items-center justify-between">
<span className="text-sm font-medium">
{med.name} ({med.dose})
</span>
<Badge
variant={
med.notificationsEnabled ? "default" : "secondary"
}
>
{med.notificationsEnabled
? t("admin.reminderCheckNotifOn")
: t("admin.reminderCheckNotifOff")}
</Badge>
</div>
<p className="text-muted-foreground text-xs">
{med.user} — {med.dayOfWeek} {med.localTime} —{" "}
{t("admin.reminderCheckEventsToday")}: {med.eventsToday}
</p>
{med.schedules.map((sched, j) => {
const statusColor =
sched.status === "open"
? "text-success dark:text-green-400"
: sched.status === "threshold"
? "text-warning dark:text-yellow-400"
: sched.status === "missed"
? "text-destructive dark:text-red-400"
: sched.status === "skipped"
? "text-muted-foreground"
: "";
return (
<div
key={j}
className="flex items-start gap-1.5 text-xs"
>
<span className="text-muted-foreground shrink-0">
{sched.window}
</span>
<span className="text-muted-foreground shrink-0">
[{sched.days}]
</span>
<span className={statusColor}>{sched.label}</span>
{sched.notificationSent && (
<span className="text-success flex shrink-0 items-center gap-0.5 dark:text-green-400">
<Bell className="h-3 w-3" />
{t("admin.reminderCheckNotifSent")}
</span>
)}
</div>
);
})}
</div>
))}
</div>
</div>
)}
</div>
<NotificationHealthPanel />
</>
);
}
@@ -86,9 +86,7 @@ describe("BD-Zielbereich tile (v1.4.28 FB-C1/C2 rewrite)", () => {
);
// Same primitive slots on both tiles.
for (const slot of [
"trend-card-value-row",
]) {
for (const slot of ["trend-card-value-row"]) {
expect(bdHtml).toContain(`data-slot="${slot}"`);
expect(weightHtml).toContain(`data-slot="${slot}"`);
}
@@ -100,9 +100,8 @@ describe("chart isError rendering — error state, not empty state", () => {
mockQueryError();
const { I18nProvider } = await import("@/lib/i18n/context");
const { MedicationComplianceChart } = await import(
"../medication-compliance-chart"
);
const { MedicationComplianceChart } =
await import("../medication-compliance-chart");
const html = renderToStaticMarkup(
<I18nProvider initialLocale="de" initialMessages={deMessages}>
@@ -125,9 +124,7 @@ describe("<ChartErrorState> — announcement, sizing, accessible name", () => {
});
async function renderState(
props: Partial<
import("../chart-error-state").ChartErrorStateProps
> = {},
props: Partial<import("../chart-error-state").ChartErrorStateProps> = {},
) {
const { ChartErrorState } = await import("../chart-error-state");
return renderToStaticMarkup(
@@ -29,7 +29,6 @@ describe("<MedicationComplianceChart> — empty-state gate by raw count", () =>
vi.resetModules();
});
it("renders need-more-days copy when chartData.length < 3 but rawCount >= 3", async () => {
const data = [
{ date: "2026-05-19", scheduled: 10, taken: 8 },
@@ -48,9 +47,8 @@ describe("<MedicationComplianceChart> — empty-state gate by raw count", () =>
}));
const { I18nProvider } = await import("@/lib/i18n/context");
const { MedicationComplianceChart } = await import(
"../medication-compliance-chart"
);
const { MedicationComplianceChart } =
await import("../medication-compliance-chart");
const html = renderToStaticMarkup(
<I18nProvider initialLocale="de" initialMessages={deMessages}>
@@ -79,9 +77,8 @@ describe("<MedicationComplianceChart> — empty-state gate by raw count", () =>
}));
const { I18nProvider } = await import("@/lib/i18n/context");
const { MedicationComplianceChart } = await import(
"../medication-compliance-chart"
);
const { MedicationComplianceChart } =
await import("../medication-compliance-chart");
const html = renderToStaticMarkup(
<I18nProvider initialLocale="de" initialMessages={deMessages}>
@@ -28,7 +28,6 @@ describe("<MoodChart> — empty-state gate by raw count", () => {
vi.resetModules();
});
it("renders need-more-days copy when chartData.length < 3 but rawCount >= 3", async () => {
const data = {
entries: [
@@ -209,8 +209,10 @@ describe("dashboard.*Short keys exist across all six locales", () => {
.default;
const dashboard = messages.dashboard ?? {};
for (const key of REQUIRED_KEYS) {
expect(dashboard[key], `dashboard.${key} missing in ${locale}.json`)
.toBeTypeOf("string");
expect(
dashboard[key],
`dashboard.${key} missing in ${locale}.json`,
).toBeTypeOf("string");
expect(
(dashboard[key] as string).length,
`dashboard.${key} empty in ${locale}.json`,
@@ -36,12 +36,12 @@ const baseProps = {
describe("<TrendCard staleDays>", () => {
it("stays silent when the metric is fresh (<= 7 days)", () => {
const html = render(<TrendCard {...baseProps} staleDays={3} />);
expect(html).not.toContain("data-slot=\"tile-stale-hint\"");
expect(html).not.toContain('data-slot="tile-stale-hint"');
});
it("renders day-bucket copy when staleDays is in (7, 30]", () => {
const html = render(<TrendCard {...baseProps} staleDays={12} />, "en");
expect(html).toContain("data-slot=\"tile-stale-hint\"");
expect(html).toContain('data-slot="tile-stale-hint"');
expect(html).toContain("Last reading 12 d ago");
});
@@ -248,7 +248,7 @@ export function ChartOverlayControlsBody({
prefs.comparisonBaseline === value ? "default" : "outline"
}
size="sm"
className={`min-h-11 sm:min-h-9 px-2 text-[11px] ${isGreyed ? "opacity-50" : ""}`.trim()}
className={`min-h-11 px-2 text-[11px] sm:min-h-9 ${isGreyed ? "opacity-50" : ""}`.trim()}
onClick={() => setComparisonBaseline(value)}
aria-pressed={prefs.comparisonBaseline === value}
aria-disabled={isGreyed || undefined}
+1 -4
View File
@@ -69,10 +69,7 @@ export function ChartSkeleton({
const [showSlowHint, setShowSlowHint] = useState(false);
useEffect(() => {
if (mini) return undefined;
const handle = setTimeout(
() => setShowSlowHint(true),
SLOW_HINT_DELAY_MS,
);
const handle = setTimeout(() => setShowSlowHint(true), SLOW_HINT_DELAY_MS);
return () => clearTimeout(handle);
}, [mini]);
+8 -2
View File
@@ -259,7 +259,11 @@ export function ComplianceHeatmap({
only kicks in for the non-stretch (static 18 px cell) case
when the parent column is narrower than `weeks * 21 px`. */}
<div
className={stretch ? "w-full sm:w-full overflow-x-auto sm:overflow-visible" : "overflow-x-auto"}
className={
stretch
? "w-full overflow-x-auto sm:w-full sm:overflow-visible"
: "overflow-x-auto"
}
>
<svg
width={svgWidth}
@@ -269,7 +273,9 @@ export function ComplianceHeatmap({
// off `containerWidth`, so a full window still fills the row) rather
// than CSS-stretching ~5 columns of capped cells into wide rectangles.
className={stretch ? "block max-w-full" : "block"}
onMouseLeave={() => setTooltip((prev) => (prev?.pinned ? prev : null))}
onMouseLeave={() =>
setTooltip((prev) => (prev?.pinned ? prev : null))
}
>
{/* Month labels */}
{monthMarkers.map((m, i) => (
+27 -22
View File
@@ -740,7 +740,9 @@ export function HealthChart({
// request so a longer comparison window still resolves.
sleepParams.set(
"days",
String(Math.max(1, Math.min(3650, Math.ceil(fetchWindow.windowDays)))),
String(
Math.max(1, Math.min(3650, Math.ceil(fetchWindow.windowDays))),
),
);
// v1.16.8 — a failed fetch rejects the whole query instead of
// silently resolving an empty series: the swallowed-catch
@@ -872,9 +874,13 @@ export function HealthChart({
const scaledMin = measurement.minValue * valueScale;
const scaledMax = measurement.maxValue * valueScale;
current.min =
current.min === null ? scaledMin : Math.min(current.min, scaledMin);
current.min === null
? scaledMin
: Math.min(current.min, scaledMin);
current.max =
current.max === null ? scaledMax : Math.max(current.max, scaledMax);
current.max === null
? scaledMax
: Math.max(current.max, scaledMax);
}
bucket.values[type] = current;
dailyAggregates.set(dayKey, bucket);
@@ -1063,20 +1069,20 @@ export function HealthChart({
// recomputes whenever the range tab changes the visible slice, so the
// `<MetricStatStrip>` a sub-page lifts the callback into always reflects the
// same window the chart paints. Skipped in mini mode (no stat strip).
const visibleStatsByType = useMemo<Record<string, MetricWindowStats> | null>(
() => {
if (mini || !chartData?.length) return null;
const out: Record<string, MetricWindowStats> = {};
for (const type of types) {
const stats = computeWindowStats(
chartData.map((point) => point[type] as number | undefined),
);
if (stats.count > 0) out[type] = stats;
}
return Object.keys(out).length > 0 ? out : null;
},
[mini, chartData, types],
);
const visibleStatsByType = useMemo<Record<
string,
MetricWindowStats
> | null>(() => {
if (mini || !chartData?.length) return null;
const out: Record<string, MetricWindowStats> = {};
for (const type of types) {
const stats = computeWindowStats(
chartData.map((point) => point[type] as number | undefined),
);
if (stats.count > 0) out[type] = stats;
}
return Object.keys(out).length > 0 ? out : null;
}, [mini, chartData, types]);
// Report the visible-range stats up to the sub-page so the shared
// `<MetricStatStrip>` can read them. Effect (not render-time call) so the
@@ -1674,10 +1680,7 @@ export function HealthChart({
// positions through the `ticks` prop so the legacy
// day-aware density policy stays effective on the
// pulse chart.
ticks={computeTickPositions(
chartData ?? [],
viewportWidth,
)}
ticks={computeTickPositions(chartData ?? [], viewportWidth)}
padding={{ left: 10, right: 10 }}
tickMargin={10}
/>
@@ -1690,7 +1693,9 @@ export function HealthChart({
domain={yDomain}
tickFormatter={(value) =>
typeof value === "number"
? formatAxisValue(useDecimalAxis ? value : Math.round(value))
? formatAxisValue(
useDecimalAxis ? value : Math.round(value),
)
: String(value)
}
unit={
@@ -246,7 +246,9 @@ export function MedicationComplianceChart({
const { data, isLoading, isError, refetch } = useQuery({
queryKey: queryKeys.dashboardMedicationCompliance(days),
queryFn: async (): Promise<DailyCompliancePoint[]> => {
return apiGet<DailyCompliancePoint[]>(`/api/medications/intake?scope=compliance&days=${days}`);
return apiGet<DailyCompliancePoint[]>(
`/api/medications/intake?scope=compliance&days=${days}`,
);
},
enabled: isAuthenticated,
});
@@ -452,7 +454,7 @@ export function MedicationComplianceChart({
/>
)
) : (
<div className="h-[var(--chart-height,240px)] md:h-[var(--chart-height-md,280px)] touch-pan-y">
<div className="h-[var(--chart-height,240px)] touch-pan-y md:h-[var(--chart-height-md,280px)]">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart
data={chartData}
+19 -6
View File
@@ -207,7 +207,7 @@ export function MoodHeatmap({
<div
className={
stretch
? "w-full sm:w-full overflow-x-auto sm:overflow-visible"
? "w-full overflow-x-auto sm:w-full sm:overflow-visible"
: "overflow-x-auto"
}
>
@@ -218,7 +218,9 @@ export function MoodHeatmap({
// natural, square, left-aligned grid rather than CSS-stretching a few
// capped columns into wide rectangles. Mirrors `compliance-heatmap`.
className={stretch ? "block max-w-full" : "block"}
onMouseLeave={() => setTooltip((prev) => (prev?.pinned ? prev : null))}
onMouseLeave={() =>
setTooltip((prev) => (prev?.pinned ? prev : null))
}
>
{monthMarkers.map((m, i) => (
<text
@@ -254,7 +256,9 @@ export function MoodHeatmap({
if (!cell.cell) {
return `${formatDateDE(cell.dateKey)}: ${t("insights.mood.heatmapNoEntry")}`;
}
const labelKey = moodLabelKeyForScore(Math.round(cell.cell.score));
const labelKey = moodLabelKeyForScore(
Math.round(cell.cell.score),
);
const moodLabel = labelKey ? t(labelKey) : "";
return `${formatDateDE(cell.dateKey)}: ${cell.cell.score.toFixed(1)}${moodLabel ? ` · ${moodLabel}` : ""}`;
};
@@ -310,9 +314,18 @@ export function MoodHeatmap({
style={{ marginLeft: stretch ? 0 : labelWidth }}
>
{[
{ color: "var(--dracula-green)", label: t("insights.mood.legendGreat") },
{ color: "var(--dracula-yellow)", label: t("insights.mood.legendGood") },
{ color: "var(--dracula-orange)", label: t("insights.mood.legendOkay") },
{
color: "var(--dracula-green)",
label: t("insights.mood.legendGreat"),
},
{
color: "var(--dracula-yellow)",
label: t("insights.mood.legendGood"),
},
{
color: "var(--dracula-orange)",
label: t("insights.mood.legendOkay"),
},
{ color: "var(--dracula-red)", label: t("insights.mood.legendLow") },
].map(({ color, label }) => (
<span key={label} className="flex items-center gap-1">
@@ -97,7 +97,7 @@ export function ScatterCorrelationChart<T extends Record<string, number>>({
// shape once layout settles; this floor only prevents the brief
// zero-height window that fires Recharts' width(-1)/height(-1)
// console warning when the chart swaps in after the skeleton.
className="touch-pan-y aspect-square min-h-[180px] sm:aspect-[3/2] sm:h-auto"
className="aspect-square min-h-[180px] touch-pan-y sm:aspect-[3/2] sm:h-auto"
style={{ height: undefined }}
data-explicit-height={height}
>
+40 -44
View File
@@ -134,11 +134,7 @@ export function TrendCard({
// appeared on the same tile because slope30 and trend7Delta could
// legitimately disagree (30-day downward arc, 7-day uptick).
const primarySignal: number | null =
trend7Delta != null
? trend7Delta
: slope30
? slope30.slope * 7
: null;
trend7Delta != null ? trend7Delta : slope30 ? slope30.slope * 7 : null;
const TrendIcon = ((): typeof ArrowUp => {
if (primarySignal == null) return Minus;
@@ -233,51 +229,51 @@ export function TrendCard({
// deterministic height. No unit; the arrow slot below still
// renders its muted placeholder so the row width stays stable.
<span
className="text-muted-foreground min-w-0 truncate text-base font-medium leading-none"
className="text-muted-foreground min-w-0 truncate text-base leading-none font-medium"
data-slot="trend-card-empty-hint"
>
{emptyHint}
</span>
) : (
<>
<span
className={cn(
"shrink-0 whitespace-nowrap font-semibold tracking-tight tabular-nums",
// v1.8.7 — the value NEVER truncates, clips, or wraps
// mid-number. The maintainer is emphatic: every digit of
// the reading must render in full ("130 mmHg" must read as
// "130", never "13" then "3 mmHg"). The headline value span
// is `shrink-0 whitespace-nowrap` so it keeps its full
// intrinsic width and wins the horizontal-space contest
// against the unit. When the strip is dense (many tiles →
// narrow column) the UNIT yields — it shrinks (`min-w-0
// truncate`) and may ellipsis — rather than losing any
// digit of the number. The earlier `min-w-0 truncate` on
// this span was the bug: it let the value shrink below its
// content width and clip the number.
//
// v1.4.49.4 — paired values (BP systolic/diastolic) render
// as `131/85` and outgrow the `text-3xl` footprint on the
// narrowest grid columns. Paired tiles drop one font step
// (text-3xl → text-2xl) so the full pair fits; single-value
// tiles (weight, pulse, glucose, …) keep text-3xl.
secondary ? "text-2xl" : "text-3xl",
// `leading-none` must come AFTER the font-size class in the
// cn() arg list. Tailwind v4 `text-3xl` / `text-2xl` ship
// their own default `line-height`, so `twMerge` would
// otherwise drop the explicit `leading-none` per the
// last-wins rule and break the W20a across-tile baseline
// alignment (digits line up at the same y-coordinate
// across every tile in the strip).
"leading-none",
)}
data-slot="trend-card-value"
>
{latest !== null ? renderPair(latest, secondary?.latest) : "—"}
</span>
<span className="text-muted-foreground min-w-0 truncate text-sm tabular-nums">
{unit}
</span>
<span
className={cn(
"shrink-0 font-semibold tracking-tight whitespace-nowrap tabular-nums",
// v1.8.7 — the value NEVER truncates, clips, or wraps
// mid-number. The maintainer is emphatic: every digit of
// the reading must render in full ("130 mmHg" must read as
// "130", never "13" then "3 mmHg"). The headline value span
// is `shrink-0 whitespace-nowrap` so it keeps its full
// intrinsic width and wins the horizontal-space contest
// against the unit. When the strip is dense (many tiles →
// narrow column) the UNIT yields — it shrinks (`min-w-0
// truncate`) and may ellipsis — rather than losing any
// digit of the number. The earlier `min-w-0 truncate` on
// this span was the bug: it let the value shrink below its
// content width and clip the number.
//
// v1.4.49.4 — paired values (BP systolic/diastolic) render
// as `131/85` and outgrow the `text-3xl` footprint on the
// narrowest grid columns. Paired tiles drop one font step
// (text-3xl → text-2xl) so the full pair fits; single-value
// tiles (weight, pulse, glucose, …) keep text-3xl.
secondary ? "text-2xl" : "text-3xl",
// `leading-none` must come AFTER the font-size class in the
// cn() arg list. Tailwind v4 `text-3xl` / `text-2xl` ship
// their own default `line-height`, so `twMerge` would
// otherwise drop the explicit `leading-none` per the
// last-wins rule and break the W20a across-tile baseline
// alignment (digits line up at the same y-coordinate
// across every tile in the strip).
"leading-none",
)}
data-slot="trend-card-value"
>
{latest !== null ? renderPair(latest, secondary?.latest) : "—"}
</span>
<span className="text-muted-foreground min-w-0 truncate text-sm tabular-nums">
{unit}
</span>
</>
)}
<span
+12 -19
View File
@@ -50,16 +50,10 @@ function typeLabel(type: string, t: Translate): string {
}
/** Render a single labelled stat row. */
function StatRow({
label,
value,
}: {
label: string;
value: string;
}) {
function StatRow({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-baseline justify-between gap-4 border-b border-border/40 py-2 last:border-0">
<span className="text-sm text-muted-foreground">{label}</span>
<div className="border-border/40 flex items-baseline justify-between gap-4 border-b py-2 last:border-0">
<span className="text-muted-foreground text-sm">{label}</span>
<span className="text-sm font-medium tabular-nums">{value}</span>
</div>
);
@@ -73,7 +67,7 @@ function Section({
children: React.ReactNode;
}) {
return (
<section className="rounded-lg border border-border bg-card p-5">
<section className="border-border bg-card rounded-lg border p-5">
<h2 className="mb-3 text-base font-semibold">{title}</h2>
{children}
</section>
@@ -100,8 +94,7 @@ export function ClinicianView({
const complianceEntries = sections.compliance
? Object.entries(report.compliance).filter(([, c]) => c.total > 0)
: [];
const wellness =
report.wellnessScores?.filter((s) => s.count > 0) ?? [];
const wellness = report.wellnessScores?.filter((s) => s.count > 0) ?? [];
return (
<main
@@ -114,18 +107,18 @@ export function ClinicianView({
{t("clinicianView.title")}
</h1>
{label ? (
<p className="mt-1 text-sm text-muted-foreground">{label}</p>
<p className="text-muted-foreground mt-1 text-sm">{label}</p>
) : null}
<p className="mt-3 text-sm text-muted-foreground">
<p className="text-muted-foreground mt-3 text-sm">
{t("clinicianView.period", {
start: fmtDate(report.period.start),
end: fmtDate(report.period.end),
})}
</p>
<p className="mt-1 text-xs text-muted-foreground">
<p className="text-muted-foreground mt-1 text-xs">
{t("clinicianView.expires", { date: fmtDate(expiresAt) })}
</p>
<p className="mt-3 rounded-md border border-border bg-muted/40 p-3 text-xs text-muted-foreground">
<p className="border-border bg-muted/40 text-muted-foreground mt-3 rounded-md border p-3 text-xs">
{t("clinicianView.provenance")}
</p>
</header>
@@ -213,10 +206,10 @@ export function ClinicianView({
{/* ── Fenced wellness card (descriptive, NOT clinical) ────── */}
{wellness.length > 0 ? (
<section className="rounded-lg border border-dashed border-amber-500/50 bg-amber-500/5 p-5">
<h2 className="mb-1 text-base font-semibold text-muted-foreground">
<h2 className="text-muted-foreground mb-1 text-base font-semibold">
{t("clinicianView.wellness.title")}
</h2>
<p className="mb-3 text-xs text-muted-foreground">
<p className="text-muted-foreground mb-3 text-xs">
{t("clinicianView.wellness.disclaimer")}
</p>
<div>
@@ -239,7 +232,7 @@ export function ClinicianView({
) : null}
</div>
<footer className="mt-8 border-t border-border pt-4 text-center text-xs text-muted-foreground">
<footer className="border-border text-muted-foreground mt-8 border-t pt-4 text-center text-xs">
{t("clinicianView.footer")}
</footer>
</main>
@@ -79,7 +79,7 @@ export function ShareUnlockGate({ t, token }: ShareUnlockGateProps) {
return (
<main className="mx-auto flex min-h-screen max-w-md flex-col justify-center px-4">
<form
className="space-y-4 rounded-lg border border-border bg-card p-6"
className="border-border bg-card space-y-4 rounded-lg border p-6"
onSubmit={(e) => {
e.preventDefault();
void submit(passphrase);
@@ -89,7 +89,7 @@ export function ShareUnlockGate({ t, token }: ShareUnlockGateProps) {
<h1 className="text-lg font-semibold">
{t("clinicianView.unlock.title")}
</h1>
<p className="text-sm text-muted-foreground">
<p className="text-muted-foreground text-sm">
{t("clinicianView.unlock.description")}
</p>
</div>
@@ -112,7 +112,7 @@ export function ShareUnlockGate({ t, token }: ShareUnlockGateProps) {
</div>
{error && (
<p role="alert" className="text-sm text-destructive">
<p role="alert" className="text-destructive text-sm">
{t("clinicianView.unlock.error")}
</p>
)}
@@ -11,10 +11,7 @@ function render(node: React.ReactNode) {
);
}
function day(
date: string,
overrides: Partial<CalendarDay> = {},
): CalendarDay {
function day(date: string, overrides: Partial<CalendarDay> = {}): CalendarDay {
return {
date,
phase: "FOLLICULAR",
@@ -29,7 +29,10 @@ describe("<FieldInfo>", () => {
// Radix renders the tooltip content lazily (portal, on open); the trigger
// therefore reports a closed state until interacted with.
const html = render(
<FieldInfo label="Cervical mucus" detail="Discharge changes across the cycle." />,
<FieldInfo
label="Cervical mucus"
detail="Discharge changes across the cycle."
/>,
);
expect(html).toContain('data-state="closed"');
});
@@ -107,7 +107,11 @@ describe("buildDayLogInput (new row → omit empties)", () => {
);
expect(input.basalBodyTempC).toBe(36.5);
expect(input.temperatureExcluded).toBe(true);
const patch = buildDayLogPatch({ ...blank(), bbt: "36.5", bbtDisturbed: true });
const patch = buildDayLogPatch({
...blank(),
bbt: "36.5",
bbtDisturbed: true,
});
expect(patch.temperatureExcluded).toBe(true);
});
@@ -145,7 +149,13 @@ describe("buildDayLogInput (new row → omit empties)", () => {
describe("section summary-badge counts (v1.17.0)", () => {
it("counts each logged symptom", () => {
expect(symptomsCount(blank())).toBe(0);
const s = { ...blank(), symptoms: new Map([["cramps", 2], ["headache", null]]) };
const s = {
...blank(),
symptoms: new Map([
["cramps", 2],
["headache", null],
]),
};
expect(symptomsCount(s)).toBe(2);
});
+8 -12
View File
@@ -131,9 +131,7 @@ export function CycleHistoryChart({
{t("cycle.history.chartTitle")}
</CardTitle>
{hasData ? (
<CardDescription>
{t("cycle.history.chartCaption")}
</CardDescription>
<CardDescription>{t("cycle.history.chartCaption")}</CardDescription>
) : null}
</CardHeader>
<CardContent className="space-y-4">
@@ -144,7 +142,10 @@ export function CycleHistoryChart({
) : (
<>
{/* Stat chips — avg / variability / regularity classification. */}
<ul className="flex flex-wrap gap-2" data-slot="cycle-history-stats">
<ul
className="flex flex-wrap gap-2"
data-slot="cycle-history-stats"
>
<StatChip
hue={REST_HUE}
label={t("cycle.history.avgLength")}
@@ -187,9 +188,7 @@ export function CycleHistoryChart({
})}
periodLabel={t("cycle.history.legendPeriodSegment")}
ovulationLabel={t("cycle.calendar.legendOvulationConfirmed")}
barTitle={(b) =>
t("cycle.history.days", { count: b.lengthDays })
}
barTitle={(b) => t("cycle.history.days", { count: b.lengthDays })}
/>
{/* Inline legend mirroring the bar segments. */}
@@ -279,8 +278,7 @@ function BarField({
const x = cx - barW / 2;
const totalH = scale(b.lengthDays);
const topY = baseY - totalH;
const periodH =
b.periodDays != null ? scale(b.periodDays) : 0;
const periodH = b.periodDays != null ? scale(b.periodDays) : 0;
const periodTopY = baseY - periodH;
const restH = totalH - periodH;
const ovuY =
@@ -353,9 +351,7 @@ function StatChip({
} & React.HTMLAttributes<HTMLLIElement>) {
return (
<li
className={cn(
"bg-muted/40 flex items-center gap-2 rounded-md px-3 py-2",
)}
className={cn("bg-muted/40 flex items-center gap-2 rounded-md px-3 py-2")}
{...rest}
>
<span
+22 -22
View File
@@ -114,28 +114,28 @@ export function CycleRingTile({ className }: { className?: string }) {
className,
)}
>
<div className="text-foreground flex items-center gap-2">
<Droplets className="h-5 w-5 shrink-0" aria-hidden="true" />
<span className="truncate text-base leading-none font-semibold">
{t("cycle.wellnessRing.label")}
</span>
</div>
<div className="flex flex-col items-center gap-1.5">
<CycleRing
dayOfCycle={wheel.dayOfCycle}
cycleLength={wheel.cycleLength}
phase={wheel.phase}
spans={wheel.spans}
animate={play}
size={120}
/>
<span
data-slot="wellness-cycle-band-word"
className="text-muted-foreground text-center text-xs"
>
{t(`cycle.phase.${wheel.phase}`)}
</span>
</div>
<div className="text-foreground flex items-center gap-2">
<Droplets className="h-5 w-5 shrink-0" aria-hidden="true" />
<span className="truncate text-base leading-none font-semibold">
{t("cycle.wellnessRing.label")}
</span>
</div>
<div className="flex flex-col items-center gap-1.5">
<CycleRing
dayOfCycle={wheel.dayOfCycle}
cycleLength={wheel.cycleLength}
phase={wheel.phase}
spans={wheel.spans}
animate={play}
size={120}
/>
<span
data-slot="wellness-cycle-band-word"
className="text-muted-foreground text-center text-xs"
>
{t(`cycle.phase.${wheel.phase}`)}
</span>
</div>
</Link>
</div>
);
+1 -5
View File
@@ -17,11 +17,7 @@ import { Switch } from "@/components/ui/switch";
import { NativeSelect } from "@/components/ui/native-select";
import { Separator } from "@/components/ui/separator";
import { useTranslations } from "@/lib/i18n/context";
import type {
CycleGoal,
CycleProfileDTO,
SecondarySymptom,
} from "./types";
import type { CycleGoal, CycleProfileDTO, SecondarySymptom } from "./types";
import { useUpdateCyclePrefs } from "./use-cycle";
/**
+39 -42
View File
@@ -171,10 +171,7 @@ export function CycleView() {
node never matched, leaving the tile stuck at the rise rule's
`opacity: 0` (invisible but still boxed). The `contents` wrapper
carries the flag without adding a layout box. */}
<div
data-revealed={play ? "true" : undefined}
className="contents"
>
<div data-revealed={play ? "true" : undefined} className="contents">
<div
data-slot="cycle-wheel-tile"
style={
@@ -187,49 +184,49 @@ export function CycleView() {
play && "wellness-tile-rise",
)}
>
{loading ? (
<div className="flex h-[220px] items-center justify-center">
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
</div>
) : calendarError ? (
<div className="flex h-[220px] flex-col items-center justify-center gap-3 text-center">
<p className="text-muted-foreground text-sm">
{t("cycle.loadError")}
{loading ? (
<div className="flex h-[220px] items-center justify-center">
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
</div>
) : calendarError ? (
<div className="flex h-[220px] flex-col items-center justify-center gap-3 text-center">
<p className="text-muted-foreground text-sm">
{t("cycle.loadError")}
</p>
<Button
variant="outline"
size="sm"
className="gap-1.5"
onClick={() => void calendar.refetch()}
>
<RefreshCw className="h-3.5 w-3.5" aria-hidden="true" />
{t("common.retry")}
</Button>
</div>
) : (
<CycleRing
dayOfCycle={wheel.dayOfCycle}
cycleLength={wheel.cycleLength}
phase={wheel.phase}
spans={wheel.spans}
animate={play}
/>
)}
{!calendarError ? (
<p className="text-muted-foreground text-xs">
{t("cycle.ring.caption")}
</p>
) : null}
{/* First-period CTA — only when no cycle is active yet. */}
{!loading && !calendarError && wheel.dayOfCycle == null ? (
<Button
variant="outline"
size="sm"
className="gap-1.5"
onClick={() => void calendar.refetch()}
className="mt-1 w-full"
onClick={() => openSheet(today)}
>
<RefreshCw className="h-3.5 w-3.5" aria-hidden="true" />
{t("common.retry")}
{t("cycle.ring.firstPeriodCta")}
</Button>
</div>
) : (
<CycleRing
dayOfCycle={wheel.dayOfCycle}
cycleLength={wheel.cycleLength}
phase={wheel.phase}
spans={wheel.spans}
animate={play}
/>
)}
{!calendarError ? (
<p className="text-muted-foreground text-xs">
{t("cycle.ring.caption")}
</p>
) : null}
{/* First-period CTA — only when no cycle is active yet. */}
{!loading && !calendarError && wheel.dayOfCycle == null ? (
<Button
variant="outline"
className="mt-1 w-full"
onClick={() => openSheet(today)}
>
{t("cycle.ring.firstPeriodCta")}
</Button>
) : null}
) : null}
</div>
</div>
+134 -135
View File
@@ -42,10 +42,7 @@ import {
PopoverTrigger,
} from "@/components/ui/popover";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import {
SheetSection,
SheetSectionCount,
} from "@/components/ui/sheet-section";
import { SheetSection, SheetSectionCount } from "@/components/ui/sheet-section";
import { useTranslations } from "@/lib/i18n/context";
import { CUSTOM_SYMPTOM_ICON_ALLOWLIST } from "@/lib/cycle/custom-symptoms-shared";
import { FieldInfo } from "./field-info";
@@ -509,7 +506,9 @@ export function LogDaySheet({
const [cervixFirmness, setCervixFirmness] = useState<CervixFirmness | null>(
null,
);
const [cervixOpening, setCervixOpening] = useState<CervixOpening | null>(null);
const [cervixOpening, setCervixOpening] = useState<CervixOpening | null>(
null,
);
const [intercourse, setIntercourse] = useState(false);
const [protectedSex, setProtectedSex] = useState(false);
const [pregnancyTest, setPregnancyTest] = useState<HomeTestResult | null>(
@@ -884,121 +883,121 @@ export function LogDaySheet({
/>
}
>
<Input
type="number"
inputMode="decimal"
step="0.01"
min={30}
max={45}
value={bbt}
onChange={(e) => setBbt(e.target.value)}
placeholder={t("cycle.sheet.temperaturePlaceholder")}
className="w-32"
aria-label={t("cycle.sheet.temperature")}
/>
<p className="text-muted-foreground text-xs">
{t("cycle.sheet.bbtHint")}
</p>
{resolveBbt(bbt) != null && (
<div className="mt-3 flex items-start justify-between gap-3">
<div className="flex flex-col">
<span className="text-sm">
{t("cycle.sheet.temperatureDisturbed")}
</span>
<span className="text-muted-foreground text-xs">
{t("cycle.sheet.temperatureDisturbedHint")}
</span>
</div>
<Switch
checked={bbtDisturbed}
onCheckedChange={setBbtDisturbed}
aria-label={t("cycle.sheet.temperatureDisturbed")}
/>
</div>
)}
</Field>
{/* Ovulation test */}
<Field
label={t("cycle.sheet.ovulationTest")}
info={
<FieldInfo
label={t("cycle.fieldInfo.ovulationTestLabel")}
detail={t("cycle.fieldInfo.ovulationTest")}
<Input
type="number"
inputMode="decimal"
step="0.01"
min={30}
max={45}
value={bbt}
onChange={(e) => setBbt(e.target.value)}
placeholder={t("cycle.sheet.temperaturePlaceholder")}
className="w-32"
aria-label={t("cycle.sheet.temperature")}
/>
}
>
<div className="flex flex-wrap gap-2">
{OPK_VALUES.map((v) => (
<Chip
key={v}
active={opk === v}
onClick={() => setOpk((cur) => (cur === v ? null : v))}
>
{t(`cycle.ovulationTest.${v}`)}
</Chip>
))}
</div>
</Field>
{/* Symptothermal secondary symptom: cervical mucus (default) OR the
cervix observation, per the user's advanced-settings choice. Only one
of the two is offered so the log sheet never overwhelms. */}
{showCervix ? (
<Field label={t("cycle.sheet.cervix")}>
<div className="space-y-3">
<CervixRow
label={t("cycle.sheet.cervixPosition")}
values={CERVIX_POSITION_VALUES}
current={cervixPosition}
labelFor={(v) => t(`cycle.cervixPosition.${v}`)}
onToggle={(v) =>
setCervixPosition((cur) => (cur === v ? null : v))
}
/>
<CervixRow
label={t("cycle.sheet.cervixFirmness")}
values={CERVIX_FIRMNESS_VALUES}
current={cervixFirmness}
labelFor={(v) => t(`cycle.cervixFirmness.${v}`)}
onToggle={(v) =>
setCervixFirmness((cur) => (cur === v ? null : v))
}
/>
<CervixRow
label={t("cycle.sheet.cervixOpening")}
values={CERVIX_OPENING_VALUES}
current={cervixOpening}
labelFor={(v) => t(`cycle.cervixOpening.${v}`)}
onToggle={(v) =>
setCervixOpening((cur) => (cur === v ? null : v))
}
/>
</div>
<p className="text-muted-foreground text-xs">
{t("cycle.sheet.bbtHint")}
</p>
{resolveBbt(bbt) != null && (
<div className="mt-3 flex items-start justify-between gap-3">
<div className="flex flex-col">
<span className="text-sm">
{t("cycle.sheet.temperatureDisturbed")}
</span>
<span className="text-muted-foreground text-xs">
{t("cycle.sheet.temperatureDisturbedHint")}
</span>
</div>
<Switch
checked={bbtDisturbed}
onCheckedChange={setBbtDisturbed}
aria-label={t("cycle.sheet.temperatureDisturbed")}
/>
</div>
)}
</Field>
) : (
{/* Ovulation test */}
<Field
label={t("cycle.sheet.mucus")}
label={t("cycle.sheet.ovulationTest")}
info={
<FieldInfo
label={t("cycle.fieldInfo.mucusLabel")}
detail={t("cycle.fieldInfo.mucus")}
label={t("cycle.fieldInfo.ovulationTestLabel")}
detail={t("cycle.fieldInfo.ovulationTest")}
/>
}
>
<div className="flex flex-wrap gap-2">
{MUCUS_VALUES.map((v) => (
{OPK_VALUES.map((v) => (
<Chip
key={v}
active={mucus === v}
onClick={() => setMucus((cur) => (cur === v ? null : v))}
active={opk === v}
onClick={() => setOpk((cur) => (cur === v ? null : v))}
>
{t(`cycle.mucus.${v}`)}
{t(`cycle.ovulationTest.${v}`)}
</Chip>
))}
</div>
</Field>
)}
{/* Symptothermal secondary symptom: cervical mucus (default) OR the
cervix observation, per the user's advanced-settings choice. Only one
of the two is offered so the log sheet never overwhelms. */}
{showCervix ? (
<Field label={t("cycle.sheet.cervix")}>
<div className="space-y-3">
<CervixRow
label={t("cycle.sheet.cervixPosition")}
values={CERVIX_POSITION_VALUES}
current={cervixPosition}
labelFor={(v) => t(`cycle.cervixPosition.${v}`)}
onToggle={(v) =>
setCervixPosition((cur) => (cur === v ? null : v))
}
/>
<CervixRow
label={t("cycle.sheet.cervixFirmness")}
values={CERVIX_FIRMNESS_VALUES}
current={cervixFirmness}
labelFor={(v) => t(`cycle.cervixFirmness.${v}`)}
onToggle={(v) =>
setCervixFirmness((cur) => (cur === v ? null : v))
}
/>
<CervixRow
label={t("cycle.sheet.cervixOpening")}
values={CERVIX_OPENING_VALUES}
current={cervixOpening}
labelFor={(v) => t(`cycle.cervixOpening.${v}`)}
onToggle={(v) =>
setCervixOpening((cur) => (cur === v ? null : v))
}
/>
</div>
</Field>
) : (
<Field
label={t("cycle.sheet.mucus")}
info={
<FieldInfo
label={t("cycle.fieldInfo.mucusLabel")}
detail={t("cycle.fieldInfo.mucus")}
/>
}
>
<div className="flex flex-wrap gap-2">
{MUCUS_VALUES.map((v) => (
<Chip
key={v}
active={mucus === v}
onClick={() => setMucus((cur) => (cur === v ? null : v))}
>
{t(`cycle.mucus.${v}`)}
</Chip>
))}
</div>
</Field>
)}
</SheetSection>
{/* Intimacy & contraception */}
@@ -1008,48 +1007,48 @@ export function LogDaySheet({
>
{/* Intercourse + protection */}
<Field label={t("cycle.sheet.intercourse")}>
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5">
<Label
htmlFor="cycle-intercourse"
className="text-sm font-normal"
>
{t("cycle.sheet.intercourse")}
</Label>
<FieldInfo
label={t("cycle.fieldInfo.intercourseLabel")}
detail={t("cycle.fieldInfo.intercourse")}
/>
</div>
<Switch
id="cycle-intercourse"
checked={intercourse}
onCheckedChange={setIntercourse}
/>
</div>
{intercourse ? (
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5">
<Label
htmlFor="cycle-protected"
className="text-muted-foreground text-sm font-normal"
htmlFor="cycle-intercourse"
className="text-sm font-normal"
>
{t("cycle.sheet.protected")}
{t("cycle.sheet.intercourse")}
</Label>
<FieldInfo
label={t("cycle.fieldInfo.protectedLabel")}
detail={t("cycle.fieldInfo.protected")}
label={t("cycle.fieldInfo.intercourseLabel")}
detail={t("cycle.fieldInfo.intercourse")}
/>
</div>
<Switch
id="cycle-protected"
checked={protectedSex}
onCheckedChange={setProtectedSex}
id="cycle-intercourse"
checked={intercourse}
onCheckedChange={setIntercourse}
/>
</div>
) : null}
</div>
{intercourse ? (
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5">
<Label
htmlFor="cycle-protected"
className="text-muted-foreground text-sm font-normal"
>
{t("cycle.sheet.protected")}
</Label>
<FieldInfo
label={t("cycle.fieldInfo.protectedLabel")}
detail={t("cycle.fieldInfo.protected")}
/>
</div>
<Switch
id="cycle-protected"
checked={protectedSex}
onCheckedChange={setProtectedSex}
/>
</div>
) : null}
</div>
</Field>
{/* Contraceptive */}
+70 -70
View File
@@ -124,83 +124,83 @@ export function PhaseEducationCard({
className,
)}
>
{/* Zone 1 — eyebrow + phase name with a hue dot (never colour-only). */}
<div className="flex items-center gap-2">
<Sparkles
className="h-4 w-4 shrink-0"
style={{ color: hue }}
aria-hidden="true"
/>
<span className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{t("cycle.phaseEducation.title")}
</span>
</div>
{/* Zone 1 — eyebrow + phase name with a hue dot (never colour-only). */}
<div className="flex items-center gap-2">
<Sparkles
className="h-4 w-4 shrink-0"
style={{ color: hue }}
aria-hidden="true"
/>
<span className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{t("cycle.phaseEducation.title")}
</span>
</div>
{stillLearning ? (
<p
data-slot="cycle-phase-education-learning"
className="text-muted-foreground mt-2 text-sm"
>
{t("cycle.phaseEducation.stillLearning")}
</p>
) : (
<>
<h3 className="text-foreground mt-2 flex items-center gap-2 text-base font-semibold">
<span
aria-hidden="true"
className="inline-block h-2.5 w-2.5 shrink-0 rounded-full"
style={{ backgroundColor: hue }}
/>
{phaseName}
</h3>
{stillLearning ? (
<p
data-slot="cycle-phase-education-learning"
className="text-muted-foreground mt-2 text-sm"
>
{t("cycle.phaseEducation.stillLearning")}
</p>
) : (
<>
<h3 className="text-foreground mt-2 flex items-center gap-2 text-base font-semibold">
<span
aria-hidden="true"
className="inline-block h-2.5 w-2.5 shrink-0 rounded-full"
style={{ backgroundColor: hue }}
/>
{phaseName}
</h3>
{/* Zone 2 — the curated, descriptive-only "what's happening" line,
{/* Zone 2 — the curated, descriptive-only "what's happening" line,
followed by a deeper "context" line (what someone might notice +
gentle framing of normal variation). Both stay descriptive, never
clinical advice / a medical claim. */}
<p className="text-muted-foreground mt-1.5 text-sm leading-relaxed">
{t(PHASE_WHATS_HAPPENING_KEY[phase as CyclePhase])}
</p>
<p className="text-muted-foreground mt-2 text-sm leading-relaxed">
{t(PHASE_CONTEXT_KEY[phase as CyclePhase])}
</p>
<p className="text-muted-foreground mt-1.5 text-sm leading-relaxed">
{t(PHASE_WHATS_HAPPENING_KEY[phase as CyclePhase])}
</p>
<p className="text-muted-foreground mt-2 text-sm leading-relaxed">
{t(PHASE_CONTEXT_KEY[phase as CyclePhase])}
</p>
{/* Zone 3 — the user's OWN clustered symptoms for this phase
{/* Zone 3 — the user's OWN clustered symptoms for this phase
("you often notice this here"). */}
{chips.length > 0 ? (
<div className="mt-4">
<p className="text-muted-foreground text-xs font-medium">
{t("cycle.phaseEducation.commonHere")}
</p>
<ul
data-slot="cycle-phase-education-chips"
className="mt-2 flex flex-wrap gap-1.5"
>
{chips.map((row) => {
const meta = byKey.get(row.symptomKey);
const Icon = meta?.icon;
const name = meta ? t(meta.labelKey) : row.symptomKey;
return (
<li
key={row.symptomKey}
className="border-foreground/15 bg-background/55 text-foreground inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium"
>
{Icon ? (
<Icon
className="h-3 w-3 shrink-0"
style={{ color: hue }}
aria-hidden="true"
/>
) : null}
{name}
</li>
);
})}
</ul>
</div>
) : null}
</>
)}
{chips.length > 0 ? (
<div className="mt-4">
<p className="text-muted-foreground text-xs font-medium">
{t("cycle.phaseEducation.commonHere")}
</p>
<ul
data-slot="cycle-phase-education-chips"
className="mt-2 flex flex-wrap gap-1.5"
>
{chips.map((row) => {
const meta = byKey.get(row.symptomKey);
const Icon = meta?.icon;
const name = meta ? t(meta.labelKey) : row.symptomKey;
return (
<li
key={row.symptomKey}
className="border-foreground/15 bg-background/55 text-foreground inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium"
>
{Icon ? (
<Icon
className="h-3 w-3 shrink-0"
style={{ color: hue }}
aria-hidden="true"
/>
) : null}
{name}
</li>
);
})}
</ul>
</div>
) : null}
</>
)}
</section>
</div>
);
+2 -4
View File
@@ -193,8 +193,7 @@ export function useDeleteCustomSymptom() {
export function useCycleProfile() {
return useQuery({
queryKey: queryKeys.cycleProfile(),
queryFn: () =>
apiGet<CycleProfileDTO>("/api/cycle/profile"),
queryFn: () => apiGet<CycleProfileDTO>("/api/cycle/profile"),
staleTime: 5 * 60_000,
});
}
@@ -207,8 +206,7 @@ export function useCycleProfile() {
export function useCycleInsights() {
return useQuery({
queryKey: queryKeys.cycleInsights(),
queryFn: () =>
apiGet<CycleInsightsResponse>("/api/cycle/insights"),
queryFn: () => apiGet<CycleInsightsResponse>("/api/cycle/insights"),
staleTime: 5 * 60_000,
});
}
@@ -40,10 +40,9 @@ let mockResult: {
};
vi.mock("@/hooks/use-workouts", async () => {
const actual =
await vi.importActual<typeof import("@/hooks/use-workouts")>(
"@/hooks/use-workouts",
);
const actual = await vi.importActual<typeof import("@/hooks/use-workouts")>(
"@/hooks/use-workouts",
);
return {
...actual,
useWorkouts: () => mockResult,
@@ -232,94 +232,94 @@ export function DashboardHero({
)}
>
<div className="flex h-full flex-col gap-4">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="min-w-0 flex-1 space-y-3">
{/* Greeting leads the typographic hierarchy: larger + heavier
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="min-w-0 flex-1 space-y-3">
{/* Greeting leads the typographic hierarchy: larger + heavier
than the verdict so the personalised line reads first.
The verdict keeps its medium weight one step down — still
the content lead, clearly subordinate to the greeting.
Both stay on the existing foreground token; the band adds
no new colour. */}
<p
data-slot="dashboard-hero-greeting"
className="text-foreground text-lg font-semibold tracking-tight sm:text-xl"
>
{welcomeText}
</p>
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
<p
data-slot="dashboard-hero-verdict"
data-verdict-variant={verdict.variant}
className="text-foreground/90 text-base font-medium"
data-slot="dashboard-hero-greeting"
className="text-foreground text-lg font-semibold tracking-tight sm:text-xl"
>
{sentence}
{welcomeText}
</p>
{cta !== null && ctaLabelKey ? (
cta.kind === "link" ? (
<Button
asChild
size="default"
variant="outline"
className="min-h-11 sm:min-h-9"
data-slot="dashboard-hero-cta"
>
<Link href={cta.href}>{t(ctaLabelKey)}</Link>
</Button>
) : (
<Button
size="default"
className="min-h-11 sm:min-h-9"
data-slot="dashboard-hero-cta"
onClick={() => onQuickEntry(cta.target)}
>
{t(ctaLabelKey)}
</Button>
)
) : null}
</div>
<div
data-slot="dashboard-hero-doses"
className="bg-muted/50 border-border/60 inline-flex max-w-full items-center gap-2 rounded-xl border px-3 py-2"
>
<Pill
className="text-muted-foreground h-4 w-4 shrink-0"
aria-hidden="true"
/>
<span className="text-foreground truncate text-sm">
{meds.scheduledToday > 0
? t("dashboard.hero.doses.summary", {
taken: meds.takenToday,
scheduled: meds.scheduledToday,
})
: t("dashboard.hero.doses.none")}
</span>
{doseDetail !== null ? (
<span className="text-muted-foreground shrink-0 text-sm">
· {doseDetail}
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
<p
data-slot="dashboard-hero-verdict"
data-verdict-variant={verdict.variant}
className="text-foreground/90 text-base font-medium"
>
{sentence}
</p>
{cta !== null && ctaLabelKey ? (
cta.kind === "link" ? (
<Button
asChild
size="default"
variant="outline"
className="min-h-11 sm:min-h-9"
data-slot="dashboard-hero-cta"
>
<Link href={cta.href}>{t(ctaLabelKey)}</Link>
</Button>
) : (
<Button
size="default"
className="min-h-11 sm:min-h-9"
data-slot="dashboard-hero-cta"
onClick={() => onQuickEntry(cta.target)}
>
{t(ctaLabelKey)}
</Button>
)
) : null}
</div>
<div
data-slot="dashboard-hero-doses"
className="bg-muted/50 border-border/60 inline-flex max-w-full items-center gap-2 rounded-xl border px-3 py-2"
>
<Pill
className="text-muted-foreground h-4 w-4 shrink-0"
aria-hidden="true"
/>
<span className="text-foreground truncate text-sm">
{meds.scheduledToday > 0
? t("dashboard.hero.doses.summary", {
taken: meds.takenToday,
scheduled: meds.scheduledToday,
})
: t("dashboard.hero.doses.none")}
</span>
) : null}
{doseDetail !== null ? (
<span className="text-muted-foreground shrink-0 text-sm">
· {doseDetail}
</span>
) : null}
</div>
</div>
</div>
{/* Right column — fixed 120 px ring footprint. A null score
{/* Right column — fixed 120 px ring footprint. A null score
renders the ring's provisional state (aria announces "not
enough data", never 0) at identical geometry, so the column
NEVER collapses. */}
<div className="flex shrink-0 items-center justify-center md:justify-end">
<ScoreRing
score={snapshot.healthScore?.score ?? null}
band={snapshot.healthScore?.band}
size="sm"
flat
label={
snapshot.healthScore
? t("dashboard.hero.scoreLabel")
: hasScoreInputs
? t("dashboard.hero.scoreComputing")
: t("dashboard.hero.scoreProvisional")
}
/>
<div className="flex shrink-0 items-center justify-center md:justify-end">
<ScoreRing
score={snapshot.healthScore?.score ?? null}
band={snapshot.healthScore?.band}
size="sm"
flat
label={
snapshot.healthScore
? t("dashboard.hero.scoreLabel")
: hasScoreInputs
? t("dashboard.hero.scoreComputing")
: t("dashboard.hero.scoreProvisional")
}
/>
</div>
</div>
</div>
{/* v1.18.1 — Rest Mode cue beneath the verdict/score row. Self-gating
(renders nothing unless an episode is active), value-free, and
untinted so it frames the score without alarming. Closes the
@@ -103,13 +103,14 @@ export function RecentWorkoutsTile() {
return (
<div
data-slot="recent-workouts-tile"
className={cn(
"bg-card border-border space-y-3 rounded-xl border p-4",
)}
className={cn("bg-card border-border space-y-3 rounded-xl border p-4")}
>
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Activity className="text-muted-foreground size-4" aria-hidden="true" />
<Activity
className="text-muted-foreground size-4"
aria-hidden="true"
/>
<h2 className="text-sm font-semibold">
{t("dashboard.recentWorkouts.title")}
</h2>
@@ -134,9 +135,7 @@ export function RecentWorkoutsTile() {
</p>
) : workouts.length === 0 ? (
<div data-slot="recent-workouts-empty" className="space-y-1">
<p className="text-sm">
{t("dashboard.recentWorkouts.empty.title")}
</p>
<p className="text-sm">{t("dashboard.recentWorkouts.empty.title")}</p>
<p className="text-muted-foreground text-xs">
{t("dashboard.recentWorkouts.empty.cta")}
</p>
+4 -5
View File
@@ -62,11 +62,10 @@ export function DeleteButton({
<AlertDialogDescription>{description}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{cancelLabel ?? t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={onConfirm}
>
<AlertDialogCancel>
{cancelLabel ?? t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction variant="destructive" onClick={onConfirm}>
{confirmLabel ?? t("common.delete")}
</AlertDialogAction>
</AlertDialogFooter>
+4 -1
View File
@@ -12,7 +12,10 @@
*/
/** Toggle a single id in/out of the selection set, returning a new Set. */
export function toggleId(selected: ReadonlySet<string>, id: string): Set<string> {
export function toggleId(
selected: ReadonlySet<string>,
id: string,
): Set<string> {
const next = new Set(selected);
if (next.has(id)) next.delete(id);
else next.add(id);
@@ -16,10 +16,11 @@ vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ isAuthenticated: true, user: mockUser }),
}));
const useAchievementsQuery =
vi.fn<(opts?: { enabled?: boolean }) => { data: undefined }>(() => ({
data: undefined,
}));
const useAchievementsQuery = vi.fn<
(opts?: { enabled?: boolean }) => { data: undefined }
>(() => ({
data: undefined,
}));
vi.mock("@/lib/queries/use-achievements-query", () => ({
useAchievementsQuery: (opts?: { enabled?: boolean }) =>
@@ -142,7 +142,11 @@ describe("<RecentAchievementsCard> — achievements module gate", () => {
mockData = {
summary: {},
achievements: [
{ ...baseAchievement, id: "a", completedAt: "2026-04-15T12:00:00.000Z" },
{
...baseAchievement,
id: "a",
completedAt: "2026-04-15T12:00:00.000Z",
},
],
metrics: {},
};
@@ -154,7 +158,11 @@ describe("<RecentAchievementsCard> — achievements module gate", () => {
mockData = {
summary: {},
achievements: [
{ ...baseAchievement, id: "a", completedAt: "2026-04-15T12:00:00.000Z" },
{
...baseAchievement,
id: "a",
completedAt: "2026-04-15T12:00:00.000Z",
},
],
metrics: {},
};
@@ -115,7 +115,10 @@ export function RecentAchievementsCard() {
>
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Trophy className="text-muted-foreground h-4 w-4" aria-hidden="true" />
<Trophy
className="text-muted-foreground h-4 w-4"
aria-hidden="true"
/>
<h2 className="text-sm font-semibold">
{t("achievements.dashboardCard.title")}
</h2>
@@ -112,16 +112,11 @@ describe("<MaintainershipBanner>", () => {
};
for (const [locale, pattern] of Object.entries(machineTranslatedPatterns)) {
const data = JSON.parse(
readFileSync(
join(process.cwd(), "messages", `${locale}.json`),
"utf8",
),
readFileSync(join(process.cwd(), "messages", `${locale}.json`), "utf8"),
) as { i18n: { maintainershipBanner: { notice: string } } };
const notice = data.i18n.maintainershipBanner.notice;
expect(notice, `${locale} banner notice`).toMatch(pattern);
expect(notice, `${locale} banner notice mentions Coach`).toMatch(
/Coach/,
);
expect(notice, `${locale} banner notice mentions Coach`).toMatch(/Coach/);
}
});
});
@@ -16,10 +16,7 @@ import { Skeleton } from "@/components/ui/skeleton";
import { useTranslations } from "@/lib/i18n/context";
import { useIllnessCorrelation } from "./use-illness";
import type {
IllnessCorrelationValue,
IllnessVitalDeviation,
} from "./types";
import type { IllnessCorrelationValue, IllnessVitalDeviation } from "./types";
/** Translate a MeasurementType to its illness-surface label. */
function useVitalLabel() {
@@ -76,7 +73,9 @@ function CorrelationBody({ value }: { value: IllnessCorrelationValue }) {
{gap > 0
? t("illness.correlation.recoveryGapLag", { days: gap })
: gap < 0
? t("illness.correlation.recoveryGapLead", { days: Math.abs(gap) })
? t("illness.correlation.recoveryGapLead", {
days: Math.abs(gap),
})
: t("illness.correlation.recoveryGapSame")}
</p>
</div>
@@ -32,8 +32,8 @@ function DayLogRow({ log }: { log: IllnessDayLogDTO }) {
const fmt = useFormatters();
return (
<div className="space-y-2 border-border/60 border-l-2 pl-3">
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">
<div className="border-border/60 space-y-2 border-l-2 pl-3">
<p className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{fmt.dateShort(`${log.date}T12:00:00`)}
</p>
@@ -86,7 +86,10 @@ export function IllnessEpisodeDetail({ episodeId }: { episodeId: string }) {
) : null}
</div>
<div className="flex shrink-0 items-center gap-1">
<Button onClick={() => setLogOpen(true)} className="min-h-11 sm:min-h-9">
<Button
onClick={() => setLogOpen(true)}
className="min-h-11 sm:min-h-9"
>
{t("illness.logDay")}
</Button>
{episode ? (
@@ -94,7 +97,9 @@ export function IllnessEpisodeDetail({ episodeId }: { episodeId: string }) {
episode={episode}
onEdit={() => setEditOpen(true)}
onResolve={
active && !isChronic ? () => resolve.mutate(episode.id) : undefined
active && !isChronic
? () => resolve.mutate(episode.id)
: undefined
}
resolving={resolve.isPending}
/>
@@ -105,7 +110,7 @@ export function IllnessEpisodeDetail({ episodeId }: { episodeId: string }) {
{episode?.note ? (
<Card>
<CardContent className="pt-6">
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">
<p className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{t("illness.detail.note")}
</p>
<p className="text-foreground mt-1 text-sm whitespace-pre-wrap">
@@ -14,7 +14,11 @@ import { useTranslations } from "@/lib/i18n/context";
import { useIllnessInsights } from "./use-illness";
export function IllnessInsightsCard({ windowDays = 365 }: { windowDays?: number }) {
export function IllnessInsightsCard({
windowDays = 365,
}: {
windowDays?: number;
}) {
const { t } = useTranslations();
const { data, isLoading, isError } = useIllnessInsights(windowDays);
@@ -47,7 +51,9 @@ export function IllnessInsightsCard({ windowDays = 365 }: { windowDays?: number
<p className="text-foreground">
{data.episodeCount === 1
? t("illness.insights.countOne")
: t("illness.insights.countOther", { count: data.episodeCount })}
: t("illness.insights.countOther", {
count: data.episodeCount,
})}
</p>
<p className="text-muted-foreground">
{data.typicalRecoveryGapDays !== null
+5 -3
View File
@@ -277,7 +277,7 @@ function EpisodeGroup({
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
aria-controls={gridId}
className="text-muted-foreground hover:text-foreground -mx-1 flex w-fit items-center gap-1.5 rounded-md px-1 py-0.5 text-xs font-medium uppercase tracking-wide transition-colors"
className="text-muted-foreground hover:text-foreground -mx-1 flex w-fit items-center gap-1.5 rounded-md px-1 py-0.5 text-xs font-medium tracking-wide uppercase transition-colors"
>
<ChevronDown
className={cn(
@@ -298,7 +298,7 @@ function EpisodeGroup({
return (
<section className="space-y-3">
<h2 className="text-muted-foreground text-xs font-medium uppercase tracking-wide">
<h2 className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{title}
</h2>
{grid}
@@ -314,7 +314,9 @@ export function IllnessView() {
const today = todayLocal();
const [newOpen, setNewOpen] = useState(false);
const [editEpisode, setEditEpisode] = useState<IllnessEpisodeDTO | null>(null);
const [editEpisode, setEditEpisode] = useState<IllnessEpisodeDTO | null>(
null,
);
const [logEpisodeId, setLogEpisodeId] = useState<string | null>(null);
const logEpisode = episodes?.find((e) => e.id === logEpisodeId) ?? null;
+5 -8
View File
@@ -17,10 +17,7 @@ import { useMemo, useState } from "react";
import { Activity, CalendarDays, NotebookPen, Thermometer } from "lucide-react";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import {
SheetSection,
SheetSectionCount,
} from "@/components/ui/sheet-section";
import { SheetSection, SheetSectionCount } from "@/components/ui/sheet-section";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -259,7 +256,9 @@ export function LogDaySheet({
title={t("illness.sheet.day")}
icon={<CalendarDays className="h-4 w-4" />}
summary={
<span className="text-muted-foreground text-xs">{selectedDate}</span>
<span className="text-muted-foreground text-xs">
{selectedDate}
</span>
}
defaultOpen
>
@@ -319,9 +318,7 @@ export function LogDaySheet({
key={value}
active={functionalImpact === value}
onClick={() =>
setFunctionalImpact(
functionalImpact === value ? null : value,
)
setFunctionalImpact(functionalImpact === value ? null : value)
}
>
{impactLabels[value]}
+13 -8
View File
@@ -22,12 +22,12 @@ import {
} from "@/components/ui/select";
import { useTranslations } from "@/lib/i18n/context";
import { useCreateEpisode, useIllnessEpisodes, useUpdateEpisode } from "./use-illness";
import type {
IllnessEpisodeDTO,
IllnessLifecycle,
IllnessType,
} from "./types";
import {
useCreateEpisode,
useIllnessEpisodes,
useUpdateEpisode,
} from "./use-illness";
import type { IllnessEpisodeDTO, IllnessLifecycle, IllnessType } from "./types";
const TYPES: IllnessType[] = [
"INFECTION",
@@ -151,7 +151,10 @@ export function NewEpisodeSheet({
<Button variant="ghost" onClick={() => onOpenChange(false)}>
{t("common.cancel")}
</Button>
<Button onClick={handleSave} disabled={pending || label.trim() === ""}>
<Button
onClick={handleSave}
disabled={pending || label.trim() === ""}
>
{t("common.save")}
</Button>
</>
@@ -212,7 +215,9 @@ export function NewEpisodeSheet({
<SelectValue placeholder={t("illness.new.parentNone")} />
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE}>{t("illness.new.parentNone")}</SelectItem>
<SelectItem value={NONE}>
{t("illness.new.parentNone")}
</SelectItem>
{parentCandidates.map((e) => (
<SelectItem key={e.id} value={e.id}>
{e.label}
+8 -4
View File
@@ -122,8 +122,13 @@ export function useCreateEpisode() {
export function useUpdateEpisode() {
const invalidate = useInvalidateIllness();
return useMutation({
mutationFn: ({ id, input }: { id: string; input: IllnessEpisodeUpdateInput }) =>
apiPatch<IllnessEpisodeDTO>(`/api/illness/episodes/${id}`, input),
mutationFn: ({
id,
input,
}: {
id: string;
input: IllnessEpisodeUpdateInput;
}) => apiPatch<IllnessEpisodeDTO>(`/api/illness/episodes/${id}`, input),
onSuccess: invalidate,
});
}
@@ -142,8 +147,7 @@ export function useResolveEpisode() {
export function useDeleteEpisode() {
const invalidate = useInvalidateIllness();
return useMutation({
mutationFn: (id: string) =>
apiDelete<void>(`/api/illness/episodes/${id}`),
mutationFn: (id: string) => apiDelete<void>(`/api/illness/episodes/${id}`),
onSuccess: invalidate,
});
}
@@ -217,17 +217,12 @@ describe("<DailyBriefing>", () => {
});
it("renders the German connect-provider hint under the de locale", () => {
const html = render(
<DailyBriefing briefing={null} noProvider />,
"de",
);
const html = render(<DailyBriefing briefing={null} noProvider />, "de");
expect(html).toContain("Kein KI-Anbieter verbunden");
});
it("prefers the briefing content over the no-provider hint when both exist", () => {
const html = render(
<DailyBriefing briefing={baseBriefing} noProvider />,
);
const html = render(<DailyBriefing briefing={baseBriefing} noProvider />);
expect(html).toContain('data-slot="daily-briefing-findings"');
expect(html).not.toContain('data-slot="daily-briefing-no-provider"');
});
@@ -120,7 +120,9 @@ describe("<HealthScoreCard> — provenance accordion (W8e)", () => {
// attribute order because additional ARIA attributes (e.g.
// `aria-labelledby`) may sit between `id` and `data-slot`.
const panelMatch = html.match(
new RegExp(`<section[^>]*\\bid="${id}"[^>]*data-slot="health-score-card-provenance-panel"`),
new RegExp(
`<section[^>]*\\bid="${id}"[^>]*data-slot="health-score-card-provenance-panel"`,
),
);
expect(panelMatch).not.toBeNull();
});
@@ -265,9 +267,7 @@ describe("<HealthScoreCard> — provenance accordion (W8e)", () => {
delta={null}
/>,
);
expect(html).toContain(
'data-slot="health-score-card-provisional-badge"',
);
expect(html).toContain('data-slot="health-score-card-provisional-badge"');
// EN badge copy.
expect(html).toContain("Provisional");
});
@@ -370,8 +370,6 @@ describe("<HealthScoreCard> — provenance accordion (W8e)", () => {
initiallyExpanded
/>,
);
expect(html).toContain(
'data-slot="health-score-card-provenance-footnote"',
);
expect(html).toContain('data-slot="health-score-card-provenance-footnote"');
});
});
@@ -208,7 +208,6 @@ describe("<HealthScoreCard>", () => {
expect(html).not.toContain('data-slot="health-score-card-ask-coach"');
});
it("renders German strings", () => {
const html = ssr(
<HealthScoreCard
@@ -274,9 +273,7 @@ describe("<HealthScoreCard>", () => {
delta={null}
/>,
);
const cardOpen = html.match(
/<div[^>]*data-slot="health-score-card"[^>]*>/,
);
const cardOpen = html.match(/<div[^>]*data-slot="health-score-card"[^>]*>/);
expect(cardOpen).not.toBeNull();
expect(cardOpen?.[0]).toContain("h-full");
expect(cardOpen?.[0]).toContain("flex");
@@ -106,9 +106,7 @@ describe("<HealthScoreDeltaExplainer>", () => {
// directly. Pinning the negative so a future refactor can't
// reintroduce the wrapper.
const html = ssr(<HealthScoreDeltaExplainer delta={-3} />);
expect(html).not.toMatch(
/<span[^>]*onclick/i,
);
expect(html).not.toMatch(/<span[^>]*onclick/i);
});
it("renders the trigger in German with the localised aria-label", () => {
@@ -129,9 +127,7 @@ describe("<HealthScoreDeltaExplainer>", () => {
// future "open on render" refactor from leaking long-form copy
// into the static markup.
const html = ssr(<HealthScoreDeltaExplainer delta={-3} />);
expect(html).not.toContain(
'data-slot="health-score-delta-explainer-body"',
);
expect(html).not.toContain('data-slot="health-score-delta-explainer-body"');
expect(html).not.toContain(
'data-slot="health-score-delta-explainer-title"',
);
@@ -187,15 +183,9 @@ describe("<HealthScoreDeltaExplainer>", () => {
// The probe re-uses the same `t()` payload the popover does,
// so any missing key surfaces as the literal key path which
// would never contain a space.
expect(html).not.toContain(
"insights.healthScore.deltaExplainer.trigger",
);
expect(html).not.toContain(
"insights.healthScore.deltaExplainer.title",
);
expect(html).not.toContain(
"insights.healthScore.deltaExplainer.body",
);
expect(html).not.toContain("insights.healthScore.deltaExplainer.trigger");
expect(html).not.toContain("insights.healthScore.deltaExplainer.title");
expect(html).not.toContain("insights.healthScore.deltaExplainer.body");
}
});
});
@@ -42,7 +42,8 @@ vi.mock("@/hooks/use-insights-analytics", () => ({
const metricStatusMock = vi.fn();
vi.mock("@/hooks/use-insight-status", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/hooks/use-insight-status")>();
const actual =
await importOriginal<typeof import("@/hooks/use-insight-status")>();
return {
...actual,
useInsightMetricStatus: (...args: unknown[]) => metricStatusMock(...args),
@@ -113,7 +114,10 @@ describe("<HealthKitMetricPage> status mount", () => {
expect(html).toContain('data-testid="insight-status-card-loading"');
expect(html).not.toContain("Your HRV has trended upward");
// The card fetch is enabled (statusMetric present + data present).
expect(metricStatusMock).toHaveBeenCalledWith("HEART_RATE_VARIABILITY", true);
expect(metricStatusMock).toHaveBeenCalledWith(
"HEART_RATE_VARIABILITY",
true,
);
});
it("does not mount the assessment card when statusMetric is omitted", () => {
@@ -158,7 +162,10 @@ describe("<HealthKitMetricPage> status mount", () => {
metricStatusMock.mockReturnValue({ data: undefined, isLoading: false });
const html = render(
<HealthKitMetricPage {...baseProps} statusMetric="HEART_RATE_VARIABILITY" />,
<HealthKitMetricPage
{...baseProps}
statusMetric="HEART_RATE_VARIABILITY"
/>,
);
// Stat-strip skeleton + chart skeleton paint; the assessment card never
@@ -180,7 +187,10 @@ describe("<HealthKitMetricPage> status mount", () => {
metricStatusMock.mockReturnValue({ data: undefined, isLoading: false });
const html = render(
<HealthKitMetricPage {...baseProps} statusMetric="HEART_RATE_VARIABILITY" />,
<HealthKitMetricPage
{...baseProps}
statusMetric="HEART_RATE_VARIABILITY"
/>,
);
expect(html).toContain('data-slot="healthkit-metric-error"');
@@ -127,9 +127,7 @@ describe("<HeroStrip>", () => {
// the insights layout shell), not as a hero affordance.
it("does NOT render an ask-the-coach action button", () => {
const html = render(<HeroStrip briefing={null} now={morningLocal} />);
expect(html).not.toContain(
'data-slot="insights-hero-strip-action-coach"',
);
expect(html).not.toContain('data-slot="insights-hero-strip-action-coach"');
});
// v1.4.25 W3 — the regenerate button moved from the hero action row
@@ -100,10 +100,7 @@ describe("<InsightStatusCard>", () => {
// upstream is unchanged; the card simply never announced it, so the
// assessment reads as authoritative.
const html = render(
<InsightStatusCard
{...baseProps}
text="Pulse stayed inside the band."
/>,
<InsightStatusCard {...baseProps} text="Pulse stayed inside the band." />,
);
expect(html).toContain("Pulse stayed inside the band.");
expect(html).not.toContain("Cached");
@@ -35,8 +35,7 @@ describe("<InsightsTabStrip> — v1.4.31 memo + availability-stability", () => {
// React.memo'd components carry the `$$typeof` Memo tag plus a
// `compare` field. Probing the type tag is the cheapest way to
// pin the memo contract without spinning a full React tree.
const tag = (InsightsTabStrip as unknown as { $$typeof: symbol })
.$$typeof;
const tag = (InsightsTabStrip as unknown as { $$typeof: symbol }).$$typeof;
expect(typeof tag).toBe("symbol");
expect(tag.toString()).toContain("memo");
});
@@ -270,12 +270,7 @@ describe("<InsightsTabStrip> — saved-layout visibility gate (v1.15.14 W2)", ()
});
it("shows a pill when its slug is layout-visible AND has data", () => {
const visibleTileIds = new Set([
"overview",
"pulse",
"weight",
"bmi",
]);
const visibleTileIds = new Set(["overview", "pulse", "weight", "bmi"]);
const html = render(
<InsightsTabStrip
availability={dataAvailability}
@@ -369,9 +364,7 @@ describe("<InsightsTabStrip> — saved-layout visibility gate (v1.15.14 W2)", ()
// when their data is present.
it("default layout shows every data-having pill (nav-pill regression guard)", () => {
const visibleTileIds = new Set(
DEFAULT_INSIGHTS_LAYOUT.tiles
.filter((t) => t.visible)
.map((t) => t.id),
DEFAULT_INSIGHTS_LAYOUT.tiles.filter((t) => t.visible).map((t) => t.id),
);
const availability: InsightInputs = {
summaries: {
@@ -470,9 +463,7 @@ describe("<InsightsTabStrip> — module enable/disable gate (v1.18.0)", () => {
});
it("hides the Recovery pill when the recovery module is disabled, keeps it when enabled", () => {
const disabled = render(
<InsightsTabStrip modules={{ recovery: false }} />,
);
const disabled = render(<InsightsTabStrip modules={{ recovery: false }} />);
expect(disabled).not.toContain(">Recovery<");
expect(disabled).not.toContain('href="/insights/recovery"');
// Overview still anchors the strip.
@@ -4,10 +4,7 @@ import { readFileSync } from "node:fs";
import { join } from "node:path";
import { I18nProvider } from "@/lib/i18n/context";
import {
SleepHypnogram,
type SleepHypnogramSession,
} from "../sleep-hypnogram";
import { SleepHypnogram, type SleepHypnogramSession } from "../sleep-hypnogram";
/**
* v1.16.8 — hypnogram polish: the night's measuring source rides the
@@ -105,9 +105,7 @@ describe("<TrendAnnotation>", () => {
annotation="Mood ran steady across the back half of the week — three good days, one dip, then a recovery. The dip aligns with the Tuesday BP spike but the magnitude was small. Logged five mood entries in seven days, well above the 30-day median of three."
/>,
);
expect(html).toMatch(
/<p[^>]*class="[^"]*line-clamp-3[^"]*"[^>]*>/,
);
expect(html).toMatch(/<p[^>]*class="[^"]*line-clamp-3[^"]*"[^>]*>/);
});
it("clamps the empty-state caption to 3 lines too (same row contract)", () => {
@@ -24,9 +24,7 @@ vi.mock("@/hooks/use-auth", () => ({
import { TrendDescriptorCaption } from "../trend-annotation";
function seededClient(
seed: (client: QueryClient) => void,
): QueryClient {
function seededClient(seed: (client: QueryClient) => void): QueryClient {
const client = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
});
@@ -34,7 +32,11 @@ function seededClient(
return client;
}
function render(client: QueryClient, node: React.ReactNode, locale: "en" | "de" = "en") {
function render(
client: QueryClient,
node: React.ReactNode,
locale: "en" | "de" = "en",
) {
return renderToStaticMarkup(
<QueryClientProvider client={client}>
<I18nProvider initialLocale={locale}>{node}</I18nProvider>
@@ -177,8 +177,7 @@ describe("<TrendsRow>", () => {
// flight. The loading flag now propagates to each annotation
// slot so the user reads a generating-state shimmer instead.
const html = render(<TrendsRow loading />);
const pending =
html.match(/data-slot="trend-annotation-pending"/g) ?? [];
const pending = html.match(/data-slot="trend-annotation-pending"/g) ?? [];
expect(pending.length).toBe(3);
expect(html).not.toContain("Awaiting more data");
});
@@ -197,8 +196,7 @@ describe("<TrendsRow>", () => {
expect(html).not.toContain("stale BP");
expect(html).not.toContain("stale weight");
expect(html).not.toContain("stale mood");
const pending =
html.match(/data-slot="trend-annotation-pending"/g) ?? [];
const pending = html.match(/data-slot="trend-annotation-pending"/g) ?? [];
expect(pending.length).toBe(3);
});
@@ -242,7 +240,9 @@ describe("<TrendsRow>", () => {
expect(html).toMatch(/data-metric="pulse"/);
expect(html).not.toMatch(/data-slot="trend-annotation-empty"/);
// The standard caption renders in its place.
expect(html).toMatch(/data-slot="trends-row-caption"[^>]*data-metric="pulse"/);
expect(html).toMatch(
/data-slot="trends-row-caption"[^>]*data-metric="pulse"/,
);
expect(html).toContain("Resting pulse over the last 30 days.");
});
@@ -259,8 +259,7 @@ describe("<TrendsRow>", () => {
expect(cards.length).toBe(3);
// None of these additive metrics carries an advisor annotation, so
// all three captions come through the standard description slot.
const captions =
html.match(/data-slot="trends-row-caption"/g) ?? [];
const captions = html.match(/data-slot="trends-row-caption"/g) ?? [];
expect(captions.length).toBe(3);
expect(html).toContain("Heart-rate variability over the last 30 days.");
expect(html).toContain("Daily step count over the last 30 days.");
@@ -14,9 +14,8 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
*/
vi.mock("@/lib/ai/schema", async () => {
const actual = await vi.importActual<typeof import("@/lib/ai/schema")>(
"@/lib/ai/schema",
);
const actual =
await vi.importActual<typeof import("@/lib/ai/schema")>("@/lib/ai/schema");
return actual;
});
@@ -84,10 +83,8 @@ describe("fetchAdvisor abort timeout — v1.4.31", () => {
// in-session despite the 1 h staleTime + focus-refetch-off defaults.
describe("nextAdvisorPollInterval — bounded revalidation poll", () => {
it("returns the interval while revalidating is true and under the cap", async () => {
const {
nextAdvisorPollInterval,
ADVISOR_REVALIDATE_POLL_MS,
} = await import("../use-insights-advisor");
const { nextAdvisorPollInterval, ADVISOR_REVALIDATE_POLL_MS } =
await import("../use-insights-advisor");
expect(nextAdvisorPollInterval(true, 1)).toBe(ADVISOR_REVALIDATE_POLL_MS);
expect(nextAdvisorPollInterval(true, 5)).toBe(ADVISOR_REVALIDATE_POLL_MS);
});
@@ -99,10 +96,8 @@ describe("nextAdvisorPollInterval — bounded revalidation poll", () => {
});
it("stops at the attempt ceiling even while still revalidating", async () => {
const {
nextAdvisorPollInterval,
ADVISOR_REVALIDATE_POLL_MAX_ATTEMPTS,
} = await import("../use-insights-advisor");
const { nextAdvisorPollInterval, ADVISOR_REVALIDATE_POLL_MAX_ATTEMPTS } =
await import("../use-insights-advisor");
expect(
nextAdvisorPollInterval(true, ADVISOR_REVALIDATE_POLL_MAX_ATTEMPTS),
).toBe(false);
@@ -498,7 +498,10 @@ describe("<MessageThread>", () => {
});
it("localises the remember control under 'de'", () => {
const html = render(<MessageThread conversation={baseConversation} />, "de");
const html = render(
<MessageThread conversation={baseConversation} />,
"de",
);
expect(html).toContain('data-slot="coach-remember-message"');
expect(html).toContain("Merken");
});
@@ -707,7 +710,10 @@ describe("errorCodeToI18nKey", () => {
* messages, the optimistic user bubble, and the thread tail.
*/
describe("placeInterleaved", () => {
const item = (key: string, anchorAnswer: string | null): InterleavedThreadItem => ({
const item = (
key: string,
anchorAnswer: string | null,
): InterleavedThreadItem => ({
key,
anchorAnswer,
node: <span data-testid={key} />,
@@ -98,11 +98,7 @@ describe("<MobileRailTray>", () => {
// that contract so the carve-out stays gated on `historyOpen` /
// `sourcesOpen` and never paints orphan overlays.
const html = render(
<MobileRailTray
{...baseProps}
historyOpen={false}
sourcesOpen={false}
/>,
<MobileRailTray {...baseProps} historyOpen={false} sourcesOpen={false} />,
);
expect(html).not.toContain('data-slot="coach-drawer-history-tray"');
expect(html).not.toContain('data-slot="coach-drawer-sources-tray"');
@@ -265,14 +265,10 @@ export function CoachConversation({
current={item.current}
actionsDisabled={send.isStreaming || dismissQuestions.isPending}
onSkip={
item.current
? () => dispatchGuided({ type: "SKIP" })
: undefined
item.current ? () => dispatchGuided({ type: "SKIP" }) : undefined
}
onLater={
item.current
? () => dispatchGuided({ type: "EXIT" })
: undefined
item.current ? () => dispatchGuided({ type: "EXIT" }) : undefined
}
onDismissRemaining={
item.current
@@ -80,7 +80,9 @@ export function CoachDrawerBody({
return (
<div
data-slot="coach-drawer-body"
data-history-open={historyRail ? (historyOpen ? "true" : "false") : undefined}
data-history-open={
historyRail ? (historyOpen ? "true" : "false") : undefined
}
// v1.18.7 — the inline rail column animates between 0 and a fixed
// width so collapsing it hands the full width back to the thread
// without a layout jump. Without a rail the body is a single
@@ -130,9 +130,7 @@ type SpeechRecognitionLike = {
};
type SpeechRecognitionResultEventLike = {
resultIndex: number;
results: ArrayLike<
ArrayLike<{ transcript: string }> & { isFinal: boolean }
>;
results: ArrayLike<ArrayLike<{ transcript: string }> & { isFinal: boolean }>;
};
type SpeechRecognitionConstructor = new () => SpeechRecognitionLike;
@@ -403,7 +401,7 @@ export function CoachInput({
// v1.18.7 — calm, thin scrollbar inside the composer when
// dictation overruns 6 lines (see also the thread/history
// scroll regions). Scoped here, not in globals.css.
"[scrollbar-color:color-mix(in_srgb,var(--dracula-purple)_35%,transparent)_transparent] [scrollbar-width:thin]",
"[scrollbar-width:thin] [scrollbar-color:color-mix(in_srgb,var(--dracula-purple)_35%,transparent)_transparent]",
"placeholder:text-muted-foreground disabled:opacity-60",
)}
/>
@@ -67,7 +67,8 @@ function ProgressDots({ current, total }: { current: number; total: number }) {
className={cn(
"size-1.5 rounded-full",
i + 1 < current && "bg-dracula-purple",
i + 1 === current && "ring-dracula-purple bg-dracula-purple/40 ring-1",
i + 1 === current &&
"ring-dracula-purple bg-dracula-purple/40 ring-1",
i + 1 > current && "bg-muted-foreground/25",
)}
/>
@@ -203,10 +204,7 @@ export function GuidedSummaryBubble({
const { t } = useTranslations();
return (
<div
data-slot="coach-guided-summary"
className="flex items-start gap-2.5"
>
<div data-slot="coach-guided-summary" className="flex items-start gap-2.5">
<GuidedAvatar icon={ClipboardCheck} />
<div
className={cn(
@@ -229,7 +227,7 @@ export function GuidedSummaryBubble({
data-slot="coach-guided-summary-link"
className={cn(
"text-dracula-purple mt-2 inline-flex min-h-8 items-center gap-1 text-xs font-medium",
"hover:underline focus-visible:ring-dracula-purple/40 rounded focus-visible:ring-2 focus-visible:outline-none",
"focus-visible:ring-dracula-purple/40 rounded hover:underline focus-visible:ring-2 focus-visible:outline-none",
)}
>
{t("insights.coach.guided.summaryLink")}
@@ -184,8 +184,7 @@ export function HistoryRail({
data-confirming={isConfirming ? "true" : undefined}
className={cn(
"size-11 shrink-0",
isConfirming &&
"text-dracula-red hover:text-dracula-red",
isConfirming && "text-dracula-red hover:text-dracula-red",
)}
>
<Trash2 className="size-4" aria-hidden="true" />
@@ -51,7 +51,10 @@ export function SelfContextAdoptOffer({
const adopt = useMutation({
mutationFn: async () => {
return apiPost<{ adopted: boolean }>("/api/coach/about-me/adopt", { question, answer });
return apiPost<{ adopted: boolean }>("/api/coach/about-me/adopt", {
question,
answer,
});
},
onSuccess: (data) => {
setSettled(data.adopted ? "adopted" : "duplicate");
@@ -300,9 +300,7 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
}, []);
const send = useCallback(
async (
params: SendCoachMessageParams,
): Promise<string | null> => {
async (params: SendCoachMessageParams): Promise<string | null> => {
// Cancel any prior in-flight request.
abortRef.current?.abort();
const controller = new AbortController();
+1 -4
View File
@@ -376,10 +376,7 @@ export function DailyBriefing({
>
{t("insights.dailyBriefing.signalsTitle")}
</p>
<div
data-slot="daily-briefing-signals"
className="space-y-2"
>
<div data-slot="daily-briefing-signals" className="space-y-2">
{briefing.signalsOfDay.map((signal, index) => (
<SignalRow
key={`${signal.sourceMetric}-${index}`}
@@ -42,10 +42,7 @@ describe("<ProvenanceExplainer>", () => {
it("meets the 44px hit-target floor on the trigger", () => {
const html = render(
<ProvenanceExplainer
provenance={PROVENANCE}
method="method copy"
/>,
<ProvenanceExplainer provenance={PROVENANCE} method="method copy" />,
);
expect(html).toContain("min-h-11");
expect(html).toContain("min-w-11");
@@ -1,7 +1,10 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { ScoreAnatomyView, type AnatomyContributor } from "../score-anatomy-view";
import {
ScoreAnatomyView,
type AnatomyContributor,
} from "../score-anatomy-view";
import { I18nProvider } from "@/lib/i18n/context";
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
@@ -23,7 +23,12 @@ function ok(value: Record<string, unknown>): Resp {
metric: "X",
status: "ok",
value,
coverage: { requiredInputs: 1, presentInputs: 1, historyDays: 30, missing: [] },
coverage: {
requiredInputs: 1,
presentInputs: 1,
historyDays: 30,
missing: [],
},
confidence: { score: 90, band: "high" },
provenance: { inputs: [], source: "DAY", windowDays: 30, computedAt: "x" },
reason: null,
@@ -71,8 +76,19 @@ beforeEach(() => vi.clearAllMocks());
describe("<VitalsDashboard>", () => {
it("renders an available baseline vital as an ok tile", () => {
const batch = mockBatch((token) => {
if (token.metric === "VITALS_BASELINE" && token.type === "RESTING_HEART_RATE") {
return ok({ type: "RESTING_HEART_RATE", center: 55, low: 48, high: 62, spread: 7, sampleDays: 30, k: 3 });
if (
token.metric === "VITALS_BASELINE" &&
token.type === "RESTING_HEART_RATE"
) {
return ok({
type: "RESTING_HEART_RATE",
center: 55,
low: 48,
high: 62,
spread: 7,
sampleDays: 30,
k: 3,
});
}
return insufficient("no_readings_in_window");
});
@@ -96,7 +112,9 @@ describe("<VitalsDashboard>", () => {
});
it("renders a skeleton row (no tiles, no heading strand) while loading", () => {
const batch = mockBatch(() => insufficient("no_readings_in_window"), { isLoading: true });
const batch = mockBatch(() => insufficient("no_readings_in_window"), {
isLoading: true,
});
const html = render(<VitalsDashboard batch={batch} />);
// Heading + grid present so the section reserves its final height, with a
// busy/live region and skeleton placeholders — but no real tiles yet.
@@ -108,7 +126,9 @@ describe("<VitalsDashboard>", () => {
});
it("shows an inline error + retry when the batch fails", () => {
const batch = mockBatch(() => insufficient("no_readings_in_window"), { isError: true });
const batch = mockBatch(() => insufficient("no_readings_in_window"), {
isError: true,
});
const html = render(<VitalsDashboard batch={batch} />);
// The surface must not silently vanish — a compact error + a Retry button.
expect(html).toContain('data-slot="vitals-dashboard-error"');
@@ -135,7 +155,14 @@ describe("<VitalsDashboard>", () => {
it("renders the fitness-age tile with its age framing", () => {
const batch = mockBatch((token) => {
if (token.metric === "FITNESS_AGE") {
return ok({ vo2Max: 46, band: "green", fitnessAgeDeltaYears: -6, referenceBand: { low: 35, high: 45 }, trendDelta: 2, readingCount: 4 });
return ok({
vo2Max: 46,
band: "green",
fitnessAgeDeltaYears: -6,
referenceBand: { low: 35, high: 45 },
trendDelta: 2,
readingCount: 4,
});
}
return insufficient("no_readings_in_window");
});
@@ -147,7 +174,13 @@ describe("<VitalsDashboard>", () => {
it("renders the BMI tile with its WHO category", () => {
const batch = mockBatch((token) => {
if (token.metric === "BMI") {
return ok({ bmi: 24.7, category: "normal", band: "green", weightKg: 80, heightCm: 180 });
return ok({
bmi: 24.7,
category: "normal",
band: "green",
weightKg: 80,
heightCm: 180,
});
}
return insufficient("no_readings_in_window");
});
@@ -210,7 +243,16 @@ describe("<VitalsDashboard>", () => {
it("renders a stair-ascent-speed baseline band tile under Mobility & body", () => {
const batch = mockBatch((token) => {
if (token.metric === "STAIR_ASCENT_SPEED_BASELINE") {
return ok({ type: "STAIR_ASCENT_SPEED", center: 0.4, low: 0.3, high: 0.5, spread: 0.05, sampleDays: 21, k: 3, series: [0.38, 0.4, 0.42] });
return ok({
type: "STAIR_ASCENT_SPEED",
center: 0.4,
low: 0.3,
high: 0.5,
spread: 0.05,
sampleDays: 21,
k: 3,
series: [0.38, 0.4, 0.42],
});
}
return insufficient("no_readings_in_window");
});
@@ -21,7 +21,12 @@ function ok(value: Record<string, unknown>): Resp {
metric: "X",
status: "ok",
value,
coverage: { requiredInputs: 1, presentInputs: 1, historyDays: 30, missing: [] },
coverage: {
requiredInputs: 1,
presentInputs: 1,
historyDays: 30,
missing: [],
},
confidence: { score: 90, band: "high" },
provenance: { inputs: [], source: "DAY", windowDays: 30, computedAt: "x" },
reason: null,
@@ -33,16 +38,21 @@ function absent(): Resp {
metric: "X",
status: "insufficient",
value: null,
coverage: { requiredInputs: 1, presentInputs: 0, historyDays: 0, missing: [] },
coverage: {
requiredInputs: 1,
presentInputs: 0,
historyDays: 0,
missing: [],
},
confidence: null,
provenance: { inputs: [], source: "none", windowDays: 30, computedAt: "x" },
reason: "no_readings_in_window",
};
}
function readFrom(map: Record<string, Resp>): <T>(
token: DerivedBatchToken,
) => DerivedMetricResponse<T> | null {
function readFrom(
map: Record<string, Resp>,
): <T>(token: DerivedBatchToken) => DerivedMetricResponse<T> | null {
return <T,>(token: DerivedBatchToken) =>
(map[token.metric] ?? absent()) as unknown as DerivedMetricResponse<T>;
}
@@ -99,11 +99,7 @@ export function CompositeScoreAnatomy({
// the displayed framing honest per-score. `null` (RECOVERY / STRESS, or no
// cache row yet) renders nothing.
let anchorLine: string | null = null;
if (
metric === "STRAIN_SCORE" &&
data?.status === "ok" &&
data.value
) {
if (metric === "STRAIN_SCORE" && data?.status === "ok" && data.value) {
const anchor = (data.value as WellnessScoreValue).anchor;
if (anchor === "personal") {
anchorLine = t("insights.derived.composite.STRAIN_SCORE.anchorPersonal");
@@ -238,10 +234,7 @@ export function CompositeScoreAnatomy({
/** Localised title per metric — composites use their existing copy keys;
* the persisted scores reuse the wellness-strip labels. */
function titleFor(
metric: AnatomyMetricId,
t: (key: string) => string,
): string {
function titleFor(metric: AnatomyMetricId, t: (key: string) => string): string {
switch (metric) {
case "SLEEP_SCORE":
case "READINESS":
@@ -123,7 +123,7 @@ export function CoverageMeter({
// 44px hit target via padding while the dots stay optically
// small; negative margin collapses the row back so the meter
// doesn't inflate its host row.
"inline-flex min-h-11 items-center gap-2 rounded -my-3 px-1 py-3",
"-my-3 inline-flex min-h-11 items-center gap-2 rounded px-1 py-3",
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none",
className,
)}
@@ -162,7 +162,7 @@ export function ProvenanceExplainer({
className={cn(
"text-muted-foreground hover:text-foreground focus-visible:ring-ring/50",
"inline-flex min-h-11 min-w-11 shrink-0 items-center justify-center rounded-full",
"-my-3 -mx-2",
"-mx-2 -my-3",
"transition-colors focus-visible:ring-2 focus-visible:outline-none",
className,
)}
@@ -127,9 +127,7 @@ export function ScoreAnatomyView({
}
className={cn(
"relative flex flex-col gap-5 rounded-xl border p-5",
hue
? "wellness-detail-card"
: "border-border bg-card",
hue ? "wellness-detail-card" : "border-border bg-card",
className,
)}
aria-label={typeof title === "string" ? title : undefined}
@@ -212,7 +210,8 @@ export function ScoreAnatomyView({
/** One ranked contributor row — label + impact bar + value + weight share. */
function ContributorRow({ contributor }: { contributor: AnatomyContributor }) {
const { t } = useTranslations();
const present = contributor.value != null && Number.isFinite(contributor.value);
const present =
contributor.value != null && Number.isFinite(contributor.value);
const clamped = present ? clampScore(contributor.value as number) : 0;
const rowBand: ScoreBand = bandForScore(clamped);
const weightPct = Math.round(contributor.weight * 100);
@@ -102,9 +102,7 @@ export function SparklineDeltaTile({
};
const sparkData =
series && series.length >= 2
? series.map((v, i) => ({ i, v }))
: null;
series && series.length >= 2 ? series.map((v, i) => ({ i, v })) : null;
// The sparkline tints with the trend sentiment so it reads "same signal"
// as the arrow; neutral metrics ride the muted-foreground line.
@@ -130,10 +128,7 @@ export function SparklineDeltaTile({
the old muted-uppercase caption. Kept on one line for the dense
grid; provenance trails to the right edge. */}
<div className="flex min-w-0 items-center gap-2">
<Icon
className="text-foreground h-5 w-5 shrink-0"
aria-hidden="true"
/>
<Icon className="text-foreground h-5 w-5 shrink-0" aria-hidden="true" />
<span
className="text-foreground min-w-0 flex-1 truncate text-base leading-none font-semibold whitespace-nowrap"
data-slot="sparkline-delta-tile-label"
@@ -184,7 +179,10 @@ export function SparklineDeltaTile({
than reserving an empty dashed placeholder (which read as visual
dead space across the whole grid). */}
{sparkData ? (
<div className="mt-3 h-10 w-full" data-slot="sparkline-delta-tile-spark">
<div
className="mt-3 h-10 w-full"
data-slot="sparkline-delta-tile-spark"
>
<ResponsiveContainer width="100%" height="100%">
<AreaChart
data={sparkData}
+128 -123
View File
@@ -35,142 +35,147 @@ export interface MetricProvenanceMeta {
* (extending the shape SLEEP_SCORE + READINESS already use) so the i18n
* bundle carries one consistent namespace.
*/
export const METRIC_PROVENANCE: Record<DerivedMetricId, MetricProvenanceMeta> = {
VITALS_BASELINE: {
methodKey: "insights.derived.composite.VITALS_BASELINE.method",
standard: {
// Robust central-tendency / dispersion via the median ± k·MAD.
name: "Leys et al. 2013, J. Exp. Soc. Psychol.",
url: "https://doi.org/10.1016/j.jesp.2013.03.013",
export const METRIC_PROVENANCE: Record<DerivedMetricId, MetricProvenanceMeta> =
{
VITALS_BASELINE: {
methodKey: "insights.derived.composite.VITALS_BASELINE.method",
standard: {
// Robust central-tendency / dispersion via the median ± k·MAD.
name: "Leys et al. 2013, J. Exp. Soc. Psychol.",
url: "https://doi.org/10.1016/j.jesp.2013.03.013",
},
},
},
HRV_BALANCE: {
methodKey: "insights.derived.composite.HRV_BALANCE.method",
standard: {
// Task Force of the ESC / NASPE — HRV standards of measurement.
name: "Task Force ESC/NASPE 1996, Circulation",
url: "https://doi.org/10.1161/01.CIR.93.5.1043",
HRV_BALANCE: {
methodKey: "insights.derived.composite.HRV_BALANCE.method",
standard: {
// Task Force of the ESC / NASPE — HRV standards of measurement.
name: "Task Force ESC/NASPE 1996, Circulation",
url: "https://doi.org/10.1161/01.CIR.93.5.1043",
},
},
},
BMI: {
methodKey: "insights.derived.composite.BMI.method",
standard: {
// WHO Technical Report Series 894 — BMI classification.
name: "WHO Technical Report Series 894",
url: "https://iris.who.int/handle/10665/42330",
BMI: {
methodKey: "insights.derived.composite.BMI.method",
standard: {
// WHO Technical Report Series 894 — BMI classification.
name: "WHO Technical Report Series 894",
url: "https://iris.who.int/handle/10665/42330",
},
},
},
FITNESS_AGE: {
methodKey: "insights.derived.composite.FITNESS_AGE.method",
standard: {
// FRIEND registry CRF reference standards (Kaminsky et al.).
name: "Kaminsky et al. 2015, Mayo Clin. Proc. (FRIEND)",
url: "https://doi.org/10.1016/j.mayocp.2015.07.026",
FITNESS_AGE: {
methodKey: "insights.derived.composite.FITNESS_AGE.method",
standard: {
// FRIEND registry CRF reference standards (Kaminsky et al.).
name: "Kaminsky et al. 2015, Mayo Clin. Proc. (FRIEND)",
url: "https://doi.org/10.1016/j.mayocp.2015.07.026",
},
},
},
VASCULAR_AGE_DELTA: {
methodKey: "insights.derived.composite.VASCULAR_AGE_DELTA.method",
standard: {
// Reference values for arterial stiffness (PWV) collaboration.
name: "Reference Values for Arterial Stiffness 2010, Eur. Heart J.",
url: "https://doi.org/10.1093/eurheartj/ehq165",
VASCULAR_AGE_DELTA: {
methodKey: "insights.derived.composite.VASCULAR_AGE_DELTA.method",
standard: {
// Reference values for arterial stiffness (PWV) collaboration.
name: "Reference Values for Arterial Stiffness 2010, Eur. Heart J.",
url: "https://doi.org/10.1093/eurheartj/ehq165",
},
caveatKey: "insights.derived.composite.VASCULAR_AGE_DELTA.caveat",
},
caveatKey: "insights.derived.composite.VASCULAR_AGE_DELTA.caveat",
},
COINCIDENT_DEVIATION: {
methodKey: "insights.derived.composite.COINCIDENT_DEVIATION.method",
standard: {
// Hampel-identifier / median ± k·MAD outlier flagging.
name: "Leys et al. 2013, J. Exp. Soc. Psychol.",
url: "https://doi.org/10.1016/j.jesp.2013.03.013",
COINCIDENT_DEVIATION: {
methodKey: "insights.derived.composite.COINCIDENT_DEVIATION.method",
standard: {
// Hampel-identifier / median ± k·MAD outlier flagging.
name: "Leys et al. 2013, J. Exp. Soc. Psychol.",
url: "https://doi.org/10.1016/j.jesp.2013.03.013",
},
caveatKey: "insights.derived.composite.COINCIDENT_DEVIATION.caveat",
},
caveatKey: "insights.derived.composite.COINCIDENT_DEVIATION.caveat",
},
SLEEP_SCORE: {
methodKey: "insights.derived.composite.SLEEP_SCORE.method",
standard: {
name: "Hirshkowitz et al. 2015, Sleep Health",
url: "https://doi.org/10.1016/j.sleh.2014.12.010",
SLEEP_SCORE: {
methodKey: "insights.derived.composite.SLEEP_SCORE.method",
standard: {
name: "Hirshkowitz et al. 2015, Sleep Health",
url: "https://doi.org/10.1016/j.sleh.2014.12.010",
},
},
},
READINESS: {
methodKey: "insights.derived.composite.READINESS.method",
standard: {
name: "Plews et al. 2013, Sports Medicine",
url: "https://doi.org/10.1007/s40279-013-0071-8",
READINESS: {
methodKey: "insights.derived.composite.READINESS.method",
standard: {
name: "Plews et al. 2013, Sports Medicine",
url: "https://doi.org/10.1007/s40279-013-0071-8",
},
caveatKey: "insights.derived.composite.READINESS.caveat",
},
caveatKey: "insights.derived.composite.READINESS.caveat",
},
RECOVERY_SCORE: {
methodKey: "insights.derived.composite.RECOVERY_SCORE.method",
standard: {
// HRV-guided recovery / training-readiness framing.
name: "Buchheit 2014, Front. Physiol.",
url: "https://doi.org/10.3389/fphys.2014.00073",
RECOVERY_SCORE: {
methodKey: "insights.derived.composite.RECOVERY_SCORE.method",
standard: {
// HRV-guided recovery / training-readiness framing.
name: "Buchheit 2014, Front. Physiol.",
url: "https://doi.org/10.3389/fphys.2014.00073",
},
caveatKey: "insights.derived.composite.RECOVERY_SCORE.caveat",
},
caveatKey: "insights.derived.composite.RECOVERY_SCORE.caveat",
},
STRESS_SCORE: {
methodKey: "insights.derived.composite.STRESS_SCORE.method",
standard: {
// Neurovisceral-integration / HRV-as-stress-index basis.
name: "Kim et al. 2018, Psychiatry Investig.",
url: "https://doi.org/10.30773/pi.2017.08.17",
STRESS_SCORE: {
methodKey: "insights.derived.composite.STRESS_SCORE.method",
standard: {
// Neurovisceral-integration / HRV-as-stress-index basis.
name: "Kim et al. 2018, Psychiatry Investig.",
url: "https://doi.org/10.30773/pi.2017.08.17",
},
caveatKey: "insights.derived.composite.STRESS_SCORE.caveat",
},
caveatKey: "insights.derived.composite.STRESS_SCORE.caveat",
},
STRAIN_SCORE: {
methodKey: "insights.derived.composite.STRAIN_SCORE.method",
standard: {
// Banister TRIMP training-load model (HR-reserve weighting).
name: "Banister 1991, TRIMP model",
url: "https://en.wikipedia.org/wiki/Training_Impulse",
STRAIN_SCORE: {
methodKey: "insights.derived.composite.STRAIN_SCORE.method",
standard: {
// Banister TRIMP training-load model (HR-reserve weighting).
name: "Banister 1991, TRIMP model",
url: "https://en.wikipedia.org/wiki/Training_Impulse",
},
caveatKey: "insights.derived.composite.STRAIN_SCORE.caveat",
},
caveatKey: "insights.derived.composite.STRAIN_SCORE.caveat",
},
WRIST_TEMPERATURE_BASELINE: {
methodKey: "insights.derived.composite.WRIST_TEMPERATURE_BASELINE.method",
standard: {
// Robust personal-deviation band via the median ± k·MAD.
name: "Leys et al. 2013, J. Exp. Soc. Psychol.",
url: "https://doi.org/10.1016/j.jesp.2013.03.013",
WRIST_TEMPERATURE_BASELINE: {
methodKey: "insights.derived.composite.WRIST_TEMPERATURE_BASELINE.method",
standard: {
// Robust personal-deviation band via the median ± k·MAD.
name: "Leys et al. 2013, J. Exp. Soc. Psychol.",
url: "https://doi.org/10.1016/j.jesp.2013.03.013",
},
caveatKey: "insights.derived.composite.WRIST_TEMPERATURE_BASELINE.caveat",
},
caveatKey: "insights.derived.composite.WRIST_TEMPERATURE_BASELINE.caveat",
},
STAIR_ASCENT_SPEED_BASELINE: {
methodKey: "insights.derived.composite.STAIR_ASCENT_SPEED_BASELINE.method",
standard: {
// Robust personal-trend band via the median ± k·MAD.
name: "Leys et al. 2013, J. Exp. Soc. Psychol.",
url: "https://doi.org/10.1016/j.jesp.2013.03.013",
STAIR_ASCENT_SPEED_BASELINE: {
methodKey:
"insights.derived.composite.STAIR_ASCENT_SPEED_BASELINE.method",
standard: {
// Robust personal-trend band via the median ± k·MAD.
name: "Leys et al. 2013, J. Exp. Soc. Psychol.",
url: "https://doi.org/10.1016/j.jesp.2013.03.013",
},
caveatKey:
"insights.derived.composite.STAIR_ASCENT_SPEED_BASELINE.caveat",
},
caveatKey: "insights.derived.composite.STAIR_ASCENT_SPEED_BASELINE.caveat",
},
STAIR_DESCENT_SPEED_BASELINE: {
methodKey: "insights.derived.composite.STAIR_DESCENT_SPEED_BASELINE.method",
standard: {
// Robust personal-trend band via the median ± k·MAD.
name: "Leys et al. 2013, J. Exp. Soc. Psychol.",
url: "https://doi.org/10.1016/j.jesp.2013.03.013",
STAIR_DESCENT_SPEED_BASELINE: {
methodKey:
"insights.derived.composite.STAIR_DESCENT_SPEED_BASELINE.method",
standard: {
// Robust personal-trend band via the median ± k·MAD.
name: "Leys et al. 2013, J. Exp. Soc. Psychol.",
url: "https://doi.org/10.1016/j.jesp.2013.03.013",
},
caveatKey:
"insights.derived.composite.STAIR_DESCENT_SPEED_BASELINE.caveat",
},
caveatKey: "insights.derived.composite.STAIR_DESCENT_SPEED_BASELINE.caveat",
},
SIX_MINUTE_WALK_BAND: {
methodKey: "insights.derived.composite.SIX_MINUTE_WALK_BAND.method",
standard: {
// Enright & Sherrill 1998 reference equations (ATS 2002 test standard).
name: "Enright & Sherrill 1998, Am. J. Respir. Crit. Care Med.",
url: "https://doi.org/10.1164/ajrccm.158.5.9710086",
SIX_MINUTE_WALK_BAND: {
methodKey: "insights.derived.composite.SIX_MINUTE_WALK_BAND.method",
standard: {
// Enright & Sherrill 1998 reference equations (ATS 2002 test standard).
name: "Enright & Sherrill 1998, Am. J. Respir. Crit. Care Med.",
url: "https://doi.org/10.1164/ajrccm.158.5.9710086",
},
caveatKey: "insights.derived.composite.SIX_MINUTE_WALK_BAND.caveat",
},
caveatKey: "insights.derived.composite.SIX_MINUTE_WALK_BAND.caveat",
},
TRAJECTORY: {
methodKey: "insights.derived.composite.TRAJECTORY.method",
standard: {
// Ordinary least squares + the textbook OLS prediction interval.
name: "Montgomery, Peck & Vining 2012, Intro. to Linear Regression Analysis",
url: "https://www.wiley.com/en-us/Introduction+to+Linear+Regression+Analysis%2C+5th+Edition-p-9780470542811",
TRAJECTORY: {
methodKey: "insights.derived.composite.TRAJECTORY.method",
standard: {
// Ordinary least squares + the textbook OLS prediction interval.
name: "Montgomery, Peck & Vining 2012, Intro. to Linear Regression Analysis",
url: "https://www.wiley.com/en-us/Introduction+to+Linear+Regression+Analysis%2C+5th+Edition-p-9780470542811",
},
caveatKey: "insights.derived.composite.TRAJECTORY.caveat",
},
caveatKey: "insights.derived.composite.TRAJECTORY.caveat",
},
};
};
@@ -169,7 +169,9 @@ function CardSkeleton({ compact = false }: { compact?: boolean }) {
<div className="bg-muted/40 h-3 w-32 rounded" />
<div className="bg-muted/40 h-5 w-5 rounded-full" />
</div>
<div className={cn("bg-muted/40 w-full rounded", compact ? "h-12" : "h-24")} />
<div
className={cn("bg-muted/40 w-full rounded", compact ? "h-12" : "h-24")}
/>
</div>
);
}
@@ -211,7 +213,10 @@ export function TrajectoryForecastCard({
base: scaled(v.lastValue, valueScale),
range: 0,
};
return { points: [anchor, ...points], lastValue: scaled(v.lastValue, valueScale) };
return {
points: [anchor, ...points],
lastValue: scaled(v.lastValue, valueScale),
};
}, [data, valueScale]);
if (!data) {
@@ -258,7 +263,11 @@ export function TrajectoryForecastCard({
const v = data.value!;
const DirectionIcon =
v.direction === "up" ? TrendingUp : v.direction === "down" ? TrendingDown : Minus;
v.direction === "up"
? TrendingUp
: v.direction === "down"
? TrendingDown
: Minus;
const last = chart.points[chart.points.length - 1];
const projectedLow = last.base;
const projectedHigh = last.base + last.range;
@@ -2,10 +2,7 @@
import { useMemo } from "react";
import {
useDerivedBatch,
type DerivedBatchToken,
} from "./use-derived-metric";
import { useDerivedBatch, type DerivedBatchToken } from "./use-derived-metric";
// Type-only — keeps the derived registry's server graph out of the bundle.
import type { DerivedMetricId } from "@/lib/insights/derived/registry";
@@ -85,7 +85,10 @@ export function useDerivedMetric<T>(
try {
const params = new URLSearchParams({ metric });
if (type) params.set("type", type);
return apiGet<DerivedMetricResponse<T>>(`/api/insights/derived?${params.toString()}`, { signal: controller.signal });
return apiGet<DerivedMetricResponse<T>>(
`/api/insights/derived?${params.toString()}`,
{ signal: controller.signal },
);
} finally {
clearTimeout(timeoutHandle);
}
@@ -154,7 +157,10 @@ export function useDerivedBatch(
);
try {
const params = new URLSearchParams({ metrics: wireTokens.join(",") });
return apiGet<DerivedBatchResponse>(`/api/insights/derived/batch?${params.toString()}`, { signal: controller.signal });
return apiGet<DerivedBatchResponse>(
`/api/insights/derived/batch?${params.toString()}`,
{ signal: controller.signal },
);
} finally {
clearTimeout(timeoutHandle);
}
@@ -23,10 +23,7 @@ import {
SECTION_MOBILITY,
type DashboardDerived,
} from "./use-dashboard-derived";
import {
resolveTileLayout,
type InsightsLayout,
} from "@/lib/insights-layout";
import { resolveTileLayout, type InsightsLayout } from "@/lib/insights-layout";
import type { TrendDirectionSentiment } from "@/lib/insights/trend-sentiment";
// Type-only — the compute payloads never drag the server graph into the bundle.
import type { VitalsBaselineValue } from "@/lib/insights/derived/baseline";
@@ -153,7 +150,10 @@ const MOBILITY_TILE_LAYOUT_ID: Record<string, string> = {
* `null` layout (caller passed none) is treated as everything-visible so the
* default behaviour is unchanged.
*/
function tileVisible(layout: InsightsLayout | undefined, tileId: string): boolean {
function tileVisible(
layout: InsightsLayout | undefined,
tileId: string,
): boolean {
if (!layout) return true;
return resolveTileLayout(layout, tileId).visible;
}
@@ -653,23 +653,23 @@ export function VitalsDashboard({ batch, layout, className }: DashboardProps) {
order: tileOrder(layout, "bmi"),
node: <BmiTile key="bmi" {...tileProps} />,
},
...SECTION_VITALS.filter(
(type) => type !== "HEART_RATE_VARIABILITY",
).map((type) => {
const id = VITAL_BASELINE_TILE_LAYOUT_ID[type] ?? type;
return {
id,
order: tileOrder(layout, id),
node: (
<BaselineTile
key={type}
metric="VITALS_BASELINE"
type={type}
{...tileProps}
/>
),
};
}),
...SECTION_VITALS.filter((type) => type !== "HEART_RATE_VARIABILITY").map(
(type) => {
const id = VITAL_BASELINE_TILE_LAYOUT_ID[type] ?? type;
return {
id,
order: tileOrder(layout, id),
node: (
<BaselineTile
key={type}
metric="VITALS_BASELINE"
type={type}
{...tileProps}
/>
),
};
},
),
];
return entries
.filter((e) => tileVisible(layout, e.id))
@@ -156,8 +156,10 @@ export function DeviceScoreTile({
);
}
export interface ConnectedDeviceScoreTileProps
extends Omit<DeviceScoreTileProps, "summary"> {
export interface ConnectedDeviceScoreTileProps extends Omit<
DeviceScoreTileProps,
"summary"
> {
/** Optional section heading rendered above the tile when it has data. */
sectionTitle?: string;
/** Optional Lucide glyph for the section heading. */
@@ -187,9 +189,7 @@ export function ConnectedDeviceScoreTile({
if ((summary?.count ?? 0) === 0) return null;
const tile = (
<DeviceScoreTile type={type} summary={summary} {...tileProps} />
);
const tile = <DeviceScoreTile type={type} summary={summary} {...tileProps} />;
if (!sectionTitle || !sectionIcon) return tile;
@@ -73,7 +73,9 @@ describe("<GlucoseClinicalPanel> learning state", () => {
measuredAt: new Date(NOW.getTime() - (4 - i) * DAY),
mgdl,
}));
const glucoseClinical = computeGlucoseClinicalMetrics(readings, { now: NOW });
const glucoseClinical = computeGlucoseClinicalMetrics(readings, {
now: NOW,
});
expect(glucoseClinical.stillLearning).toBe(true);
analyticsMock.mockReturnValue({
@@ -85,7 +87,7 @@ describe("<GlucoseClinicalPanel> learning state", () => {
expect(html).toContain('data-state="learning"');
expect(html).toContain("Still learning your glucose");
// the asserted blocks must NOT be present in the learning state
expect(html).not.toContain("data-slot=\"glucose-tir-bar\"");
expect(html).not.toContain('data-slot="glucose-tir-bar"');
expect(html).not.toContain("Glucose Management Indicator");
// the spot-reading caveat is always carried
expect(html).toContain("spot-reading estimates");
@@ -115,7 +117,10 @@ describe("<GlucoseClinicalPanel> asserted state", () => {
measuredAt: new Date(NOW.getTime() - (values.length - 1 - i) * DAY),
mgdl,
}));
return computeGlucoseClinicalMetrics(readings, { now: NOW, windowDays: 30 });
return computeGlucoseClinicalMetrics(readings, {
now: NOW,
windowDays: 30,
});
}
it("renders TIR bar, GMI, eA1C, a CV badge, and the advanced disclosure", () => {
@@ -140,7 +145,7 @@ describe("<GlucoseClinicalPanel> asserted state", () => {
expect(html).toContain('data-slot="glucose-cv-badge"');
// advanced disclosure trigger is present (region collapsed by default in SSR)
expect(html).toContain('data-slot="glucose-advanced-toggle"');
expect(html).toContain("aria-expanded=\"false\"");
expect(html).toContain('aria-expanded="false"');
expect(html).toContain("J-index");
});
@@ -169,7 +174,10 @@ describe("<GlucoseClinicalPanel> asserted state", () => {
});
it("renders nothing when the thick slice carries no glucoseClinical", () => {
analyticsMock.mockReturnValue({ isLoading: false, data: { summaries: {} } });
analyticsMock.mockReturnValue({
isLoading: false,
data: { summaries: {} },
});
const html = render(<GlucoseClinicalPanel />);
expect(html).toBe("");
});
@@ -26,7 +26,8 @@ import type { DataSummary } from "@/lib/analytics/trends";
const CONTEXT_LABEL_KEYS: Record<string, string> = {
FASTING: "insights.bloodGlucose.clinical.advanced.byContext.fasting",
POSTPRANDIAL: "insights.bloodGlucose.clinical.advanced.byContext.postprandial",
POSTPRANDIAL:
"insights.bloodGlucose.clinical.advanced.byContext.postprandial",
RANDOM: "insights.bloodGlucose.clinical.advanced.byContext.random",
BEDTIME: "insights.bloodGlucose.clinical.advanced.byContext.bedtime",
};
@@ -68,10 +69,7 @@ export function GlucoseAdvancedDisclosure({
<span>{t("insights.bloodGlucose.clinical.advanced.toggle")}</span>
<ChevronDown
aria-hidden="true"
className={cn(
"h-4 w-4 transition-transform",
open && "rotate-180",
)}
className={cn("h-4 w-4 transition-transform", open && "rotate-180")}
/>
</button>
@@ -159,7 +159,10 @@ export function GlucoseClinicalPanel() {
) : null}
{/* Headline metrics */}
<section className="grid grid-cols-2 gap-3 sm:grid-cols-4" data-slot="glucose-headline">
<section
className="grid grid-cols-2 gap-3 sm:grid-cols-4"
data-slot="glucose-headline"
>
{clinical.meanMgdl !== null ? (
<Metric
icon={<Droplet className="h-3.5 w-3.5" aria-hidden="true" />}
@@ -217,7 +220,10 @@ export function GlucoseClinicalPanel() {
and reports `isSpotEstimate: false`, so the caveat would undersell
the data. */}
{clinical.isSpotEstimate ? (
<p className="text-muted-foreground/80 text-xs" data-slot="glucose-spot-caveat">
<p
className="text-muted-foreground/80 text-xs"
data-slot="glucose-spot-caveat"
>
{t("insights.bloodGlucose.clinical.spotCaveat")}
</p>
) : null}
@@ -41,11 +41,31 @@ export function GlucoseTirBar({ distribution }: GlucoseTirBarProps) {
const veryHigh = distribution.tarLevel2;
const segments: Array<{ key: BandKey; label: string; fraction: number }> = [
{ key: "veryLow", label: t("insights.bloodGlucose.clinical.tir.veryLow"), fraction: veryLow },
{ key: "low", label: t("insights.bloodGlucose.clinical.tir.low"), fraction: low },
{ key: "inRange", label: t("insights.bloodGlucose.clinical.tir.inRange"), fraction: inRange },
{ key: "high", label: t("insights.bloodGlucose.clinical.tir.high"), fraction: high },
{ key: "veryHigh", label: t("insights.bloodGlucose.clinical.tir.veryHigh"), fraction: veryHigh },
{
key: "veryLow",
label: t("insights.bloodGlucose.clinical.tir.veryLow"),
fraction: veryLow,
},
{
key: "low",
label: t("insights.bloodGlucose.clinical.tir.low"),
fraction: low,
},
{
key: "inRange",
label: t("insights.bloodGlucose.clinical.tir.inRange"),
fraction: inRange,
},
{
key: "high",
label: t("insights.bloodGlucose.clinical.tir.high"),
fraction: high,
},
{
key: "veryHigh",
label: t("insights.bloodGlucose.clinical.tir.veryHigh"),
fraction: veryHigh,
},
];
const pct = (f: number) => Math.round(f * 100);
@@ -91,7 +111,7 @@ export function GlucoseTirBar({ distribution }: GlucoseTirBarProps) {
/>
{s.label}
</span>
<span className="text-foreground tabular-nums font-medium">
<span className="text-foreground font-medium tabular-nums">
{pct(s.fraction)}%
</span>
</li>
+7 -14
View File
@@ -228,8 +228,9 @@ export function HealthScoreCard({
// descending so the biggest contributor sits first. Components with
// null values sink to the bottom (`weight * 0 === 0`); the tie-break
// is the alphabetical key order so determinism holds across renders.
const provenanceRows = COMPONENT_ORDER
.filter((key) => moodEnabled || key !== "mood")
const provenanceRows = COMPONENT_ORDER.filter(
(key) => moodEnabled || key !== "mood",
)
.map((key) => {
const c = components[key];
const inferredSource: HealthScoreComponentSource =
@@ -244,10 +245,7 @@ export function HealthScoreCard({
effective: c.weight * (c.value !== null ? 1 : 0),
};
})
.sort(
(a, b) =>
b.effective - a.effective || a.key.localeCompare(b.key),
);
.sort((a, b) => b.effective - a.effective || a.key.localeCompare(b.key));
const presentCount = provenanceRows.filter((r) => r.value !== null).length;
const totalCount = provenanceRows.length;
@@ -273,7 +271,7 @@ export function HealthScoreCard({
// gutter). The basis values still bias the column generous
// (~36 % at md, ~40 % at xl) but cede headroom when the parent
// narrows.
"w-full md:basis-[22rem] md:shrink-0 md:grow-0 xl:basis-[26rem]",
"w-full md:shrink-0 md:grow-0 md:basis-[22rem] xl:basis-[26rem]",
// v1.4.28 R3c-Insights — stretch to match the parent's
// `items-stretch` row height (FB-H1/H2). `h-full` + an inner
// flex column lets the disclaimer ride to the bottom with
@@ -377,10 +375,7 @@ export function HealthScoreCard({
) : (
<>
{delta > 0 && (
<ArrowUp
className="text-success h-3 w-3"
aria-hidden="true"
/>
<ArrowUp className="text-success h-3 w-3" aria-hidden="true" />
)}
{delta < 0 && (
<ArrowDown
@@ -580,9 +575,7 @@ export function HealthScoreCard({
<div
className={cn(
"h-full",
isEmpty
? "bg-muted"
: "bg-info/60",
isEmpty ? "bg-muted" : "bg-info/60",
)}
style={{
width: isEmpty
@@ -105,7 +105,7 @@ export function HealthScoreDeltaExplainer({
// the extra reach.
"text-muted-foreground hover:text-foreground focus-visible:ring-ring/50",
"inline-flex min-h-11 min-w-11 shrink-0 items-center justify-center rounded-full",
"-my-3 -mx-2",
"-mx-2 -my-3",
"transition-colors focus-visible:ring-2 focus-visible:outline-none",
className,
)}
@@ -123,10 +123,9 @@ export function HealthScoreDeltaExplainer({
open={open}
onOpenChange={setOpen}
title={title}
description={t(
"insights.healthScore.deltaExplainer.description",
{ delta: delta > 0 ? `+${delta}` : `${delta}` },
)}
description={t("insights.healthScore.deltaExplainer.description", {
delta: delta > 0 ? `+${delta}` : `${delta}`,
})}
>
<p
id={bodyId}
@@ -254,8 +254,7 @@ export function HealthKitMetricPage({
min: rawSummary.min === null ? null : rawSummary.min * scale,
max: rawSummary.max === null ? null : rawSummary.max * scale,
mean: rawSummary.mean === null ? null : rawSummary.mean * scale,
median:
rawSummary.median === null ? null : rawSummary.median * scale,
median: rawSummary.median === null ? null : rawSummary.median * scale,
}
: rawSummary;
+1 -1
View File
@@ -230,7 +230,7 @@ export function HeroStrip({
aria-hidden="true"
className={cn(
"bg-card/65 border-border/60 rounded-xl border px-4 py-4 shadow-sm backdrop-blur-sm",
"w-full md:basis-[22rem] md:shrink-0 md:grow-0 xl:basis-[26rem]",
"w-full md:shrink-0 md:grow-0 md:basis-[22rem] xl:basis-[26rem]",
"flex h-full min-h-64 flex-col gap-3.5",
)}
>
@@ -116,10 +116,10 @@ export function InsightsEditMode({
const saveMutation = useMutation({
mutationFn: async (next: InsightsLayout) => {
return apiPut<InsightsLayout>("/api/insights/layout", {
version: 2,
sections: next.sections,
tiles: next.tiles,
});
version: 2,
sections: next.sections,
tiles: next.tiles,
});
},
onSuccess: (saved) => {
// Optimistic-style settle: write the server-resolved layout into the
@@ -115,8 +115,7 @@ export function InsightsLayoutShell({ children }: { children: ReactNode }) {
// `.planning/research/v15-insights-blocking-bug.md` fix 2.
const summaries = analyticsQuery.data?.summaries;
const hasMood = (comprehensiveQuery.data?.moodSummary?.count ?? 0) > 0;
const hasMedication =
(comprehensiveQuery.data?.medications?.length ?? 0) > 0;
const hasMedication = (comprehensiveQuery.data?.medications?.length ?? 0) > 0;
const hasWorkouts = (workoutsProbe.data?.workouts.length ?? 0) > 0;
const availability: InsightInputs | undefined = useMemo(() => {
if (!isAuthenticated) return undefined;
@@ -138,7 +137,9 @@ export function InsightsLayoutShell({ children }: { children: ReactNode }) {
const visibleTileIds = useMemo(() => {
if (!layoutSettled) return undefined;
return new Set(
insightsLayout.tiles.filter((tile) => tile.visible).map((tile) => tile.id),
insightsLayout.tiles
.filter((tile) => tile.visible)
.map((tile) => tile.id),
);
}, [layoutSettled, insightsLayout]);
const tileOrder = useMemo(() => {
+1 -5
View File
@@ -254,11 +254,7 @@ export function LayoutCoachFab() {
</Button>
{/* Polite announcement for the unread-nudge arrival — see the
rising-edge effect above. */}
<span
data-slot="coach-fab-live"
aria-live="polite"
className="sr-only"
>
<span data-slot="coach-fab-live" aria-live="polite" className="sr-only">
{liveAnnouncement}
</span>
</>
@@ -102,7 +102,7 @@ export function MeasurementDiversityNudge({
type="button"
data-slot="measurement-diversity-nudge"
aria-label={triggerLabel}
className="text-dracula-yellow hover:text-dracula-yellow/80 focus-visible:ring-ring/50 -my-3 -mx-2 inline-flex min-h-11 min-w-11 shrink-0 items-center justify-center rounded-full transition-colors focus-visible:ring-2 focus-visible:outline-none"
className="text-dracula-yellow hover:text-dracula-yellow/80 focus-visible:ring-ring/50 -mx-2 -my-3 inline-flex min-h-11 min-w-11 shrink-0 items-center justify-center rounded-full transition-colors focus-visible:ring-2 focus-visible:outline-none"
>
<Lightbulb className="size-4" aria-hidden="true" />
</button>
@@ -62,7 +62,9 @@ export function MetricCorrelationCard({ slug }: MetricCorrelationCardProps) {
// The query is gated by the hook on `isAuthenticated`, and disabled
// entirely when this slug owns no correlation so a target-less metric
// never fires the thick fetch.
const { data } = useAnalyticsQuery({ enabled: key != null ? undefined : false });
const { data } = useAnalyticsQuery({
enabled: key != null ? undefined : false,
});
const analytics = data as AnalyticsWithCorrelations | undefined;
if (!flags.correlations || !key) return null;
@@ -335,69 +335,69 @@ function TargetReferencePanel({
</CardHeader>
<CardContent className="space-y-3">
{/* Row 2: range bar — where today's value sits inside the band. */}
{target.current != null ? (
<RangeBar
value={target.current}
min={range.min}
max={range.max}
unit={unit}
orangeMin={isMedicationCompliance ? 70 : undefined}
orangeMax={isMedicationCompliance ? 100 : undefined}
/>
) : null}
{/* Row 2b: diastolic range bar for blood pressure. */}
{isBp && bpDiastolic?.range && bpDiastolic.current != null ? (
<div className="space-y-1">
<p className="text-muted-foreground text-xs">
{t("targets.diastolic")}
</p>
{target.current != null ? (
<RangeBar
value={bpDiastolic.current}
min={bpDiastolic.range.min}
max={bpDiastolic.range.max}
unit="mmHg"
value={target.current}
min={range.min}
max={range.max}
unit={unit}
orangeMin={isMedicationCompliance ? 70 : undefined}
orangeMax={isMedicationCompliance ? 100 : undefined}
/>
</div>
) : null}
) : null}
{/* Row 3: 30-day average (blood pressure only — every other metric
{/* Row 2b: diastolic range bar for blood pressure. */}
{isBp && bpDiastolic?.range && bpDiastolic.current != null ? (
<div className="space-y-1">
<p className="text-muted-foreground text-xs">
{t("targets.diastolic")}
</p>
<RangeBar
value={bpDiastolic.current}
min={bpDiastolic.range.min}
max={bpDiastolic.range.max}
unit="mmHg"
/>
</div>
) : null}
{/* Row 3: 30-day average (blood pressure only — every other metric
surfaces its 30-day average in `<MetricPrimaryTile>` above the
chart, so repeating it here would duplicate the figure). */}
{averageLabel ? (
<div className="text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs">
<span>{averageLabel}</span>
</div>
) : null}
{averageLabel ? (
<div className="text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs">
<span>{averageLabel}</span>
</div>
) : null}
{/* Row 4: 7-day consistency strip. */}
{showConsistency && target.consistency7d ? (
<ConsistencyStrip
days={target.consistency7d}
daysInRange={target.daysInRange7d ?? 0}
daysLogged={target.daysLogged7d ?? 0}
/>
) : null}
{/* Row 4: 7-day consistency strip. */}
{showConsistency && target.consistency7d ? (
<ConsistencyStrip
days={target.consistency7d}
daysInRange={target.daysInRange7d ?? 0}
daysLogged={target.daysLogged7d ?? 0}
/>
) : null}
{/* Footer: guideline source link. The adjust-target affordance
{/* Footer: guideline source link. The adjust-target affordance
moved to the header gear (see `TargetAdjustProvider`); this
panel registers itself as that gear's edit target above. */}
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 pt-0.5">
{sourceLink ? (
<a
href={sourceLink}
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 text-xs"
>
<span>{t("targets.sourceLabel", { source: target.source })}</span>
<ExternalLink className="size-3" aria-hidden="true" />
</a>
) : (
<span className="text-muted-foreground text-xs">
{t("targets.sourceLabel", { source: target.source })}
</span>
)}
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 pt-0.5">
{sourceLink ? (
<a
href={sourceLink}
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 text-xs"
>
<span>{t("targets.sourceLabel", { source: target.source })}</span>
<ExternalLink className="size-3" aria-hidden="true" />
</a>
) : (
<span className="text-muted-foreground text-xs">
{t("targets.sourceLabel", { source: target.source })}
</span>
)}
</div>
</CardContent>
</Card>
Loaded 100 of 1000 files, more files were not shown because too many files have changed in this diff. Show more