merge(coherence): unify nav, layout settings, mood source, learning gates, coach nav home

# Conflicts:
#	src/lib/personal-records/pr-direction.ts
This commit is contained in:
Marc-André Bombeck committed 2026-06-15 00:23:54 +02:00
commit d1055b4047
29 files changed
+1136 -434

No files matched your search

+21
View File
@@ -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."
+21
View File
@@ -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."
+21
View File
@@ -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."
+21
View File
@@ -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."
+21
View File
@@ -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."
+21
View File
@@ -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."
+12 -156
View File
@@ -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<string, { sum: number; count: number }>();
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<typeof summarize>;
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<MoodAnalyticsResult> {
// 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<MoodAnalyticsResult>,
caches.moodAnalytics as ServerCache<MoodDailySeries>,
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 });
+2
View File
@@ -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,
@@ -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({
</div>
{insufficient ? (
<p
data-slot="score-anatomy-insufficient"
className="text-muted-foreground rounded-lg border border-dashed px-4 py-6 text-center text-xs"
>
{insufficientNote ??
t("insights.derived.anatomy.insufficient")}
</p>
<LearningGate
variant="bordered"
bodySlot="score-anatomy-insufficient"
message={
insufficientNote ?? t("insights.derived.anatomy.insufficient")
}
/>
) : (
<div data-slot="score-anatomy-contributors" className="space-y-3">
<p className="text-muted-foreground text-[11px] font-semibold tracking-wide uppercase">
@@ -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 (
<div className={className}>
<CardShell provenance={data.provenance} compact={compact}>
<p
className={cn(
"text-muted-foreground",
compact ? "text-xs leading-snug" : "text-sm",
)}
data-slot="trajectory-insufficient"
>
{t("insights.derived.trajectory.insufficient")}
</p>
{daysNeeded > 0 ? (
<p
className="text-muted-foreground text-xs leading-snug"
data-slot="trajectory-days-needed"
>
{t("insights.derived.trajectory.daysNeeded", {
count: daysNeeded,
})}
</p>
) : null}
<LearningGate
compact={compact}
bodySlot="trajectory-insufficient"
message={t("insights.derived.trajectory.insufficient")}
caveat={
daysNeeded > 0
? t("insights.derived.trajectory.daysNeeded", {
count: daysNeeded,
})
: undefined
}
/>
<CoverageMeter coverage={data.coverage} />
</CardShell>
</div>
@@ -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")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-muted-foreground text-sm" data-slot="glucose-learning-body">
{hasAny
? t("insights.bloodGlucose.clinical.learningBody", {
count: readingsLabel,
days: daysLabel,
})
: t("insights.bloodGlucose.clinical.learningBodyEmpty")}
</p>
<p className="text-muted-foreground/80 text-xs">
{t("insights.bloodGlucose.clinical.spotCaveat")}
</p>
<CardContent>
<LearningGate
bodySlot="glucose-learning-body"
message={
hasAny
? t("insights.bloodGlucose.clinical.learningBody", {
count: readingsLabel,
days: daysLabel,
})
: t("insights.bloodGlucose.clinical.learningBodyEmpty")
}
caveat={t("insights.bloodGlucose.clinical.spotCaveat")}
/>
</CardContent>
</Card>
);
@@ -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 }) {
</div>
</CardHeader>
<CardContent>
<p className="text-muted-foreground text-sm">
{t("insights.sleep.chronotype.learning", {
<LearningGate
message={t("insights.sleep.chronotype.learning", {
counted: chronotype.freeNightsCounted,
total: need,
})}
</p>
/>
</CardContent>
</Card>
);
@@ -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 }) {
</div>
</CardHeader>
<CardContent>
<p className="text-muted-foreground text-sm">
{t("insights.sleep.debt.learning", {
<LearningGate
message={t("insights.sleep.debt.learning", {
counted: debt.nightsCounted,
remaining: debt.nightsUntilReady,
})}
</p>
/>
</CardContent>
</Card>
);
@@ -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,
);
});
});
@@ -131,6 +131,29 @@ describe("<SidebarNav> targets deprecation (v1.8.6)", () => {
});
});
describe("<SidebarNav> 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(/<a[^>]*href="\/insights\/coach"[^>]*>/);
const insights = html.match(/<a[^>]*href="\/insights"[^>]*>/);
expect(coach?.[0]).toMatch(/aria-current="page"/);
expect(insights?.[0]).not.toMatch(/aria-current="page"/);
});
});
describe("<SidebarNav> 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 `<AdminShell>`
+44 -54
View File
@@ -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 `<CapturePicker>`).
//
// 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<NavLink> = [
{ href: "/", tKey: "nav.dashboard", icon: Home },
{ href: "/medications", tKey: "nav.medications", icon: Pill },
@@ -63,33 +64,23 @@ const PRIMARY_RIGHT: ReadonlyArray<NavLink> = [
{ 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<NavLink> = [
{ 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<NavLink> = 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<NavLink> = 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<ReadonlyArray<NavLink>>(() => {
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
+144
View File
@@ -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<NavDestination> = [
{ 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<NavDestination> = 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;
}
+16 -72
View File
@@ -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 — `<AdminShell>`
// 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() {
)}
<div className="space-y-1">
{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) {
@@ -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(
<I18nProvider initialLocale={locale}>
<LayoutSection />
</I18nProvider>,
);
}
describe("<LayoutSection>", () => {
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");
});
});
@@ -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("<SettingsShell>", () => {
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 =
/<a\b[^>]*\baria-current="page"[^>]*\bhref="\/settings\/layout"|<a\b[^>]*\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 =
/<a\b[^>]*\baria-current="page"[^>]*\bhref="\/settings\/layout"|<a\b[^>]*\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("<SettingsShell>", () => {
// (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 &amp; 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 &amp; Tokens");
@@ -192,17 +222,13 @@ describe("<SettingsShell>", () => {
// 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 &amp; 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");
+110
View File
@@ -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<LayoutLink> = [
{
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 (
<section
aria-labelledby="settings-section-layout-title"
className="space-y-6"
>
<header className="space-y-1">
<h1 id="settings-section-layout-title" className="sr-only">
{t("settings.sections.layout.title")}
</h1>
<p className="text-muted-foreground text-sm">
{t("settings.sections.layout.description")}
</p>
</header>
<ul className="space-y-3">
{LAYOUT_LINKS.map((link) => {
const Icon = link.icon;
return (
<li key={link.href}>
<Link
href={link.href}
className={cn(
"bg-card border-border hover:bg-accent/40 group flex items-center gap-4 rounded-xl border p-4 transition-colors sm:p-5",
)}
>
<Icon
className="text-muted-foreground h-5 w-5 shrink-0"
aria-hidden="true"
/>
<div className="min-w-0 flex-1 space-y-0.5">
<p className="text-sm font-semibold">{t(link.titleKey)}</p>
<p className="text-muted-foreground text-xs">
{t(link.descriptionKey)}
</p>
</div>
<ChevronRight
className="text-muted-foreground/60 group-hover:text-foreground h-4 w-4 shrink-0 transition-colors"
aria-hidden="true"
/>
</Link>
</li>
);
})}
</ul>
</section>
);
}
@@ -37,6 +37,7 @@ const SLUG_ICON: Record<SettingsSectionSlug, LucideIcon> = {
account: User,
integrations: Link2,
notifications: Bell,
layout: LayoutDashboard,
dashboard: LayoutDashboard,
insights: TrendingUp,
medications: Pill,
+8
View File
@@ -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",
+25 -21
View File
@@ -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<string> = 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/<slug>` 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";
}
@@ -0,0 +1,52 @@
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { LearningGate } from "../learning-gate";
describe("<LearningGate>", () => {
it("renders the message in a polite live region marked as learning", () => {
const html = renderToStaticMarkup(
<LearningGate message="Still learning your sleep." />,
);
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(
<LearningGate
message="Not enough data yet."
caveat="About 5 more days needed."
/>,
);
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(<LearningGate message="Learning." />);
expect(html).not.toContain("learning-gate-caveat");
});
it("supports a per-surface body slot override for existing selectors", () => {
const html = renderToStaticMarkup(
<LearningGate
message="Not enough to score."
bodySlot="score-anatomy-insufficient"
/>,
);
expect(html).toContain('data-slot="score-anatomy-insufficient"');
});
it("bordered variant adds the dashed standalone surface", () => {
const inline = renderToStaticMarkup(<LearningGate message="x" />);
const bordered = renderToStaticMarkup(
<LearningGate message="x" variant="bordered" />,
);
expect(inline).not.toContain("border-dashed");
expect(bordered).toContain("border-dashed");
});
});
+92
View File
@@ -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<React.HTMLAttributes<HTMLDivElement>, "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 = (
<p
data-slot={bodySlot ?? "learning-gate-body"}
className={cn(
"text-muted-foreground",
compact ? "text-xs leading-snug" : "text-sm",
variant === "bordered" && "text-center text-xs",
)}
>
{message}
</p>
);
return (
<div
data-slot="learning-gate"
data-state="learning"
role="status"
aria-live="polite"
className={cn(
"space-y-2",
variant === "bordered" &&
"rounded-lg border border-dashed px-4 py-6",
className,
)}
{...props}
>
{body}
{caveat ? (
<p
data-slot="learning-gate-caveat"
className="text-muted-foreground/80 text-xs leading-snug"
>
{caveat}
</p>
) : null}
</div>
);
}
@@ -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);
});
});
+149
View File
@@ -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<typeof summarize>;
/** 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<string, { sum: number; count: number }>();
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<MoodDailySeries> {
// 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",
};
}
+9 -66
View File
@@ -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<string, { sum: number; count: number }>();
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 };
}
/**