Files
HealthLog/src/components/settings/modules-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

278 lines
11 KiB
TypeScript

"use client";
/**
* `<ModulesSection>` — Settings → Module ("Was du trackst") hub. v1.18.0.
*
* The one place a user decides which secondary domains HealthLog surfaces.
* Each toggleable module (mood, sleep, glucose, workouts, recovery, labs,
* achievements, coach, insights, doctor report, cycle) gets a row with an
* icon, label, one-line description, and a live `<Switch>`. For most modules
* the switch PATCHes `/api/auth/me/modules` (a DISABLED allowlist; the
* endpoint refuses core keys). The two delegated modules drive their
* canonical column instead — coach → `PATCH /api/auth/me/disable-coach`
* (`User.disableCoach`, inverted), cycle → `PATCH /api/auth/me/cycle-prefs`
* (`cycleTrackingEnabled`) — so there is exactly one source of truth, and
* keep a small "manage" deep-link to the fuller settings surface beside the
* switch. Every path then invalidates `authMe()` (delegated cycle also evicts
* its own reads) so the nav, Insights pills, and dashboard tiles re-gate live
* off `useAuth().user.modules`.
*
* Operator precedence stays honest: a module the operator turned off
* server-wide (the module-availability blob, plus the assistant master flag
* `flags.coach` for the coach row) renders a disabled switch + a
* "disabled server-wide" hint — a per-user toggle could not re-enable it.
*
* The three CORE domains (weight, blood pressure, pulse) render as a
* separate read-only "always on" group — locked switches with a short note —
* so the always-on measurement engine reads as deliberately fixed, never
* disableable. Palette stays neutral throughout; this is a calm
* configuration surface, not an alarm panel. (v1.18.1 D3 — medications
* graduated to the toggleable group above.)
*
* State source is `useAuth().user.modules` (the resolved `/auth/me` map).
* A module is enabled unless its key is explicitly `false` — default-on.
*/
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
Activity,
Award,
Blocks,
Brain,
CalendarHeart,
Droplet,
CloudSun,
Dumbbell,
FileScan,
FileText,
MessageCircleHeart,
Leaf,
Moon,
Plug,
Pill,
Smile,
Sparkles,
TestTube,
Thermometer,
type LucideIcon,
} from "lucide-react";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { ModuleToggleRow } from "@/components/settings/module-toggle-row";
import { useAuth } from "@/hooks/use-auth";
import { useFeatureFlags } from "@/hooks/use-feature-flags";
import { useTranslations } from "@/lib/i18n/context";
import { apiPatch } from "@/lib/api/api-fetch";
import {
readUpdatedAtToken,
withBaseToken,
isConflict,
} from "@/lib/api/optimistic-token";
import { queryKeys } from "@/lib/query-keys";
import {
MODULE_REGISTRY,
isCodeDisabledModule,
moduleDelegatesTo,
type ModuleKey,
} from "@/lib/modules/registry";
/** Shape of the `/api/auth/me/modules` PATCH response (resolved map + token). */
type ModulesPatchResult = {
modules: Partial<Record<ModuleKey, boolean>>;
updatedAt?: string;
};
/** Neutral Lucide glyph per toggleable module. */
const MODULE_ICONS: Record<ModuleKey, LucideIcon> = {
cycle: CalendarHeart,
mood: Smile,
sleep: Moon,
glucose: Droplet,
workouts: Dumbbell,
recovery: Activity,
labs: TestTube,
illness: Thermometer,
achievements: Award,
coach: MessageCircleHeart,
insights: Sparkles,
// v1.18.1 (D3) — medications graduated from CORE to a toggleable module.
medications: Pill,
doctorReport: FileText,
// v1.25.0 — the environmental-context module (opt-in weather/daylight feed).
environment: CloudSun,
// v1.22.0 — the remote MCP endpoint (opt-in connectivity module).
mcp: Plug,
// v1.25.0 (W-DOCS-IN) — inbound clinical documents (opt-in).
inboundDocuments: FileScan,
// v1.25.0 — opt-in mental-health screeners (PHQ-9 / GAD-7).
mentalHealth: Brain,
// v1.28 — opt-in micronutrient-intake sync (Apple Health day totals).
nutrients: Leaf,
};
export function ModulesSection() {
const { t } = useTranslations();
const { user } = useAuth();
const flags = useFeatureFlags();
const queryClient = useQueryClient();
const modules = user?.modules ?? {};
const moduleAvailability = user?.moduleAvailability ?? {};
const toggle = useMutation({
// Factory-routed; the in-repo eslint rule forbids a bare literal here.
mutationKey: queryKeys.modulesPrefs(),
mutationFn: async (vars: { key: ModuleKey; enabled: boolean }) => {
// The delegated modules keep a single source of truth: their switch
// drives the canonical column, not the `modulePreferencesJson`
// allowlist (the gate ignores the blob for these two keys). Reuse the
// existing per-column endpoints rather than inventing a new one.
const delegate = moduleDelegatesTo(vars.key);
if (delegate === "coach") {
// The stored column is `disableCoach` — the inverse of "on".
return apiPatch("/api/auth/me/disable-coach", {
disableCoach: !vars.enabled,
});
}
if (delegate === "cycle") {
// `enabled` maps straight onto `cycleTrackingEnabled`.
return apiPatch("/api/auth/me/cycle-prefs", { enabled: vars.enabled });
}
// DISABLED allowlist: send only the single key the user flipped.
//
// v1.32.22 (R5b) — echo the optimistic-concurrency token so an
// interleaved modules PATCH (another tab, the iOS client) 409s instead of
// clobbering. The token rides on `User.updatedAt`; the module map itself
// rides on `/auth/me`, and no dedicated GET populates the token, so it is
// seeded from each write response below and read back here at mutate
// time. The first write of a session is tokenless — the server's
// backward-compatible unconditional arm.
const result = await apiPatch<ModulesPatchResult>(
"/api/auth/me/modules",
withBaseToken(
{ [vars.key]: vars.enabled },
readUpdatedAtToken(queryClient, queryKeys.modulesPrefs()),
),
);
queryClient.setQueryData<{ updatedAt?: string }>(
queryKeys.modulesPrefs(),
{ updatedAt: result.updatedAt },
);
return result;
},
onSuccess: (_data, vars) => {
// Re-gate every surface that reads the module map off /auth/me: nav,
// Insights pills, dashboard tiles, quick-add, search. The two delegated
// keys also feed dedicated settings surfaces, so evict their reads too
// (mirrors the canonical Coach / cycle cards' own invalidation).
void queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
if (moduleDelegatesTo(vars.key) === "cycle") {
void queryClient.invalidateQueries({
queryKey: queryKeys.cyclePrefs(),
});
void queryClient.invalidateQueries({ queryKey: queryKeys.cycle() });
}
toast.success(t("settings.sections.modules.saved"));
},
onError: (err) => {
// v1.32.22 (R5b) — instant-write disposition: a 409 means the module map
// advanced since this switch was based (another tab / the iOS client
// wrote in between). Drop the now-stale seeded token and refetch /auth/me
// so the switch snaps back to server truth, then nudge — nothing was
// clobbered. A retry re-seeds a fresh token from its own response.
if (isConflict(err)) {
queryClient.removeQueries({ queryKey: queryKeys.modulesPrefs() });
void queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
toast.message(t("common.conflictReloaded"));
return;
}
toast.error(t("settings.sections.modules.error"));
},
});
// v1.18.6 (W9) — the visible heading + subtitle now come from the shared
// `<SettingsSectionFrame>` (the former muted-`<p>` intro folded into the
// frame's standard subtitle). The "Module immer aktiv" core-domains card
// was removed: a domain that can't be turned off doesn't need to be listed.
return (
<SettingsCard>
{/* The "what this section does" hint rides the header's standard
description slot; the body follows at the settings-wide mt-4
header→content rhythm instead of the former mb-2/mb-3 one-offs. */}
<SettingsCardHeader
icon={Blocks}
title={t("settings.sections.modules.toggleable.title")}
description={t("settings.sections.modules.toggleable.description")}
/>
<div className="divide-border divide-y">
{(Object.keys(MODULE_REGISTRY) as ModuleKey[])
// Modules switched off in code (pending a rebuild) carry no live
// toggle — drop the row entirely so a user can't turn one on.
.filter((key) => !isCodeDisabledModule(key))
.map((key) => {
const def = MODULE_REGISTRY[key];
const delegate = moduleDelegatesTo(key);
// Enabled-state per key. Delegated modules read their canonical
// per-user state, NOT the `modulePreferencesJson` allowlist the
// gate ignores for them: coach ← `!disableCoach`, cycle ← the
// resolved `cycleTrackingEnabled`. Every other module is
// default-on unless explicitly `false`.
let enabled: boolean;
if (delegate === "coach") {
enabled = !(user?.disableCoach ?? false);
} else if (delegate === "cycle") {
enabled = user?.cycleTrackingEnabled ?? false;
} else {
enabled = modules[key] !== false;
}
// Operator precedence. A per-user toggle can never re-enable a
// module the operator turned off server-wide, so the switch goes
// disabled + hint. For coach the operator layer is BOTH the
// module-availability blob AND the assistant master flag
// (`flags.coach`, already master-composed); for cycle and the
// owned modules it is the module-availability blob alone.
const operatorAvailable =
delegate === "coach"
? moduleAvailability[key] !== false && flags.coach
: moduleAvailability[key] !== false;
const disabledReason = operatorAvailable
? undefined
: t("settings.sections.modules.operatorDisabled");
// Delegated modules carry more than on/off at their canonical
// surface (Coach cadence/memory; cycle goal/predictions/lengths),
// so keep a small deep-link to it beside the live switch.
const manageLink =
delegate !== undefined && def.managedAt
? {
href: def.managedAt.href,
label: t("settings.sections.modules.manageIn", {
section: t(def.managedAt.labelKey),
}),
}
: undefined;
return (
<ModuleToggleRow
key={key}
moduleKey={key}
icon={MODULE_ICONS[key]}
label={t(def.labelKey)}
description={t(def.descriptionKey)}
enabled={enabled}
pending={toggle.isPending}
manageLink={manageLink}
disabledReason={disabledReason}
onToggle={(next) => toggle.mutate({ key, enabled: next })}
/>
);
})}
</div>
</SettingsCard>
);
}