From 775df8cfe986440e45fb24b259b379de73ba708d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 10 May 2026 02:16:41 +0200 Subject: [PATCH] feat(comparison): comparison toggle persists baseline preference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a "Compare to" pulldown to the Settings → Dashboard section that lets the user pick None / Last month / Last year. The preference rides on the existing User.dashboardWidgetsJson blob — research §7 Q3 settled on the JSON-pivot path so v1.4.16 ships migration-free. The resolver defaults to "none" for legacy layouts and clamps unknown values back to "none", so a stale client cannot poison the dashboard with a baseline the renderer doesn't know how to draw. The PUT endpoint accepts the new optional comparisonBaseline field; pre-B8 clients keep PUTing without it and see no behavioural change. EN+DE i18n keys land for the toggle label, the three options, the "unavailable" fallback, and the chart caption — Vormonat / Vorjahr per Marc's brief. Co-Authored-By: Claude Opus 4.7 (1M context) --- messages/de.json | 30 +++++++++ messages/en.json | 30 +++++++++ src/app/api/dashboard/widgets/route.ts | 5 ++ .../settings/dashboard-layout-section.tsx | 63 +++++++++++++++++++ src/lib/__tests__/dashboard-layout.test.ts | 51 +++++++++++++++ src/lib/dashboard-layout.ts | 44 +++++++++++++ 6 files changed, 223 insertions(+) diff --git a/messages/de.json b/messages/de.json index 48567cded..2e80dbaad 100644 --- a/messages/de.json +++ b/messages/de.json @@ -648,6 +648,36 @@ "targetWeight": "Ziel", "deltaVsTarget": "{delta} vs. Ziel" }, + "comparison": { + "toggleLabel": "Vergleichen mit", + "toggleHint": "Blendet eine gedimmte Vorperioden-Linie in jedem Chart ein und zeigt ein Delta-Callout auf jeder Kachel.", + "baseline": { + "none": "Aus", + "lastMonth": "Vormonat", + "lastYear": "Vorjahr" + }, + "captionLastMonth": "vs. Vormonat", + "captionLastYear": "vs. Vorjahr", + "delta": { + "improved": "{delta} besser", + "worsened": "{delta} schlechter", + "stable": "unverändert" + }, + "deltaVs": { + "lastMonth": "{delta} vs. Vormonat", + "lastYear": "{delta} vs. Vorjahr" + }, + "tooltipPrior": "Vorperiode", + "tooltipDelta": "Δ {delta}", + "unavailable": { + "lastMonth": "Vergleich nicht verfügbar — noch keine Daten aus dem Vormonat", + "lastYear": "Vergleich nicht verfügbar — noch keine Daten aus dem Vorjahr" + }, + "insightsCallout": { + "lastMonth": "Vergleich zum Vormonat aktiv", + "lastYear": "Vergleich zum Vorjahr aktiv" + } + }, "insights": { "aiAnalysisTitle": "KI-Gesundheitsanalyse", "emptyTitle": "Noch nicht genug Daten", diff --git a/messages/en.json b/messages/en.json index e52d6d388..8260c4018 100644 --- a/messages/en.json +++ b/messages/en.json @@ -648,6 +648,36 @@ "targetWeight": "Target", "deltaVsTarget": "{delta} vs. target" }, + "comparison": { + "toggleLabel": "Compare to", + "toggleHint": "Adds a dimmed prior-period overlay to every chart and a delta callout to every tile.", + "baseline": { + "none": "None", + "lastMonth": "Last month", + "lastYear": "Last year" + }, + "captionLastMonth": "vs. last month", + "captionLastYear": "vs. last year", + "delta": { + "improved": "{delta} improved", + "worsened": "{delta} worsened", + "stable": "unchanged" + }, + "deltaVs": { + "lastMonth": "{delta} vs. last month", + "lastYear": "{delta} vs. last year" + }, + "tooltipPrior": "Prior period", + "tooltipDelta": "Δ {delta}", + "unavailable": { + "lastMonth": "Comparison unavailable — no data from last month yet", + "lastYear": "Comparison unavailable — no data from last year yet" + }, + "insightsCallout": { + "lastMonth": "Tracking against last month", + "lastYear": "Tracking against last year" + } + }, "insights": { "aiAnalysisTitle": "AI Health Analysis", "noAnalysisYet": "No analysis yet.", diff --git a/src/app/api/dashboard/widgets/route.ts b/src/app/api/dashboard/widgets/route.ts index 56f43351a..3a0821325 100644 --- a/src/app/api/dashboard/widgets/route.ts +++ b/src/app/api/dashboard/widgets/route.ts @@ -14,6 +14,7 @@ import { serializeDashboardLayout, DEFAULT_DASHBOARD_LAYOUT, DASHBOARD_WIDGET_IDS, + COMPARISON_BASELINES, type DashboardLayout, } from "@/lib/dashboard-layout"; import { Prisma } from "@/generated/prisma/client"; @@ -48,6 +49,10 @@ const layoutSchema = z.object({ ) .min(1) .max(20), + // v1.4.16 phase B8 — comparison baseline (Vormonat / Vorjahr) rides + // on the layout blob per research §7 Q3 (no Prisma migration). Optional + // so v1.4.15 clients that don't know the field can still PUT. + comparisonBaseline: z.enum(COMPARISON_BASELINES).optional(), }); export const GET = apiHandler(async () => { diff --git a/src/components/settings/dashboard-layout-section.tsx b/src/components/settings/dashboard-layout-section.tsx index 18cb37799..9fd963db6 100644 --- a/src/components/settings/dashboard-layout-section.tsx +++ b/src/components/settings/dashboard-layout-section.tsx @@ -16,8 +16,17 @@ import { useTranslations } from "@/lib/i18n/context"; import { type DashboardLayout, type DashboardWidgetId, + type ComparisonBaseline, + COMPARISON_BASELINES, DEFAULT_DASHBOARD_LAYOUT, } from "@/lib/dashboard-layout"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; const WIDGET_LABEL_KEYS: Record = { weight: "dashboard.weight", @@ -115,6 +124,16 @@ export function DashboardLayoutSection({ id }: { id: string }) { }); } + /** + * v1.4.16 phase B8 — comparison baseline picker. The toggle persists + * via the same `/api/dashboard/widgets` PUT the existing layout + * controls already use; saving rides through the same `Save` button. + */ + function setComparisonBaseline(value: ComparisonBaseline) { + if (!layout) return; + setDraft({ ...layout, comparisonBaseline: value }); + } + function move(widgetId: DashboardWidgetId, delta: -1 | 1) { if (!layout) return; const sorted = [...layout.widgets].sort((a, b) => a.order - b.order); @@ -157,6 +176,50 @@ export function DashboardLayoutSection({ id }: { id: string }) { {t("dashboard.customizeSubtitle")}

+ {/* v1.4.16 phase B8 — comparison baseline picker. Lives at the top + of the section because it changes how every chart + tile below + renders, not just one. Mobile-friendly: full-width Select with + a 44px tap target on touch viewports per the Wave-C polish. */} + {layout && ( +
+ + +

+ {t("comparison.toggleHint")} +

+
+ )} + {isLoading || !layout ? (
diff --git a/src/lib/__tests__/dashboard-layout.test.ts b/src/lib/__tests__/dashboard-layout.test.ts index 1455d7e54..8e4acc5dd 100644 --- a/src/lib/__tests__/dashboard-layout.test.ts +++ b/src/lib/__tests__/dashboard-layout.test.ts @@ -154,3 +154,54 @@ describe("DEFAULT_DASHBOARD_LAYOUT contract", () => { ); }); }); + +/** + * v1.4.16 phase B8 — comparison baseline persistence. + * + * The comparison toggle (Vormonat / Vorjahr) piggy-backs on the + * existing `User.dashboardWidgetsJson` blob per research §7 Q3 + * (no Prisma migration). The resolver must default to "none" + * for legacy layouts where the field is absent, and must round-trip + * the value through serialize → resolve so the UI's optimistic + * toggle persists across reloads. + */ +describe("resolveDashboardLayout() — comparisonBaseline (B8)", () => { + it("defaults to 'none' for legacy layouts (no field saved)", () => { + const legacy = { + version: 1, + widgets: [{ id: "weight", visible: true, order: 0 }], + }; + const resolved = resolveDashboardLayout(legacy); + expect(resolved.comparisonBaseline).toBe("none"); + }); + + it("respects an explicit comparisonBaseline value when present", () => { + const saved = { + version: 1, + widgets: [{ id: "weight", visible: true, order: 0 }], + comparisonBaseline: "lastMonth" as const, + }; + const resolved = resolveDashboardLayout(saved); + expect(resolved.comparisonBaseline).toBe("lastMonth"); + }); + + it("clamps unknown comparisonBaseline values back to 'none'", () => { + const saved = { + version: 1, + widgets: [{ id: "weight", visible: true, order: 0 }], + comparisonBaseline: "lastDecade", + }; + const resolved = resolveDashboardLayout(saved); + expect(resolved.comparisonBaseline).toBe("none"); + }); + + it("preserves comparisonBaseline through serialize → resolve round-trip", () => { + const layout: DashboardLayout = { + ...DEFAULT_DASHBOARD_LAYOUT, + comparisonBaseline: "lastYear", + }; + const serialized = serializeDashboardLayout(layout); + const resolved = resolveDashboardLayout(serialized); + expect(resolved.comparisonBaseline).toBe("lastYear"); + }); +}); diff --git a/src/lib/dashboard-layout.ts b/src/lib/dashboard-layout.ts index 0bb7040b4..28f9e8850 100644 --- a/src/lib/dashboard-layout.ts +++ b/src/lib/dashboard-layout.ts @@ -55,9 +55,43 @@ export interface DashboardWidgetConfig { order: number; } +/** + * v1.4.16 phase B8 — comparison baseline persisted alongside widgets. + * + * Research §7 Q3 settled on piggy-backing the comparison preference + * onto the existing `User.dashboardWidgetsJson` blob rather than + * carving a dedicated Prisma column — the field is ephemeral (a UI + * affordance, not an analytical attribute) and a JSON pivot here + * keeps the v1.4.16 release migration-free. + * + * Values: + * - "none" — comparison is off (default; pre-B8 behaviour). + * - "lastMonth" — overlay the matching window from 30 days earlier. + * - "lastYear" — overlay the matching window from 365 days earlier. + * + * The resolver defaults to "none" when the field is missing so legacy + * users see no behavioural change until they explicitly flip the + * toggle in Settings → Dashboard. Unknown values are clamped back to + * "none" so a stale client cannot poison the dashboard with a value + * the renderer doesn't know how to draw. + */ +export const COMPARISON_BASELINES = ["none", "lastMonth", "lastYear"] as const; +export type ComparisonBaseline = (typeof COMPARISON_BASELINES)[number]; + +export function isComparisonBaseline( + value: unknown, +): value is ComparisonBaseline { + return ( + typeof value === "string" && + (COMPARISON_BASELINES as readonly string[]).includes(value) + ); +} + export interface DashboardLayout { version: number; widgets: DashboardWidgetConfig[]; + /** v1.4.16 phase B8 — see `COMPARISON_BASELINES` doc. */ + comparisonBaseline?: ComparisonBaseline; } export const DASHBOARD_LAYOUT_VERSION = 1; @@ -129,6 +163,12 @@ export function resolveDashboardLayout(raw: unknown): DashboardLayout { return { version: DASHBOARD_LAYOUT_VERSION, widgets: [...normalized, ...appended].sort((a, b) => a.order - b.order), + // v1.4.16 phase B8 — clamp unknown / missing comparison values to + // "none" so the dashboard renders the pre-B8 behaviour for legacy + // layouts and refuses to act on a value it doesn't recognise. + comparisonBaseline: isComparisonBaseline(candidate.comparisonBaseline) + ? candidate.comparisonBaseline + : "none", }; } @@ -149,5 +189,9 @@ export function serializeDashboardLayout( typeof w.tileVisible === "boolean" ? w.tileVisible : w.visible, order: i, // normalize to 0-based dense order })), + // v1.4.16 phase B8 — persist the comparison baseline explicitly. + comparisonBaseline: isComparisonBaseline(layout.comparisonBaseline) + ? layout.comparisonBaseline + : "none", }; }