merge(nav): Coach standalone page, Recovery+Workouts as Insights pills

This commit is contained in:
Marc-André Bombeck committed 2026-06-15 21:06:50 +02:00
commit 76d24dd684
30 files changed
+149 -114

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 });
+1
View File
@@ -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.",
+1
View File
@@ -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.",
+1
View File
@@ -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.",
+1
View File
@@ -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.",
+1
View File
@@ -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.",
+1
View File
@@ -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.",
+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();
@@ -94,6 +94,25 @@ describe("<InsightsTabStrip> — 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(<InsightsTabStrip />);
expect(noAvailability).toContain(">Recovery<");
expect(noAvailability).toContain('href="/insights/recovery"');
const emptyAvailability: InsightInputs = {
summaries: {},
hasMood: false,
hasMedication: false,
};
const html = render(<InsightsTabStrip availability={emptyAvailability} />);
expect(html).toContain(">Recovery<");
});
it("lights up Mood when hasMood flips to true", () => {
const availability: InsightInputs = {
summaries: {},
@@ -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>
);
}
@@ -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
+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(),
@@ -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
@@ -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";
@@ -20,16 +21,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");
});
@@ -56,11 +54,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)", () => {
@@ -171,19 +176,20 @@ 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", () => {
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,
);
});
@@ -133,22 +133,22 @@ describe("<SidebarNav> targets deprecation (v1.8.6)", () => {
});
describe("<SidebarNav> 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="/insights/coach"');
expect(html).toContain("Workouts");
expect(html).toContain('href="/coach"');
expect(html).toContain("Coach");
expect(html).not.toContain('href="/insights/workouts"');
});
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>
+8 -23
View File
@@ -3,9 +3,7 @@ import {
Bell,
Bug,
Droplets,
Dumbbell,
FlaskConical,
HeartPulse,
Home,
Lightbulb,
MessagesSquare,
@@ -93,23 +91,10 @@ export const NAV_DESTINATIONS: ReadonlyArray<NavDestination> = [
icon: Stethoscope,
tourId: "nav-vorsorge",
},
{
href: "/insights/workouts",
tKey: "nav.workouts",
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 — 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",
@@ -121,7 +106,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 +225,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 +239,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",