Files
HealthLog/src/components/admin/assistant-section.tsx
T
Marc-André Bombeck 5445bfc1f1 refactor(ui): give Settings and Admin one card rhythm and one page header
The two consoles used the same primitives and still read as two systems,
because everything without a primitive had drifted: the header-to-body
step existed in three competing techniques at once (`mt-4` on the first
body child, `space-y-4` on the card, `mb-4` on the header), the body
started either at the card edge or 28 px in, and both shells hand-rolled
the page title instead of calling `PageHeader`.

Move the decisions into the primitives so a call site cannot re-open them:

- `SettingsCard` is gap-based like `Card` (`flex flex-col gap-4`). The
  header-to-body step is the container's, not a margin somebody remembers.
  A dense card still overrides the gap on the card itself.
- The body starts at the card edge. `pl-7` is gone — off the spacing scale,
  and it mis-aligned action rows against their own forms wherever two
  neighbouring cards disagreed about it.
- The card header's description slot loses its `space-y-1`. That was the
  licence call sites used to stack explainer paragraphs into a muted
  `text-xs` slot; the slot holds one sentence.
- New `SettingsCardActions`: one action row, trailing edge, last thing in
  the card. `align="start"` spells the one exception, a card whose only
  actions are links out.
- New `StatTile` replaces four hand-rolled label/value boxes in Admin.
- `SectionHeading` moves from `insights/` to `ui/`. It is the heading above
  a group of cards, which is what Settings needs; Settings had been using a
  card header with no card, which reads as a card that failed to paint.

Both shells now render their heading through `PageHeader`, which gained
`headingAs="div"` for the second copy each shell paints (mobile above the
nav strip, desktop inside the grid) so only one is a real `<h1>`. The
page explainer is muted everywhere: the primitive said foreground, the
standards and every hand-rolled copy said muted, so pages disagreed with
each other. Medications, Vorsorge, the labs / custom-metric / illness
detail pages and the clinician view drop their hand-rolled title blocks
too, including the mid-word truncation on the medications subtitle.

Tests pin the parts that can be checked exactly: the card's gap, the
description slot's missing rhythm, the action row's two alignments, the
`<h1>`-free shells, and a sweep asserting `pl-7` stays gone.
2026-08-07 19:05:15 +02:00

159 lines
5.2 KiB
TypeScript

"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Sparkles } from "lucide-react";
import { toast } from "sonner";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { SettingsToggle } from "./_shared";
import { apiGet, apiPut } from "@/lib/api/api-fetch";
/**
* v1.4.31 — operator-side panel for the five assistant feature
* flags. The master toggle gates the whole assistant; four
* sub-toggles carve specific surfaces (Coach, Daily Briefing,
* per-metric status cards, correlation narration).
*
* UX:
* - Master toggle at the top. When off, the sub-toggles are
* visually greyed out (kept rendered so the operator can see
* which sub-flags are individually flipped before they unmute
* the master) but cannot be flipped via the disabled `<Switch>`.
* - Optimistic flip + server confirm via `useMutation`; failure
* surfaces a toast and reverts the in-flight toggle by
* re-invalidating the read query.
*/
interface AssistantFlagsResponse {
raw: {
assistantEnabled: boolean;
assistantCoachEnabled: boolean;
assistantBriefingEnabled: boolean;
assistantInsightStatusEnabled: boolean;
assistantCorrelationsEnabled: boolean;
};
resolved: {
enabled: boolean;
coach: boolean;
briefing: boolean;
insightStatus: boolean;
correlations: boolean;
};
}
function useAssistantFlags() {
return useQuery({
queryKey: queryKeys.adminAssistantFlags(),
queryFn: async () => {
return apiGet<AssistantFlagsResponse>(
"/api/admin/settings/assistant-flags",
);
},
});
}
function useUpdateAssistantFlags() {
const client = useQueryClient();
const { t } = useTranslations();
return useMutation({
mutationFn: async (
patch: Partial<AssistantFlagsResponse["raw"]>,
): Promise<AssistantFlagsResponse> => {
return apiPut<AssistantFlagsResponse>(
"/api/admin/settings/assistant-flags",
patch,
);
},
onSuccess: (data) => {
client.setQueryData(queryKeys.adminAssistantFlags(), data);
// Bust the runtime `/api/feature-flags` cache so the operator
// sees the toggled surface react within the same session.
client.invalidateQueries({ queryKey: queryKeys.featureFlags() });
toast.success(t("common.saved"));
},
onError: (err) => {
toast.error(
err instanceof Error && err.message
? err.message
: t("admin.settingsSaveError"),
);
client.invalidateQueries({
queryKey: queryKeys.adminAssistantFlags(),
});
},
});
}
export function AssistantSection() {
const { t } = useTranslations();
const { data } = useAssistantFlags();
const mutation = useUpdateAssistantFlags();
const raw = data?.raw;
const masterOn = raw?.assistantEnabled ?? true;
const disabledSubs = !masterOn || mutation.isPending;
return (
<SettingsCard>
<SettingsCardHeader
icon={Sparkles}
title={t("admin.assistant.title")}
description={t("admin.assistant.description")}
/>
<div className="space-y-4">
<SettingsToggle
label={t("admin.assistant.master.title")}
description={t("admin.assistant.master.description")}
checked={raw?.assistantEnabled ?? true}
onCheckedChange={(checked) =>
mutation.mutate({ assistantEnabled: checked })
}
disabled={mutation.isPending}
/>
<div className="border-border space-y-4 border-t pt-4">
<SettingsToggle
label={t("admin.assistant.coach.title")}
description={t("admin.assistant.coach.description")}
checked={raw?.assistantCoachEnabled ?? true}
onCheckedChange={(checked) =>
mutation.mutate({ assistantCoachEnabled: checked })
}
disabled={disabledSubs}
/>
<SettingsToggle
label={t("admin.assistant.briefing.title")}
description={t("admin.assistant.briefing.description")}
checked={raw?.assistantBriefingEnabled ?? true}
onCheckedChange={(checked) =>
mutation.mutate({ assistantBriefingEnabled: checked })
}
disabled={disabledSubs}
/>
<SettingsToggle
label={t("admin.assistant.insightStatus.title")}
description={t("admin.assistant.insightStatus.description")}
checked={raw?.assistantInsightStatusEnabled ?? true}
onCheckedChange={(checked) =>
mutation.mutate({ assistantInsightStatusEnabled: checked })
}
disabled={disabledSubs}
/>
<SettingsToggle
label={t("admin.assistant.correlations.title")}
description={t("admin.assistant.correlations.description")}
checked={raw?.assistantCorrelationsEnabled ?? true}
onCheckedChange={(checked) =>
mutation.mutate({ assistantCorrelationsEnabled: checked })
}
disabled={disabledSubs}
/>
</div>
</div>
</SettingsCard>
);
}