diff --git a/src/app/__tests__/insights-polish.test.ts b/src/app/__tests__/insights-polish.test.ts index 864666549..cae362655 100644 --- a/src/app/__tests__/insights-polish.test.ts +++ b/src/app/__tests__/insights-polish.test.ts @@ -59,17 +59,31 @@ describe("v1.4.19 A3 — dashboard polish", () => { }); describe("v1.4.27 R3d MB4 — the layout mounts the Coach drawer", () => { - it("the insights layout wraps children in and mounts the drawer", () => { - // v1.4.27 MB4 promoted the drawer mount from the mother page up to - // `/insights/layout.tsx` so a routed sub-page does not unmount the - // drawer. The layout owns the mount + the provider; the page (and - // every sub-page) consumes the launch context. - const layout = load(INSIGHTS_LAYOUT_PATH); - expect(layout).toContain( + // v1.4.34 IW-B — `` + `` moved + // up to the global `` so the dashboard hero (and every + // other authed surface) can call `askCoach()` from the same context. + // The insights layout keeps the FAB but no longer wraps the provider + // or mounts the drawer. + it("the global auth-shell wraps the authenticated tree in and mounts the drawer", () => { + const shellSource = load( + `${process.cwd()}/src/components/layout/auth-shell.tsx`, + ); + expect(shellSource).toContain( 'from "@/lib/insights/coach-launch-context"', ); - expect(layout).toMatch(//); + expect(shellSource).toMatch(//); + }); + + it("the insights layout no longer double-mounts the provider or drawer", () => { + const layout = load(INSIGHTS_LAYOUT_PATH); + expect(layout).not.toContain( + 'from "@/lib/insights/coach-launch-context"', + ); + expect(layout).not.toContain("import { LayoutCoachMount }"); + // FAB stays scoped to `/insights/**` because the floating button + // would distract on surfaces with a contextual inline CTA. + expect(layout).toContain("LayoutCoachFab"); }); it("hero strip's onAskCoach handler calls the launch context", () => { diff --git a/src/app/insights/layout.tsx b/src/app/insights/layout.tsx index 25ba61cb7..5d7a83631 100644 --- a/src/app/insights/layout.tsx +++ b/src/app/insights/layout.tsx @@ -2,8 +2,6 @@ import type { ReactNode } from "react"; import { InsightsLayoutShell } from "@/components/insights/insights-layout-shell"; import { LayoutCoachFab } from "@/components/insights/layout-coach-fab"; -import { LayoutCoachMount } from "@/components/insights/layout-coach-mount"; -import { CoachLaunchProvider } from "@/lib/insights/coach-launch-context"; /** * v1.4.25 W4 — shared layout for the routed `/insights` sub-pages. @@ -13,27 +11,32 @@ import { CoachLaunchProvider } from "@/lib/insights/coach-launch-context"; * into a sub-page. The actual page body still owns its own data fetches * — the layout is a thin presentational frame. * - * v1.4.27 R3d MB4 — the Coach drawer now mounts here (above the routed - * children) inside a `` so navigating into a sub- - * page no longer unmounts the drawer. Every sub-page mounts a + * v1.4.27 R3d MB4 — the Coach drawer mounts above the routed children + * inside a `` so navigating into a sub-page no + * longer unmounts the drawer. Every sub-page mounts a * `` that calls `askCoach()` on the same context; * the mother `/insights/page.tsx` consumes the same hook for its hero - * strip + suggested-prompt chips. Decision F (audit MA3) drove the - * promotion: a mobile user who navigates from `/insights` to - * `/insights/blutdruck` keeps a one-tap path back into the Coach. + * strip + suggested-prompt chips. * - * v1.4.28 R3c — the mobile FAB now also lives here via - * `` (carved out of `` so each sub- - * page mount no longer duplicates the FAB into the a11y tree). The - * inline `` pill stays on the per-page action rows - * where copy + position matter. + * v1.4.28 R3c — the mobile FAB also lives here via `` + * (carved out of `` so each sub-page mount no + * longer duplicates the FAB into the a11y tree). The inline + * `` pill stays on the per-page action rows where + * copy + position matter. + * + * v1.4.34 IW-B — `` + `` now + * live on the global `` (`src/components/layout/auth-shell.tsx`) + * so every authenticated route (dashboard included) can open the drawer + * from the same context. The FAB stays scoped to `/insights/**` only — + * the floating action would distract from the dashboard hero on + * surfaces where a contextual inline button already covers the same + * affordance. */ export default function InsightsLayout({ children }: { children: ReactNode }) { return ( - + <> {children} - - + ); } diff --git a/src/app/page.tsx b/src/app/page.tsx index f1dcfbd78..85c62d0bc 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -13,6 +13,7 @@ import { Percent, Plus, Smile, + Sparkles, Target, TrendingUp, Waves, @@ -61,6 +62,8 @@ const MedicationComplianceChart = dynamic( { ssr: false, loading: () => }, ); import { useTranslations, useFormatters } from "@/lib/i18n/context"; +import { useCoachLaunch } from "@/lib/insights/coach-launch-context"; +import { useFeatureFlags } from "@/hooks/use-feature-flags"; import { queryKeys } from "@/lib/query-keys"; import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query"; import type { DataSummary } from "@/lib/analytics/trends"; @@ -195,6 +198,14 @@ export default function DashboardPage() { const { isAuthenticated, user } = useAuth(); const { t } = useTranslations(); const fmt = useFormatters(); + // v1.4.34 IW-B — Coach launch context lives on the auth-shell now so + // every authed route (this dashboard included) can call `askCoach()`. + // Returns `null` outside the provider; the dashboard hero CTA self- + // gates on the hook + the operator's coach feature flag so the + // button silently disappears when either is unavailable. + const coachLaunch = useCoachLaunch(); + const featureFlags = useFeatureFlags(); + const coachEnabled = featureFlags.coach && coachLaunch !== null; const [quickEntryDialog, setQuickEntryDialog] = useState< "measurement" | "mood" | null >(null); @@ -533,49 +544,73 @@ export default function DashboardPage() {

{welcomeText}

- - - {/* v1.4.33 maintainer-item-7 — restore proportional sizing - across viewports. The v1.4.27 fix pinned a `size="sm" - min-h-11` combo to hit WCAG 2.5.5's 44 px touch-target - contract on mobile, but `size="sm"` is h-8 (32 px) and - the `min-h-11` override stretched the cap vertically - while keeping the small horizontal padding — the - button read as klobig on Pixel 5. Switch to - `size="default"` (h-10 = 40 px) on mobile with a - responsive `min-h-11 sm:min-h-9` so the button is - 44 px tall under finger pressure and shrinks back to - the desktop-friendly 36 px on `sm:` upwards. The icon - + label keep the same visual contract. */} +
+ {/* v1.4.34 IW-B — dashboard hero CTA to the Coach drawer. The + provider lives on the auth-shell now so this button can + call `askCoach()` from the same context every authed + surface shares. Mobile-first: 44 px tap target via + `min-h-11` (WCAG 2.5.5), shrinks back to 36 px on `sm:` + upwards alongside the sibling "Hinzufügen" button. Self- + gates on the provider mount + the operator's `coach` + feature flag so the button disappears cleanly when the + Coach surface is disabled or unreachable. */} + {coachEnabled && coachLaunch ? ( - - - {/* Menu items must each carry a self-contained verb-phrase - ("Log measurement", "Log mood") — the trigger above already - says "Add", and the icon is `aria-hidden`, so the visible - text is the only thing distinguishing the rows. v1.4.15 - phase-A3 fix #1 hardened this with a unit guard at - `src/app/__tests__/quick-add-labels.test.ts` — both labels - must differ from each other AND from `common.add`. */} - setQuickEntryDialog("measurement")} - > - - setQuickEntryDialog("mood")}> - - - + ) : null} + + + {/* v1.4.33 maintainer-item-7 — restore proportional sizing + across viewports. The v1.4.27 fix pinned a `size="sm" + min-h-11` combo to hit WCAG 2.5.5's 44 px touch-target + contract on mobile, but `size="sm"` is h-8 (32 px) and + the `min-h-11` override stretched the cap vertically + while keeping the small horizontal padding — the + button read as klobig on Pixel 5. Switch to + `size="default"` (h-10 = 40 px) on mobile with a + responsive `min-h-11 sm:min-h-9` so the button is + 44 px tall under finger pressure and shrinks back to + the desktop-friendly 36 px on `sm:` upwards. The icon + + label keep the same visual contract. */} + + + + {/* Menu items must each carry a self-contained verb-phrase + ("Log measurement", "Log mood") — the trigger above already + says "Add", and the icon is `aria-hidden`, so the visible + text is the only thing distinguishing the rows. v1.4.15 + phase-A3 fix #1 hardened this with a unit guard at + `src/app/__tests__/quick-add-labels.test.ts` — both labels + must differ from each other AND from `common.add`. */} + setQuickEntryDialog("measurement")} + > + + setQuickEntryDialog("mood")}> + + + +
{/* v1.4: Getting-started checklist for brand-new users. diff --git a/src/components/layout/__tests__/auth-shell-coach-hoist.test.ts b/src/components/layout/__tests__/auth-shell-coach-hoist.test.ts new file mode 100644 index 000000000..fbe85e1b3 --- /dev/null +++ b/src/components/layout/__tests__/auth-shell-coach-hoist.test.ts @@ -0,0 +1,62 @@ +/** + * v1.4.34 IW-B — auth-shell hoist contract. + * + * The Coach launch provider used to live on `app/insights/layout.tsx`, + * which meant the drawer was only reachable from `/insights/**`. The + * hoist moves the provider + the drawer mount to `` so every + * authenticated route (dashboard included) shares the same context and + * can call `askCoach()` from any descendant. + * + * Project convention keeps this contract pinned at the source level + * because `` is hard to render under SSR (it reads + * `useAuth()` + `usePathname()` + the router) and we don't want a fully + * mocked `@testing-library/react` setup for a one-line invariant. + */ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +import { describe, expect, it } from "vitest"; + +const AUTH_SHELL_PATH = resolve( + __dirname, + "..", + "auth-shell.tsx", +); +const INSIGHTS_LAYOUT_PATH = resolve( + __dirname, + "..", + "..", + "..", + "app", + "insights", + "layout.tsx", +); + +describe("v1.4.34 IW-B — CoachLaunchProvider hoist", () => { + it("imports the CoachLaunchProvider in the auth-shell", () => { + const source = readFileSync(AUTH_SHELL_PATH, "utf8"); + expect(source).toContain( + 'from "@/lib/insights/coach-launch-context"', + ); + expect(source).toContain("CoachLaunchProvider"); + }); + + it("mounts the LayoutCoachMount inside the auth-shell so the drawer is reachable globally", () => { + const source = readFileSync(AUTH_SHELL_PATH, "utf8"); + expect(source).toContain(""); + }); + + it("no longer mounts the provider in the routed insights layout", () => { + const source = readFileSync(INSIGHTS_LAYOUT_PATH, "utf8"); + // The provider import drops; the FAB import stays because the FAB + // is still scoped to `/insights/**` only. + expect(source).not.toContain( + 'from "@/lib/insights/coach-launch-context"', + ); + // The drawer mount lives on the shell now — verify the routed + // layout doesn't double-mount it. + expect(source).not.toContain("import { LayoutCoachMount }"); + // FAB stays scoped to the routed insights surface. + expect(source).toContain("LayoutCoachFab"); + }); +}); diff --git a/src/components/layout/auth-shell.tsx b/src/components/layout/auth-shell.tsx index b53dd0594..9732e7a7e 100644 --- a/src/components/layout/auth-shell.tsx +++ b/src/components/layout/auth-shell.tsx @@ -5,8 +5,10 @@ import { usePathname, useRouter } from "next/navigation"; import { useEffect } from "react"; import { AchievementUnlockNotifier } from "@/components/gamification/achievement-unlock-notifier"; import { MaintainershipBanner } from "@/components/i18n/maintainership-banner"; +import { LayoutCoachMount } from "@/components/insights/layout-coach-mount"; import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; +import { CoachLaunchProvider } from "@/lib/insights/coach-launch-context"; import { BottomNav } from "./bottom-nav"; import { SidebarNav } from "./sidebar-nav"; import { TopBar } from "./top-bar"; @@ -125,9 +127,19 @@ export function AuthShell({ children }: { children: React.ReactNode }) { ); } - // Authenticated — full app shell + // Authenticated — full app shell. + // + // v1.4.34 IW-B — the Coach launch provider lives here (not inside + // `app/insights/layout.tsx` anymore) so every authenticated route can + // call `askCoach()` from the same context. Pre-hoist the drawer was + // only reachable from `/insights/**`; the dashboard hero CTA now opens + // it without a route hop. `` consumes the same + // context to render the drawer once at the shell level — the + // insights-only mobile FAB stays where it is, inside the routed + // insights layout, so the bottom-right floating action sits beside + // the chart tooltips only on the surfaces that need it. return ( - <> + {showUnlockNotifier && user?.id ? ( ) : null} @@ -170,6 +182,7 @@ export function AuthShell({ children }: { children: React.ReactNode }) { - + + ); }