From aa27708ebcdf51a6e3a247554c7a5e1190437713 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 15 Jun 2026 21:00:46 +0200 Subject: [PATCH] refactor(nav): move Coach to a standalone /coach page The Coach lived under /insights/coach, rendering inside the Insights layout's tab strip. It is the product's headline surface, not a metric sub-page, so it now lives at the top-level /coach in the standard page chrome without the Insights tab strip. The nav model points at /coach, every in-app link and the proactive-nudge deep link follow, and a 301 redirect keeps /insights/coach (bookmarks, cached PWA navigation, older push payloads) resolving. --- e2e/coach-guided-questions.spec.ts | 6 ++-- e2e/v1427-coach-launch.spec.ts | 10 +++--- next.config.ts | 31 +++++++++++++------ src/app/{insights => }/coach/page.tsx | 15 ++++----- .../__tests__/layout-coach-fab.test.tsx | 2 +- .../coach-panel/coach-conversation.tsx | 2 +- .../insights/coach-panel/coach-drawer.tsx | 4 +-- .../insights/insights-layout-shell.tsx | 26 ++++++---------- src/components/insights/layout-coach-fab.tsx | 4 +-- .../layout/__tests__/nav-model.test.ts | 21 ++++++------- .../layout/__tests__/sidebar-nav.test.tsx | 10 +++--- src/components/layout/auth-shell.tsx | 2 +- src/components/layout/nav-model.ts | 8 ++--- src/components/settings/about-me-section.tsx | 2 +- src/components/settings/coach-nudge-card.tsx | 2 +- .../settings/coach-prefs-section.tsx | 2 +- .../__tests__/coach-cascade.test.tsx | 3 +- .../__tests__/coach-user-disable.test.tsx | 3 +- src/lib/jobs/coach-nudge.ts | 4 +-- src/lib/notifications/senders/web-push.ts | 2 +- src/lib/notifications/types.ts | 2 +- 21 files changed, 82 insertions(+), 79 deletions(-) rename src/app/{insights => }/coach/page.tsx (82%) diff --git a/e2e/coach-guided-questions.spec.ts b/e2e/coach-guided-questions.spec.ts index 3d708b2ca..4d913274d 100644 --- a/e2e/coach-guided-questions.spec.ts +++ b/e2e/coach-guided-questions.spec.ts @@ -143,7 +143,7 @@ test.describe("Coach guided clarifying questions", () => { "flow logic is viewport-independent; desktop run suffices", ); - await page.goto("/insights/coach", { waitUntil: "domcontentloaded" }); + await page.goto("/coach", { waitUntil: "domcontentloaded" }); // 1. Entry card with the three choices. const offer = page.locator('[data-slot="coach-guided-offer"]'); @@ -197,7 +197,7 @@ test.describe("Coach guided clarifying questions", () => { "flow logic is viewport-independent; desktop run suffices", ); - await page.goto("/insights/coach", { waitUntil: "domcontentloaded" }); + await page.goto("/coach", { waitUntil: "domcontentloaded" }); const offer = page.locator('[data-slot="coach-guided-offer"]'); await expect(offer).toBeVisible({ timeout: 10_000 }); @@ -216,7 +216,7 @@ test.describe("Coach guided clarifying questions", () => { "flow logic is viewport-independent; desktop run suffices", ); - await page.goto("/insights/coach", { waitUntil: "domcontentloaded" }); + await page.goto("/coach", { waitUntil: "domcontentloaded" }); const offer = page.locator('[data-slot="coach-guided-offer"]'); await expect(offer).toBeVisible({ timeout: 10_000 }); diff --git a/e2e/v1427-coach-launch.spec.ts b/e2e/v1427-coach-launch.spec.ts index 62177edf9..9616b30b0 100644 --- a/e2e/v1427-coach-launch.spec.ts +++ b/e2e/v1427-coach-launch.spec.ts @@ -24,7 +24,7 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup"; * page underneath stays) and the dot clears. * * 4. The drawer's "Conversations" button no longer opens an in-panel - * tray; it hands off to the full-page route `/insights/coach`. + * tray; it hands off to the full-page route `/coach`. * * The three sub-pages covered (blutdruck, gewicht, schlaf) span the * empty-state branch (no data → CTA + Coach launch) and the populated @@ -258,7 +258,7 @@ test.describe("Coach launch surfaces on insights sub-pages", () => { await fab.click(); // v1.16.11 — the FAB opens the side drawer in place instead of - // navigating to `/insights/coach`; the page underneath stays. + // navigating to `/coach`; the page underneath stays. const drawer = page.locator('[data-slot="coach-drawer"]'); await expect(drawer).toBeVisible({ timeout: 10_000 }); await expect(drawer).toHaveAttribute( @@ -267,7 +267,7 @@ test.describe("Coach launch surfaces on insights sub-pages", () => { ? "bottom-sheet" : "side-sheet", ); - expect(new URL(page.url()).pathname).not.toContain("/insights/coach"); + expect(new URL(page.url()).pathname).not.toContain("/coach"); // Opening the Coach counts as reading the nudge on this device — // the dot clears and the unread flag drops off the FAB. @@ -281,7 +281,7 @@ test.describe("Coach launch surfaces on insights sub-pages", () => { page, }, testInfo) => { // The in-panel history tray is gone from the drawer; the button - // navigates to `/insights/coach` where the conversation list + // navigates to `/coach` where the conversation list // renders inline on lg+. test.skip( testInfo.project.name !== "chromium-desktop", @@ -314,7 +314,7 @@ test.describe("Coach launch surfaces on insights sub-pages", () => { await historyButton.click(); // No in-panel tray opens — the click navigates to the full view. - await page.waitForURL("**/insights/coach", { timeout: 10_000 }); + await page.waitForURL("**/coach", { timeout: 10_000 }); await expect( page.locator('[data-slot="coach-page"]'), ).toBeVisible({ timeout: 10_000 }); diff --git a/next.config.ts b/next.config.ts index 4cef1bf1c..fd22ff481 100644 --- a/next.config.ts +++ b/next.config.ts @@ -98,19 +98,30 @@ const nextConfig: NextConfig = { ["stimmung", "mood"], ["medikamente", "medications"], ]; - return insightsSlugRenames.flatMap(([from, to]) => [ + return [ + ...insightsSlugRenames.flatMap(([from, to]) => [ + { + source: `/insights/${from}`, + destination: `/insights/${to}`, + permanent: true, + }, + // Preserve any nested path (e.g. a future `/insights/sleep/2026-05-31`). + { + source: `/insights/${from}/:path*`, + destination: `/insights/${to}/:path*`, + permanent: true, + }, + ]), + // v1.18.0 — the Coach moved out of the Insights surface to a + // standalone top-level page. The legacy `/insights/coach` URL + // 301-redirects to `/coach` so bookmarks, the PWA's cached + // navigation, and any pre-update push deep-link keep resolving. { - source: `/insights/${from}`, - destination: `/insights/${to}`, + source: "/insights/coach", + destination: "/coach", permanent: true, }, - // Preserve any nested path (e.g. a future `/insights/sleep/2026-05-31`). - { - source: `/insights/${from}/:path*`, - destination: `/insights/${to}/:path*`, - permanent: true, - }, - ]); + ]; }, async headers() { return [ diff --git a/src/app/insights/coach/page.tsx b/src/app/coach/page.tsx similarity index 82% rename from src/app/insights/coach/page.tsx rename to src/app/coach/page.tsx index a601d6f9b..77e3efe36 100644 --- a/src/app/insights/coach/page.tsx +++ b/src/app/coach/page.tsx @@ -20,13 +20,14 @@ import { useDisableCoach } from "@/hooks/use-disable-coach"; * thin chrome wrapper that supplies a "minimize back to drawer" * control where the drawer supplies "maximize" + "close". * - * The route lives inside `app/insights/layout.tsx` (sticky tab strip + - * mobile FAB) and the authenticated `` (which provides the - * `` the minimize control reopens the drawer - * through), so the page inherits the same Coach feature gating as every - * other Insights surface. When the operator's master flag is off or the - * user opted out, the page redirects back to `/insights` rather than - * painting a dead chat shell. + * v1.18.0 — the route is a standalone top-level page (`/coach`), no longer + * nested under `app/insights/layout.tsx`, so it renders in the standard + * page chrome WITHOUT the Insights tab strip. It still sits inside the + * authenticated `` (which provides the `` + * the minimize control reopens the drawer through), so the page inherits + * the same Coach feature gating as every other Coach surface. When the + * operator's master flag is off or the user opted out, the page redirects + * back to `/insights` rather than painting a dead chat shell. */ export default function CoachPage() { const { t } = useTranslations(); diff --git a/src/components/insights/__tests__/layout-coach-fab.test.tsx b/src/components/insights/__tests__/layout-coach-fab.test.tsx index f2f545df5..95d0a3758 100644 --- a/src/components/insights/__tests__/layout-coach-fab.test.tsx +++ b/src/components/insights/__tests__/layout-coach-fab.test.tsx @@ -113,7 +113,7 @@ describe("", () => { }); it("hides itself on the Coach page", () => { - mockPathname = "/insights/coach"; + mockPathname = "/coach"; const html = render( diff --git a/src/components/insights/coach-panel/coach-conversation.tsx b/src/components/insights/coach-panel/coach-conversation.tsx index ae9cf5139..81869d47f 100644 --- a/src/components/insights/coach-panel/coach-conversation.tsx +++ b/src/components/insights/coach-panel/coach-conversation.tsx @@ -35,7 +35,7 @@ import { useCoachConversation, useSendCoachMessage } from "./use-coach"; /** * v1.12.0 (Coach v2 #6) — shared chat surface for both the Coach drawer * (``, right/bottom `` overlay) and the full-page - * Coach route (`/insights/coach`). + * Coach route (`/coach`). * * This component is the single source of truth for the Coach * conversation: it owns the active-conversation id, the streaming send diff --git a/src/components/insights/coach-panel/coach-drawer.tsx b/src/components/insights/coach-panel/coach-drawer.tsx index 31e6e60f7..07885095e 100644 --- a/src/components/insights/coach-panel/coach-drawer.tsx +++ b/src/components/insights/coach-panel/coach-drawer.tsx @@ -38,7 +38,7 @@ export { * v1.12.0 (Coach v2 #6) — the chat surface itself (header actions, * body, thread, composer, rails, settings, mobile trays) is now the * shared `` component, reused verbatim by the - * full-page Coach route (`/insights/coach`). This file is the drawer + * full-page Coach route (`/coach`). This file is the drawer * CHROME only: the `` wrapper, the close button, and the * maximize control that hands the conversation off to the full page. */ @@ -85,7 +85,7 @@ export function CoachDrawer({ open, onOpenChange, prefill }: CoachDrawerProps) { // then route to the dedicated page. onOpenChange(false); resetRef.current?.(); - router.push("/insights/coach"); + router.push("/coach"); }, [onOpenChange, router]); return ( diff --git a/src/components/insights/insights-layout-shell.tsx b/src/components/insights/insights-layout-shell.tsx index fb37956ae..38070c29f 100644 --- a/src/components/insights/insights-layout-shell.tsx +++ b/src/components/insights/insights-layout-shell.tsx @@ -1,7 +1,6 @@ "use client"; import { useMemo, type ReactNode } from "react"; -import { useSelectedLayoutSegment } from "next/navigation"; import { useQuery } from "@tanstack/react-query"; import { useTranslations } from "@/lib/i18n/context"; @@ -76,13 +75,6 @@ export function InsightsLayoutShell({ children }: { children: ReactNode }) { const advisorEnabled = mounted && isAuthenticated && flags.enabled && flags.briefing; - // v1.12.6 — the Coach segment is excluded from the generic disclaimer - // footer (v1.16.1 dropped the in-chat disclaimer line entirely; the - // chat surface stays footer-free by design). Every other segment — the - // overview (null segment), the metric sub-pages, mood, medications, the - // raw values table — keeps the single generic footer. - const segment = useSelectedLayoutSegment(); - const showDisclaimerFooter = segment !== "coach"; const advisor = useInsightsAdvisorQuery(advisorEnabled); // Shared analytics fetch — the layout shell only reads @@ -176,15 +168,15 @@ export function InsightsLayoutShell({ children }: { children: ReactNode }) { rhythm events) stay on their own cards — those convey real, non-generic information. Other waves remove the generic line from the overview / mood / medications pages; this footer is where it lives. - Suppressed on the Coach route, which carries its own disclaimer. */} - {showDisclaimerFooter ? ( -

- {t("insights.disclaimer.footer")} -

- ) : null} + v1.18.0 — Coach moved to the standalone `/coach` route (its own + disclaimer), so this footer now renders under every `/insights/*` + route unconditionally. */} +

+ {t("insights.disclaimer.footer")} +

); } diff --git a/src/components/insights/layout-coach-fab.tsx b/src/components/insights/layout-coach-fab.tsx index ef0f73f83..144045537 100644 --- a/src/components/insights/layout-coach-fab.tsx +++ b/src/components/insights/layout-coach-fab.tsx @@ -26,7 +26,7 @@ import { apiGet } from "@/lib/api/api-fetch"; * nudge (server-derived), or opened the Coach on this device (local * seen stamp keyed by the nudge timestamp). * - * The FAB hides inside the Coach view itself (`/insights/coach`) — a + * The FAB hides inside the Coach view itself (`/coach`) — a * launcher pointing at the page the user is already on is noise. * * It yields while a data-list selection bar is mounted (CSS `:has()` @@ -76,7 +76,7 @@ export function LayoutCoachFab() { }); const coachAvailable = !!launch && flags.coach && !disableCoach; - const onCoachPage = pathname?.startsWith("/insights/coach") ?? false; + const onCoachPage = pathname?.startsWith("/coach") ?? false; const { data: status } = useQuery({ queryKey: queryKeys.coachNudgeStatus(), diff --git a/src/components/layout/__tests__/nav-model.test.ts b/src/components/layout/__tests__/nav-model.test.ts index eefb35d2c..eb6684687 100644 --- a/src/components/layout/__tests__/nav-model.test.ts +++ b/src/components/layout/__tests__/nav-model.test.ts @@ -20,16 +20,13 @@ import { } from "../nav-model"; describe("nav-model destination list", () => { - it("lists Workouts and Coach as first-class destinations", () => { + it("lists Coach as a first-class destination", () => { const hrefs = NAV_DESTINATIONS.map((d) => d.href); - expect(hrefs).toContain("/insights/workouts"); - expect(hrefs).toContain("/insights/coach"); + expect(hrefs).toContain("/coach"); }); - it("gives the Coach exactly one nav home (F-3)", () => { - const coach = NAV_DESTINATIONS.filter( - (d) => d.href === "/insights/coach", - ); + it("gives the Coach exactly one nav home at the top-level /coach (F-3)", () => { + const coach = NAV_DESTINATIONS.filter((d) => d.href === "/coach"); expect(coach).toHaveLength(1); expect(coach[0]!.tKey).toBe("nav.coach"); }); @@ -171,11 +168,11 @@ describe("isNavDestinationActive most-specific resolution", () => { expect(isNavDestinationActive("/", "/measurements")).toBe(false); }); - it("does not light up Insights when on its Coach sibling", () => { - expect(isNavDestinationActive("/insights/coach", "/insights/coach")).toBe( - true, - ); - expect(isNavDestinationActive("/insights", "/insights/coach")).toBe(false); + it("lights up the top-level Coach home on its own route", () => { + expect(isNavDestinationActive("/coach", "/coach")).toBe(true); + // The standalone /coach route is not a sibling of /insights, so the + // Insights entry must stay dark on it. + expect(isNavDestinationActive("/insights", "/coach")).toBe(false); }); it("does not light up Insights when on its Workouts sibling", () => { diff --git a/src/components/layout/__tests__/sidebar-nav.test.tsx b/src/components/layout/__tests__/sidebar-nav.test.tsx index c60f50bc6..acebe471e 100644 --- a/src/components/layout/__tests__/sidebar-nav.test.tsx +++ b/src/components/layout/__tests__/sidebar-nav.test.tsx @@ -139,16 +139,16 @@ describe(" unified destination model (v1.17.1 F-1 / F-3)", () => { // now render the one shared model, so both carry both destinations. const html = render(); expect(html).toContain('href="/insights/workouts"'); - expect(html).toContain('href="/insights/coach"'); + expect(html).toContain('href="/coach"'); expect(html).toContain("Workouts"); expect(html).toContain("Coach"); }); it("marks Coach active without also marking Insights active", () => { - const html = render({ pathname: "/insights/coach" }); - // The Coach link carries aria-current="page"; the Insights link, its - // less-specific sibling, must not (most-specific resolution). - const coach = html.match(/]*href="\/insights\/coach"[^>]*>/); + const html = render({ pathname: "/coach" }); + // The Coach link carries aria-current="page"; the standalone /coach + // route is not a sibling of /insights, so the Insights link must not. + const coach = html.match(/]*href="\/coach"[^>]*>/); const insights = html.match(/]*href="\/insights"[^>]*>/); expect(coach?.[0]).toMatch(/aria-current="page"/); expect(insights?.[0]).not.toMatch(/aria-current="page"/); diff --git a/src/components/layout/auth-shell.tsx b/src/components/layout/auth-shell.tsx index 02fb68705..d95f9a2bc 100644 --- a/src/components/layout/auth-shell.tsx +++ b/src/components/layout/auth-shell.tsx @@ -199,7 +199,7 @@ export function AuthShell({ // // v1.16.8 — `` joins the shell too: the floating // Coach launcher renders once here for every authenticated route - // (it hides itself on `/insights/coach` and carries the unread-nudge + // (it hides itself on `/coach` and carries the unread-nudge // dot). The routed insights layout no longer mounts it. return ( diff --git a/src/components/layout/nav-model.ts b/src/components/layout/nav-model.ts index eef6484a0..764aa5cac 100644 --- a/src/components/layout/nav-model.ts +++ b/src/components/layout/nav-model.ts @@ -121,7 +121,7 @@ export const NAV_DESTINATIONS: ReadonlyArray = [ // states, per-metric icons …) but nowhere in the nav, so a new user // could miss the differentiator entirely. The other entry points stay. { - href: "/insights/coach", + href: "/coach", tKey: "nav.coach", icon: MessagesSquare, tourId: "nav-coach", @@ -240,8 +240,8 @@ export function mobileMoreHubDestinations(opts: { * Whether `href` is the active nav destination for the current `pathname`, * resolved against the full destination set so the most-specific entry * wins. Without this, a plain `startsWith("/insights")` would light up - * Insights while the user is on its siblings `/insights/workouts` or - * `/insights/coach` — both of which are now their own top-level nav homes. + * Insights while the user is on its sibling `/insights/workouts`, which + * is its own nav home (Coach lives at the top-level `/coach`). * The dashboard (`/`) only matches an exact path. */ export function isNavDestinationActive( @@ -254,7 +254,7 @@ export function isNavDestinationActive( pathname === candidate || pathname.startsWith(`${candidate}/`); if (!matches(href)) return false; // A longer sibling that also matches is the more specific home — defer - // to it (e.g. on `/insights/coach`, `/insights` must NOT read active). + // to it (e.g. on `/insights/workouts`, `/insights` must NOT read active). const moreSpecific = destinations.some( (d) => d.href !== href && d.href.startsWith(`${href}/`) && matches(d.href), ); diff --git a/src/components/settings/about-me-section.tsx b/src/components/settings/about-me-section.tsx index 5b22235c3..aeb554416 100644 --- a/src/components/settings/about-me-section.tsx +++ b/src/components/settings/about-me-section.tsx @@ -324,7 +324,7 @@ export function AboutMeSection({

{t("settings.ai.aboutMe.questionsOpenCoach")} diff --git a/src/components/settings/coach-nudge-card.tsx b/src/components/settings/coach-nudge-card.tsx index 46f204a7c..132f3ed87 100644 --- a/src/components/settings/coach-nudge-card.tsx +++ b/src/components/settings/coach-nudge-card.tsx @@ -2,7 +2,7 @@ /** * v1.15.20 — Settings → Notifications opt-out for the proactive Coach - * nudge (the daily 05:15 cron that points the user at /insights/coach + * nudge (the daily 05:15 cron that points the user at /coach * when a deterministic trigger fires). Default ON; the toggle writes * `notificationPrefs.coach.nudgesEnabled` through the roaming * notification-prefs blob so web + iOS share one source of truth. diff --git a/src/components/settings/coach-prefs-section.tsx b/src/components/settings/coach-prefs-section.tsx index 79f855a57..be712b335 100644 --- a/src/components/settings/coach-prefs-section.tsx +++ b/src/components/settings/coach-prefs-section.tsx @@ -218,7 +218,7 @@ export function CoachPrefsSection({ isAuthenticated }: CoachPrefsSectionProps) { > {t("insights.coach.settingsSourcesPointer")}{" "} {t("insights.coach.settingsSourcesPointerLink")} diff --git a/src/lib/feature-flags/__tests__/coach-cascade.test.tsx b/src/lib/feature-flags/__tests__/coach-cascade.test.tsx index e2944db1f..b0463690d 100644 --- a/src/lib/feature-flags/__tests__/coach-cascade.test.tsx +++ b/src/lib/feature-flags/__tests__/coach-cascade.test.tsx @@ -296,7 +296,8 @@ describe("Coach disable cascade invariant", () => { "src/components/insights/suggested-prompts.tsx", // v1.12.0 — the full-page Coach route gates on `flags.coach` and // redirects to `/insights` when the operator master flag is off. - "src/app/insights/coach/page.tsx", + // v1.18.0 — moved to the standalone top-level `/coach` route. + "src/app/coach/page.tsx", // v1.15.20 — the proactive Coach-nudge cron short-circuits on // `flags.coach` (gate 1) so an operator kill-switch also silences // the nudges. Server-side cron, not a render path — the cascade 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 caedd7d9e..7daf06e5d 100644 --- a/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx +++ b/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx @@ -295,7 +295,8 @@ describe("Coach per-user disableCoach invariant", () => { // v1.12.0 — the full-page Coach route mirrors the launch-button / // FAB gate: operator master flag OR per-user opt-out redirects the // page back to `/insights` instead of painting a dead chat shell. - "src/app/insights/coach/page.tsx", + // v1.18.0 — moved to the standalone top-level `/coach` route. + "src/app/coach/page.tsx", // The hook itself + its `useAuth`-backed reader. "src/hooks/use-disable-coach.ts", // Cross-cut gates owned by sibling invariants / route tests. diff --git a/src/lib/jobs/coach-nudge.ts b/src/lib/jobs/coach-nudge.ts index db20cf094..b92107a2e 100644 --- a/src/lib/jobs/coach-nudge.ts +++ b/src/lib/jobs/coach-nudge.ts @@ -4,7 +4,7 @@ * One daily cron tick (05:15 Europe/Berlin, after the nightly score * passes have settled) evaluates a small set of DETERMINISTIC triggers * per user and — when one fires — dispatches a single `COACH_NUDGE` - * notification deep-linking to `/insights/coach`. No AI call happens + * notification deep-linking to `/coach`. No AI call happens * here: the triggers are plain arithmetic over already-persisted rows; * the Coach conversation the nudge invites the user into is where the * model gets involved. @@ -809,7 +809,7 @@ export async function runCoachNudgeTick( trigger, // Deep link — the web-push sender threads this into the // service-worker payload's click target. - url: "/insights/coach", + url: "/coach", }, }); diff --git a/src/lib/notifications/senders/web-push.ts b/src/lib/notifications/senders/web-push.ts index 59eccc7cc..2ff484147 100644 --- a/src/lib/notifications/senders/web-push.ts +++ b/src/lib/notifications/senders/web-push.ts @@ -91,7 +91,7 @@ export async function sendViaWebPush( tag: payload.discreet ? "REMINDER" : payload.eventType, // v1.15.20 — opt-in deep link: a dispatcher payload may carry a // same-origin path in `metadata.url` (e.g. the Coach nudge's - // `/insights/coach`); everything else keeps the legacy "/" click + // `/coach`); everything else keeps the legacy "/" click // target. Only relative paths are honoured so a poisoned metadata // value can never point the SW at a foreign origin. url: diff --git a/src/lib/notifications/types.ts b/src/lib/notifications/types.ts index c6ee29446..ea23e1ce9 100644 --- a/src/lib/notifications/types.ts +++ b/src/lib/notifications/types.ts @@ -62,7 +62,7 @@ export const EVENT_TYPES = [ // v1.15.20 — proactive Coach nudge. Fired by the 05:15 Europe/Berlin // cron when a deterministic trigger (7-day compliance < 60 %, BP // weekly mean above target, sharply falling recovery score) warrants - // pointing the user at /insights/coach. Capped at one nudge per user + // pointing the user at /coach. Capped at one nudge per user // per rolling week via the `push_attempts` ledger; per-user opt-out // lives in `notificationPrefs.coach.nudgesEnabled`. "COACH_NUDGE",