From fd5cc783774d6d2b2ca4784f5985883c92360cf8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 23:53:22 +0200 Subject: [PATCH 1/6] refactor(mood): read the dashboard and analytics mood series from one engine The dashboard snapshot and the /api/mood/analytics route each carried a hand-copied rollup read, coverage fallback, and daily summarize of the same numbers. Two reads of one metric is the seam where the dashboard tile and the insights mood sparkline can drift apart. Extract buildMoodDailySeries as the single mood engine and route both callers through it, so the same mood number reads identically on the dashboard, the insights surface, and the iOS client. --- src/app/api/mood/analytics/route.ts | 168 ++---------------- .../analytics/__tests__/mood-series.test.ts | 94 ++++++++++ src/lib/analytics/mood-series.ts | 149 ++++++++++++++++ src/lib/dashboard/snapshot.ts | 75 +------- 4 files changed, 264 insertions(+), 222 deletions(-) create mode 100644 src/lib/analytics/__tests__/mood-series.test.ts create mode 100644 src/lib/analytics/mood-series.ts 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/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 }; } /** From b1a81bb65e9af1589e62c191d1e492d432081466 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 23:57:15 +0200 Subject: [PATCH 2/6] refactor(insights): unify the learning-state gates behind one primitive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Glucose, sleep debt, chronotype, the trajectory forecast, and the composite-score anatomy each hand-rolled the warm 'still learning your X' paragraph — five copies with different markup, markers, and no shared accessibility contract. The calm voice held only because each author copied the tone by hand. Add a LearningGate primitive that owns the presentation and behaviour of a learning state (the muted body, an optional caveat line, the learning marker, and the polite live region) and repoint all five surfaces to it. Each keeps its own card chrome and its metric-specific copy; only the gate's look and behaviour are now shared, so they can no longer drift. --- .../insights/derived/score-anatomy-view.tsx | 15 +-- .../derived/trajectory-forecast-card.tsx | 32 +++---- .../glucose/glucose-clinical-panel.tsx | 26 +++--- .../insights/sleep/chronotype-card.tsx | 7 +- .../insights/sleep/sleep-debt-card.tsx | 7 +- .../ui/__tests__/learning-gate.test.tsx | 52 +++++++++++ src/components/ui/learning-gate.tsx | 92 +++++++++++++++++++ 7 files changed, 187 insertions(+), 44 deletions(-) create mode 100644 src/components/ui/__tests__/learning-gate.test.tsx create mode 100644 src/components/ui/learning-gate.tsx 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/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} +
+ ); +} From c90459a7bd3c48876afe9279c7eb3de4d985ba0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 15 Jun 2026 00:04:13 +0200 Subject: [PATCH 3/6] refactor(nav): drive desktop and mobile from one destination model The desktop sidebar and the mobile bottom-nav each hand-curated their own destination list, and the two had drifted: the sidebar listed Measurements and Mood inline but hid Workouts; the bottom bar buried Measurements and Mood in More but promoted Workouts. A user who learned the product on one platform re-learned it on the other. Extract one ordered destination model both bars consume. The sidebar renders every entry in order; the bottom bar keeps its 5-slot shape and derives its More hub from the same list, so the two surfaces tell one story. Workouts is now first-class on both. A shared active-resolver prefers the most-specific sibling so a sub-route no longer lights up its parent. --- .../layout/__tests__/nav-model.test.ts | 75 ++++++++++ .../layout/__tests__/sidebar-nav.test.tsx | 22 +++ src/components/layout/bottom-nav.tsx | 98 ++++++------- src/components/layout/nav-model.ts | 133 ++++++++++++++++++ src/components/layout/sidebar-nav.tsx | 88 +++--------- 5 files changed, 290 insertions(+), 126 deletions(-) create mode 100644 src/components/layout/__tests__/nav-model.test.ts create mode 100644 src/components/layout/nav-model.ts 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..b522a43b8 --- /dev/null +++ b/src/components/layout/__tests__/nav-model.test.ts @@ -0,0 +1,75 @@ +/** + * 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 as a first-class destination on both surfaces", () => { + const hrefs = NAV_DESTINATIONS.map((d) => d.href); + expect(hrefs).toContain("/insights/workouts"); + }); + + 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 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..a0db7aa88 100644 --- a/src/components/layout/__tests__/sidebar-nav.test.tsx +++ b/src/components/layout/__tests__/sidebar-nav.test.tsx @@ -131,6 +131,28 @@ describe(" targets deprecation (v1.8.6)", () => { }); }); +describe(" unified destination model (v1.17.1 F-1)", () => { + it("surfaces Workouts as a first-class sidebar destination", () => { + // Pre-unify the sidebar hid Workouts entirely while the mobile bar + // promoted it. Both now render the one shared model, so both carry it. + const html = render(); + expect(html).toContain('href="/insights/workouts"'); + expect(html).toContain("Workouts"); + }); + + it("marks Workouts active without also marking Insights active", () => { + const html = render({ pathname: "/insights/workouts" }); + // The Workouts link carries aria-current="page"; the Insights link, + // its less-specific sibling, must not (most-specific resolution). + const workouts = html.match( + /]*href="\/insights\/workouts"[^>]*>/, + ); + const insights = html.match(/]*href="\/insights"[^>]*>/); + expect(workouts?.[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..da7109d54 --- /dev/null +++ b/src/components/layout/nav-model.ts @@ -0,0 +1,133 @@ +import { + Activity, + Droplets, + Dumbbell, + FlaskConical, + Home, + Lightbulb, + 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", + }, + { + 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) { From 9bedd6b4f8c7bcb782f1d803dc35892d44565ffc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 15 Jun 2026 00:06:28 +0200 Subject: [PATCH 4/6] feat(nav): give the Coach a single primary nav home MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Coach is reachable from seven scattered entry points — a floating button, the hero CTA, suggested-prompt chips, every metric empty state, a per-metric icon, the conversations drawer — but it had no entry in the nav. Power users found it instantly; a new user could miss the differentiator entirely behind a 56px floating circle. Add the Coach to the shared destination model as one labeled, primary home, consistent with every other destination across desktop and mobile. The existing entry points stay; this adds the front door they lacked. --- messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 1 + messages/it.json | 1 + messages/pl.json | 1 + .../layout/__tests__/nav-model.test.ts | 18 ++++++++++++- .../layout/__tests__/sidebar-nav.test.tsx | 25 ++++++++++--------- src/components/layout/nav-model.ts | 11 ++++++++ 9 files changed, 47 insertions(+), 13 deletions(-) diff --git a/messages/de.json b/messages/de.json index a00c1da74..569854ee4 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", diff --git a/messages/en.json b/messages/en.json index 982cde664..adfa22579 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", diff --git a/messages/es.json b/messages/es.json index d8a572cc4..2d284fe6d 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", diff --git a/messages/fr.json b/messages/fr.json index 52f5b9d53..2e480080f 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", diff --git a/messages/it.json b/messages/it.json index 7c50a8d29..b52b754a8 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", diff --git a/messages/pl.json b/messages/pl.json index f96c44bb7..02f4a3914 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", diff --git a/src/components/layout/__tests__/nav-model.test.ts b/src/components/layout/__tests__/nav-model.test.ts index b522a43b8..86484cb44 100644 --- a/src/components/layout/__tests__/nav-model.test.ts +++ b/src/components/layout/__tests__/nav-model.test.ts @@ -16,9 +16,18 @@ import { } from "../nav-model"; describe("nav-model destination list", () => { - it("lists Workouts as a first-class destination on both surfaces", () => { + 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", () => { @@ -57,6 +66,13 @@ describe("isNavDestinationActive most-specific resolution", () => { 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"), diff --git a/src/components/layout/__tests__/sidebar-nav.test.tsx b/src/components/layout/__tests__/sidebar-nav.test.tsx index a0db7aa88..82955553a 100644 --- a/src/components/layout/__tests__/sidebar-nav.test.tsx +++ b/src/components/layout/__tests__/sidebar-nav.test.tsx @@ -131,24 +131,25 @@ describe(" targets deprecation (v1.8.6)", () => { }); }); -describe(" unified destination model (v1.17.1 F-1)", () => { - it("surfaces Workouts as a first-class sidebar destination", () => { - // Pre-unify the sidebar hid Workouts entirely while the mobile bar - // promoted it. Both now render the one shared model, so both carry it. +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 Workouts active without also marking Insights active", () => { - const html = render({ pathname: "/insights/workouts" }); - // The Workouts link carries aria-current="page"; the Insights link, - // its less-specific sibling, must not (most-specific resolution). - const workouts = html.match( - /]*href="\/insights\/workouts"[^>]*>/, - ); + 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(workouts?.[0]).toMatch(/aria-current="page"/); + expect(coach?.[0]).toMatch(/aria-current="page"/); expect(insights?.[0]).not.toMatch(/aria-current="page"/); }); }); diff --git a/src/components/layout/nav-model.ts b/src/components/layout/nav-model.ts index da7109d54..75ff2b4d4 100644 --- a/src/components/layout/nav-model.ts +++ b/src/components/layout/nav-model.ts @@ -5,6 +5,7 @@ import { FlaskConical, Home, Lightbulb, + MessagesSquare, Pill, Trophy, Waves, @@ -87,6 +88,16 @@ export const NAV_DESTINATIONS: ReadonlyArray = [ 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", From cf31211fbcc46cdb73bee23d283d9ee0cc1a8031 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 15 Jun 2026 00:20:16 +0200 Subject: [PATCH 5/6] refactor(settings): gather layout personalization under one home MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Personalizing how the app is laid out was split 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, plus the settings nav carried four separate arrange entries. Add one Layout & Personalization hub that presents the four editors as a consistent set of linked cards and replaces the four scattered nav entries with a single home. Each editor keeps its own route and mutation flow — deep links, the page-header cogs, and the hub's links all still resolve — and a Layout child route now highlights the hub in the nav. --- messages/de.json | 20 ++++ messages/en.json | 20 ++++ messages/es.json | 20 ++++ messages/fr.json | 20 ++++ messages/it.json | 20 ++++ messages/pl.json | 20 ++++ src/app/settings/[section]/page.tsx | 2 + .../__tests__/layout-section.test.tsx | 48 ++++++++ .../__tests__/settings-shell.test.tsx | 68 +++++++---- src/components/settings/layout-section.tsx | 110 ++++++++++++++++++ .../settings/section-placeholder.tsx | 1 + src/components/settings/section-slugs.ts | 8 ++ src/components/settings/settings-shell.tsx | 46 ++++---- 13 files changed, 361 insertions(+), 42 deletions(-) create mode 100644 src/components/settings/__tests__/layout-section.test.tsx create mode 100644 src/components/settings/layout-section.tsx diff --git a/messages/de.json b/messages/de.json index 569854ee4..b49ed0581 100644 --- a/messages/de.json +++ b/messages/de.json @@ -3814,6 +3814,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 adfa22579..8afabdd03 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3814,6 +3814,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 2d284fe6d..9af3b04d0 100644 --- a/messages/es.json +++ b/messages/es.json @@ -3814,6 +3814,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 2e480080f..aa345cfec 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -3814,6 +3814,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 b52b754a8..fc6312184 100644 --- a/messages/it.json +++ b/messages/it.json @@ -3814,6 +3814,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 02f4a3914..1bd18cc61 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -3814,6 +3814,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/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/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"; } From 347143c4b702f4b2a90d1e107ec6e2a117f5a617 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 15 Jun 2026 00:21:42 +0200 Subject: [PATCH 6/6] fix(personal-records): map the new derived metric types to a null PR direction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ANS charge, cardio load, the sleep score, and the body-temperature deviation reached the MeasurementType enum without a branch in getPRDirection, which left the exhaustive switch non-total — typecheck fell through to an implicit undefined return and the drift guard failed. These are derived / homeostatic composites with no goal axis, so they join the other scores and temperature metrics on the null PR direction. --- src/lib/personal-records/pr-direction.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/lib/personal-records/pr-direction.ts b/src/lib/personal-records/pr-direction.ts index 91d2f82fa..517d6b687 100644 --- a/src/lib/personal-records/pr-direction.ts +++ b/src/lib/personal-records/pr-direction.ts @@ -176,6 +176,16 @@ export function getPRDirection( // WALKING_HEART_RATE_AVERAGE case above. Defer to a null PR direction. case "AVERAGE_HEART_RATE": case "MAX_HEART_RATE": + // Derived / homeostatic device composites with no goal axis. ANS + // charge, cardio load, and the sleep score are recomputed daily from + // their own underlying signals (a PR on them would double-count the + // inputs' PRs), and the body-temperature deviation is a baseline- + // relative reading where neither direction is an achievement — they + // stay null like the other derived scores + temperature metrics above. + case "ANS_CHARGE": + case "CARDIO_LOAD": + case "SLEEP_SCORE": + case "BODY_TEMPERATURE_DEVIATION": return null; } }