mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
The B8 comparison overlay shipped behind a Settings → Dashboard Select buried 3 clicks deep. The brief asked for "mobile-friendly comparison toggle (not a fragile hover-only interaction)" and the toggle that shipped failed that bar — flipping Vormonat / Vorjahr meant a 5-step round-trip every time. New `<CompareToggle>` segmented control (None / Vormonat / Vorjahr) sits next to the dashboard greeting and on the insights page hero. Persists via the same `/api/dashboard/widgets` PUT the Settings section already uses, with optimistic update + cache invalidation so the chart overlays + tile callouts respond instantly. 44 px (min-h-11) tap targets, focus-visible ring, role=group + aria-label for screen readers, EN+DE i18n. 6 SSR tests pin segment count, persisted-active state, tap-target floor, EN+DE labels, and ARIA shape. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
118 lines
3.9 KiB
TypeScript
118 lines
3.9 KiB
TypeScript
"use client";
|
|
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { useTranslations } from "@/lib/i18n/context";
|
|
import { cn } from "@/lib/utils";
|
|
import {
|
|
COMPARISON_BASELINES,
|
|
resolveDashboardLayout,
|
|
type ComparisonBaseline,
|
|
type DashboardLayout,
|
|
} from "@/lib/dashboard-layout";
|
|
|
|
/**
|
|
* v1.4.16 phase D reconcile (CRITICAL C3) — on-surface comparison
|
|
* toggle. Replaces the Settings-only Select buried 3 clicks deep with
|
|
* a 3-segment control mounted next to the dashboard greeting and the
|
|
* insights page hero. Persists via the same `/api/dashboard/widgets`
|
|
* PUT the Settings page uses, so a flip on either surface updates the
|
|
* other on next refetch.
|
|
*
|
|
* Hit area: each segment is `min-h-11` (44 px) per WCAG 2.5.5; pressed
|
|
* state is the `default` button variant, idle is `outline`.
|
|
*/
|
|
export function CompareToggle({ className }: { className?: string }) {
|
|
const { t } = useTranslations();
|
|
const queryClient = useQueryClient();
|
|
|
|
const { data: layoutData } = useQuery({
|
|
queryKey: ["user", "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;
|
|
},
|
|
});
|
|
|
|
const layout = layoutData ? resolveDashboardLayout(layoutData) : null;
|
|
const value: ComparisonBaseline = layout?.comparisonBaseline ?? "none";
|
|
|
|
const mutation = useMutation({
|
|
mutationFn: async (next: ComparisonBaseline) => {
|
|
if (!layout) throw new Error("layout-not-loaded");
|
|
const body: DashboardLayout = { ...layout, comparisonBaseline: next };
|
|
const res = await fetch("/api/dashboard/widgets", {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
});
|
|
if (!res.ok) throw new Error("save-failed");
|
|
return (await res.json()).data as DashboardLayout;
|
|
},
|
|
onMutate: async (next) => {
|
|
await queryClient.cancelQueries({
|
|
queryKey: ["user", "dashboardWidgets"],
|
|
});
|
|
const prev = queryClient.getQueryData<DashboardLayout>([
|
|
"user",
|
|
"dashboardWidgets",
|
|
]);
|
|
if (prev) {
|
|
queryClient.setQueryData<DashboardLayout>(
|
|
["user", "dashboardWidgets"],
|
|
{ ...prev, comparisonBaseline: next },
|
|
);
|
|
}
|
|
return { prev };
|
|
},
|
|
onError: (_err, _next, ctx) => {
|
|
if (ctx?.prev) {
|
|
queryClient.setQueryData(["user", "dashboardWidgets"], ctx.prev);
|
|
}
|
|
},
|
|
onSettled: () => {
|
|
queryClient.invalidateQueries({ queryKey: ["user", "dashboardWidgets"] });
|
|
},
|
|
});
|
|
|
|
return (
|
|
<div
|
|
role="group"
|
|
aria-label={t("comparison.toggleLabel")}
|
|
data-slot="compare-toggle"
|
|
className={cn(
|
|
"border-border bg-background inline-flex flex-wrap items-center gap-0.5 rounded-md border p-0.5",
|
|
className,
|
|
)}
|
|
>
|
|
{COMPARISON_BASELINES.map((option) => {
|
|
const active = value === option;
|
|
return (
|
|
<button
|
|
key={option}
|
|
type="button"
|
|
data-slot={`compare-toggle-option-${option}`}
|
|
data-active={active ? "true" : undefined}
|
|
aria-pressed={active}
|
|
disabled={mutation.isPending || !layout}
|
|
onClick={() => {
|
|
if (!active) mutation.mutate(option);
|
|
}}
|
|
className={cn(
|
|
"min-h-11 rounded px-3 text-xs font-medium transition-colors",
|
|
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
|
|
active
|
|
? "bg-primary text-primary-foreground"
|
|
: "text-muted-foreground hover:text-foreground hover:bg-accent",
|
|
mutation.isPending && "cursor-progress",
|
|
)}
|
|
>
|
|
{t(`comparison.baseline.${option}`)}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|