Files
HealthLog/src/lib/onboarding/checklist.ts
T
Marc-André Bombeck 0dcafeeb70 fix(visits): open an upcoming appointment where it lives
Visits are the second tab of `/checkups`, and the bare path opens the
first one, the preventive-care list, which never shows an appointment.
Every surface that listed an upcoming appointment linked it there: the
dashboard's next-appointment line, the Today rail item, the Today
overview fact and the appointment's push notification. A tap on the
appointment landed on the list it is not on.

Each now opens the visit itself (`/checkups?visit=<id>`), or the visits
tab (`/checkups?view=visits`) for a line naming several appointments.
The checkups page reads `view` to pick a tab. The rail item stops
borrowing the check-up item's action: it carries its own label
("View appointment") and intent `visit.view`. The appointment push names
its visit through the producer's `metadata.url` override; a check-up's
push keeps the checkups list.

The same mis-route sat on three links that lead to visits without
being upcoming: a condition's visit rows, the address book's back link
and the onboarding and timeline prompts to add a visit.

`visit-link-guard.test.ts` keeps the visit paths in the builder and
counts every bare `/checkups` per file, each with the reason it names
the checkups list.
2026-10-21 12:22:36 +02:00

395 lines
14 KiB
TypeScript

/**
* Pure progression logic for the v1.4 dashboard "Getting started"
* checklist. Decoupled from React so it can be unit-tested without a
* DOM. The component layer (`<GettingStartedChecklist>`) reads inputs
* (auth user + analytics summaries + medications count + Withings
* status + dismissed-ids set) and renders against the result.
*/
import { VISITS_PATH } from "@/lib/encounters/visit-links";
import {
hasEnteredOnboardingFlow,
isOnboardingSettled,
type OnboardingNeeds,
type OnboardingStateDto,
} from "./needs";
export const CHECKLIST_ITEM_IDS = [
"profile",
"measurement",
"medication",
"dataSource",
"notifications",
"insights",
// v1.39 — present only for an answer that named a visit within a
// month (design spec §After the flow: "prepare the visit").
"visit",
// v1.39 — "someone I look after" (or "both") with no managed profile
// yet: the answers were given for a record that does not exist, and this
// row is where it gets created.
"managedProfile",
] 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" && id !== "managedProfile");
export interface ChecklistItem {
id: ChecklistItemId;
done: boolean;
/**
* Pre-built href the CTA should jump to. The settings entries point at
* the v1.4 split routes under `/settings/[section]` (introduced by PR
* A2-shell), so the CTA lands directly on the right section instead of
* relying on a hash anchor on the legacy monolith.
*/
href: string;
/** True if the user explicitly hid this row. */
dismissed: boolean;
}
export interface ChecklistInputs {
/** Profile completeness — height + dateOfBirth + gender all set. */
profile: {
heightCm: number | null;
dateOfBirth: string | null;
gender: string | null;
};
/** Total measurements logged across all types. */
measurementCount: number;
/** Number of medications the user has created. */
medicationCount: number;
/**
* True iff at least one data source is connected — Withings, WHOOP,
* Oura, Polar, Nightscout, Fitbit, or Apple Health. Any one satisfies
* the "connect a data source" step.
*/
dataSourceConnected: boolean;
/**
* True iff the user has set up at least one notification channel
* (Telegram, ntfy, or Web Push).
*/
notificationsConfigured: boolean;
/**
* True iff any AI provider can serve this user — a personal key,
* a local model, an OAuth sign-in, OR the operator's shared key.
* `ai.provider.configured` from `GET /api/auth/me`, so the row
* self-satisfies the moment a provider is set up (including on a
* deployment that ships a shared operator key). Presence-only — it
* never decrypts a credential or probes liveness.
*/
insightsConfigured: boolean;
/**
* Whether AI can be set up from here at all (`ai.provider.canConfigure`):
* false while the operator has AI switched off and inside a record
* somebody else owns. The AI row exists only when true. HealthLog is
* complete without AI, so the list never carries a to-do nobody on this
* screen can finish.
*/
aiConfigurable: boolean;
/** Dismissed item ids (per-item localStorage state). */
dismissedIds: ReadonlySet<ChecklistItemId>;
/**
* v1.39 — 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 — managed profiles this account looks after, for the row that
* asks a guardian to create the one their answers were about.
*/
managedProfileCount: number;
/**
* v1.39 — the needs-based setup state from `GET /api/auth/me`, or null
* for a record that never entered the flow.
*
* Required rather than optional: the ordering below is the whole reason the
* answers are published, and a caller that stops passing them should fail to
* compile rather than quietly fall back to the fixed order.
*/
onboarding: OnboardingStateDto | null;
}
/**
* v1.39 — the order the answers imply.
*
* The rows themselves are fixed; what the questionnaire decides is which of
* them a person sees first. Someone who said they take medication daily should
* not have to scroll past "connect a data source" to find it, and someone who
* named a wearable should meet that row before the manual-entry one.
*
* `profile` stays first whatever the answers say — it is the identity the
* clinical surfaces derive from, not a domain choice — and any row the answers
* do not speak to keeps its position relative to the others. The result is a
* reordering, never a removal: nothing here drops a row.
*/
export function checklistOrderFromNeeds(
needs: OnboardingNeeds,
): ChecklistItemId[] {
const promoted: ChecklistItemId[] = [];
const promote = (id: ChecklistItemId) => {
if (!promoted.includes(id)) promoted.push(id);
};
// Answers given for somebody else's record say nothing about the
// guardian's own dashboard: the one row they promote is the profile.
if (needs.recordTarget === "someone-else") {
promote("managedProfile");
return [
"profile",
...promoted,
...CHECKLIST_ITEM_IDS.filter(
(id) => id !== "profile" && !promoted.includes(id),
),
];
}
if (needs.medication === "yes" || needs.medication === "sometimes") {
promote("medication");
}
// "I type them in" and "a file I already have" are not connections, so they
// say nothing about the data-source row.
if (needs.sources.some((s) => s !== "manual" && s !== "file")) {
promote("dataSource");
}
if (needs.areas.length > 0) promote("measurement");
if (needs.visit === "within-a-month") promote("visit");
return [
"profile",
...promoted,
...CHECKLIST_ITEM_IDS.filter(
(id) => id !== "profile" && !promoted.includes(id),
),
];
}
/**
* Compute the ordered checklist for the dashboard hero. Stable order:
* profile → measurement → medication → dataSource → notifications →
* 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);
const items: ChecklistItem[] = [
{
id: "profile",
done: profileDone,
href: "/settings/account",
dismissed: inputs.dismissedIds.has("profile"),
},
{
id: "measurement",
done: inputs.measurementCount >= 1,
href: "/measurements",
dismissed: inputs.dismissedIds.has("measurement"),
},
{
id: "medication",
done: inputs.medicationCount >= 1,
href: "/medications",
dismissed: inputs.dismissedIds.has("medication"),
},
{
id: "dataSource",
done: inputs.dataSourceConnected,
href: "/settings/integrations",
dismissed: inputs.dismissedIds.has("dataSource"),
},
{
id: "notifications",
done: inputs.notificationsConfigured,
href: "/settings/notifications",
dismissed: inputs.dismissedIds.has("notifications"),
},
...(inputs.aiConfigurable
? [
{
id: "insights" as const,
done: inputs.insightsConfigured,
href: "/settings/ai",
dismissed: inputs.dismissedIds.has("insights"),
},
]
: []),
];
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.
const target = inputs.onboarding.needs.recordTarget;
if (
target !== "someone-else" &&
inputs.onboarding.needs.visit === "within-a-month"
) {
items.push({
id: "visit",
done: inputs.upcomingVisitCount >= 1,
// The visits tab, where a visit is added; the bare page opens checkups.
href: VISITS_PATH,
dismissed: inputs.dismissedIds.has("visit"),
});
}
// The profile the answers were about. Offered to "both" as well, which
// the spec says gets the profile at the end.
if (target === "someone-else" || target === "both") {
items.push({
id: "managedProfile",
done: inputs.managedProfileCount >= 1,
href: "/settings/access",
dismissed: inputs.dismissedIds.has("managedProfile"),
});
}
// 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.
if (inputs.onboarding.completedAt === null) return items;
const order = checklistOrderFromNeeds(inputs.onboarding.needs);
return order
.map((id) => items.find((item) => item.id === id))
.filter((item): item is ChecklistItem => item !== undefined);
}
/**
* The checklist visible to the user — drops dismissed items and items
* already done **and** hidden by completion. We keep done items
* visible until the user completes the whole list, so they get the
* satisfaction of ticking the last box.
*/
export function visibleChecklist(items: ChecklistItem[]): ChecklistItem[] {
return items.filter((item) => !item.dismissed);
}
export interface ChecklistProgress {
total: number;
done: number;
/** Integer percentage 0-100. */
percent: number;
/** True iff every non-dismissed item is done. */
allDone: boolean;
}
export function checklistProgress(items: ChecklistItem[]): ChecklistProgress {
const visible = visibleChecklist(items);
const total = visible.length;
const done = visible.filter((item) => item.done).length;
const percent = total === 0 ? 100 : Math.round((done / total) * 100);
return { total, done, percent, allDone: total > 0 && done === total };
}
/**
* Should the dashboard render the checklist at all?
*
* Never when the whole list is dismissed, and never when there is no
* non-dismissed row left to show.
*
* For a record that ran the setup flow the design spec retires the old
* five-reading rule: the list goes when its rows are done or the person hides
* it. Five readings were never evidence that anything was set up. A record
* that never entered the flow keeps the pre-v1.39 rule unchanged — first-run
* wizard unfinished, or fewer than five readings.
*
* A list whose rows are all done still stays while the flow itself is
* unfinished, because the one task the flow offered is not one of the six
* rows: "every row done" and "the setup finished" are different statements.
*/
export interface StillInSetupInputs {
/**
* v1.39 — the needs-based setup state for this record, or null before
* the account payload resolves. A record that never entered the flow reads
* as empty answers with nine pending steps and is treated as having no
* unfinished setup, which is what keeps every account that predates the flow
* on the rule it already had.
*/
onboarding?: OnboardingStateDto | null;
onboardingCompletedAt: string | null;
measurementCount: number;
}
/**
* v1.39 — "is this record still being set up?", as ONE predicate.
*
* Two places decide this: whether the card renders at all, and whether the
* component fetches the data its rows are about. They were two restatements of
* the same rule and they drifted — the visibility rule gained
* `hasEnteredOnboardingFlow`, the query gate kept the older half, and a
* wizard-run account past five readings got a permanently visible card whose
* every network-backed row read "not done". A record whose
* restarted run was abandoned is in the same state: `restart` clears the
* record's own `completedAt` and deliberately not the account stamp, so
* `hasEnteredOnboardingFlow` is what keeps its rows live.
*
* Exported so the component calls it rather than restating it.
*/
export function isStillInSetup(args: StillInSetupInputs): boolean {
return (
hasEnteredOnboardingFlow(args.onboarding ?? null) ||
args.onboardingCompletedAt == null ||
args.measurementCount < 5
);
}
export function shouldShowChecklist(
args: StillInSetupInputs & {
dismissedAll: boolean;
items: ChecklistItem[];
},
): boolean {
if (args.dismissedAll) return false;
const onboarding = args.onboarding ?? null;
const visible = visibleChecklist(args.items);
if (visible.length === 0) return false;
if (!isStillInSetup(args)) return false;
if (!isOnboardingSettled(onboarding)) return true;
return visible.some((item) => !item.done);
}
/**
* The upcoming-visit count off the visits list's own body. `GET
* /api/encounters` answers `{ upcoming, past }` as DATA — there is no `meta`
* on that wire, and a reader that looked for one counted zero forever (the
* "prepare the visit" row could never flip). Pure, so the shape is pinned
* against the DTO the route publishes.
*/
export function upcomingVisitCountFrom(
body: { upcoming: readonly unknown[] } | null | undefined,
): number {
return Array.isArray(body?.upcoming) ? body.upcoming.length : 0;
}
/**
* Profile is "complete" once height, date of birth and gender are all
* set. Display name is captured automatically at signup, so it doesn't
* gate this item.
*/
export function isProfileComplete(
profile: ChecklistInputs["profile"],
): boolean {
return (
profile.heightCm != null &&
profile.heightCm > 0 &&
profile.dateOfBirth != null &&
profile.gender != null &&
profile.gender !== ""
);
}
/**
* Trend hint: when the user has between 1 and 4 readings of a metric we
* surface "First trend after 5 readings — N more to go". 0 readings is
* handled by the existing chart empty-state. ≥5 readings hides the
* hint entirely.
*/
export function trendHintFor(
count: number,
): { kind: "hidden" } | { kind: "show"; remaining: number } {
if (count < 1 || count >= 5) return { kind: "hidden" };
return { kind: "show", remaining: 5 - count };
}