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 1/3] 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", From 640793a3e88391aaac0b6cbed1463c962418920e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 15 Jun 2026 21:03:48 +0200 Subject: [PATCH 2/3] refactor(nav): surface Recovery as an Insights tab instead of a left-nav item Recovery is a composite wearable surface (WHOOP / Polar / Oura recovery, strain, ANS charge, cardio load), not a top-level tracking destination. It moves off the left nav and onto the Insights tab strip as a pill at /insights/recovery. The pill is always present (like Overview) since the page self-gates every block and falls back to a calm empty note rather than carrying its own availability signal. Adds the insights.navRecovery label across all six locales. --- messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 1 + messages/it.json | 1 + messages/pl.json | 1 + .../__tests__/insights-tab-strip.test.tsx | 19 +++++++++++++++++++ .../insights/insights-tab-strip.tsx | 12 ++++++++++++ .../layout/__tests__/nav-model.test.ts | 9 ++++++++- src/components/layout/nav-model.ts | 15 +++------------ 10 files changed, 48 insertions(+), 13 deletions(-) diff --git a/messages/de.json b/messages/de.json index c25559632..0509f4a9d 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2784,6 +2784,7 @@ } }, "navWorkouts": "Workouts", + "navRecovery": "Erholung", "workouts": { "title": "Workouts", "description": "Letzte Läufe, Touren, Spaziergänge und Krafteinheiten — quellenübergreifend entdoppelt zwischen Apple Watch und Withings.", diff --git a/messages/en.json b/messages/en.json index 2d774166a..bbeb6b05a 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2784,6 +2784,7 @@ } }, "navWorkouts": "Workouts", + "navRecovery": "Recovery", "workouts": { "title": "Workouts", "description": "Recent runs, rides, walks and strength sessions, deduped across Apple Watch and Withings sources.", diff --git a/messages/es.json b/messages/es.json index 21d3c6f22..1eed54b4c 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2784,6 +2784,7 @@ } }, "navWorkouts": "Entrenamientos", + "navRecovery": "Recuperación", "workouts": { "title": "Entrenamientos", "description": "Carreras, salidas en bici, paseos y sesiones de fuerza recientes, deduplicados entre Apple Watch y Withings.", diff --git a/messages/fr.json b/messages/fr.json index e75419797..c3a2a9dcc 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2784,6 +2784,7 @@ } }, "navWorkouts": "Entraînements", + "navRecovery": "Récupération", "workouts": { "title": "Entraînements", "description": "Courses, sorties à vélo, marches et séances de musculation récentes, dédupliquées entre Apple Watch et Withings.", diff --git a/messages/it.json b/messages/it.json index ca7636642..2afb522b8 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2784,6 +2784,7 @@ } }, "navWorkouts": "Allenamenti", + "navRecovery": "Recupero", "workouts": { "title": "Allenamenti", "description": "Corse, uscite in bici, camminate e sessioni di forza recenti, deduplicati tra Apple Watch e Withings.", diff --git a/messages/pl.json b/messages/pl.json index e9c9ca571..a14120194 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2784,6 +2784,7 @@ } }, "navWorkouts": "Treningi", + "navRecovery": "Regeneracja", "workouts": { "title": "Treningi", "description": "Ostatnie biegi, jazdy, marsze i treningi siłowe — zdeduplikowane między Apple Watch a Withings.", diff --git a/src/components/insights/__tests__/insights-tab-strip.test.tsx b/src/components/insights/__tests__/insights-tab-strip.test.tsx index 755b1567c..db8f61043 100644 --- a/src/components/insights/__tests__/insights-tab-strip.test.tsx +++ b/src/components/insights/__tests__/insights-tab-strip.test.tsx @@ -94,6 +94,25 @@ describe(" — availability gating (v1.4.27 F19)", () => { expect(html).toContain(">Overview<"); }); + it("always renders the Recovery pill (composite wearable surface, self-gated page)", () => { + // v1.18.0 — Recovery left the left-nav for an Insights pill. It is a + // composite WHOOP / Polar / Oura surface, not a single MeasurementType, + // so it has no `summaries[METRIC].count` to gate on; the page itself + // data-gates each block and falls back to a calm empty note, so the + // pill is always present (like Overview) regardless of availability. + const noAvailability = render(); + expect(noAvailability).toContain(">Recovery<"); + expect(noAvailability).toContain('href="/insights/recovery"'); + + const emptyAvailability: InsightInputs = { + summaries: {}, + hasMood: false, + hasMedication: false, + }; + const html = render(); + expect(html).toContain(">Recovery<"); + }); + it("lights up Mood when hasMood flips to true", () => { const availability: InsightInputs = { summaries: {}, diff --git a/src/components/insights/insights-tab-strip.tsx b/src/components/insights/insights-tab-strip.tsx index f54d00086..b61e02394 100644 --- a/src/components/insights/insights-tab-strip.tsx +++ b/src/components/insights/insights-tab-strip.tsx @@ -420,6 +420,18 @@ function buildTabs( href: INSIGHTS_OVERVIEW_PATH, labelKey: "insights.navOverview", }, + // v1.18.0 — Recovery is an Insights pill, no longer a left-nav home. + // It is a composite wearable surface (WHOOP / Polar / Oura recovery, + // strain, ANS charge, cardio load), not a single MeasurementType, so + // it has no `summaries[METRIC].count` to gate on. The page itself + // data-gates every block and falls back to a calm empty note, so the + // pill is always present like Overview rather than carrying its own + // availability signal. + { + kind: "link", + href: `${INSIGHTS_OVERVIEW_PATH}/recovery`, + labelKey: "insights.navRecovery", + }, ]; // v1.4.34 IW-D — track which groups have already been emitted so the diff --git a/src/components/layout/__tests__/nav-model.test.ts b/src/components/layout/__tests__/nav-model.test.ts index eb6684687..c7f64cc3f 100644 --- a/src/components/layout/__tests__/nav-model.test.ts +++ b/src/components/layout/__tests__/nav-model.test.ts @@ -53,11 +53,18 @@ describe("nav-model destination list", () => { expect(hrefs).toContain("/vorsorge"); const vorsorge = NAV_DESTINATIONS.find((d) => d.href === "/vorsorge"); expect(vorsorge?.tKey).toBe("nav.vorsorge"); - // Peer to Labs / Recovery: sits in the clinical spine, before Insights. + // Peer to Labs: sits in the clinical spine, before Insights. expect(hrefs.indexOf("/labs")).toBeLessThan(hrefs.indexOf("/vorsorge")); expect(hrefs.indexOf("/vorsorge")).toBeLessThan(hrefs.indexOf("/insights")); }); + it("does not list Recovery as a left-nav destination (it is an Insights pill)", () => { + // v1.18.0 — Recovery moved off the left nav and surfaces as an + // Insights tab-strip pill at `/insights/recovery` instead. + const hrefs = NAV_DESTINATIONS.map((d) => d.href); + expect(hrefs).not.toContain("/insights/recovery"); + }); + }); describe("nav-model utility tail (N-1 — one shared list)", () => { diff --git a/src/components/layout/nav-model.ts b/src/components/layout/nav-model.ts index 764aa5cac..5fce0b12b 100644 --- a/src/components/layout/nav-model.ts +++ b/src/components/layout/nav-model.ts @@ -5,7 +5,6 @@ import { Droplets, Dumbbell, FlaskConical, - HeartPulse, Home, Lightbulb, MessagesSquare, @@ -99,17 +98,9 @@ export const NAV_DESTINATIONS: ReadonlyArray = [ icon: Dumbbell, tourId: "nav-workouts", }, - // v1.17.1 — the Recovery surface gets a nav home alongside Workouts. It - // hosts the WHOOP / Polar device-native recovery + strain scores that were - // stored end-to-end but had no page. Like Workouts it is shown to every - // account (the page itself data-gates each block, so a non-wearable user - // lands on a calm empty note rather than a broken surface). - { - href: "/insights/recovery", - tKey: "nav.recovery", - icon: HeartPulse, - tourId: "nav-recovery", - }, + // v1.18.0 — Recovery left the left-nav: it surfaces as an Insights pill + // (`/insights/recovery`) rather than a top-level destination, so it no + // longer appears in `NAV_DESTINATIONS`. { href: "/insights", tKey: "nav.insights", From f0a030f1cee203efd2e50e10abcd92950c4548a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 15 Jun 2026 21:05:24 +0200 Subject: [PATCH 3/3] refactor(nav): drop Workouts from the left nav (already an Insights tab) Workouts already surfaces as an Insights tab-strip pill (the workouts slug, gated on a logged workout row), so a parallel left-nav home was redundant. Removing it from NAV_DESTINATIONS lets the Insights nav home read active on /insights/workouts like any other /insights/* sub-route. --- .../layout/__tests__/bottom-nav.test.tsx | 5 +++-- .../layout/__tests__/nav-model.test.ts | 16 +++++++++------- .../layout/__tests__/sidebar-nav.test.tsx | 12 ++++++------ src/components/layout/nav-model.ts | 14 ++++---------- 4 files changed, 22 insertions(+), 25 deletions(-) diff --git a/src/components/layout/__tests__/bottom-nav.test.tsx b/src/components/layout/__tests__/bottom-nav.test.tsx index 7e7a602bd..b676af7ce 100644 --- a/src/components/layout/__tests__/bottom-nav.test.tsx +++ b/src/components/layout/__tests__/bottom-nav.test.tsx @@ -7,8 +7,9 @@ * `aria-haspopup="dialog"`), not a navigation link * - the More button opens a hub (closed by default under SSR) * - the More hub is a real hub: Measurements + Mood (which left the - * always-visible strip) stay reachable here, alongside Workouts, - * Achievements, Notifications and Settings — nothing is orphaned + * always-visible strip) stay reachable here, alongside Achievements, + * Notifications and Settings — nothing is orphaned (v1.18.0 — Workouts + * and Recovery left the nav for their Insights pills) * - WCAG 2.5.5 floor: every strip entry is `min-h-11 min-w-11` * * v1.8.6 — the Targets (Zielwerte) page is deprecated and dropped from diff --git a/src/components/layout/__tests__/nav-model.test.ts b/src/components/layout/__tests__/nav-model.test.ts index c7f64cc3f..24df312a5 100644 --- a/src/components/layout/__tests__/nav-model.test.ts +++ b/src/components/layout/__tests__/nav-model.test.ts @@ -3,9 +3,10 @@ * * Pins the coherence contract the desktop sidebar and the mobile * bottom-nav both consume: one ordered destination list (so the two bars - * tell one story), Workouts AND Coach as first-class destinations on both + * tell one story), the Coach as a first-class destination on both * surfaces, cycle gated by the account flag, and an active-resolver that - * prefers the most-specific sibling under `/insights`. + * prefers the most-specific sibling under `/insights`. (v1.18.0 — + * Workouts and Recovery left the left nav for their Insights pills.) */ import { describe, expect, it } from "vitest"; @@ -182,12 +183,13 @@ describe("isNavDestinationActive most-specific resolution", () => { expect(isNavDestinationActive("/insights", "/coach")).toBe(false); }); - it("does not light up Insights when on its Workouts sibling", () => { - expect( - isNavDestinationActive("/insights/workouts", "/insights/workouts"), - ).toBe(true); + it("lights up Insights on its Workouts sub-route (no longer a separate nav home)", () => { + // v1.18.0 — Workouts left the left nav for its Insights pill, so it is + // no longer a more-specific NAV_DESTINATIONS sibling. The Insights nav + // home now reads active on `/insights/workouts`, like any other + // `/insights/*` sub-route. expect(isNavDestinationActive("/insights", "/insights/workouts")).toBe( - false, + true, ); }); diff --git a/src/components/layout/__tests__/sidebar-nav.test.tsx b/src/components/layout/__tests__/sidebar-nav.test.tsx index acebe471e..3f1145d20 100644 --- a/src/components/layout/__tests__/sidebar-nav.test.tsx +++ b/src/components/layout/__tests__/sidebar-nav.test.tsx @@ -133,15 +133,15 @@ describe(" targets deprecation (v1.8.6)", () => { }); describe(" unified destination model (v1.17.1 F-1 / F-3)", () => { - it("surfaces Workouts and the Coach as first-class sidebar destinations", () => { - // Pre-unify the sidebar hid Workouts entirely and had no Coach home, - // while the mobile bar promoted Workouts and still missed Coach. Both - // now render the one shared model, so both carry both destinations. + it("surfaces the Coach as a first-class sidebar destination", () => { + // Pre-unify the sidebar had no Coach home while the mobile bar missed + // it too; both now render the one shared model so both carry it. + // (v1.18.0 — Workouts left the left nav for its Insights pill, so it is + // no longer a sidebar destination.) const html = render(); - expect(html).toContain('href="/insights/workouts"'); expect(html).toContain('href="/coach"'); - expect(html).toContain("Workouts"); expect(html).toContain("Coach"); + expect(html).not.toContain('href="/insights/workouts"'); }); it("marks Coach active without also marking Insights active", () => { diff --git a/src/components/layout/nav-model.ts b/src/components/layout/nav-model.ts index 5fce0b12b..7af0a288c 100644 --- a/src/components/layout/nav-model.ts +++ b/src/components/layout/nav-model.ts @@ -3,7 +3,6 @@ import { Bell, Bug, Droplets, - Dumbbell, FlaskConical, Home, Lightbulb, @@ -92,15 +91,10 @@ export const NAV_DESTINATIONS: ReadonlyArray = [ icon: Stethoscope, tourId: "nav-vorsorge", }, - { - href: "/insights/workouts", - tKey: "nav.workouts", - icon: Dumbbell, - tourId: "nav-workouts", - }, - // v1.18.0 — Recovery left the left-nav: it surfaces as an Insights pill - // (`/insights/recovery`) rather than a top-level destination, so it no - // longer appears in `NAV_DESTINATIONS`. + // v1.18.0 — Workouts and Recovery both left the left-nav: each already + // surfaces as an Insights tab-strip pill (`/insights/workouts` gated on + // a workout row, `/insights/recovery` always present), so neither is a + // top-level `NAV_DESTINATIONS` entry any more. { href: "/insights", tKey: "nav.insights",