mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
The Modules hub listed Coach and Cycle as read-only "manage in X" deep-links because their enabled-state is owned outside `modulePreferencesJson` (Coach in Coach settings, Cycle in the Account card). Give both a real switch like every other module, driving the SAME canonical state instead of a second copy: - Coach → `PATCH /api/auth/me/disable-coach` (`User.disableCoach`, inverted); the switch reads `!disableCoach`. - Cycle → `PATCH /api/auth/me/cycle-prefs` (`cycleTrackingEnabled`); the switch reads the resolved per-user gate. Precedence stays honest. A module the operator turned off server-wide can never be re-enabled by a per-user toggle, so the switch renders disabled with a "disabled server-wide" hint. For Coach the operator layer is BOTH the module-availability blob AND the assistant master flag (`flags.coach`); for Cycle it is the module-availability blob. Each delegated row keeps a small "manage" deep-link beside its switch, since the canonical surface carries more than on/off (Coach cadence/memory; cycle goal, predictions, lengths). Flipping either switch invalidates `authMe()` so every gated surface re-gates live; the cycle flip also evicts its own `cyclePrefs`/`cycle` reads, matching the canonical cycle card. Reuses the existing endpoints — no new route, no OpenAPI change.
133 lines
5.1 KiB
TypeScript
133 lines
5.1 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";
|
|
|
|
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 (
|
|
<div className="flex 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">
|
|
<Label htmlFor={inputId} className="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 ? (
|
|
<Link
|
|
href={manageLink.href}
|
|
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 text-xs underline-offset-2 hover:underline"
|
|
>
|
|
{manageLink.label}
|
|
<ChevronRight className="h-3.5 w-3.5" aria-hidden="true" />
|
|
</Link>
|
|
) : 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>
|
|
);
|
|
}
|