mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
1 parent
a062610895
commit
ec82ad3a93
13 files changed
+325
-16
No files matched your search
+16
-3
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
Reference in new issue
Block a user