diff --git a/CHANGELOG.md b/CHANGELOG.md
index d73bcbf86..8c55c05f4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,21 @@
## [Unreleased]
+## [1.18.6.1] — 2026-06-18 — settings and Coach UI follow-ups
+
+A small UI and settings patch on top of v1.18.6. No migration, no API contract change.
+
+### Fixed
+
+- The settings and admin left menu now aligns with the top of the first card, instead of sitting slightly offset.
+- The Coach conversation scrolls within a bounded area with the composer pinned to the bottom, instead of overflowing the panel.
+- The health-record settings section (PDF and FHIR export) is reliably reachable again — its entry no longer disappears from Settings.
+
+### Changed
+
+- The guided tour is first-run only. The per-page and Settings "show tour" triggers are removed.
+- Preventive-care cards now mirror the medication card: the cadence sits as a chip beside the name, due and last dates read as text, and "last done" always shows — with an em-dash when there is nothing yet.
+
## [1.18.6] — 2026-06-18 — offline reads, grounded guidance, and one consistent app
A large release that makes the app genuinely useful offline, gives every metric cited reference context, grounds the AI Coach and Daily Briefing in that same context, adds responsible safety alerts for clinically urgent readings, and brings the newer modules (preventive care, illness, labs) and the whole settings area in line with the established patterns. A guided tour now explains what each module does and how they connect. No breaking changes; all schema changes are additive.
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index 166748f5d..f560b3954 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
- version: 1.18.6
+ version: 1.18.6.1
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
diff --git a/messages/de.json b/messages/de.json
index 321cf3dc0..da00a7739 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -3552,9 +3552,6 @@
"back": "Zurück",
"skip": "Tour überspringen",
"done": "Verstanden",
- "restart": "Modul-Tour neu starten",
- "restartHint": "Zeigt die geführte Modul-Tour beim nächsten Dashboard-Besuch von vorne.",
- "restartConfirmation": "Die Modul-Tour startet bei deinem nächsten Dashboard-Besuch von vorne.",
"steps": {
"dashboardOverview": {
"title": "Dein Dashboard",
@@ -3616,8 +3613,7 @@
"title": "Das war die Tour",
"body": "Du kannst jedes Modul jederzeit einzeln erneut ansehen („Diese Tour zeigen“ auf jeder Seite) oder die komplette Tour unter Einstellungen → Erweitert neu starten."
}
- },
- "moduleTrigger": "Diese Tour zeigen"
+ }
},
"shell": {
"back": "Zurück",
diff --git a/messages/en.json b/messages/en.json
index 2dd3fb4f3..4a9917e07 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -3552,9 +3552,6 @@
"back": "Back",
"skip": "Skip tour",
"done": "Got it",
- "restart": "Restart module tour",
- "restartHint": "Replays the guided module tour from the start on your next dashboard visit.",
- "restartConfirmation": "The module tour will restart from the beginning on your next dashboard visit.",
"steps": {
"dashboardOverview": {
"title": "Your dashboard",
@@ -3616,8 +3613,7 @@
"title": "That’s the tour",
"body": "You can revisit any module on its own at any time (“Show this tour” on every page) or restart the whole tour under Settings → Advanced."
}
- },
- "moduleTrigger": "Show this tour"
+ }
},
"shell": {
"back": "Back",
diff --git a/messages/es.json b/messages/es.json
index e5a4a8a3d..d922014dd 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -3552,9 +3552,6 @@
"back": "Atrás",
"skip": "Saltar tour",
"done": "Entendido",
- "restart": "Reiniciar la visita de módulos",
- "restartHint": "Vuelve a reproducir la visita guiada de módulos desde el principio en tu próxima visita al panel.",
- "restartConfirmation": "La visita de módulos se reiniciará desde el principio en tu próxima visita al panel.",
"steps": {
"dashboardOverview": {
"title": "Tu panel",
@@ -3616,8 +3613,7 @@
"title": "Esto es la visita",
"body": "Puedes volver a ver cualquier módulo por separado cuando quieras («Mostrar esta visita» en cada página) o reiniciar la visita completa en Ajustes → Avanzado."
}
- },
- "moduleTrigger": "Mostrar esta visita"
+ }
},
"shell": {
"back": "Atrás",
diff --git a/messages/fr.json b/messages/fr.json
index a9b43fc82..4837f53e2 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -3552,9 +3552,6 @@
"back": "Précédent",
"skip": "Passer la visite",
"done": "Compris",
- "restart": "Relancer la visite des modules",
- "restartHint": "Rejoue la visite guidée des modules depuis le début lors de votre prochaine ouverture du tableau de bord.",
- "restartConfirmation": "La visite des modules redémarrera depuis le début à votre prochaine ouverture du tableau de bord.",
"steps": {
"dashboardOverview": {
"title": "Votre tableau de bord",
@@ -3616,8 +3613,7 @@
"title": "C’est la fin de la visite",
"body": "Vous pouvez revoir chaque module séparément à tout moment (« Afficher cette visite » sur chaque page) ou relancer la visite complète dans Réglages → Avancé."
}
- },
- "moduleTrigger": "Afficher cette visite"
+ }
},
"shell": {
"back": "Retour",
diff --git a/messages/it.json b/messages/it.json
index 672aaa46a..a285b0228 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -3552,9 +3552,6 @@
"back": "Indietro",
"skip": "Salta tour",
"done": "Ho capito",
- "restart": "Riavvia la visita ai moduli",
- "restartHint": "Riproduce la visita guidata ai moduli dall’inizio al tuo prossimo accesso alla dashboard.",
- "restartConfirmation": "La visita ai moduli ripartirà dall’inizio al tuo prossimo accesso alla dashboard.",
"steps": {
"dashboardOverview": {
"title": "La tua dashboard",
@@ -3616,8 +3613,7 @@
"title": "Questa è la visita guidata",
"body": "Puoi rivedere ogni modulo singolarmente in qualsiasi momento («Mostra questa visita» su ogni pagina) o riavviare l’intera visita da Impostazioni → Avanzate."
}
- },
- "moduleTrigger": "Mostra questa visita"
+ }
},
"shell": {
"back": "Indietro",
diff --git a/messages/pl.json b/messages/pl.json
index b4ccb8202..8d176eccc 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -3552,9 +3552,6 @@
"back": "Wstecz",
"skip": "Pomiń tour",
"done": "Rozumiem",
- "restart": "Uruchom przewodnik modułów ponownie",
- "restartHint": "Odtwarza przewodnik po modułach od początku przy następnej wizycie na pulpicie.",
- "restartConfirmation": "Przewodnik po modułach uruchomi się od początku przy następnej wizycie na pulpicie.",
"steps": {
"dashboardOverview": {
"title": "Twój pulpit",
@@ -3616,8 +3613,7 @@
"title": "To koniec przewodnika",
"body": "Każdy moduł możesz obejrzeć osobno w dowolnej chwili („Pokaż ten przewodnik” na każdej stronie) lub uruchomić cały przewodnik ponownie w Ustawienia → Zaawansowane."
}
- },
- "moduleTrigger": "Pokaż ten przewodnik"
+ }
},
"shell": {
"back": "Wstecz",
diff --git a/package.json b/package.json
index ca1f3164d..1e980ffd2 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "healthlog",
- "version": "1.18.6",
+ "version": "1.18.6.1",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
diff --git a/src/app/achievements/page.tsx b/src/app/achievements/page.tsx
index f0a9448c3..98dae02ee 100644
--- a/src/app/achievements/page.tsx
+++ b/src/app/achievements/page.tsx
@@ -34,7 +34,6 @@ import { Badge } from "@/components/ui/badge";
import { EmptyState } from "@/components/ui/empty-state";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
-import { ModuleTourTrigger } from "@/components/onboarding/module-tour-trigger";
import { formatDate } from "@/lib/format";
import { useAchievementsQuery } from "@/lib/queries/use-achievements-query";
import {
@@ -331,7 +330,6 @@ export default function AchievementsPage() {
-
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
-
-
+ {/* 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 && (
-
-
{/* 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/app/settings/[section]/page.tsx b/src/app/settings/[section]/page.tsx
index b5295c215..dd3e9159d 100644
--- a/src/app/settings/[section]/page.tsx
+++ b/src/app/settings/[section]/page.tsx
@@ -1,5 +1,5 @@
import { notFound } from "next/navigation";
-import type { JSX, ReactNode } from "react";
+import type { JSX } from "react";
import { AccountSection } from "@/components/settings/account-section";
import { AboutSection } from "@/components/settings/about-section";
@@ -29,9 +29,6 @@ import {
type SettingsSectionSlug,
} from "@/components/settings/section-slugs";
import { SettingsShell } from "@/components/settings/settings-shell";
-import { SettingsSectionFrame } from "@/components/settings/settings-section-frame";
-import { SettingsHubBackLink } from "@/components/settings/settings-hub-back-link";
-import { ModuleTourTrigger } from "@/components/onboarding/module-tour-trigger";
/**
* Dynamic settings section route. Each of the `SETTINGS_SECTION_SLUGS`
@@ -74,50 +71,6 @@ const SECTION_COMPONENTS: Record<
advanced: AdvancedSection,
};
-// v1.18.6 (W9) — the two Layout-hub child editors render a "← back to hub"
-// link above their heading. The shared `` 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). */}
);
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). */}
-
{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. */}
{/* v1.15.18 — customise cog. Sits immediately LEFT of the
regenerate button and links to the Insights settings section
(overview arrange + pill sort), matching the Dashboard cog
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({
@@ -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 = (
);
@@ -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
diff --git a/src/components/onboarding/__tests__/tour-launcher-gate.test.ts b/src/components/onboarding/__tests__/tour-launcher-gate.test.ts
index 320fea350..870803ba7 100644
--- a/src/components/onboarding/__tests__/tour-launcher-gate.test.ts
+++ b/src/components/onboarding/__tests__/tour-launcher-gate.test.ts
@@ -10,20 +10,15 @@
* The fix turns the tour into "the second-visit thing": auto-launch
* is suppressed until `onboardingCompletedAt + 24 h`. This file pins
* the pure decision helper so a future refactor doesn't silently
- * regress the gate. The "manual restart from Settings" path bypasses
- * the gate entirely and lives in ``'s
- * `healthlog:tour-restart` window event listener — exercised by the
- * sections SSR smoke test (`settings/__tests__/sections.test.tsx`) +
- * the e2e suite, not by this file.
+ * regress the gate. v1.18.6.1 — the tour is first-time-auto-start
+ * only; the former "manual restart from Settings" bypass was removed.
*/
-import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { describe, expect, it } from "vitest";
import {
- setTourForceLaunch,
shouldAutoLaunchTour,
tourIncludesAchievements,
TOUR_AUTOLAUNCH_DELAY_MS,
- tourForceLaunchKey,
} from "../tour-launcher";
const NOW = Date.parse("2026-05-21T12:00:00Z");
@@ -153,68 +148,3 @@ describe("tourIncludesAchievements() — v1.18.0 B5 module gate", () => {
).toBe(true);
});
});
-
-describe("setTourForceLaunch() — v1.4.47 W5 manual replay bypass", () => {
- // (c) of the W5 acceptance: the manual "launch tour" trigger still
- // works. The launcher's render-phase gate is exercised by
- // shouldAutoLaunchTour() above; the *bypass* is a sessionStorage
- // marker the Settings buttons drop and the launcher reads-and-
- // clears on its next mount. Vitest runs in the Node environment by
- // default — stub a minimal window so the helper's window-guard +
- // try/catch wrappers can be exercised end-to-end.
- let storage: Map;
- const originalWindow = (globalThis as { window?: unknown }).window;
-
- beforeEach(() => {
- storage = new Map();
- (globalThis as { window?: unknown }).window = {
- sessionStorage: {
- getItem: (key: string) => storage.get(key) ?? null,
- setItem: (key: string, value: string) => {
- storage.set(key, value);
- },
- removeItem: (key: string) => {
- storage.delete(key);
- },
- },
- };
- });
-
- afterEach(() => {
- if (originalWindow === undefined) {
- delete (globalThis as { window?: unknown }).window;
- } else {
- (globalThis as { window?: unknown }).window = originalWindow;
- }
- });
-
- it("writes the per-user force-launch marker into sessionStorage", () => {
- // The Settings → About + Settings → Account "Replay tour" buttons
- // call this exact helper after the API round-trip succeeds. The
- // launcher reads-and-clears the marker on its next dashboard
- // mount, bypassing the 24 h auto-launch gate so a same-day user
- // who explicitly asked for the tour actually gets it.
- setTourForceLaunch("u_replay_demo");
- expect(storage.get(tourForceLaunchKey("u_replay_demo"))).toBe("1");
- });
-
- it("scopes the marker per user (two users do not bleed into each other)", () => {
- setTourForceLaunch("u_alice");
- setTourForceLaunch("u_bob");
- expect(storage.get(tourForceLaunchKey("u_alice"))).toBe("1");
- expect(storage.get(tourForceLaunchKey("u_bob"))).toBe("1");
- // The two keys are independent slots — clearing one (which the
- // launcher does on mount) must not affect the other.
- storage.delete(tourForceLaunchKey("u_alice"));
- expect(storage.get(tourForceLaunchKey("u_bob"))).toBe("1");
- });
-
- it("does not throw when window is undefined (SSR safety)", () => {
- // The launcher itself is "use client" but the helper is exported
- // from the same module and could be imported by a server component
- // in the future. The window-guard inside the helper has to keep
- // SSR happy — verify it stays a silent no-op.
- delete (globalThis as { window?: unknown }).window;
- expect(() => setTourForceLaunch("u_x")).not.toThrow();
- });
-});
diff --git a/src/components/onboarding/__tests__/tour-launcher-keys.test.ts b/src/components/onboarding/__tests__/tour-launcher-keys.test.ts
index cc51c0dae..4208c3296 100644
--- a/src/components/onboarding/__tests__/tour-launcher-keys.test.ts
+++ b/src/components/onboarding/__tests__/tour-launcher-keys.test.ts
@@ -12,16 +12,12 @@
* accept that one-off tax in exchange for the multi-tenant
* correctness.
*
- * v1.4.47 W5 adds a third key, `tourForceLaunchKey`, that carries
- * the "user just clicked Replay in Settings → About/Account" intent
- * across the navigation back to the dashboard. The launcher reads
- * and clears it on its next mount, bypassing the new 24 h
- * auto-launch gate so a first-day user who *wants* the tour
- * actually gets it.
+ * v1.18.6.1 — the tour is first-time-auto-start only; the former
+ * `tourForceLaunchKey` replay-bypass key was removed along with every
+ * in-app tour re-entry trigger.
*/
import { describe, expect, it } from "vitest";
import {
- tourForceLaunchKey,
tourReferrerKey,
tourSessionDismissedKey,
} from "../tour-launcher";
@@ -37,20 +33,11 @@ describe("tour-launcher sessionStorage key scoping", () => {
expect(tourReferrerKey("u_abc")).toBe("healthlog-tour-referrer:u_abc");
});
- it("scopes the force-launch key by user id (v1.4.47 W5)", () => {
- expect(tourForceLaunchKey("u_abc")).toBe(
- "healthlog-tour-force-launch:u_abc",
- );
- });
-
it("returns distinct keys for distinct users", () => {
expect(tourSessionDismissedKey("u_alice")).not.toBe(
tourSessionDismissedKey("u_bob"),
);
expect(tourReferrerKey("u_alice")).not.toBe(tourReferrerKey("u_bob"));
- expect(tourForceLaunchKey("u_alice")).not.toBe(
- tourForceLaunchKey("u_bob"),
- );
});
it("does not collide with the legacy un-scoped keys", () => {
@@ -58,20 +45,17 @@ describe("tour-launcher sessionStorage key scoping", () => {
"healthlog-tour-session-dismissed",
);
expect(tourReferrerKey("u_x")).not.toBe("healthlog-tour-referrer");
- expect(tourForceLaunchKey("u_x")).not.toBe("healthlog-tour-force-launch");
});
- it("uses three distinct namespaces so the keys can never alias each other", () => {
+ it("uses distinct namespaces so the keys can never alias each other", () => {
// Belt-and-braces: if a future refactor accidentally collapses
// two key builders onto the same prefix, the launcher's gating
- // logic would conflate "session-dismissed" with "force-launch"
- // and produce a one-shot tour-loop on every dashboard mount.
+ // logic would conflate "session-dismissed" with "referrer".
const userId = "u_test";
const keys = new Set([
tourSessionDismissedKey(userId),
tourReferrerKey(userId),
- tourForceLaunchKey(userId),
]);
- expect(keys.size).toBe(3);
+ expect(keys.size).toBe(2);
});
});
diff --git a/src/components/onboarding/module-tour-trigger.tsx b/src/components/onboarding/module-tour-trigger.tsx
deleted file mode 100644
index 717a3fa01..000000000
--- a/src/components/onboarding/module-tour-trigger.tsx
+++ /dev/null
@@ -1,53 +0,0 @@
-"use client";
-
-/**
- * v1.18.6 — per-module "Diese Tour zeigen" re-entry.
- *
- * A small ghost button each module page drops into its header action
- * cluster. It dispatches the `healthlog:module-tour` window event with
- * the page's stop id; the shell-level `` picks it up and
- * opens the spotlight narrowed to that single module card on the
- * current page (no cross-page navigation, no completion flip).
- *
- * Anchors to the page's `data-tour-id="<…>-hero"` element via the launcher
- * → overlay path. The button itself carries no anchor — it only fires
- * the event.
- */
-
-import { Compass } from "lucide-react";
-
-import { Button } from "@/components/ui/button";
-import { useTranslations } from "@/lib/i18n/context";
-import type { TourStopId } from "@/lib/onboarding/tour-state";
-
-export function ModuleTourTrigger({ stopId }: { stopId: TourStopId }) {
- const { t } = useTranslations();
-
- function show() {
- if (typeof window === "undefined") return;
- try {
- window.dispatchEvent(
- new CustomEvent("healthlog:module-tour", { detail: { stopId } }),
- );
- } catch {
- /* ignore — sandboxed iframes etc. */
- }
- }
-
- return (
-
- );
-}
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/__tests__/settings-shell.test.tsx b/src/components/settings/__tests__/settings-shell.test.tsx
index 275a4ec9f..24caa863b 100644
--- a/src/components/settings/__tests__/settings-shell.test.tsx
+++ b/src/components/settings/__tests__/settings-shell.test.tsx
@@ -239,17 +239,23 @@ describe("", () => {
expect(disabled).not.toContain('href="/settings/coach"');
});
- it("module-gates the Health-record entry off `user.modules.doctorReport` (B3)", () => {
- // Fail-open default: doctorReport undefined → entry shown.
+ it("always surfaces the Health-record entry, regardless of `doctorReport` (v1.18.6.1)", () => {
+ // The health-record (PDF / FHIR) entry is a flagship export capability
+ // and is no longer nav-gated: a default/unset modules map shows it, and
+ // even an explicit `doctorReport: false` keeps the Settings entry-point
+ // reachable. The server-side `/api/export/health-record` route remains
+ // the hard enforcement of the opt-out.
expect(renderShell({ active: "account" })).toContain(
'href="/settings/gesundheitsakte"',
);
- // Explicitly disabled → entry hidden.
- const disabled = renderShell({
+ expect(renderShell({ active: "account", modules: {} })).toContain(
+ 'href="/settings/gesundheitsakte"',
+ );
+ const optedOut = renderShell({
active: "account",
modules: { doctorReport: false },
});
- expect(disabled).not.toContain('href="/settings/gesundheitsakte"');
+ expect(optedOut).toContain('href="/settings/gesundheitsakte"');
});
it("falls back to `account` when the pathname doesn't match a known slug", () => {
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/components/settings/gesundheitsakte-section.tsx b/src/components/settings/gesundheitsakte-section.tsx
index 72ee9adac..df924a4b5 100644
--- a/src/components/settings/gesundheitsakte-section.tsx
+++ b/src/components/settings/gesundheitsakte-section.tsx
@@ -10,10 +10,12 @@
* the generic CSV / JSON data-out paths. Export & Import now keeps only
* the generic data export/import surfaces.
*
- * Module-gated on `doctorReport` (v1.18.0 B3): the nav entry hides when the
- * module is off (see `SETTINGS_SECTIONS` in `settings-shell.tsx`), and the
- * server-side `/api/export/health-record` route refuses with a 403
- * `module.disabled` envelope — so a disabled doctor-report surface cannot be
+ * The `doctorReport` module governs the data layer, not the Settings
+ * entry-point: as of v1.18.6.1 the nav entry always shows (the health record
+ * is a flagship export capability and should never silently vanish from
+ * Settings). The server-side `/api/export/health-record` route is the hard
+ * enforcement — it refuses with a 403 `module.disabled` envelope when the
+ * account has opted out, so a disabled doctor-report surface cannot be
* reached over a Bearer token either.
*/
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 (
-
-
{title || " "}
-
-
- );
-}
diff --git a/src/components/settings/settings-shell.tsx b/src/components/settings/settings-shell.tsx
index fd6c3464e..6575b2834 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
@@ -177,15 +177,21 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
},
{ slug: "api", titleKey: "settings.sections.api.title", icon: KeyRound },
// v1.18.0 (S5) — the full health record (PDF + FHIR R4 + zip package)
- // earns its own home, lifted out of Export & Import. Gated on the
- // `doctorReport` module: when the user turns the doctor-report surface off,
- // the entry hides (the server-side `/api/export/health-record` gate is the
- // hard enforcement; this hides the entry-point to match).
+ // earns its own home, lifted out of Export & Import.
+ //
+ // v1.18.6.1 — the nav entry is NOT module-gated. The health record is a
+ // flagship export capability; gating its only entry-point on the
+ // `doctorReport` opt-out meant a stray toggle in the Modules hub made the
+ // whole section vanish with no in-context way back. The entry now always
+ // shows. The `doctorReport` module still governs the data layer: the
+ // server-side `/api/export/health-record` route remains the hard
+ // enforcement (403 `module.disabled` over Bearer when the account opts
+ // out), and the Modules hub keeps the toggle for anyone who wants the
+ // surface gone elsewhere — but the Settings entry-point stays reachable.
{
slug: "gesundheitsakte",
titleKey: "settings.sections.gesundheitsakte.title",
icon: FileHeart,
- moduleGate: "doctorReport",
},
{
slug: "export",
@@ -211,6 +217,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 +274,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 +332,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 +386,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 +438,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). */}