mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
9f2c403f84
commit
024da9fe96
67 files changed
+545
-344
No files matched your search
+26
-2
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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")}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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. */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user