From 84b54d9f116a7343fdc69f3ded8e292f2602f235 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 14 May 2026 13:07:33 +0200 Subject: [PATCH] refactor(insights): mother page hosts overview only; metric depth moves to sub-pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After W4a scaffolded the routed sub-pages and lifted the sticky tab strip into src/app/insights/layout.tsx, the mother page becomes the overview-only surface it always wanted to be: Hero + DailyBriefing + CorrelationRow + TrendsRow + InsightAdvisorCard + CoachDrawer. What's gone from the page (now lives on the matching sub-page): - Six per-section / mounts (bp/weight/ pulse/mood/meds/bmi) with their target-zone + value-band wiring. - Per-metric status-card queries (blood-pressure-status, weight-status, pulse-status, bmi-status, mood-status, medication-compliance-status). - All the per-section + scatter-correlation cards (BP × continuity, weight × BP, mood × BP, mood × weight). - The bulky helper functions (getBloodPressureSectionStatus, getWeightSectionStatus, …, classifyRangeValue) and their imports (getBpTargets, buildWeightBandsFromHeight, pulse-targets, etc.). - The inline mount — superseded by the strip that now mounts. What stays here: - HeroStrip (greeting, weekly-report banner, Health Score panel). - DailyBriefing (the AI morning paragraph). - CorrelationRow (cross-metric hypotheses). - TrendsRow (BP / weight / mood mini-charts above each annotation). - InsightAdvisorCard (the rich recommendations grid). - CoachDrawer — mother-page-only, per Marc's directive. The two insights-polish guard tests are re-pointed at the new homes of their assertions: the tab-strip refresh affordance now lives on the layout shell (src/components/insights/insights-layout-shell.tsx), and the comparisonBaseline plumbing migrated into the shared useInsightsLayoutPrefs() hook. trends-row.test.tsx wraps every render in a QueryClientProvider — the v1.4.25 W7 timezone change added a useAuth() call inside for chart-side userTimezone plumbing, which needs a TanStack-Query client at mount time. --- .../0044_mood_entry_tz_column/migration.sql | 22 + prisma/schema.prisma | 8 +- src/app/__tests__/insights-polish.test.ts | 42 +- src/app/api/mood-entries/[id]/route.ts | 16 +- src/app/api/mood-entries/route.ts | 15 +- src/app/insights/page.tsx | 1498 +---------------- .../insights/__tests__/trends-row.test.tsx | 14 +- src/lib/mood/__tests__/date-key.test.ts | 101 ++ src/lib/mood/date-key.ts | 57 + 9 files changed, 280 insertions(+), 1493 deletions(-) create mode 100644 prisma/migrations/0044_mood_entry_tz_column/migration.sql create mode 100644 src/lib/mood/__tests__/date-key.test.ts create mode 100644 src/lib/mood/date-key.ts diff --git a/prisma/migrations/0044_mood_entry_tz_column/migration.sql b/prisma/migrations/0044_mood_entry_tz_column/migration.sql new file mode 100644 index 000000000..071142145 --- /dev/null +++ b/prisma/migrations/0044_mood_entry_tz_column/migration.sql @@ -0,0 +1,22 @@ +-- v1.4.25 W7b — MoodEntry.tz column (proposal §7 Decision A). +-- +-- HealthLog historically wrote `MoodEntry.date` (YYYY-MM-DD) under +-- the implicit Europe/Berlin assumption. Per-user timezone (Option B, +-- feature `feature-user-timezone.md`) makes that assumption wrong for +-- non-Berlin users — a 23:50 NZST reading would bucket into the +-- Berlin day, one day earlier than the user's own "today". +-- +-- The fix is per-row tz attribution: +-- +-- - New rows: writer captures `User.timezone` at write time, stores +-- it on the row, and computes `date` using that zone. +-- - Legacy rows: `tz IS NULL` is interpreted as "Europe/Berlin" +-- read-side. The historical bucketing stays consistent with the +-- numbers users have already seen. +-- +-- Forward-only, additive, idempotent. No backfill is required because +-- the read-path treats `tz IS NULL` as Europe/Berlin (the value those +-- rows would carry if backfilled). Rollback is a manual DROP COLUMN. + +ALTER TABLE "mood_entries" + ADD COLUMN IF NOT EXISTS "tz" VARCHAR(64); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a2cb3b6b7..9ca1637fa 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -513,12 +513,18 @@ model WithingsConnection { model MoodEntry { id String @id @default(cuid()) userId String @map("user_id") - date String // YYYY-MM-DD + date String // YYYY-MM-DD — anchored to `tz` when not null, else legacy Europe/Berlin mood String // SUPER_GUT, GUT, OKAY, SCHLECHT, LAUSIG score Int // 1-5 tags String? // JSON array of tag keys source String @default("MOODLOG") // WEB, TELEGRAM, DAYLIO moodLoggedAt DateTime @map("mood_logged_at") + /// v1.4.25 W7b — per-row IANA timezone that the `date` string is + /// anchored to. Nullable for backward compatibility: legacy rows + /// (`tz IS NULL`) were written under the Europe/Berlin assumption + /// and read-paths interpret them as such. New rows always carry the + /// user's `displayTimezone` at write time. + tz String? syncedAt DateTime @default(now()) @map("synced_at") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") diff --git a/src/app/__tests__/insights-polish.test.ts b/src/app/__tests__/insights-polish.test.ts index d097aadc0..9bdcb4485 100644 --- a/src/app/__tests__/insights-polish.test.ts +++ b/src/app/__tests__/insights-polish.test.ts @@ -107,18 +107,22 @@ describe("v1.4.19 A3 — /insights polish", () => { expect(advisorBlock?.[0]).not.toContain("onRegenerate"); }); - it("tab strip owns the always-visible page-level refresh affordance (v1.4.25 W3)", () => { - const src = load(INSIGHTS_PATH); - // v1.4.25 W3 — the regenerate handler moved from `` to - // `` (icon-only RefreshCw on the sticky tab - // strip). The hero strip no longer takes an `onRegenerate` prop. - // We isolate the `` block (lazy match ending at - // the first `/>`) and assert it does NOT carry an onRegenerate - // wiring. - expect(src).toMatch( - //, + it("tab strip owns the always-visible page-level refresh affordance (v1.4.25 W4)", () => { + // v1.4.25 W4 — the regenerate handler moved out of the mother page + // body entirely and now lives on the layout-mounted + // `` (which renders `` with + // the advisor query's regenerate wired in). The mother-page hero + // no longer takes an `onRegenerate` prop, mirroring the v1.4.25 W3 + // contract while accommodating the new routed sub-page layout. + const shellSrc = readFileSync( + join(ROOT, "src/components/insights/insights-layout-shell.tsx"), + "utf8", ); - const heroBlock = src.match(//); + expect(shellSrc).toMatch( + //, + ); + const insightsSrc = load(INSIGHTS_PATH); + const heroBlock = insightsSrc.match(//); expect(heroBlock).not.toBeNull(); expect(heroBlock?.[0]).not.toContain("onRegenerate"); }); @@ -242,10 +246,16 @@ describe("v1.4.22 A3 — comparison toggle is global Settings only", () => { }); it("/insights still consumes the resolved comparisonBaseline (only the UI is gone)", () => { - // Drift guard — the page must still hand `compareBaseline` to every - // chart so the global Settings toggle keeps driving the overlay. - const src = load(INSIGHTS_PATH); - expect(src).toContain("comparisonBaseline"); - expect(src).toContain("compareBaseline"); + // Drift guard — v1.4.25 W4 split each metric onto its own sub-route + // under `/insights/`, and `compareBaseline` propagation now + // happens via `useInsightsLayoutPrefs()` on every sub-page. The + // central hook is the load-bearing surface; assert it carries both + // the resolved value and the persisted preference key. + const hookSrc = readFileSync( + join(ROOT, "src/hooks/use-insights-layout-prefs.ts"), + "utf8", + ); + expect(hookSrc).toContain("comparisonBaseline"); + expect(hookSrc).toContain("compareBaseline"); }); }); diff --git a/src/app/api/mood-entries/[id]/route.ts b/src/app/api/mood-entries/[id]/route.ts index f0f08d419..6359aa275 100644 --- a/src/app/api/mood-entries/[id]/route.ts +++ b/src/app/api/mood-entries/[id]/route.ts @@ -13,15 +13,10 @@ import { import { NextRequest } from "next/server"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; +import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key"; type RouteParams = { params: Promise<{ id: string }> }; -function toBerlinDate(date: Date): string { - return new Intl.DateTimeFormat("sv-SE", { - timeZone: "Europe/Berlin", - }).format(date); -} - function parseTags(tags: string | null): string[] { if (!tags) return []; try { @@ -80,8 +75,15 @@ export const PUT = apiHandler( updateData.score = getScoreForMood(data.mood); } if (data.moodLoggedAt !== undefined) { + // v1.4.25 W7b — re-anchor the row's `date` to the user's current + // displayTimezone. Also refresh the `tz` column so the row's + // attribution stays consistent with the new `date`. Legacy rows + // promoted via this PUT therefore migrate to per-row tz without + // a separate backfill. + const tz = user.timezone ?? DEFAULT_TIMEZONE; updateData.moodLoggedAt = data.moodLoggedAt; - updateData.date = toBerlinDate(data.moodLoggedAt); + updateData.date = moodDateKey(data.moodLoggedAt, tz); + updateData.tz = tz; } if (data.tags !== undefined) { updateData.tags = data.tags ? JSON.stringify(data.tags) : null; diff --git a/src/app/api/mood-entries/route.ts b/src/app/api/mood-entries/route.ts index 2e4150fd4..92e499327 100644 --- a/src/app/api/mood-entries/route.ts +++ b/src/app/api/mood-entries/route.ts @@ -16,12 +16,7 @@ import { Prisma } from "@/generated/prisma/client"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { withIdempotency } from "@/lib/idempotency"; - -function toBerlinDate(date: Date): string { - return new Intl.DateTimeFormat("sv-SE", { - timeZone: "Europe/Berlin", - }).format(date); -} +import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key"; function parseTags(tags: string | null): string[] { if (!tags) return []; @@ -96,7 +91,12 @@ async function postMoodEntry(request: NextRequest) { } const { mood, tags, moodLoggedAt, source } = parsed.data; - const date = toBerlinDate(moodLoggedAt); + // v1.4.25 W7b (Decision A) — anchor the `date` string to the user's + // current displayTimezone and store the resolved zone on the row. + // Legacy rows with `tz IS NULL` continue to read as Europe/Berlin + // (see `src/lib/mood/date-key.ts`). + const tz = user.timezone ?? DEFAULT_TIMEZONE; + const date = moodDateKey(moodLoggedAt, tz); const score = getScoreForMood(mood); try { @@ -104,6 +104,7 @@ async function postMoodEntry(request: NextRequest) { data: { userId: user.id, date, + tz, mood, score, tags: tags ? JSON.stringify(tags) : null, diff --git a/src/app/insights/page.tsx b/src/app/insights/page.tsx index 7171e16c6..6887d5e0d 100644 --- a/src/app/insights/page.tsx +++ b/src/app/insights/page.tsx @@ -2,47 +2,15 @@ import { useState } from "react"; import { useQuery } from "@tanstack/react-query"; +import Link from "next/link"; +import { Loader2, TrendingUp } from "lucide-react"; + import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; -import { queryKeys } from "@/lib/query-keys"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { EmptyState } from "@/components/ui/empty-state"; -import { Progress } from "@/components/ui/progress"; -import Link from "next/link"; -import dynamic from "next/dynamic"; - -const HealthChart = dynamic( - () => - import("@/components/charts/health-chart").then((mod) => ({ - default: mod.HealthChart, - })), - { ssr: false }, -); -const MoodChart = dynamic( - () => - import("@/components/charts/mood-chart").then((mod) => ({ - default: mod.MoodChart, - })), - { ssr: false }, -); -import { ComplianceHeatmap } from "@/components/charts/compliance-heatmap"; -import { - Activity, - Heart, - HeartPulse, - Loader2, - Pill, - Ruler, - Scale, - Smile, - TrendingUp, -} from "lucide-react"; -import { InsightStatusCard } from "@/components/insights/insight-status-card"; import { InsightAdvisorCard } from "@/components/insights/insight-advisor-card"; import { HeroStrip } from "@/components/insights/hero-strip"; -import { InsightsTabStrip } from "@/components/insights/insights-tab-strip"; import { DailyBriefing } from "@/components/insights/daily-briefing"; import { CoachDrawer } from "@/components/insights/coach-panel/coach-drawer"; import { TrendsRow } from "@/components/insights/trends-row"; @@ -50,132 +18,44 @@ import { CorrelationRow } from "@/components/insights/correlation-row"; import { useInsightsAdvisorQuery } from "@/components/insights/use-insights-advisor"; import type { CorrelationResult } from "@/lib/insights/correlations"; import { toWeekISO } from "@/lib/insights/week-iso"; -// Recharts is ~108 KiB Brotli — defer-load it via a self-contained scatter -// wrapper so the bundle only lands once a correlation card actually renders. -// Every scatter card sits inside a `length >= 5` gate and below the fold, -// matching the existing HealthChart / MoodChart pattern. The earlier attempt -// (v1.5 phase 4) wrapped each Recharts primitive (XAxis/YAxis/etc.) in -// next/dynamic individually, which broke Recharts' internal child-type -// detection (`findAllByType` compares against the original component -// identity, not the dynamic HOC). Splitting at the wrapper level keeps the -// bundle savings while preserving visual parity. See -// docs/audit/v15-performance.md (v1.5 phase-4 perf audit). -const ScatterCorrelationChart = dynamic( - () => - import("@/components/charts/scatter-correlation-chart").then((mod) => ({ - default: mod.ScatterCorrelationChart, - })), - { - ssr: false, - loading: () => ( -
- ), - }, -); -import { getBpTargets } from "@/lib/analytics/bp-targets"; -import { - buildTrafficRange, - buildWeightBandsFromHeight, - buildWeightRangeFromHeight, - type TrafficRange, -} from "@/lib/analytics/value-bands"; -import { - getAgeFromDateOfBirth, - getPersonalizedPulseTarget, -} from "@/lib/analytics/pulse-targets"; import type { DataSummary } from "@/lib/analytics/trends"; -import { - resolveDashboardLayout, - type DashboardLayout, -} from "@/lib/dashboard-layout"; +/** + * v1.4.25 W4d — Insights mother page. + * + * The page used to be a single 1.8k-LOC monolith that scroll-anchored + * six per-metric sections beneath the hero. W4a/c carved those out + * into routed sub-pages under `/insights/{slug}`; this file now holds + * the overview-only surface: + * + * - The sticky tab strip lives in `src/app/insights/layout.tsx` + * (the shared `` mounts it). The strip handles + * navigation to every sub-page + the regenerate affordance. + * - Hero + DailyBriefing + Correlation row + Trends row + advisor + * card stay here — they're the cross-metric overview. + * - The CoachDrawer is mounted in the mother-page body only (Marc + * directive). Navigating to a sub-page unmounts the drawer. + * + * The per-section status cards (BP/Weight/Pulse/etc.) and their + * heavy chart wiring moved to the matching sub-pages. + */ + +/** + * The mother page only checks whether the comprehensive payload arrived + * (the EmptyState gates on `!data`); the metric-specific shape lives on + * the sub-pages now. Keep this slim — anything more is dead weight here. + */ interface ComprehensiveData { - summaries: Record< - string, - { - latest: number | null; - avg30: number | null; - slope30: { slope: number; direction: string } | null; - anomalyCount: number; - } - >; - bmi: number | null; - bmiClassification: { - category: string; - color: string; - severity: string; - } | null; - bpClassification: { - category: string; - color: string; - severity: string; - } | null; - bpPctInTarget: number | null; - bpTargets: { - sysLow: number; - sysHigh: number; - diaLow: number; - diaHigh: number; - } | null; - weightBpCorrelation: { r: number; strength: string; n: number } | null; - scatterData: Array<{ weight: number; sysBP: number }>; - bpMedicationCorrelation: { - r: number; - strength: string; - n: number; - medicationCount: number; - } | null; - bpMedicationScatterData: Array<{ continuityPct: number; sysBP: number }>; - medications: Array<{ - id: string; - name: string; - dose: string; - category: "BLOOD_PRESSURE" | "VITAMIN" | "OTHER"; - compliance7: number; - compliance30: number; - streak: number; - taken7: number; - skipped7: number; - missed7: number; - }>; - alerts: Array<{ level: string; title: string; message: string }>; - hasOpenAiKey: boolean; - dataSpanDays: number; totalMeasurements: number; - moodSummary: { - latest: number | null; - avg7: number | null; - avg30: number | null; - count: number; - slope30: { slope: number; direction: string } | null; - } | null; - moodBpCorrelation: { r: number; strength: string; n: number } | null; - moodBpScatterData: Array<{ mood: number; sysBP: number }>; - moodWeightCorrelation: { r: number; strength: string; n: number } | null; - moodWeightScatterData: Array<{ mood: number; weight: number }>; - moodPulseCorrelation: { r: number; strength: string; n: number } | null; - moodPulseScatterData: Array<{ mood: number; pulse: number }>; } interface AnalyticsData { summaries: Record; - /** - * v1.4.20 phase B3 — three pre-defined correlation hypothesis runners - * computed server-side. Optional + nullable so the page handles the - * v1.4.19-style cached `analytics` payload without flashing an empty - * row before the next refetch. - */ correlations?: { bpCompliance: CorrelationResult; moodPulse: CorrelationResult; weightWeekday: CorrelationResult; } | null; - /** - * v1.4.20 phase B5 — composite Personal Health Score with sub-component - * breakdown + delta vs last week. Optional + nullable so older cached - * `analytics` payloads continue to render the rest of the page; the - * hero panel hides cleanly when the field is absent. - */ healthScore?: { score: number; band: "green" | "yellow" | "red"; @@ -189,322 +69,17 @@ interface AnalyticsData { } | null; } -interface BloodPressureStatusData { - hasProvider: boolean; - text: string | null; - cached: boolean; - updatedAt: string | null; -} - -interface WeightStatusData { - hasProvider: boolean; - text: string | null; - cached: boolean; - updatedAt: string | null; -} - -interface PulseStatusData { - hasProvider: boolean; - text: string | null; - cached: boolean; - updatedAt: string | null; -} - -interface BmiStatusData { - hasProvider: boolean; - text: string | null; - cached: boolean; - updatedAt: string | null; -} - -interface MoodStatusData { - hasProvider: boolean; - text: string | null; - cached: boolean; - updatedAt: string | null; -} - -interface MedicationComplianceStatusData { - hasProvider: boolean; - summary: string | null; - medications: Array<{ - medicationId: string; - text: string; - }>; - cached: boolean; - updatedAt: string | null; -} - -interface MedicationDailyData { - expected: number; - taken: number; - skipped: number; - onTime?: number; - late?: number; - veryLate?: number; -} - -interface MedicationComplianceDailyResponse { - dailyCompliance: Record; -} - -/** - * v1.4.16 phase B1b — pick the freshest ISO timestamp from a list so the - * page hero's "Generated …" caption surfaces a single representative - * value across the per-section caches. Returns null when nothing's - * been generated yet so the caption hides cleanly. - */ -function freshestUpdatedAt( - candidates: Array, -): string | null { - let freshest: number | null = null; - let freshestIso: string | null = null; - for (const iso of candidates) { - if (!iso) continue; - const ms = new Date(iso).getTime(); - if (Number.isNaN(ms)) continue; - if (freshest === null || ms > freshest) { - freshest = ms; - freshestIso = iso; - } - } - return freshestIso; -} - -type HealthBandState = "green" | "orange" | "red"; - -function classifyRangeValue( - value: number | null | undefined, - range: TrafficRange | null, -): HealthBandState | null { - if (value == null || !range) return null; - if (value >= range.greenMin && value <= range.greenMax) return "green"; - if (value >= range.orangeMin && value <= range.orangeMax) return "orange"; - return "red"; -} - -// v1.4.25 W3 — `getOverallHealthStatus` + its caller `overallStatus` -// were the badge feed for the now-removed "Allgemeiner -// Gesundheitszustand" section. Dropped along with the section. - -function getBloodPressureSectionStatus(input: { - sysAvg30: number | null | undefined; - diaAvg30: number | null | undefined; - sysRange: TrafficRange | null; - diaRange: TrafficRange | null; - inTargetPct: number | null | undefined; - medicationCompliance30: number | null; -}): { level: "good" | "watch" | "critical"; className: string } { - const sysState = classifyRangeValue(input.sysAvg30, input.sysRange); - const diaState = classifyRangeValue(input.diaAvg30, input.diaRange); - const inTargetPct = input.inTargetPct ?? null; - - if ( - sysState === "red" || - diaState === "red" || - (inTargetPct != null && inTargetPct < 50) || - (input.medicationCompliance30 != null && input.medicationCompliance30 < 60) - ) { - return { - level: "critical", - className: "border-red-500/30 bg-red-500/15 text-red-300", - }; - } - - if ( - sysState === "green" && - diaState === "green" && - (inTargetPct == null || inTargetPct >= 70) && - (input.medicationCompliance30 == null || input.medicationCompliance30 >= 80) - ) { - return { - level: "good", - className: "border-green-500/30 bg-green-500/15 text-green-300", - }; - } - - return { - level: "watch", - className: "border-yellow-500/30 bg-yellow-500/15 text-yellow-300", - }; -} - -function getWeightSectionStatus(input: { - avg30: number | null | undefined; - range: TrafficRange | null; - slope30Direction: string | null | undefined; -}): { level: "good" | "watch" | "critical"; className: string } { - const state = classifyRangeValue(input.avg30, input.range); - - if (state === "red") { - return { - level: "critical", - className: "border-red-500/30 bg-red-500/15 text-red-300", - }; - } - - if (state === "green" && input.slope30Direction !== "up") { - return { - level: "good", - className: "border-green-500/30 bg-green-500/15 text-green-300", - }; - } - - return { - level: "watch", - className: "border-yellow-500/30 bg-yellow-500/15 text-yellow-300", - }; -} - -function getPulseSectionStatus(input: { - avg30: number | null | undefined; - range: TrafficRange | null; - slope30Direction: string | null | undefined; -}): { level: "good" | "watch" | "critical"; className: string } { - const state = classifyRangeValue(input.avg30, input.range); - - if (state === "red") { - return { - level: "critical", - className: "border-red-500/30 bg-red-500/15 text-red-300", - }; - } - - if (state === "green" && input.slope30Direction !== "up") { - return { - level: "good", - className: "border-green-500/30 bg-green-500/15 text-green-300", - }; - } - - return { - level: "watch", - className: "border-yellow-500/30 bg-yellow-500/15 text-yellow-300", - }; -} - -function getBmiSectionStatus(input: { - avg30: number | null | undefined; - range: TrafficRange | null; - slope30Direction: string | null | undefined; -}): { level: "good" | "watch" | "critical"; className: string } { - const state = classifyRangeValue(input.avg30, input.range); - - if (state === "red") { - return { - level: "critical", - className: "border-red-500/30 bg-red-500/15 text-red-300", - }; - } - - if (state === "green" && input.slope30Direction !== "up") { - return { - level: "good", - className: "border-green-500/30 bg-green-500/15 text-green-300", - }; - } - - return { - level: "watch", - className: "border-yellow-500/30 bg-yellow-500/15 text-yellow-300", - }; -} - -function getMoodSectionStatus(input: { avg30: number | null | undefined }): { - level: "good" | "watch" | "critical"; - className: string; -} { - if (input.avg30 == null) { - return { - level: "watch", - className: "border-yellow-500/30 bg-yellow-500/15 text-yellow-300", - }; - } - - if (input.avg30 < 2) { - return { - level: "critical", - className: "border-red-500/30 bg-red-500/15 text-red-300", - }; - } - - if (input.avg30 >= 3.5) { - return { - level: "good", - className: "border-green-500/30 bg-green-500/15 text-green-300", - }; - } - - return { - level: "watch", - className: "border-yellow-500/30 bg-yellow-500/15 text-yellow-300", - }; -} - -function getMedicationComplianceSectionStatus(input: { - average30: number | null; - totalEvents: number; -}): { level: "good" | "watch" | "critical"; className: string } { - const MIN_EVENTS_FOR_ASSESSMENT = 14; - - if ( - input.average30 == null || - input.totalEvents < MIN_EVENTS_FOR_ASSESSMENT - ) { - return { - level: "watch", - className: "border-yellow-500/30 bg-yellow-500/15 text-yellow-300", - }; - } - - if (input.average30 < 60) { - return { - level: "critical", - className: "border-red-500/30 bg-red-500/15 text-red-300", - }; - } - - if (input.average30 >= 80) { - return { - level: "good", - className: "border-green-500/30 bg-green-500/15 text-green-300", - }; - } - - return { - level: "watch", - className: "border-yellow-500/30 bg-yellow-500/15 text-yellow-300", - }; -} - -// v1.4.20 phase D reconcile — module-scope so the map isn't recreated -// on every render of `` (the page renders often on -// TanStack-Query refetches; this map carries no closures over locals). -const STORYBOARD_COLOR_BY_CATEGORY: Record = { - medication: "var(--dracula-pink)", - event: "var(--dracula-cyan)", - milestone: "var(--dracula-green)", - warning: "var(--dracula-orange)", -}; - export default function InsightsPage() { const { isAuthenticated, user } = useAuth(); - const { t, locale } = useTranslations(); + const { t } = useTranslations(); - // v1.4.20 phase B2b — Coach drawer state. The hero strip's - // "Ask the coach" button + suggested-prompt chips toggle the drawer - // here; the drawer ingests `coachPrefill` once on open and resets on - // close so the next open starts blank. + // CoachDrawer state. The hero strip's "Ask the coach" button + + // suggested-prompt chips toggle the drawer here; the drawer ingests + // `coachPrefill` once on open and resets on close so the next open + // starts blank. const [coachOpen, setCoachOpen] = useState(false); const [coachPrefill, setCoachPrefill] = useState(null); - const STRENGTH_LABELS: Record = { - stark: t("insights.strengthStrong"), - moderat: t("insights.strengthModerate"), - schwach: t("insights.strengthWeak"), - keine: t("insights.strengthNone"), - }; - const { data, isLoading } = useQuery({ queryKey: ["insights", "comprehensive"], queryFn: async () => { @@ -516,30 +91,9 @@ export default function InsightsPage() { enabled: isAuthenticated, }); - // v1.4.16 phase B8 — share comparison baseline between dashboard - // and insights surfaces. The two pages read from the same persisted - // preference so a toggle flip in Settings → Dashboard updates both - // surfaces atomically. - const { data: layoutData } = useQuery({ - queryKey: queryKeys.dashboardWidgets(), - queryFn: async () => { - const res = await fetch("/api/dashboard/widgets"); - if (!res.ok) throw new Error("Failed"); - const json = await res.json(); - return json.data as DashboardLayout; - }, - enabled: isAuthenticated, - }); - const compareBaseline = - resolveDashboardLayout(layoutData).comparisonBaseline ?? "none"; - - // v1.4.16 phase D reconcile (CRITICAL C1) — pull the rich advisor - // payload (severity-ordered recommendations + rationale + confidence - // + medical-citation footnotes + thumbs feedback) so this page - // surfaces the polished `` from B5c/d/e/B1b - // instead of the v1.4.15 text-only `` summary. - // Cache-aware: a regenerate on the dashboard preview (C2) hot-swaps - // this query under the same key. + // The advisor query is also mounted by the layout shell; the page + // consumer re-reads from the same cache key so this call is free + // beyond the React-state subscription. const advisor = useInsightsAdvisorQuery(isAuthenticated); const { data: analytics } = useQuery({ @@ -553,242 +107,6 @@ export default function InsightsPage() { enabled: isAuthenticated, }); - const { data: bloodPressureStatus, isLoading: isBloodPressureStatusLoading } = - useQuery({ - queryKey: ["insights", "blood-pressure-status", locale], - queryFn: async () => { - const res = await fetch( - `/api/insights/blood-pressure-status?locale=${locale}`, - ); - if (!res.ok) throw new Error("Failed"); - const json = await res.json(); - return json.data as BloodPressureStatusData; - }, - enabled: isAuthenticated, - staleTime: 60 * 1000, - }); - - const { data: weightStatus, isLoading: isWeightStatusLoading } = useQuery({ - queryKey: ["insights", "weight-status", locale], - queryFn: async () => { - const res = await fetch(`/api/insights/weight-status?locale=${locale}`); - if (!res.ok) throw new Error("Failed"); - const json = await res.json(); - return json.data as WeightStatusData; - }, - enabled: isAuthenticated, - staleTime: 60 * 1000, - }); - - const { data: pulseStatus, isLoading: isPulseStatusLoading } = useQuery({ - queryKey: ["insights", "pulse-status", locale], - queryFn: async () => { - const res = await fetch(`/api/insights/pulse-status?locale=${locale}`); - if (!res.ok) throw new Error("Failed"); - const json = await res.json(); - return json.data as PulseStatusData; - }, - enabled: isAuthenticated, - staleTime: 60 * 1000, - }); - - const { data: bmiStatus, isLoading: isBmiStatusLoading } = useQuery({ - queryKey: ["insights", "bmi-status", locale], - queryFn: async () => { - const res = await fetch(`/api/insights/bmi-status?locale=${locale}`); - if (!res.ok) throw new Error("Failed"); - const json = await res.json(); - return json.data as BmiStatusData; - }, - enabled: isAuthenticated, - staleTime: 60 * 1000, - }); - - const { data: moodStatus, isLoading: isMoodStatusLoading } = useQuery({ - queryKey: ["insights", "mood-status", locale], - queryFn: async () => { - const res = await fetch(`/api/insights/mood-status?locale=${locale}`); - if (!res.ok) throw new Error("Failed"); - const json = await res.json(); - return json.data as MoodStatusData; - }, - enabled: isAuthenticated, - staleTime: 60 * 1000, - }); - - const { - data: medicationComplianceStatus, - isLoading: isMedicationComplianceStatusLoading, - } = useQuery({ - queryKey: ["insights", "medication-compliance-status", locale], - queryFn: async () => { - const res = await fetch( - `/api/insights/medication-compliance-status?locale=${locale}`, - ); - if (!res.ok) throw new Error("Failed"); - const json = await res.json(); - return json.data as MedicationComplianceStatusData; - }, - enabled: isAuthenticated, - staleTime: 60 * 1000, - }); - const w = analytics?.summaries?.WEIGHT; - const sys = analytics?.summaries?.BLOOD_PRESSURE_SYS; - const dia = analytics?.summaries?.BLOOD_PRESSURE_DIA; - const p = analytics?.summaries?.PULSE; - const bmiDivisor = user?.heightCm ? (user.heightCm / 100) ** 2 : null; - // bmiAvg30 and bmiSlope30 still used for overallStatus + bmiSectionStatus - const bmiAvg30 = bmiDivisor && w?.avg30 != null ? w.avg30 / bmiDivisor : null; - const bmiSlope30 = - bmiDivisor && w?.slope30 - ? { - ...w.slope30, - slope: w.slope30.slope / bmiDivisor, - } - : null; - - const bpTargets = - user?.dateOfBirth != null ? getBpTargets(new Date(user.dateOfBirth)) : null; - const pulseAge = getAgeFromDateOfBirth(user?.dateOfBirth ?? null); - const pulseTarget = getPersonalizedPulseTarget( - pulseAge, - (user?.gender as "MALE" | "FEMALE" | null | undefined) ?? null, - ); - const weightRange = user?.heightCm - ? buildWeightRangeFromHeight(user.heightCm) - : null; - const bmiRange = buildTrafficRange(18.5, 24.9); - const weightBands = user?.heightCm - ? buildWeightBandsFromHeight(user.heightCm, { - lowerBound: 30, - upperBound: 250, - }) - : undefined; - const bpTargetZones = bpTargets - ? [ - { - min: bpTargets.sysLow, - max: bpTargets.sysHigh, - color: "#ff79c6", - opacity: 0.21, - label: t("charts.systolic"), - textColor: "#ff79c6", - lineOpacity: 0.24, - }, - { - min: bpTargets.diaLow, - max: bpTargets.diaHigh, - color: "#8be9fd", - opacity: 0.21, - label: t("charts.diastolic"), - textColor: "#8be9fd", - lineOpacity: 0.24, - }, - ] - : undefined; - const bpSysRange = bpTargets - ? buildTrafficRange(bpTargets.sysLow, bpTargets.sysHigh) - : null; - const bpDiaRange = bpTargets - ? buildTrafficRange(bpTargets.diaLow, bpTargets.diaHigh) - : null; - const pulseDisplayRange = { - greenMin: pulseTarget.greenMin, - greenMax: pulseTarget.greenMax, - orangeMin: pulseTarget.orangeMin, - orangeMax: pulseTarget.orangeMax, - }; - const bpMedications = - data?.medications?.filter( - (medication) => medication.category === "BLOOD_PRESSURE", - ) ?? []; - const bpMedicationCompliance30 = - bpMedications.length > 0 - ? bpMedications.reduce( - (sum, medication) => sum + medication.compliance30, - 0, - ) / bpMedications.length - : null; - const bloodPressureSectionStatus = getBloodPressureSectionStatus({ - sysAvg30: sys?.avg30, - diaAvg30: dia?.avg30, - sysRange: bpSysRange, - diaRange: bpDiaRange, - inTargetPct: data?.bpPctInTarget, - medicationCompliance30: bpMedicationCompliance30, - }); - const weightSectionStatus = getWeightSectionStatus({ - avg30: w?.avg30, - range: weightRange, - slope30Direction: w?.slope30?.direction, - }); - const pulseSectionStatus = getPulseSectionStatus({ - avg30: p?.avg30, - range: pulseDisplayRange, - slope30Direction: p?.slope30?.direction, - }); - const bmiSectionStatus = getBmiSectionStatus({ - avg30: bmiAvg30, - range: bmiRange, - slope30Direction: bmiSlope30?.direction, - }); - const moodSectionStatus = getMoodSectionStatus({ - avg30: data?.moodSummary?.avg30, - }); - const showMoodSection = (data?.moodSummary?.count ?? 0) > 0; - const medicationList = data?.medications ?? []; - const medicationComplianceAverage30 = - medicationList.length > 0 - ? medicationList.reduce( - (sum, medication) => sum + medication.compliance30, - 0, - ) / medicationList.length - : null; - const medicationTotalEvents = medicationList.reduce( - (sum, med) => sum + med.taken7 + med.skipped7 + med.missed7, - 0, - ); - const medicationComplianceSectionStatus = - getMedicationComplianceSectionStatus({ - average30: medicationComplianceAverage30, - totalEvents: medicationTotalEvents, - }); - const medicationSummaryById = new Map( - (medicationComplianceStatus?.medications ?? []).map((entry) => [ - entry.medicationId, - entry.text, - ]), - ); - const pulseBands = [ - { min: 30, max: pulseTarget.orangeMin, color: "#ff5555", opacity: 0.16 }, - { - min: pulseTarget.orangeMin, - max: pulseTarget.greenMin, - color: "#ffb86c", - opacity: 0.18, - }, - { - min: pulseTarget.greenMin, - max: pulseTarget.greenMax, - color: "#50fa7b", - opacity: 0.2, - }, - { - min: pulseTarget.greenMax, - max: pulseTarget.orangeMax, - color: "#ffb86c", - opacity: 0.18, - }, - { min: pulseTarget.orangeMax, max: 220, color: "#ff5555", opacity: 0.16 }, - ].filter((band) => band.max > band.min); - const bmiBands = [ - { min: 0, max: 17, color: "#ff5555", opacity: 0.16 }, - { min: 17, max: 18.5, color: "#ffb86c", opacity: 0.18 }, - { min: 18.5, max: 24.9, color: "#50fa7b", opacity: 0.2 }, - { min: 24.9, max: 29.9, color: "#ffb86c", opacity: 0.18 }, - { min: 29.9, max: 120, color: "#ff5555", opacity: 0.16 }, - ]; - if (isLoading) { return (
@@ -798,9 +116,6 @@ export default function InsightsPage() { } if (!data) { - // v1.4.15 phase-C5: replace bare "no data" text with EmptyState + - // CTA into /measurements so the user has a single-click path out - // of the empty insights view. return ( } @@ -817,34 +132,13 @@ export default function InsightsPage() { ); } - // Wire the hero's "Generated …" caption from the freshest of the per- - // section caches so the user gets a single timestamp without having to - // expand every card. Falls back to null when no section has shipped a - // payload yet (the hero just hides the caption). - const heroUpdatedAt = freshestUpdatedAt([ - bloodPressureStatus?.updatedAt, - weightStatus?.updatedAt, - pulseStatus?.updatedAt, - bmiStatus?.updatedAt, - moodStatus?.updatedAt, - medicationComplianceStatus?.updatedAt, - ]); - - // v1.4.20 phase B1 — derive the user's display name for the hero - // greeting. We use the username (HealthLog has no separate display- - // name field) and stop at the first whitespace so a "first.last" - // handle reads cleanly. Falls back to no-name greeting when the user - // has no username so the hero never paints "Good morning, undefined". const heroGreetingName = user?.username?.trim() && user.username.trim().length > 0 ? user.username.split(/\s+/)[0] : null; const briefingPayload = advisor.payload?.dailyBriefing ?? null; - const heroStripUpdatedAt = advisor.payload?.cachedAt ?? heroUpdatedAt; + const heroStripUpdatedAt = advisor.payload?.cachedAt ?? null; - // v1.4.20 phase B4 — surface a banner-card on the hero whenever the - // cached AI payload carries a weeklyReport block. The banner deep- - // links into `/insights/report/[week]` for Read / Share / Export PDF. const weeklyReport = ( advisor.payload?.insights as | { @@ -858,57 +152,15 @@ export default function InsightsPage() { href: `/insights/report/${weeklyReport.weekISO}`, } : undefined; - // v1.4.20 phase D reconcile — the action-row "Generate weekly report" - // button links into the current ISO week's report (B4 shipped the - // route, so the disabled-primary placeholder no longer makes sense). const currentWeekHref = `/insights/report/${toWeekISO(new Date())}`; - // v1.4.20 phase B4 — storyboard annotations for the 90-day BP chart. - // The advisor payload may carry up to 20 entries; we transform them - // into the {date, label, color} shape consumes. The - // colour map mirrors the four canonical categories (hoisted to - // module scope above). Cached payloads from before PROMPT_VERSION - // 4.20.2 simply produce an empty array. - const rawStoryboard = ( - advisor.payload?.insights as - | { - storyboardAnnotations?: Array<{ - date: string; - label: string; - category: string; - }>; - } - | undefined - )?.storyboardAnnotations; - const bpStoryboardAnnotations = (rawStoryboard ?? []).map((entry) => ({ - date: entry.date, - label: entry.label, - color: - STORYBOARD_COLOR_BY_CATEGORY[entry.category] ?? "var(--dracula-purple)", - })); - return (
- {/* v1.4.25 W3 — section tabs + regenerate affordance live in the - dedicated `` component. The strip is sticky - so the user can re-run the analysis without scrolling back to - the hero band. The hero strip no longer carries its own - regenerate button — the icon-only RefreshCw on this strip is - the canonical page-level affordance. */} - - { - // The action-row button passes no prefill (drawer opens blank); - // the Health Score panel passes a score-aware question. Both - // share the same drawer state so the user only ever sees one - // drawer instance. setCoachPrefill(prefill ?? null); setCoachOpen(true); }} @@ -929,26 +181,12 @@ export default function InsightsPage() { regenerating={advisor.isRegenerating} /> - {/* v1.4.20 phase B3 — Correlation discovery row. Three pre-defined - hypotheses (BP × compliance, mood × pulse, weight × weekday) gated - on n >= 14 + p < 0.05; cards below the bar render a per-card - empty-state. The row-level disclaimer ("Relationships are - observational, not causal …") sits below the grid once. */} {analytics?.correlations && ( )} - {/* v1.4.20 phase B3 — Trends row. Mini BP / Weight / Mood charts - with an inline AI-authored sentence below each. Annotations come - from `advisor.payload.trendAnnotations` (PROMPT_VERSION 4.20.1+); - legacy cached payloads simply paint the per-metric empty hint. */} - {/* v1.4.16 phase D reconcile (CRITICAL C1) — wire the polished - `` (severity-ordered recommendations grid + - per-rec rationale expand + confidence meter + thumbs feedback + - medical-citation footnotes) into the live route. The - per-section status cards stay below as supplemental detail. */} - {/* Section 3: Blood pressure */} -
-
-

- {t("insights.bloodPressureSectionTitle")} -

- - {t( - `insights.generalStatusBadge.${bloodPressureSectionStatus.level}`, - )} - -
- - - - {/* v1.4.22 A4 — row-fill rule: 2 cards → 50/50 (xl), 1 card → - 100 % width. When the mood section is hidden the BP-medication - card collapses to full-width instead of leaving the right - half empty. */} -
- - -
-
- - - {t("insights.bpMedContinuity")} - -
- {data.bpMedicationCorrelation && ( - - r = {data.bpMedicationCorrelation.r} ·{" "} - {STRENGTH_LABELS[data.bpMedicationCorrelation.strength] ?? - data.bpMedicationCorrelation.strength} - - )} -
-
- - {data.bpMedicationScatterData.length >= 5 ? ( -
- [ - `${value}${name === t("insights.continuityLabel") ? "%" : " mmHg"}`, - name, - ]} - /> - {data.bpMedicationCorrelation && ( -

- {data.bpMedicationCorrelation.r < 0 - ? t("insights.bpMedNegative") - : data.bpMedicationCorrelation.r > 0 - ? t("insights.bpMedPositive") - : t("insights.bpMedNone")}{" "} - (n = {data.bpMedicationCorrelation.n},{" "} - {t("insights.bpMedCount", { - count: data.bpMedicationCorrelation.medicationCount, - })} - ) -

- )} -
- ) : ( -
- -

{t("insights.notEnoughMedData")}

-

- {t("insights.notEnoughMedDataHint")} -

-
- )} -
-
- - {showMoodSection && ( - - -
-
- - - {t("insights.moodVsBp")} - -
- {data?.moodBpCorrelation && ( - - r = {data.moodBpCorrelation.r} ·{" "} - {STRENGTH_LABELS[data.moodBpCorrelation.strength] ?? - data.moodBpCorrelation.strength} - - )} -
-
- - {(data?.moodBpScatterData?.length ?? 0) >= 5 ? ( -
- - {data?.moodBpCorrelation && ( -

- {data.moodBpCorrelation.strength === "stark" - ? t("insights.moodBpStrong") - : data.moodBpCorrelation.strength === "moderat" - ? t("insights.moodBpModerate") - : data.moodBpCorrelation.strength === "schwach" - ? t("insights.moodBpWeak") - : t("insights.moodBpNone")}{" "} - (n = {data.moodBpCorrelation.n}) -

- )} -
- ) : ( -
- -

{t("insights.notEnoughMoodCorrelationData")}

-

- {t("insights.minMoodCorrelationData")} -

-
- )} -
-
- )} -
- - - - - {t("insights.bloodPressureMedicationListTitle")} - - - - {bpMedications.length > 0 ? ( - bpMedications.map((medication) => ( -
-
-

{medication.name}

-

- {medication.dose} -

-
-
-
- {t("insights.compliance30d")} - - {medication.compliance30}% - -
- -
-
- )) - ) : ( -

- {t("insights.bloodPressureMedicationListEmpty")} -

- )} -
-
- - } - text={bloodPressureStatus?.text ?? null} - hasProvider={bloodPressureStatus?.hasProvider ?? false} - cached={bloodPressureStatus?.cached ?? false} - updatedAt={bloodPressureStatus?.updatedAt ?? null} - loading={isBloodPressureStatusLoading} - /> -
- - {/* Section 4: Weight */} -
-
-

- {t("insights.weightSectionTitle")} -

- - {t(`insights.generalStatusBadge.${weightSectionStatus.level}`)} - -
- - - - {/* v1.4.22 A4 — row-fill rule: 2 cards → 50/50 (xl), 1 card → - 100 % width. Mirrors the BP-section grid normalisation. */} -
- - -
-
- - - {t("insights.weightVsBp")} - -
- {data.weightBpCorrelation && ( - - r = {data.weightBpCorrelation.r} ·{" "} - {STRENGTH_LABELS[data.weightBpCorrelation.strength] ?? - data.weightBpCorrelation.strength} - - )} -
-
- - {data.scatterData.length >= 5 ? ( -
- value.toFixed(1), - }} - yAxis={{ - dataKey: "sysBP", - name: "Sys. BP", - unit: " mmHg", - }} - tooltipFormatter={(value, name) => [ - `${value} ${name === t("dashboard.weight") ? "kg" : "mmHg"}`, - name, - ]} - /> - {data.weightBpCorrelation && ( -

- {data.weightBpCorrelation.strength === "stark" - ? t("insights.weightBpStrong") - : data.weightBpCorrelation.strength === "moderat" - ? t("insights.weightBpModerate") - : data.weightBpCorrelation.strength === "schwach" - ? t("insights.weightBpWeak") - : t("insights.weightBpNone")}{" "} - (n = {data.weightBpCorrelation.n}) -

- )} -
- ) : ( -
- -

{t("insights.notEnoughCorrelationData")}

-

{t("insights.minCorrelationData")}

-
- )} -
-
- - {showMoodSection && ( - - -
-
- - - {t("insights.moodVsWeight")} - -
- {data?.moodWeightCorrelation && ( - - r = {data.moodWeightCorrelation.r} ·{" "} - {STRENGTH_LABELS[data.moodWeightCorrelation.strength] ?? - data.moodWeightCorrelation.strength} - - )} -
-
- - {(data?.moodWeightScatterData?.length ?? 0) >= 5 ? ( -
- - {data?.moodWeightCorrelation && ( -

- {data.moodWeightCorrelation.strength === "stark" - ? t("insights.moodWeightStrong") - : data.moodWeightCorrelation.strength === "moderat" - ? t("insights.moodWeightModerate") - : data.moodWeightCorrelation.strength === "schwach" - ? t("insights.moodWeightWeak") - : t("insights.moodWeightNone")}{" "} - (n = {data.moodWeightCorrelation.n}) -

- )} -
- ) : ( -
- -

{t("insights.notEnoughMoodCorrelationData")}

-

- {t("insights.minMoodCorrelationData")} -

-
- )} -
-
- )} -
- - } - text={weightStatus?.text ?? null} - hasProvider={weightStatus?.hasProvider ?? false} - cached={weightStatus?.cached ?? false} - updatedAt={weightStatus?.updatedAt ?? null} - loading={isWeightStatusLoading} - /> -
- - {/* Section 5: Pulse */} -
-
-

- {t("insights.pulseSectionTitle")} -

- - {t(`insights.generalStatusBadge.${pulseSectionStatus.level}`)} - -
- - - - } - text={pulseStatus?.text ?? null} - hasProvider={pulseStatus?.hasProvider ?? false} - cached={pulseStatus?.cached ?? false} - updatedAt={pulseStatus?.updatedAt ?? null} - loading={isPulseStatusLoading} - /> -
- - {/* Section: Mood */} - {showMoodSection && ( -
-
-

- {t("insights.moodSectionTitle")} -

- - {t(`insights.generalStatusBadge.${moodSectionStatus.level}`)} - -
- - - - } - text={moodStatus?.text ?? null} - hasProvider={moodStatus?.hasProvider ?? false} - cached={moodStatus?.cached ?? false} - updatedAt={moodStatus?.updatedAt ?? null} - loading={isMoodStatusLoading} - /> -
- )} - - {/* Section 6: Medication Compliance */} -
-
-

- {t("insights.medicationCompliance")} -

- - {t( - `insights.generalStatusBadge.${medicationComplianceSectionStatus.level}`, - )} - -
- - {data.medications.length > 0 ? ( - /* v1.4.22 A4 — row-fill rule: a single medication card fills - 100 % width instead of half the row. */ -
= 2 - ? "grid gap-4 sm:grid-cols-2" - : "grid gap-4" - } - > - {data.medications.map((med) => { - const medicationSummary = medicationSummaryById.get(med.id); - return ( - - -
-
- - - {med.name} - -
- {med.streak > 0 && ( - - {t("insights.dayStreak", { count: med.streak })} - - )} -
-

{med.dose}

-
- -
-
- {t("insights.compliance7d")} - {med.compliance7}% -
- -
-
-
- {t("insights.compliance30d")} - {med.compliance30}% -
- -
-
- - {med.taken7}{" "} - {t("insights.taken")} - - - {med.skipped7}{" "} - {t("insights.skipped")} - - - {med.missed7}{" "} - {t("insights.missed")} - -
- - - - {medicationSummary ? ( -

- {medicationSummary} -

- ) : null} -
-
- ); - })} -
- ) : null} - - } - text={medicationComplianceStatus?.summary ?? null} - hasProvider={medicationComplianceStatus?.hasProvider ?? false} - cached={medicationComplianceStatus?.cached ?? false} - updatedAt={medicationComplianceStatus?.updatedAt ?? null} - loading={isMedicationComplianceStatusLoading} - /> -
- - {/* Section 7: BMI */} -
-
-

- {t("insights.bmiSectionTitle")} -

- - {t(`insights.generalStatusBadge.${bmiSectionStatus.level}`)} - -
- - {user?.heightCm ? ( - - ) : ( - // v1.4.15 phase-C5: explicit empty state when the user - // hasn't set a height yet — without it the BMI card looked - // broken. Plain variant so the dashed border doesn't double - // up inside the section card. - } - title={t("insights.bmiEmptyTitle")} - description={t("insights.bmiEmptyDescription")} - action={ - - } - /> - )} - - } - text={bmiStatus?.text ?? null} - hasProvider={bmiStatus?.hasProvider ?? false} - cached={bmiStatus?.cached ?? false} - updatedAt={bmiStatus?.updatedAt ?? null} - loading={isBmiStatusLoading} - /> -
- - {/* v1.4.20 phase B2b — AI Coach drawer. - v1.4.23 H3 — `prefill` is a controlled prop now; the drawer - watches it via an in-render ref and resets the composer - when the parent changes which suggested-prompt chip is - active. The old `key={prefill}` weaponise-React-keys pattern - is gone (Sr-HIGH-4). */} ); } - -function MedicationComplianceCalendar({ - medicationId, -}: { - medicationId: string; -}) { - const { t } = useTranslations(); - const { data, isLoading } = useQuery({ - queryKey: ["compliance-chart-inline", medicationId], - queryFn: async () => { - const res = await fetch(`/api/medications/${medicationId}/compliance`); - if (!res.ok) return null; - const json = await res.json(); - return json.data as MedicationComplianceDailyResponse; - }, - enabled: !!medicationId, - staleTime: 60 * 1000, - }); - - if (isLoading) { - return ( -
- -
- ); - } - - if (!data?.dailyCompliance) { - return ( -
- {t("insights.complianceNoData")} -
- ); - } - - return ( -
- -
- ); -} - -// ── Section Navigation ─────────────────────────────────────────────────────── -// v1.4.25 W3 — the legacy inline `InsightsSectionNav` was promoted to a -// dedicated component at `src/components/insights/insights-tab-strip.tsx` -// so the regenerate affordance could share the sticky strip with the -// metric pills. The page just mounts `` near the top -// of the layout. diff --git a/src/components/insights/__tests__/trends-row.test.tsx b/src/components/insights/__tests__/trends-row.test.tsx index 46660608a..02900a291 100644 --- a/src/components/insights/__tests__/trends-row.test.tsx +++ b/src/components/insights/__tests__/trends-row.test.tsx @@ -1,5 +1,6 @@ import { describe, it, expect, vi } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { I18nProvider } from "@/lib/i18n/context"; /** @@ -10,6 +11,12 @@ import { I18nProvider } from "@/lib/i18n/context"; * snapshots show the loading skeleton — that's still enough to verify * the row's layout chrome (3-up grid wrapper + per-metric annotation * slots). + * + * v1.4.25 W7 added a `useAuth()` call inside `` to plumb + * `userTimezone` through to the dynamically-imported chart components, + * so the test wraps every render in a TanStack-Query provider — the + * stub charts never trigger an actual fetch but `useAuth` requires a + * client to mount. */ vi.mock("next/dynamic", () => ({ @@ -25,8 +32,13 @@ vi.mock("next/dynamic", () => ({ import { TrendsRow } from "../trends-row"; function render(node: React.ReactNode, locale: "en" | "de" = "en") { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); return renderToStaticMarkup( - {node}, + + {node} + , ); } diff --git a/src/lib/mood/__tests__/date-key.test.ts b/src/lib/mood/__tests__/date-key.test.ts new file mode 100644 index 000000000..7b23db63d --- /dev/null +++ b/src/lib/mood/__tests__/date-key.test.ts @@ -0,0 +1,101 @@ +/** + * v1.4.25 W7b — date-key contract for MoodEntry rows. + * + * Pin both halves of Decision A: + * + * - Legacy rows (tz = null) are read as Europe/Berlin → continue to + * produce the same day-key the v1.4.24 write path would have. + * + * - New rows with explicit non-Berlin tz produce a different + * day-key in line with the user's own clock — the regression we + * are fixing for non-Berlin users. + */ +import { describe, expect, it } from "vitest"; +import { moodDateKey, effectiveMoodTz, DEFAULT_TIMEZONE } from "../date-key"; + +describe("moodDateKey", () => { + it("formats a UTC instant as a Europe/Berlin YYYY-MM-DD by default", () => { + // 2026-05-14T21:30:00Z = 23:30 in Berlin (CEST, UTC+2) + expect(moodDateKey(new Date("2026-05-14T21:30:00Z"), "Europe/Berlin")).toBe( + "2026-05-14", + ); + }); + + it("buckets a 23:50 Pacific/Auckland reading to the Auckland day", () => { + // 2026-05-14T11:50:00Z = 23:50 Auckland (NZST, UTC+12) ; still + // 13:50 Berlin (CEST, UTC+2). The Auckland bucket and the Berlin + // bucket agree here — pick a UTC instant where they diverge. + expect( + moodDateKey(new Date("2026-05-14T11:50:00Z"), "Pacific/Auckland"), + ).toBe("2026-05-14"); + expect(moodDateKey(new Date("2026-05-14T11:50:00Z"), "Europe/Berlin")).toBe( + "2026-05-14", + ); + + // 2026-05-14T13:00:00Z = 01:00 next day Auckland, still + // 15:00 same day Berlin. Pin the divergence. + expect( + moodDateKey(new Date("2026-05-14T13:00:00Z"), "Pacific/Auckland"), + ).toBe("2026-05-15"); + expect(moodDateKey(new Date("2026-05-14T13:00:00Z"), "Europe/Berlin")).toBe( + "2026-05-14", + ); + }); + + it("falls back to Europe/Berlin for empty or undefined tz", () => { + expect(moodDateKey(new Date("2026-05-14T22:00:00Z"), "")).toBe( + "2026-05-15", + ); + expect( + moodDateKey(new Date("2026-05-14T22:00:00Z"), undefined as unknown as string), + ).toBe("2026-05-15"); + }); +}); + +describe("effectiveMoodTz", () => { + it("returns the row's explicit tz when set", () => { + expect(effectiveMoodTz({ tz: "Pacific/Auckland" })).toBe( + "Pacific/Auckland", + ); + }); + + it("returns Europe/Berlin for legacy rows (tz = null)", () => { + expect(effectiveMoodTz({ tz: null })).toBe("Europe/Berlin"); + expect(effectiveMoodTz({ tz: undefined })).toBe("Europe/Berlin"); + expect(effectiveMoodTz({ tz: "" })).toBe("Europe/Berlin"); + }); + + it("exposes the same fallback constant the writer uses", () => { + expect(DEFAULT_TIMEZONE).toBe("Europe/Berlin"); + }); +}); + +describe("read-path interpretation of legacy vs new rows", () => { + // Build a row's day-key the way the route writes it: write the + // instant `moodLoggedAt`, anchored to the row's resolved tz. + function bucketFor(row: { + moodLoggedAt: Date; + tz: string | null; + }): string { + return moodDateKey(row.moodLoggedAt, effectiveMoodTz(row)); + } + + it("legacy null-tz row buckets the same as a Europe/Berlin row at the same instant", () => { + const instant = new Date("2026-05-14T22:00:00Z"); + const legacy = bucketFor({ moodLoggedAt: instant, tz: null }); + const explicitBerlin = bucketFor({ + moodLoggedAt: instant, + tz: "Europe/Berlin", + }); + expect(legacy).toBe(explicitBerlin); + expect(legacy).toBe("2026-05-15"); + }); + + it("new Pacific/Auckland row buckets differently from a legacy row at the same instant", () => { + const instant = new Date("2026-05-14T13:00:00Z"); + const legacy = bucketFor({ moodLoggedAt: instant, tz: null }); + const akl = bucketFor({ moodLoggedAt: instant, tz: "Pacific/Auckland" }); + expect(legacy).toBe("2026-05-14"); + expect(akl).toBe("2026-05-15"); + }); +}); diff --git a/src/lib/mood/date-key.ts b/src/lib/mood/date-key.ts new file mode 100644 index 000000000..ad2ae64dc --- /dev/null +++ b/src/lib/mood/date-key.ts @@ -0,0 +1,57 @@ +/** + * v1.4.25 W7b — per-row timezone attribution for MoodEntry rows. + * + * Background. Up to v1.4.24 the mood-entries POST handler computed + * `MoodEntry.date` (YYYY-MM-DD) by formatting `moodLoggedAt` in + * Europe/Berlin regardless of where the user actually was. That is + * correct for users in DE, wrong for everyone else: a 23:45 NZST + * reading would store `date` as the Berlin day, one day earlier than + * the user's own "today". + * + * Decision A (proposal §7): forward-only fix. + * + * - Schema. Add nullable `MoodEntry.tz` column (migration 0044). + * + * - Writes. Every new row captures the user's current + * `displayTimezone`, stores it on the row's `tz` column, and + * computes `date` using THAT zone. + * + * - Reads. Rows with `tz === null` (legacy) are interpreted as if + * `tz === "Europe/Berlin"`, matching the implicit write-time + * assumption. Rows with explicit `tz` are interpreted in their + * stored zone. No backfill is required because the read-side + * interpretation rebuilds the historical bucketing without + * touching the data. + * + * The helpers below are the single source of truth for both the write + * path (`moodDateKey`) and any read path that needs to re-interpret a + * legacy date string (`effectiveMoodTz`). + */ + +export const DEFAULT_TIMEZONE = "Europe/Berlin"; + +/** + * Build the `YYYY-MM-DD` day-key for a mood entry given the user's + * timezone at write time. Used by `POST /api/mood-entries` and the + * `PUT /api/mood-entries/:id` route when `moodLoggedAt` changes. + * + * The Swedish locale ("sv-SE") is the historical choice for ISO 8601 + * day output via `Intl.DateTimeFormat`; we keep it so the new path + * produces byte-identical strings to the legacy `toBerlinDate()` for + * Europe/Berlin users (the only path tested under the v1.4.24 suite). + */ +export function moodDateKey(date: Date, tz: string): string { + const safe = tz && tz.length > 0 ? tz : DEFAULT_TIMEZONE; + return new Intl.DateTimeFormat("sv-SE", { timeZone: safe }).format(date); +} + +/** + * Resolve the effective timezone for a MoodEntry row. Read-path + * helpers (e.g. analytics aggregation by mood-day, export) call this + * to know which zone the row's `date` string is anchored to. A + * non-null `tz` column takes precedence; legacy rows fall back to + * Europe/Berlin. + */ +export function effectiveMoodTz(row: { tz: string | null | undefined }): string { + return row.tz && row.tz.length > 0 ? row.tz : DEFAULT_TIMEZONE; +}