mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
The catalogue lists the answers and restart codes and no longer the retired step ones, and the generated document quotes that list. The skippable-step id type nothing read goes with it.
440 lines
15 KiB
TypeScript
440 lines
15 KiB
TypeScript
/**
|
|
* 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<T extends string>(
|
|
values: readonly T[],
|
|
value: unknown,
|
|
): T | null {
|
|
return typeof value === "string" &&
|
|
(values as readonly string[]).includes(value)
|
|
? (value as T)
|
|
: null;
|
|
}
|
|
|
|
function memberList<T extends string>(
|
|
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<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
? (value as Record<string, unknown>)
|
|
: {};
|
|
}
|
|
|
|
/**
|
|
* 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<string, OnboardingStepStatus>();
|
|
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,
|
|
);
|
|
}
|