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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-15 21:00:46 +02:00
1 parent 56c92e0cfe
commit aa27708ebc
21 files changed
+82 -79

No files matched your search

+3 -3
View File
@@ -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 });
+5 -5
View File
@@ -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 });
+21 -10
View File
@@ -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 [
@@ -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 `<AuthShell>` (which provides the
* `<CoachLaunchProvider>` 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 `<AuthShell>` (which provides the `<CoachLaunchProvider>`
* 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();
@@ -113,7 +113,7 @@ describe("<LayoutCoachFab>", () => {
});
it("hides itself on the Coach page", () => {
mockPathname = "/insights/coach";
mockPathname = "/coach";
const html = render(
<CoachLaunchProvider>
<LayoutCoachFab />
@@ -35,7 +35,7 @@ import { useCoachConversation, useSendCoachMessage } from "./use-coach";
/**
* v1.12.0 (Coach v2 #6) — shared chat surface for both the Coach drawer
* (`<CoachDrawer>`, right/bottom `<Sheet>` 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
@@ -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 `<CoachConversation>` 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 `<Sheet>` 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 (
@@ -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 ? (
<p
data-slot="insights-disclaimer-footer"
className="text-muted-foreground border-border/60 border-t pt-4 text-center text-xs leading-relaxed"
>
{t("insights.disclaimer.footer")}
</p>
) : null}
v1.18.0 — Coach moved to the standalone `/coach` route (its own
disclaimer), so this footer now renders under every `/insights/*`
route unconditionally. */}
<p
data-slot="insights-disclaimer-footer"
className="text-muted-foreground border-border/60 border-t pt-4 text-center text-xs leading-relaxed"
>
{t("insights.disclaimer.footer")}
</p>
</div>
);
}
+2 -2
View File
@@ -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(),
@@ -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", () => {
@@ -139,16 +139,16 @@ describe("<SidebarNav> 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(/<a[^>]*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(/<a[^>]*href="\/coach"[^>]*>/);
const insights = html.match(/<a[^>]*href="\/insights"[^>]*>/);
expect(coach?.[0]).toMatch(/aria-current="page"/);
expect(insights?.[0]).not.toMatch(/aria-current="page"/);
+1 -1
View File
@@ -199,7 +199,7 @@ export function AuthShell({
//
// v1.16.8 — `<LayoutCoachFab>` 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 (
<CoachLaunchProvider>
+4 -4
View File
@@ -121,7 +121,7 @@ export const NAV_DESTINATIONS: ReadonlyArray<NavDestination> = [
// 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),
);
+1 -1
View File
@@ -324,7 +324,7 @@ export function AboutMeSection({
</ul>
<p className="mt-3 text-xs">
<Link
href="/insights/coach"
href="/coach"
className="text-dracula-purple underline-offset-4 hover:underline"
>
{t("settings.ai.aboutMe.questionsOpenCoach")}
+1 -1
View File
@@ -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.
@@ -218,7 +218,7 @@ export function CoachPrefsSection({ isAuthenticated }: CoachPrefsSectionProps) {
>
{t("insights.coach.settingsSourcesPointer")}{" "}
<Link
href="/insights/coach"
href="/coach"
className="text-foreground underline underline-offset-2"
>
{t("insights.coach.settingsSourcesPointerLink")}
@@ -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
@@ -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.
+2 -2
View File
@@ -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",
},
});
+1 -1
View File
@@ -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:
+1 -1
View File
@@ -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",