Files
HealthLog/src/lib/onboarding/__tests__/checklist.test.ts
T
Marc-André Bombeck ec82ad3a93 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.
2026-09-10 15:41:06 +02:00

544 lines
17 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
buildChecklist,
checklistOrderFromNeeds,
checklistProgress,
CHECKLIST_BASE_ITEM_IDS,
CHECKLIST_ITEM_IDS,
isProfileComplete,
shouldShowChecklist,
trendHintFor,
visibleChecklist,
type ChecklistItemId,
} from "../checklist";
import {
defaultOnboardingSteps,
emptyOnboardingNeeds,
type OnboardingNeeds,
type OnboardingStateDto,
} from "../needs";
/** A setup state the flow really produced: Q1 answered, confirm reached. */
function onboardingState(
overrides: Partial<OnboardingStateDto> = {},
needs: Partial<OnboardingNeeds> = {},
): OnboardingStateDto {
return {
steps: defaultOnboardingSteps().map((step) => ({
...step,
status: "done" as const,
})),
needs: { ...emptyOnboardingNeeds(), recordTarget: "me", ...needs },
completedAt: "2026-09-10T08:00:00.000Z",
firstResult: null,
...overrides,
};
}
const completeProfile = {
heightCm: 175,
dateOfBirth: "1990-01-01",
gender: "MALE",
};
function inputs(
overrides: Partial<Parameters<typeof buildChecklist>[0]> = {},
): Parameters<typeof buildChecklist>[0] {
return {
profile: completeProfile,
measurementCount: 0,
medicationCount: 0,
dataSourceConnected: false,
notificationsConfigured: false,
insightsConfigured: false,
dismissedIds: new Set<ChecklistItemId>(),
upcomingVisitCount: 0,
onboarding: null,
...overrides,
};
}
describe("isProfileComplete", () => {
it("requires height, dob and gender all set", () => {
expect(isProfileComplete(completeProfile)).toBe(true);
expect(isProfileComplete({ ...completeProfile, heightCm: null })).toBe(
false,
);
expect(isProfileComplete({ ...completeProfile, dateOfBirth: null })).toBe(
false,
);
expect(isProfileComplete({ ...completeProfile, gender: "" })).toBe(false);
expect(isProfileComplete({ ...completeProfile, heightCm: 0 })).toBe(false);
});
});
describe("buildChecklist", () => {
it("emits the six canonical items in stable order", () => {
const items = buildChecklist(inputs());
expect(items.map((i) => i.id)).toEqual([
"profile",
"measurement",
"medication",
"dataSource",
"notifications",
"insights",
]);
});
it("flags insights done once any provider can serve the user", () => {
const off = buildChecklist(inputs({ insightsConfigured: false }));
expect(off.find((i) => i.id === "insights")?.done).toBe(false);
// `insightsConfigured` is derived from aiAvailable, which is true for a
// personal key, a local model, an OAuth sign-in, OR the operator's
// shared key — any one flips the row done.
const on = buildChecklist(inputs({ insightsConfigured: true }));
expect(on.find((i) => i.id === "insights")?.done).toBe(true);
});
it("marks profile done when all three fields set", () => {
const items = buildChecklist(inputs());
expect(items.find((i) => i.id === "profile")?.done).toBe(true);
});
it("flags measurement done at first reading", () => {
const items = buildChecklist(inputs({ measurementCount: 1 }));
expect(items.find((i) => i.id === "measurement")?.done).toBe(true);
});
it("flags medication and dataSource independently", () => {
const items = buildChecklist(
inputs({ medicationCount: 2, dataSourceConnected: true }),
);
expect(items.find((i) => i.id === "medication")?.done).toBe(true);
expect(items.find((i) => i.id === "dataSource")?.done).toBe(true);
});
it("dataSource is satisfied by any connected source", () => {
// The predicate is source-agnostic — a single boolean stands in for
// Withings, WHOOP, Oura, Polar, Nightscout, Fitbit or Apple Health.
const off = buildChecklist(inputs({ dataSourceConnected: false }));
expect(off.find((i) => i.id === "dataSource")?.done).toBe(false);
const on = buildChecklist(inputs({ dataSourceConnected: true }));
expect(on.find((i) => i.id === "dataSource")?.done).toBe(true);
});
it("propagates per-item dismissal", () => {
const items = buildChecklist(
inputs({ dismissedIds: new Set<ChecklistItemId>(["medication"]) }),
);
expect(items.find((i) => i.id === "medication")?.dismissed).toBe(true);
expect(items.find((i) => i.id === "profile")?.dismissed).toBe(false);
});
it("attaches deep-link hrefs", () => {
const items = buildChecklist(inputs());
const hrefs = Object.fromEntries(items.map((i) => [i.id, i.href]));
expect(hrefs.profile).toBe("/settings/account");
expect(hrefs.measurement).toBe("/measurements");
expect(hrefs.medication).toBe("/medications");
expect(hrefs.dataSource).toBe("/settings/integrations");
expect(hrefs.notifications).toBe("/settings/notifications");
expect(hrefs.insights).toBe("/settings/ai");
});
});
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(
inputs({ dismissedIds: new Set<ChecklistItemId>(["dataSource"]) }),
);
const visible = visibleChecklist(items);
expect(visible.map((i) => i.id)).not.toContain("dataSource");
});
it("counts done items inside the visible subset", () => {
const items = buildChecklist(
inputs({
measurementCount: 3,
medicationCount: 1,
// dismiss the ones not done so percent jumps to 100
dismissedIds: new Set<ChecklistItemId>([
"dataSource",
"notifications",
"insights",
]),
}),
);
const progress = checklistProgress(items);
expect(progress.total).toBe(3);
expect(progress.done).toBe(3);
expect(progress.percent).toBe(100);
expect(progress.allDone).toBe(true);
});
it("returns 0% when nothing done", () => {
const items = buildChecklist({
profile: { heightCm: null, dateOfBirth: null, gender: null },
onboarding: null,
measurementCount: 0,
medicationCount: 0,
dataSourceConnected: false,
notificationsConfigured: false,
insightsConfigured: false,
dismissedIds: new Set(),
upcomingVisitCount: 0,
});
const progress = checklistProgress(items);
expect(progress.percent).toBe(0);
expect(progress.allDone).toBe(false);
});
});
describe("shouldShowChecklist", () => {
it("hides when user fully dismissed the checklist", () => {
const items = buildChecklist(inputs());
expect(
shouldShowChecklist({
onboardingCompletedAt: null,
measurementCount: 0,
dismissedAll: true,
items,
}),
).toBe(false);
});
it("hides once user has finished onboarding AND has 5+ measurements", () => {
const items = buildChecklist(inputs({ measurementCount: 10 }));
expect(
shouldShowChecklist({
onboardingCompletedAt: "2026-01-01T00:00:00Z",
measurementCount: 10,
dismissedAll: false,
items,
}),
).toBe(false);
});
it("stays visible while onboarding is incomplete", () => {
const items = buildChecklist(inputs({ measurementCount: 7 }));
expect(
shouldShowChecklist({
onboardingCompletedAt: null,
measurementCount: 7,
dismissedAll: false,
items,
}),
).toBe(true);
});
it("stays visible while measurement count under 5 even after onboarding ack", () => {
const items = buildChecklist(inputs({ measurementCount: 2 }));
expect(
shouldShowChecklist({
onboardingCompletedAt: "2026-01-01T00:00:00Z",
measurementCount: 2,
dismissedAll: false,
items,
}),
).toBe(true);
});
it("hides when every visible item is done", () => {
const items = buildChecklist(
inputs({
measurementCount: 3,
medicationCount: 1,
dataSourceConnected: true,
notificationsConfigured: true,
insightsConfigured: true,
}),
);
expect(
shouldShowChecklist({
onboardingCompletedAt: null,
measurementCount: 3,
dismissedAll: false,
items,
}),
).toBe(false);
});
});
describe("trendHintFor", () => {
it("hides at 0 readings (chart empty-state owns this)", () => {
expect(trendHintFor(0)).toEqual({ kind: "hidden" });
});
it("shows the right remainder between 1 and 4", () => {
expect(trendHintFor(1)).toEqual({ kind: "show", remaining: 4 });
expect(trendHintFor(4)).toEqual({ kind: "show", remaining: 1 });
});
it("hides once 5 readings reached", () => {
expect(trendHintFor(5)).toEqual({ kind: "hidden" });
expect(trendHintFor(99)).toEqual({ kind: "hidden" });
});
});
describe("checklistOrderFromNeeds", () => {
function order(needs: Partial<OnboardingNeeds>) {
return checklistOrderFromNeeds({ ...emptyOnboardingNeeds(), ...needs });
}
it("keeps profile first whatever the answers say", () => {
for (const needs of [
{},
{ medication: "yes" as const },
{ sources: ["withings" as const] },
{ areas: ["sleep" as const] },
]) {
expect(order(needs)[0]).toBe("profile");
}
});
it("puts medication second for somebody who takes one on a schedule", () => {
expect(order({ medication: "yes" })[1]).toBe("medication");
expect(order({ medication: "sometimes" })[1]).toBe("medication");
expect(order({ medication: "no" })[1]).not.toBe("medication");
});
it("promotes the data source only for a source that is a connection", () => {
expect(order({ sources: ["withings"] }).indexOf("dataSource")).toBe(1);
// Typing readings in and uploading a file are not connections, so they
// say nothing about that row.
expect(order({ sources: ["manual", "file"] }).indexOf("dataSource")).toBe(
3,
);
});
it("promotes the reading row once an area was chosen", () => {
expect(order({ areas: ["blood-pressure"] })[1]).toBe("measurement");
});
it("orders medication ahead of the data source when both are answered", () => {
const ids = order({ medication: "yes", sources: ["oura"] });
expect(ids.indexOf("medication")).toBeLessThan(ids.indexOf("dataSource"));
});
it("never drops or duplicates a row, for any answer combination", () => {
const combinations: Partial<OnboardingNeeds>[] = [
{},
{ medication: "yes" },
{ sources: ["manual"] },
{ sources: ["fitbit"] },
{ areas: ["mood", "labs"] },
{ medication: "sometimes", sources: ["nightscout"], areas: ["glucose"] },
];
for (const needs of combinations) {
const ids = order(needs);
expect([...ids].sort()).toEqual([...CHECKLIST_ITEM_IDS].sort());
}
});
});
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_BASE_ITEM_IDS,
]);
});
it("keeps the fixed order while the answers are still being given", () => {
const items = buildChecklist(
inputs({
onboarding: onboardingState(
{ completedAt: null },
{ medication: "yes" },
),
}),
);
// Re-ordering the dashboard under somebody mid-question would be movement
// they did not ask for.
expect(items.map((i) => i.id)).toEqual([...CHECKLIST_BASE_ITEM_IDS]);
});
it("orders the rows from the answers once the flow is confirmed", () => {
const items = buildChecklist(
inputs({
onboarding: onboardingState(
{},
{ medication: "yes", sources: ["withings"] },
),
}),
);
expect(items.map((i) => i.id).slice(0, 3)).toEqual([
"profile",
"medication",
"dataSource",
]);
expect([...items.map((i) => i.id)].sort()).toEqual(
[...CHECKLIST_BASE_ITEM_IDS].sort(),
);
});
it("carries each row's own state through the reordering", () => {
const items = buildChecklist(
inputs({
medicationCount: 2,
dismissedIds: new Set<ChecklistItemId>(["insights"]),
onboarding: onboardingState({}, { medication: "yes" }),
}),
);
expect(items.find((i) => i.id === "medication")?.done).toBe(true);
expect(items.find((i) => i.id === "insights")?.dismissed).toBe(true);
});
});
describe("shouldShowChecklist and the setup flow", () => {
const doneItems = () =>
buildChecklist(
inputs({
measurementCount: 3,
medicationCount: 1,
dataSourceConnected: true,
notificationsConfigured: true,
insightsConfigured: true,
}),
);
it("no longer disappears at five readings for a record that ran the flow", () => {
const items = buildChecklist(inputs({ measurementCount: 10 }));
expect(
shouldShowChecklist({
onboardingCompletedAt: "2026-01-01T00:00:00Z",
measurementCount: 10,
dismissedAll: false,
items,
onboarding: onboardingState(),
}),
).toBe(true);
});
it("goes when its rows are done, even for a record that ran the flow", () => {
expect(
shouldShowChecklist({
onboardingCompletedAt: "2026-01-01T00:00:00Z",
measurementCount: 10,
dismissedAll: false,
items: doneItems(),
onboarding: onboardingState(),
}),
).toBe(false);
});
it("goes when the person hides the whole list", () => {
expect(
shouldShowChecklist({
onboardingCompletedAt: null,
measurementCount: 0,
dismissedAll: true,
items: buildChecklist(inputs()),
onboarding: onboardingState(),
}),
).toBe(false);
});
it("stays while the flow is unfinished, even with every row done", () => {
// The one task the flow offered is not one of the six rows, so "every row
// done" is not the same statement as "the setup finished".
expect(
shouldShowChecklist({
onboardingCompletedAt: "2026-01-01T00:00:00Z",
measurementCount: 10,
dismissedAll: false,
items: doneItems(),
onboarding: onboardingState({
firstResult: {
task: "log-reading",
target: null,
completedAt: null,
},
}),
}),
).toBe(true);
});
it("does not stay pinned by a first-result task the person passed", () => {
const state = onboardingState({
firstResult: { task: "log-reading", target: null, completedAt: null },
});
expect(
shouldShowChecklist({
onboardingCompletedAt: "2026-01-01T00:00:00Z",
measurementCount: 10,
dismissedAll: false,
items: doneItems(),
onboarding: {
...state,
steps: state.steps.map((step) =>
step.id === "first-result"
? { ...step, status: "skipped" as const }
: step,
),
},
}),
).toBe(false);
});
it("leaves an account that predates the flow on the rule it already had", () => {
// The payload publishes the field for every record, so "nine pending
// steps" must not read as an unfinished setup.
const untouched: OnboardingStateDto = {
steps: defaultOnboardingSteps(),
needs: emptyOnboardingNeeds(),
completedAt: null,
firstResult: null,
};
expect(
shouldShowChecklist({
onboardingCompletedAt: "2026-01-01T00:00:00Z",
measurementCount: 10,
dismissedAll: false,
items: buildChecklist(inputs({ measurementCount: 10 })),
onboarding: untouched,
}),
).toBe(false);
});
});