/** * v1.39 (C1) — the needs-based onboarding's server state, as data. * * The design spec (`.planning/2026-09-09-onboarding-design-spec.md`) asks five * questions, saves each answer as it is given, and derives the record's module * map from the set of answers once. This file is the vocabulary and the * fail-soft readers for the three JSON columns behind that: the answers, the * ordered steps, and the one task the flow ended on. * * Pure and Prisma-free on purpose — the Zod schemas in * `src/lib/validations/onboarding-needs.ts` build on it, the OpenAPI generator * pulls it in without dragging the server graph along, and the browser reads * the same step ids the server writes. * * ── The completion stamp ─────────────────────────────────────────────────── * * `User.onboardingCompletedAt` stays what it was — the first-run redirect's * gate — which is why the needs flow carries a completion stamp of its own * rather than borrowing that column: "set up again" has to be able to re-ask * the questions without pushing the person back through the first-run * redirect. The five-step wizard that used to share this state is gone (C2); * its step counter and goal slugs left the account row with it, and the * dashboard order those slugs seeded now comes from the answers here * (`./dashboard-seed.ts`). */ import type { OnboardingAreaKey, OnboardingModuleNeeds, } from "@/lib/modules/registry"; import { ONBOARDING_AREA_KEYS } from "@/lib/modules/registry"; /** * The ordered steps, with the stable ids the spec names. Order is the order * they are asked in; the ids are the wire contract a client (web now, native * later) matches on, and a client that meets an id it does not know is * expected to skip it rather than refuse the list. */ export const ONBOARDING_STEP_IDS = [ "who", "areas", "medication", "sources", "visit", "units", "confirm", "first-result", "done", ] as const; export type OnboardingStepId = (typeof ONBOARDING_STEP_IDS)[number]; export const ONBOARDING_STEP_STATUSES = ["pending", "done", "skipped"] as const; export type OnboardingStepStatus = (typeof ONBOARDING_STEP_STATUSES)[number]; /** * The steps a person may pass without answering. `who` is the one required * question in the spec; `confirm` and `done` are not questions at all, so * "skipped" would not mean anything for them. */ export const ONBOARDING_SKIPPABLE_STEP_IDS = [ "areas", "medication", "sources", "visit", "units", "first-result", ] as const; /** * Q1 — whose record this is. The vocabularies below are pinned against the * registry's derivation input with `satisfies`, so a value added here that the * mapping cannot read fails to compile rather than falling through the * derivation silently. */ export const ONBOARDING_RECORD_TARGETS = [ "me", "someone-else", "both", ] as const satisfies readonly OnboardingModuleNeeds["recordTarget"][]; export type OnboardingRecordTarget = (typeof ONBOARDING_RECORD_TARGETS)[number]; /** Q3 — medication on a schedule. */ export const ONBOARDING_MEDICATION_ANSWERS = [ "yes", "no", "sometimes", ] as const satisfies readonly OnboardingModuleNeeds["medication"][]; export type OnboardingMedicationAnswer = (typeof ONBOARDING_MEDICATION_ANSWERS)[number]; /** Q4 — where the readings come from today. */ export const ONBOARDING_SOURCE_KEYS = [ "manual", "apple-health", "withings", "oura", "whoop", "polar", "fitbit", "strava", "nightscout", "file", ] as const; export type OnboardingSourceKey = (typeof ONBOARDING_SOURCE_KEYS)[number]; /** Q5 — a doctor's visit coming up. */ export const ONBOARDING_VISIT_ANSWERS = [ "within-a-month", "later", "no", ] as const satisfies readonly OnboardingModuleNeeds["visit"][]; export type OnboardingVisitAnswer = (typeof ONBOARDING_VISIT_ANSWERS)[number]; /** * The one task the flow ends on. `target` names what the task is about — a * source key for a connection, an area key for a reading — and is null for the * medication task, which has nothing to disambiguate. */ export const ONBOARDING_FIRST_RESULT_TASKS = [ "connect-source", "add-medication", "log-reading", ] as const; export type OnboardingFirstResultTask = (typeof ONBOARDING_FIRST_RESULT_TASKS)[number]; /** * Q6 — the unit answers, recorded as given. * * These are NOT the source of truth for how a value is displayed: * `User.glucoseUnit` and `User.unitPreference` are, and the answers step * writes them. What is kept here is what the flow was TOLD, so a restart can * show the person the answer they already gave without re-deriving it from a * display column somebody may have changed in Settings since. */ export interface OnboardingUnitAnswers { glucoseUnit: "mg/dL" | "mmol/L" | null; unitPreference: "metric" | "imperial" | null; } /** The answers as given. Every field is null / empty until it is answered. */ export interface OnboardingNeeds { recordTarget: OnboardingRecordTarget | null; areas: OnboardingAreaKey[]; medication: OnboardingMedicationAnswer | null; sources: OnboardingSourceKey[]; visit: OnboardingVisitAnswer | null; units: OnboardingUnitAnswers; } export interface OnboardingStepState { id: OnboardingStepId; status: OnboardingStepStatus; } export interface OnboardingFirstResult { task: OnboardingFirstResultTask; target: string | null; /** ISO instant when the offered task produced its result, or null. */ completedAt: string | null; } /** * What `GET /api/auth/me` publishes under `onboarding`, and what a client * reads. Additive: an account that never entered the flow gets the empty * answers and nine `pending` steps rather than a missing field, so a consumer * never has to branch on absence. */ export interface OnboardingStateDto { steps: OnboardingStepState[]; needs: OnboardingNeeds; completedAt: string | null; firstResult: OnboardingFirstResult | null; } export function emptyOnboardingNeeds(): OnboardingNeeds { return { recordTarget: null, areas: [], medication: null, sources: [], visit: null, units: { glucoseUnit: null, unitPreference: null }, }; } /** Nine steps, all pending, in the spec's order. */ export function defaultOnboardingSteps(): OnboardingStepState[] { return ONBOARDING_STEP_IDS.map((id) => ({ id, status: "pending" as const })); } function member( values: readonly T[], value: unknown, ): T | null { return typeof value === "string" && (values as readonly string[]).includes(value) ? (value as T) : null; } function memberList( values: readonly T[], value: unknown, ): T[] { if (!Array.isArray(value)) return []; const out: T[] = []; for (const entry of value) { const parsed = member(values, entry); if (parsed !== null && !out.includes(parsed)) out.push(parsed); } return out; } function asRecord(value: unknown): Record { return typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record) : {}; } /** * Read the stored answers, dropping anything that is not a known value. * * Fail-soft rather than throwing: a column written by an older release, or by * a value this release retired, must not 500 the account payload that rides * every app boot. What cannot be read reads as unanswered, which is the state * the flow already knows how to handle. */ export function parseOnboardingNeeds(value: unknown): OnboardingNeeds { const raw = asRecord(value); const units = asRecord(raw.units); return { recordTarget: member(ONBOARDING_RECORD_TARGETS, raw.recordTarget), areas: memberList(ONBOARDING_AREA_KEYS, raw.areas), medication: member(ONBOARDING_MEDICATION_ANSWERS, raw.medication), sources: memberList(ONBOARDING_SOURCE_KEYS, raw.sources), visit: member(ONBOARDING_VISIT_ANSWERS, raw.visit), units: { glucoseUnit: member(["mg/dL", "mmol/L"] as const, units.glucoseUnit), unitPreference: member( ["metric", "imperial"] as const, units.unitPreference, ), }, }; } /** * Read the stored steps as the full ordered nine. * * The stored blob is treated as a status lookup, never as the order: the order * is this release's `ONBOARDING_STEP_IDS`, so a step added to the flow appears * as `pending` for an account mid-way through rather than vanishing, and a * step removed from the flow stops being published without a migration. */ export function parseOnboardingSteps(value: unknown): OnboardingStepState[] { const stored = new Map(); if (Array.isArray(value)) { for (const entry of value) { const row = asRecord(entry); const id = member(ONBOARDING_STEP_IDS, row.id); const status = member(ONBOARDING_STEP_STATUSES, row.status); if (id && status) stored.set(id, status); } } return ONBOARDING_STEP_IDS.map((id) => ({ id, status: stored.get(id) ?? "pending", })); } export function parseOnboardingFirstResult( value: unknown, ): OnboardingFirstResult | null { const raw = asRecord(value); const task = member(ONBOARDING_FIRST_RESULT_TASKS, raw.task); if (!task) return null; return { task, target: typeof raw.target === "string" ? raw.target.slice(0, 64) : null, completedAt: typeof raw.completedAt === "string" && raw.completedAt.length > 0 ? raw.completedAt : null, }; } /** * The steps that are QUESTIONS — everything the flow puts to the person before * the confirm screen. * * Sliced out of the ordered list rather than written out again, so a question * added to the flow joins this set on the day it lands and the completion gate * below cannot fall behind the screens. */ export const ONBOARDING_QUESTION_STEP_IDS: readonly OnboardingStepId[] = ONBOARDING_STEP_IDS.slice(0, ONBOARDING_STEP_IDS.indexOf("confirm")); /** * Has the questionnaire been finished? * * Every question answered or deliberately passed — not "Q1 is set". The * difference matters because the answers become a module map: deriving from a * flow somebody abandoned after the first screen reads every unanswered * question as its conservative default and switches off the surfaces that * default implies, and the derivation latch then makes that unrepeatable. * * `who` cannot be passed (the schema gives it no skip arm), so "not pending" * means "answered" for it and "answered or passed" for the rest. */ export function everyOnboardingQuestionSettled( steps: readonly OnboardingStepState[], ): boolean { return ONBOARDING_QUESTION_STEP_IDS.every( (id) => steps.find((step) => step.id === id)?.status !== "pending", ); } /** True once every step has been answered or deliberately passed. */ export function everyOnboardingStepSettled( steps: readonly OnboardingStepState[], ): boolean { return steps.every((step) => step.status !== "pending"); } /** * Did this record ever enter the setup flow? * * Q1 is the one question with no skip arm, so an answer to it is the mark of * having started, and the flow's own completion stamp covers a row whose * answers a later release stopped recognising. * * Deliberately NOT "any step is not pending": `units` resolves to `done` from * the account's own unit columns, so a record that never saw a question can * carry a settled step. The published state is additive — every record has an * `onboarding` field, and a record that predates the flow reads as empty * answers with nine pending steps — so this is what tells the two apart. */ export function hasEnteredOnboardingFlow( state: OnboardingStateDto | null | undefined, ): boolean { if (!state) return false; return state.completedAt !== null || state.needs.recordTarget !== null; } /** * Has the person finished setting up? * * Three conditions, and each of them is a way the flow can be left half-done: * a step still waiting for an answer, a set of answers never confirmed, and a * first-result task that was offered and never produced. The last one is the * point of the whole flow — it ends on one completed task — so an offer with * no result is not a finished setup. * * Two things settle it outright. `null`, and a record that never entered the * flow: there is no unfinished setup to keep anything open for, and since the * payload publishes the field for every record, without that arm every account * that predates the flow would read as permanently unfinished. * * And a first-result step the person deliberately passed. The task is offered * (`firstResult` records which one, with no completion) and then skipped; the * offer is still on the row, so asking only "did the offered task produce * anything" answers "no" forever. A deliberate pass is an answer, not an * unfinished task. */ export function isOnboardingSettled( state: OnboardingStateDto | null | undefined, ): boolean { if (!state) return true; if (!hasEnteredOnboardingFlow(state)) return true; if (!everyOnboardingStepSettled(state.steps)) return false; if (state.completedAt === null) return false; const offered = state.steps.find((step) => step.id === "first-result"); if (offered?.status === "skipped") return true; return state.firstResult === null || state.firstResult.completedAt !== null; } /** * The unit columns an account already holds, read as the vocabulary the wire * publishes. * * `User.unitPreference` is nullable and `null` means "default metric", which * the account payload coerces away (`unitPreference === "imperial" ? * "imperial" : "metric"`). That coercion is fine to compare a CHOSEN value * against and useless for telling a chosen metric from an unset column, so the * server reads the raw column here and answers `null` for "never chosen". */ export interface HeldUnitPreferences { glucoseUnit: "mg/dL" | "mmol/L" | null; unitPreference: "metric" | "imperial" | null; } /** Read the two raw columns into the wire vocabulary; anything else is unset. */ export function readHeldUnitPreferences(row: { glucoseUnit: string | null; unitPreference: string | null; }): HeldUnitPreferences { return { glucoseUnit: member(["mg/dL", "mmol/L"] as const, row.glucoseUnit), unitPreference: member(["metric", "imperial"] as const, row.unitPreference), }; } /** True once the account carries both unit preferences as deliberate values. */ export function accountHoldsBothUnits(held: HeldUnitPreferences): boolean { return held.glucoseUnit !== null && held.unitPreference !== null; } /** * The steps as they should be READ, given what the account already holds. * * The spec's Q6 is "skipped when the account already holds them", and the * evidence for that is the two unit columns, not the questionnaire's own * memory of having asked. So a record whose account carries both preferences * publishes `units` as `done` even though nobody answered it here — which is * what stops the flow re-asking a value the account holds, and what stops the * completion gate below waiting for an answer to a question the flow will * never show. * * Read-side only: the stored ledger keeps saying `pending`, because the person * really has not answered it, and an answer given later still lands. */ export function resolveOnboardingSteps( steps: readonly OnboardingStepState[], held: HeldUnitPreferences, ): OnboardingStepState[] { if (!accountHoldsBothUnits(held)) return [...steps]; return steps.map((step) => step.id === "units" && step.status === "pending" ? { ...step, status: "done" as const } : step, ); }