mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
merge(nav): Coach standalone page, Recovery+Workouts as Insights pills
This commit is contained in:
commit
76d24dd684
30 files changed
+149
-114
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 });
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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"/);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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