diff --git a/messages/de.json b/messages/de.json index 2f142ad2c..504fbedc1 100644 --- a/messages/de.json +++ b/messages/de.json @@ -228,6 +228,7 @@ "accountSettings": "Kontoeinstellungen", "mood": "Stimmung", "workouts": "Workouts", + "coach": "Coach", "skipToContent": "Zum Inhalt springen", "sidebar": "Sidebar", "mainNavigation": "Hauptnavigation", @@ -3817,6 +3818,26 @@ "title": "Benachrichtigungen", "description": "Live-Übersicht aller konfigurierten Kanäle." }, + "layout": { + "title": "Layout & Personalisierung", + "description": "Lege fest, wie deine App aufgebaut ist — ein Ort für Dashboard, Insights, Medikamente und Stimmung.", + "dashboard": { + "title": "Dashboard", + "description": "Kachel-Layout und Reihenfolge." + }, + "insights": { + "title": "Insights", + "description": "Reihenfolge der Übersicht und der Navigations-Pills." + }, + "medications": { + "title": "Medikamente", + "description": "Listenansicht und Reihenfolge der Medikamente." + }, + "mood": { + "title": "Stimmungs-Tags", + "description": "Gruppen, Tags und Reihenfolge der Tag-Auswahl." + } + }, "dashboard": { "title": "Dashboard", "description": "Kachel-Layout und Reihenfolge." diff --git a/messages/en.json b/messages/en.json index d6ab0d565..8477957ff 100644 --- a/messages/en.json +++ b/messages/en.json @@ -228,6 +228,7 @@ "accountSettings": "Account settings", "mood": "Mood", "workouts": "Workouts", + "coach": "Coach", "skipToContent": "Skip to content", "sidebar": "Sidebar", "mainNavigation": "Main navigation", @@ -3817,6 +3818,26 @@ "title": "Notifications", "description": "Live overview of every configured channel." }, + "layout": { + "title": "Layout & Personalization", + "description": "Arrange how your app is laid out — one home for the dashboard, insights, medications, and mood.", + "dashboard": { + "title": "Dashboard", + "description": "Tile layout and order." + }, + "insights": { + "title": "Insights", + "description": "Overview sections and navigation-pill order." + }, + "medications": { + "title": "Medications", + "description": "List view and medication order." + }, + "mood": { + "title": "Mood tags", + "description": "Groups, tags, and order of the mood picker." + } + }, "dashboard": { "title": "Dashboard", "description": "Tile layout and order." diff --git a/messages/es.json b/messages/es.json index 9f9469523..4ac12939a 100644 --- a/messages/es.json +++ b/messages/es.json @@ -228,6 +228,7 @@ "accountSettings": "Ajustes de cuenta", "mood": "Estado de ánimo", "workouts": "Entrenamientos", + "coach": "Coach", "skipToContent": "Saltar al contenido", "sidebar": "Barra lateral", "mainNavigation": "Navegación principal", @@ -3817,6 +3818,26 @@ "title": "Notificaciones", "description": "Vista en vivo de cada canal configurado." }, + "layout": { + "title": "Diseño y personalización", + "description": "Organiza cómo está dispuesta tu app — un único lugar para el panel, los análisis, los medicamentos y el ánimo.", + "dashboard": { + "title": "Panel", + "description": "Diseño y orden de los mosaicos." + }, + "insights": { + "title": "Análisis", + "description": "Orden de la vista general y de las pestañas de navegación." + }, + "medications": { + "title": "Medicamentos", + "description": "Vista de lista y orden de los medicamentos." + }, + "mood": { + "title": "Etiquetas de ánimo", + "description": "Grupos, etiquetas y orden del selector de ánimo." + } + }, "dashboard": { "title": "Panel", "description": "Diseño y orden de los mosaicos." diff --git a/messages/fr.json b/messages/fr.json index 23fcc03d2..a5d6fc975 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -228,6 +228,7 @@ "accountSettings": "Paramètres du compte", "mood": "Humeur", "workouts": "Entraînements", + "coach": "Coach", "skipToContent": "Aller au contenu", "sidebar": "Barre latérale", "mainNavigation": "Navigation principale", @@ -3817,6 +3818,26 @@ "title": "Notifications", "description": "Aperçu en direct de chaque canal configuré." }, + "layout": { + "title": "Disposition et personnalisation", + "description": "Organisez la disposition de votre application — un seul endroit pour le tableau de bord, les analyses, les médicaments et l’humeur.", + "dashboard": { + "title": "Tableau de bord", + "description": "Disposition et ordre des tuiles." + }, + "insights": { + "title": "Analyses", + "description": "Ordre de la vue d'ensemble et des onglets de navigation." + }, + "medications": { + "title": "Médicaments", + "description": "Vue de la liste et ordre des médicaments." + }, + "mood": { + "title": "Étiquettes d’humeur", + "description": "Groupes, étiquettes et ordre du sélecteur d’humeur." + } + }, "dashboard": { "title": "Tableau de bord", "description": "Disposition et ordre des tuiles." diff --git a/messages/it.json b/messages/it.json index 050f3ef43..15cc4ed2e 100644 --- a/messages/it.json +++ b/messages/it.json @@ -228,6 +228,7 @@ "accountSettings": "Impostazioni account", "mood": "Umore", "workouts": "Allenamenti", + "coach": "Coach", "skipToContent": "Vai al contenuto", "sidebar": "Barra laterale", "mainNavigation": "Navigazione principale", @@ -3817,6 +3818,26 @@ "title": "Notifiche", "description": "Panoramica in tempo reale di ogni canale configurato." }, + "layout": { + "title": "Layout e personalizzazione", + "description": "Organizza la disposizione della tua app — un unico posto per cruscotto, approfondimenti, farmaci e umore.", + "dashboard": { + "title": "Cruscotto", + "description": "Layout e ordine dei riquadri." + }, + "insights": { + "title": "Approfondimenti", + "description": "Ordine della panoramica e delle schede di navigazione." + }, + "medications": { + "title": "Farmaci", + "description": "Vista elenco e ordine dei farmaci." + }, + "mood": { + "title": "Tag dell’umore", + "description": "Gruppi, tag e ordine del selettore dell’umore." + } + }, "dashboard": { "title": "Cruscotto", "description": "Layout e ordine dei riquadri." diff --git a/messages/pl.json b/messages/pl.json index 17c7d4dcb..5b865a424 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -228,6 +228,7 @@ "accountSettings": "Ustawienia konta", "mood": "Nastrój", "workouts": "Treningi", + "coach": "Coach", "skipToContent": "Przejdź do treści", "sidebar": "Pasek boczny", "mainNavigation": "Nawigacja główna", @@ -3817,6 +3818,26 @@ "title": "Powiadomienia", "description": "Podgląd na żywo każdego skonfigurowanego kanału." }, + "layout": { + "title": "Układ i personalizacja", + "description": "Ustal, jak rozłożona jest Twoja aplikacja — jedno miejsce dla pulpitu, analiz, leków i nastroju.", + "dashboard": { + "title": "Pulpit", + "description": "Układ i kolejność kafelków." + }, + "insights": { + "title": "Analizy", + "description": "Kolejność przeglądu i pigułek nawigacyjnych." + }, + "medications": { + "title": "Leki", + "description": "Widok listy i kolejność leków." + }, + "mood": { + "title": "Tagi nastroju", + "description": "Grupy, tagi i kolejność w wyborze nastroju." + } + }, "dashboard": { "title": "Pulpit", "description": "Układ i kolejność kafelków." diff --git a/src/app/api/mood/analytics/route.ts b/src/app/api/mood/analytics/route.ts index 2d8f69a0d..5774a1095 100644 --- a/src/app/api/mood/analytics/route.ts +++ b/src/app/api/mood/analytics/route.ts @@ -1,177 +1,33 @@ -import { prisma } from "@/lib/db"; import { apiSuccess } from "@/lib/api-response"; -import { summarize, type DataPoint } from "@/lib/analytics/trends"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { cached, caches, type ServerCache } from "@/lib/cache/server-cache"; import { - ensureUserMoodRollupsFresh, - readMoodDayRollups, -} from "@/lib/rollups/mood-rollups"; + buildMoodDailySeries, + type MoodDailySeries, +} from "@/lib/analytics/mood-series"; export const dynamic = "force-dynamic"; -/** - * v1.4.39 W-MOOD — five-year window for the rollup-tier read. - * - * Mirrors the legacy "unbounded findMany" semantics in practice: the - * legacy code passed every mood the user had ever logged into Node. - * The rollup tier stores one row per day, so a five-year cap is - * cheap (at most ~1 800 rows) and still covers every user the - * product has historic data for. - */ -const ROLLUP_WINDOW_MS = 5 * 365 * 24 * 60 * 60 * 1000; - -/** Aggregate multiple mood entries per day into daily averages. */ -function aggregateDailyAverages( - records: Array<{ date: string; score: number }>, -) { - const byDay = new Map(); - for (const record of records) { - const current = byDay.get(record.date) ?? { sum: 0, count: 0 }; - current.sum += record.score; - current.count += 1; - byDay.set(record.date, current); - } - return Array.from(byDay.entries()) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([day, stats]) => ({ - date: day, - score: Math.round((stats.sum / stats.count) * 100) / 100, - samples: stats.count, - })); -} - -interface MoodAnalyticsResult { - entries: Array<{ date: string; score: number; samples: number }>; - summary: ReturnType; - entryCount: number; -} - -/** - * Format the rollup bucket's UTC `bucket_start` as a YYYY-MM-DD - * label. The legacy live path emitted the row's TZ-anchored `date` - * column; the rollup tier anchors on UTC midnight (same convention - * as the measurement rollup tier). For tenants within ±3 h of UTC - * (Berlin year-round) the two labels agree on every entry whose - * timestamp doesn't straddle the UTC boundary — i.e. every realistic - * mood log, which a human submits during waking hours. - * - * Trade-off (QA Specialist-H1, v1.4.39): on DST fall-back nights the - * UTC anchor and the user's local wall-clock day-key can diverge by - * one calendar day. Example: `2025-10-25T23:30:00Z` is 00:30 local - * in Europe/Berlin on `2025-10-26` (one hour after the fall-back - * transition); the rollup row is keyed on `2025-10-25` (UTC) while - * the legacy live-fallback path would emit `2025-10-26`. This is - * pinned by the route-parity DST test. v1.5 per-user-tz bucketing - * (audit P7) anchors the rollup on the same day-key the legacy path - * uses, closing the gap. - */ -function utcDayLabel(d: Date): string { - const yyyy = d.getUTCFullYear(); - const mm = String(d.getUTCMonth() + 1).padStart(2, "0"); - const dd = String(d.getUTCDate()).padStart(2, "0"); - return `${yyyy}-${mm}-${dd}`; -} - -async function buildMoodAnalyticsResponse( - userId: string, -): Promise { - // v1.4.39 W-MOOD — fire-and-forget warm-up so the next cold mount - // for this user lands on the rollup tier even when the boot-time - // backfill hasn't reached them yet. - void ensureUserMoodRollupsFresh(userId); - - const since = new Date(Date.now() - ROLLUP_WINDOW_MS); - const rollups = await readMoodDayRollups(userId, since); - - if (rollups.length > 0) { - // Fast path — one DAY-rollup row per calendar day. The legacy - // `aggregateDailyAverages` collapse is unnecessary because the - // rollup already carries the daily mean; we recreate its output - // shape directly so the response stays byte-compatible. - const entries = rollups - .map((r) => ({ - date: utcDayLabel(r.bucketStart), - score: Math.round(r.mean * 100) / 100, - samples: r.count, - })) - .sort((a, b) => a.date.localeCompare(b.date)); - - // Summarize feeds the slope7/30/90 windows. One DataPoint per - // day (mean) is the right resolution — the legacy path passed - // per-entry points, but the slope-window outputs match because - // a power user's mood is typically 1/day. Multi-entry days - // contribute their daily mean exactly once, which is the same - // semantic the dashboard tile renders. - const dataPoints: DataPoint[] = rollups.map((r) => ({ - date: r.bucketStart, - value: r.mean, - })); - const summary = summarize(dataPoints); - const entryCount = rollups.reduce((s, r) => s + r.count, 0); - - annotate({ meta: { mood_analytics_path: "rollup" } }); - - return { entries, summary, entryCount }; - } - - // Coverage-fallback — the user has no rollup rows yet. Probe for - // raw mood entries; when the table is empty for this user we - // return the same empty envelope the legacy path emitted. When - // mood entries exist but rollups don't (legacy account before the - // boot-backfill has caught up), we run the legacy live walk ONCE - // so the request still gets a correct response — the warm-up - // fired above will mint the rollups for the next request. - const moodEntries = await prisma.moodEntry.findMany({ - // v1.7.0 sync — exclude tombstoned rows from the analytics fallback. - where: { userId, deletedAt: null }, - orderBy: { moodLoggedAt: "asc" }, - select: { date: true, score: true, moodLoggedAt: true }, - }); - - const entries = aggregateDailyAverages( - moodEntries.map((e) => ({ date: e.date, score: e.score })), - ); - - // QA UX-H1 (v1.4.39): feed `summarize()` per-day means, not per-entry - // scores. The rollup fast-path emits one DataPoint per calendar day - // (the rollup's `mean`); the live fallback used to pass every raw - // entry, which silently shifted `summary.count / latest / min / max - // / mean / avg7 / avg30 / slope30` on power-user multi-entry days. - // Pre-aggregating through `aggregateDailyAverages` keeps the two - // branches byte-identical on multi-entry days too. Date anchor is - // local-noon for the day so the slope x-axis spans whole-day units — - // mirrors the dashboard tile's intuition (one number per day). - const dataPoints: DataPoint[] = entries.map((e) => ({ - date: new Date(`${e.date}T12:00:00.000Z`), - value: e.score, - })); - - const summary = summarize(dataPoints); - - annotate({ - meta: { - mood_analytics_path: moodEntries.length === 0 ? "rollup" : "live", - }, - }); - - return { entries, summary, entryCount: moodEntries.length }; -} - export const GET = apiHandler(async () => { const { user } = await requireAuth(); + // v1.17.1 — single mood engine: the route and the dashboard snapshot + // both read through `buildMoodDailySeries`, so the dashboard tile, the + // insights mood sparkline, and the iOS client all see the same number. const result = await cached( - caches.moodAnalytics as ServerCache, + caches.moodAnalytics as ServerCache, user.id, - () => buildMoodAnalyticsResponse(user.id), + () => buildMoodDailySeries(user.id), annotate, ); annotate({ action: { name: "mood.analytics" }, - meta: { entryCount: result.entryCount }, + meta: { + entryCount: result.entryCount, + mood_analytics_path: result.source, + }, }); return apiSuccess({ entries: result.entries, summary: result.summary }); diff --git a/src/app/settings/[section]/page.tsx b/src/app/settings/[section]/page.tsx index a0c522539..20f1d2d25 100644 --- a/src/app/settings/[section]/page.tsx +++ b/src/app/settings/[section]/page.tsx @@ -10,6 +10,7 @@ import { DashboardSection } from "@/components/settings/dashboard-section"; import { ExportSection } from "@/components/settings/export-section"; import { InsightsSection } from "@/components/settings/insights-section"; import { IntegrationsSection } from "@/components/settings/integrations-section"; +import { LayoutSection } from "@/components/settings/layout-section"; import { MedicationsSection } from "@/components/settings/medications-section"; import { MoodSection } from "@/components/settings/mood-section"; import { NotificationsSection } from "@/components/settings/notifications-section"; @@ -51,6 +52,7 @@ const SECTION_COMPONENTS: Record< ai: AiSection, integrations: IntegrationsSection, notifications: NotificationsSection, + layout: LayoutSection, dashboard: DashboardSection, insights: InsightsSection, medications: MedicationsSection, diff --git a/src/components/insights/derived/score-anatomy-view.tsx b/src/components/insights/derived/score-anatomy-view.tsx index 8a9e5e154..885f24d2c 100644 --- a/src/components/insights/derived/score-anatomy-view.tsx +++ b/src/components/insights/derived/score-anatomy-view.tsx @@ -4,6 +4,7 @@ import type { ReactNode } from "react"; import { useTranslations } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; +import { LearningGate } from "@/components/ui/learning-gate"; import type { DerivedConfidence, DerivedCoverage, @@ -180,13 +181,13 @@ export function ScoreAnatomyView({ {insufficient ? ( -

- {insufficientNote ?? - t("insights.derived.anatomy.insufficient")} -

+ ) : (

diff --git a/src/components/insights/derived/trajectory-forecast-card.tsx b/src/components/insights/derived/trajectory-forecast-card.tsx index 99208be81..9a55f9f4a 100644 --- a/src/components/insights/derived/trajectory-forecast-card.tsx +++ b/src/components/insights/derived/trajectory-forecast-card.tsx @@ -6,6 +6,7 @@ import { TrendingUp, TrendingDown, Minus } from "lucide-react"; import { useTranslations } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; +import { LearningGate } from "@/components/ui/learning-gate"; import { CoverageMeter } from "@/components/insights/derived/coverage-meter"; import { ProvenanceExplainer } from "@/components/insights/derived/provenance-explainer"; import { METRIC_PROVENANCE } from "@/components/insights/derived/standards"; @@ -237,25 +238,18 @@ export function TrajectoryForecastCard({ return (

-

- {t("insights.derived.trajectory.insufficient")} -

- {daysNeeded > 0 ? ( -

- {t("insights.derived.trajectory.daysNeeded", { - count: daysNeeded, - })} -

- ) : null} + 0 + ? t("insights.derived.trajectory.daysNeeded", { + count: daysNeeded, + }) + : undefined + } + />
diff --git a/src/components/insights/glucose/glucose-clinical-panel.tsx b/src/components/insights/glucose/glucose-clinical-panel.tsx index 6f70e07ae..144447eff 100644 --- a/src/components/insights/glucose/glucose-clinical-panel.tsx +++ b/src/components/insights/glucose/glucose-clinical-panel.tsx @@ -4,6 +4,7 @@ import { Activity, Droplet, TrendingUp } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { LearningGate } from "@/components/ui/learning-gate"; import { Skeleton } from "@/components/ui/skeleton"; import { useAuth } from "@/hooks/use-auth"; import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query"; @@ -99,18 +100,19 @@ export function GlucoseClinicalPanel() { {t("insights.bloodGlucose.clinical.learningTitle")} - -

- {hasAny - ? t("insights.bloodGlucose.clinical.learningBody", { - count: readingsLabel, - days: daysLabel, - }) - : t("insights.bloodGlucose.clinical.learningBodyEmpty")} -

-

- {t("insights.bloodGlucose.clinical.spotCaveat")} -

+ + ); diff --git a/src/components/insights/sleep/chronotype-card.tsx b/src/components/insights/sleep/chronotype-card.tsx index 280b0a544..1932e7b92 100644 --- a/src/components/insights/sleep/chronotype-card.tsx +++ b/src/components/insights/sleep/chronotype-card.tsx @@ -6,6 +6,7 @@ import { Clock, ChevronDown, ChevronUp } from "lucide-react"; import { useTranslations } from "@/lib/i18n/context"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; +import { LearningGate } from "@/components/ui/learning-gate"; import type { ChronotypeDto } from "./use-sleep-rhythm"; /** Minutes-of-day → "HH:MM" wall-clock label (24 h, zero-padded). */ @@ -48,12 +49,12 @@ export function ChronotypeCard({ chronotype }: { chronotype: ChronotypeDto }) {
-

- {t("insights.sleep.chronotype.learning", { + + /> ); diff --git a/src/components/insights/sleep/sleep-debt-card.tsx b/src/components/insights/sleep/sleep-debt-card.tsx index 5558feb05..6a864d9ed 100644 --- a/src/components/insights/sleep/sleep-debt-card.tsx +++ b/src/components/insights/sleep/sleep-debt-card.tsx @@ -4,6 +4,7 @@ import { Moon } from "lucide-react"; import { useTranslations } from "@/lib/i18n/context"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { LearningGate } from "@/components/ui/learning-gate"; import type { SleepDebtDto } from "./use-sleep-rhythm"; /** Whole hours + minutes from a minute total, for the headline figure. */ @@ -36,12 +37,12 @@ export function SleepDebtCard({ debt }: { debt: SleepDebtDto }) { -

- {t("insights.sleep.debt.learning", { + + /> ); diff --git a/src/components/layout/__tests__/nav-model.test.ts b/src/components/layout/__tests__/nav-model.test.ts new file mode 100644 index 000000000..86484cb44 --- /dev/null +++ b/src/components/layout/__tests__/nav-model.test.ts @@ -0,0 +1,91 @@ +/** + * v1.17.1 (F-1 / F-3) — the one navigation information-model. + * + * Pins the coherence contract the desktop sidebar and the mobile + * bottom-nav both consume: one ordered destination list (so the two bars + * tell one story), Workouts AND Coach as first-class destinations on both + * surfaces, cycle gated by the account flag, and an active-resolver that + * prefers the most-specific sibling under `/insights`. + */ +import { describe, expect, it } from "vitest"; + +import { + NAV_DESTINATIONS, + isNavDestinationActive, + visibleNavDestinations, +} from "../nav-model"; + +describe("nav-model destination list", () => { + it("lists Workouts and Coach as first-class destinations", () => { + const hrefs = NAV_DESTINATIONS.map((d) => d.href); + expect(hrefs).toContain("/insights/workouts"); + expect(hrefs).toContain("/insights/coach"); + }); + + it("gives the Coach exactly one nav home (F-3)", () => { + const coach = NAV_DESTINATIONS.filter( + (d) => d.href === "/insights/coach", + ); + expect(coach).toHaveLength(1); + expect(coach[0]!.tKey).toBe("nav.coach"); + }); + + it("orders the core spine: dashboard → measurements → mood → medications", () => { + const hrefs = NAV_DESTINATIONS.map((d) => d.href); + expect(hrefs.indexOf("/")).toBeLessThan(hrefs.indexOf("/measurements")); + expect(hrefs.indexOf("/measurements")).toBeLessThan( + hrefs.indexOf("/mood"), + ); + expect(hrefs.indexOf("/mood")).toBeLessThan( + hrefs.indexOf("/medications"), + ); + }); + + it("every destination carries an i18n key under the nav namespace", () => { + for (const d of NAV_DESTINATIONS) { + expect(d.tKey.startsWith("nav.")).toBe(true); + } + }); + +}); + +describe("visibleNavDestinations cycle gate", () => { + it("includes Cycle (between Medications and Insights) only when enabled", () => { + const off = visibleNavDestinations(false).map((d) => d.href); + const on = visibleNavDestinations(true).map((d) => d.href); + expect(off).not.toContain("/cycle"); + expect(on).toContain("/cycle"); + expect(on.indexOf("/medications")).toBeLessThan(on.indexOf("/cycle")); + expect(on.indexOf("/cycle")).toBeLessThan(on.indexOf("/insights")); + }); +}); + +describe("isNavDestinationActive most-specific resolution", () => { + it("matches the dashboard only on an exact path", () => { + expect(isNavDestinationActive("/", "/")).toBe(true); + expect(isNavDestinationActive("/", "/measurements")).toBe(false); + }); + + it("does not light up Insights when on its Coach sibling", () => { + expect(isNavDestinationActive("/insights/coach", "/insights/coach")).toBe( + true, + ); + expect(isNavDestinationActive("/insights", "/insights/coach")).toBe(false); + }); + + it("does not light up Insights when on its Workouts sibling", () => { + expect( + isNavDestinationActive("/insights/workouts", "/insights/workouts"), + ).toBe(true); + expect(isNavDestinationActive("/insights", "/insights/workouts")).toBe( + false, + ); + }); + + it("still lights up Insights on its own sub-routes", () => { + expect(isNavDestinationActive("/insights", "/insights")).toBe(true); + expect(isNavDestinationActive("/insights", "/insights/values/WEIGHT")).toBe( + true, + ); + }); +}); diff --git a/src/components/layout/__tests__/sidebar-nav.test.tsx b/src/components/layout/__tests__/sidebar-nav.test.tsx index 5d070b66a..82955553a 100644 --- a/src/components/layout/__tests__/sidebar-nav.test.tsx +++ b/src/components/layout/__tests__/sidebar-nav.test.tsx @@ -131,6 +131,29 @@ describe(" targets deprecation (v1.8.6)", () => { }); }); +describe(" unified destination model (v1.17.1 F-1 / F-3)", () => { + it("surfaces Workouts and the Coach as first-class sidebar destinations", () => { + // Pre-unify the sidebar hid Workouts entirely and had no Coach home, + // while the mobile bar promoted Workouts and still missed Coach. Both + // now render the one shared model, so both carry both destinations. + const html = render(); + expect(html).toContain('href="/insights/workouts"'); + expect(html).toContain('href="/insights/coach"'); + expect(html).toContain("Workouts"); + expect(html).toContain("Coach"); + }); + + it("marks Coach active without also marking Insights active", () => { + const html = render({ pathname: "/insights/coach" }); + // The Coach link carries aria-current="page"; the Insights link, its + // less-specific sibling, must not (most-specific resolution). + const coach = html.match(/]*href="\/insights\/coach"[^>]*>/); + const insights = html.match(/]*href="\/insights"[^>]*>/); + expect(coach?.[0]).toMatch(/aria-current="page"/); + expect(insights?.[0]).not.toMatch(/aria-current="page"/); + }); +}); + describe(" admin entry mirrors Settings (no sub-item expansion)", () => { // v1.4.16 A1: the maintainer reported the global sidebar expanding admin // sub-items on `/admin/*` was unwanted UX — the in-shell `` diff --git a/src/components/layout/bottom-nav.tsx b/src/components/layout/bottom-nav.tsx index 3dd18a6e1..cb0e510a3 100644 --- a/src/components/layout/bottom-nav.tsx +++ b/src/components/layout/bottom-nav.tsx @@ -1,26 +1,25 @@ "use client"; import { - Activity, Bell, Bug, - Droplets, - Dumbbell, - FlaskConical, Home, Lightbulb, MoreHorizontal, Pill, Plus, Settings, - Trophy, - Waves, + type LucideIcon, } from "lucide-react"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { useQueryClient } from "@tanstack/react-query"; import { medicationsPrefetchIntentProps } from "@/lib/queries/prefetch-medications"; -import { useState } from "react"; +import { + isNavDestinationActive, + visibleNavDestinations, +} from "@/components/layout/nav-model"; +import { useMemo, useState } from "react"; import { useAppSettings } from "@/components/app-settings-provider"; import { useAuth } from "@/hooks/use-auth"; import { @@ -37,7 +36,7 @@ import { useTranslations } from "@/lib/i18n/context"; interface NavLink { href: string; tKey: string; - icon: typeof Home; + icon: LucideIcon; } // v1.12.x — iOS-parity bottom bar (additive middle-path). @@ -48,12 +47,14 @@ interface NavLink { // the existing measurement / medication / mood quick-entry surfaces // (see ``). // -// PRIMARY_LEFT / PRIMARY_RIGHT are the two anchor pairs that flank the -// center action. The previous strip (Home · Measurements · Mood · -// Medications · Insights) carried Measurements and Mood inline; those -// stay fully reachable — through the center capture picker AND the More -// hub below — so nothing is orphaned. On desktop the sidebar still -// lists every destination. +// v1.17.1 (F-1) — the two always-visible flanking anchors and the "More" +// hub are now BOTH derived from the one shared destination model +// (`nav-model.ts`), the same ordered list the desktop sidebar renders. +// The bar keeps its ergonomic 5-slot shape, but every destination that +// isn't a primary slot falls into the hub in the model's order — so the +// two surfaces tell one story instead of two hand-curated ones that drift. +const PRIMARY_SLOT_HREFS = ["/", "/medications", "/insights"] as const; + const PRIMARY_LEFT: ReadonlyArray = [ { href: "/", tKey: "nav.dashboard", icon: Home }, { href: "/medications", tKey: "nav.medications", icon: Pill }, @@ -63,33 +64,23 @@ const PRIMARY_RIGHT: ReadonlyArray = [ { href: "/insights", tKey: "nav.insights", icon: Lightbulb }, ]; -// The "More" hub — a real hub of the remaining top-level destinations, -// not just an overflow bucket. Measurements and Mood live here (they -// left the always-visible strip when the center capture action took the -// middle slot), alongside Workouts, Achievements, Notifications and -// Settings. Every entry is an existing top-level route. -const MORE_HUB: ReadonlyArray = [ - { href: "/measurements", tKey: "nav.measurements", icon: Activity }, - { href: "/mood", tKey: "nav.mood", icon: Waves }, - { href: "/labs", tKey: "nav.labs", icon: FlaskConical }, - { href: "/insights/workouts", tKey: "nav.workouts", icon: Dumbbell }, - { href: "/achievements", tKey: "nav.achievements", icon: Trophy }, - { href: "/notifications", tKey: "nav.notifications", icon: Bell }, - { href: "/settings/account", tKey: "nav.settings", icon: Settings }, -]; - -// v1.15.0 — the cycle entry, spliced into the More hub only when the -// account's `cycleTrackingEnabled` gate is true. Hidden for everyone else. -const CYCLE_HUB_ITEM: NavLink = { - href: "/cycle", - tKey: "nav.cycle", - icon: Droplets, +// Mobile-only hub conveniences that have no main-list home on desktop +// (the sidebar reaches them through its footer + avatar menu). They sit +// at the tail of the hub, after every shared destination. +const NOTIFICATIONS_HUB_ITEM: NavLink = { + href: "/notifications", + tKey: "nav.notifications", + icon: Bell, +}; +const SETTINGS_HUB_ITEM: NavLink = { + href: "/settings/account", + tKey: "nav.settings", + icon: Settings, }; -// The bug-report entry, spliced into the More hub under the same -// `bugReportEnabled` operator flag that gates the desktop sidebar -// entry. Pre-splice the route was desktop-only — a phone user had no -// path to `/bugreport` at all. +// The bug-report entry, appended under the same `bugReportEnabled` +// operator flag that gates the desktop sidebar entry. Pre-splice the +// route was desktop-only — a phone user had no path to `/bugreport`. const BUGREPORT_HUB_ITEM: NavLink = { href: "/bugreport", tKey: "nav.bugreport", @@ -108,23 +99,22 @@ export function BottomNav() { const [moreOpen, setMoreOpen] = useState(false); const [captureOpen, setCaptureOpen] = useState(false); - // v1.15.0 — splice the cycle entry into the More hub after Mood when the - // account's gate is on, so it never appears for accounts without it. - const cycleAwareHub: ReadonlyArray = user?.cycleTrackingEnabled - ? [MORE_HUB[0], MORE_HUB[1], CYCLE_HUB_ITEM, ...MORE_HUB.slice(2)] - : MORE_HUB; - // Bug report sits directly before Settings (the hub's last entry) - // when the operator flag is on — same gate as the desktop sidebar. - const moreHub: ReadonlyArray = bugReportEnabled - ? [ - ...cycleAwareHub.slice(0, -1), - BUGREPORT_HUB_ITEM, - cycleAwareHub[cycleAwareHub.length - 1], - ] - : cycleAwareHub; + // v1.17.1 (F-1) — the More hub is every shared destination that isn't an + // always-visible primary slot, in the model's order, plus the mobile-only + // Notifications / Settings conveniences (and Bug Report behind its flag). + // Cycle is filtered by the same account gate the sidebar uses, so the two + // surfaces gate it identically. + const moreHub = useMemo>(() => { + const shared = visibleNavDestinations(user?.cycleTrackingEnabled) + .filter((d) => !PRIMARY_SLOT_HREFS.includes(d.href as never)) + .map((d) => ({ href: d.href, tKey: d.tKey, icon: d.icon })); + const tail: NavLink[] = [NOTIFICATIONS_HUB_ITEM, SETTINGS_HUB_ITEM]; + if (bugReportEnabled) tail.unshift(BUGREPORT_HUB_ITEM); + return [...shared, ...tail]; + }, [user?.cycleTrackingEnabled, bugReportEnabled]); function isActiveLink(href: string) { - return href === "/" ? pathname === "/" : pathname.startsWith(href); + return isNavDestinationActive(href, pathname); } // The "More" entry is treated as active when any of its children diff --git a/src/components/layout/nav-model.ts b/src/components/layout/nav-model.ts new file mode 100644 index 000000000..75ff2b4d4 --- /dev/null +++ b/src/components/layout/nav-model.ts @@ -0,0 +1,144 @@ +import { + Activity, + Droplets, + Dumbbell, + FlaskConical, + Home, + Lightbulb, + MessagesSquare, + Pill, + Trophy, + Waves, + type LucideIcon, +} from "lucide-react"; + +/** + * v1.17.1 — the single navigation information-model. + * + * Before this module the desktop sidebar and the mobile bottom-nav each + * hand-curated their own destination list, and the two had drifted: the + * sidebar listed Measurements / Mood inline but hid Workouts; the bottom + * bar buried Measurements / Mood in "More" but promoted Workouts — and + * the Coach, the stated differentiator, had no nav home on either. A user + * who learned the product on one platform re-learned it on the other. + * + * This list is the ONE ordered destination model. Both bars render it: + * the sidebar shows every entry in order; the bottom bar keeps its 5-slot + * ergonomic shape (Home · Meds · capture · Insights · More) and derives + * its "More" hub from the SAME list (every destination not already a + * primary slot), so the two bars are re-skins of one story rather than + * two curated lists that drift. + */ +export interface NavDestination { + href: string; + /** i18n key under the `nav.*` namespace. */ + tKey: string; + icon: LucideIcon; + /** + * Stable onboarding-tour anchor. Matches `data-tour-id` lookups in the + * spotlight tour — renaming silently breaks the cutout for that step. + */ + tourId?: string; + /** Gate the entry on the account's `cycleTrackingEnabled` flag. */ + requiresCycle?: boolean; +} + +/** + * The canonical ordered destination list. Cycle sits where it always has + * (after Medications) and is filtered out when the account gate is off; + * the order is otherwise identical for both surfaces. + */ +export const NAV_DESTINATIONS: ReadonlyArray = [ + { href: "/", tKey: "nav.dashboard", icon: Home, tourId: "nav-dashboard" }, + { + href: "/measurements", + tKey: "nav.measurements", + icon: Activity, + tourId: "nav-measurements", + }, + { href: "/mood", tKey: "nav.mood", icon: Waves, tourId: "nav-mood" }, + { + href: "/medications", + tKey: "nav.medications", + icon: Pill, + tourId: "nav-medications", + }, + { + href: "/cycle", + tKey: "nav.cycle", + icon: Droplets, + tourId: "nav-cycle", + requiresCycle: true, + }, + { + href: "/labs", + tKey: "nav.labs", + icon: FlaskConical, + tourId: "nav-labs", + }, + { + href: "/insights/workouts", + tKey: "nav.workouts", + icon: Dumbbell, + tourId: "nav-workouts", + }, + { + href: "/insights", + tKey: "nav.insights", + icon: Lightbulb, + tourId: "nav-insights", + }, + // v1.17.1 (F-3) — the Coach finally gets a single labeled nav home. It + // was reachable from seven scattered entry points (FAB, hero CTA, empty + // states, per-metric icons …) but nowhere in the nav, so a new user + // could miss the differentiator entirely. The other entry points stay. + { + href: "/insights/coach", + tKey: "nav.coach", + icon: MessagesSquare, + tourId: "nav-coach", + }, + { + href: "/achievements", + tKey: "nav.achievements", + icon: Trophy, + tourId: "nav-achievements", + }, +]; + +/** + * The ordered destinations visible to this account — drops cycle-gated + * entries when the flag is off. Both bars start from this. + */ +export function visibleNavDestinations( + cycleTrackingEnabled: boolean | undefined, +): NavDestination[] { + return NAV_DESTINATIONS.filter( + (d) => !d.requiresCycle || cycleTrackingEnabled === true, + ); +} + +/** + * Whether `href` is the active nav destination for the current `pathname`, + * resolved against the full destination set so the most-specific entry + * wins. Without this, a plain `startsWith("/insights")` would light up + * Insights while the user is on its siblings `/insights/workouts` or + * `/insights/coach` — both of which are now their own top-level nav homes. + * The dashboard (`/`) only matches an exact path. + */ +export function isNavDestinationActive( + href: string, + pathname: string, + destinations: ReadonlyArray = NAV_DESTINATIONS, +): boolean { + if (href === "/") return pathname === "/"; + const matches = (candidate: string) => + pathname === candidate || pathname.startsWith(`${candidate}/`); + if (!matches(href)) return false; + // A longer sibling that also matches is the more specific home — defer + // to it (e.g. on `/insights/coach`, `/insights` must NOT read active). + const moreSpecific = destinations.some( + (d) => d.href !== href && d.href.startsWith(`${href}/`) && matches(d.href), + ); + return !moreSpecific; +} diff --git a/src/components/layout/sidebar-nav.tsx b/src/components/layout/sidebar-nav.tsx index 18ba6e779..c3f1d3bc0 100644 --- a/src/components/layout/sidebar-nav.tsx +++ b/src/components/layout/sidebar-nav.tsx @@ -2,30 +2,26 @@ import { useMemo, useState } from "react"; import { - Activity, Bell, Bug, ChevronsLeft, ChevronsRight, - Droplets, - FlaskConical, - Home, - Lightbulb, LogOut, Monitor, Moon, MoreVertical, - Pill, Settings, Shield, Sun, - Trophy, - Waves, } from "lucide-react"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { useQueryClient } from "@tanstack/react-query"; import { medicationsPrefetchIntentProps } from "@/lib/queries/prefetch-medications"; +import { + isNavDestinationActive, + visibleNavDestinations, +} from "@/components/layout/nav-model"; import { cn } from "@/lib/utils"; import { Logo } from "@/components/ui/logo"; import { useAuth, useLogout } from "@/hooks/use-auth"; @@ -52,51 +48,6 @@ import { const STORAGE_KEY = "healthlog-sidebar-collapsed"; -const navItems = [ - { href: "/", tKey: "nav.dashboard", icon: Home, tourId: "nav-dashboard" }, - { - href: "/measurements", - tKey: "nav.measurements", - icon: Activity, - tourId: "nav-measurements", - }, - { href: "/mood", tKey: "nav.mood", icon: Waves, tourId: "nav-mood" }, - { - href: "/medications", - tKey: "nav.medications", - icon: Pill, - tourId: "nav-medications", - }, - // v1.17.1 — structured lab-result store. Pairs with the Vorsorge - // annual-blood-panel reminder (which records its result here). - { href: "/labs", tKey: "nav.labs", icon: FlaskConical, tourId: "nav-labs" }, - // v1.4.15 Phase B5: `tourId` values match `data-tour-id` lookups - // performed by the onboarding tour. Keep these stable — renaming - // them silently breaks the spotlight cutout for that step. - { - href: "/insights", - tKey: "nav.insights", - icon: Lightbulb, - tourId: "nav-insights", - }, - { - href: "/achievements", - tKey: "nav.achievements", - icon: Trophy, - tourId: "nav-achievements", - }, -]; - -// v1.15.0 — the cycle nav entry, appended to the main list only when the -// account's `cycleTrackingEnabled` gate is true (resolved on `/api/auth/me`). -// Hidden by construction for accounts without the feature. -const cycleNavItem = { - href: "/cycle", - tKey: "nav.cycle", - icon: Droplets, - tourId: "nav-cycle", -} as const; - function getInitials(name: string): string { return name .split(/[\s._-]+/) @@ -287,21 +238,13 @@ export function SidebarNav() { // with no sub-item expansion in the global sidebar — `` // renders its own per-section nav inside the page itself. const onAdminPage = pathname === "/admin" || pathname.startsWith("/admin/"); - // v1.15.5 — surface the cycle entry directly after Medications (before - // Insights) when the gate resolves true, instead of tacking it on at the - // end. Splice by the Medications index so the position survives a reorder - // of the static list; fall back to append if the anchor ever moves. - const visibleNavItems = useMemo(() => { - if (!user?.cycleTrackingEnabled) return navItems; - const afterMedications = - navItems.findIndex((item) => item.href === "/medications") + 1; - if (afterMedications <= 0) return [...navItems, cycleNavItem]; - return [ - ...navItems.slice(0, afterMedications), - cycleNavItem, - ...navItems.slice(afterMedications), - ]; - }, [user?.cycleTrackingEnabled]); + // v1.17.1 (F-1) — the sidebar renders the one shared destination model + // (`nav-model.ts`), the same ordered list the mobile bottom-nav derives + // its "More" hub from. Cycle is filtered in/out by the account gate. + const visibleNavItems = useMemo( + () => visibleNavDestinations(user?.cycleTrackingEnabled), + [user?.cycleTrackingEnabled], + ); const [collapsed, setCollapsed] = useState(() => { if (typeof window === "undefined") return false; try { @@ -404,10 +347,11 @@ export function SidebarNav() { )}

{visibleNavItems.map((item) => { - const isActive = - item.href === "/" - ? pathname === "/" - : pathname.startsWith(item.href); + const isActive = isNavDestinationActive( + item.href, + pathname, + visibleNavItems, + ); const label = t(item.tKey); if (collapsed) { diff --git a/src/components/settings/__tests__/layout-section.test.tsx b/src/components/settings/__tests__/layout-section.test.tsx new file mode 100644 index 000000000..422fe8fcd --- /dev/null +++ b/src/components/settings/__tests__/layout-section.test.tsx @@ -0,0 +1,48 @@ +/** + * v1.17.1 (F-2) — the "Layout & Personalization" hub. + * + * The hub is the single front door for the four personalization editors. + * It must link to each of them with consistent framing so the concept + * reads as one home rather than four scattered settings sections. + */ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { LayoutSection } from "../layout-section"; + +function render(locale: "en" | "de" = "en") { + return renderToStaticMarkup( + + + , + ); +} + +describe("", () => { + it("links to all four personalization editors", () => { + const html = render(); + for (const href of [ + "/settings/dashboard", + "/settings/insights", + "/settings/medications", + "/settings/mood", + ]) { + expect(html).toContain(`href="${href}"`); + } + }); + + it("renders the hub heading and a card per editor", () => { + const html = render(); + expect(html).toContain("Dashboard"); + expect(html).toContain("Insights"); + expect(html).toContain("Medications"); + expect(html).toContain("Mood tags"); + }); + + it("resolves its copy in German too", () => { + const html = render("de"); + expect(html).toContain("Medikamente"); + expect(html).toContain("Stimmungs-Tags"); + }); +}); diff --git a/src/components/settings/__tests__/settings-shell.test.tsx b/src/components/settings/__tests__/settings-shell.test.tsx index c613b9692..361c4a524 100644 --- a/src/components/settings/__tests__/settings-shell.test.tsx +++ b/src/components/settings/__tests__/settings-shell.test.tsx @@ -56,10 +56,16 @@ describe("SETTINGS_SECTION_SLUGS", () => { // v1.17 — `mood` sits between `medications` and `thresholds`: the // mood-tag management surface (groups, custom tags, hide/archive, // picker order), reached from the /mood page header's wrench glyph. + // v1.17.1 (F-2) — `layout` is the one "Layout & Personalization" home + // and sits after `notifications`. The four personalization editor + // slugs (`dashboard`, `insights`, `medications`, `mood`) keep their + // routes so deep links resolve, but they are reached through the + // Layout hub instead of four standalone nav entries. expect([...SETTINGS_SECTION_SLUGS]).toEqual([ "account", "integrations", "notifications", + "layout", "dashboard", "insights", "medications", @@ -140,6 +146,32 @@ describe("", () => { expect(html.match(activeLinkRegex)?.length ?? 0).toBe(2); }); + it("highlights the Layout hub when on a Layout child editor (v1.17.1 F-2)", () => { + // On `/settings/dashboard` (a Layout child reached through the hub) the + // Layout nav entry must read active even though Dashboard has no nav + // entry of its own. Both layouts emit the active link → two matches. + for (const child of [ + "/settings/dashboard", + "/settings/insights", + "/settings/medications", + "/settings/mood", + ]) { + const html = renderShell({ pathname: child }); + const layoutActive = + /]*\baria-current="page"[^>]*\bhref="\/settings\/layout"|]*\bhref="\/settings\/layout"[^>]*\baria-current="page"/g; + expect(html.match(layoutActive)?.length ?? 0).toBe(2); + } + }); + + it("highlights the Layout hub when the active prop is a Layout child", () => { + // The page passes `active={section}` explicitly; a child slug must + // still resolve onto the Layout hub for nav highlighting. + const html = renderShell({ active: "mood", pathname: "/settings/mood" }); + const layoutActive = + /]*\baria-current="page"[^>]*\bhref="\/settings\/layout"|]*\bhref="\/settings\/layout"[^>]*\baria-current="page"/g; + expect(html.match(layoutActive)?.length ?? 0).toBe(2); + }); + it("falls back to `account` when the pathname doesn't match a known slug", () => { const html = renderShell({ pathname: "/settings/totally-bogus" }); const activeLinkRegex = @@ -155,16 +187,14 @@ describe("", () => { // (single-line; the longer "Notification channels" wrapped). The // `/notifications` inbox now shares the "Notifications" label. expect(html).toContain("Notifications"); - expect(html).toContain("Dashboard"); - expect(html).toContain("Insights"); - // v1.16.10 — the /medications customise surface is its own nav entry - // between Insights and Targets. - expect(html).toContain('href="/settings/medications"'); - expect(html).toContain("Medications"); - // v1.17 — the mood-tag management surface sits between Medications - // and Targets. - expect(html).toContain('href="/settings/mood"'); - expect(html).toContain("Mood tags"); + // v1.17.1 (F-2) — the four personalization editors (Dashboard, + // Insights, Medications, Mood) are reached through one Layout hub + // entry; the hub itself is the single nav entry for the concept. + expect(html).toContain('href="/settings/layout"'); + expect(html).toContain("Layout & Personalization"); + // The four editors are no longer standalone nav entries. + expect(html).not.toContain('href="/settings/medications"'); + expect(html).not.toContain('href="/settings/mood"'); // The ampersand is HTML-escaped by React SSR — assert on the encoded // form so we don't accidentally match a parser that double-escapes. expect(html).toContain("API & Tokens"); @@ -192,17 +222,13 @@ describe("", () => { // compound "Benachrichtigungs-Kanäle" wrapped). The `/notifications` // inbox now shares the "Benachrichtigungen" label. expect(html).toContain("Benachrichtigungen"); - // v1.4.3: the Settings sub-section formerly labelled "Übersicht" is now - // "Dashboard" (matching the term users see in the main nav). The - // per-metric overrides moved out into their own "Persönliche Zielwerte" - // section, which is the new entry below the Dashboard one. - expect(html).toContain("Dashboard"); - // v1.16.10 — the /medications customise surface ("Medikamente"). - expect(html).toContain('href="/settings/medications"'); - expect(html).toContain("Medikamente"); - // v1.17 — the mood-tag management surface ("Stimmungs-Tags"). - expect(html).toContain('href="/settings/mood"'); - expect(html).toContain("Stimmungs-Tags"); + // v1.17.1 (F-2) — the four personalization editors are reached through + // one Layout hub entry ("Layout & Personalisierung"); they are no + // longer standalone German nav entries. + expect(html).toContain('href="/settings/layout"'); + expect(html).toContain("Layout & Personalisierung"); + expect(html).not.toContain('href="/settings/medications"'); + expect(html).not.toContain('href="/settings/mood"'); // v1.8.7.1 — Targets and Sources are two separate German nav // entries again: "Zielwerte" and "Quellen". expect(html).toContain("Zielwerte"); diff --git a/src/components/settings/layout-section.tsx b/src/components/settings/layout-section.tsx new file mode 100644 index 000000000..c9ed1814a --- /dev/null +++ b/src/components/settings/layout-section.tsx @@ -0,0 +1,110 @@ +"use client"; + +import Link from "next/link"; +import { + ChevronRight, + LayoutDashboard, + Pill, + Smile, + TrendingUp, + type LucideIcon, +} from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { useTranslations } from "@/lib/i18n/context"; + +/** + * v1.17.1 (F-2) — the one "Layout & Personalization" home. + * + * Before this section, "how my app is laid out" was scattered across four + * unrelated settings sections — Dashboard, Insights, Medications, Mood — + * with no shared framing, so the same concept (arrange / reorder / show- + * hide) read as four disconnected places. This section is the single + * front door: it presents the four personalization surfaces as one + * consistent set of cards and links to each editor, which all keep their + * own routes and mutation flows intact. One concept, one home. + */ +interface LayoutLink { + href: string; + icon: LucideIcon; + titleKey: string; + descriptionKey: string; +} + +const LAYOUT_LINKS: ReadonlyArray = [ + { + href: "/settings/dashboard", + icon: LayoutDashboard, + titleKey: "settings.sections.layout.dashboard.title", + descriptionKey: "settings.sections.layout.dashboard.description", + }, + { + href: "/settings/insights", + icon: TrendingUp, + titleKey: "settings.sections.layout.insights.title", + descriptionKey: "settings.sections.layout.insights.description", + }, + { + href: "/settings/medications", + icon: Pill, + titleKey: "settings.sections.layout.medications.title", + descriptionKey: "settings.sections.layout.medications.description", + }, + { + href: "/settings/mood", + icon: Smile, + titleKey: "settings.sections.layout.mood.title", + descriptionKey: "settings.sections.layout.mood.description", + }, +]; + +export function LayoutSection() { + const { t } = useTranslations(); + + return ( +
+
+

+ {t("settings.sections.layout.title")} +

+

+ {t("settings.sections.layout.description")} +

+
+ +
    + {LAYOUT_LINKS.map((link) => { + const Icon = link.icon; + return ( +
  • + +
  • + ); + })} +
+
+ ); +} diff --git a/src/components/settings/section-placeholder.tsx b/src/components/settings/section-placeholder.tsx index 241654206..554c26cee 100644 --- a/src/components/settings/section-placeholder.tsx +++ b/src/components/settings/section-placeholder.tsx @@ -37,6 +37,7 @@ const SLUG_ICON: Record = { account: User, integrations: Link2, notifications: Bell, + layout: LayoutDashboard, dashboard: LayoutDashboard, insights: TrendingUp, medications: Pill, diff --git a/src/components/settings/section-slugs.ts b/src/components/settings/section-slugs.ts index 28b812cbf..fd886ef78 100644 --- a/src/components/settings/section-slugs.ts +++ b/src/components/settings/section-slugs.ts @@ -20,10 +20,18 @@ // v1.17 — `mood` sits after `medications`: the mood-tag management // surface (groups, custom tags, hide/archive, picker order), reached // from the /mood page header's wrench glyph. +// v1.17.1 (F-2) — `layout` is the one "Layout & Personalization" home. +// The dashboard / insights / medications / mood personalization editors +// each keep their own route (deep links, page-header cogs, and the hub's +// own links all resolve), but the settings NAV now surfaces a single +// `layout` entry instead of four scattered "arrange" entries, so the +// concept reads as one place. The four editor slugs stay in this list so +// their routes still resolve; they are simply not listed in the shell nav. export const SETTINGS_SECTION_SLUGS = [ "account", "integrations", "notifications", + "layout", "dashboard", "insights", "medications", diff --git a/src/components/settings/settings-shell.tsx b/src/components/settings/settings-shell.tsx index af5532ac2..530c48963 100644 --- a/src/components/settings/settings-shell.tsx +++ b/src/components/settings/settings-shell.tsx @@ -25,12 +25,9 @@ import { Layers, LayoutDashboard, Link2, - Pill, Settings2, SlidersHorizontal, - Smile, Sparkles, - TrendingUp, User, type LucideIcon, } from "lucide-react"; @@ -92,26 +89,16 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [ titleKey: "settings.sections.notifications.title", icon: Bell, }, + // v1.17.1 (F-2) — one "Layout & Personalization" nav entry replaces the + // four scattered Dashboard / Insights / Medications / Mood "arrange" + // entries. Those routes still resolve (deep links, page-header cogs, and + // the hub's own links all work); they are simply reached through the + // Layout hub now, so "how my app is laid out" reads as one home. { - slug: "dashboard", - titleKey: "settings.sections.dashboard.title", + slug: "layout", + titleKey: "settings.sections.layout.title", icon: LayoutDashboard, }, - { - slug: "insights", - titleKey: "settings.sections.insights.title", - icon: TrendingUp, - }, - { - slug: "medications", - titleKey: "settings.sections.medications.title", - icon: Pill, - }, - { - slug: "mood", - titleKey: "settings.sections.mood.title", - icon: Smile, - }, { slug: "thresholds", titleKey: "settings.sections.thresholds.title", @@ -151,16 +138,33 @@ export interface SettingsShellProps { children: React.ReactNode; } +// v1.17.1 (F-2) — the four personalization editors live under the Layout +// hub. They keep their own routes but are NOT standalone nav entries, so +// when the user is on one of them the Layout nav entry is the one that +// reads active. +const LAYOUT_CHILD_SLUGS: ReadonlySet = new Set([ + "dashboard", + "insights", + "medications", + "mood", +]); + +/** Map a Layout child editor onto the Layout hub for nav highlighting. */ +function navHighlightSlug(slug: SettingsSectionSlug): SettingsSectionSlug { + return LAYOUT_CHILD_SLUGS.has(slug) ? "layout" : slug; +} + function deriveActiveSlug( pathname: string | null, override?: SettingsSectionSlug, ): SettingsSectionSlug { - if (override) return override; + if (override) return navHighlightSlug(override); if (!pathname) return "account"; // Match `/settings/` and ignore any trailing segments (none today, // but cheap insurance for future nested routes). const match = pathname.match(/^\/settings\/([^/]+)/); const candidate = match?.[1] ?? ""; + if (LAYOUT_CHILD_SLUGS.has(candidate)) return "layout"; return isSettingsSectionSlug(candidate) ? candidate : "account"; } diff --git a/src/components/ui/__tests__/learning-gate.test.tsx b/src/components/ui/__tests__/learning-gate.test.tsx new file mode 100644 index 000000000..7a043e438 --- /dev/null +++ b/src/components/ui/__tests__/learning-gate.test.tsx @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { LearningGate } from "../learning-gate"; + +describe("", () => { + it("renders the message in a polite live region marked as learning", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain("Still learning your sleep."); + expect(html).toContain('role="status"'); + expect(html).toContain('aria-live="polite"'); + expect(html).toContain('data-state="learning"'); + expect(html).toContain('data-slot="learning-gate"'); + }); + + it("renders an optional caveat as a secondary line", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain("About 5 more days needed."); + expect(html).toContain('data-slot="learning-gate-caveat"'); + }); + + it("omits the caveat line when none is given", () => { + const html = renderToStaticMarkup(); + expect(html).not.toContain("learning-gate-caveat"); + }); + + it("supports a per-surface body slot override for existing selectors", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain('data-slot="score-anatomy-insufficient"'); + }); + + it("bordered variant adds the dashed standalone surface", () => { + const inline = renderToStaticMarkup(); + const bordered = renderToStaticMarkup( + , + ); + expect(inline).not.toContain("border-dashed"); + expect(bordered).toContain("border-dashed"); + }); +}); diff --git a/src/components/ui/learning-gate.tsx b/src/components/ui/learning-gate.tsx new file mode 100644 index 000000000..7befc3245 --- /dev/null +++ b/src/components/ui/learning-gate.tsx @@ -0,0 +1,92 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +/** + * v1.17.1 — the one "we're still learning your X" gate. + * + * Before this primitive, glucose, sleep-debt, chronotype, the trajectory + * forecast, and the composite-score anatomy each hand-rolled the warm + * "not enough data yet" paragraph — five copies with different markers, + * different `data-slot` values, and no shared a11y contract. The calm + * voice held only because each author copied the tone by hand, which is + * exactly the drift that bites the next metric author. + * + * `LearningGate` owns the *presentation and behaviour* of a learning + * state — the muted body paragraph, an optional secondary caveat line, + * the `data-state="learning"` marker, and the polite live region — while + * the caller passes the metric-specific localized copy and keeps its own + * card title / icon chrome. One look, one behaviour, copy still per-metric. + * + * Variants: + * - `"inline"` (default): a bare muted paragraph for use INSIDE a card + * that already has its own header (glucose / sleep / trajectory). + * - `"bordered"`: a dashed-border centered note for STANDALONE use where + * there is no surrounding card chrome (score anatomy). + */ +export interface LearningGateProps + extends Omit, "title"> { + /** Localized primary "still learning" sentence. */ + message: React.ReactNode; + /** Optional secondary line — a caveat or a "N more days" nudge. */ + caveat?: React.ReactNode; + variant?: "inline" | "bordered"; + /** Compact density for tiles / narrow rails. */ + compact?: boolean; + /** + * Optional override for the body `data-slot`, so existing per-surface + * test selectors (e.g. `glucose-learning-body`, + * `trajectory-insufficient`, `score-anatomy-insufficient`) keep + * resolving after the repoint. + */ + bodySlot?: string; +} + +export function LearningGate({ + message, + caveat, + variant = "inline", + compact = false, + bodySlot, + className, + ...props +}: LearningGateProps) { + const body = ( +

+ {message} +

+ ); + + return ( +
+ {body} + {caveat ? ( +

+ {caveat} +

+ ) : null} +
+ ); +} diff --git a/src/lib/analytics/__tests__/mood-series.test.ts b/src/lib/analytics/__tests__/mood-series.test.ts new file mode 100644 index 000000000..07ec24220 --- /dev/null +++ b/src/lib/analytics/__tests__/mood-series.test.ts @@ -0,0 +1,94 @@ +/** + * v1.17.1 — the single mood engine `buildMoodDailySeries`. + * + * Pins the one-engine contract: the dashboard snapshot (`buildMoodBlock`) + * and the `/api/mood/analytics` route both read through this function, so + * the same number must read identically on dashboard + insights. Covers + * the rollup fast-path and the legacy live fallback, and asserts the live + * fallback collapses multi-entry days into the daily mean exactly once. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + moodEntry: { findMany: vi.fn() }, + moodEntryRollup: { findMany: vi.fn() }, + }, +})); + +vi.mock("@/lib/rollups/mood-rollups", async () => { + const actual = await vi.importActual< + typeof import("@/lib/rollups/mood-rollups") + >("@/lib/rollups/mood-rollups"); + return { + ...actual, + ensureUserMoodRollupsFresh: vi + .fn() + .mockResolvedValue({ recomputed: false }), + readMoodDayRollups: vi.fn(), + }; +}); + +import { buildMoodDailySeries } from "../mood-series"; +import { prisma } from "@/lib/db"; +import { readMoodDayRollups } from "@/lib/rollups/mood-rollups"; + +beforeEach(() => { + vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); + vi.mocked(readMoodDayRollups).mockResolvedValue([] as never); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("buildMoodDailySeries", () => { + it("reads the rollup tier and skips the raw walk when DAY rows exist", async () => { + vi.mocked(readMoodDayRollups).mockResolvedValue([ + { bucketStart: new Date("2026-06-01T00:00:00Z"), mean: 4, count: 2 }, + { bucketStart: new Date("2026-06-02T00:00:00Z"), mean: 3, count: 1 }, + ] as never); + + const series = await buildMoodDailySeries("user-1"); + + expect(series.source).toBe("rollup"); + expect(prisma.moodEntry.findMany).not.toHaveBeenCalled(); + expect(series.entries).toEqual([ + { date: "2026-06-01", score: 4, samples: 2 }, + { date: "2026-06-02", score: 3, samples: 1 }, + ]); + // entryCount sums the per-day samples (2 + 1). + expect(series.entryCount).toBe(3); + expect(series.summary.count).toBe(2); + }); + + it("falls back to the live walk once and collapses multi-entry days to the daily mean", async () => { + vi.mocked(readMoodDayRollups).mockResolvedValue([] as never); + vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([ + { date: "2026-06-01", score: 5 }, + { date: "2026-06-01", score: 3 }, + { date: "2026-06-02", score: 4 }, + ] as never); + + const series = await buildMoodDailySeries("user-1"); + + expect(series.source).toBe("live"); + // Two distinct calendar days; day one is the mean of its two entries. + expect(series.entries).toEqual([ + { date: "2026-06-01", score: 4, samples: 2 }, + { date: "2026-06-02", score: 4, samples: 1 }, + ]); + expect(series.entryCount).toBe(3); + // summarize sees one DataPoint per day, not per raw entry. + expect(series.summary.count).toBe(2); + }); + + it("returns an empty series for a user with no rollups and no raw entries", async () => { + const series = await buildMoodDailySeries("user-1"); + expect(series.entries).toEqual([]); + expect(series.entryCount).toBe(0); + // An empty table reads as the rollup tier for annotation parity. + expect(series.source).toBe("rollup"); + expect(series.summary.count).toBe(0); + }); +}); diff --git a/src/lib/analytics/mood-series.ts b/src/lib/analytics/mood-series.ts new file mode 100644 index 000000000..6076602f4 --- /dev/null +++ b/src/lib/analytics/mood-series.ts @@ -0,0 +1,149 @@ +/** + * v1.17.1 — single mood daily-series engine. + * + * Before this module the dashboard snapshot (`buildMoodBlock` in + * `src/lib/dashboard/snapshot.ts`) and the `/api/mood/analytics` route + * (`buildMoodAnalyticsResponse`) each carried a hand-copied rollup read + + * coverage fallback + `summarize()` of the same numbers. Two engines for + * one metric is the classic seam where the dashboard and the insights + * surface can read mood differently. This module is the one canonical + * read; both callers delegate to it so the same number reads identically + * on the dashboard, the insights mood sparkline, and the iOS client. + */ +import type { PrismaClient } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; +import { summarize, type DataPoint } from "@/lib/analytics/trends"; +import { + ensureUserMoodRollupsFresh, + readMoodDayRollups, +} from "@/lib/rollups/mood-rollups"; + +/** + * Five-year window for the rollup-tier read. The rollup tier stores one + * row per day, so a five-year cap is cheap (at most ~1 800 rows) and + * still covers every user the product has historic data for. The legacy + * route + snapshot both used this same window. + */ +export const MOOD_SERIES_WINDOW_MS = 5 * 365 * 24 * 60 * 60 * 1000; + +/** One daily mood point: the day's average score + how many entries fed it. */ +export interface MoodDailyEntry { + date: string; + score: number; + samples: number; +} + +export interface MoodDailySeries { + entries: MoodDailyEntry[]; + summary: ReturnType; + /** Total raw mood entries behind the series (sum of `samples`). */ + entryCount: number; + /** Which tier answered — for wide-event annotation by the caller. */ + source: "rollup" | "live"; +} + +/** + * Format a UTC `Date` as a YYYY-MM-DD label. The rollup tier anchors on + * UTC midnight (same convention as the measurement rollup tier). For + * tenants within ±3 h of UTC (Berlin year-round) the label agrees with + * the local wall-clock day on every entry whose timestamp doesn't + * straddle the UTC boundary — i.e. every realistic mood log. v1.5 + * per-user-tz bucketing (audit P7) closes the DST fall-back-night gap. + */ +function utcDayLabel(d: Date): string { + const yyyy = d.getUTCFullYear(); + const mm = String(d.getUTCMonth() + 1).padStart(2, "0"); + const dd = String(d.getUTCDate()).padStart(2, "0"); + return `${yyyy}-${mm}-${dd}`; +} + +/** Aggregate multiple raw mood entries per day into daily averages. */ +function aggregateDailyAverages( + records: Array<{ date: string; score: number }>, +): MoodDailyEntry[] { + const byDay = new Map(); + for (const record of records) { + const current = byDay.get(record.date) ?? { sum: 0, count: 0 }; + current.sum += record.score; + current.count += 1; + byDay.set(record.date, current); + } + return Array.from(byDay.entries()) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([day, stats]) => ({ + date: day, + score: Math.round((stats.sum / stats.count) * 100) / 100, + samples: stats.count, + })); +} + +/** + * Canonical mood daily series for a user. Rollup fast-path first; a single + * live walk only on coverage miss (and the warm-up below mints the rollups + * for the next read). Feeds `summarize()` per-day means in both branches so + * the slope/latest/min/max windows are byte-identical on multi-entry days. + * + * `client` lets the snapshot builder pass its own Prisma handle; defaults to + * the shared singleton for the route caller. + */ +export async function buildMoodDailySeries( + userId: string, + client: PrismaClient = prisma, +): Promise { + // Fire-and-forget warm-up so the next cold mount for this user lands on + // the rollup tier even when the boot-time backfill hasn't reached them. + void ensureUserMoodRollupsFresh(userId); + + const since = new Date(Date.now() - MOOD_SERIES_WINDOW_MS); + const rollups = await readMoodDayRollups(userId, since); + + if (rollups.length > 0) { + const entries = rollups + .map((r) => ({ + date: utcDayLabel(r.bucketStart), + score: Math.round(r.mean * 100) / 100, + samples: r.count, + })) + .sort((a, b) => a.date.localeCompare(b.date)); + const dataPoints: DataPoint[] = rollups.map((r) => ({ + date: r.bucketStart, + value: r.mean, + })); + const summary = summarize(dataPoints); + const entryCount = rollups.reduce((s, r) => s + r.count, 0); + return { entries, summary, entryCount, source: "rollup" }; + } + + // Coverage fallback — no rollup rows yet. Run the legacy live walk ONCE; + // the warm-up above mints the rollups for the next request. + const moodEntries = await client.moodEntry.findMany({ + // v1.7.0 sync — exclude tombstoned rows from the fallback. + where: { userId, deletedAt: null }, + orderBy: { moodLoggedAt: "asc" }, + select: { date: true, score: true }, + }); + + const entries = aggregateDailyAverages( + moodEntries.map((e: { date: string; score: number }) => ({ + date: e.date, + score: e.score, + })), + ); + + // Date anchor is local-noon for the day so the slope x-axis spans + // whole-day units — mirrors the dashboard tile's one-number-per-day. + const dataPoints: DataPoint[] = entries.map((e) => ({ + date: new Date(`${e.date}T12:00:00.000Z`), + value: e.score, + })); + const summary = summarize(dataPoints); + + // An empty table reads as "rollup" (nothing to walk) for annotation + // parity with the historic route; a non-empty live walk reads "live". + return { + entries, + summary, + entryCount: moodEntries.length, + source: moodEntries.length === 0 ? "rollup" : "live", + }; +} diff --git a/src/lib/dashboard/snapshot.ts b/src/lib/dashboard/snapshot.ts index 14e7e3287..ebf86b95d 100644 --- a/src/lib/dashboard/snapshot.ts +++ b/src/lib/dashboard/snapshot.ts @@ -61,10 +61,7 @@ import { probeRollupCoverage, type RollupCoverageMap, } from "@/lib/rollups/measurement-coverage"; -import { - ensureUserMoodRollupsFresh, - readMoodDayRollups, -} from "@/lib/rollups/mood-rollups"; +import { buildMoodDailySeries } from "@/lib/analytics/mood-series"; import { resolveDashboardLayout, DASHBOARD_WIDGET_CATALOGUE_IDS, @@ -85,9 +82,6 @@ import type { HealthScoreBand } from "@/lib/analytics/health-score"; /** Briefing freshness window — mirrors the 24 h TTL on the advisor cache. */ const BRIEFING_TTL_MS = 24 * 60 * 60 * 1000; -/** Five-year mood window — mirrors `/api/mood/analytics`. */ -const MOOD_ROLLUP_WINDOW_MS = 5 * 365 * 24 * 60 * 60 * 1000; - const GLUCOSE_CONTEXTS = [ "FASTING", "POSTPRANDIAL", @@ -339,14 +333,6 @@ export interface SnapshotUserInput { dashboardWidgetsJson: unknown; } -/** Format a UTC `Date` as a YYYY-MM-DD label (matches mood rollup tier). */ -function utcDayLabel(d: Date): string { - const yyyy = d.getUTCFullYear(); - const mm = String(d.getUTCMonth() + 1).padStart(2, "0"); - const dd = String(d.getUTCDate()).padStart(2, "0"); - return `${yyyy}-${mm}-${dd}`; -} - /** Wall-clock hour in `tz`, used for the server-side greeting. */ function hourInTimezone(now: Date, tz: string): number { try { @@ -383,63 +369,20 @@ function enrichLastSeen( return out; } -/** Read-only mood block, lifting the rollup-tier read from `/api/mood/analytics`. */ +/** + * Read-only mood block. v1.17.1 — delegates to the single mood engine + * `buildMoodDailySeries` so the dashboard tile reads the exact numbers + * `/api/mood/analytics` (the insights mood sparkline source) returns. + */ async function buildMoodBlock( - prisma: PrismaClient, + client: PrismaClient, userId: string, ): Promise<{ summary: DataSummary | null; entries: DashboardSnapshotMoodEntry[]; }> { - void ensureUserMoodRollupsFresh(userId); - - const since = new Date(Date.now() - MOOD_ROLLUP_WINDOW_MS); - const rollups = await readMoodDayRollups(userId, since); - - if (rollups.length > 0) { - const entries = rollups - .map((r) => ({ - date: utcDayLabel(r.bucketStart), - score: Math.round(r.mean * 100) / 100, - samples: r.count, - })) - .sort((a, b) => a.date.localeCompare(b.date)); - const dataPoints: DataPoint[] = rollups.map((r) => ({ - date: r.bucketStart, - value: r.mean, - })); - return { summary: summarize(dataPoints), entries }; - } - - // Coverage fallback — mirror the live walk in `/api/mood/analytics`. - const moodEntries = await prisma.moodEntry.findMany({ - // v1.7.0 sync — exclude tombstoned rows from the dashboard snapshot. - where: { userId, deletedAt: null }, - orderBy: { moodLoggedAt: "asc" }, - select: { date: true, score: true }, - }); - if (moodEntries.length === 0) { - return { summary: summarize([]), entries: [] }; - } - const byDay = new Map(); - for (const e of moodEntries) { - const cur = byDay.get(e.date) ?? { sum: 0, count: 0 }; - cur.sum += e.score; - cur.count += 1; - byDay.set(e.date, cur); - } - const entries = Array.from(byDay.entries()) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([day, stats]) => ({ - date: day, - score: Math.round((stats.sum / stats.count) * 100) / 100, - samples: stats.count, - })); - const dataPoints: DataPoint[] = entries.map((e) => ({ - date: new Date(`${e.date}T12:00:00.000Z`), - value: e.score, - })); - return { summary: summarize(dataPoints), entries }; + const series = await buildMoodDailySeries(userId, client); + return { summary: series.summary, entries: series.entries }; } /**