feat(api): publish the setup state on the account payload, and read it

Additive: `onboarding` carries the nine steps with their status, the answers as
given, the flow's own completion stamp and the one task it offered. Always
present — a record that never entered the flow reads as empty answers with nine
pending steps, so a client branches on the statuses rather than on whether the
field exists, and an unknown step id is skipped rather than refused.

It is resolved for the CALLER's own record rather than for the active one,
unlike the two module fields beside it. The three write routes are actor-only,
and publishing a setup state the browser cannot write would be a read it could
act on and never complete.

Both ends land here, because a field with no reader is the failure this
repository keeps rediscovering. The getting-started checklist orders its rows
from the answers — medication first for somebody who takes one daily, the
data-source row first for somebody who named a wearable — and it stays on screen
while the flow is unfinished. That is the second half: five readings were never
evidence that the setup finished, and now a pending step, an unconfirmed set of
answers or an offered task that produced nothing keeps the list where it is.

The ordering only applies once the answers are confirmed. Re-ordering the
dashboard under somebody who is still mid-question would be movement they did
not ask for.
This commit is contained in:
Marc-André Bombeck committed 2026-09-10 11:03:47 +02:00
1 parent 021ea8f1ff
commit dbfad0f28e
7 files changed
+852 -35

No files matched your search

+58 -29
View File
@@ -54,6 +54,7 @@ import {
resolveModuleMap,
getOperatorModuleAvailability,
} from "@/lib/modules/gate";
import { loadOnboardingState } from "@/lib/onboarding/needs-store";
import { parseTourProgress } from "@/lib/onboarding/tour-progress";
import { parseNotificationPrefs } from "@/lib/validations/notification-prefs";
import { resolveAccountAccess } from "@/lib/sharing/account-access";
@@ -107,35 +108,50 @@ export const GET = apiHandler(async () => {
// no switch at all, and the reason the own-record payload is untouched.
const sections = accountAccess.active?.sections ?? null;
const [record, cycleProfile, resolvedModules, moduleAvailability] =
await Promise.all([
// The actor's own row is already in hand; a switched session needs the
// record's `gender`, which is the column the cycle gate derives from.
recordId === user.id
? Promise.resolve({ gender: user.gender })
: prisma.user.findUnique({
where: { id: recordId },
select: { gender: true },
}),
prisma.cycleProfile.findUnique({
where: { userId: recordId },
select: { cycleTrackingEnabled: true },
}),
// v1.18.0 — resolved module enable/disable map for every toggleable
// module. cycle/coach reflect their real delegated state (the cycle
// gate / disableCoach + operator assistant flag); the rest read the
// disabled-allowlist `modulePreferencesJson`. Default-on. Clients
// hide a whole module surface end-to-end when its key is `false`.
resolveModuleMap(recordId),
// v1.18.0 — operator-layer availability map (server-wide kill-switch).
// The resolved `modules` map above already AND-s this in, so it cannot
// distinguish operator-off from user-off. The Modules hub needs that
// distinction to render an operator-disabled module as a read-only
// "disabled server-wide" row instead of a live toggle that no-ops.
// Server-WIDE, so it is the same map for every record and is not
// re-scoped here.
getOperatorModuleAvailability(),
]);
const [
record,
cycleProfile,
resolvedModules,
moduleAvailability,
onboarding,
] = await Promise.all([
// The actor's own row is already in hand; a switched session needs the
// record's `gender`, which is the column the cycle gate derives from.
recordId === user.id
? Promise.resolve({ gender: user.gender })
: prisma.user.findUnique({
where: { id: recordId },
select: { gender: true },
}),
prisma.cycleProfile.findUnique({
where: { userId: recordId },
select: { cycleTrackingEnabled: true },
}),
// v1.18.0 — resolved module enable/disable map for every toggleable
// module. cycle/coach reflect their real delegated state (the cycle
// gate / disableCoach + operator assistant flag); the rest read the
// disabled-allowlist `modulePreferencesJson`. Default-on. Clients
// hide a whole module surface end-to-end when its key is `false`.
resolveModuleMap(recordId),
// v1.18.0 — operator-layer availability map (server-wide kill-switch).
// The resolved `modules` map above already AND-s this in, so it cannot
// distinguish operator-off from user-off. The Modules hub needs that
// distinction to render an operator-disabled module as a read-only
// "disabled server-wide" row instead of a live toggle that no-ops.
// Server-WIDE, so it is the same map for every record and is not
// re-scoped here.
getOperatorModuleAvailability(),
// v1.39 (C1) — the needs-based setup state, for the ACTOR's own record
// rather than the active one. The two module fields above answer for the
// record because they say what that record tracks; this one is resolved
// the other way because the three write routes are actor-only
// (`requireAuth()` refuses outright under a switch), and publishing a
// setup state the caller cannot write would be a read the browser could
// act on and never complete. A guardian's route into a managed record's
// flow arrives with the rest of that record's configuration (#939 / C2);
// the storage is already keyed by record for it.
loadOnboardingState(prisma, user.id),
]);
// The masking step, and the one place the record scoping is narrowed rather
// than resolved. `active` is set for ANY live grant — a READ grant scoped to
@@ -186,6 +202,19 @@ export const GET = apiHandler(async () => {
timezone: user.timezone,
onboardingCompletedAt: user.onboardingCompletedAt,
onboardingTourCompleted: user.onboardingTourCompleted,
// v1.39 (C1) — the needs-based setup flow, published additively:
// `steps` (the nine stable ids and their `pending | done | skipped`
// status), `needs` (the answers as given), `completedAt` (the flow's OWN
// completion, distinct from `onboardingCompletedAt` above, which keeps its
// meaning as the first-run redirect's gate) and `firstResult` (the one
// task the flow offered, and whether it produced its result).
//
// Always present, never null: a record that never entered the flow reads
// as empty answers with nine `pending` steps, so a client branches on the
// step statuses rather than on the field's existence. A client that meets
// a step id it does not know skips it — the list is the contract, not the
// client's own copy of it.
onboarding,
// v1.18.6 (DISC-02) — one-time medical-disclaimer acknowledgment. Null
// when never acknowledged; the onboarding welcome gate reads this to
// decide whether to require the acknowledgment before "Get started".
@@ -312,11 +312,17 @@ export function GettingStartedChecklist() {
notificationsConfigured,
insightsConfigured,
dismissedIds,
// 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. Null until the account payload
// resolves, and for any record that never entered the flow.
onboarding: user?.onboarding ?? null,
}),
[
user?.heightCm,
user?.dateOfBirth,
user?.gender,
user?.onboarding,
measurementCount,
medicationCount,
dataSourceConnected,
@@ -334,6 +340,10 @@ export function GettingStartedChecklist() {
measurementCount,
dismissedAll,
items,
// v1.39 (C1) — five readings are not evidence the setup finished. While a
// step is still pending, the answers were never confirmed, or the one task
// the flow offered never produced its result, the list stays.
onboarding: user?.onboarding ?? null,
});
// v1.4.15 phase-A3 fix #3 — flicker guard. Until BOTH the auth user
+17
View File
@@ -23,6 +23,7 @@ import { isDateFormatPreference, storeDateFormat } from "@/lib/date-format";
import { storeTimezone } from "@/lib/timezone-mirror";
import type { ModuleKey } from "@/lib/modules/registry";
import type { ModuleAccessState } from "@/lib/sharing/module-disclosure";
import type { OnboardingStateDto } from "@/lib/onboarding/needs";
import type { TourProgress } from "@/lib/onboarding/tour-progress";
import { clearOfflineCachesForSessionEnd } from "@/lib/pwa/query-persister";
import {
@@ -81,6 +82,18 @@ export interface AuthUser {
* `onboardingTourCompleted` boolean, which stays the auto-launch gate.
*/
onboardingTourProgress: AuthTourProgress | null;
/**
* v1.39 (C1) — the needs-based setup flow's state for this record: the nine
* ordered steps with their `pending | done | skipped` status, the answers as
* given, the flow's own completion stamp (distinct from
* `onboardingCompletedAt` above, which stays the first-run redirect's gate)
* and the one task the flow offered.
*
* Optional in the type so a stale /me payload — an older server image, or a
* test fixture written before the field existed — coerces to "no flow" in
* the mapper below rather than failing the shape.
*/
onboarding?: OnboardingStateDto | null;
/**
* v1.18.6 (DISC-02) — ISO timestamp of the one-time medical-disclaimer
* acknowledgment, or null when never acknowledged. The onboarding welcome
@@ -386,6 +399,10 @@ export async function fetchMe(): Promise<AuthUser> {
// v1.18.6 — coerce against a stale /me payload (older server image
// without the field) to null so the tour starts from the top.
onboardingTourProgress: data.onboardingTourProgress ?? null,
// v1.39 (C1) — coerce against a stale /me payload (an older server image
// without the field) to null, which every reader treats as "no flow was
// ever run" rather than as an unfinished one.
onboarding: data.onboarding ?? null,
// v1.7.0 — coerce against a stale /me payload (older server image
// without the field) so the display defaults to metric.
unitPreference: data.unitPreference === "imperial" ? "imperial" : "metric",
@@ -16,7 +16,9 @@ const completeProfile = {
gender: "MALE",
};
function inputs(overrides: Partial<Parameters<typeof buildChecklist>[0]> = {}) {
function inputs(
overrides: Partial<Parameters<typeof buildChecklist>[0]> = {},
): Parameters<typeof buildChecklist>[0] {
return {
profile: completeProfile,
measurementCount: 0,
@@ -25,6 +27,7 @@ function inputs(overrides: Partial<Parameters<typeof buildChecklist>[0]> = {}) {
notificationsConfigured: false,
insightsConfigured: false,
dismissedIds: new Set<ChecklistItemId>(),
onboarding: null,
...overrides,
};
}
@@ -145,6 +148,7 @@ describe("visibleChecklist + checklistProgress", () => {
it("returns 0% when nothing done", () => {
const items = buildChecklist({
profile: { heightCm: null, dateOfBirth: null, gender: null },
onboarding: null,
measurementCount: 0,
medicationCount: 0,
dataSourceConnected: false,
+78 -2
View File
@@ -6,6 +6,12 @@
* status + dismissed-ids set) and renders against the result.
*/
import {
isOnboardingSettled,
type OnboardingNeeds,
type OnboardingStateDto,
} from "./needs";
export const CHECKLIST_ITEM_IDS = [
"profile",
"measurement",
@@ -64,6 +70,55 @@ export interface ChecklistInputs {
insightsConfigured: boolean;
/** Dismissed item ids (per-item localStorage state). */
dismissedIds: ReadonlySet<ChecklistItemId>;
/**
* v1.39 (C1) — 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 (C1) — 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);
};
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");
return [
"profile",
...promoted,
...CHECKLIST_ITEM_IDS.filter(
(id) => id !== "profile" && !promoted.includes(id),
),
];
}
/**
@@ -112,7 +167,15 @@ export function buildChecklist(inputs: ChecklistInputs): ChecklistItem[] {
dismissed: inputs.dismissedIds.has("insights"),
},
];
return items;
if (!inputs.onboarding) return items;
// 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);
}
/**
@@ -157,10 +220,23 @@ export function shouldShowChecklist(args: {
measurementCount: number;
dismissedAll: boolean;
items: ChecklistItem[];
/**
* v1.39 (C1) — the needs-based setup state. While it is unsettled — a step
* still pending, the flow never confirmed, or a first-result task offered
* and never produced — the checklist stays whatever the reading count says.
* That is the design spec's "it no longer disappears at five measurements":
* five readings are not evidence that the setup finished.
*
* Optional, and absence means nothing is outstanding: a record with no
* onboarding row has no unfinished flow to keep the list open for.
*/
onboarding?: OnboardingStateDto | null;
}): boolean {
if (args.dismissedAll) return false;
const stillInSetup =
args.onboardingCompletedAt == null || args.measurementCount < 5;
args.onboardingCompletedAt == null ||
args.measurementCount < 5 ||
!isOnboardingSettled(args.onboarding);
if (!stillInSetup) return false;
const visible = visibleChecklist(args.items);
if (visible.length === 0) return false;
@@ -34,6 +34,7 @@ import {
inviteManagedProfileGuardianSchema,
updateManagedProfileSchema,
} from "@/lib/validations/managed-profiles";
import { onboardingStateResource } from "./onboarding";
import { moduleAccessMap } from "./profile";
import { dataEnvelope, errorEnvelope, stdResponses } from "./shared";
@@ -462,6 +463,14 @@ const accountPayload = z
notificationPrefs: z.looseObject({
medication: z.looseObject({ clientManaged: z.boolean() }),
}),
// v1.39 (C1) — enumerated for the same reason `notificationPrefs` is: a
// client DECIDES on it. The getting-started checklist orders its rows from
// `needs` and stays on screen while the flow is unfinished, and a future
// native client renders the steps it knows. Always present, resolved for
// the CALLER's own record rather than the active one — the three write
// routes are actor-only, and publishing a setup state the caller cannot
// write would be a read a browser could act on and never complete.
onboarding: onboardingStateResource,
})
.meta({
id: "AccountPayload",