mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
commit
d1055b4047
29 files changed
+1136
-434
No files matched your search
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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>`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 & 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("<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 & 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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user