feat(settings): add the Module (what-you-track) hub

Surface a dedicated settings section for enabling and disabling the
secondary tracking domains (mood, sleep, glucose, workouts, recovery,
labs, achievements, coach, insights, doctor report, cycle). Each row
carries an icon, label, and one-line description with a switch bound to
the resolved module map on /auth/me; toggling PATCHes /api/auth/me/modules
and invalidates the auth/me query so the navigation, Insights pills, and
dashboard tiles re-gate live. Core domains (weight, blood pressure, pulse,
medications) render as a read-only always-on group. The entry sits right
below Account in the settings navigation.
This commit is contained in:
Marc-André Bombeck committed 2026-06-15 22:36:04 +02:00
1 parent dceac65ab8
commit f50f92902e
14 files changed
+749

No files matched your search

+2
View File
@@ -12,6 +12,7 @@ import { InsightsSection } from "@/components/settings/insights-section";
import { IntegrationsSection } from "@/components/settings/integrations-section";
import { LayoutSection } from "@/components/settings/layout-section";
import { MedicationsSection } from "@/components/settings/medications-section";
import { ModulesSection } from "@/components/settings/modules-section";
import { MoodSection } from "@/components/settings/mood-section";
import { NotificationsSection } from "@/components/settings/notifications-section";
import { RemindersSection } from "@/components/settings/reminders-section";
@@ -51,6 +52,7 @@ const SECTION_COMPONENTS: Record<
() => JSX.Element | null
> = {
account: AccountSection,
modules: ModulesSection,
about: AboutSection,
ai: AiSection,
integrations: IntegrationsSection,
@@ -63,8 +63,11 @@ describe("SETTINGS_SECTION_SLUGS", () => {
// Layout hub instead of four standalone nav entries.
// v1.17.1 — `reminders` is the one "Reminders & Notifications" home and
// sits right after `notifications`.
// v1.18.0 — `modules` ("Was du trackst") sits right after `account` as
// the single front door for enabling/disabling secondary domains.
expect([...SETTINGS_SECTION_SLUGS]).toEqual([
"account",
"modules",
"integrations",
"notifications",
"reminders",
@@ -0,0 +1,85 @@
"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.
*
* Two modes:
* - toggleable: the Switch is live; flipping it calls `onToggle(next)`.
* - locked (core domains): the Switch is checked + disabled with a short
* "always on" note, so the always-on measurement engine + meds read as
* deliberately fixed rather than broken. Palette stays neutral — no
* alarming green-when-on / red-when-off tint.
*/
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;
/** 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,
pending = false,
onToggle,
}: ModuleToggleRowProps) {
const inputId = `module-toggle-${moduleKey}`;
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/80 text-xs">{lockedNote}</p>
) : null}
</div>
</div>
<Switch
id={inputId}
checked={locked ? true : enabled}
disabled={locked || pending}
onCheckedChange={locked ? undefined : (v) => onToggle?.(v)}
className="mt-0.5 shrink-0"
aria-label={label}
/>
</div>
);
}
+175
View File
@@ -0,0 +1,175 @@
"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 `<Switch>`. Flipping a switch
* PATCHes `/api/auth/me/modules` (a DISABLED allowlist; the endpoint refuses
* core keys) and then invalidates the `authMe()` query so the nav, Insights
* pills, and dashboard tiles re-gate live off `useAuth().user.modules`.
*
* The four CORE domains (weight, blood pressure, pulse, medications) render
* as a separate read-only "always on" group — locked switches with a short
* note — so the always-on measurement engine + meds read as deliberately
* fixed, never disableable. Palette stays neutral throughout; this is a
* calm configuration surface, not an alarm panel.
*
* 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,
CalendarHeart,
Droplet,
Dumbbell,
FileText,
HeartPulse,
Lock,
MessageCircleHeart,
Moon,
Pill,
Scale,
Smile,
Sparkles,
TestTube,
type LucideIcon,
} from "lucide-react";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { ModuleToggleRow } from "@/components/settings/module-toggle-row";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { apiPatch } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import {
CORE_DOMAIN_KEYS,
MODULE_REGISTRY,
type CoreDomainKey,
type ModuleKey,
} from "@/lib/modules/registry";
/** 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,
achievements: Award,
coach: MessageCircleHeart,
insights: Sparkles,
doctorReport: FileText,
};
/** Neutral Lucide glyph per core (always-on) domain. */
const CORE_ICONS: Record<CoreDomainKey, LucideIcon> = {
weight: Scale,
bloodPressure: HeartPulse,
pulse: Activity,
medications: Pill,
};
export function ModulesSection() {
const { t } = useTranslations();
const { user } = useAuth();
const queryClient = useQueryClient();
const modules = user?.modules ?? {};
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 }) => {
// DISABLED allowlist: send only the single key the user flipped.
return apiPatch("/api/auth/me/modules", { [vars.key]: vars.enabled });
},
onSuccess: () => {
// Re-gate every surface that reads the module map off /auth/me: nav,
// Insights pills, dashboard tiles, quick-add, search.
void queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
toast.success(t("settings.sections.modules.saved"));
},
onError: () => {
toast.error(t("settings.sections.modules.error"));
},
});
return (
<section
aria-labelledby="settings-section-modules-title"
className="space-y-6"
>
<header className="space-y-1">
<h1 id="settings-section-modules-title" className="sr-only">
{t("settings.sections.modules.title")}
</h1>
<p className="text-muted-foreground text-sm">
{t("settings.sections.modules.description")}
</p>
</header>
{/* Toggleable modules */}
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
<SettingsCardHeader
icon={Blocks}
title={t("settings.sections.modules.toggleable.title")}
description={t("settings.sections.modules.toggleable.description")}
className="mb-2"
/>
<div className="divide-border divide-y pl-7">
{(Object.keys(MODULE_REGISTRY) as ModuleKey[]).map((key) => {
const def = MODULE_REGISTRY[key];
// Default-on: a module is enabled unless explicitly `false`.
const enabled = modules[key] !== false;
return (
<ModuleToggleRow
key={key}
moduleKey={key}
icon={MODULE_ICONS[key]}
label={t(def.labelKey)}
description={t(def.descriptionKey)}
enabled={enabled}
pending={toggle.isPending}
onToggle={(next) => toggle.mutate({ key, enabled: next })}
/>
);
})}
</div>
</div>
{/* Core domains — always on, read-only */}
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
<SettingsCardHeader
icon={Lock}
title={t("settings.sections.modules.core.title")}
description={t("settings.sections.modules.core.description")}
className="mb-2"
/>
<div className="divide-border divide-y pl-7">
{CORE_DOMAIN_KEYS.map((key) => (
<ModuleToggleRow
key={key}
moduleKey={key}
icon={CORE_ICONS[key]}
label={t(`modules.${key}.label`)}
description={t(`modules.${key}.description`)}
enabled
locked
lockedNote={t("settings.sections.modules.core.note")}
/>
))}
</div>
</div>
</section>
);
}
@@ -14,6 +14,7 @@
import {
AlarmClock,
Bell,
Blocks,
Download,
Info,
KeyRound,
@@ -36,6 +37,7 @@ import type { SettingsSectionSlug } from "./section-slugs";
const SLUG_ICON: Record<SettingsSectionSlug, LucideIcon> = {
account: User,
modules: Blocks,
integrations: Link2,
notifications: Bell,
reminders: AlarmClock,
+3
View File
@@ -34,6 +34,9 @@
// the hub is consolidation by linking.
export const SETTINGS_SECTION_SLUGS = [
"account",
// v1.18.0 — `modules` ("Was du trackst") sits right after account: the
// single front door for enabling/disabling secondary tracking domains.
"modules",
"integrations",
"notifications",
"reminders",
@@ -20,6 +20,7 @@ import { usePathname } from "next/navigation";
import {
AlarmClock,
Bell,
Blocks,
Download,
Info,
KeyRound,
@@ -80,6 +81,13 @@ interface SettingsSection {
*/
export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
{ slug: "account", titleKey: "settings.sections.account.title", icon: User },
// v1.18.0 — the "Was du trackst" hub. Sits right below Account: the one
// place to enable/disable the secondary tracking domains.
{
slug: "modules",
titleKey: "settings.sections.modules.title",
icon: Blocks,
},
{
slug: "integrations",
titleKey: "settings.sections.integrations.title",
+9
View File
@@ -21,6 +21,15 @@ export const settingsKeys = {
/** v1.8.5 — user-level injection-site preferences (global exclusion). */
injectionSitePrefs: () => ["settings", "injection-site-prefs"] as const,
/**
* v1.18.0 — the per-user module enable/disable map behind
* `GET/PATCH /api/auth/me/modules`. Used as the Module-hub mutation key;
* the resolved map itself rides on `authMe()` (the `/auth/me` payload),
* so the toggle mutation invalidates `authMe()` to re-gate the nav,
* Insights pills, and dashboard tiles in lockstep.
*/
modulesPrefs: () => ["settings", "modules"] as const,
apiVersion: () => ["api", "version"] as const,
publicVersion: () => ["public", "version"] as const,
/** v1.15.12 H1 — admin overview "update available" check against the