Carry the setup answers into the checklist, Settings and the module pages

The getting-started checklist reads the answers: a visit named within a
month adds a "prepare the visit" row that is done once one is on the
calendar, and the data-source row names the wearable that was chosen.
Its per-browser state moves to one module so the done screen, the skip
and Settings write the same keys.

Settings → Account gains the two ways back in: "Set up again" through
the restart route, which resets the ledger, keeps the answers as
prefill and touches no module; and "Show the checklist again", which
forgets what this browser hid.

A page whose module is off now answers with the shared notice and the
one action that helps, in one place for every module page: the shell
maps the route to the module through the navigation model's own gate,
so a page and its nav entry cannot disagree about which switch they
follow. And the tour's two dead stops get their anchors on the
integrations and health-record group headings.
This commit is contained in:
Marc-André Bombeck committed 2026-09-10 15:41:06 +02:00
1 parent a062610895
commit ec82ad3a93
13 files changed
+325 -16

No files matched your search

+6 -1
View File
@@ -27,6 +27,7 @@ import { DemoBanner } from "./demo-banner";
import { OfflineBanner } from "./offline-banner";
import { SharedRecordBanner } from "./shared-record-banner";
import { SharedRecordUnavailable } from "./shared-record-unavailable";
import { ModulePageGate } from "@/components/layout/module-page-gate";
import { RecordScopeHydrationGate } from "./record-scope-hydration-gate";
import { SidebarNav } from "./sidebar-nav";
import { TopBar } from "./top-bar";
@@ -466,7 +467,11 @@ export function AuthShell({
data-slot="main-content-wrapper"
className="mx-auto max-w-screen-xl px-4 pt-6 pb-20 md:px-6"
>
{outsideSharedRecord ? <SharedRecordUnavailable /> : children}
{outsideSharedRecord ? (
<SharedRecordUnavailable />
) : (
<ModulePageGate>{children}</ModulePageGate>
)}
</div>
</main>
</div>
@@ -0,0 +1,63 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import type { ReactNode } from "react";
import { ModuleDisabledNotice } from "@/components/layout/module-disabled-notice";
import {
isNavDestinationActive,
NAV_DESTINATIONS,
} from "@/components/layout/nav-model";
import { Button } from "@/components/ui/button";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
/**
* v1.39 (C2) — the page of a module that is switched off answers with an
* empty state that names the switch (design spec §After the flow), in one
* place for every module page.
*
* The setup flow switches modules OFF for a new record — an explicit `false`
* for every module the answers did not name — and the navigation drops the
* entry. A person who reaches the page anyway (a bookmark, a link in a
* report, the tour) used to get the page's own empty state, which said
* "nothing here yet" about a module that is not there at all. This maps the
* route to the module through the navigation model's own gate, so a module
* page and its nav entry can never disagree about which switch they follow,
* and renders the shared notice with the one action that can help: the
* Modules settings, offered only when the record's own switch is what is off.
*
* Paint, not enforcement: every route behind a module refuses on its own
* through the server gate. Mounted inside the shell, which renders children
* only after the account payload resolved, so the map is never read on a
* hydration render.
*/
export function ModulePageGate({ children }: { children: ReactNode }) {
const pathname = usePathname();
const { user } = useAuth();
const { t } = useTranslations();
const destination = NAV_DESTINATIONS.find(
(d) => d.requiresModule && isNavDestinationActive(d.href, pathname),
);
const moduleKey = destination?.requiresModule;
const off = moduleKey !== undefined && user?.modules?.[moduleKey] === false;
if (!off || !destination || !moduleKey) return <>{children}</>;
const Icon = destination.icon;
return (
<ModuleDisabledNotice
moduleKey={moduleKey}
icon={<Icon className="size-6" />}
action={
<Button asChild size="sm">
<Link href="/settings/modules" data-slot="module-off-open-settings">
{t("moduleOff.openModules")}
</Link>
</Button>
}
/>
);
}
+1 -1
View File
@@ -37,7 +37,7 @@ export function ChoiceChip({
return (
<label
className={cn(
"border-border bg-card flex min-h-11 cursor-pointer items-center gap-3 rounded-lg border px-3 py-2.5 text-left transition-colors duration-150 ease-out motion-reduce:transition-none",
"border-border bg-card flex min-h-11 cursor-pointer items-center gap-3 rounded-lg border p-3 text-left transition-colors duration-150 ease-out motion-reduce:transition-none",
"hover:border-primary/50",
"has-[:focus-visible]:ring-ring has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-offset-2",
checked && "border-primary bg-primary/5",
@@ -10,6 +10,7 @@ import {
ChevronDown,
Pill,
Sparkles,
Stethoscope,
User2,
Wifi,
X,
@@ -28,11 +29,14 @@ import {
visibleChecklist,
type ChecklistItemId,
} from "@/lib/onboarding/checklist";
import { apiGet } from "@/lib/api/api-fetch";
const DISMISSED_ITEMS_KEY = "healthlog-getting-started-dismissed";
const DISMISSED_ALL_KEY = "healthlog-getting-started-hidden";
const EXPANDED_KEY = "healthlog-getting-started-expanded";
import { apiFetchEnvelope, apiGet } from "@/lib/api/api-fetch";
import {
CHECKLIST_DISMISSED_ALL_KEY as DISMISSED_ALL_KEY,
CHECKLIST_DISMISSED_ITEMS_KEY as DISMISSED_ITEMS_KEY,
CHECKLIST_EXPANDED_KEY as EXPANDED_KEY,
} from "@/lib/onboarding/checklist-storage";
import { questionOptionLabelKey } from "@/lib/onboarding/question-config";
import { isBrowserConnectableSource } from "@/lib/onboarding/wizard-steps";
const ITEM_ICONS: Record<ChecklistItemId, LucideIcon> = {
profile: User2,
@@ -41,6 +45,7 @@ const ITEM_ICONS: Record<ChecklistItemId, LucideIcon> = {
dataSource: Wifi,
notifications: Bell,
insights: Sparkles,
visit: Stethoscope,
};
const ITEM_LABEL_KEYS: Record<
@@ -77,8 +82,17 @@ const ITEM_LABEL_KEYS: Record<
description: "gettingStarted.items.insightsDescription",
cta: "gettingStarted.items.insightsCta",
},
visit: {
title: "gettingStarted.items.visitTitle",
description: "gettingStarted.items.visitDescription",
cta: "gettingStarted.items.visitCta",
},
};
interface EncounterListMeta {
upcoming?: number;
}
interface IntegrationsStatus {
integrations?: Array<{ connected?: boolean; enabled?: boolean }>;
}
@@ -288,6 +302,26 @@ export function GettingStartedChecklist() {
enabled: checklistRelevant,
});
// v1.39 (C2) — the "prepare the visit" row, only for the answer that asked
// for it: a visit within a month. Reads the visits list's own meta count so
// the row flips done the moment one is on the calendar.
const visitAsked = user?.onboarding?.needs.visit === "within-a-month";
const { data: encountersMeta } = useQuery<EncounterListMeta>({
queryKey: queryKeys.onboardingUpcomingVisits(),
queryFn: async () => {
const { meta } = await apiFetchEnvelope<unknown, EncounterListMeta>(
"/api/encounters",
);
return meta ?? {};
},
enabled: checklistRelevant && visitAsked,
});
// The named source: "Connect Oura" rather than "Connect a data source"
// for somebody who said their readings come from one.
const namedSource =
user?.onboarding?.needs.sources.find(isBrowserConnectableSource) ?? null;
const medicationCount = medsData?.length ?? 0;
const dataSourceConnected = (integrationsData?.integrations ?? []).some(
(integration) =>
@@ -312,6 +346,7 @@ export function GettingStartedChecklist() {
notificationsConfigured,
insightsConfigured,
dismissedIds,
upcomingVisitCount: encountersMeta?.upcoming ?? 0,
// v1.39 (C1) — the setup answers order these rows: medication first
// for somebody who said they take one daily, the data-source row first
// for somebody who named a wearable. The payload resolves them for the
@@ -331,6 +366,7 @@ export function GettingStartedChecklist() {
notificationsConfigured,
insightsConfigured,
dismissedIds,
encountersMeta?.upcoming,
],
);
@@ -497,7 +533,13 @@ export function GettingStartedChecklist() {
: "truncate text-sm font-medium"
}
>
{t(labels.title)}
{item.id === "dataSource" && namedSource
? t("gettingStarted.items.dataSourceNamedTitle", {
source: t(
questionOptionLabelKey("sources", namedSource),
),
})
: t(labels.title)}
</p>
<p className="text-muted-foreground truncate text-xs">
{t(labels.description)}
@@ -48,6 +48,7 @@ import {
type StatusMessage,
} from "./account-section-utils";
import { AvatarSection } from "./avatar-section";
import { SetupCard } from "./setup-card";
export { resolveInitialTimezone } from "./account-section-utils";
@@ -330,6 +331,10 @@ export function AccountSection() {
{/* Profile photo card */}
<AvatarSection />
{/* v1.39 (C2) — the way back into the first-run experience: the setup
questions again, and the dashboard checklist this browser hid. */}
<SetupCard />
<SettingsCard>
<SettingsCardHeader icon={User} title={t("settings.profile")} />
<form onSubmit={handleSaveProfile} className="space-y-4">
@@ -0,0 +1,88 @@
"use client";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { ListChecks, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useOnboardingRestart } from "@/components/onboarding/use-onboarding-flow";
import { localizedApiError } from "@/lib/api/localized-error";
import { useTranslations } from "@/lib/i18n/context";
import { resetChecklistDismissals } from "@/lib/onboarding/checklist-storage";
/**
* v1.39 (C2) — Settings → Account → Setup.
*
* The two ways back into the first-run experience (design spec §Principles 4
* and §After the flow). "Set up again" asks the questions once more through
* the restart route, which resets the ledger and keeps the answers as
* prefill — and writes no module state, so a module switched on by hand is
* never switched off by a re-run. "Show the checklist again" forgets what
* this browser hid; the checklist's dismissals live in the browser, so the
* button lives on the same origin.
*/
export function SetupCard() {
const { t } = useTranslations();
const router = useRouter();
const restart = useOnboardingRestart();
async function setUpAgain() {
if (restart.isPending) return;
try {
await restart.mutateAsync();
router.push("/onboarding/who");
} catch (err) {
toast.error(localizedApiError(err, t, "onboarding.errorGeneric"));
}
}
function showChecklist() {
const hadState = resetChecklistDismissals();
toast.success(
hadState
? t("settings.setup.checklistShown")
: t("settings.setup.checklistNothingHidden"),
);
}
return (
<SettingsCard data-slot="settings-setup-card">
<SettingsCardHeader
icon={ListChecks}
title={t("settings.setup.title")}
description={t("settings.setup.description")}
/>
<SettingsCardActions>
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11"
onClick={showChecklist}
data-slot="settings-show-checklist"
>
{t("settings.setup.showChecklist")}
</Button>
<Button
type="button"
size="sm"
className="min-h-11"
onClick={setUpAgain}
disabled={restart.isPending}
data-slot="settings-set-up-again"
>
{restart.isPending ? (
<Loader2
aria-hidden="true"
className="size-4 animate-spin motion-reduce:animate-none"
/>
) : null}
{t("settings.setup.setUpAgain")}
</Button>
</SettingsCardActions>
</SettingsCard>
);
}
@@ -43,6 +43,8 @@ export function GesundheitsakteSection() {
<section id="sharing" className="scroll-mt-28 space-y-4">
<SectionHeading
// The module tour's "export" stop (the doctor report) anchors here.
tourId="export-hero"
icon={Share2}
title={t("settings.sections.sharing.title")}
subtitle={t("settings.sections.sharing.subtitle")}
@@ -64,6 +64,8 @@ export function IntegrationsSection({
<SectionHeading
icon={Link2}
title={t("settings.sections.integrations.connectionsHeading")}
// The module tour's "integrations" stop anchors here.
tourId="integrations-hero"
/>
<ConnectionsPanel callbackUrls={callbackUrls} />
+8
View File
@@ -53,6 +53,12 @@ interface SectionHeadingProps {
action?: ReactNode;
/** Optional id on the `<h2>`, e.g. for an `aria-labelledby` link. */
id?: string;
/**
* Stable spotlight-tour anchor, rendered as `data-tour-id` on the root.
* The nav model carries the same field for its entries; a stop whose
* anchor is a settings group heading names it here.
*/
tourId?: string;
className?: string;
}
@@ -62,11 +68,13 @@ export function SectionHeading({
subtitle,
action,
id,
tourId,
className,
}: SectionHeadingProps) {
return (
<div
data-slot="section-heading"
data-tour-id={tourId}
className={cn(
"flex flex-wrap items-center justify-between gap-2",
className,