feat(dashboard): hoist Coach launch provider and add hero CTA

The Coach drawer was previously only reachable from `/insights/**`. The
`<CoachLaunchProvider>` + `<LayoutCoachMount>` now live on the global
`<AuthShell>` so every authenticated route shares the same launch context,
and the dashboard hero strip carries a "Frag den Coach" / "Ask the coach"
CTA next to the existing "Hinzufügen" pill.

Provider hoist
- `<CoachLaunchProvider>` wraps the authed branch in
  `src/components/layout/auth-shell.tsx`; `<LayoutCoachMount>` sits beneath
  the main scroller so the drawer renders once at the shell level.
- `src/app/insights/layout.tsx` drops the provider + drawer mount. The FAB
  stays scoped to `/insights/**` because the floating action would distract
  from the dashboard hero on surfaces with a contextual inline CTA.

Dashboard hero CTA
- New `<Button>` next to the "Add" dropdown calls `coachLaunch.askCoach(null)`
  on the hoisted context. 44 px tap target via `min-h-11` (WCAG 2.5.5),
  shrinks back to 36 px on `sm:` upwards alongside the sibling pill.
- Self-gates on the provider mount + the operator's `coach` feature flag so
  the button silently disappears when the Coach surface is disabled.

The `dashboard.coachCta` i18n key (6 locales) ships in the locale commit
that pairs with this change. New test file pins the hoist contract at the
source level; the existing v1.4.27 R3d MB4 polish test was updated to
reflect the new mount location instead of the old insights-layout one.
This commit is contained in:
Marc-André Bombeck committed 2026-05-16 20:48:50 +02:00
1 parent 435021857a
commit 0729c2028b
5 files changed
+194 -67

No files matched your search

+23 -9
View File
@@ -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 <CoachLaunchProvider> 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 — `<CoachLaunchProvider>` + `<LayoutCoachMount>` moved
// up to the global `<AuthShell>` 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 <CoachLaunchProvider> 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(/<CoachLaunchProvider\b/);
expect(layout).toMatch(/<LayoutCoachMount\b/);
expect(shellSource).toMatch(/<CoachLaunchProvider>/);
expect(shellSource).toMatch(/<LayoutCoachMount \/>/);
});
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", () => {
+19 -16
View File
@@ -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 `<CoachLaunchProvider>` 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 `<CoachLaunchProvider>` so navigating into a sub-page no
* longer unmounts the drawer. Every sub-page mounts a
* `<CoachLaunchButton>` 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
* `<LayoutCoachFab>` (carved out of `<CoachLaunchButton>` so each sub-
* page mount no longer duplicates the FAB into the a11y tree). The
* inline `<CoachLaunchButton>` pill stays on the per-page action rows
* where copy + position matter.
* v1.4.28 R3c — the mobile FAB also lives here via `<LayoutCoachFab>`
* (carved out of `<CoachLaunchButton>` so each sub-page mount no
* longer duplicates the FAB into the a11y tree). The inline
* `<CoachLaunchButton>` pill stays on the per-page action rows where
* copy + position matter.
*
* v1.4.34 IW-B — `<CoachLaunchProvider>` + `<LayoutCoachMount>` now
* live on the global `<AuthShell>` (`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 (
<CoachLaunchProvider>
<>
<InsightsLayoutShell>{children}</InsightsLayoutShell>
<LayoutCoachFab />
<LayoutCoachMount />
</CoachLaunchProvider>
</>
);
}
+74 -39
View File
@@ -13,6 +13,7 @@ import {
Percent,
Plus,
Smile,
Sparkles,
Target,
TrendingUp,
Waves,
@@ -61,6 +62,8 @@ const MedicationComplianceChart = dynamic(
{ ssr: false, loading: () => <ChartSkeleton /> },
);
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() {
</h1>
<p className="text-muted-foreground mt-1 text-sm">{welcomeText}</p>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
{/* 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. */}
<div className="flex items-center gap-2">
{/* 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 ? (
<Button
type="button"
variant="outline"
size="default"
className="min-h-11 sm:min-h-9"
data-tour-id="dashboard-quick-add"
className="min-h-11 gap-1 sm:min-h-9"
data-slot="dashboard-coach-cta"
onClick={() => coachLaunch.askCoach(null)}
>
<Plus className="mr-1 h-4 w-4" />
{t("common.add")}
<Sparkles className="h-4 w-4" aria-hidden="true" />
<span>{t("dashboard.coachCta")}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{/* 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`. */}
<DropdownMenuItem
onClick={() => setQuickEntryDialog("measurement")}
>
<Activity className="mr-2 h-4 w-4" aria-hidden="true" />
{t("dashboard.quickAddMeasurement")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setQuickEntryDialog("mood")}>
<Waves className="mr-2 h-4 w-4" aria-hidden="true" />
{t("dashboard.quickAddMood")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null}
<DropdownMenu>
<DropdownMenuTrigger asChild>
{/* 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. */}
<Button
size="default"
className="min-h-11 sm:min-h-9"
data-tour-id="dashboard-quick-add"
>
<Plus className="mr-1 h-4 w-4" />
{t("common.add")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{/* 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`. */}
<DropdownMenuItem
onClick={() => setQuickEntryDialog("measurement")}
>
<Activity className="mr-2 h-4 w-4" aria-hidden="true" />
{t("dashboard.quickAddMeasurement")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setQuickEntryDialog("mood")}>
<Waves className="mr-2 h-4 w-4" aria-hidden="true" />
{t("dashboard.quickAddMood")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{/* v1.4: Getting-started checklist for brand-new users.
@@ -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 `<AuthShell>` 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 `<AuthShell>` 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("<LayoutCoachMount />");
});
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");
});
});
+16 -3
View File
@@ -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. `<LayoutCoachMount>` 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 (
<>
<CoachLaunchProvider>
{showUnlockNotifier && user?.id ? (
<AchievementUnlockNotifier userId={user.id} />
) : null}
@@ -170,6 +182,7 @@ export function AuthShell({ children }: { children: React.ReactNode }) {
</div>
<BottomNav />
</div>
</>
<LayoutCoachMount />
</CoachLaunchProvider>
);
}