mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +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
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user