diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 0acb08f3d..388d2826b 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -16162,6 +16162,125 @@ paths: "401": *a1 "422": *a4 "429": *a5 + /api/onboarding/answers: + patch: + tags: + - Onboarding + summary: Save one answer of the needs-based setup flow + description: >- + Persists one step of the setup questionnaire the moment it is answered, so leaving and returning resumes at the + same step. The body names the step and carries either its answer or `status: "skipped"`; the two can never ride + together. + + + Idempotent. The stored state is computed by a pure function of the row and the body, so sending the same step + twice writes the same value and the second write changes nothing a reader can see — including the first-result + completion instant, which is kept from the existing row rather than re-taken. There is no ordering contract: + steps may be answered, re-answered and passed in any order, which is what makes a back button work. + + + The `units` step is the one answer with a consequence outside this state: it writes the record's `glucoseUnit` + and `unitPreference`, which every display surface already reads, and records the answer beside them. + + + Cookie or wildcard Bearer, and the CALLER's own record only: a request made while the session is acting on + somebody else's record is refused with 403 `sharing.not_permitted` at every grant level, so a delegate can never + write another record's setup. Rate-limited to 120 writes per 10 minutes per account. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/OnboardingAnswerRequest" + responses: + "200": + description: The full setup state after the write. + content: + application/json: + schema: + $ref: "#/components/schemas/OnboardingStateEnvelope" + "401": *a1 + "413": + description: Body exceeds 16 KiB. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "415": + description: "`Content-Type` is not `application/json`." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": + description: The body did not validate. `meta.errorCode` = `onboarding.answers.invalid`, with every issue on the wire. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "429": + description: More than 120 answer writes in 10 minutes. `meta.errorCode` = `onboarding.answers.rateLimited`. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + /api/onboarding/restart: + post: + tags: + - Onboarding + summary: Ask the setup questions again + description: >- + "Set up again", from Settings. Puts the nine steps back to `pending`, clears the flow's own completion stamp, + and clears the derivation marker so the next `POST /api/onboarding/complete` may derive a module map again. + + + It writes no module state at all: the ordering-versus-removal decision says a re-run never turns off a module + somebody turned on by hand, and the protection against the second derivation lives in the merge rather than + here. It also keeps the answers, as the prefill for the re-run, keeps a first result that really happened, and + leaves the account payload's `onboardingCompletedAt` alone so nobody is pushed back through the first-run + redirect. + + + Cookie or wildcard Bearer, own record only — same refusal under a switch as the answers route. Rate-limited to + 10 restarts per 10 minutes per account. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/OnboardingRestartRequest" + responses: + "200": + description: The reset setup state. + content: + application/json: + schema: + $ref: "#/components/schemas/OnboardingStateEnvelope" + "401": *a1 + "413": + description: Body exceeds 4 KiB. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "415": + description: "`Content-Type` is not `application/json`." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": + description: The body carried a field. `meta.errorCode` = `onboarding.restart.invalid`. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "429": + description: More than 10 restarts in 10 minutes. `meta.errorCode` = `onboarding.restart.rateLimited`. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" /api/onboarding/step: post: tags: @@ -16245,12 +16364,23 @@ paths: post: tags: - Onboarding - summary: Stamp onboarding as complete (legacy single-shot path) + summary: Stamp onboarding as complete, and derive the module map description: >- Marks onboarding finished and saves whatever profile fields came with it, then clears the proxy-readable pending cookie so the next navigation stops redirecting to `/onboarding`. + v1.39 — this is also the needs-based flow's confirm endpoint, and the two halves live side by side. For a record + that answered the questions (Q1 is set), it derives that record's module map from the answers and applies it, + ONCE: the derivation marker is what stops a second confirm re-applying the questionnaire over decisions taken in + Settings since, and `POST /api/onboarding/restart` clears the marker when the person asks for the questions + again. The merge is one-directional — a module the answers name is switched on, a module they do not name is + switched off only where the record does not already carry an explicit on. The `cycle` key is not written here at + all: it delegates to the cycle profile, which this route sets to `true` when the cycle area was chosen and never + to `false`. The response then carries `onboarding` beside `completed`; a caller that never entered the needs + flow gets the fixed acknowledgement it always did. + + The older sibling of `POST /api/onboarding/step`. It is unconditional in a way the step route is not: it stamps the completion whatever the stored step is, it re-stamps on every call rather than refusing a second one, and it writes no audit row and enforces no rate limit of its own. It also does NOT seed the dashboard from the goal @@ -16270,8 +16400,9 @@ paths: $ref: "#/components/schemas/OnboardingCompleteRequest" responses: "200": - description: "Onboarding stamped complete. The body is the fixed `{ completed: true }` acknowledgement — it does not - echo what was saved." + description: Onboarding stamped complete. `completed` is the fixed acknowledgement — it does not echo the profile fields + that were saved. `onboarding` is present only for a record that entered the needs-based flow, and carries + the state after the derivation. content: application/json: schema: @@ -27554,6 +27685,282 @@ components: minLength: 1 required: - ids + OnboardingAnswerRequest: + anyOf: + - type: object + properties: + step: + type: string + const: who + status: + type: string + const: done + recordTarget: + type: string + enum: + - me + - someone-else + - both + required: + - step + - recordTarget + additionalProperties: false + - type: object + properties: + step: + type: string + const: areas + status: + type: string + const: done + areas: + maxItems: 9 + type: array + items: + type: string + enum: + - blood-pressure + - weight-body + - glucose + - sleep + - mood + - cycle + - activity + - labs + - illness + required: + - step + - areas + additionalProperties: false + - type: object + properties: + step: + type: string + const: medication + status: + type: string + const: done + medication: + type: string + enum: + - yes + - no + - sometimes + required: + - step + - medication + additionalProperties: false + - type: object + properties: + step: + type: string + const: sources + status: + type: string + const: done + sources: + maxItems: 10 + type: array + items: + type: string + enum: + - manual + - apple-health + - withings + - oura + - whoop + - polar + - fitbit + - strava + - nightscout + - file + required: + - step + - sources + additionalProperties: false + - type: object + properties: + step: + type: string + const: visit + status: + type: string + const: done + visit: + type: string + enum: + - within-a-month + - later + - no + required: + - step + - visit + additionalProperties: false + - type: object + properties: + step: + type: string + const: units + status: + type: string + const: done + units: + type: object + properties: + glucoseUnit: + anyOf: + - type: string + enum: + - mg/dL + - mmol/L + - type: "null" + unitPreference: + anyOf: + - type: string + enum: + - metric + - imperial + - type: "null" + additionalProperties: false + required: + - step + - units + additionalProperties: false + - type: object + properties: + step: + type: string + const: confirm + status: + type: string + const: done + required: + - step + additionalProperties: false + - type: object + properties: + step: + type: string + const: first-result + status: + type: string + const: done + firstResult: + type: object + properties: + task: + type: string + enum: + - connect-source + - add-medication + - log-reading + target: + anyOf: + - type: string + minLength: 1 + maxLength: 64 + - type: "null" + completed: + type: boolean + required: + - task + additionalProperties: false + required: + - step + - firstResult + additionalProperties: false + - type: object + properties: + step: + type: string + const: done + status: + type: string + const: done + required: + - step + additionalProperties: false + - type: object + properties: + step: + type: string + const: areas + status: + type: string + const: skipped + required: + - step + - status + additionalProperties: false + - type: object + properties: + step: + type: string + const: medication + status: + type: string + const: skipped + required: + - step + - status + additionalProperties: false + - type: object + properties: + step: + type: string + const: sources + status: + type: string + const: skipped + required: + - step + - status + additionalProperties: false + - type: object + properties: + step: + type: string + const: visit + status: + type: string + const: skipped + required: + - step + - status + additionalProperties: false + - type: object + properties: + step: + type: string + const: units + status: + type: string + const: skipped + required: + - step + - status + additionalProperties: false + - type: object + properties: + step: + type: string + const: first-result + status: + type: string + const: skipped + required: + - step + - status + additionalProperties: false + description: "One step, answered or deliberately passed. The union has an arm per step id plus a skip arm for the six + steps that may be passed — `who` is the one required question, and `confirm` / `done` are acknowledgements + rather than questions, so none of the three has a skip arm. Every arm is strict: an unknown key is a 422, and a + skip may never carry an answer." + OnboardingRestartRequest: + type: object + properties: {} + additionalProperties: false + description: No input beyond the session. Send `{}`. OnboardingStepRequest: type: object properties: @@ -34603,6 +35010,62 @@ components: - configChangedAt - computedAt additionalProperties: {} + onboardingRecord: + default: null + anyOf: + - type: object + properties: + id: + type: string + minLength: 1 + needs: + default: {} + type: object + propertyNames: + type: string + additionalProperties: {} + steps: + default: [] + type: array + items: + type: object + propertyNames: + type: string + additionalProperties: {} + firstResult: + default: null + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: {} + - type: "null" + modulesDerivedAt: + default: null + anyOf: + - type: string + minLength: 1 + - type: "null" + completedAt: + default: null + anyOf: + - type: string + minLength: 1 + - type: "null" + createdAt: + type: string + minLength: 1 + updatedAt: + type: string + minLength: 1 + required: + - needs + - steps + - firstResult + - modulesDerivedAt + - completedAt + additionalProperties: {} + - type: "null" practitioners: default: [] type: array @@ -35941,6 +36404,7 @@ components: - correlationPatterns - intradayProfiles - healthScoreRecords + - onboardingRecord - practitioners - encounters - encounterDocumentLinks @@ -62667,6 +63131,209 @@ components: - data - error additionalProperties: false + OnboardingStateEnvelope: + type: object + properties: + data: + type: object + properties: + onboarding: + $ref: "#/components/schemas/OnboardingState" + required: + - onboarding + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + OnboardingState: + type: object + properties: + steps: + type: array + items: + $ref: "#/components/schemas/OnboardingStepState" + needs: + $ref: "#/components/schemas/OnboardingNeeds" + completedAt: + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" + description: "When the needs flow was completed. Distinct from the account payload's `onboardingCompletedAt`, which + stays the first-run redirect's gate: `POST /api/onboarding/restart` clears this one and leaves that one + alone." + firstResult: + anyOf: + - $ref: "#/components/schemas/OnboardingFirstResult" + - type: "null" + required: + - steps + - needs + - completedAt + - firstResult + additionalProperties: false + description: The needs-based setup flow for one record. Always present on the account payload — 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. + OnboardingStepState: + type: object + properties: + id: + type: string + enum: + - who + - areas + - medication + - sources + - visit + - units + - confirm + - first-result + - done + status: + type: string + enum: + - pending + - done + - skipped + required: + - id + - status + additionalProperties: false + description: One step of the setup flow. The ids are stable; a client that meets one it does not know SKIPS it rather + than refusing the list, so the flow may grow a screen without a client release. + OnboardingNeeds: + type: object + properties: + recordTarget: + anyOf: + - type: string + enum: + - me + - someone-else + - both + - type: "null" + description: Q1 — whose record this is. Null until answered. + areas: + type: array + items: + type: string + enum: + - blood-pressure + - weight-body + - glucose + - sleep + - mood + - cycle + - activity + - labs + - illness + description: Q2 — the areas to keep an eye on. Each one maps to at least one module through `ONBOARDING_AREA_MODULES`; + the map is the server's, and a client must not re-derive it. + medication: + anyOf: + - type: string + enum: + - yes + - no + - sometimes + - type: "null" + description: Q3 — medication on a schedule. + sources: + type: array + items: + type: string + enum: + - manual + - apple-health + - withings + - oura + - whoop + - polar + - fitbit + - strava + - nightscout + - file + description: Q4 — where the readings come from today. + visit: + anyOf: + - type: string + enum: + - within-a-month + - later + - no + - type: "null" + description: Q5 — a doctor's visit coming up. + units: + type: object + properties: + glucoseUnit: + anyOf: + - type: string + enum: + - mg/dL + - mmol/L + - type: "null" + unitPreference: + anyOf: + - type: string + enum: + - metric + - imperial + - type: "null" + required: + - glucoseUnit + - unitPreference + additionalProperties: false + description: "Q6 — the unit answers as given. NOT the source of truth for display: `glucoseUnit` and `unitPreference` on + the account payload are, and the answers step writes them. This records what the flow was told." + required: + - recordTarget + - areas + - medication + - sources + - visit + - units + additionalProperties: false + description: The setup answers as given. Every field is null or empty until its question is answered. + OnboardingFirstResult: + type: object + properties: + task: + type: string + enum: + - connect-source + - add-medication + - log-reading + target: + anyOf: + - type: string + - type: "null" + description: What the task is about — a source key for a connection, an area key for a reading. Null for the medication + task. + completedAt: + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" + description: When the offered task produced its result, or null. + required: + - task + - target + - completedAt + additionalProperties: false + description: The one task the flow offered at the end, and whether it produced its result. OnboardingStepEnvelope: type: object properties: @@ -62712,6 +63379,8 @@ components: completed: type: boolean const: true + onboarding: + $ref: "#/components/schemas/OnboardingState" required: - completed additionalProperties: false @@ -66362,6 +67031,8 @@ components: required: - medication additionalProperties: {} + onboarding: + $ref: "#/components/schemas/OnboardingState" required: - id - username @@ -66369,6 +67040,7 @@ components: - moduleAccess - recordSession - notificationPrefs + - onboarding additionalProperties: {} description: "The signed-in account: its identity, its preferences, and (since v1.36.0) what account sharing lets it do. Additional properties are the preference fields this spec does not yet enumerate. Under an active switch the diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts index c6a64acbe..69a283bb9 100644 --- a/src/app/api/auth/me/route.ts +++ b/src/app/api/auth/me/route.ts @@ -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". diff --git a/src/components/onboarding/getting-started-checklist.tsx b/src/components/onboarding/getting-started-checklist.tsx index 9202fad0f..a4eb66278 100644 --- a/src/components/onboarding/getting-started-checklist.tsx +++ b/src/components/onboarding/getting-started-checklist.tsx @@ -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 diff --git a/src/hooks/use-auth.ts b/src/hooks/use-auth.ts index c1f5058a4..aad63b656 100644 --- a/src/hooks/use-auth.ts +++ b/src/hooks/use-auth.ts @@ -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 { // 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", diff --git a/src/lib/onboarding/__tests__/checklist.test.ts b/src/lib/onboarding/__tests__/checklist.test.ts index dc734d5d3..c765877f8 100644 --- a/src/lib/onboarding/__tests__/checklist.test.ts +++ b/src/lib/onboarding/__tests__/checklist.test.ts @@ -16,7 +16,9 @@ const completeProfile = { gender: "MALE", }; -function inputs(overrides: Partial[0]> = {}) { +function inputs( + overrides: Partial[0]> = {}, +): Parameters[0] { return { profile: completeProfile, measurementCount: 0, @@ -25,6 +27,7 @@ function inputs(overrides: Partial[0]> = {}) { notificationsConfigured: false, insightsConfigured: false, dismissedIds: new Set(), + 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, diff --git a/src/lib/onboarding/checklist.ts b/src/lib/onboarding/checklist.ts index a4a828b8c..9ec9e4496 100644 --- a/src/lib/onboarding/checklist.ts +++ b/src/lib/onboarding/checklist.ts @@ -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; + /** + * 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; diff --git a/src/lib/openapi/routes/account-sharing.ts b/src/lib/openapi/routes/account-sharing.ts index 25a30a8b5..fbfea85c5 100644 --- a/src/lib/openapi/routes/account-sharing.ts +++ b/src/lib/openapi/routes/account-sharing.ts @@ -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",