fix(ui): granular sleep sources, viewport-safe tour, one aligned settings language

A night with coarse apple-health samples next to granular watch data
showed only awake and asleep — the per-session source pick now prefers
the source that knows its stages. Tour popovers clamp into the
viewport, wrap their footer and fall back to a bottom sheet on narrow
screens. Settings and admin sections share one alignment rule — icon
column left, everything else flush on the content column — with neutral
header icons, no duplicated section titles under the shell heading, and
every select on the account-style idiom with a visible chevron. The
dashboard's customize icon matches insights, the target-settings link
leaves the home charts, and the mood heatmap adopts the damped fills
the other mood charts already use.
This commit is contained in:
Marc-André Bombeck committed 2026-06-10 21:19:05 +02:00
1 parent 9f2c403f84
commit 024da9fe96
67 files changed
+545 -344

No files matched your search

+26 -2
View File
@@ -218,7 +218,31 @@ labelled `<Select>`s and date inputs.
fetching stay in the owning page. Use `FilterBarSelect` for enum
dimensions and `FilterBarDateRange` for the from/to pair.
### 2.7 Status indicators
### 2.7 Section-card headers — icon column + content column
Every Settings/Admin section card header follows one shape, implemented
by `<SettingsCardHeader>` (`src/components/settings/_card-header.tsx`)
or an equivalent inline structure:
- **Icon column** (left): one Lucide icon, `text-muted-foreground h-5 w-5`.
Icons in card headers are always neutral — never `text-primary` or any
`dracula-*` accent. Purple/primary is reserved for actions and
highlight states (§2.8), not for iconography.
- **Content column** (right of the icon, `gap-2` → 28 px offset): the
title, its description, and **everything textual that follows in the
card** — descriptions, follow-up headings after separators, form
stacks, helper/feedback lines. Apply `pl-7` to body blocks so nothing
textual ever starts further left than the title above it, and
follow-up headings align with the title column, not the icon.
- **Exception — full-bleed structures**: data tables, tile/card grids,
charts, ladder lists, and `<hr>` separators may span the full card
width. The rule governs text alignment, not structural surfaces.
- The page-level section `<h1>` is `sr-only`: the shell header already
carries "Einstellungen · <Sektion>", so a visible in-section repeat
is a double title. Keep the `<h1>` in the DOM for the document
outline and `aria-labelledby`.
### 2.8 Status indicators
Use the same color taxonomy across the app:
@@ -297,7 +321,7 @@ Same shape as Settings, with the right-hand sidebar listing:
- Maintenance
- Audit log
Status badges use the §2.7 color taxonomy. Each section opens to a card
Status badges use the §2.8 color taxonomy. Each section opens to a card
grid, never a long fluid list.
---
+2 -2
View File
@@ -1979,8 +1979,7 @@
"stop": "Stopp",
"thinking": "Denke nach…",
"composerPlaceholder": "Frag mich etwas zu deinen Daten…",
"composerHint": "Enter zum Senden, Umschalt+Enter für eine neue Zeile.",
"composerDisclaimer": "Coach-Antworten sind generiert. Klinische Entscheidungen gehören in die Hand deines Arztes.",
"nudgeBubbleLabel": "Neue Nachricht vom Coach — Chat öffnen",
"selfContextChipsLabel": "Der Coach würde gern wissen:",
"selfContextChipDismiss": "Frage verwerfen",
"voiceComingSoon": "Spracheingabe kommt mit der iOS-App in v1.5.",
@@ -3759,6 +3758,7 @@
"resetConfirmTitle": "Kette auf Standard zurücksetzen?",
"resetConfirmBody": "Die Kette wird auf Codex → OpenAI → Anthropic → Lokal → Admin-OpenAI zurückgesetzt. Gespeicherte Zugangsdaten bleiben erhalten."
},
"providerCardTitle": "KI-Anbieter",
"activeProviderHeading": "Aktiver Provider",
"activeProviderBody": "Wähle den Provider, der zuerst genutzt wird. Das Formular darunter konfiguriert ausschließlich den ausgewählten Provider; die Fallback-Kette unten regelt, was passiert, wenn er ausfällt.",
"activeProviderLabel": "Primärer Provider",
+2 -2
View File
@@ -1979,8 +1979,7 @@
"stop": "Stop",
"thinking": "Thinking…",
"composerPlaceholder": "Ask anything about your data…",
"composerHint": "Press Enter to send, Shift+Enter for a new line.",
"composerDisclaimer": "Coach replies are generated. Clinical decisions belong with your doctor.",
"nudgeBubbleLabel": "New message from the Coach — open chat",
"selfContextChipsLabel": "The Coach would like to know:",
"selfContextChipDismiss": "Dismiss question",
"voiceComingSoon": "Voice input arrives with the iOS app in v1.5.",
@@ -3759,6 +3758,7 @@
"resetConfirmTitle": "Reset chain to defaults?",
"resetConfirmBody": "The chain reverts to Codex → OpenAI → Anthropic → Local → Admin OpenAI. Your saved credentials are not touched."
},
"providerCardTitle": "AI provider",
"activeProviderHeading": "Active provider",
"activeProviderBody": "Pick the provider you want to use first. The form below configures only the selected provider; the fallback chain at the bottom decides what happens if it fails.",
"activeProviderLabel": "Primary provider",
+2 -2
View File
@@ -1979,8 +1979,7 @@
"stop": "Detener",
"thinking": "Pensando…",
"composerPlaceholder": "Pregúntame sobre tus datos…",
"composerHint": "Intro para enviar, Mayús+Intro para una nueva línea.",
"composerDisclaimer": "Las respuestas son generadas. Las decisiones clínicas las toma tu médico.",
"nudgeBubbleLabel": "Nuevo mensaje del coach — abrir el chat",
"selfContextChipsLabel": "Al Coach le gustaría saber:",
"selfContextChipDismiss": "Descartar pregunta",
"voiceComingSoon": "La entrada por voz llega con la app de iOS en la v1.5.",
@@ -3759,6 +3758,7 @@
"resetConfirmTitle": "¿Restablecer la cadena a los valores predeterminados?",
"resetConfirmBody": "La cadena vuelve a Codex → OpenAI → Anthropic → Local → OpenAI de administrador. Tus credenciales guardadas no se modifican."
},
"providerCardTitle": "Proveedor de IA",
"activeProviderHeading": "Proveedor activo",
"activeProviderBody": "Elige el proveedor que quieres usar primero. El formulario de abajo configura únicamente el proveedor seleccionado; la cadena de respaldo del final decide qué ocurre si falla.",
"activeProviderLabel": "Proveedor principal",
+2 -2
View File
@@ -1979,8 +1979,7 @@
"stop": "Arrêter",
"thinking": "Réflexion en cours…",
"composerPlaceholder": "Pose-moi une question sur tes données…",
"composerHint": "Entrée pour envoyer, Maj+Entrée pour une nouvelle ligne.",
"composerDisclaimer": "Les réponses sont générées. Les décisions cliniques relèvent de ton médecin.",
"nudgeBubbleLabel": "Nouveau message du coach — ouvrir le chat",
"selfContextChipsLabel": "Le Coach aimerait savoir :",
"selfContextChipDismiss": "Ignorer la question",
"voiceComingSoon": "La saisie vocale arrive avec l’app iOS en v1.5.",
@@ -3759,6 +3758,7 @@
"resetConfirmTitle": "Réinitialiser la chaîne aux valeurs par défaut ?",
"resetConfirmBody": "La chaîne revient à Codex → OpenAI → Anthropic → Local → OpenAI administrateur. Vos identifiants enregistrés ne sont pas affectés."
},
"providerCardTitle": "Fournisseur d'IA",
"activeProviderHeading": "Fournisseur actif",
"activeProviderBody": "Choisissez le fournisseur à utiliser en premier. Le formulaire ci-dessous configure uniquement le fournisseur sélectionné ; la chaîne de secours en bas détermine ce qui se passe en cas d’échec.",
"activeProviderLabel": "Fournisseur principal",
+2 -2
View File
@@ -1979,8 +1979,7 @@
"stop": "Interrompi",
"thinking": "Sto pensando…",
"composerPlaceholder": "Chiedimi qualcosa sui tuoi dati…",
"composerHint": "Invio per inviare, Shift+Invio per una nuova riga.",
"composerDisclaimer": "Le risposte sono generate. Le decisioni cliniche restano al tuo medico.",
"nudgeBubbleLabel": "Nuovo messaggio dal coach — apri la chat",
"selfContextChipsLabel": "Il Coach vorrebbe sapere:",
"selfContextChipDismiss": "Ignora la domanda",
"voiceComingSoon": "L’input vocale arriva con l’app iOS in v1.5.",
@@ -3759,6 +3758,7 @@
"resetConfirmTitle": "Ripristinare la catena ai valori predefiniti?",
"resetConfirmBody": "La catena torna a Codex → OpenAI → Anthropic → Locale → OpenAI amministratore. Le credenziali salvate non vengono toccate."
},
"providerCardTitle": "Provider IA",
"activeProviderHeading": "Provider attivo",
"activeProviderBody": "Scegli il provider da usare per primo. Il modulo qui sotto configura solo il provider selezionato; la catena di fallback in fondo decide cosa accade se fallisce.",
"activeProviderLabel": "Provider principale",
+2 -2
View File
@@ -1979,8 +1979,7 @@
"stop": "Zatrzymaj",
"thinking": "Myślę…",
"composerPlaceholder": "Zapytaj o swoje dane…",
"composerHint": "Enter wysyła, Shift+Enter przechodzi do nowej linii.",
"composerDisclaimer": "Odpowiedzi są generowane. Decyzje kliniczne należą do lekarza.",
"nudgeBubbleLabel": "Nowa wiadomość od trenera — otwórz czat",
"selfContextChipsLabel": "Coach chciałby wiedzieć:",
"selfContextChipDismiss": "Odrzuć pytanie",
"voiceComingSoon": "Wejście głosowe pojawi się z aplikacją iOS w v1.5.",
@@ -3759,6 +3758,7 @@
"resetConfirmTitle": "Przywrócić domyślny łańcuch?",
"resetConfirmBody": "Łańcuch wraca do Codex → OpenAI → Anthropic → Lokalny → OpenAI administratora. Zapisane dane logowania pozostają nienaruszone."
},
"providerCardTitle": "Dostawca AI",
"activeProviderHeading": "Aktywny dostawca",
"activeProviderBody": "Wybierz dostawcę, którego chcesz użyć jako pierwszego. Formularz poniżej konfiguruje tylko wybranego dostawcę; łańcuch zapasowy na dole decyduje, co się stanie w razie awarii.",
"activeProviderLabel": "Główny dostawca",
+7
View File
@@ -171,6 +171,11 @@
--secondary-foreground: #f8f8f2;
--muted: #44475a;
--muted-foreground: #9aa3b3;
/* NativeSelect chevron — a data-URI background-image cannot resolve
* `currentColor` (it falls back to black per the SVG spec), so the
* muted-foreground value is baked into the stroke per theme. Keep in
* sync with `--muted-foreground` above. */
--native-select-chevron: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%239aa3b3' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
--accent: #44475a;
--accent-foreground: #f8f8f2;
--destructive: #ff5555;
@@ -266,6 +271,8 @@
--secondary-foreground: #1f1f1f;
--muted: #dcdeef;
--muted-foreground: #5b6273;
/* Light-theme counterpart of the NativeSelect chevron (see dark block). */
--native-select-chevron: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%235b6273' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
--accent: #dcdeef;
--accent-foreground: #1f1f1f;
--destructive: #cb3a2a;
+3 -5
View File
@@ -13,11 +13,11 @@ import {
Percent,
Pill,
Plus,
Settings2,
Smile,
Target,
TrendingUp,
Waves,
Wrench,
} from "lucide-react";
import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose";
import { cn } from "@/lib/utils";
@@ -844,7 +844,7 @@ export default function DashboardPage() {
<p className="text-muted-foreground mt-1 text-sm">{welcomeText}</p>
</div>
<div className="flex items-center gap-2">
{/* Wrench shortcut to the dashboard-customization settings
{/* Customize shortcut to the dashboard-customization settings
(the tile/layout editor at /settings/dashboard). Sits to
the left of the add button as a monochrome ghost icon, with
a `min-h-11 min-w-11` mobile floor so it meets the 44 px
@@ -862,7 +862,7 @@ export default function DashboardPage() {
aria-label={t("dashboard.customizeDashboard")}
title={t("dashboard.customizeDashboard")}
>
<Wrench className="h-4 w-4" aria-hidden="true" />
<Settings2 className="h-4 w-4" aria-hidden="true" />
</Link>
</Button>
<DropdownMenu>
@@ -1456,7 +1456,6 @@ export default function DashboardPage() {
colors={["#bd93f9"]}
unit="kg"
valueBands={weightBands}
targetSettingsHref="/settings/thresholds"
compareBaseline={compareBaseline}
userTimezone={user?.timezone}
/>
@@ -1507,7 +1506,6 @@ export default function DashboardPage() {
unit="mmHg"
yAxisUnit="Hg"
targetZones={bpTargetZones}
targetSettingsHref="/settings/thresholds"
compareBaseline={compareBaseline}
userTimezone={user?.timezone}
/>
@@ -159,7 +159,7 @@ export function ApiTokenOverviewSection() {
return (
<div className="bg-card border-border overflow-hidden rounded-xl border p-4 sm:p-6">
<div className="flex items-center gap-2">
<Key className="text-primary h-5 w-5" />
<Key className="text-muted-foreground h-5 w-5" />
<div className="text-lg font-semibold">{t("admin.apiTokens")}</div>
</div>
@@ -116,7 +116,7 @@ export function AppLogPreviewSection() {
<div className="bg-card border-border space-y-4 rounded-xl border p-6">
<header className="flex items-start justify-between gap-3">
<div className="flex items-start gap-2">
<FileText className="text-primary mt-0.5 h-5 w-5" />
<FileText className="text-muted-foreground mt-0.5 h-5 w-5" />
<div>
<h2 className="text-lg font-semibold">
{t("admin.section.app-logs.title")}
+2 -2
View File
@@ -107,10 +107,10 @@ export function AssistantSection() {
return (
<div className="bg-card border-border space-y-6 rounded-xl border p-6">
<div className="flex items-center gap-2">
<Sparkles className="text-primary h-5 w-5" />
<Sparkles className="text-muted-foreground h-5 w-5" />
<div className="text-lg font-semibold">{t("admin.assistant.title")}</div>
</div>
<p className="text-muted-foreground text-sm">
<p className="text-muted-foreground pl-7 text-sm">
{t("admin.assistant.description")}
</p>
+1 -1
View File
@@ -349,7 +349,7 @@ export function BackupsSection() {
<div className="bg-card border-border rounded-xl border p-6">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Database className="text-primary h-5 w-5" />
<Database className="text-muted-foreground h-5 w-5" />
<div className="text-lg font-semibold">
{t("admin.section.backups.title")}
</div>
+1 -1
View File
@@ -48,7 +48,7 @@ export function BugReportSection() {
<div className="bg-card border-border rounded-xl border p-6">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="flex items-center gap-2">
<Bug className="text-primary h-5 w-5" />
<Bug className="text-muted-foreground h-5 w-5" />
<h2 className="text-lg font-semibold">
{t("admin.bugReportGithub")}
</h2>
@@ -74,7 +74,7 @@ export function FeedbackInboxSection() {
`/admin/feedback`, so the page header already provides the
title + description — the card-level repetition was just
visual noise. The icon stays as a low-key card affordance. */}
<Inbox className="text-primary h-5 w-5" aria-hidden="true" />
<Inbox className="text-muted-foreground h-5 w-5" aria-hidden="true" />
<Tabs
value={activeStatus}
@@ -27,7 +27,7 @@ export function GeneralSettingsSection() {
return (
<div className="bg-card border-border rounded-xl border p-6">
<div className="flex items-center gap-2">
<Settings className="text-primary h-5 w-5" />
<Settings className="text-muted-foreground h-5 w-5" />
<div className="text-lg font-semibold">{t("admin.appSettings")}</div>
</div>
<div className="mt-4 space-y-4">
+1 -1
View File
@@ -75,7 +75,7 @@ export function GlitchtipSection() {
<div className="bg-card border-border rounded-xl border p-6">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="flex items-center gap-2">
<AlertTriangle className="text-primary h-5 w-5" />
<AlertTriangle className="text-muted-foreground h-5 w-5" />
<h2 className="text-lg font-semibold">{t("admin.glitchtipTitle")}</h2>
</div>
<div className="flex flex-wrap items-center gap-2">
@@ -314,15 +314,15 @@ export function InviteTokensSection() {
<div className="flex flex-col gap-4 p-4 sm:flex-row sm:items-start sm:justify-between sm:p-6">
<div className="min-w-0">
<div className="flex items-center gap-2">
<Ticket className="text-primary h-5 w-5" aria-hidden="true" />
<Ticket className="text-muted-foreground h-5 w-5" aria-hidden="true" />
<h2 id="admin-invites-title" className="text-lg font-semibold">
{t("admin.invites.title")}
</h2>
</div>
<p className="text-muted-foreground mt-1 text-sm">
<p className="text-muted-foreground mt-1 pl-7 text-sm">
{t("admin.invites.description")}
</p>
<p className="text-muted-foreground/80 mt-1 flex items-center gap-1.5 text-xs">
<p className="text-muted-foreground/80 mt-1 flex items-center gap-1.5 pl-7 text-xs">
<ShieldCheck className="size-3.5 shrink-0" aria-hidden="true" />
{t("admin.invites.adminOnlyHint")}
</p>
@@ -198,7 +198,7 @@ export function LoginOverviewSection() {
return (
<div className="bg-card border-border rounded-xl border p-6">
<div className="flex items-center gap-2">
<ScrollText className="text-primary h-5 w-5" />
<ScrollText className="text-muted-foreground h-5 w-5" />
<div className="text-lg font-semibold">{t("admin.loginOverview")}</div>
</div>
@@ -59,7 +59,7 @@ export function RecentAuditPreview() {
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<ScrollText className="text-primary h-5 w-5" aria-hidden="true" />
<ScrollText className="text-muted-foreground h-5 w-5" aria-hidden="true" />
<h2
id="admin-overview-audit-heading"
className="text-lg font-semibold"
+2 -2
View File
@@ -111,12 +111,12 @@ export function RemindersSection() {
{/* 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. */}
<Bell className="text-primary h-5 w-5" />
<Bell className="text-muted-foreground h-5 w-5" />
<div className="text-lg font-semibold">
{t("admin.medicationReminders")}
</div>
</div>
<p className="text-muted-foreground mt-1 text-xs">
<p className="text-muted-foreground mt-1 pl-7 text-xs">
{t("admin.medicationRemindersDescription")}
</p>
+2 -2
View File
@@ -12,10 +12,10 @@ export function ServicesSection() {
return (
<div className="bg-card border-border rounded-xl border p-6">
<div className="flex items-center gap-2">
<Globe className="text-primary h-5 w-5" />
<Globe className="text-muted-foreground h-5 w-5" />
<div className="text-lg font-semibold">{t("admin.servicesGlobal")}</div>
</div>
<p className="text-muted-foreground mt-1 text-xs">
<p className="text-muted-foreground mt-1 pl-7 text-xs">
{t("admin.servicesGlobalDescription")}
</p>
<div className="mt-4 space-y-3">
@@ -53,7 +53,7 @@ export function SystemStatusSection() {
<HostMetricsChart />
<div className="bg-card border-border rounded-xl border p-6">
<div className="flex items-center gap-2">
<Server className="text-primary h-5 w-5" />
<Server className="text-muted-foreground h-5 w-5" />
<div className="text-lg font-semibold">{t("admin.systemStatus")}</div>
</div>
{status ? (
@@ -37,7 +37,7 @@ export function SystemStatusSummary() {
className="bg-card border-border rounded-xl border p-6"
>
<div className="flex items-center gap-2">
<Server className="text-primary h-5 w-5" aria-hidden="true" />
<Server className="text-muted-foreground h-5 w-5" aria-hidden="true" />
<h2
id="admin-overview-snapshot-heading"
className="text-lg font-semibold"
+1 -1
View File
@@ -78,7 +78,7 @@ export function UmamiSection() {
<div className="bg-card border-border rounded-xl border p-6">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="flex items-center gap-2">
<Activity className="text-primary h-5 w-5" />
<Activity className="text-muted-foreground h-5 w-5" />
<h2 className="text-lg font-semibold">{t("admin.umamiTitle")}</h2>
</div>
<div className="flex flex-wrap items-center gap-2">
@@ -243,7 +243,7 @@ export function UserManagementSection() {
floating into the title's gap. */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-2">
<div className="flex items-center gap-2">
<Users className="text-primary h-5 w-5" />
<Users className="text-muted-foreground h-5 w-5" />
<div className="text-lg font-semibold">
{t("admin.userManagement")}
</div>
@@ -77,7 +77,7 @@ export function VersionTileSection() {
className="bg-card border-border rounded-xl border p-6"
>
<div className="flex flex-wrap items-center gap-2">
<Tag className="text-primary h-5 w-5" aria-hidden="true" />
<Tag className="text-muted-foreground h-5 w-5" aria-hidden="true" />
<h2
id="admin-overview-version-heading"
className="text-lg font-semibold"
@@ -51,7 +51,7 @@ export function WebPushVapidSection() {
<div className="bg-card border-border rounded-xl border p-6">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="flex items-center gap-2">
<BellRing className="text-primary h-5 w-5" />
<BellRing className="text-muted-foreground h-5 w-5" />
<h2 className="text-lg font-semibold">
{t("admin.webPushVapidTitle")}
</h2>
+2 -1
View File
@@ -267,6 +267,7 @@ export function MoodHeatmap({
height={cellSize}
rx={2}
fill={cell.color}
fillOpacity={cell.cell ? 0.55 : 1}
className="cursor-pointer"
onPointerEnter={(e) => {
if (e.pointerType === "touch") return;
@@ -317,7 +318,7 @@ export function MoodHeatmap({
<span key={label} className="flex items-center gap-1">
<div
className="h-3 w-3 rounded-sm"
style={{ backgroundColor: color }}
style={{ backgroundColor: color, opacity: 0.55 }}
/>
{label}
</span>
@@ -1,106 +1,79 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
// SSR test render has no Next.js app-router context; stub the two
// navigation hooks the bubble uses.
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => "/insights",
}));
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { CoachLaunchProvider } from "@/lib/insights/coach-launch-context";
import { LayoutCoachFab } from "../layout-coach-fab";
import { isNudgeUnread, LayoutCoachFab } from "../layout-coach-fab";
/**
* v1.4.33 (F15) — layout-level Coach FAB contract.
* v1.16.1 — the layout FAB is nudge-driven now: it renders ONLY while
* an unseen proactive `COACH_NUDGE` exists (per
* `/api/insights/coach/nudge-status` + the device-local seen stamp).
* No permanent launcher remains — the inline pill is the everyday
* entry point.
*
* The FAB sits at the mobile bottom-right. Before v1.4.33 it overlaid
* Recharts tooltips when the user tapped a data point in the chart's
* lower-right corner — the maintainer flagged this as F15 in the
* runtime audit. The fix wires the FAB to `useChartTooltipActive()`
* which reads a singleton MutationObserver of every
* `.recharts-tooltip-wrapper`; while a wrapper is visible the FAB
* fades and drops `pointer-events`.
*
* Project convention is SSR-only tests (no `@testing-library/react`),
* so we pin the initial-paint state — no tooltip can be active before
* hydration, so the FAB renders without the active-flag attribute
* and stays interactive on first paint. The end-to-end auto-hide
* behaviour is exercised in the Playwright mobile suite.
* Project convention is SSR-only tests (no `@testing-library/react`).
* On first paint the nudge-status query has no data, so the bubble
* must render nothing; the unread derivation is pinned through the
* exported pure helper instead.
*/
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
const client = new QueryClient();
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
<QueryClientProvider client={client}>
<I18nProvider initialLocale={locale}>{node}</I18nProvider>
</QueryClientProvider>,
);
}
describe("<LayoutCoachFab>", () => {
it("returns nothing when no <CoachLaunchProvider> is mounted", () => {
// The hook returns `null` outside the provider; the FAB then
// renders nothing rather than crashing — same posture as
// `<CoachLaunchButton>`.
const html = render(<LayoutCoachFab />);
expect(html).not.toContain('data-slot="coach-nudge-bubble"');
});
it("renders nothing on first paint (no nudge-status data yet)", () => {
// The bubble only exists for an unseen nudge; before the status
// query resolves there is nothing to show — no permanent FAB.
const html = render(
<CoachLaunchProvider>
<LayoutCoachFab />
</CoachLaunchProvider>,
);
expect(html).not.toContain('data-slot="coach-nudge-bubble"');
expect(html).not.toContain('data-slot="coach-launch-fab"');
});
});
it("mounts the FAB with the launch-fab slot under the provider", () => {
const html = render(
<CoachLaunchProvider>
<LayoutCoachFab />
</CoachLaunchProvider>,
);
expect(html).toMatch(/data-slot="coach-launch-fab"/);
describe("isNudgeUnread", () => {
const nudgedAt = "2026-06-09T05:15:00.000Z";
it("is false without status or without a nudge", () => {
expect(isNudgeUnread(undefined, null)).toBe(false);
expect(isNudgeUnread({ nudgedAt: null, unread: false }, null)).toBe(false);
});
it("renders without the chart-tooltip-active flag on first paint", () => {
// SSR snapshot of `useChartTooltipActive()` is `false` so the
// FAB stays interactive on initial render. The active classes
// (opacity-0 + pointer-events-none) are applied client-side only
// once a Recharts tooltip wrapper flips its inline visibility.
const html = render(
<CoachLaunchProvider>
<LayoutCoachFab />
</CoachLaunchProvider>,
);
// The data attribute is `undefined` when inactive — React drops
// it from the DOM so the rendered markup never carries it.
expect(html).not.toContain('data-chart-tooltip-active="true"');
// The shadcn `<Button>` primitive bakes in
// `disabled:pointer-events-none` and `[&_svg]:pointer-events-none`,
// so we look for the unprefixed token that the fade-out adds.
// Match it as a class atom (preceded by space/start, followed by
// space/end-of-class-list/quote) so the primitive's prefixed
// variants can't false-match.
expect(html).not.toMatch(/(?:^|["\s])pointer-events-none(?=["\s])/);
expect(html).not.toContain("opacity-0");
it("is true for a server-unread nudge the device has not seen", () => {
expect(isNudgeUnread({ nudgedAt, unread: true }, null)).toBe(true);
expect(isNudgeUnread({ nudgedAt, unread: true }, "2026-01-01")).toBe(true);
});
it("carries the transition class so the fade is animated", () => {
// The fade-out + fade-in shouldn't pop visually — the FAB
// animates opacity over 150ms.
const html = render(
<CoachLaunchProvider>
<LayoutCoachFab />
</CoachLaunchProvider>,
);
expect(html).toContain("transition-opacity");
expect(html).toContain("duration-150");
it("is false once the server counts the nudge as read", () => {
// The user sent a Coach message after the nudge.
expect(isNudgeUnread({ nudgedAt, unread: false }, null)).toBe(false);
});
it("hides the FAB above lg via `lg:hidden`", () => {
// The desktop Coach surface is the inline pill; the FAB stays
// mobile-only.
const html = render(
<CoachLaunchProvider>
<LayoutCoachFab />
</CoachLaunchProvider>,
);
expect(html).toContain("lg:hidden");
});
it("renders the localised label in German", () => {
const html = render(
<CoachLaunchProvider>
<LayoutCoachFab />
</CoachLaunchProvider>,
"de",
);
expect(html).toContain("Coach fragen");
it("is false once this device stored the matching seen stamp", () => {
expect(isNudgeUnread({ nudgedAt, unread: true }, nudgedAt)).toBe(false);
});
});
@@ -91,10 +91,10 @@ export function CoachLaunchButton({
);
}
// Inline header-style action on `lg+`. Hidden below `lg` because the
// layout-level FAB (`<LayoutCoachFab>`) covers that breakpoint —
// mounting both at the same time would put two affordances in front
// of the user.
// Inline header-style action. v1.16.1 — visible on every viewport:
// the layout-level FAB is no longer a permanent launcher (it only
// surfaces an unseen proactive nudge), so the pill is the everyday
// Coach entry point on mobile too.
return (
<Button
type="button"
@@ -102,7 +102,7 @@ export function CoachLaunchButton({
size="sm"
data-slot="coach-launch-inline"
onClick={() => launch.askCoach(prefill ?? null)}
className={cn("hidden h-10 gap-2 self-end lg:inline-flex", className)}
className={cn("inline-flex h-10 gap-2 self-end", className)}
>
<Sparkles className="size-4" aria-hidden="true" />
<span>{accessibleLabel}</span>
@@ -60,10 +60,9 @@ export function InsightsLayoutShell({ children }: { children: ReactNode }) {
const advisorEnabled =
isAuthenticated && flags.enabled && flags.briefing;
// v1.12.6 — routes that carry their own purpose-built disclaimer must NOT
// also show the generic footer below (otherwise two disclaimers stack).
// The Coach full page renders its own `composerDisclaimer` under the
// composer, so its segment is excluded here. Every other segment — the
// v1.12.6 — the Coach segment is excluded from the generic disclaimer
// footer (v1.16.1 dropped the in-chat disclaimer line entirely; the
// chat surface stays footer-free by design). Every other segment — the
// overview (null segment), the metric sub-pages, mood, medications, the
// raw values table — keeps the single generic footer.
const segment = useSelectedLayoutSegment();
+105 -51
View File
@@ -1,5 +1,8 @@
"use client";
import { useEffect, useState } from "react";
import { usePathname, useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -8,88 +11,139 @@ import { useTranslations } from "@/lib/i18n/context";
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
import { useFeatureFlags } from "@/hooks/use-feature-flags";
import { useDisableCoach } from "@/hooks/use-disable-coach";
import { queryKeys } from "@/lib/query-keys";
import { useChartTooltipActive } from "./use-chart-tooltip-active";
/**
* Layout-level mobile Coach launch FAB.
* v1.16.1 — the layout FAB is no longer a permanent launcher. It now
* renders ONLY while an unseen Coach-initiated nudge exists (the
* proactive `COACH_NUDGE` dispatch, surfaced by
* `/api/insights/coach/nudge-status`): a floating circle at the bottom
* right that opens the full-page Coach chat. It disappears once the
* nudge counts as read — the user sent a Coach message after the
* nudge (server-derived), or opened the Coach on this device (local
* seen stamp keyed by the nudge timestamp).
*
* v1.4.28 R3c — promoted out of `<CoachLaunchButton>` (which previously
* rendered both the FAB and the inline pill from a single component;
* each sub-page mount therefore painted a duplicate FAB into the DOM
* before `fixed` positioning collapsed them visually). Mounting the
* FAB once at the layout level keeps the affordance reachable from
* every routed Insights surface while leaving the a11y tree with a
* single button.
* The previous always-on mobile FAB drew attention to nothing; the
* inline "Ask Coach" pill in each page's action row stays the everyday
* entry point.
*
* Hidden on `lg+` viewports — desktop users reach the Coach via the
* inline `<CoachLaunchButton>` pill that each page mounts in its
* action row.
*
* v1.4.33 (F15) — the FAB sits at `bottom-right` and used to overlay
* Recharts tooltips on the bottom-right of any insight chart, so a
* mobile user tapping a data point near the chart's lower-right
* couldn't read the bubble. The button now auto-hides while a chart
* tooltip is active (the wrapper's `visibility: visible` flips us
* to fade-out + pointer-events-none). When the user releases / scrolls
* away the tooltip clears and the FAB fades back in.
* v1.4.33 (F15) — the bubble keeps the chart-tooltip auto-hide so it
* never overlays a Recharts tooltip on the lower right.
*/
const NUDGE_SEEN_STORAGE_KEY = "healthlog-coach-nudge-seen";
export interface CoachNudgeStatus {
nudgedAt: string | null;
unread: boolean;
}
/**
* Pure unread derivation — server signal AND-ed with the local
* seen-stamp. Exported so the unit test pins the contract without a
* QueryClient round-trip.
*/
export function isNudgeUnread(
status: CoachNudgeStatus | undefined,
seenStamp: string | null,
): boolean {
if (!status?.unread || status.nudgedAt === null) return false;
return seenStamp !== status.nudgedAt;
}
export function LayoutCoachFab() {
const { t } = useTranslations();
const router = useRouter();
const pathname = usePathname();
const launch = useCoachLaunch();
const flags = useFeatureFlags();
const disableCoach = useDisableCoach();
const tooltipActive = useChartTooltipActive();
if (!launch) return null;
// v1.4.31 — operator can hide the Coach surface app-wide. The
// FAB returns null when the master OR the coach sub-flag is off.
if (!flags.coach) return null;
// v1.4.47 W3 — per-user opt-out beats the operator's "on" default.
// A privacy-leaning user who toggles "Hide Coach" in Settings → Insights
// gets the FAB short-circuited here, identical to the global-off
// branch above. The hook defaults to `false` in the absence of a
// QueryClient (legacy SSR-only snapshot tests).
if (disableCoach) return null;
const accessibleLabel = t("insights.heroActionAskCoach");
// Local "seen on this device" stamp — the nudge timestamp the user
// last dismissed by opening the chat. Lazy initialiser per the
// `react-hooks/set-state-in-effect` rule.
const [seenStamp, setSeenStamp] = useState<string | null>(() => {
if (typeof window === "undefined") return null;
try {
return window.localStorage.getItem(NUDGE_SEEN_STORAGE_KEY);
} catch {
return null;
}
});
const coachAvailable = !!launch && flags.coach && !disableCoach;
const onCoachPage = pathname?.startsWith("/insights/coach") ?? false;
const { data: status } = useQuery({
queryKey: queryKeys.coachNudgeStatus(),
queryFn: async (): Promise<CoachNudgeStatus> => {
const res = await fetch("/api/insights/coach/nudge-status");
if (!res.ok) throw new Error("coach-nudge-status.failed");
return (await res.json()).data as CoachNudgeStatus;
},
enabled: coachAvailable,
staleTime: 5 * 60 * 1000,
});
const nudgedAt = status?.nudgedAt ?? null;
const unread = isNudgeUnread(status, seenStamp);
// Visiting the Coach page itself counts as reading the nudge on this
// device — persist the stamp so the bubble stays gone after leaving.
useEffect(() => {
if (!onCoachPage || nudgedAt === null) return;
try {
window.localStorage.setItem(NUDGE_SEEN_STORAGE_KEY, nudgedAt);
} catch {
// Storage unavailable (private mode) — the server-side read
// signal still clears the bubble once the user sends a message.
}
}, [onCoachPage, nudgedAt]);
if (!coachAvailable) return null;
if (onCoachPage || !unread) return null;
const accessibleLabel = t("insights.coach.nudgeBubbleLabel");
const handleOpen = () => {
if (nudgedAt !== null) {
setSeenStamp(nudgedAt);
try {
window.localStorage.setItem(NUDGE_SEEN_STORAGE_KEY, nudgedAt);
} catch {
// Best effort — see above.
}
}
router.push("/insights/coach");
};
return (
<Button
type="button"
size="lg"
data-slot="coach-launch-fab"
size="icon"
data-slot="coach-nudge-bubble"
data-chart-tooltip-active={tooltipActive ? "true" : undefined}
onClick={() => launch.askCoach(null)}
onClick={handleOpen}
aria-label={accessibleLabel}
title={accessibleLabel}
// v1.4.33 (F15) — `aria-hidden` + `tabIndex={-1}` while a chart
// tooltip is painted so screen-reader users + keyboard users
// don't trip over a button that's visually faded; pointer-events
// are gone, so a tap can't land on the invisible FAB either.
aria-hidden={tooltipActive ? true : undefined}
tabIndex={tooltipActive ? -1 : undefined}
className={cn(
// Sit above the 64 px bottom-nav + the iPhone home-indicator
// safe-area inset. `calc(env(safe-area-inset-bottom,0)+5rem)`
// keeps the FAB clear of the nav on notched iPhones (where the
// inset is ~34 px) without forcing extra padding into the
// layout grid.
"fixed right-4 z-40 h-12 rounded-full px-4 shadow-lg",
"bottom-[calc(env(safe-area-inset-bottom,0px)+5rem)]",
// safe-area inset on mobile; plain bottom offset on desktop.
"fixed right-4 z-40 size-12 rounded-full shadow-lg",
"bottom-[calc(env(safe-area-inset-bottom,0px)+5rem)] lg:bottom-6",
"from-dracula-purple to-dracula-pink bg-gradient-to-br text-white",
"hover:from-dracula-purple/90 hover:to-dracula-pink/90",
"lg:hidden",
// v1.4.33 (F15) — fade out while a Recharts tooltip is open.
// 150 ms transition matches the rest of the Coach drawer's
// fade timing; `motion-reduce:transition-none` honours user
// preference. `pointer-events-none` keeps the faded FAB from
// intercepting taps that should reach the tooltip behind.
// Fade out while a Recharts tooltip is open (see header note).
"transition-opacity duration-150 motion-reduce:transition-none",
tooltipActive && "pointer-events-none opacity-0",
)}
>
<Sparkles className="size-4" aria-hidden="true" />
<span>{accessibleLabel}</span>
<Sparkles className="size-5" aria-hidden="true" />
</Button>
);
}
+68 -20
View File
@@ -164,28 +164,51 @@ function computeTooltipPosition(
left: rect.left - tooltipSize.width - GAP,
});
}
// Pick the first candidate that fits the viewport; otherwise the
// first candidate (the renderer will clamp inside the box).
// Pick the first candidate that fits the viewport; otherwise fall back to
// centred. EVERY returned position is clamped into the viewport with a
// collision padding — a candidate computed off a target near the edge must
// never push the card (and its footer buttons) out of view.
const clamp = (c: {
placement: TourStop["placement"];
top: number;
left: number;
}) => ({
placement: c.placement,
top: Math.min(
Math.max(COLLISION_PAD, c.top),
Math.max(COLLISION_PAD, vh - tooltipSize.height - COLLISION_PAD),
),
left: Math.min(
Math.max(COLLISION_PAD, c.left),
Math.max(COLLISION_PAD, vw - tooltipSize.width - COLLISION_PAD),
),
});
for (const c of candidates) {
if (
c.top >= 8 &&
c.left >= 8 &&
c.top + tooltipSize.height <= vh - 8 &&
c.left + tooltipSize.width <= vw - 8
c.top >= COLLISION_PAD &&
c.left >= COLLISION_PAD &&
c.top + tooltipSize.height <= vh - COLLISION_PAD &&
c.left + tooltipSize.width <= vw - COLLISION_PAD
) {
return c;
}
}
// Last-resort: centred bottom of viewport.
return {
// None fits as-is: prefer the first requested candidate clamped into the
// viewport (keeps the card near its target) over jumping to centre.
if (candidates.length > 0) return clamp(candidates[0]);
return clamp({
placement: "center",
top: Math.max(16, (vh - tooltipSize.height) / 2),
left: Math.max(16, (vw - tooltipSize.width) / 2),
};
top: (vh - tooltipSize.height) / 2,
left: (vw - tooltipSize.width) / 2,
});
}
const TOOLTIP_WIDTH = 320;
const TOOLTIP_HEIGHT = 220; // enough headroom for two-line title + 4-line body + footer
/** Minimum gap the tooltip keeps from every viewport edge. */
const COLLISION_PAD = 8;
/** Below this viewport width the tooltip renders as a bottom sheet. */
const SHEET_BREAKPOINT = 480;
export function OnboardingTour({
includeAchievements = true,
@@ -333,8 +356,19 @@ export function OnboardingTour({
const counter = stepCounter(state);
const isLast = counter.current === counter.total;
// Responsive sizing: the card never claims more width than the viewport
// minus the collision padding, and very small viewports skip the anchored
// popover entirely in favour of a bottom sheet — anchoring a 20 rem card
// next to a target on a ~400 px screen always ends up clipped.
const viewportWidth =
typeof window !== "undefined" ? window.innerWidth : 1024;
const asSheet = viewportWidth < SHEET_BREAKPOINT;
const tooltipWidth = Math.min(
TOOLTIP_WIDTH,
viewportWidth - COLLISION_PAD * 2,
);
const tooltipPos = computeTooltipPosition(rect, stop.placement, {
width: TOOLTIP_WIDTH,
width: tooltipWidth,
height: TOOLTIP_HEIGHT,
});
@@ -481,13 +515,24 @@ export function OnboardingTour({
<div
ref={tooltipRef}
data-testid="onboarding-tour-tooltip"
data-placement={tooltipPos.placement}
className="bg-card border-border pointer-events-auto absolute max-h-[80vh] overflow-y-auto rounded-xl border p-5 shadow-2xl"
style={{
top: `${tooltipPos.top}px`,
left: `${tooltipPos.left}px`,
width: `${TOOLTIP_WIDTH}px`,
}}
data-placement={asSheet ? "sheet" : tooltipPos.placement}
className={
asSheet
? // Bottom-sheet fallback for small viewports — full-width card
// pinned above the bottom edge; no anchored positioning that
// could clip the footer buttons off-screen.
"bg-card border-border pointer-events-auto absolute inset-x-2 bottom-2 max-h-[70vh] overflow-x-hidden overflow-y-auto rounded-xl border p-5 shadow-2xl"
: "bg-card border-border pointer-events-auto absolute max-h-[80vh] max-w-[22rem] overflow-x-hidden overflow-y-auto rounded-xl border p-5 shadow-2xl"
}
style={
asSheet
? undefined
: {
top: `${tooltipPos.top}px`,
left: `${tooltipPos.left}px`,
width: `${tooltipWidth}px`,
}
}
>
<header className="flex items-start justify-between gap-3">
<div>
@@ -516,7 +561,10 @@ export function OnboardingTour({
on the iPad / iPhone PWA shell. Bumping to `min-h-11` (44 px)
keeps the desktop visual close enough — the buttons grow
~4 px taller — while making mobile usable. */}
<footer className="mt-5 flex items-center justify-between gap-2">
{/* `flex-wrap` keeps long localised labels (Skip / Back / Next) from
forcing a horizontal scrollbar inside the card — the row wraps
instead, so the primary action is always reachable. */}
<footer className="mt-5 flex flex-wrap items-center justify-between gap-2">
<Button
type="button"
variant="ghost"
+4 -2
View File
@@ -33,7 +33,9 @@ import type { LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";
export interface SettingsCardHeaderProps {
/** Lucide icon component; rendered as `text-primary h-5 w-5`. */
/** Lucide icon component; rendered as `text-muted-foreground h-5 w-5`.
* Neutral by design — primary/purple stays reserved for actions and
* highlight states, never for section-header iconography. */
icon: LucideIcon;
/** Card title — rendered as `<h2 class="text-lg font-semibold">`.
* Accepts a node so a card can wrap the title in a link. */
@@ -68,7 +70,7 @@ export function SettingsCardHeader({
return (
<header className={cn("flex items-start gap-2", className)}>
<Icon
className="text-primary mt-0.5 h-5 w-5 shrink-0"
className="text-muted-foreground mt-0.5 h-5 w-5 shrink-0"
aria-hidden="true"
/>
{/* Title + description share one column to the RIGHT of the icon, so the
+4 -4
View File
@@ -229,7 +229,7 @@ export function AboutMeSection({
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="grid gap-4 pl-7 sm:grid-cols-2">
{structuredField(
"conditions",
t("settings.ai.aboutMe.conditionsLabel"),
@@ -249,7 +249,7 @@ export function AboutMeSection({
</div>
</div>
<div className="space-y-1.5">
<div className="space-y-1.5 pl-7">
<Label htmlFor="settings-about-me-freetext">
{t("settings.ai.aboutMe.freeTextLabel")}
</Label>
@@ -267,7 +267,7 @@ export function AboutMeSection({
/>
</div>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap items-center justify-between gap-3 pl-7">
<p
data-testid="settings-about-me-count"
className="text-muted-foreground text-xs tabular-nums"
@@ -354,7 +354,7 @@ export function AboutMeSection({
</p>
)}
<p className="text-muted-foreground border-border border-t pt-3 text-xs">
<p className="text-muted-foreground border-border border-t pt-3 pl-7 text-xs">
{t("settings.ai.aboutMe.hint")} {t("settings.ai.aboutMe.profileHint")}
</p>
</section>
+11 -11
View File
@@ -238,7 +238,7 @@ export function AboutSection() {
<header className="space-y-1">
<h1
id="settings-section-about-title"
className="text-2xl font-semibold tracking-tight"
className="sr-only"
>
{t("settings.sections.about.title")}
</h1>
@@ -255,14 +255,14 @@ export function AboutSection() {
<SettingsCardHeader icon={Info} title="HealthLog" className="mb-4" />
{isLoading || !version ? (
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 pl-7">
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin motion-reduce:animate-none" />
<span className="text-muted-foreground text-sm">
{t("common.loading")}
</span>
</div>
) : (
<dl className="flex flex-wrap items-baseline gap-x-6 gap-y-3 text-sm">
<dl className="flex flex-wrap items-baseline gap-x-6 gap-y-3 pl-7 text-sm">
<div className="flex items-baseline gap-2">
<dt className="text-muted-foreground text-xs tracking-wide uppercase">
{t("settings.about.version")}
@@ -327,7 +327,7 @@ export function AboutSection() {
title={t("settings.about.linksHeading")}
className="mb-4"
/>
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap">
<div className="flex flex-col gap-2 pl-7 sm:flex-row sm:flex-wrap">
<Button asChild variant="outline" size="sm">
<a
href={version.repository}
@@ -372,16 +372,16 @@ export function AboutSection() {
tour" card so the two surfaces feel consistent. */}
<div className="bg-card border-border rounded-xl border p-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-2">
<div className="space-y-1">
<div className="flex items-center gap-2">
<Compass className="text-primary h-5 w-5" />
<div className="flex items-start gap-2">
<Compass className="text-muted-foreground mt-0.5 h-5 w-5 shrink-0" />
<div className="space-y-1">
<h2 className="text-lg font-semibold">
{t("settings.about.tourReplay")}
</h2>
<p className="text-muted-foreground text-xs">
{t("settings.about.tourReplayHint")}
</p>
</div>
<p className="text-muted-foreground text-xs">
{t("settings.about.tourReplayHint")}
</p>
</div>
<Button
type="button"
@@ -402,7 +402,7 @@ export function AboutSection() {
{tourFeedback && (
<p
role="alert"
className={`mt-2 text-xs ${
className={`mt-2 pl-7 text-xs ${
tourFeedback.type === "success"
? "text-success"
: "text-destructive"
+20 -18
View File
@@ -383,7 +383,7 @@ export function AccountSection() {
<header className="space-y-1">
<h1
id="settings-section-account-title"
className="text-2xl font-semibold tracking-tight"
className="sr-only"
>
{t("settings.sections.account.title")}
</h1>
@@ -402,7 +402,7 @@ export function AccountSection() {
title={t("settings.profile")}
className="mb-4"
/>
<form onSubmit={handleSaveProfile} className="space-y-4">
<form onSubmit={handleSaveProfile} className="space-y-4 pl-7">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="username">{t("settings.username")}</Label>
@@ -615,8 +615,10 @@ export function AccountSection() {
title={t("settings.passkeys")}
className="mb-4"
/>
<PasskeyListSection isAuthenticated={isAuthenticated} />
<div className="mt-4 flex justify-end">
<div className="pl-7">
<PasskeyListSection isAuthenticated={isAuthenticated} />
</div>
<div className="mt-4 flex justify-end pl-7">
<Button
variant="outline"
onClick={handleAddPasskey}
@@ -653,16 +655,16 @@ export function AccountSection() {
right border by ~48 px). */}
<div className="bg-card border-border rounded-xl border p-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-2">
<div className="space-y-1">
<div className="flex items-center gap-2">
<Shield className="text-primary h-5 w-5" />
<div className="flex items-start gap-2">
<Shield className="text-muted-foreground mt-0.5 h-5 w-5 shrink-0" />
<div className="space-y-1">
<h2 className="text-lg font-semibold">
{t("settings.passwordReset")}
</h2>
<p className="text-muted-foreground text-xs">
{t("settings.changePasswordDescription")}
</p>
</div>
<p className="text-muted-foreground text-xs">
{t("settings.changePasswordDescription")}
</p>
</div>
<Button
type="button"
@@ -683,16 +685,16 @@ export function AccountSection() {
as the password card so both action surfaces look identical. */}
<div className="bg-card border-border rounded-xl border p-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-2">
<div className="space-y-1">
<div className="flex items-center gap-2">
<Compass className="text-primary h-5 w-5" />
<div className="flex items-start gap-2">
<Compass className="text-muted-foreground mt-0.5 h-5 w-5 shrink-0" />
<div className="space-y-1">
<h2 className="text-lg font-semibold">
{t("onboarding.tour.restart")}
</h2>
<p className="text-muted-foreground text-xs">
{t("onboarding.tour.restartHint")}
</p>
</div>
<p className="text-muted-foreground text-xs">
{t("onboarding.tour.restartHint")}
</p>
</div>
<Button
type="button"
@@ -1156,10 +1158,10 @@ function AvatarSection() {
title={t("settings.avatar.title")}
className="mb-4"
/>
<p className="text-muted-foreground mb-4 text-sm">
<p className="text-muted-foreground mb-4 pl-7 text-sm">
{t("settings.avatar.description")}
</p>
<div className="flex items-center gap-4">
<div className="flex items-center gap-4 pl-7">
<Avatar className="size-16">
{avatarUrl && <AvatarImage src={avatarUrl} alt={user.username} />}
<AvatarFallback className="text-base">
+3 -3
View File
@@ -57,7 +57,7 @@ export function AdvancedSection() {
<header className="space-y-1">
<h1
id="settings-section-advanced-title"
className="text-2xl font-semibold tracking-tight"
className="sr-only"
>
{t("settings.sections.advanced.title")}
</h1>
@@ -154,12 +154,12 @@ function ResearchModeCard() {
data-slot="settings-research-mode-card"
>
<div className="flex items-center gap-2">
<BookOpenCheck className="text-primary h-5 w-5" />
<BookOpenCheck className="text-muted-foreground h-5 w-5" />
<h2 className="text-lg font-semibold">
{t("settings.researchMode.sectionTitle")}
</h2>
</div>
<p className="text-muted-foreground mt-1 text-xs">
<p className="text-muted-foreground mt-1 pl-7 text-xs">
{t("settings.researchMode.subtitle")}
</p>
+65 -50
View File
@@ -35,7 +35,9 @@ import { NativeSelect } from "@/components/ui/native-select";
import { Switch } from "@/components/ui/switch";
import { PasswordInput } from "@/components/ui/password-input";
import { AboutMeSection } from "@/components/settings/about-me-section";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { CoachMemorySection } from "@/components/settings/coach-memory-section";
import { CoachPrefsSection } from "@/components/settings/coach-prefs-section";
import { useAuth } from "@/hooks/use-auth";
import { formatDateTime } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
@@ -183,7 +185,7 @@ export function AiSection() {
<header className="space-y-1">
<h1
id="settings-section-ai-title"
className="text-2xl font-semibold tracking-tight"
className="sr-only"
>
{t("settings.sections.ai.title")}
</h1>
@@ -199,6 +201,12 @@ export function AiSection() {
through provider configuration before finding the toggle. */}
<DisableCoachCard isAuthenticated={isAuthenticated} />
{/* v1.16.1 — Coach preferences (tone, verbosity, data scope)
moved here from the in-chat sheet; the chat header gear
deep-links to this section. Gated on the Coach being
enabled. */}
{coachEnabled && <CoachPrefsSection isAuthenticated={isAuthenticated} />}
{/* v1.11.2 — "What the Coach remembers": durable-fact review +
forget controls. Gated on the Coach being enabled. */}
{coachEnabled && <CoachMemorySection isAuthenticated={isAuthenticated} />}
@@ -447,57 +455,64 @@ function AiInsightsCard({ isAuthenticated }: { isAuthenticated: boolean }) {
return (
<div className="bg-card border-border space-y-4 rounded-xl border p-6">
{/* v1.4.33 IW7 — the dedicated H2 ("KI-Insights") used to repeat
the parent section title ("KI-Auswertungen"). Both are gone now:
the section renders "Auswertungen" once at the top, and this
card carries only the Sparkles icon + the live provider-status
badges, mirroring the description that lives on the section
header. The configuration description is moved one level up
into the section subtitle so the visual hierarchy stays
two-level (section -> form) instead of three-level
(section -> card title -> form). */}
<div className="flex flex-wrap items-center justify-between gap-2">
<Sparkles className="text-primary h-5 w-5" aria-hidden="true" />
<ProviderStatusBadges
settings={insightsSettings}
activeProvider={chainData?.activeProvider ?? null}
{/* The card used to render an icon-only header row (Sparkles +
status badges, no title), which left the tile unanchored next
to its titled siblings. It now follows the shared
`SettingsCardHeader` contract: icon column, title + description
in the content column, status badges top-right — and the body
below indents to the title column (`pl-7`). */}
<SettingsCardHeader
icon={Sparkles}
title={t("settings.ai.providerCardTitle")}
description={t("settings.kiInsightsDescription")}
status={
<ProviderStatusBadges
settings={insightsSettings}
activeProvider={chainData?.activeProvider ?? null}
/>
}
/>
<div className="space-y-4 pl-7">
<ActiveProviderSelect
value={selectedProvider}
onChange={pickProvider}
/>
<ProviderConfigCard
provider={selectedProvider}
insightsSettings={insightsSettings}
userProvider={userProvider}
/>
<FallbackChainCard
chain={chainData?.configuredChain ?? []}
selected={selectedProvider}
onSelect={pickProvider}
/>
<RuntimeActionsRow
provider={selectedProvider}
userProvider={userProvider}
canRegenerate={
insightsSettings?.codexStatus === "connected" ||
insightsSettings?.hasAdminKey ||
Boolean(userProvider?.provider)
}
privacyMode={insightsSettings?.privacyMode ?? "aggregated"}
lastInsightAt={insightsSettings?.lastInsightAt ?? null}
onRegenerated={() =>
queryClient.invalidateQueries({
queryKey: queryKeys.insightsRoot(),
})
}
onPrivacyChanged={() =>
queryClient.invalidateQueries({
queryKey: queryKeys.insightsRoot(),
})
}
/>
</div>
<p className="text-muted-foreground text-xs">
{t("settings.kiInsightsDescription")}
</p>
<ActiveProviderSelect value={selectedProvider} onChange={pickProvider} />
<ProviderConfigCard
provider={selectedProvider}
insightsSettings={insightsSettings}
userProvider={userProvider}
/>
<FallbackChainCard
chain={chainData?.configuredChain ?? []}
selected={selectedProvider}
onSelect={pickProvider}
/>
<RuntimeActionsRow
provider={selectedProvider}
userProvider={userProvider}
canRegenerate={
insightsSettings?.codexStatus === "connected" ||
insightsSettings?.hasAdminKey ||
Boolean(userProvider?.provider)
}
privacyMode={insightsSettings?.privacyMode ?? "aggregated"}
lastInsightAt={insightsSettings?.lastInsightAt ?? null}
onRegenerated={() =>
queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() })
}
onPrivacyChanged={() =>
queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() })
}
/>
</div>
);
}
+2 -2
View File
@@ -43,7 +43,7 @@ export function ApiSection() {
<header className="space-y-1">
<h1
id="settings-section-api-title"
className="text-2xl font-semibold tracking-tight"
className="sr-only"
>
{t("settings.sections.api.title")}
</h1>
@@ -262,7 +262,7 @@ function ApiTokensCard() {
}
/>
<div className="mt-4 space-y-4">
<div className="mt-4 space-y-4 pl-7">
{/* The button mirrors the Input's responsive height (h-11 / sm:h-10)
so the row reads as one aligned control group instead of a tall
input next to a shorter `sm` button. */}
+2 -2
View File
@@ -139,8 +139,8 @@ export function CoachNudgeCard({
aria-live="polite"
className={
msgType === "error"
? "text-destructive mt-3 text-sm"
: "text-muted-foreground mt-3 text-sm"
? "text-destructive mt-3 pl-7 text-sm"
: "text-muted-foreground mt-3 pl-7 text-sm"
}
>
{msg}
@@ -72,7 +72,7 @@ export function CycleTrackingCard({
title={t("settings.cycleTracking.title")}
description={t("settings.cycleTracking.description")}
/>
<div className="mt-4 space-y-4">
<div className="mt-4 space-y-4 pl-7">
<div className="flex items-start justify-between gap-4">
<Label htmlFor="cycle-tracking-enable" className="text-sm">
{t("settings.cycleTracking.enable")}
@@ -312,7 +312,7 @@ export function DashboardLayoutSection({ id }: { id: string }) {
zurücksetzen" copy clipping the card edge at narrow widths. */}
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
<div className="flex items-center gap-2">
<LayoutDashboard className="text-primary h-5 w-5" />
<LayoutDashboard className="text-muted-foreground h-5 w-5" />
<h2 className="text-lg font-semibold">
{t("dashboard.customizeTitle")}
</h2>
@@ -14,7 +14,7 @@ export function DashboardSection() {
<header className="space-y-1">
<h1
id="settings-section-dashboard-title"
className="text-2xl font-semibold tracking-tight"
className="sr-only"
>
{t("settings.sections.dashboard.title")}
</h1>
+2 -2
View File
@@ -80,7 +80,7 @@ export function ExportSection() {
<header className="space-y-1">
<h1
id="settings-section-export-title"
className="text-2xl font-semibold tracking-tight"
className="sr-only"
>
{t("settings.sections.export.title")}
</h1>
@@ -161,7 +161,7 @@ function ExportCardShell({
>
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2">
<Icon className="text-primary h-5 w-5 shrink-0" />
<Icon className="text-muted-foreground h-5 w-5 shrink-0" />
<h2 className="text-base font-semibold">{title}</h2>
</div>
<span className="border-border text-muted-foreground rounded-full border px-2 py-0.5 text-[11px] font-medium tracking-wide uppercase">
@@ -200,7 +200,7 @@ export function HealthRecordExportPanel() {
>
<div className="mb-1 flex items-center gap-2">
<FileText
className="text-primary h-5 w-5 shrink-0"
className="text-muted-foreground h-5 w-5 shrink-0"
aria-hidden="true"
/>
<h2
+1 -1
View File
@@ -95,7 +95,7 @@ function ImportCardShell({
className="bg-card border-border flex h-full flex-col rounded-xl border p-6"
>
<div className="flex items-center gap-2">
<Icon className="text-primary h-5 w-5 shrink-0" aria-hidden="true" />
<Icon className="text-muted-foreground h-5 w-5 shrink-0" aria-hidden="true" />
<h3 className="text-base font-semibold">{title}</h3>
</div>
<p className="text-muted-foreground mt-1 text-xs">{description}</p>
@@ -74,7 +74,7 @@ export function InjectionSitesCard({
title={t("settings.globalExcludedInjectionSitesLabel")}
description={t("settings.globalExcludedInjectionSitesHint")}
/>
<div className="mt-3 grid grid-cols-2 gap-2">
<div className="mt-3 grid grid-cols-2 gap-2 pl-7">
{INJECTION_SITE_KEYS.map((site) => (
<label key={site} className="flex items-center gap-2 text-sm">
<Checkbox
+1 -1
View File
@@ -26,7 +26,7 @@ export function InsightsSection() {
<header className="space-y-1">
<h1
id="settings-section-insights-title"
className="text-2xl font-semibold tracking-tight"
className="sr-only"
>
{t("settings.sections.insights.title")}
</h1>
@@ -257,7 +257,7 @@ export function IntegrationsSection() {
<header className="space-y-1">
<h1
id="settings-section-integrations-title"
className="text-2xl font-semibold tracking-tight"
className="sr-only"
>
{t("settings.sections.integrations.title")}
</h1>
@@ -463,7 +463,7 @@ function WithingsCard({
className="border-border/60 mt-4"
/>
<div className="mt-4 space-y-4">
<div className="mt-4 space-y-4 pl-7">
{errorMessage && <IntegrationErrorMessage message={errorMessage} />}
{/* v1.4.25 W5d — Withings activity-scope reconnect banner.
Only paints when (a) the user has an active Withings
@@ -920,7 +920,7 @@ function WhoopCard({
className="border-border/60 mt-4"
/>
<div className="mt-4 space-y-4">
<div className="mt-4 space-y-4 pl-7">
{errorMessage && <IntegrationErrorMessage message={errorMessage} />}
{/* Parked-integration resume CTA. Surfaces only when the row
state is `parked` (>24h of persistent failures). The button
@@ -1370,7 +1370,7 @@ function FitbitCard({
className="border-border/60 mt-4"
/>
<div className="mt-4 space-y-4">
<div className="mt-4 space-y-4 pl-7">
{errorMessage && <IntegrationErrorMessage message={errorMessage} />}
{/* Parked-integration resume CTA. Surfaces only when the row
state is `parked` (>24h of persistent failures). The button
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { SmilePlus } from "lucide-react";
import { NativeSelect } from "@/components/ui/native-select";
import { Switch } from "@/components/ui/switch";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
@@ -181,16 +182,16 @@ export function MoodReminderCard({
}
/>
{enabled && (
<div className="mt-4 flex min-h-11 items-center gap-3">
<div className="mt-4 flex min-h-11 items-center gap-3 pl-7">
<label
htmlFor="mood-reminder-hour"
className="text-sm font-medium"
>
{t("notifications.moodReminder.hourLabel")}
</label>
<select
<NativeSelect
id="mood-reminder-hour"
className="border-input bg-background h-11 rounded-md border px-3 text-sm"
className="w-auto"
value={reminderHour}
disabled={!isAuthenticated || saving}
onChange={(e) => handleHourChange(Number(e.target.value))}
@@ -201,7 +202,7 @@ export function MoodReminderCard({
{`${h.toString().padStart(2, "0")}:00`}
</option>
))}
</select>
</NativeSelect>
</div>
)}
{msg && (
@@ -210,8 +211,8 @@ export function MoodReminderCard({
aria-live="polite"
className={
msgType === "error"
? "text-destructive mt-3 text-sm"
: "text-muted-foreground mt-3 text-sm"
? "text-destructive mt-3 pl-7 text-sm"
: "text-muted-foreground mt-3 pl-7 text-sm"
}
>
{msg}
@@ -136,12 +136,12 @@ export function NotificationStatusCard() {
data-testid="notification-status-empty"
>
<div className="flex items-center gap-2">
<Bell className="text-primary h-5 w-5" />
<Bell className="text-muted-foreground h-5 w-5" />
<h2 className="text-lg font-semibold">
{t("settings.notificationStatus.title")}
</h2>
</div>
<p className="text-muted-foreground mt-1 text-sm">
<p className="text-muted-foreground mt-1 pl-7 text-sm">
{t("settings.notificationStatus.emptyDescription")}
</p>
</div>
@@ -152,12 +152,12 @@ export function NotificationStatusCard() {
<div className="bg-card border-border rounded-xl border p-6">
<header className="mb-4">
<div className="flex items-center gap-2">
<Bell className="text-primary h-5 w-5" />
<Bell className="text-muted-foreground h-5 w-5" />
<h2 className="text-lg font-semibold">
{t("settings.notificationStatus.title")}
</h2>
</div>
<p className="text-muted-foreground text-xs">
<p className="text-muted-foreground pl-7 text-xs">
{t("settings.notificationStatus.description")}
</p>
</header>
@@ -77,7 +77,7 @@ export function NotificationsSection() {
<header className="space-y-1">
<h1
id="settings-section-notifications-title"
className="text-2xl font-semibold tracking-tight"
className="sr-only"
>
{t("settings.sections.notifications.title")}
</h1>
+1 -1
View File
@@ -91,7 +91,7 @@ export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) {
description={t("settings.ntfyDescription")}
/>
<div className="mt-4 space-y-4">
<div className="mt-4 space-y-4 pl-7">
<div className="flex items-center justify-between">
<Label htmlFor="ntfy-toggle">{t("settings.ntfyEnable")}</Label>
<Switch
@@ -72,7 +72,7 @@ export function SectionPlaceholder({ slug }: SectionPlaceholderProps) {
<header className="space-y-1">
<h1
id={`settings-section-${slug}-title`}
className="text-2xl font-semibold tracking-tight"
className="sr-only"
>
{sectionTitle}
</h1>
+3 -3
View File
@@ -301,7 +301,7 @@ export function SourcesSection() {
<header className="space-y-1">
<h1
id="settings-section-sources-title"
className="text-2xl font-semibold tracking-tight"
className="sr-only"
>
{t("settings.sections.sources.title")}
</h1>
@@ -325,7 +325,7 @@ export function SourcesSection() {
<div className="bg-card border-border space-y-4 rounded-xl border p-6">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
<div className="flex items-center gap-2">
<Layers className="text-primary h-5 w-5" />
<Layers className="text-muted-foreground h-5 w-5" />
<h2 className="text-lg font-semibold">
{t("settings.sections.sources.cardTitle")}
</h2>
@@ -342,7 +342,7 @@ export function SourcesSection() {
</Button>
</div>
<p className="text-muted-foreground text-xs">
<p className="text-muted-foreground pl-7 text-xs">
{t("settings.sections.sources.help")}
</p>
+1 -1
View File
@@ -96,7 +96,7 @@ export function TelegramCard({
description={t("settings.telegramDescription")}
/>
<div className="mt-4 space-y-4">
<div className="mt-4 space-y-4 pl-7">
<form onSubmit={handleSave} className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
@@ -128,7 +128,7 @@ export function ThresholdsEditorSection({ id }: { id: string }) {
control affordance. */}
<div className="flex items-center justify-between">
<SlidersHorizontal
className="text-primary h-5 w-5"
className="text-muted-foreground h-5 w-5"
aria-hidden="true"
/>
{data && Object.keys(data.overrides).length > 0 && (
@@ -41,7 +41,7 @@ export function ThresholdsSection() {
<header className="space-y-1">
<h1
id="settings-section-thresholds-title"
className="text-2xl font-semibold tracking-tight"
className="sr-only"
>
{t("settings.sections.thresholds.title")}
</h1>
+1 -1
View File
@@ -208,7 +208,7 @@ export function WebPushCard() {
/>
{(!loading && !isSupported) || (!loading && isDenied) || msg ? (
<div className="mt-4 space-y-4">
<div className="mt-4 space-y-4 pl-7">
{!loading && !isSupported ? (
<p className="text-muted-foreground text-sm">
{t("settings.webPushNotSupported")}
+8 -10
View File
@@ -46,10 +46,13 @@ import { cn } from "@/lib/utils";
// Doing it as a background (rather than an overlay element) keeps `<select>` a
// single element, so every call site's width / margin className still composes
// straight onto it. `pe-9` reserves room so a long value never runs under the
// chevron. The chevron glyph is the same path Lucide's `ChevronDownIcon` draws,
// at `currentColor` with 50 % opacity to match the trigger.
const CHEVRON_SVG =
"url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' opacity='0.5'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E\")";
// chevron. The chevron glyph is the same path Lucide's `ChevronDownIcon` draws.
// Colour: `currentColor` inside a background-image data URI resolves to black
// (no CSS cascade reaches it), which read as a near-black arrow on the light
// theme. The stroke is therefore baked per theme into the
// `--native-select-chevron` custom property in `globals.css`, pinned to the
// theme's `--muted-foreground` — same colour the shadcn `<SelectTrigger>`
// chevron uses.
const NATIVE_SELECT_CLASS =
"border-input bg-background text-foreground ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-11 sm:h-10 w-full appearance-none rounded-md border ps-3 pe-9 py-1 text-sm shadow-xs transition-[color,box-shadow] focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none bg-[image:var(--native-select-chevron)] bg-[length:1rem] bg-[position:right_0.75rem_center] bg-no-repeat";
@@ -65,12 +68,7 @@ export const NativeSelect = React.forwardRef<
ref={ref}
data-slot="native-select"
className={cn(NATIVE_SELECT_CLASS, className)}
style={
{
"--native-select-chevron": CHEVRON_SVG,
...style,
} as React.CSSProperties
}
style={style}
{...props}
>
{children}
+5 -1
View File
@@ -63,7 +63,11 @@ function SelectTrigger({
from the right edge than the value text sits from the left — the
trigger read lopsided. Pulling the icon box 2 px toward the edge
makes the visible chevron and the value text inset symmetric. */}
<ChevronDownIcon className="-me-0.5 size-4 opacity-50" />
{/* Full-strength muted-foreground (no `opacity-50`): the halved
chevron read near-invisible on the light theme while the value
text next to it sat at full strength. The chevron now matches
the NativeSelect chevron and the input text weight. */}
<ChevronDownIcon className="text-muted-foreground -me-0.5 size-4" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
@@ -286,6 +286,47 @@ describe("reconstructSleepNights", () => {
// Apple Health wins under the override: 230 + 80 = 310.
expect(nights[0].asleepMinutes).toBe(310);
});
it("prefers the stage-granular source over a coarse one regardless of ladder", () => {
// Mixed night: WHOOP writes the full hypnogram (REM/CORE/DEEP/AWAKE),
// Apple Health writes only coarse AWAKE/ASLEEP blocks + IN_BED for the
// SAME night. Even with a ladder that ranks Apple Health first, the
// granular WHOOP partition must carry the night — otherwise the chart
// collapses to awake/asleep and the stages disappear.
const priorityJson = { sleep: ["APPLE_HEALTH", "WHOOP"] };
const rows: SleepStageRow[] = [
srcRow("2026-06-04T01:00:00.000Z", "CORE", 240, "WHOOP"),
srcRow("2026-06-04T03:00:00.000Z", "DEEP", 90, "WHOOP"),
srcRow("2026-06-04T04:30:00.000Z", "REM", 90, "WHOOP"),
srcRow("2026-06-04T04:40:00.000Z", "AWAKE", 10, "WHOOP"),
// Apple Health's coarse parallel export of the same night.
srcRow("2026-06-04T01:30:00.000Z", "ASLEEP", 110, "APPLE_HEALTH"),
srcRow("2026-06-04T01:40:00.000Z", "AWAKE", 10, "APPLE_HEALTH"),
srcRow("2026-06-04T03:30:00.000Z", "ASLEEP", 100, "APPLE_HEALTH"),
srcRow("2026-06-04T04:30:00.000Z", "ASLEEP", 60, "APPLE_HEALTH"),
srcRow("2026-06-04T04:40:00.000Z", "IN_BED", 430, "APPLE_HEALTH"),
];
const nights = reconstructSleepNights(rows, "UTC", priorityJson);
expect(nights).toHaveLength(1);
// WHOOP's granular partition: 240 + 90 + 90 = 420.
expect(nights[0].asleepMinutes).toBe(420);
expect(nights[0].stages.REM).toBe(90);
expect(nights[0].stages.DEEP).toBe(90);
expect(nights[0].stages.CORE).toBe(240);
// The coarse source's bare ASLEEP blocks must not blend in.
expect(nights[0].stages.ASLEEP).toBeUndefined();
});
it("keeps the coarse source as fallback when no granular source exists", () => {
const priorityJson = { sleep: ["APPLE_HEALTH", "WHOOP"] };
const rows: SleepStageRow[] = [
srcRow("2026-06-04T01:30:00.000Z", "ASLEEP", 110, "APPLE_HEALTH"),
srcRow("2026-06-04T03:30:00.000Z", "ASLEEP", 100, "APPLE_HEALTH"),
];
const nights = reconstructSleepNights(rows, "UTC", priorityJson);
expect(nights).toHaveLength(1);
expect(nights[0].asleepMinutes).toBe(210);
});
});
describe("reconstructSleepSessions", () => {
+18 -5
View File
@@ -206,10 +206,15 @@ export interface SleepNight {
const NO_SOURCE = "__none__";
/**
* Pick the one canonical source for a session. The user's `sleep`
* source-priority ladder wins (lowest ladder index = highest priority).
* Sources absent from the ladder fall back to "most asleep minutes", then
* a stable source-name tiebreak. Single-source (or source-less) sessions
* Pick the one canonical source for a session. STAGE GRANULARITY wins first:
* a source carrying at least one granular stage (CORE / DEEP / REM) always
* beats a coarse ASLEEP/AWAKE-only source, so a wearable's full hypnogram is
* never masked by a parallel coarse export of the same night (e.g. Apple
* Health's 6–9 AWAKE/ASLEEP blocks alongside WHOOP's per-stage rows). Among
* equally granular (or equally coarse) sources the user's `sleep`
* source-priority ladder decides (lowest ladder index = highest priority);
* sources absent from the ladder fall back to "most asleep minutes", then a
* stable source-name tiebreak. Single-source (or source-less) sessions
* resolve to that one bucket with no work.
*/
function pickSessionSource(
@@ -233,10 +238,18 @@ function pickSessionSource(
}
// No asleep minutes anywhere (IN_BED / AWAKE only) — fall back to the set
// of all present sources so the session still resolves to a single bucket.
const sources =
let sources =
asleepBySource.size > 0
? [...asleepBySource.keys()]
: [...new Set(rows.map((r) => r.source ?? NO_SOURCE))];
// Granularity gate: a source with a CORE/DEEP/REM partition always beats a
// coarse ASLEEP-only source — otherwise a parallel coarse export of the
// same night masks the wearable's hypnogram. Coarse sources stay only as
// the fallback when NO granular source covers the session.
const granularSources = sources.filter((src) =>
sawGranularStage(rowsBySource.get(src) ?? []),
);
if (granularSources.length > 0) sources = granularSources;
if (sources.length <= 1) return sources[0] ?? NO_SOURCE;
const rankOf = (src: string): number => {
@@ -37,6 +37,13 @@ vi.mock("@/hooks/use-feature-flags", async () => {
};
});
// v1.16.1 — the nudge-driven `<LayoutCoachFab>` reads the app router
// (push + pathname); SSR test renders have no router context.
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => "/insights",
}));
/**
* v1.4.37 W5 — Coach disable cascade invariant.
*
@@ -178,7 +185,11 @@ const COACH_SURFACES: CoachSurface[] = [
<LayoutCoachFab />
</CoachLaunchProvider>
),
proofWhenOn: 'data-slot="coach-launch-fab"',
// v1.16.1 — the FAB is nudge-driven: it renders nothing until the
// nudge-status query resolves with an unseen nudge, so its SSR
// shape is empty even when the flag is on. The negative `coach-*`
// grep + the gate-fired spy assertion still pin the off state.
proofWhenOn: "",
},
{
name: "LayoutCoachMount drawer subtree",
@@ -69,6 +69,13 @@ vi.mock("@/hooks/use-auth", async () => {
// below trivially fail; instead we let the real `useDisableCoach`
// pass through and capture the underlying `useAuth` calls.
// v1.16.1 — the nudge-driven `<LayoutCoachFab>` reads the app router
// (push + pathname); SSR test renders have no router context.
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => "/insights",
}));
function buildUser(disableCoach: boolean): AuthUser {
// Minimal `AuthUser` shape — the Coach gates only read
// `disableCoach`, but TypeScript requires the full interface to
@@ -143,7 +150,10 @@ const DISABLE_COACH_SURFACES: DisableCoachSurface[] = [
<LayoutCoachFab />
</CoachLaunchProvider>
),
proofWhenVisible: 'data-slot="coach-launch-fab"',
// v1.16.1 — nudge-driven: SSR shape is empty even when visible
// (the bubble waits for the nudge-status query). Negative
// assertion still pins the gate via the `coach-*` slot grep.
proofWhenVisible: "",
},
{
name: "LayoutCoachMount",