mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
1 parent
a3217ff767
commit
dadb6e2d34
8 files changed
+99
-122
No files matched your search
@@ -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 ? (
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user