mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
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:
1 parent
56c92e0cfe
commit
aa27708ebc
21 files changed
+82
-79
No files matched your search
@@ -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 });
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"/);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user