Merge pull request #430 from MBombeck/release/v1.27.20

Release v1.27.20 — coach and cycle toggle from the modules hub
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-07-07 17:21:33 +02:00
commit 2fc7160ead
10 files changed
+346 -123

No files matched your search

+6
View File
@@ -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
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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/*`
@@ -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<unknown>;
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
// `<QueryClientProvider>`; 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> = {},
): 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("<ModulesSection>", () => {
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("<ModulesSection>", () => {
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("<ModulesSection>", () => {
}
});
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("<ModulesSection>", () => {
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(
/<button[^>]*id="module-toggle-coach"[^>]*>/,
)?.[0];
expect(coachTag).toMatch(/data-state="checked"/);
authSpy.mockImplementation(() => ({
user: buildUser({}, { disableCoach: true }),
isAuthenticated: true,
}));
coachTag = render().match(
/<button[^>]*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(
/<button[^>]*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(
/<button[^>]*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(
/<button[^>]*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(
/<button[^>]*id="module-toggle-cycle"[^>]*>/,
)?.[0];
expect(cycleTag).toMatch(/disabled/);
expect(html).toContain("Disabled server-wide");
});
});
});
+52 -60
View File
@@ -9,18 +9,22 @@
* row is presentation-only — the parent `<ModulesSection>` 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 (
<div className="flex items-start justify-between gap-4 py-3">
<div className="flex min-w-0 items-start gap-3">
@@ -97,44 +98,35 @@ export function ModuleToggleRow({
aria-hidden="true"
/>
<div className="min-w-0 space-y-0.5">
{managed ? (
<p className="text-sm font-medium">{label}</p>
) : (
<Label htmlFor={inputId} className="text-sm font-medium">
{label}
</Label>
)}
<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>
{operatorDisabled ? (
// Operator kill-switch: read-only note, no toggle.
<span className="text-muted-foreground mt-0.5 shrink-0 text-xs">
{operatorNote}
</span>
) : managed ? (
// Delegated module: deep-link to the real control instead of a
// dead toggle.
<Link
href={managedAt.href}
className="text-muted-foreground hover:text-foreground mt-0.5 inline-flex shrink-0 items-center gap-1 text-xs underline-offset-2 hover:underline"
>
{manageLinkLabel}
<ChevronRight className="h-3.5 w-3.5" aria-hidden="true" />
</Link>
) : (
<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}
/>
)}
<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>
);
}
+87 -36
View File
@@ -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 `<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`.
* 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 —
@@ -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<ModuleKey, LucideIcon> = {
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 (
<ModuleToggleRow
key={key}
@@ -155,23 +221,8 @@ export function ModulesSection() {
description={t(def.descriptionKey)}
enabled={enabled}
pending={toggle.isPending}
managedAt={
delegated && def.managedAt
? {
href: def.managedAt.href,
label: t(def.managedAt.labelKey),
}
: undefined
}
manageLinkLabel={
delegated && def.managedAt
? t("settings.sections.modules.manageIn", {
section: t(def.managedAt.labelKey),
})
: undefined
}
operatorDisabled={operatorDisabled}
operatorNote={t("settings.sections.modules.operatorDisabled")}
manageLink={manageLink}
disabledReason={disabledReason}
onToggle={(next) => toggle.mutate({ key, enabled: next })}
/>
);
@@ -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",
];
/**
@@ -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",
];
/**
+11 -6
View File
@@ -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<Record<ModuleKey, ModuleDefinition>> =
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<Record<ModuleKey, ModuleDefinition>> =
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",