Files
HealthLog/src/components/comparison/compare-toggle.tsx
T
Marc-André Bombeck 3b480b9885 perf(i18n,shell): ship one locale and stop blocking the first paint
The client bundle carries only the active locale — resolved on the
server and handed to the provider with no hydration flash, halving the
dashboard route to under a megabyte parsed (288 KiB gzip). The auth
shell renders the app chrome immediately instead of a spinner while the
session resolves in parallel, the widgets request folds into the
snapshot it duplicated, and insight pills prefetch their sub-pages —
a first visit to a sub-page lands in about ninety milliseconds instead
of nearly a second.
2026-06-11 02:19:27 +02:00

112 lines
3.7 KiB
TypeScript

"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { queryKeys } from "@/lib/query-keys";
import {
COMPARISON_BASELINES,
resolveDashboardLayout,
type ComparisonBaseline,
type DashboardLayout,
} from "@/lib/dashboard-layout";
import { apiGet, apiPut } from "@/lib/api/api-fetch";
/**
* 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: queryKeys.dashboardWidgets(),
queryFn: async () => {
return apiGet<DashboardLayout>("/api/dashboard/widgets");
},
});
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 };
return apiPut<DashboardLayout>("/api/dashboard/widgets", body);
},
onMutate: async (next) => {
await queryClient.cancelQueries({
queryKey: queryKeys.dashboardWidgets(),
});
const prev = queryClient.getQueryData<DashboardLayout>(
queryKeys.dashboardWidgets(),
);
if (prev) {
queryClient.setQueryData<DashboardLayout>(
queryKeys.dashboardWidgets(),
{ ...prev, comparisonBaseline: next },
);
}
return { prev };
},
onError: (_err, _next, ctx) => {
if (ctx?.prev) {
queryClient.setQueryData(queryKeys.dashboardWidgets(), ctx.prev);
}
},
onSettled: () => {
queryClient.invalidateQueries({
queryKey: queryKeys.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>
);
}