mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +00:00
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:
1 parent
435021857a
commit
0729c2028b
5 files changed
+194
-67
No files matched your search
@@ -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
@@ -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
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user