From f67542d29e8f443b57d5382ab6577317d5292d3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 18 Jun 2026 23:58:10 +0200 Subject: [PATCH 1/6] fix(settings): align section nav with the first card via the grid, not a spacer The settings and admin shells reserved the page-heading height in the left nav with a hidden replica block. The replica wrapped differently in the 220px nav column than the real heading in the wide content column, so the first nav item sat too low and drifted across locales. Hoist the heading into the shell and place it in its own grid row spanning only the content column: row 1 holds the heading, row 2 holds the nav and the cards. The nav's first item and the first card now start the same grid row, so they align by construction regardless of how the title or subtitle wraps. Drop the spacer component. --- src/app/admin/[section]/renderer.tsx | 21 ++-- src/app/admin/page.tsx | 9 +- src/app/settings/[section]/page.tsx | 67 ++-------- src/components/admin/admin-shell.tsx | 47 ++++++-- .../settings/section-nav-heading-spacer.tsx | 32 ----- src/components/settings/settings-shell.tsx | 114 +++++++++++++++--- 6 files changed, 158 insertions(+), 132 deletions(-) delete mode 100644 src/components/settings/section-nav-heading-spacer.tsx diff --git a/src/app/admin/[section]/renderer.tsx b/src/app/admin/[section]/renderer.tsx index 4adc26c77..4e87add9e 100644 --- a/src/app/admin/[section]/renderer.tsx +++ b/src/app/admin/[section]/renderer.tsx @@ -224,21 +224,16 @@ export function AdminSectionRenderer({ } interface SectionFrameProps { - title: string; + // v1.18.6.1 — the visible heading + subtitle moved to ``, which + // places them in their own grid row so the left nav lines up with the first + // card. `title` / `subtitle` are still passed by every call site (and remain + // the i18n source of truth the shell reads), but the frame no longer paints + // them — it is now just the `space-y-6` card-stack wrapper. + title?: string; subtitle?: string; children: React.ReactNode; } -function SectionFrame({ title, subtitle, children }: SectionFrameProps) { - return ( -
-
-

{title}

- {subtitle && ( -

{subtitle}

- )} -
- {children} -
- ); +function SectionFrame({ children }: SectionFrameProps) { + return
{children}
; } diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index f9ec57245..3cc1eb594 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -30,13 +30,8 @@ export default function AdminOverviewPage() { return (
-
-

- {t("admin.title")} -

-

{t("admin.subtitle")}

-
- + {/* v1.18.6.1 — the console title + subtitle render in `` + now (its own grid row, so the nav lines up with the first card). */} {settingsError && (
` exposes a -// `topSlot` for exactly this; the link lives here so the section bodies stay -// pure card content. -const HUB_CHILD_SLUGS: ReadonlySet = new Set([ - "dashboard", - "insights", -]); - -/** Optional content rendered above the canonical heading, per slug. */ -function frameTopSlot(slug: SettingsSectionSlug): ReactNode { - if (HUB_CHILD_SLUGS.has(slug)) { - return ( - - ); - } - return undefined; -} - -/** Optional inline content rendered at the top-right of the heading row. */ -function frameHeadingAccessory(slug: SettingsSectionSlug): ReactNode { - // v1.18.6 (W9) — the module guided-tour re-entry triggers used to live in - // a `justify-end` header inside the section body; they move to the frame's - // heading row so the heading itself is the spotlight anchor. - if (slug === "gesundheitsakte") { - return ( - - - - ); - } - if (slug === "integrations") { - return ( - - - - ); - } - return undefined; -} - interface PageProps { // Next.js 16 made route `params` an async Promise. We `await` it before use. params: Promise<{ section: string }>; @@ -138,17 +91,19 @@ export default async function SettingsSectionPage({ params }: PageProps) { // guards the slug, but a future slug added to `SETTINGS_SECTION_SLUGS` // without a wired component would otherwise crash silently — placeholder // surfaces the gap visually instead. - // v1.18.6 (W9) — every section now renders inside the shared - // ``, which emits the one visible heading + subtitle - // each page must show (the section bodies no longer carry their own). + // v1.18.6.1 — the heading + subtitle (and the Layout-hub "← back" link) + // now live in ``, which places them in their own grid row + // spanning only the content column so the left nav's first item lines up + // with the top of the first card. The page body is pure card content, + // wrapped in the labelled `
` so the historic + // `settings-section--title` `aria-labelledby` linkage still resolves. const body = SectionComponent ? ( - - +
) : ( ); diff --git a/src/components/admin/admin-shell.tsx b/src/components/admin/admin-shell.tsx index 4b44785cf..28e54b689 100644 --- a/src/components/admin/admin-shell.tsx +++ b/src/components/admin/admin-shell.tsx @@ -40,7 +40,6 @@ import { cn } from "@/lib/utils"; import { scrollBehaviorForUser } from "@/lib/motion"; import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; -import { SectionNavHeadingSpacer } from "@/components/settings/section-nav-heading-spacer"; import { isAdminSectionSlug, type AdminSectionSlug } from "./section-slugs"; interface AdminSection { @@ -208,6 +207,24 @@ export function AdminShell({ active, children }: AdminShellProps) { // children — until the role is confirmed ADMIN. if (!user || user.role !== "ADMIN") return null; + // v1.18.6.1 — the heading lives in the shell so it can occupy its own grid + // row spanning only the content column; the nav's first item then lines up + // with the top of the first card by construction. `/admin` overview + // (activeSlug === null) shows the console title; each section shows its own. + const headingTitle = + activeSlug === null ? t("admin.title") : t(`admin.section.${activeSlug}.title`); + const headingSubtitle = + activeSlug === null + ? t("admin.subtitle") + : t(`admin.section.${activeSlug}.subtitle`); + + const headingBlock = ( +
+

{headingTitle}

+

{headingSubtitle}

+
+ ); + // v1.4.25 W8 — AuthShell wraps the page in `px-4 py-6 md:px-6` // already, so this inner shell only carries the wider max-width. // Previously the duplicate `px-4 py-6 md:px-6 md:py-8` here was @@ -215,6 +232,10 @@ export function AdminShell({ active, children }: AdminShellProps) { // pages than on Dashboard/Insights/Measurements. return (
+ {/* v1.18.6.1 — heading above the chip strip on mobile; on desktop it + renders inside the grid (row 1 / content column). */} +
{headingBlock}
+ {/* Mobile section strip — horizontal scroll, hidden on md+. `no-scrollbar` (defined in `globals.css`) suppresses the painted scrollbar at the top of every admin page; the @@ -278,16 +299,22 @@ export function AdminShell({ active, children }: AdminShellProps) { -
- {/* Desktop sticky sidebar */} + {/* v1.18.6.1 — two-row grid: heading in row 1 / col 2, nav in row 2 / + col 1, cards in row 2 / col 2. The nav's first item starts the same + grid row as the first card, so they align by construction across + locales and however the heading wraps — no fixed-height spacer. */} +
+ {/* Heading — desktop only (mobile renders it above the strip). */} +
+ {headingBlock} +
+ + {/* Desktop sticky sidebar — starts at the cards row (row 2). */} - {/* Main column — page renders its own h1 + subtitle. Same + {/* Main column (cards) — row 2 / col 2. Same `min-h-[calc(100dvh-12rem)]` reserve as `` so navigating between admin sub-pages (e.g. system-status → login-overview) does not jump the page height while the new section is still fetching. */} -
{children}
+
+ {children} +
); diff --git a/src/components/settings/section-nav-heading-spacer.tsx b/src/components/settings/section-nav-heading-spacer.tsx deleted file mode 100644 index e8d7c13e6..000000000 --- a/src/components/settings/section-nav-heading-spacer.tsx +++ /dev/null @@ -1,32 +0,0 @@ -/** - * `` — invisible spacer that reserves exactly the - * height of a section's heading block (h1 + subtitle) plus the `space-y-6` - * gap that separates the heading from the first card. - * - * v1.18.6 (W9) — Marc's symmetry ask: the left settings / admin menu's first - * item must line up with the TOP OF THE FIRST CARD, not with the page - * heading. The page heading lives in the content column above the first card, - * so the sidebar needs to start one heading-block lower. Replicating the - * exact heading markup (same `text-2xl` h1 + `text-sm` subtitle) as a hidden, - * `aria-hidden` block keeps the offset pixel-accurate across locales and - * line-wrapping — a hard-coded `pt-[…]` would drift the moment a title or - * subtitle wraps to a second line. - * - * It is only rendered on `md+` (the desktop sticky sidebar); the mobile chip - * strip has no heading-to-align-with. - * - * v1.18.6 (L11) — pass the active section's resolved `title` so the h1 line - * wraps identically when a locale's title runs to two lines, rather than - * reserving a single ` ` line. The subtitle copy lives per-page in the - * section frame and is not threaded up here, so the spacer reserves one - * subtitle line; a subtitle that wraps to two lines in a given locale is the - * residual known limitation the original comment called out. - */ -export function SectionNavHeadingSpacer({ title }: { title?: string }) { - return ( - - ); -} diff --git a/src/components/settings/settings-shell.tsx b/src/components/settings/settings-shell.tsx index fd6c3464e..5b4ee1a18 100644 --- a/src/components/settings/settings-shell.tsx +++ b/src/components/settings/settings-shell.tsx @@ -48,7 +48,7 @@ import { isSettingsSectionSlug, type SettingsSectionSlug, } from "./section-slugs"; -import { SectionNavHeadingSpacer } from "./section-nav-heading-spacer"; +import { SettingsHubBackLink } from "./settings-hub-back-link"; // Re-export so existing call-sites importing from this module keep working. // New server-side call-sites (e.g. `generateStaticParams()`) should import @@ -211,6 +211,28 @@ export interface SettingsShellProps { * pass an explicit value so they don't need to mock the router. */ active?: SettingsSectionSlug; + /** + * v1.18.6.1 — the page heading + subtitle now live in the shell, not in + * the section body. The shell places them in their own grid row that + * spans ONLY the content column, so the left nav's first item lines up + * with the top of the first card by construction — no fixed-height + * spacer to drift across locales or subtitle line-wraps. + * + * Title comes from `settings.sections..title` when omitted; pass + * an explicit value for the per-module pages (Vorsorge / Illness / Labs) + * that stay out of the slug registry. `headingId` keeps the historic + * `settings-section--title` anchor for `aria-labelledby` and the + * spotlight tour. + */ + heading?: { + title: string; + subtitle: string; + headingId: string; + /** Rendered above the heading (e.g. a "← back to hub" link). */ + topSlot?: React.ReactNode; + /** Rendered inline at the top-right of the heading row. */ + headingAccessory?: React.ReactNode; + }; children: React.ReactNode; } @@ -246,7 +268,11 @@ function deriveActiveSlug( return isSettingsSectionSlug(candidate) ? candidate : "account"; } -export function SettingsShell({ active, children }: SettingsShellProps) { +export function SettingsShell({ + active, + heading, + children, +}: SettingsShellProps) { const pathname = usePathname(); const { t } = useTranslations(); const { user } = useAuth(); @@ -300,6 +326,53 @@ export function SettingsShell({ active, children }: SettingsShellProps) { }); }, [activeSlug]); + // v1.18.6.1 — resolve the heading once. Pages pass an explicit `heading` + // (the canonical path); the slug fallback keeps the shell renderable on + // its own (and in tests that mount it without a heading) by deriving the + // title from the active section. The heading lives in the shell so it can + // occupy its own grid row spanning only the content column — see the grid + // below. + const resolvedHeading = + heading ?? + (activeSection + ? { + title: t(activeSection.titleKey), + subtitle: t(`settings.sections.${activeSection.slug}.subtitle`), + headingId: `settings-section-${activeSection.slug}-title`, + topSlot: LAYOUT_CHILD_SLUGS.has(activeSection.slug) ? ( + + ) : undefined, + headingAccessory: undefined, + } + : null); + + const headingBlock = resolvedHeading ? ( +
+ {resolvedHeading.topSlot ?
{resolvedHeading.topSlot}
: null} +
+
+

+ {resolvedHeading.title} +

+ {resolvedHeading.subtitle ? ( +

+ {resolvedHeading.subtitle} +

+ ) : null} +
+ {resolvedHeading.headingAccessory ? ( +
{resolvedHeading.headingAccessory}
+ ) : null} +
+
+ ) : null; + // v1.4.25 W8 — AuthShell wraps the page in `px-4 py-6 md:px-6` // already, so this inner shell only carries the wider max-width. // Previously the duplicate `px-4 py-6 md:px-6 md:py-8` here was @@ -307,6 +380,11 @@ export function SettingsShell({ active, children }: SettingsShellProps) { // pages than on Dashboard/Insights/Measurements. return (
+ {/* v1.18.6.1 — on mobile the heading sits above the chip strip; on + desktop it is rendered inside the grid (row 1 / content column) so + it does not paint twice. */} + {headingBlock ?
{headingBlock}
: null} + {/* Mobile section strip — horizontal scroll, hidden on md+. `no-scrollbar` (defined in `globals.css`) suppresses the painted scrollbar; the horizontal swipe + keyboard arrow @@ -354,22 +432,28 @@ export function SettingsShell({ active, children }: SettingsShellProps) { -
- {/* Desktop sticky sidebar */} + {/* v1.18.6.1 — two-row grid on desktop: + row 1 / col 2 = heading (content column only) + row 2 / col 1 = nav row 2 / col 2 = cards + The nav's first item lines up with the top of the first card BY + CONSTRUCTION — both start the grid's second row — so the alignment + holds across locales and however the title or subtitle wraps. No + fixed-height spacer to guess. The `gap-6` between rows reproduces + the `space-y-6` the heading-to-first-card gap used to carry. */} +
+ {/* Heading — desktop only here (mobile renders it above the strip). */} + {headingBlock ? ( +
+ {headingBlock} +
+ ) : null} + + {/* Desktop sticky sidebar — starts at the cards row (row 2). */}
- {/* v1.18.6 (MOD-01) — the wrench is the module's customize entry point, left of the primary Add and linking to the Labs settings page (view, sort order, biomarker CRUD + reorder). Mirrors the diff --git a/src/app/measurements/page.tsx b/src/app/measurements/page.tsx index 7e72ff863..57fc95791 100644 --- a/src/app/measurements/page.tsx +++ b/src/app/measurements/page.tsx @@ -16,7 +16,6 @@ import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; import { Plus, Loader2 } from "lucide-react"; import { useRouter, useSearchParams } from "next/navigation"; import { useTranslations } from "@/lib/i18n/context"; -import { ModuleTourTrigger } from "@/components/onboarding/module-tour-trigger"; export default function MeasurementsPage() { const { isAuthenticated, isLoading } = useAuth(); @@ -125,7 +124,6 @@ export default function MeasurementsPage() {

-
- {/* v1.16.11 (#316) — "Alle fälligen einnehmen". Contextual, not a permanent fixture: it renders only while at least TWO medications are currently due (a single due dose is the diff --git a/src/app/mood/page.tsx b/src/app/mood/page.tsx index 9a8485974..645b183f5 100644 --- a/src/app/mood/page.tsx +++ b/src/app/mood/page.tsx @@ -15,7 +15,6 @@ import { Plus, Loader2, Wrench } from "lucide-react"; import { useRouter } from "next/navigation"; import { useEffect } from "react"; import { useTranslations } from "@/lib/i18n/context"; -import { ModuleTourTrigger } from "@/components/onboarding/module-tour-trigger"; export default function MoodPage() { const { isAuthenticated, isLoading } = useAuth(); @@ -79,7 +78,6 @@ export default function MoodPage() {

- {/* v1.17 — the wrench is the one customize entry point: it links to /settings/mood, which owns the tag groups, custom tags, visibility, and picker order. Same glyph, slot (left diff --git a/src/components/cycle/cycle-view.tsx b/src/components/cycle/cycle-view.tsx index 381316336..74e6b9bea 100644 --- a/src/components/cycle/cycle-view.tsx +++ b/src/components/cycle/cycle-view.tsx @@ -8,7 +8,6 @@ import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { useTranslations } from "@/lib/i18n/context"; -import { ModuleTourTrigger } from "@/components/onboarding/module-tour-trigger"; import { cn } from "@/lib/utils"; import { CycleRing } from "./cycle-ring"; import { BbtChart } from "./bbt-chart"; @@ -151,7 +150,6 @@ export function CycleView() {

-
- {/* v1.18.6 — re-enter the guided module tour from the dashboard. */} - {/* Customize shortcut to the dashboard-customization settings (the tile/layout editor at /settings/dashboard). Sits to the left of the add button as a monochrome ghost icon, with diff --git a/src/components/illness/illness-view.tsx b/src/components/illness/illness-view.tsx index 7af11279d..d3722301e 100644 --- a/src/components/illness/illness-view.tsx +++ b/src/components/illness/illness-view.tsx @@ -33,7 +33,6 @@ import { Skeleton } from "@/components/ui/skeleton"; import { cn } from "@/lib/utils"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { applyOrder, useModuleListPrefs } from "@/lib/module-list-prefs"; -import { ModuleTourTrigger } from "@/components/onboarding/module-tour-trigger"; import { LogDaySheet } from "./log-day-sheet"; import { NewEpisodeSheet } from "./new-episode-sheet"; @@ -380,7 +379,6 @@ export function IllnessView() {

- {/* v1.18.6 (MOD-01) — wrench left of the primary Add, linking to the Illness settings page (view + reorder). */} - ); -} diff --git a/src/components/onboarding/tour-launcher.tsx b/src/components/onboarding/tour-launcher.tsx index c76b6c0d1..02a7e5869 100644 --- a/src/components/onboarding/tour-launcher.tsx +++ b/src/components/onboarding/tour-launcher.tsx @@ -5,8 +5,9 @@ * * Decides *when* to start the spotlight tour. Mounted on the * dashboard; opens the tour exactly once for new users and never - * again unless they explicitly request a replay from - * Settings → Account or Settings → About. + * again. v1.18.6.1 — the tour exists ONLY as this first-time + * auto-start: every in-app re-entry trigger (the per-module "show + * this tour" buttons and the Settings replay card) was removed. * * Gating rules: * @@ -34,17 +35,8 @@ * before the user gets to touch the dashboard. The tour now * auto-launches only after the wizard has been finished at * least 24 h ago. Brand-new users (`onboardingCompletedAt == - * null`) never see the auto-launch — they can still trigger - * the tour manually from Settings → About or Settings → - * Account. - * - * Replay flow: the Settings → Account "Restart" button (and the - * Settings → About "Replay" button added in v1.4.47 W5) POST - * `{ completed: false }` to /api/onboarding/tour, then dispatch a - * `healthlog:tour-restart` window event. This launcher listens for - * that event and force-opens the tour ignoring the auto-launch gate - * (24 h delay, session-dismissed flag, post-wizard grace) so the - * manual replay always works. + * null`) never see the auto-launch on the same session they + * finished onboarding. */ import { usePathname } from "next/navigation"; @@ -53,7 +45,6 @@ import { useQueryClient } from "@tanstack/react-query"; import { useAuth } from "@/hooks/use-auth"; import { apiPost } from "@/lib/api/api-fetch"; -import type { TourStopId } from "@/lib/onboarding/tour-state"; import { OnboardingTour } from "./tour"; const POST_WIZARD_GRACE_MS = 1500; @@ -148,48 +139,6 @@ export function tourReferrerKey(userId: string): string { return `healthlog-tour-referrer:${userId}`; } -/** - * v1.4.47 W5 — sessionStorage key used by the Settings → About - * "Replay the tour" + Settings → Account "Restart onboarding tour" - * buttons to bypass the 24 h auto-launch gate on the next dashboard - * mount. Without this, a first-day user who manually clicks the - * replay button would still hit the 24 h block after navigating to - * the dashboard (because the launcher mounts fresh and re-evaluates - * the auto-launch gate). Exported for the unit-test suite + so the - * key construction stays pinned across refactors. - */ -export function tourForceLaunchKey(userId: string): string { - return `healthlog-tour-force-launch:${userId}`; -} - -/** - * Write the force-launch marker. Called by both Settings → Account - * and Settings → About when the user explicitly requests the tour. - * Exported so the Settings buttons can call it without re-implementing - * the sessionStorage dance (and so the test suite can stub it). - */ -export function setTourForceLaunch(userId: string): void { - if (typeof window === "undefined") return; - try { - window.sessionStorage.setItem(tourForceLaunchKey(userId), "1"); - } catch { - /* ignore — sandboxed iframes etc. */ - } -} - -function readAndClearForceLaunch(userId: string): boolean { - if (typeof window === "undefined") return false; - try { - const key = tourForceLaunchKey(userId); - const value = window.sessionStorage.getItem(key); - if (value !== "1") return false; - window.sessionStorage.removeItem(key); - return true; - } catch { - return false; - } -} - /** * Whether the user just navigated here from `/onboarding`. Read * once at mount via a `useState` lazy initialiser (which runs in a @@ -253,15 +202,6 @@ function writeSessionDismissed(userId: string) { } } -function clearSessionDismissed(userId: string) { - if (typeof window === "undefined") return; - try { - window.sessionStorage.removeItem(tourSessionDismissedKey(userId)); - } catch { - /* ignore */ - } -} - interface TourLauncherProps { /** * v1.18.6 — optional readiness sentinel. The dashboard used to pass @@ -287,7 +227,6 @@ export function TourLauncher({ ready = true }: TourLauncherProps) { // v1.18.6 — when the tour opens for a single-module re-entry // ("Diese Tour zeigen" on a module page) this carries the stop id so // the overlay renders just that card. `null` = the full tour. - const [singleStop, setSingleStop] = useState(null); // Track post-wizard deferral as a boolean so the render path stays // pure (no Date.now() comparisons). The effect below reads / clears it. const [deferredFromWizard, setDeferredFromWizard] = useState(false); @@ -347,17 +286,9 @@ export function TourLauncher({ ready = true }: TourLauncherProps) { // render body). The `decidedFor` tuple above further guarantees // this branch runs at most once per (user, flag) transition. // - // Force-launch bypass: when the user explicitly requests the tour - // from Settings → About / Settings → Account, those buttons set a - // per-user sessionStorage marker via `setTourForceLaunch()`. The - // launcher reads-and-clears the marker here so the tour launches - // on the very next dashboard mount — independent of the 24 h gate, - // the session-dismissed flag, and the post-wizard grace. The - // marker survives navigation but not a page reload, which matches - // user expectation ("I clicked replay, now show me the tour"). - if (readAndClearForceLaunch(user.id)) { - setShowTour(true); - } else if ( + // v1.18.6.1 — the tour is now first-time-auto-start only; there is + // no in-app replay trigger and therefore no force-launch bypass. + if ( !shouldAutoLaunchTour({ onboardingTourCompleted: user.onboardingTourCompleted, onboardingCompletedAt: user.onboardingCompletedAt, @@ -396,39 +327,10 @@ export function TourLauncher({ ready = true }: TourLauncherProps) { return () => window.clearTimeout(id); }, [deferredFromWizard, user?.id]); - // Listen for explicit restart events from Settings → Advanced. A - // restart opens the FULL tour from the top (singleStop cleared). - // setState inside a window-event listener is fine under the - // set-state-in-effect rule. - const userIdForRestart = user?.id; - useEffect(() => { - function onRestart() { - if (userIdForRestart) clearSessionDismissed(userIdForRestart); - setSingleStop(null); - setShowTour(true); - } - window.addEventListener("healthlog:tour-restart", onRestart); - return () => { - window.removeEventListener("healthlog:tour-restart", onRestart); - }; - }, [userIdForRestart]); - - // v1.18.6 — per-module "Diese Tour zeigen" re-entry. A module page - // dispatches `healthlog:module-tour` with `{ stopId }`; the launcher - // opens the overlay narrowed to that single card on the current page - // (no cross-page navigation, no completion flip). - useEffect(() => { - function onModuleTour(e: Event) { - const detail = (e as CustomEvent<{ stopId?: string }>).detail; - if (!detail?.stopId) return; - setSingleStop(detail.stopId as TourStopId); - setShowTour(true); - } - window.addEventListener("healthlog:module-tour", onModuleTour); - return () => { - window.removeEventListener("healthlog:module-tour", onModuleTour); - }; - }, []); + // v1.18.6.1 — the guided tour exists ONLY as the first-time auto-start + // after onboarding. The per-module "show this tour" re-entry and the + // Settings replay card were removed, so there is no longer any in-app + // trigger that re-opens the overlay. // v1.18.6 — stable progress callback so the overlay's checkpoint effect // fires on a real step transition, not on every launcher re-render. @@ -446,20 +348,15 @@ export function TourLauncher({ ready = true }: TourLauncherProps) { // The resolved module map gates which stops appear (default-on) and // keeps the "Schritt n/total" counter honest. const modules = user?.modules; - // v1.18.6 — resume the full tour from the persisted checkpoint. The - // single-stop re-entry ignores the resume point (it opens its card). - const resumeFromStopId = singleStop - ? null - : (user?.onboardingTourProgress?.lastStopId ?? null); + // v1.18.6 — resume the full tour from the persisted checkpoint. + const resumeFromStopId = user?.onboardingTourProgress?.lastStopId ?? null; return ( { @@ -470,13 +367,7 @@ export function TourLauncher({ ready = true }: TourLauncherProps) { // for the rest of the session, and the next refetch of // `/api/auth/me` (which the user gets on any nav) will // surface any divergence. - const wasSingleStop = singleStop !== null; setShowTour(false); - setSingleStop(null); - // A single-stop re-entry must NOT flip the global completion - // flag or write the session-dismiss guard — it's one card, not - // "the tour". Just close. - if (wasSingleStop) return; if (user?.id) writeSessionDismissed(user.id); try { await apiPost("/api/onboarding/tour", { completed: true, outcome }); diff --git a/src/components/settings/__tests__/sections.test.tsx b/src/components/settings/__tests__/sections.test.tsx index 34ac19d7e..296ce813a 100644 --- a/src/components/settings/__tests__/sections.test.tsx +++ b/src/components/settings/__tests__/sections.test.tsx @@ -136,15 +136,14 @@ describe("settings sections — SSR smoke", () => { expect(html).not.toContain("settings.sections."); }); - it(" exposes the single Restart-tour card", () => { - // v1.18.1 (D1) — the tour-replay card moved from Account into Advanced. - // v1.18.6 — relabelled "Restart module tour" and consolidated as the one - // full-tour replay home (the About duplicate was removed). + it(" no longer carries a tour-replay card", () => { + // v1.18.6.1 — the guided tour exists only as the first-time auto-start + // after onboarding. Every in-app replay / re-entry trigger was removed, + // including this Advanced card. const html = render(); - expect(html).toContain('data-testid="settings-restart-tour"'); - expect(html).toContain("Restart module tour"); - // No raw i18n key leaks. - expect(html).not.toContain("onboarding.tour."); + expect(html).not.toContain('data-testid="settings-restart-tour"'); + expect(html).not.toContain("Restart module tour"); + expect(html).not.toContain("onboarding.tour.restart"); }); it(" no longer carries the Restart-tour button", () => { diff --git a/src/components/settings/advanced-section.tsx b/src/components/settings/advanced-section.tsx index b8710d71f..7650cefee 100644 --- a/src/components/settings/advanced-section.tsx +++ b/src/components/settings/advanced-section.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { BookOpenCheck, Compass, Loader2, Trash2 } from "lucide-react"; +import { BookOpenCheck, Loader2, Trash2 } from "lucide-react"; import { AlertDialog, @@ -20,9 +20,7 @@ import { Switch } from "@/components/ui/switch"; import { ResearchModeAcknowledgmentDialog } from "@/components/medications/ResearchModeAcknowledgmentDialog"; import { formatDateTime } from "@/lib/format"; import { SettingsCardHeader } from "@/components/settings/_card-header"; -import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; -import { restartOnboardingTour } from "@/lib/onboarding/tour-restart"; import { queryKeys } from "@/lib/query-keys"; import { type ResearchModeStatus, @@ -53,10 +51,11 @@ import { apiFetchRaw } from "@/lib/api/api-fetch"; export function AdvancedSection() { // v1.18.6 (W9) — the visible heading + subtitle now come from the shared // `` in the route; this body is the advanced cards. + // v1.18.6.1 — the guided tour replay card was removed: the tour now exists + // only as the first-time auto-start after onboarding. This page is the + // research-mode + data/account destructive controls. return (
- {/* v1.18.1 (D1) — "Tour neu starten" relocated here from Account. */} - @@ -64,85 +63,6 @@ export function AdvancedSection() { ); } -/** - * v1.18.1 (D1) — onboarding-tour replay, relocated from Settings → Account. - * - * Resets `users.onboarding_tour_completed` on the server and dispatches a - * `healthlog:tour-restart` window event so a dashboard already in the - * background reopens the spotlight tour immediately. Delegates to the shared - * `restartOnboardingTour()` worker (the same one Settings → About uses) and - * refetches the auth payload so the launcher's `onboardingTourCompleted` flag - * matches the server flip without a navigation. - */ -function TourReplayCard() { - const { t } = useTranslations(); - const { user, refetch } = useAuth(); - const [restarting, setRestarting] = useState(false); - const [feedback, setFeedback] = useState<{ - key: string; - type: "success" | "error"; - } | null>(null); - - async function handleRestartTour() { - setRestarting(true); - setFeedback(null); - const result = await restartOnboardingTour(user?.id); - if (result.ok) { - await refetch(); - setFeedback({ - key: "onboarding.tour.restartConfirmation", - type: "success", - }); - } else { - setFeedback({ key: result.messageKey, type: "error" }); - } - setRestarting(false); - } - - return ( -
-
-
- -
-

- {t("onboarding.tour.restart")} -

-

- {t("onboarding.tour.restartHint")} -

-
-
- -
- {feedback && ( -

- {t(feedback.key)} -

- )} -
- ); -} - /** * v1.4.25 W21 Fix-N (simp-H3) — pull the nested ternary that decided * the toggle's caption out of the JSX. The four-way ladder (loading / diff --git a/src/lib/onboarding/__tests__/tour-restart.test.ts b/src/lib/onboarding/__tests__/tour-restart.test.ts deleted file mode 100644 index fb5b0c08b..000000000 --- a/src/lib/onboarding/__tests__/tour-restart.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -// Stub the launcher import so the helper test never has to instantiate -// the @dnd-kit / launcher render tree just to exercise its -// sessionStorage write side-effect. We assert the helper *calls* it on -// the success branch and skips it when no user-id was passed in. -const setTourForceLaunchSpy = vi.fn(); -vi.mock("@/components/onboarding/tour-launcher", () => ({ - setTourForceLaunch: (userId: string) => setTourForceLaunchSpy(userId), -})); - -import { restartOnboardingTour } from "../tour-restart"; - -describe("restartOnboardingTour()", () => { - beforeEach(() => { - setTourForceLaunchSpy.mockClear(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it("success branch — flips the server flag, fires the force-launch marker, returns { ok: true }", async () => { - const fetchImpl = vi - .fn() - .mockResolvedValue(new Response("{}", { status: 200 })); - - const result = await restartOnboardingTour("user-42", { fetchImpl }); - - expect(result).toEqual({ ok: true }); - expect(fetchImpl).toHaveBeenCalledTimes(1); - const [url, init] = fetchImpl.mock.calls[0]; - expect(url).toBe("/api/onboarding/tour"); - expect(init?.method).toBe("POST"); - expect(init?.headers).toEqual({ "Content-Type": "application/json" }); - expect(init?.body).toBe(JSON.stringify({ completed: false })); - // Per-user force-launch marker fired on success. - expect(setTourForceLaunchSpy).toHaveBeenCalledWith("user-42"); - }); - - it("success branch — skips the force-launch marker when no userId was supplied", async () => { - const fetchImpl = vi - .fn() - .mockResolvedValue(new Response("{}", { status: 200 })); - - const result = await restartOnboardingTour(undefined, { fetchImpl }); - - expect(result).toEqual({ ok: true }); - expect(setTourForceLaunchSpy).not.toHaveBeenCalled(); - }); - - it("error-network branch — fetch rejects, returns { ok: false } with common.networkError", async () => { - const fetchImpl = vi - .fn() - .mockRejectedValue(new TypeError("network down")); - - const result = await restartOnboardingTour("user-42", { fetchImpl }); - - expect(result).toEqual({ ok: false, messageKey: "common.networkError" }); - // No force-launch marker should fire on the error branch — a failed - // server flip MUST NOT arm the launcher. - expect(setTourForceLaunchSpy).not.toHaveBeenCalled(); - }); - - it("error-server-422 branch — non-ok response returns { ok: false } with settings.savingError", async () => { - const fetchImpl = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ error: "Unprocessable" }), { - status: 422, - }), - ); - - const result = await restartOnboardingTour("user-42", { fetchImpl }); - - expect(result).toEqual({ ok: false, messageKey: "settings.savingError" }); - expect(setTourForceLaunchSpy).not.toHaveBeenCalled(); - }); -}); diff --git a/src/lib/onboarding/tour-restart.ts b/src/lib/onboarding/tour-restart.ts deleted file mode 100644 index 75c395c8e..000000000 --- a/src/lib/onboarding/tour-restart.ts +++ /dev/null @@ -1,83 +0,0 @@ -/** - * v1.4.48 M6b — shared "restart onboarding tour" worker. - * - * `` and `` both expose a button that - * resets `users.onboarding_tour_completed = false` and arranges for the - * dashboard's `` to re-open the spotlight on the user's - * next navigation. Until v1.4.48 the two handlers were 90 % identical - * copy-paste — a known drift hazard. This helper owns the surface so - * both call sites narrow to a single mutate-then-route step: - * - * const result = await restartOnboardingTour(user?.id); - * if (result.ok) toast(t("onboarding.tour.restartConfirmation")); - * else toast(t(result.messageKey)); - * - * Side-effects this helper owns: - * - * - PUT `/api/onboarding/tour` with `{ completed: false }` (the same - * server flip both Account and About already drove). - * - On success — write the per-user `setTourForceLaunch(userId)` - * marker into sessionStorage so the next dashboard mount opens - * the tour even when the 24 h auto-launch gate would suppress it. - * - Fire a `healthlog:tour-restart` window event so a dashboard - * mounted in another tab / background reopens the spotlight - * immediately. The dispatch is best-effort; SSR or sandboxed - * iframes that can't construct CustomEvent are tolerated. - * - * Translation + toast rendering are deliberately NOT owned here — the - * helper returns a discriminated `{ ok }` result and lets the React - * component pick the right `useTranslations()` instance. Keeps the - * helper pure of i18n state and easy to unit-test under the default - * vitest "node" environment. - */ - -import { setTourForceLaunch } from "@/components/onboarding/tour-launcher"; - -export type RestartTourResult = - | { ok: true } - | { ok: false; messageKey: string }; - -export interface RestartTourOptions { - /** - * Fetch implementation injected for tests. Defaults to the global - * `fetch` so production code paths read exactly as before. - */ - fetchImpl?: typeof fetch; -} - -/** - * Persist the tour-replay request server-side and arm the launcher - * for the user's next dashboard navigation. - * - * `userId` may be undefined when the auth payload hasn't landed yet — - * the server flip still fires, and the force-launch marker is simply - * skipped (the launcher's secondary "tour-completed = false" path - * still picks the change up on the next `/api/auth/me` read). - */ -export async function restartOnboardingTour( - userId: string | undefined, - opts: RestartTourOptions = {}, -): Promise { - const fetchImpl = opts.fetchImpl ?? fetch; - try { - const res = await fetchImpl("/api/onboarding/tour", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ completed: false }), - }); - if (!res.ok) { - return { ok: false, messageKey: "settings.savingError" }; - } - if (userId) setTourForceLaunch(userId); - try { - if (typeof window !== "undefined") { - window.dispatchEvent(new CustomEvent("healthlog:tour-restart")); - } - } catch { - /* ignore — only matters if a dashboard is already mounted */ - } - return { ok: true }; - } catch { - return { ok: false, messageKey: "common.networkError" }; - } -} From 8234427a94c44fe196216b57b45e514b6cd3a8f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 18 Jun 2026 23:58:35 +0200 Subject: [PATCH 3/6] fix(vorsorge): match the medication-card grammar on the reminder card Mirror the medication card's name + cadence + status layout: - the cadence ("alle 7 Tage") renders as a chip directly to the right of the metric name via a new MedicationCardHeader nameChip slot, instead of on the state-badge row below the category; - the due / overdue status renders as plain text, not a chip; - the "zuletzt erledigt" line always renders, falling back to an em dash when there is no last-done value, instead of being hidden. The green action button is unchanged. Also drop the per-module tour trigger from this surface (the tour is first-time-only now). --- .../vorsorge-section.tsx | 58 ++++++++++--------- .../medications/MedicationCardHeader.tsx | 15 ++++- 2 files changed, 44 insertions(+), 29 deletions(-) diff --git a/src/components/measurement-reminders/vorsorge-section.tsx b/src/components/measurement-reminders/vorsorge-section.tsx index fef044321..0ade64e1e 100644 --- a/src/components/measurement-reminders/vorsorge-section.tsx +++ b/src/components/measurement-reminders/vorsorge-section.tsx @@ -41,7 +41,6 @@ import { import { useFormatters, useTranslations } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; import { applyOrder, useModuleListPrefs } from "@/lib/module-list-prefs"; -import { ModuleTourTrigger } from "@/components/onboarding/module-tour-trigger"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { AlertDialog, @@ -387,7 +386,6 @@ export function VorsorgeSection({

- {wrenchButton} {addButton}
@@ -766,12 +764,17 @@ function VorsorgeCard({ ? t(`measurementReminders.types.${reminder.measurementType}`) : t("measurementReminders.selfPlanned"); - // v1.18.6 (MOD-06) — the cadence renders as a chip beside the item name - // (the med-card cadence-chip convention), alongside the provenance / - // disabled state badges. - const stateBadges = ( + // v1.18.6.1 — the cadence renders as a chip DIRECTLY to the right of the + // metric name (the med-card convention: cadence sits next to the name). + const cadenceChip = cadence ? ( + {cadence} + ) : null; + + // Provenance / disabled state badges keep their own row below the category. + // Omit the row entirely when neither applies (don't render an empty band). + const hasStateBadges = isCoach || !reminder.enabled; + const stateBadges = hasStateBadges ? ( <> - {cadence && {cadence}} {isCoach && ( {t("measurementReminders.originCoach")} )} @@ -781,7 +784,7 @@ function VorsorgeCard({ )} - ); + ) : undefined; // Single med-style kebab: Edit + Delete. The Delete item is the shared // confirm-on-delete control rendered as a full-width menu row. @@ -872,17 +875,17 @@ function VorsorgeCard({
-
+
{resolveReminderLabel(reminder, t)} - {cadence && {cadence}} + {cadenceChip}
-
- +
+ {t(`measurementReminders.${due.key}`, { days: due.days })} - - {categoryLabel} + + {categoryLabel}
@@ -913,29 +916,29 @@ function VorsorgeCard({ // (MOD-06: never "Einnahme" / intake — a measurement is not an intake). The // "next" value carries the discreet neutral due badge; the "last" value is // the last satisfied date. + // v1.18.6.1 — the due/overdue status renders as plain TEXT (not a chip), + // and the "zuletzt erledigt" line ALWAYS renders, falling back to "—" when + // there is no last-done value — mirroring the medication card's next/last + // text rows. const lastValue = reminder.lastSatisfiedAt ? fmt.date(new Date(reminder.lastSatisfiedAt)) - : null; + : "—"; const nextLastSlot = (
{t("measurementReminders.nextDueLabel")} - - - {t(`measurementReminders.${due.key}`, { days: due.days })} - + + {t(`measurementReminders.${due.key}`, { days: due.days })}
- {lastValue && ( -
- - {t("measurementReminders.lastDoneLabel")} - - {lastValue} -
- )} +
+ + {t("measurementReminders.lastDoneLabel")} + + {lastValue} +
); @@ -949,6 +952,7 @@ function VorsorgeCard({ name={resolveReminderLabel(reminder, t)} dose="" categoryLabel={categoryLabel} + nameChip={cadenceChip} stateBadges={stateBadges} actions={headerActions} /> diff --git a/src/components/medications/MedicationCardHeader.tsx b/src/components/medications/MedicationCardHeader.tsx index fe8f3dbb6..a00f84b37 100644 --- a/src/components/medications/MedicationCardHeader.tsx +++ b/src/components/medications/MedicationCardHeader.tsx @@ -36,6 +36,13 @@ export interface MedicationCardHeaderProps { name: string; dose: string; categoryLabel: string; + /** + * v1.18.6.1 — an inline chip rendered DIRECTLY to the right of the name on + * line 1 (e.g. the Vorsorge cadence chip "alle 7 Tage"), mirroring how the + * medication card surfaces its cadence next to the drug name. Kept distinct + * from `stateBadges` (which ride their own row below the category). + */ + nameChip?: ReactNode; stateBadges?: ReactNode; actions?: ReactNode; /** @@ -52,6 +59,7 @@ export function MedicationCardHeader({ name, dose, categoryLabel, + nameChip, stateBadges, actions, href, @@ -59,8 +67,11 @@ export function MedicationCardHeader({ }: MedicationCardHeaderProps) { const body = ( <> - - {name} {dose} + + + {name} {dose} + + {nameChip} {/* D-H6 — state badges (without-notification, paused-since, inactive) used to share line 2 with the category badge From 6f866ff1c650809cf51d3ad6099e26c5cec3ff10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 18 Jun 2026 23:58:43 +0200 Subject: [PATCH 4/6] fix(coach): bound the conversation scroll region so the composer stays pinned The message thread used h-full inside a plain-block wrapper, so its height resolved against an auto-height parent: the scroll container grew with its content instead of scrolling, pushing the composer down and leaving a stray scrollbar on the page. Make the thread wrapper a flex column and switch the thread (and its empty state) from h-full to min-h-0 flex-1, so the scroll region takes a bounded track from the flex parent. The conversation now scrolls cleanly inside the card while the composer stays pinned to the bottom. --- .../insights/coach-panel/coach-drawer-body.tsx | 9 ++++++++- src/components/insights/coach-panel/message-thread.tsx | 7 +++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/components/insights/coach-panel/coach-drawer-body.tsx b/src/components/insights/coach-panel/coach-drawer-body.tsx index 1c677b86f..0afaa5f01 100644 --- a/src/components/insights/coach-panel/coach-drawer-body.tsx +++ b/src/components/insights/coach-panel/coach-drawer-body.tsx @@ -146,7 +146,14 @@ export function CoachDrawerBody({
-
{thread}
+ {/* v1.18.6.1 — the thread wrapper must be a flex column itself so the + `` scroll region resolves a bounded height. The + wrapper used to be a plain block, so the thread's `h-full` resolved + against an auto-height parent and the scroll container grew with its + content instead of scrolling — pushing the composer off-screen and + leaving a stray scrollbar on the page. `flex min-h-0 flex-1 flex-col` + gives the thread a definite track to scroll within. */} +
{thread}
{/* Composer pinned to the bottom. */}