diff --git a/CHANGELOG.md b/CHANGELOG.md index ec5381fa7..c7b5a6214 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## [Unreleased] +## [1.27.20] — 2026-07-07 — Coach and cycle toggle from the modules hub + +### Changed + +- The Coach and cycle-tracking rows under Settings → Modules now carry a real on/off switch, like every other module, instead of only a "manage in …" link. The switch drives the same underlying setting the dedicated page does, and a "manage" link stays beside it for the settings those pages carry beyond on/off. When the operator has turned a module off server-wide, its switch shows as disabled with a note rather than a control that can't take effect. + ## [1.27.19] — 2026-07-07 — Vault page polish ### Changed diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 60486b9ce..3a629e6a0 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.27.19 + version: 1.27.20 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/package.json b/package.json index c754ad4ad..9f6f481ae 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.27.19", + "version": "1.27.20", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/public/sw.js b/public/sw.js index 53fe8ffb5..c4b0b4b70 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.27.19"; + /* @sw-version-fallback */ "v1.27.20"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` diff --git a/src/components/settings/__tests__/modules-section.test.tsx b/src/components/settings/__tests__/modules-section.test.tsx index db37c4881..f1b7a97ab 100644 --- a/src/components/settings/__tests__/modules-section.test.tsx +++ b/src/components/settings/__tests__/modules-section.test.tsx @@ -5,7 +5,10 @@ * to the resolved module map (`useAuth().user.modules`), plus a read-only * "always on" group for the four core domains. Flipping a toggle PATCHes * `/api/auth/me/modules` (a DISABLED allowlist) and invalidates the - * `authMe()` query so nav / pills / tiles re-gate live. + * `authMe()` query so nav / pills / tiles re-gate live. The two delegated + * modules (coach, cycle) render a live switch too, but their flip drives the + * canonical column (`disableCoach` / `cycleTrackingEnabled`) via the existing + * per-column endpoints — never the module-allowlist the gate ignores for them. * * Test strategy mirrors `advanced-research-mode.test.tsx`: mock * `@tanstack/react-query` so the rendered tree executes under SSR @@ -20,18 +23,14 @@ import { renderToStaticMarkup } from "react-dom/server"; import { I18nProvider } from "@/lib/i18n/context"; import type { AuthUser } from "@/hooks/use-auth"; import { queryKeys } from "@/lib/query-keys"; -import { - MODULE_KEYS, - isCodeDisabledModule, - moduleDelegatesTo, -} from "@/lib/modules/registry"; +import { MODULE_KEYS, isCodeDisabledModule } from "@/lib/modules/registry"; // Capture the latest useMutation config + a shared mutate spy so we can // invoke `mutationFn` / `onSuccess` by hand (the SSR pass can't fire a // real DOM click). type MutationConfig = { mutationFn?: (vars: unknown) => Promise; - onSuccess?: (data?: unknown) => void; + onSuccess?: (data?: unknown, variables?: unknown) => void; onError?: (err?: unknown) => void; }; let lastMutation: MutationConfig | null = null; @@ -57,7 +56,28 @@ vi.mock("@/hooks/use-auth", async () => { return { ...actual, useAuth: () => authSpy() }; }); -function buildUser(modules: AuthUser["modules"]): AuthUser { +// The hub now renders a live coach switch, so it reads the operator assistant +// flag matrix (`flags.coach`) to decide whether the operator killed Coach +// server-wide. Mock the hook directly so the SSR pass doesn't need a +// ``; default all-on. +import type { AssistantFlagSet } from "@/hooks/use-feature-flags"; +const ALL_ON_FLAGS: AssistantFlagSet = { + enabled: true, + coach: true, + briefing: true, + insightStatus: true, + correlations: true, + healthScoreExplainer: true, +}; +const flagsSpy = vi.fn<() => AssistantFlagSet>(() => ALL_ON_FLAGS); +vi.mock("@/hooks/use-feature-flags", () => ({ + useFeatureFlags: () => flagsSpy(), +})); + +function buildUser( + modules: AuthUser["modules"], + overrides: Partial = {}, +): AuthUser { return { id: "user-1", username: "testuser", @@ -82,6 +102,8 @@ function buildUser(modules: AuthUser["modules"]): AuthUser { insuranceNumber: null, cycleTrackingEnabled: false, modules, + moduleAvailability: {}, + ...overrides, }; } @@ -103,6 +125,7 @@ beforeEach(() => { user: buildUser({}), isAuthenticated: true, })); + flagsSpy.mockImplementation(() => ALL_ON_FLAGS); }); afterEach(() => { @@ -110,7 +133,7 @@ afterEach(() => { }); describe("", () => { - it("renders a Switch for every non-delegated toggleable module and a managed deep-link for delegated ones", () => { + it("renders a live Switch for every toggleable module, including the delegated ones", () => { // v1.18.6 (W9) — the "Always on" core-domains card was removed (a domain // that can't be turned off doesn't need listing), so this section now // only renders the toggleable modules. @@ -122,15 +145,9 @@ describe("", () => { expect(html, `code-disabled has no switch ${key}`).not.toContain( `id="module-toggle-${key}"`, ); - } else if (moduleDelegatesTo(key) !== undefined) { - // Delegated modules (cycle/coach) are owned by their real control - // elsewhere — they render as a read-only "manage in X" deep-link, - // never a live Switch that would write an inert disabled-allowlist - // entry the gate ignores. - expect(html, `delegated has no switch ${key}`).not.toContain( - `id="module-toggle-${key}"`, - ); } else { + // Delegated modules (coach, cycle) now render a real Switch too — it + // drives their canonical column — alongside a "manage" deep-link. expect(html, `toggleable switch ${key}`).toContain( `id="module-toggle-${key}"`, ); @@ -138,6 +155,13 @@ describe("", () => { } }); + it("keeps a manage deep-link beside the delegated coach + cycle switches", () => { + const html = render(); + // Coach → Coach settings; cycle → the Account cycle-tracking card. + expect(html).toContain('href="/settings/coach"'); + expect(html).toContain('href="/settings/account#cycle-tracking"'); + }); + it("no longer renders the 'Always on' core-domains card", () => { // v1.18.6 (W9) — the read-only core card (weight / BP / pulse) was // removed; the core domains are always active regardless. @@ -199,10 +223,141 @@ describe("", () => { render(); expect(lastMutation?.onSuccess).toBeTypeOf("function"); - lastMutation!.onSuccess!(); + // onSuccess reads `variables.key` to decide the extra evictions; a + // non-delegated key only touches authMe. + lastMutation!.onSuccess!(undefined, { key: "glucose", enabled: false }); expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: queryKeys.authMe(), }); }); + + describe("delegated coach row", () => { + it("reflects `!disableCoach`: checked when Coach is active, unchecked when hidden", () => { + authSpy.mockImplementation(() => ({ + user: buildUser({}, { disableCoach: false }), + isAuthenticated: true, + })); + let coachTag = render().match( + /]*id="module-toggle-coach"[^>]*>/, + )?.[0]; + expect(coachTag).toMatch(/data-state="checked"/); + + authSpy.mockImplementation(() => ({ + user: buildUser({}, { disableCoach: true }), + isAuthenticated: true, + })); + coachTag = render().match( + /]*id="module-toggle-coach"[^>]*>/, + )?.[0]; + expect(coachTag).toMatch(/data-state="unchecked"/); + }); + + it("writes the inverted `disableCoach` to the canonical endpoint", async () => { + render(); + const fetchSpy = vi.fn( + async () => + new Response(JSON.stringify({ data: { disableCoach: true } }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + vi.stubGlobal("fetch", fetchSpy); + + // Turning the hub switch OFF (enabled:false) writes disableCoach:true. + await lastMutation!.mutationFn!({ key: "coach", enabled: false }); + + const [url, init] = fetchSpy.mock.calls[0] as unknown as [ + string, + RequestInit, + ]; + expect(url).toContain("/api/auth/me/disable-coach"); + expect(init.method).toBe("PATCH"); + expect(JSON.parse(init.body as string)).toEqual({ disableCoach: true }); + }); + + it("disables the switch + shows a hint when the operator killed Coach", () => { + // Operator assistant master flag off → coach is unavailable server-wide. + flagsSpy.mockImplementation(() => ({ ...ALL_ON_FLAGS, coach: false })); + const html = render(); + const coachTag = html.match( + /]*id="module-toggle-coach"[^>]*>/, + )?.[0]; + expect(coachTag).toMatch(/disabled/); + expect(html).toContain("Disabled server-wide"); + }); + + it("also disables the switch when the module-availability blob kills Coach", () => { + authSpy.mockImplementation(() => ({ + user: buildUser({}, { moduleAvailability: { coach: false } }), + isAuthenticated: true, + })); + const coachTag = render().match( + /]*id="module-toggle-coach"[^>]*>/, + )?.[0]; + expect(coachTag).toMatch(/disabled/); + }); + }); + + describe("delegated cycle row", () => { + it("reflects the resolved `cycleTrackingEnabled`", () => { + authSpy.mockImplementation(() => ({ + user: buildUser({}, { cycleTrackingEnabled: true }), + isAuthenticated: true, + })); + const cycleTag = render().match( + /]*id="module-toggle-cycle"[^>]*>/, + )?.[0]; + expect(cycleTag).toMatch(/data-state="checked"/); + }); + + it("writes `{ enabled }` to the cycle-prefs endpoint", async () => { + render(); + const fetchSpy = vi.fn( + async () => + new Response(JSON.stringify({ data: { enabled: true } }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + vi.stubGlobal("fetch", fetchSpy); + + await lastMutation!.mutationFn!({ key: "cycle", enabled: true }); + + const [url, init] = fetchSpy.mock.calls[0] as unknown as [ + string, + RequestInit, + ]; + expect(url).toContain("/api/auth/me/cycle-prefs"); + expect(init.method).toBe("PATCH"); + expect(JSON.parse(init.body as string)).toEqual({ enabled: true }); + }); + + it("evicts cyclePrefs + cycle + authMe on a successful cycle toggle", () => { + render(); + lastMutation!.onSuccess!(undefined, { key: "cycle", enabled: true }); + expect(invalidateSpy).toHaveBeenCalledWith({ + queryKey: queryKeys.authMe(), + }); + expect(invalidateSpy).toHaveBeenCalledWith({ + queryKey: queryKeys.cyclePrefs(), + }); + expect(invalidateSpy).toHaveBeenCalledWith({ + queryKey: queryKeys.cycle(), + }); + }); + + it("disables the switch + shows a hint when the operator killed cycle", () => { + authSpy.mockImplementation(() => ({ + user: buildUser({}, { moduleAvailability: { cycle: false } }), + isAuthenticated: true, + })); + const html = render(); + const cycleTag = html.match( + /]*id="module-toggle-cycle"[^>]*>/, + )?.[0]; + expect(cycleTag).toMatch(/disabled/); + expect(html).toContain("Disabled server-wide"); + }); + }); }); diff --git a/src/components/settings/module-toggle-row.tsx b/src/components/settings/module-toggle-row.tsx index 3c252864a..912739990 100644 --- a/src/components/settings/module-toggle-row.tsx +++ b/src/components/settings/module-toggle-row.tsx @@ -9,18 +9,22 @@ * row is presentation-only — the parent `` owns the mutation * and cache invalidation, so this component stays a pure, easily-tested leaf. * - * Four modes: + * 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 + meds read as + * "always on" note, so the always-on measurement engine reads as * deliberately fixed rather than broken. - * - managed elsewhere (delegated modules cycle/coach): no Switch at all — - * the real on/off lives in another section, so the row renders a - * read-only deep-link ("Manage in X") rather than a dead toggle that - * no-ops and snaps back behind a misleading "saved" toast. - * - operator-disabled: the operator turned the module off server-wide, so - * the Switch is replaced by a read-only "disabled server-wide" note — - * a per-user toggle could not re-enable it anyway. + * - 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. @@ -49,20 +53,19 @@ export interface ModuleToggleRowProps { /** One-line "core / always on" note, only shown when `locked`. */ lockedNote?: string; /** - * Delegated modules (cycle/coach): the real control lives elsewhere. When - * set, the row renders a read-only deep-link instead of a Switch. - * `label` is the destination name; `href` the in-app link. + * 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. */ - managedAt?: { href: string; label: string }; - /** Localised "Manage in {section}" link text (managed-elsewhere rows). */ - manageLinkLabel?: string; + manageLink?: { href: string; label: string }; /** - * Operator turned this module off server-wide. Replaces the Switch with a - * read-only note; a per-user toggle cannot re-enable it. `operatorNote` - * is the localised "disabled server-wide" copy. + * 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. */ - operatorDisabled?: boolean; - operatorNote?: string; + 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). */ @@ -77,18 +80,16 @@ export function ModuleToggleRow({ enabled, locked = false, lockedNote, - managedAt, - manageLinkLabel, - operatorDisabled = false, - operatorNote, + manageLink, + disabledReason, pending = false, onToggle, }: ModuleToggleRowProps) { const inputId = `module-toggle-${moduleKey}`; - // A delegated module whose real control lives elsewhere, or an - // operator-disabled module: either way no live Switch — the per-user - // toggle would be a dead no-op. - const managed = managedAt != null; + // 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 (
@@ -97,44 +98,35 @@ export function ModuleToggleRow({ aria-hidden="true" />
- {managed ? ( -

{label}

- ) : ( - - )} +

{description}

{locked && lockedNote ? (

{lockedNote}

) : null} + {disabledReason ? ( +

{disabledReason}

+ ) : null} + {manageLink ? ( + + {manageLink.label} +
- {operatorDisabled ? ( - // Operator kill-switch: read-only note, no toggle. - - {operatorNote} - - ) : managed ? ( - // Delegated module: deep-link to the real control instead of a - // dead toggle. - - {manageLinkLabel} -
); } diff --git a/src/components/settings/modules-section.tsx b/src/components/settings/modules-section.tsx index 145219093..e5f735928 100644 --- a/src/components/settings/modules-section.tsx +++ b/src/components/settings/modules-section.tsx @@ -6,10 +6,21 @@ * 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 ``. 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`. + * icon, label, one-line description, and a live ``. 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 — @@ -50,6 +61,7 @@ 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 { queryKeys } from "@/lib/query-keys"; @@ -89,6 +101,7 @@ const MODULE_ICONS: Record = { export function ModulesSection() { const { t } = useTranslations(); const { user } = useAuth(); + const flags = useFeatureFlags(); const queryClient = useQueryClient(); const modules = user?.modules ?? {}; @@ -98,16 +111,36 @@ export function ModulesSection() { // 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. - // Delegated keys (cycle/coach) never reach here — they render as - // read-only deep-link rows, so the PATCH only ever carries a - // directly-owned key. return apiPatch("/api/auth/me/modules", { [vars.key]: vars.enabled }); }, - onSuccess: () => { + onSuccess: (_data, vars) => { // Re-gate every surface that reads the module map off /auth/me: nav, - // Insights pills, dashboard tiles, quick-add, search. + // 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: () => { @@ -136,16 +169,49 @@ export function ModulesSection() { .filter((key) => !isCodeDisabledModule(key)) .map((key) => { const def = MODULE_REGISTRY[key]; - // Default-on: a module is enabled unless explicitly `false`. - const enabled = modules[key] !== false; - // Delegated modules (cycle/coach) are owned elsewhere — never a - // live toggle here; deep-link to their real control instead. - const delegated = moduleDelegatesTo(key) !== undefined; - // Operator turned this module off server-wide: no per-user - // toggle can re-enable it, so show a read-only note. Delegated - // rows already deep-link, so this only matters for owned keys. - const operatorDisabled = - !delegated && moduleAvailability[key] === false; + 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 ( toggle.mutate({ key, enabled: next })} /> ); diff --git a/src/lib/feature-flags/__tests__/coach-cascade.test.tsx b/src/lib/feature-flags/__tests__/coach-cascade.test.tsx index cba211193..7f6f1c45c 100644 --- a/src/lib/feature-flags/__tests__/coach-cascade.test.tsx +++ b/src/lib/feature-flags/__tests__/coach-cascade.test.tsx @@ -296,6 +296,13 @@ describe("Coach disable cascade invariant", () => { // the nudges. Server-side cron, not a render path — the cascade // fixture cannot mount it, so the allowlist entry is the pin. "src/lib/jobs/coach-nudge.ts", + // The Modules hub renders a live Coach on/off switch. It reads + // `flags.coach` (the operator assistant master flag) so the switch goes + // disabled + "disabled server-wide" when the operator killed Coach — a + // per-user toggle cannot re-enable it. Distinct from the render-path + // gates above: here the flag drives the switch's disabled state, not a + // null-return. + "src/components/settings/modules-section.tsx", ]; /** diff --git a/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx b/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx index 3b259bace..3b277c0d4 100644 --- a/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx +++ b/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx @@ -335,6 +335,13 @@ describe("Coach per-user disableCoach invariant", () => { // second copy in `modulePreferencesJson`. Covered by the coach- // delegation cases in `src/lib/modules/__tests__/gate.test.ts`. "src/lib/modules/gate.ts", + // The Modules hub Coach row renders a live switch bound to the SAME + // `user.disableCoach` source of truth: `checked` is `!disableCoach` and a + // flip writes the inverted flag via `PATCH /api/auth/me/disable-coach`. + // Reads the flag off `useAuth().user` (no second copy). Covered by the + // delegated-coach cases in + // `src/components/settings/__tests__/modules-section.test.tsx`. + "src/components/settings/modules-section.tsx", ]; /** diff --git a/src/lib/modules/registry.ts b/src/lib/modules/registry.ts index a588307e7..479b174d5 100644 --- a/src/lib/modules/registry.ts +++ b/src/lib/modules/registry.ts @@ -79,10 +79,13 @@ export interface ModuleDefinition { */ optIn?: boolean; /** - * For delegated modules only: where the real on/off control lives, so the - * Modules hub can render a read-only "managed in X" row that deep-links to - * the canonical control instead of a dead toggle. `href` is the in-app - * link; `labelKey` names the destination ("Account", "Coach settings"). + * For delegated modules only: where the canonical settings surface lives. + * The Modules hub renders a LIVE switch for these keys (driving the real + * column — coach → `User.disableCoach`, cycle → `cycleTrackingEnabled`) and + * keeps this as a small "manage" deep-link beside the switch, because that + * surface carries more than on/off (Coach cadence/memory; cycle + * goal/predictions/lengths). `href` is the in-app link; `labelKey` names the + * destination ("Account", "Coach settings"). */ managedAt?: { href: string; labelKey: string }; } @@ -193,7 +196,8 @@ export const MODULE_REGISTRY: Readonly> = descriptionKey: "modules.cycle.description", category: "tracking", delegatesTo: "cycle", - // The real on/off lives in the Account section's cycle-tracking card. + // The hub switch drives `cycleTrackingEnabled`; the fuller cycle-tracking + // card (goal, predictions, cycle lengths) lives in the Account section. managedAt: { href: "/settings/account#cycle-tracking", labelKey: "settings.sections.account.title", @@ -253,7 +257,8 @@ export const MODULE_REGISTRY: Readonly> = descriptionKey: "modules.coach.description", category: "intelligence", delegatesTo: "coach", - // The real on/off lives in the dedicated Coach settings section. + // The hub switch drives `User.disableCoach`; the dedicated Coach settings + // section carries the rest (cadence, memory, activation copy). managedAt: { href: "/settings/coach", labelKey: "settings.sections.coach.title",