feat(comparison): comparison toggle persists baseline preference

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) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-10 02:16:41 +02:00
1 parent 43204cbb86
commit 775df8cfe9
6 files changed
+223

No files matched your search

+30
View File
@@ -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",
+30
View File
@@ -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.",
+5
View File
@@ -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 () => {
@@ -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<DashboardWidgetId, string> = {
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")}
</p>
{/* 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 && (
<div className="space-y-2">
<label
htmlFor="comparison-baseline"
className="text-foreground text-sm font-medium"
>
{t("comparison.toggleLabel")}
</label>
<Select
value={layout.comparisonBaseline ?? "none"}
onValueChange={(value) =>
setComparisonBaseline(value as ComparisonBaseline)
}
disabled={saveMutation.isPending}
>
<SelectTrigger
id="comparison-baseline"
className="min-h-11 w-full sm:w-72"
data-slot="comparison-baseline-trigger"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{COMPARISON_BASELINES.map((value) => (
<SelectItem
key={value}
value={value}
data-slot={`comparison-baseline-option-${value}`}
>
{t(`comparison.baseline.${value}`)}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs">
{t("comparison.toggleHint")}
</p>
</div>
)}
{isLoading || !layout ? (
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<Loader2 className="h-4 w-4 animate-spin" />
@@ -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");
});
});
+44
View File
@@ -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",
};
}