fix(settings): finish the D0-D8 settings/admin frame cleanup

- Drop the leading blurb paragraph from the Channels + Integrations
  connection panels so each body starts at its first card (D0 alignment).
- Correct three stale CORE/always-on comments: medications has been a
  fail-open toggleable module since D3; CORE is now weight/BP/pulse only.
- Remove the dead variant/isSubtab branch from SourcesSection — no callers
  after the D4 split; render the standalone header unconditionally.
- Render the admin AI-quality card frame + header once and branch only the
  body across loading/error/empty/data, matching the coach-feedback card.
- Rename the Coach activation card's section id from the legacy
  settings-ai-* namespace to settings-coach-activate-* after the S5 move.
- Gate the medication low-stock reminder card on the medications module so
  it hides when the account turns medications off, mirroring mood/coach.
This commit is contained in:
Marc-André Bombeck committed 2026-06-16 23:12:36 +02:00
1 parent a3217ff767
commit dadb6e2d34
8 files changed
+99 -122

No files matched your search

+43 -50
View File
@@ -65,64 +65,56 @@ export function AiQualitySection() {
},
});
if (query.isLoading) {
return (
<div className="bg-card border-border flex items-center gap-2 rounded-xl border p-4 sm:p-6">
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
<span className="text-muted-foreground text-sm">
{t("admin.aiQuality.loading")}
</span>
</div>
);
}
if (query.isError) {
return (
<div
role="alert"
className="text-destructive bg-destructive/10 border-destructive/30 rounded-xl border p-4 text-sm"
>
{t("admin.aiQuality.loadError")}
</div>
);
}
const summary = query.data;
if (!summary || summary.buckets.length === 0) {
return (
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
<SettingsCardHeader
icon={Sparkles}
title={t("admin.aiQuality.title")}
/>
<p className="text-muted-foreground mt-3 pl-7 text-sm">
{t("admin.aiQuality.empty")}
</p>
</div>
);
}
const summary = query.data ?? null;
const buckets = summary?.buckets ?? [];
const hasData = !!summary && buckets.length > 0;
// v1.18.1 — keep the outer card + header rendered across every query
// state (loading / error / empty / data) so the heading baseline stays at
// a constant Y-offset and the section never snaps downward when the data
// resolves. Mirrors <CoachFeedbackSection> (header outside the
// fetch-state branch).
return (
<div className="bg-card border-border space-y-4 rounded-xl border p-4 sm:p-6">
<SettingsCardHeader
icon={Sparkles}
title={t("admin.aiQuality.title")}
status={
<p className="text-muted-foreground text-xs">
{t("admin.aiQuality.windowLabel", {
days: String(summary.windowDays),
})}
{" · "}
{t("admin.aiQuality.generatedAtLabel")}{" "}
{fmt.dateTime(summary.generatedAt)}
</p>
hasData && summary ? (
<p className="text-muted-foreground text-xs">
{t("admin.aiQuality.windowLabel", {
days: String(summary.windowDays),
})}
{" · "}
{t("admin.aiQuality.generatedAtLabel")}{" "}
{fmt.dateTime(summary.generatedAt)}
</p>
) : null
}
/>
<div className="overflow-x-auto">
<table className="w-full text-sm" data-slot="ai-quality-table">
<thead>
{query.isLoading ? (
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
<span className="text-muted-foreground text-sm">
{t("admin.aiQuality.loading")}
</span>
</div>
) : query.isError ? (
<div
role="alert"
className="text-destructive bg-destructive/10 border-destructive/30 rounded-md border p-3 text-sm"
>
{t("admin.aiQuality.loadError")}
</div>
) : !hasData ? (
<p className="text-muted-foreground text-sm">
{t("admin.aiQuality.empty")}
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm" data-slot="ai-quality-table">
<thead>
<tr className="text-muted-foreground border-border border-b text-left text-xs tracking-wider uppercase">
<th className="py-2 pr-3 font-medium">
{t("admin.aiQuality.colSeverity")}
@@ -148,7 +140,7 @@ export function AiQualitySection() {
</tr>
</thead>
<tbody>
{summary.buckets.map((bucket, idx) => {
{buckets.map((bucket, idx) => {
const ratePct = Math.round(bucket.helpfulRate * 100);
return (
<tr
@@ -184,7 +176,8 @@ export function AiQualitySection() {
})}
</tbody>
</table>
</div>
</div>
)}
</div>
);
}
@@ -3,6 +3,10 @@
/**
* v1.4.47 W3 — Coach activation toggle card.
*
* v1.18.0 (S5) — moved out of Settings → AI into the dedicated Coach
* section; its `<section>` id namespace is `settings-coach-activate-*`
* accordingly. (The file path stays under `ai/` as pre-existing drift.)
*
* v1.18.1 (D7) — polarity flip. The card now reads "Activate Coach" with the
* Switch ON by default (the Coach is on for everyone unless the user opts
* out). The persisted state is still the single `User.disableCoach` field —
@@ -137,14 +141,14 @@ export function DisableCoachCard({
return (
<section
aria-labelledby="settings-ai-disable-coach-title"
aria-labelledby="settings-coach-activate-title"
data-testid="settings-disable-coach-card"
className="bg-card border-border rounded-xl border p-4 sm:p-6"
>
<SettingsCardHeader
icon={MessageCircleHeart}
title={t("settings.coach.activate.title")}
titleId="settings-ai-disable-coach-title"
titleId="settings-coach-activate-title"
description={t("settings.coach.activate.description")}
status={
<Switch
@@ -242,10 +242,6 @@ export function ConnectionsPanel() {
return (
<div className="space-y-6" data-slot="connections-panel">
<p className="text-muted-foreground text-sm">
{t("settings.sections.integrations.connections.description")}
</p>
<WithingsCard viewModel={withingsViewModel} />
<WhoopCard viewModel={whoopViewModel} />
<FitbitCard viewModel={fitbitViewModel} />
@@ -18,7 +18,6 @@
import { useQuery } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { NotificationStatusCard } from "@/components/settings/notification-status-card";
import { NtfyCard } from "@/components/settings/ntfy-card";
@@ -37,7 +36,6 @@ interface GlobalServiceAvailability {
}
export function NotificationChannelsPanel() {
const { t } = useTranslations();
const { isAuthenticated } = useAuth();
const { data: globalServices } = useQuery({
@@ -54,10 +52,6 @@ export function NotificationChannelsPanel() {
return (
<div className="space-y-6" data-slot="notification-channels-panel">
<p className="text-muted-foreground text-sm">
{t("settings.sections.integrations.channels.description")}
</p>
<NotificationStatusCard />
{/* v1.4.27 MB3 — `scroll-mt-28` keeps each channel card's anchor
@@ -19,9 +19,11 @@ import { useMedicationListLayout } from "@/lib/queries/use-medication-list-layou
/**
* v1.16.10 — the "Medikamente" settings section. v1.18.0 (S5) promoted it
* from a Layout-hub child to its own standalone nav entry (always shown —
* medications is a CORE domain) and gathered the medication-specific
* preferences here. Hosts three blocks:
* from a Layout-hub child to its own standalone nav entry and gathered the
* medication-specific preferences here. v1.18.1 (D3) made medications a
* toggleable fail-open module, so this nav entry hides when the account
* turns the module off (the medication data routes stay live). Hosts three
* blocks:
* 1. The list VIEW preference — cards vs table, written optimistically
* through the same `PUT /api/medications/layout` the page header
* toggle uses (the toggle component is shared).
@@ -17,9 +17,9 @@ import { MoodReminderCard } from "@/components/settings/mood-reminder-card";
*
* Each reminder-type card is shown ONLY when its module is enabled, read from
* the resolved `useAuth().user.modules` map (the same `/auth/me` map nav and
* Insights pills gate off). Mood → `mood`, Coach nudge → `coach`. Low-stock
* runway maps to medications, which fails open (a missing key reads as
* enabled) so a stale `/me` payload never blanks a card.
* Insights pills gate off). Mood → `mood`, Coach nudge → `coach`, low-stock
* runway → `medications` (a toggleable fail-open module since D3, so a missing
* key reads as enabled and a stale `/me` payload never blanks a card).
*
* v1.18.1 (D5) — the page is intentionally lean: the section blurb, the
* channels / inbox cross-links, and the embedded Vorsorge (preventive-care)
@@ -35,6 +35,7 @@ export function NotificationsSection() {
// so a stale `/me` payload without the module map keeps every card visible.
const moodEnabled = user?.modules?.mood !== false;
const coachEnabled = user?.modules?.coach !== false;
const medsEnabled = user?.modules?.medications !== false;
return (
<section
@@ -61,11 +62,13 @@ export function NotificationsSection() {
</div>
) : null}
{/* Medication low-stock runway — medications is a CORE domain, so this
is always shown. */}
<div id="low-stock" className="scroll-mt-28">
<LowStockCard isAuthenticated={isAuthenticated} />
</div>
{/* Medication low-stock runway — only when the medications module is
enabled (toggleable, fail-open, since D3). */}
{medsEnabled ? (
<div id="low-stock" className="scroll-mt-28">
<LowStockCard isAuthenticated={isAuthenticated} />
</div>
) : null}
{/* Proactive Coach nudge — only when the coach module is enabled. */}
{coachEnabled ? (
+22 -39
View File
@@ -111,31 +111,16 @@ const DEVICE_TYPE_LABEL_KEYS: Record<DeviceType, string> = {
unknown: "settings.sections.sources.deviceLabels.unknown",
};
export interface SourcesSectionProps {
/**
* `"standalone"` (the default) renders the full page header + the
* cross-link back to Integrations — the shape used while Sources had its
* own `/settings/sources` route. `"subtab"` (v1.18.0 S3) suppresses both:
* Sources now renders inside the Integrations → Sources sub-tab, so the
* page header is the parent section's and a cross-link back to the page
* that already contains it would be circular.
*/
variant?: "standalone" | "subtab";
}
/**
* `<SourcesSection>` — the per-metric source-priority + device-type ladders.
*
* v1.18.1 (D4) — Sources is a standalone `/settings/sources` left-side entry
* again (split back out of the Integrations sub-tabs). The default
* `variant="standalone"` renders the section header + the cross-link back to
* Integrationen; `variant="subtab"` is retained for any embedded use and
* suppresses the header.
* again (split back out of the Integrations sub-tabs). It renders its own
* section header + the cross-link back to Integrationen.
*/
export function SourcesSection({ variant = "standalone" }: SourcesSectionProps = {}) {
export function SourcesSection() {
const { t } = useTranslations();
const queryClient = useQueryClient();
const isSubtab = variant === "subtab";
const { data: remote, isLoading } = useQuery({
queryKey: queryKeys.sourcePriority(),
@@ -298,27 +283,25 @@ export function SourcesSection({ variant = "standalone" }: SourcesSectionProps =
aria-labelledby="settings-section-sources-title"
className="space-y-6"
>
{isSubtab ? null : (
// v1.18.1 (D0/D4) — standalone left-side entry again. The section
// blurb is dropped for consistent top alignment; the cross-link back
// to Integrationen (where the sources this ladder ranks are added /
// removed) stays as a quiet pointer.
<header>
<h1 id="settings-section-sources-title" className="sr-only">
{t("settings.sections.sources.title")}
</h1>
<p className="text-muted-foreground text-xs">
{t("settings.sections.sources.integrationsHint")}{" "}
<Link
href="/settings/integrations"
className="text-primary underline underline-offset-2"
data-slot="sources-integrations-cross-link"
>
{t("settings.sections.sources.integrationsHintLink")}
</Link>
</p>
</header>
)}
{/* v1.18.1 (D0/D4) — standalone left-side entry again. The section
blurb is dropped for consistent top alignment; the cross-link back
to Integrationen (where the sources this ladder ranks are added /
removed) stays as a quiet pointer. */}
<header>
<h1 id="settings-section-sources-title" className="sr-only">
{t("settings.sections.sources.title")}
</h1>
<p className="text-muted-foreground text-xs">
{t("settings.sections.sources.integrationsHint")}{" "}
<Link
href="/settings/integrations"
className="text-primary underline underline-offset-2"
data-slot="sources-integrations-cross-link"
>
{t("settings.sections.sources.integrationsHintLink")}
</Link>
</p>
</header>
<div className="bg-card border-border space-y-4 rounded-xl border p-4 sm:p-6">
<SettingsCardHeader
+12 -10
View File
@@ -10,11 +10,12 @@
*
* Two classes of domain:
*
* - CORE (always-on): weight, blood pressure, pulse, medications — the
* measurement engine + meds. These are NOT in `MODULE_KEYS` and have
* NO registry entry. They can never be disabled; the gate has no key
* to flip and the write endpoint refuses them. This is structural,
* not a runtime check future code can soften.
* - CORE (always-on): weight, blood pressure, pulse — the measurement
* engine. These are NOT in `MODULE_KEYS` and have NO registry entry.
* They can never be disabled; the gate has no key to flip and the
* write endpoint refuses them. This is structural, not a runtime check
* future code can soften. (Medications was CORE through v1.18.0; D3
* graduated it to a fail-open toggleable module below.)
*
* - TOGGLEABLE (the "secondary domains"): the maintainer-chosen scope
* below. Each carries a stable key, an i18n label + description key,
@@ -118,11 +119,12 @@ export const MODULE_KEYS = [
export type ModuleKey = (typeof MODULE_KEYS)[number];
/**
* CORE domains — the always-on measurement engine + medications. Listed
* here for documentation + the write-endpoint denylist; they have NO
* `ModuleDefinition` and can never appear as a toggle. A crafted
* `{ "weight": false }` blob is inert: `weight` is not a `ModuleKey`, so
* the gate never reads it and the PATCH validator rejects it.
* CORE domains — the always-on measurement engine (weight / blood pressure
* / pulse). Listed here for documentation + the write-endpoint denylist;
* they have NO `ModuleDefinition` and can never appear as a toggle. A
* crafted `{ "weight": false }` blob is inert: `weight` is not a
* `ModuleKey`, so the gate never reads it and the PATCH validator rejects
* it. (Medications is a toggleable module since D3 — see `MODULE_KEYS`.)
*/
export const CORE_DOMAIN_KEYS = [
"weight",