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",
};
}