Files
HealthLog/src/components/settings/module-toggle-row.tsx
T
Marc-André Bombeck 71dbef1742 feat(settings): nine groups, module pages and one switch per state
Settings now reads as nine groups: Account, Tracking, Arrangement,
Notifications, Health profile, AI, Connections, Data and System. On a
phone one button names the current section and opens the grouped list,
replacing the strip of every section.

What moved, every old address still working:
- Sessions, trusted devices and security activity sit under Security;
  the privacy page anchors forward there.
- Notifications holds every reminder kind: the Coach nudges and the
  checkup reminder switches joined, and a card names the reminders set
  where their subject lives (dose times, cycle reminders).
- Arrangement holds order and view only. Mood tags, the labs biomarker
  catalog and on-device text recognition, and the injection sites live on
  module pages under /settings/modules/<id>, linked from each Modules row
  and from the module's own wrench. /settings/layout/mood redirects.
- The timezone saves on change in the Display card, with a one-tap offer
  of the device zone.
- Screening hints sit beside the life circumstances they come from.
- System gains Preview features with the Topics switch.

Duplicates: the Coach and MCP pages show their state and link to the one
switch in Modules (turning MCP off keeps tokens), the Coach overlay shows
the provider and model with a link to Settings > AI instead of a second
set of selects, and the privacy page drops its copy of the export links.

A guard walks every historical /settings address through the redirects
and every forwarded anchor to a page that renders.
2026-10-21 19:43:02 +02:00

141 lines
5.5 KiB
TypeScript

"use client";
/**
* `<ModuleToggleRow>` — one row in the Settings → Module ("Was du trackst")
* hub. v1.18.0.
*
* Renders a single module: a neutral icon, the i18n label + one-line
* description, and a `<Switch>` reflecting the resolved enabled-state. The
* row is presentation-only — the parent `<ModulesSection>` owns the mutation
* and cache invalidation, so this component stays a pure, easily-tested leaf.
*
* Every module — including the two delegated ones (coach, cycle) — now
* renders a real Switch, so the hub reads uniformly. Four shapes ride on top
* of that Switch:
* - toggleable: the Switch is live; flipping it calls `onToggle(next)`.
* - delegated (coach, cycle): the Switch is live and drives the canonical
* state (coach → `User.disableCoach`; cycle → `cycleTrackingEnabled`),
* plus a small `manageLink` deep-link beneath the description points at
* the fuller settings surface (Coach cadence, cycle goal/predictions).
* - locked (core domains): the Switch is checked + disabled with a short
* "always on" note, so the always-on measurement engine reads as
* deliberately fixed rather than broken.
* - disabled-with-reason: the operator turned the module off server-wide
* (or the module is otherwise not applicable). The Switch is disabled and
* a `disabledReason` hint renders beneath the description — an honest
* "you can't flip this and here's why", never a toggle that no-ops and
* snaps back behind a misleading "saved" toast.
*
* Palette stays neutral throughout — no alarming green-when-on /
* red-when-off tint.
*/
import Link from "next/link";
import { ChevronRight } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { InlineAction } from "@/components/ui/inline-action";
export interface ModuleToggleRowProps {
/** Stable key — used for the input id + the test query handle. */
moduleKey: string;
icon: LucideIcon;
label: string;
description: string;
/** Whether the Switch reads on. Core rows pass `true`. */
enabled: boolean;
/**
* When set the row is read-only: the Switch is locked checked and the
* `lockedNote` line renders beneath the description. Core domains use this.
*/
locked?: boolean;
/** One-line "core / always on" note, only shown when `locked`. */
lockedNote?: string;
/**
* Delegated modules (coach, cycle) keep a small deep-link to the fuller
* settings surface beside their live Switch — Coach cadence/memory, the
* cycle goal/prediction/length fields. `label` is the link text; `href`
* the in-app destination.
*/
manageLink?: { href: string; label: string };
/**
* When set the Switch is disabled and this line renders beneath the
* description as an honest "why you can't flip this" hint. Used when the
* operator turned the module off server-wide (a per-user toggle cannot
* re-enable it) or the module is otherwise not applicable to the account.
*/
disabledReason?: string;
/** Disable the Switch while a mutation is in flight (toggleable rows). */
pending?: boolean;
/** Fired with the next desired state on a user toggle (toggleable rows). */
onToggle?: (next: boolean) => void;
}
export function ModuleToggleRow({
moduleKey,
icon: Icon,
label,
description,
enabled,
locked = false,
lockedNote,
manageLink,
disabledReason,
pending = false,
onToggle,
}: ModuleToggleRowProps) {
const inputId = `module-toggle-${moduleKey}`;
// The Switch is inert when the domain is core (locked), when the operator
// turned it off / it is inapplicable (disabledReason), or while a mutation
// is in flight.
const interactive = !locked && disabledReason == null;
return (
// `id="module-<key>"` is the anchor other pages link to when they show a
// module's state and send the person here to change it.
<div
id={`module-${moduleKey}`}
className="flex scroll-mt-28 items-start justify-between gap-4 py-3"
>
<div className="flex min-w-0 items-start gap-3">
<Icon
className="text-muted-foreground mt-0.5 h-5 w-5 shrink-0"
aria-hidden="true"
/>
<div className="min-w-0 space-y-0.5">
{/* A row title beside a switch, not a field label: no colon, and
on the description's left edge (the primitive's `pl-1` would
inset it), matching the admin `SettingsToggle` rows. */}
<Label htmlFor={inputId} noColon className="pl-0 text-sm font-medium">
{label}
</Label>
<p className="text-muted-foreground text-xs">{description}</p>
{locked && lockedNote ? (
<p className="text-muted-foreground text-xs">{lockedNote}</p>
) : null}
{disabledReason ? (
<p className="text-muted-foreground text-xs">{disabledReason}</p>
) : null}
{manageLink ? (
<InlineAction asChild tone="muted">
<Link href={manageLink.href}>
{manageLink.label}
<ChevronRight className="size-3.5" aria-hidden="true" />
</Link>
</InlineAction>
) : null}
</div>
</div>
<Switch
id={inputId}
checked={locked ? true : enabled}
disabled={!interactive || pending}
onCheckedChange={interactive ? (v) => onToggle?.(v) : undefined}
className="mt-0.5 shrink-0"
aria-label={label}
/>
</div>
);
}