mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
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()}`);
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
+4
-7
@@ -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}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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";
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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
Reference in new issue
Block a user