mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
1 parent
43204cbb86
commit
775df8cfe9
6 files changed
+223
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user