Merge v1.18.6.1 into main

v1.18.6.1 — settings/admin alignment, first-run-only tour, preventive-care card, coach scroll, FHIR section.
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-06-19 00:13:49 +02:00
commit 85272a956b
39 files changed
+301 -755

No files matched your search

+15
View File
@@ -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.
+1 -1
View File
@@ -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.
+1 -5
View File
@@ -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",
+1 -5
View File
@@ -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",
+1 -5
View File
@@ -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",
+1 -5
View File
@@ -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",
+1 -5
View File
@@ -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",
+1 -5
View File
@@ -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",
+1 -1
View File
@@ -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",
-2
View File
@@ -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() {
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<ModuleTourTrigger stopId="achievements" />
</div>
</div>
+8 -13
View File
@@ -224,21 +224,16 @@ export function AdminSectionRenderer({
}
interface SectionFrameProps {
title: string;
// v1.18.6.1 — the visible heading + subtitle moved to `<AdminShell>`, 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 (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">{title}</h1>
{subtitle && (
<p className="text-muted-foreground text-sm">{subtitle}</p>
)}
</div>
{children}
</div>
);
function SectionFrame({ children }: SectionFrameProps) {
return <div className="space-y-6">{children}</div>;
}
+2 -7
View File
@@ -30,13 +30,8 @@ export default function AdminOverviewPage() {
return (
<AdminShell>
<div className="space-y-6">
<header>
<h1 className="text-2xl font-bold tracking-tight">
{t("admin.title")}
</h1>
<p className="text-muted-foreground text-sm">{t("admin.subtitle")}</p>
</header>
{/* v1.18.6.1 — the console title + subtitle render in `<AdminShell>`
now (its own grid row, so the nav lines up with the first card). */}
{settingsError && (
<div
role="alert"
-2
View File
@@ -6,7 +6,6 @@ import { Minimize2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { CoachConversation } from "@/components/insights/coach-panel/coach-conversation";
import { ModuleTourTrigger } from "@/components/onboarding/module-tour-trigger";
import { useTranslations } from "@/lib/i18n/context";
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
import { useFeatureFlags } from "@/hooks/use-feature-flags";
@@ -100,7 +99,6 @@ export default function CoachPage() {
)}
leadingHeaderActions={
<>
<ModuleTourTrigger stopId="coach" />
<Button
type="button"
variant="ghost"
-2
View File
@@ -16,7 +16,6 @@ import { useMounted } from "@/hooks/use-mounted";
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { ModuleTourTrigger } from "@/components/onboarding/module-tour-trigger";
export default function LabsPage() {
const { isAuthenticated, isLoading } = useAuth();
@@ -68,7 +67,6 @@ export default function LabsPage() {
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<ModuleTourTrigger stopId="labs" />
{/* 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
-2
View File
@@ -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() {
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<ModuleTourTrigger stopId="measurements" />
<Button
className="min-h-11 sm:min-h-9"
onClick={() => setDialogOpen(true)}
-2
View File
@@ -7,7 +7,6 @@ import { useRouter, useSearchParams } from "next/navigation";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { ModuleTourTrigger } from "@/components/onboarding/module-tour-trigger";
import { MedicationWizardDialog } from "@/components/medications/wizard/MedicationWizardDialog";
import { MedicationCard } from "@/components/medications/medication-card";
import { Glp1MedicationCard } from "@/components/medications/glp1-medication-card";
@@ -342,7 +341,6 @@ export default function MedicationsPage() {
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<ModuleTourTrigger stopId="medications" />
{/* 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
-2
View File
@@ -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() {
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<ModuleTourTrigger stopId="mood" />
{/* 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
+11 -56
View File
@@ -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 `<SettingsSectionFrame>` exposes a
// `topSlot` for exactly this; the link lives here so the section bodies stay
// pure card content.
const HUB_CHILD_SLUGS: ReadonlySet<SettingsSectionSlug> = 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 (
<SettingsHubBackLink
href="/settings/layout"
labelKey="settings.sections.layout.backToHub"
/>
);
}
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 (
<span data-tour-id="export-hero">
<ModuleTourTrigger stopId="export" />
</span>
);
}
if (slug === "integrations") {
return (
<span data-tour-id="integrations-hero">
<ModuleTourTrigger stopId="integrations" />
</span>
);
}
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
// `<SettingsSectionFrame>`, 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 `<SettingsShell>`, 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 `<section>` so the historic
// `settings-section-<slug>-title` `aria-labelledby` linkage still resolves.
const body = SectionComponent ? (
<SettingsSectionFrame
slug={section}
topSlot={frameTopSlot(section)}
headingAccessory={frameHeadingAccessory(section)}
<section
aria-labelledby={`settings-section-${section}-title`}
className="space-y-6"
>
<SectionComponent />
</SettingsSectionFrame>
</section>
) : (
<SectionPlaceholder slug={section} />
);
+38 -9
View File
@@ -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 = (
<div>
<h1 className="text-2xl font-bold tracking-tight">{headingTitle}</h1>
<p className="text-muted-foreground text-sm">{headingSubtitle}</p>
</div>
);
// 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 (
<div className="mx-auto w-full max-w-screen-xl">
{/* v1.18.6.1 — heading above the chip strip on mobile; on desktop it
renders inside the grid (row 1 / content column). */}
<div className="mb-4 md:hidden">{headingBlock}</div>
{/* 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) {
</ul>
</nav>
<div className="grid gap-6 md:grid-cols-[220px_1fr]">
{/* 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. */}
<div className="grid gap-6 md:grid-cols-[220px_1fr] md:grid-rows-[auto_1fr]">
{/* Heading — desktop only (mobile renders it above the strip). */}
<div className="hidden md:col-start-2 md:row-start-1 md:block">
{headingBlock}
</div>
{/* Desktop sticky sidebar — starts at the cards row (row 2). */}
<aside
aria-label={t("admin.shell.sectionsNav")}
className="hidden md:block"
className="hidden md:col-start-1 md:row-start-2 md:block"
>
<div className="sticky top-20">
{/* v1.18.6 (W9) — align the first nav item with the top of the
first card, matching the settings shell. */}
<SectionNavHeadingSpacer />
<ul className="space-y-1">
<li>
<Link
@@ -329,12 +356,14 @@ export function AdminShell({ active, children }: AdminShellProps) {
</div>
</aside>
{/* Main column — page renders its own h1 + subtitle. Same
{/* Main column (cards) — row 2 / col 2. Same
`min-h-[calc(100dvh-12rem)]` reserve as `<SettingsShell>`
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. */}
<main className="min-h-[calc(100dvh-12rem)] min-w-0">{children}</main>
<main className="min-h-[calc(100dvh-12rem)] min-w-0 md:col-start-2 md:row-start-2">
{children}
</main>
</div>
</div>
);
-2
View File
@@ -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() {
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<ModuleTourTrigger stopId="cycle" />
<Button
onClick={() => openSheet(today)}
className="min-h-11 sm:min-h-9"
@@ -23,7 +23,6 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useTranslations } from "@/lib/i18n/context";
import { ModuleTourTrigger } from "@/components/onboarding/module-tour-trigger";
import { useAuth } from "@/hooks/use-auth";
import { useMounted } from "@/hooks/use-mounted";
import { getHourForTimeZone } from "@/components/dashboard/range-display";
@@ -97,8 +96,6 @@ export function DashboardHeader({
) : null}
</div>
<div className="flex shrink-0 items-center gap-2">
{/* v1.18.6 — re-enter the guided module tour from the dashboard. */}
<ModuleTourTrigger stopId="dashboardOverview" />
{/* 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
-2
View File
@@ -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() {
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<ModuleTourTrigger stopId="illness" />
{/* v1.18.6 (MOD-01) — wrench left of the primary Add, linking to the
Illness settings page (view + reorder). */}
<Button
@@ -146,7 +146,14 @@ export function CoachDrawerBody({
</Button>
</div>
<div className="min-h-0 flex-1">{thread}</div>
{/* v1.18.6.1 — the thread wrapper must be a flex column itself so the
`<MessageThread>` 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. */}
<div className="flex min-h-0 flex-1 flex-col">{thread}</div>
{/* Composer pinned to the bottom. */}
<div
data-slot="coach-drawer-composer"
@@ -342,7 +342,7 @@ export function MessageThread({
data-slot="coach-message-thread"
role="status"
aria-live="polite"
className="text-muted-foreground flex h-full flex-col items-center justify-center gap-3 px-6 text-center"
className="text-muted-foreground flex min-h-0 flex-1 flex-col items-center justify-center gap-3 px-6 text-center"
>
<div
aria-hidden="true"
@@ -362,7 +362,10 @@ export function MessageThread({
ref={scrollerRef}
data-slot="coach-message-thread"
className={cn(
"flex h-full flex-col gap-4 overflow-y-auto px-4 py-4 sm:px-6",
// v1.18.6.1 — `min-h-0 flex-1` (not `h-full`) so the scroll region
// resolves its height from the flex parent rather than a 100%-of-auto
// chain that let the thread grow instead of scroll.
"flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-4 py-4 sm:px-6",
// v1.18.6 (CCH-01) — on the now full-width page surface a thread
// stretched edge-to-edge sprawled the prose to an unreadable
// measure. Centre the content on a comfortable max width (Claude-
@@ -19,7 +19,6 @@ import {
PopoverTrigger,
} from "@/components/ui/popover";
import { useTranslations } from "@/lib/i18n/context";
import { ModuleTourTrigger } from "@/components/onboarding/module-tour-trigger";
import { cn } from "@/lib/utils";
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
import {
@@ -863,7 +862,6 @@ function InsightsTabStripImpl({
{/* v1.18.6 — per-module tour re-entry, travelling with the
top-right control cluster. Anchors to this strip's
`insights-hero` and opens the Insights tour card on the spot. */}
<ModuleTourTrigger stopId="insights" />
{/* 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
@@ -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({
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<ModuleTourTrigger stopId="vorsorge" />
{wrenchButton}
{addButton}
</div>
@@ -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 ? (
<Badge variant="secondary">{cadence}</Badge>
) : 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 && <Badge variant="secondary">{cadence}</Badge>}
{isCoach && (
<Badge variant="outline">{t("measurementReminders.originCoach")}</Badge>
)}
@@ -781,7 +784,7 @@ function VorsorgeCard({
</Badge>
)}
</>
);
) : 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({
<Card>
<CardContent className="flex items-center justify-between gap-3 px-4 py-2.5">
<div className="min-w-0 space-y-0.5">
<div className="flex flex-wrap items-baseline gap-x-2 text-sm">
<div className="flex flex-wrap items-center gap-x-2 text-sm">
<span className="truncate font-medium">
{resolveReminderLabel(reminder, t)}
</span>
{cadence && <Badge variant="secondary">{cadence}</Badge>}
{cadenceChip}
</div>
<div className="flex flex-wrap items-center gap-x-2 text-xs">
<Badge variant="outline">
<div className="text-muted-foreground flex flex-wrap items-center gap-x-2 text-xs">
<span className="text-foreground">
{t(`measurementReminders.${due.key}`, { days: due.days })}
</Badge>
<span className="text-muted-foreground">{categoryLabel}</span>
</span>
<span>{categoryLabel}</span>
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
@@ -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 = (
<div className="min-h-[2.75rem] space-y-1.5 text-sm">
<div className="text-muted-foreground flex items-baseline justify-between gap-3">
<span className="min-w-0 flex-shrink truncate font-medium">
{t("measurementReminders.nextDueLabel")}
</span>
<span className="text-right">
<Badge variant="secondary">
{t(`measurementReminders.${due.key}`, { days: due.days })}
</Badge>
<span className="text-foreground text-right">
{t(`measurementReminders.${due.key}`, { days: due.days })}
</span>
</div>
{lastValue && (
<div className="text-muted-foreground flex items-baseline justify-between gap-3">
<span className="min-w-0 flex-shrink truncate font-medium">
{t("measurementReminders.lastDoneLabel")}
</span>
<span className="text-foreground text-right">{lastValue}</span>
</div>
)}
<div className="text-muted-foreground flex items-baseline justify-between gap-3">
<span className="min-w-0 flex-shrink truncate font-medium">
{t("measurementReminders.lastDoneLabel")}
</span>
<span className="text-foreground text-right">{lastValue}</span>
</div>
</div>
);
@@ -949,6 +952,7 @@ function VorsorgeCard({
name={resolveReminderLabel(reminder, t)}
dose=""
categoryLabel={categoryLabel}
nameChip={cadenceChip}
stateBadges={stateBadges}
actions={headerActions}
/>
@@ -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 = (
<>
<CardTitle className="text-lg">
{name} {dose}
<CardTitle className="flex flex-wrap items-center gap-x-2 gap-y-1 text-lg">
<span>
{name} {dose}
</span>
{nameChip}
</CardTitle>
{/* D-H6 — state badges (without-notification, paused-since,
inactive) used to share line 2 with the category badge
@@ -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 `<TourLauncher>`'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<string, string>;
const originalWindow = (globalThis as { window?: unknown }).window;
beforeEach(() => {
storage = new Map<string, string>();
(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();
});
});
@@ -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);
});
});
@@ -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 `<TourLauncher>` 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 (
<Button
type="button"
variant="ghost"
size="sm"
onClick={show}
// 44px mobile tap floor, matching the rest of the module headers.
className="text-muted-foreground hover:text-foreground min-h-11 sm:min-h-9"
data-testid={`module-tour-trigger-${stopId}`}
>
<Compass className="h-4 w-4" />
<span className="sr-only sm:not-sr-only">
{t("onboarding.tour.moduleTrigger")}
</span>
</Button>
);
}
+15 -124
View File
@@ -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<TourStopId | null>(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 (
<OnboardingTour
modules={modules}
resumeFromStopId={resumeFromStopId}
filterToStop={singleStop ?? undefined}
// Fire-and-forget checkpoint so a mid-tour reload resumes at the
// right module. Skipped implicitly by the overlay for the single-
// stop re-entry. A failed write is non-fatal — the resume point is
// right module. A failed write is non-fatal — the resume point is
// a convenience, not a correctness invariant.
onProgress={handleProgress}
onClose={async (outcome) => {
@@ -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 });
@@ -136,15 +136,14 @@ describe("settings sections — SSR smoke", () => {
expect(html).not.toContain("settings.sections.");
});
it("<AdvancedSection> 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("<AdvancedSection> 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(<AdvancedSection />);
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("<AccountSection> no longer carries the Restart-tour button", () => {
@@ -239,17 +239,23 @@ describe("<SettingsShell>", () => {
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", () => {
+4 -84
View File
@@ -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
// `<SettingsSectionFrame>` 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 (
<div className="space-y-6">
{/* v1.18.1 (D1) — "Tour neu starten" relocated here from Account. */}
<TourReplayCard />
<ResearchModeCard />
<DataResetCard />
<AccountDeleteCard />
@@ -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 (
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-2">
<div className="flex items-start gap-2">
<Compass className="text-muted-foreground mt-0.5 h-5 w-5 shrink-0" />
<div className="space-y-1">
<h2 className="text-lg font-semibold">
{t("onboarding.tour.restart")}
</h2>
<p className="text-muted-foreground text-xs">
{t("onboarding.tour.restartHint")}
</p>
</div>
</div>
<Button
type="button"
variant="outline"
onClick={handleRestartTour}
disabled={restarting}
data-testid="settings-restart-tour"
className="w-full shrink-0 sm:w-auto"
>
{restarting ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Compass className="h-4 w-4" />
)}
{t("onboarding.tour.restart")}
</Button>
</div>
{feedback && (
<p
role="alert"
className={`mt-2 text-xs ${
feedback.type === "success" ? "text-success" : "text-destructive"
}`}
>
{t(feedback.key)}
</p>
)}
</div>
);
}
/**
* 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 /
@@ -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.
*/
@@ -1,32 +0,0 @@
/**
* `<SectionNavHeadingSpacer>` — 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 `&nbsp;` 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 (
<div aria-hidden="true" className="invisible mb-6 select-none">
<h1 className="text-2xl font-bold tracking-tight">{title || " "}</h1>
<p className="text-muted-foreground text-sm">&nbsp;</p>
</div>
);
}
+110 -20
View File
@@ -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.<active>.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-<slug>-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) ? (
<SettingsHubBackLink
href="/settings/layout"
labelKey="settings.sections.layout.backToHub"
/>
) : undefined,
headingAccessory: undefined,
}
: null);
const headingBlock = resolvedHeading ? (
<div className="space-y-6">
{resolvedHeading.topSlot ? <div>{resolvedHeading.topSlot}</div> : null}
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<h1
id={resolvedHeading.headingId}
className="text-2xl font-bold tracking-tight"
>
{resolvedHeading.title}
</h1>
{resolvedHeading.subtitle ? (
<p className="text-muted-foreground text-sm">
{resolvedHeading.subtitle}
</p>
) : null}
</div>
{resolvedHeading.headingAccessory ? (
<div className="shrink-0">{resolvedHeading.headingAccessory}</div>
) : null}
</div>
</div>
) : 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 (
<div className="mx-auto w-full max-w-screen-xl">
{/* 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 ? <div className="mb-4 md:hidden">{headingBlock}</div> : 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) {
</ul>
</nav>
<div className="grid gap-6 md:grid-cols-[220px_1fr]">
{/* 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. */}
<div className="grid gap-6 md:grid-cols-[220px_1fr] md:grid-rows-[auto_1fr]">
{/* Heading — desktop only here (mobile renders it above the strip). */}
{headingBlock ? (
<div className="hidden md:col-start-2 md:row-start-1 md:block">
{headingBlock}
</div>
) : null}
{/* Desktop sticky sidebar — starts at the cards row (row 2). */}
<aside
aria-label={t("settings.shell.sectionsNav")}
className="hidden md:block"
className="hidden md:col-start-1 md:row-start-2 md:block"
>
<div className="sticky top-20">
{/* v1.18.6 (W9) — push the first nav item down to the top of the
first card (level with the page heading's following gap), so
the menu reads symmetric with the content column. Feed the
active title so the spacer's h1 wraps the same way (L11). */}
<SectionNavHeadingSpacer
title={
activeSection ? t(activeSection.titleKey) : undefined
}
/>
<ul className="space-y-1">
{visibleSections.map((section) => {
const isActive = section.slug === activeSlug;
@@ -412,7 +502,7 @@ export function SettingsShell({ active, children }: SettingsShellProps) {
v1.16.4 — a `<div>`, not `<main>`: the surrounding AuthShell
already provides the page's single `<main>` landmark and a
nested second one is an a11y violation. */}
<div className="min-h-[calc(100dvh-12rem)] min-w-0 pb-24 md:pb-0">
<div className="min-h-[calc(100dvh-12rem)] min-w-0 pb-24 md:col-start-2 md:row-start-2 md:pb-0">
{children}
</div>
</div>
@@ -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<typeof fetch>()
.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<typeof fetch>()
.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<typeof fetch>()
.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<typeof fetch>().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();
});
});
-83
View File
@@ -1,83 +0,0 @@
/**
* v1.4.48 M6b — shared "restart onboarding tour" worker.
*
* `<AccountSection>` and `<AboutSection>` both expose a button that
* resets `users.onboarding_tour_completed = false` and arranges for the
* dashboard's `<TourLauncher>` 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<RestartTourResult> {
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" };
}
}