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

+16 -3
View File
@@ -4803,7 +4803,11 @@
"notificationsCta": "Open notifications",
"insightsTitle": "Turn on insights (optional)",
"insightsDescription": "Add a local model or your own AI key to unlock the daily briefing and Coach.",
"insightsCta": "Turn on insights"
"insightsCta": "Turn on insights",
"visitTitle": "Prepare for the visit",
"visitDescription": "Put the appointment on the calendar; the doctor report bundles what to bring.",
"visitCta": "Open visits",
"dataSourceNamedTitle": "Connect {source}"
}
},
"trendHints": {
@@ -6615,7 +6619,15 @@
},
"dangerZoneDetail": "Your account and the credentials you sign in with are kept.",
"deleteAccountCardDetail": "This cannot be undone.",
"passkeyStepUpRequired": "For your security, sign in again before removing a passkey."
"passkeyStepUpRequired": "For your security, sign in again before removing a passkey.",
"setup": {
"title": "Setup",
"description": "The questions that decide what HealthLog shows first, and the getting-started checklist on the dashboard.",
"setUpAgain": "Set up again",
"showChecklist": "Show the checklist again",
"checklistShown": "The checklist is back on the dashboard.",
"checklistNothingHidden": "The checklist was not hidden; it opens on the dashboard."
}
},
"admin": {
"title": "Administration",
@@ -8887,7 +8899,8 @@
"unavailable": {
"description": "The operator switched {module} off for every account on this server. No setting of your own can bring it back.",
"title": "Not available on this server"
}
},
"openModules": "Open Settings, Modules"
},
"vaccinations": {
"title": "Vaccinations",
+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,
+51 -3
View File
@@ -4,6 +4,7 @@ import {
buildChecklist,
checklistOrderFromNeeds,
checklistProgress,
CHECKLIST_BASE_ITEM_IDS,
CHECKLIST_ITEM_IDS,
isProfileComplete,
shouldShowChecklist,
@@ -52,6 +53,7 @@ function inputs(
notificationsConfigured: false,
insightsConfigured: false,
dismissedIds: new Set<ChecklistItemId>(),
upcomingVisitCount: 0,
onboarding: null,
...overrides,
};
@@ -141,6 +143,51 @@ describe("buildChecklist", () => {
});
});
describe("the visit row (v1.39 C2)", () => {
it("exists only for an answer that named a visit within a month", () => {
const asked = buildChecklist(
inputs({ onboarding: onboardingState({}, { visit: "within-a-month" }) }),
);
expect(asked.map((i) => i.id)).toContain("visit");
expect(asked.find((i) => i.id === "visit")).toMatchObject({
done: false,
href: "/checkups",
});
const later = buildChecklist(
inputs({ onboarding: onboardingState({}, { visit: "later" }) }),
);
expect(later.map((i) => i.id)).not.toContain("visit");
expect(buildChecklist(inputs()).map((i) => i.id)).not.toContain("visit");
});
it("is done once a visit is on the calendar", () => {
const items = buildChecklist(
inputs({
onboarding: onboardingState({}, { visit: "within-a-month" }),
upcomingVisitCount: 1,
}),
);
expect(items.find((i) => i.id === "visit")?.done).toBe(true);
});
it("is promoted by the answers like the other needs-shaped rows", () => {
expect(
checklistOrderFromNeeds({
...emptyOnboardingNeeds(),
visit: "within-a-month",
}),
).toEqual([
"profile",
"visit",
"measurement",
"medication",
"dataSource",
"notifications",
"insights",
]);
});
});
describe("visibleChecklist + checklistProgress", () => {
it("hides per-item dismissed rows from the visible list", () => {
const items = buildChecklist(
@@ -180,6 +227,7 @@ describe("visibleChecklist + checklistProgress", () => {
notificationsConfigured: false,
insightsConfigured: false,
dismissedIds: new Set(),
upcomingVisitCount: 0,
});
const progress = checklistProgress(items);
expect(progress.percent).toBe(0);
@@ -332,7 +380,7 @@ describe("checklistOrderFromNeeds", () => {
describe("buildChecklist, ordered by the setup answers", () => {
it("keeps the fixed order for a record that never entered the flow", () => {
expect(buildChecklist(inputs()).map((i) => i.id)).toEqual([
...CHECKLIST_ITEM_IDS,
...CHECKLIST_BASE_ITEM_IDS,
]);
});
@@ -347,7 +395,7 @@ describe("buildChecklist, ordered by the setup answers", () => {
);
// Re-ordering the dashboard under somebody mid-question would be movement
// they did not ask for.
expect(items.map((i) => i.id)).toEqual([...CHECKLIST_ITEM_IDS]);
expect(items.map((i) => i.id)).toEqual([...CHECKLIST_BASE_ITEM_IDS]);
});
it("orders the rows from the answers once the flow is confirmed", () => {
@@ -365,7 +413,7 @@ describe("buildChecklist, ordered by the setup answers", () => {
"dataSource",
]);
expect([...items.map((i) => i.id)].sort()).toEqual(
[...CHECKLIST_ITEM_IDS].sort(),
[...CHECKLIST_BASE_ITEM_IDS].sort(),
);
});
+26 -2
View File
@@ -20,10 +20,17 @@ export const CHECKLIST_ITEM_IDS = [
"dataSource",
"notifications",
"insights",
// v1.39 (C2) — present only for an answer that named a visit within a
// month (design spec §After the flow: "prepare the visit").
"visit",
] as const;
export type ChecklistItemId = (typeof CHECKLIST_ITEM_IDS)[number];
/** The rows every record has; the visit row joins only when asked for. */
export const CHECKLIST_BASE_ITEM_IDS: readonly ChecklistItemId[] =
CHECKLIST_ITEM_IDS.filter((id) => id !== "visit");
export interface ChecklistItem {
id: ChecklistItemId;
done: boolean;
@@ -71,6 +78,12 @@ export interface ChecklistInputs {
insightsConfigured: boolean;
/** Dismissed item ids (per-item localStorage state). */
dismissedIds: ReadonlySet<ChecklistItemId>;
/**
* v1.39 (C2) — visits booked from today on, for the "prepare the visit"
* row. Read only while the row can exist, so a caller that never asked the
* question passes zero.
*/
upcomingVisitCount: number;
/**
* v1.39 (C1) — the needs-based setup state from `GET /api/auth/me`, or null
* for a record that never entered the flow.
@@ -112,6 +125,7 @@ export function checklistOrderFromNeeds(
promote("dataSource");
}
if (needs.areas.length > 0) promote("measurement");
if (needs.visit === "within-a-month") promote("visit");
return [
"profile",
@@ -125,8 +139,8 @@ export function checklistOrderFromNeeds(
/**
* Compute the ordered checklist for the dashboard hero. Stable order:
* profile → measurement → medication → dataSource → notifications →
* insights. Each item carries the deep-link the row's CTA should
* navigate to.
* insights, plus the visit row for an answer that named one. Each item
* carries the deep-link the row's CTA should navigate to.
*/
export function buildChecklist(inputs: ChecklistInputs): ChecklistItem[] {
const profileDone = isProfileComplete(inputs.profile);
@@ -169,6 +183,16 @@ export function buildChecklist(inputs: ChecklistInputs): ChecklistItem[] {
},
];
if (!inputs.onboarding) return items;
// The visit row exists only for the answer that asked for it; every other
// record keeps the six rows it had. Done once a visit is on the calendar.
if (inputs.onboarding.needs.visit === "within-a-month") {
items.push({
id: "visit",
done: inputs.upcomingVisitCount >= 1,
href: "/checkups",
dismissed: inputs.dismissedIds.has("visit"),
});
}
// Ordered from the answers only once the flow has been confirmed: before
// that the answers are still being given, and re-ordering the dashboard
// under someone mid-question would be movement they did not ask for.
+9
View File
@@ -24,4 +24,13 @@ export const onboardingKeys = {
*/
onboardingLatestReading: (type: string) =>
["measurements", "onboarding-latest", type] as const,
/**
* The upcoming-visit count the checklist's "prepare the visit" row reads
* off the visits list's meta. Its own key under the `["encounters"]`
* prefix: the list surfaces cache the rows under `encounters()`, and a
* second `queryFn` shape on the same key would poison that cell, while
* the prefix still lets `encounterDependentKeys` evict this one.
*/
onboardingUpcomingVisits: () =>
["encounters", "onboarding-upcoming"] as const,
};