diff --git a/CHANGELOG.md b/CHANGELOG.md index c8304e3b8..8a2a65f51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,76 @@ # Changelog +## [1.38.18] — 2026-09-10 + +The first run asks what you need and switches on exactly that; the +five-step wizard and the two columns it wrote are gone. + +### Added + +- **A needs-based setup flow replaces the five-step wizard.** Five questions + (who the record is for, the areas to watch, medication on a schedule, + where readings come from, an upcoming visit) and a sixth about units only + when an area needs one and the account does not hold it yet, then a + confirm screen that shows what the answers switch on, one first-result + task with its form inline (connect a source, add the first medication with + a reminder, or log one reading), and a done screen with the checklist + open. Every answer is saved as it is given, so leaving and returning + resumes at the same step; every question but the first can be skipped, and + "Skip for now" on the welcome leaves the app usable with nothing switched + on. "Set up again" and "Show the checklist again" live under Settings → + Account. The module map is derived by the server from the answers, once, + by the same function the server side shipped in v1.38.16, so the screen + renders the derivation and never computes one of its own; a module the + person switched on by hand or whose domain already holds rows is never + turned off by a choice. + +- **Answers given for someone you look after go to their record.** With + "someone I look after" the questions run in the guardian's own record, the + managed profile is created on the confirm screen, and + `POST /api/onboarding/complete` takes `managedRecordId` to apply the + derived modules to that record through the guardian's record-keyed write + from v1.38.15. The guardian's own map is left alone, and finishing without + creating the profile derives nothing onto the guardian either; a record + the caller does not manage is a 404, as on the managed-profile routes. The + checklist carries a row for the profile instead of reading those answers + as the guardian's. + +- **Module pages say which switch is off.** A page whose module is off + answers with an empty state that names the switch instead of a blank + surface, and the tour's two stops without an anchor (integrations, the + health record) have theirs. + +- **Seven locales, held to the screen.** Every setup screen is rendered once + more with strings 30 % longer than English and must not overflow; the axe + scan covers every screen in both themes. + +### Changed + +- **`POST /api/onboarding/complete` seeds the dashboard order from the + answers** the first time, and only while the layout is unset, so an order + the person has already arranged is never overwritten. The demo mutation + allowlist admits the disclaimer and tour writes, so the demo can finish + the flow; a structural test holds every onboarding write to the allowlist. + +### Removed + +- **`POST /api/onboarding/step`, the six goal slugs, and the + `users.onboarding_step` / `users.onboarding_goals` columns** (migration + `0339_retire_wizard_columns`). The native client never called the route or + read the columns; `POST /api/onboarding/disclaimer`, + `POST /api/onboarding/tour` and the disclaimer and tour fields on + `GET /api/auth/me` are unchanged. + +### Upgrade note + +- **Take a backup before deploying, and expect no image rollback.** + Migration 0339 drops two columns the v1.38.16 and v1.38.17 images select + on every session load, so an older image against a migrated database fails + at login, not only on the wizard. Rolling the image back means restoring + the database, or re-adding the two columns with their defaults, which is + loss-free because nothing at this version writes them: + `ALTER TABLE users ADD COLUMN onboarding_step integer NOT NULL DEFAULT 0, ADD COLUMN onboarding_goals text[] NOT NULL DEFAULT '{}';` + ## [1.38.17] — 2026-09-10 A webhook or ntfy target on your own network can be reached again, with the diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 859b9d511..6ba15e063 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.38.17 + version: 1.38.18 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. @@ -16402,85 +16402,6 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - /api/onboarding/step: - post: - tags: - - Onboarding - summary: Advance the onboarding wizard by one step - description: >- - Persists progress through the four-step wizard. The contract is strictly ordered: the submitted `step` must be - exactly the stored step plus one, so a client cannot skip ahead or replay a step it already sent. - - - Submitting step 4 completes onboarding — the completion instant is stamped in the same write, the proxy-readable - pending cookie is cleared so the next navigation stops redirecting to `/onboarding`, and the stored goal - selection seeds the dashboard layout. That seed is ONE-TIME and conditional: it only runs while the layout - column is still unset, so a person who already arranged their tiles is never clobbered, and a concurrent layout - save that lands first wins. - - - The write is guarded on the state it validated, so two tabs submitting the same step do not both succeed — - exactly one lands and the other gets 409. - - - Rate-limited to 30 writes per 10 minutes per user, which is generous for a four-step flow and tight enough to - defang a stuck retry loop. Every accepted call writes an audit row. Cookie or wildcard Bearer; `userId` is never - read from the body. - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/OnboardingStepRequest" - responses: - "200": - description: The stored step and completion stamp after the write. - content: - application/json: - schema: - $ref: "#/components/schemas/OnboardingStepEnvelope" - "401": *a1 - "404": - description: The session's user row no longer exists. `meta.errorCode` = `onboarding.user.notFound`. - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorEnvelope" - "409": - description: "The write was refused and nothing changed. `meta.errorCode` distinguishes three cases: - `onboarding.step.completed` — onboarding is already finished, so the wizard has nothing left to advance; - `onboarding.step.outOfOrder` — the submitted step is not the stored step plus one, and the message names the - current step; `onboarding.step.concurrent` — the row moved between the check and the write, which is what a - second tab sees." - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorEnvelope" - "413": - description: Body exceeds 64 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.step.invalid`. Single-message, not the - multi-issue envelope — the per-field detail is not on the wire. - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorEnvelope" - "429": - description: More than 30 onboarding writes in 10 minutes. `meta.errorCode` = `onboarding.step.rateLimited`. - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorEnvelope" /api/onboarding/complete: post: tags: @@ -16491,36 +16412,37 @@ paths: 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 FINISHED the questions — every one of them answered or deliberately passed — 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`. A half-answered flow derives nothing and is - not stamped as complete: the answers become a module map, and a flow somebody abandoned after the first screen - would have every remaining question read as its conservative default. The same gate is what keeps the LEGACY - wizard's completion out of the derivation, since that wizard never puts these questions. + v1.39 — this is the needs-based flow's confirm endpoint. For a record that FINISHED the questions — every one of + them answered or deliberately passed — 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 and holds no data in that domain. 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`. In the same derivation the dashboard order is seeded from the + answers — the tiles of the chosen areas (and the medication tile for a schedule) move to the front and are made + visible — but only while the layout column is still unset, so a person who already arranged their tiles is never + clobbered. A half-answered flow derives nothing and is not stamped as complete. - 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 - enforces no rate limit of its own. Its legacy half writes no audit row; the needs half writes one - (`user.modules.update`) when it derives, because that is the same column the dedicated modules route audits. It - also does NOT seed the dashboard from the goal selection — that only happens on the step route's completing - call. - - - Calling this mid-wizard makes every remaining `POST /api/onboarding/step` answer 409 - `onboarding.step.completed`, because that route's guarded update requires the completion stamp to still be null. + The response carries `onboarding` beside `completed`; a caller that never entered the needs flow gets the fixed + acknowledgement alone — it stamps the completion whatever the answers say, re-stamps on every call rather than + refusing a second one, and enforces no rate limit of its own. The legacy half writes no audit row; the needs + half writes one (`user.modules.update`) when it derives, because that is the same column the dedicated modules + route audits. Cookie or wildcard Bearer; `userId` is never read from the body. + + + `managedRecordId` — "someone I look after". The answers were given for the profile the confirm screen created, + so the derivation and the seed land on THAT record through the same record-keyed write the guardian's modules + route uses, its setup row is written as finished, and the caller's own record is stamped complete and latched + with its map untouched. A managed record the caller does not actively guard, a record that is not a managed + profile, or an id that does not exist all answer 404 with nothing stamped; a body naming a record when Q1 was + not `someone-else` is a 422. With Q1 = `someone-else` and NO record id, the flow completes and latches without + deriving anything onto anybody: there is no record the answers describe yet, and they never describe the + guardian's. requestBody: required: true content: @@ -16537,6 +16459,14 @@ paths: schema: $ref: "#/components/schemas/OnboardingCompleteEnvelope" "401": *a1 + "404": + description: "`managedRecordId` names no managed profile the caller actively guards — missing, not a managed record, or + not theirs; one answer for all three. `meta.errorCode` = `onboarding.complete.recordNotFound`. Nothing was + stamped." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "413": description: Body exceeds 64 KiB. content: @@ -16550,8 +16480,10 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": - description: The body did not validate. `meta.errorCode` = `onboarding.complete.invalid`. Single-message, not the - multi-issue envelope. + description: The body did not validate (`meta.errorCode` = `onboarding.complete.invalid`; single-message, not the + multi-issue envelope), or `managedRecordId` was sent for answers that were not given for somebody else's + record — Q1 was `me` or `both` (`meta.errorCode` = `onboarding.complete.recordTargetMismatch`; nothing + stamped). content: application/json: schema: @@ -28092,33 +28024,20 @@ components: properties: {} additionalProperties: false description: No input beyond the session. Send `{}`. - OnboardingStepRequest: - type: object - properties: - step: - type: integer - minimum: 1 - maximum: 4 - goals: - maxItems: 6 - type: array - items: - type: string - enum: - - weight-management - - bp-tracking - - glucose-tracking - - sleep-improvement - - medication-compliance - - general-wellness - required: - - step - description: The wizard step being COMPLETED, 1–4. It must equal the stored step plus one — the server does not clamp or - skip. `goals` rides the step-2 submit; every slug is checked against the closed set, so one unknown slug fails - the whole request rather than being dropped. OnboardingCompleteRequest: type: object properties: + managedRecordId: + description: The managed record the setup answers were given FOR — the profile "someone I look after" created on the + confirm screen. When present, the module map and the dashboard order derived from the caller's answers are + applied to THAT record and its own setup row is written as finished; the caller's own record is stamped + complete and its derivation latched without any module being touched. Honoured only for a managed profile + the caller actively guards (otherwise 404 `onboarding.complete.recordNotFound`) and only when the answer to + Q1 was `someone-else` (otherwise 422 `onboarding.complete.recordTargetMismatch`); nothing is stamped on a + refusal. Absent with Q1 = `someone-else`, the flow completes without deriving anything anywhere. + type: string + minLength: 1 + maxLength: 64 displayName: type: string minLength: 1 @@ -28135,10 +28054,12 @@ components: - MALE - FEMALE - OTHER - description: "Optional profile fields to save alongside the completion stamp. Every field is optional and only a truthy - value is written — sending `heightCm: 0` or an empty `displayName` leaves the column alone rather than clearing - it. There is no way to CLEAR a field through this endpoint. `dateOfBirth` is a free string parsed with `new - Date(...)`: an unparseable value is silently ignored, not refused." + description: "The completion stamp's body. `managedRecordId` (v1.39) names the managed record the answers were given for + — see the field. The profile fields are the legacy half: every one optional, only a truthy value is written — + sending `heightCm: 0` or an empty `displayName` leaves the column alone rather than clearing it, and there is no + way to CLEAR a field through this endpoint. `dateOfBirth` is a free string parsed with `new Date(...)`: an + unparseable value is silently ignored, not refused. The web flow writes its profile through `PUT + /api/auth/profile` instead." DisclaimerAckRequest: type: object properties: @@ -30078,10 +29999,9 @@ components: nutrient — nutrient.batch.invalid, nutrient.batch.too_large, nutrient.daily.invalid, nutrient.read.invalid, nutrient.water.invalid, nutrient.water.invalid_day; onboarding — onboarding.answers.invalid, onboarding.answers.rateLimited, onboarding.complete.invalid, + onboarding.complete.recordNotFound, onboarding.complete.recordTargetMismatch, onboarding.disclaimer.invalid, onboarding.restart.invalid, onboarding.restart.rateLimited, - onboarding.step.completed, onboarding.step.concurrent, onboarding.step.invalid, - onboarding.step.outOfOrder, onboarding.step.rateLimited, onboarding.tour.invalid, - onboarding.user.notFound; personal_records — personal_records.invalid_query; practitioner — + onboarding.tour.invalid; personal_records — personal_records.invalid_query; practitioner — practitioner.invalid; profile — profile.update.emailInUse, profile.update.invalidBody, profile.update.nothingSaved; record_write — record_write.rate_limited; report-selection — report-selection.body.invalid_json, report-selection.body.invalid_shape, @@ -63567,42 +63487,6 @@ components: - completedAt additionalProperties: false description: The one task the flow offered at the end, and whether it produced its result. - OnboardingStepEnvelope: - type: object - properties: - data: - $ref: "#/components/schemas/OnboardingStepResponse" - error: - type: "null" - meta: - type: object - properties: - requestId: - type: string - additionalProperties: false - required: - - data - - error - additionalProperties: false - OnboardingStepResponse: - type: object - properties: - step: - type: integer - minimum: -9007199254740991 - maximum: 9007199254740991 - description: The stored step after the write. - onboardingCompletedAt: - 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: Non-null once step 4 landed. - required: - - step - - onboardingCompletedAt - additionalProperties: false OnboardingCompleteEnvelope: type: object properties: diff --git a/e2e/setup-flow-a11y.spec.ts b/e2e/setup-flow-a11y.spec.ts new file mode 100644 index 000000000..3891eb7c4 --- /dev/null +++ b/e2e/setup-flow-a11y.spec.ts @@ -0,0 +1,224 @@ +/** + * v1.39 (C2) — axe over every setup screen, at both viewports, plus one + * keyboard-only pass through a question. + * + * The pattern is `a11y.spec.ts`: a scan waits for something the SCREEN + * renders once its state is there — the question's chips, the confirm + * screen's module card, the first-result task — never for the heading the + * scan then checks. The state is driven through the answers route on the + * page's own session, screen by screen, so every URL is one the step machine + * lets through. Both widths run inside the desktop project, because the + * account is mutated and a second project would drive it from a second + * browser. + */ +import AxeBuilder from "@axe-core/playwright"; +import type { Locator, Page } from "@playwright/test"; + +import { + E2E_SETUP_A11Y, + SETUP_A11Y_STORAGE_STATE_PATH, +} from "./setup/global-setup"; +import { resetSetupFlow } from "./setup/setup-flow-fixture"; +import { expect, test } from "./setup/test"; +import { + answer, + answerEverything, + complete, + expectScreen, + type SetupScreen, +} from "./setup-flow-helpers"; + +type AxeViolation = Awaited< + ReturnType +>["violations"][number]; + +const WCAG_TAGS: Record = { + wcag2a: true, + wcag2aa: true, + wcag21a: true, + wcag21aa: true, +}; +const SEMANTIC_RULES: Record = { + "heading-order": true, + "page-has-heading-one": true, + "landmark-one-main": true, + "landmark-unique": true, + region: true, +}; + +const VIEWPORTS = [ + { label: "desktop 1280", width: 1280, height: 720 }, + { label: "phone 390", width: 390, height: 844 }, +]; + +/** Where to go, and what has to be on screen before axe may look. */ +interface ScreenCase { + screen: SetupScreen; + path: string; + painted: (page: Page) => Locator; + /** The state the screen needs, written through the API before the visit. */ + prepare?: (page: Page) => Promise; +} + +const SCREENS: ScreenCase[] = [ + { + screen: "welcome", + path: "/onboarding", + painted: (page) => page.locator('[data-slot="onboarding-set-up"]'), + }, + { + screen: "who", + path: "/onboarding/who", + painted: (page) => page.locator('[data-slot="onboarding-question-who"]'), + }, + { + screen: "areas", + path: "/onboarding/areas", + painted: (page) => page.locator('[data-slot="onboarding-question-areas"]'), + prepare: (page) => answer(page, { step: "who", recordTarget: "me" }), + }, + { + screen: "medication", + path: "/onboarding/medication", + painted: (page) => + page.locator('[data-slot="onboarding-question-medication"]'), + prepare: (page) => + answer(page, { step: "areas", areas: ["glucose", "weight-body"] }), + }, + { + screen: "sources", + path: "/onboarding/sources", + painted: (page) => + page.locator('[data-slot="onboarding-question-sources"]'), + prepare: (page) => answer(page, { step: "medication", medication: "yes" }), + }, + { + screen: "visit", + path: "/onboarding/visit", + painted: (page) => page.locator('[data-slot="onboarding-question-visit"]'), + prepare: (page) => answer(page, { step: "sources", sources: ["oura"] }), + }, + { + screen: "units", + path: "/onboarding/units", + painted: (page) => + page.locator('[data-slot="onboarding-question-glucose-unit"]'), + prepare: (page) => answer(page, { step: "visit", visit: "within-a-month" }), + }, + { + screen: "confirm", + path: "/onboarding/confirm", + painted: (page) => page.locator('[data-slot="onboarding-confirm-modules"]'), + prepare: (page) => + answer(page, { + step: "units", + units: { glucoseUnit: "mmol/L", unitPreference: "metric" }, + }), + }, + { + screen: "first-result", + path: "/onboarding/first-result", + painted: (page) => page.locator('[data-slot="onboarding-task-connect"]'), + prepare: (page) => complete(page), + }, + { + screen: "done", + path: "/onboarding/done", + painted: (page) => page.locator('[data-slot="onboarding-open-dashboard"]'), + prepare: (page) => + answer(page, { step: "first-result", status: "skipped" }), + }, +]; + +function reportBlocking(label: string, blocking: AxeViolation[]) { + if (blocking.length === 0) return; + console.log( + `axe violations for ${label}:\n${blocking + .map( + (violation) => + ` - [${violation.impact}] ${violation.id}: ${violation.help}\n` + + violation.nodes + .map((node) => ` ${node.target.join(" ")}: ${node.html}`) + .join("\n"), + ) + .join("\n")}`, + ); +} + +async function runAxe(page: Page): Promise { + const results = await new AxeBuilder({ page }) + .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "best-practice"]) + .analyze(); + return results.violations.filter((violation) => { + if (SEMANTIC_RULES[violation.id]) { + return ( + violation.impact === "moderate" || + violation.impact === "serious" || + violation.impact === "critical" + ); + } + const isWcagRule = violation.tags.some((tag) => WCAG_TAGS[tag]); + return ( + isWcagRule && + (violation.impact === "serious" || violation.impact === "critical") + ); + }); +} + +// Serial: every test here drives the ONE account through the API, and two +// workers doing that at once would each find the other's answers. +test.describe.configure({ mode: "serial" }); + +test.describe("setup flow — axe on every screen", () => { + test.use({ storageState: SETUP_A11Y_STORAGE_STATE_PATH }); + + for (const vp of VIEWPORTS) { + test(`every screen is clean at ${vp.label}`, async ({ page }) => { + test.setTimeout(180_000); + await resetSetupFlow(E2E_SETUP_A11Y.username); + await page.setViewportSize({ width: vp.width, height: vp.height }); + + for (const item of SCREENS) { + await item.prepare?.(page); + await page.goto(item.path, { waitUntil: "domcontentloaded" }); + await expectScreen(page, item.screen); + await expect(item.painted(page)).toBeVisible({ timeout: 15_000 }); + // Focus lands on the heading on every screen. + await expect(page.locator("h1")).toBeFocused(); + const blocking = await runAxe(page); + reportBlocking(`${item.screen} @${vp.label}`, blocking); + expect(blocking, `${item.screen} @${vp.label}`).toEqual([]); + } + }); + } + + test("a question can be answered with the keyboard alone", async ({ + page, + }) => { + await resetSetupFlow(E2E_SETUP_A11Y.username); + await answerEverything(page); + await page.goto("/onboarding/who", { waitUntil: "domcontentloaded" }); + await expectScreen(page, "who"); + // Focus is on the heading; Tab reaches the first chip's radio, Space + // picks it, and the keyboard walks to "Next". + await page.keyboard.press("Tab"); + const chosen = page.locator( + '[data-slot="onboarding-question-who"] input[type="radio"]:focus', + ); + await expect(chosen).toHaveCount(1); + await page.keyboard.press("ArrowDown"); + await expect( + page.locator('[data-slot="onboarding-choice"][data-checked="true"]'), + ).toHaveAttribute("data-value", "someone-else"); + // Tab past the chip group to the action row, then activate "Next". + for (let i = 0; i < 4; i += 1) { + if (await page.locator('[data-slot="onboarding-next"]:focus').count()) { + break; + } + await page.keyboard.press("Tab"); + } + await expect(page.locator('[data-slot="onboarding-next"]')).toBeFocused(); + await page.keyboard.press("Enter"); + await expectScreen(page, "areas"); + }); +}); diff --git a/e2e/setup-flow-bp.spec.ts b/e2e/setup-flow-bp.spec.ts new file mode 100644 index 000000000..afdf55b91 --- /dev/null +++ b/e2e/setup-flow-bp.spec.ts @@ -0,0 +1,150 @@ +/** + * v1.39 (C2) — the setup flow, path 1: blood pressure and weight, typed in. + * + * From the welcome screen to the dashboard on one account of its own: the + * questions, leaving after Q3 and coming back to Q4, the units question that + * only appears because weight was ticked, the confirm screen, one blood + * pressure reading entered inline as the first result, and the dashboard + * with the checklist open. Then the four things the design spec's definition + * of done names, read off the API rather than the viewport: the module map, + * the tile order, the checklist, and the resume. + * + * Runs in one project (see `playwright.config.ts`): it resets and mutates its + * own account. + */ +import { + E2E_SETUP_BP, + SETUP_BP_STORAGE_STATE_PATH, +} from "./setup/global-setup"; +import { resetSetupFlow } from "./setup/setup-flow-fixture"; +import { expect, test } from "./setup/test"; +import { + acceptAndSetUp, + choose, + expectScreen, + next, + readMe, + readTileOrder, +} from "./setup-flow-helpers"; + +test.describe("setup flow — blood pressure and weight, typed in", () => { + test.use({ storageState: SETUP_BP_STORAGE_STATE_PATH }); + + test.beforeEach(async () => { + await resetSetupFlow(E2E_SETUP_BP.username); + }); + + test("walks from the welcome screen to a first reading on the dashboard", async ({ + page, + }) => { + test.setTimeout(90_000); + + await page.goto("/onboarding"); + await acceptAndSetUp(page); + + await choose(page, "who", ["me"]); + await next(page); + await expectScreen(page, "areas"); + + await choose(page, "areas", ["blood-pressure", "weight-body"]); + await next(page); + await expectScreen(page, "medication"); + + await choose(page, "medication", ["no"]); + await next(page); + await expectScreen(page, "sources"); + + // Leave after Q3 and come back: the first-run redirect lands on + // /onboarding, and the front door sends the flow to the step it owes. + await page.goto("/"); + await expect + .poll(() => new URL(page.url()).pathname) + .toBe("/onboarding/sources"); + await expectScreen(page, "sources"); + + await choose(page, "sources", ["manual"]); + await next(page); + await expectScreen(page, "visit"); + + await choose(page, "visit", ["no"]); + await next(page); + + // Q6 appears because weight was ticked and the account holds no unit. + await expectScreen(page, "units"); + await expect( + page.locator('[data-slot="onboarding-question-unit-preference"]'), + ).toBeVisible(); + await expect( + page.locator('[data-slot="onboarding-question-glucose-unit"]'), + ).toHaveCount(0); + await choose(page, "unit-preference", ["metric"]); + await next(page); + + await expectScreen(page, "confirm"); + await expect( + page.locator('[data-slot="onboarding-confirm-modules"]'), + ).toBeVisible(); + await next(page); + + // The first result: one blood-pressure reading, entered inline. + await expectScreen(page, "first-result"); + const screen = page.locator("section[data-task]"); + await expect(screen).toHaveAttribute("data-task", "log-reading"); + await expect(screen).toHaveAttribute("data-target", "blood-pressure"); + await page.locator("#sys").fill("124"); + await page.locator("#dia").fill("78"); + await page.locator("#puls").fill("66"); + await page.getByRole("button", { name: /^save$/i }).click(); + const done = page.locator('[data-slot="onboarding-first-result-done"]'); + await expect(done).toBeVisible({ timeout: 15_000 }); + // The reading on its tile, not only a tick: the value that was typed. + await expect(done).toContainText("124/78", { timeout: 15_000 }); + await next(page); + + await expectScreen(page, "done"); + await page.locator('[data-slot="onboarding-open-dashboard"]').click(); + await expect.poll(() => new URL(page.url()).pathname).toBe("/"); + + // The dashboard, with the checklist open. + const checklist = page.locator('[data-testid="onboarding-card"]'); + await expect(checklist).toBeVisible({ timeout: 15_000 }); + await expect(checklist.locator("#getting-started-body")).toBeVisible(); + + // The module map: nothing the answers did not name is on; the always-on + // trio is. + const me = await readMe(page); + for (const key of [ + "glucose", + "sleep", + "recovery", + "mood", + "mentalHealth", + "labs", + "illness", + "workouts", + "medications", + "doctorReport", + "vaccinations", + ]) { + expect(me.modules[key], `module ${key}`).toBe(false); + } + for (const key of ["insights", "achievements", "inboundDocuments"]) { + expect(me.modules[key], `module ${key}`).not.toBe(false); + } + expect(me.onboarding.completedAt).not.toBeNull(); + expect(me.onboarding.firstResult?.task).toBe("log-reading"); + expect(me.onboarding.firstResult?.completedAt).not.toBeNull(); + expect( + me.onboarding.steps.every((step) => step.status !== "pending"), + "every step settled", + ).toBe(true); + + // The tile order: the two areas' five tiles lead, in the default + // layout's own relative order, and everything else sits behind them. + const order = await readTileOrder(page); + expect(new Set(order.slice(0, 5))).toEqual( + new Set(["weight", "bp", "pulse", "bodyFat", "bpInTarget"]), + ); + expect(order.indexOf("hrv")).toBeGreaterThan(4); + }); +}); diff --git a/e2e/setup-flow-child.spec.ts b/e2e/setup-flow-child.spec.ts new file mode 100644 index 000000000..769d8fa68 --- /dev/null +++ b/e2e/setup-flow-child.spec.ts @@ -0,0 +1,189 @@ +/** + * v1.39 (C2) — the setup flow, path 4: a child's managed profile. + * + * Q1 "someone I look after" keeps the questions in the guardian's own record + * and creates the profile on the confirm screen, after the questions — the + * maintainer's answer to spec question 2, and the only order the routes + * allow, since the three writes refuse under an acting-account switch. The + * answers are then applied to the CHILD: the completion carries the new + * record's id, the derivation lands there (immunization log on, everything + * the answers did not name off), and the guardian's own module map is left + * exactly as it was. The first-result screen is not in the order (its tasks + * would write the guardian's record), and the done screen offers the new + * record through the same switch the account menu uses. + * + * This spec once asserted `glucose === false` on the GUARDIAN's map — the + * review's H1 — which pinned the child's answers re-ordering a parent's own + * record. It now reads the child's map through a real switch and the + * guardian's map for being untouched, so a regression to deriving onto the + * caller fails twice over. + * + * Creating the profile resolves `requireFreshMfa`, so the account carries a + * confirmed factor (stamped by global setup after its login) and the fixture + * makes the session's step-up fresh right before the walk. + */ +import { + E2E_SETUP_CHILD, + SETUP_CHILD_STORAGE_STATE_PATH, +} from "./setup/global-setup"; +import { + clearManagedProfilesOf, + resetSetupFlow, + stampFreshMfa, +} from "./setup/setup-flow-fixture"; +import { expect, test } from "./setup/test"; +import { + acceptAndSetUp, + choose, + expectScreen, + next, + readMe, +} from "./setup-flow-helpers"; + +const PROFILE_NAME = "Setup journey child"; + +// Serial: both tests reset and walk the ONE account, and two workers doing +// that at once each undo the other's setup mid-flow. +test.describe.configure({ mode: "serial" }); + +test.describe("setup flow — a child's managed profile", () => { + test.use({ storageState: SETUP_CHILD_STORAGE_STATE_PATH }); + + test.beforeEach(async () => { + await clearManagedProfilesOf(E2E_SETUP_CHILD.username); + await resetSetupFlow(E2E_SETUP_CHILD.username); + await stampFreshMfa(E2E_SETUP_CHILD.username); + }); + + test("creates the profile on the confirm screen and offers its record", async ({ + page, + }) => { + test.setTimeout(90_000); + + await page.goto("/onboarding"); + await acceptAndSetUp(page); + + await choose(page, "who", ["someone-else"]); + await next(page); + await expectScreen(page, "areas"); + await choose(page, "areas", ["blood-pressure"]); + await next(page); + await expectScreen(page, "medication"); + await choose(page, "medication", ["no"]); + await next(page); + await expectScreen(page, "sources"); + await choose(page, "sources", ["manual"]); + await next(page); + await expectScreen(page, "visit"); + await choose(page, "visit", ["no"]); + await next(page); + + // The confirm screen is the managed-profile form for this answer. + await expectScreen(page, "confirm"); + const managed = page.locator('[data-slot="onboarding-confirm-managed"]'); + await expect(managed).toBeVisible(); + await managed + .locator('[data-slot="managed-profile-name"]') + .fill(PROFILE_NAME); + await managed + .locator('[data-slot="managed-profile-create-submit"]') + .click(); + + // No first-result screen for this answer: straight to done, with the new + // record offered. + await expectScreen(page, "done"); + await expect( + page.locator('[data-slot="onboarding-open-managed-record"]'), + ).toBeVisible({ timeout: 15_000 }); + + // The guardian's own record: flow complete, module map untouched — a + // fresh account carries no explicit map, so nothing reads `false`. + const me = await readMe(page); + expect(me.onboarding.completedAt).not.toBeNull(); + expect( + me.onboarding.steps.find((step) => step.id === "first-result")?.status, + ).toBe("skipped"); + for (const key of ["glucose", "sleep", "mood", "labs", "medications"]) { + expect(me.modules[key], `guardian module ${key}`).not.toBe(false); + } + const managedEntries = (me.accountAccess?.accounts ?? []).filter( + (entry) => entry.recordKind === "managed", + ); + expect(managedEntries).toHaveLength(1); + + // The child's record, read through a real switch: the answers landed + // here. Switched back afterwards so the jar leaves the way it came. + const switched = await page.request.post("/api/account/switch", { + data: { accountId: managedEntries[0].accountId }, + }); + expect(switched.status(), "switching into the child's record").toBe(200); + try { + const child = await readMe(page); + expect(child.modules.vaccinations).not.toBe(false); + for (const key of ["glucose", "sleep", "mood", "labs", "medications"]) { + expect(child.modules[key], `child module ${key}`).toBe(false); + } + expect(child.onboarding.needs.recordTarget).toBe("someone-else"); + expect(child.onboarding.completedAt).not.toBeNull(); + } finally { + const back = await page.request.post("/api/account/switch", { + data: { accountId: null }, + }); + expect(back.status(), "switching back").toBe(200); + } + + await page.locator('[data-slot="onboarding-open-dashboard"]').click(); + await expect.poll(() => new URL(page.url()).pathname).toBe("/"); + }); + + test("finishing without the profile derives nothing onto the guardian", async ({ + page, + }) => { + // The other exit of the same screen (H1'): no profile is created, so + // there is no record the answers describe. The guardian's own map must + // stay exactly as it was, and the checklist carries the profile instead. + test.setTimeout(90_000); + + await page.goto("/onboarding"); + await acceptAndSetUp(page); + await choose(page, "who", ["someone-else"]); + await next(page); + await expectScreen(page, "areas"); + await choose(page, "areas", ["blood-pressure"]); + await next(page); + await expectScreen(page, "medication"); + await choose(page, "medication", ["yes"]); + await next(page); + await expectScreen(page, "sources"); + await choose(page, "sources", ["oura"]); + await next(page); + await expectScreen(page, "visit"); + await choose(page, "visit", ["within-a-month"]); + await next(page); + + await expectScreen(page, "confirm"); + await page + .locator('[data-slot="onboarding-finish-without-profile"]') + .click(); + await expectScreen(page, "done"); + await expect( + page.locator('[data-slot="onboarding-open-managed-record"]'), + ).toHaveCount(0); + + const me = await readMe(page); + expect(me.onboarding.completedAt).not.toBeNull(); + for (const key of ["glucose", "sleep", "mood", "labs", "vaccinations"]) { + expect(me.modules[key], `guardian module ${key}`).not.toBe(false); + } + expect((me.accountAccess?.accounts ?? []).length).toBe(0); + + await page.locator('[data-slot="onboarding-open-dashboard"]').click(); + await expect.poll(() => new URL(page.url()).pathname).toBe("/"); + const checklist = page.locator('[data-testid="onboarding-card"]'); + await expect(checklist).toBeVisible({ timeout: 15_000 }); + await expect( + checklist.locator('li[data-item-id="managedProfile"]'), + ).toHaveAttribute("data-done", "false"); + await expect(checklist.locator('li[data-item-id="visit"]')).toHaveCount(0); + }); +}); diff --git a/e2e/setup-flow-helpers.ts b/e2e/setup-flow-helpers.ts new file mode 100644 index 000000000..53973ed4e --- /dev/null +++ b/e2e/setup-flow-helpers.ts @@ -0,0 +1,119 @@ +/** + * v1.39 (C2) — driving the setup flow in a browser, shared by its journeys. + * + * Everything here addresses stable `data-slot` / `data-*` attributes, never + * viewport text: the copy is i18n-driven and one of the specs runs every + * locale. The API helpers ride the page's own cookie jar, so what they write + * lands on the account the browser is signed in as. + */ +import { expect, type Page } from "@playwright/test"; + +export type SetupScreen = + | "welcome" + | "who" + | "areas" + | "medication" + | "sources" + | "visit" + | "units" + | "confirm" + | "first-result" + | "done"; + +export const SHELL = '[data-slot="onboarding-shell"]'; + +export async function expectScreen(page: Page, screen: SetupScreen) { + await expect(page.locator(SHELL)).toHaveAttribute("data-screen", screen, { + timeout: 15_000, + }); +} + +/** Tick one or more chips of the question on screen. */ +export async function choose( + page: Page, + question: string, + values: readonly string[], +): Promise { + for (const value of values) { + await page + .locator( + `[data-slot="onboarding-question-${question}"] [data-slot="onboarding-choice"][data-value="${value}"]`, + ) + .click(); + } +} + +export async function next(page: Page): Promise { + await page.locator('[data-slot="onboarding-next"]').click(); +} + +export async function skip(page: Page): Promise { + await page.locator('[data-slot="onboarding-skip"]').click(); +} + +/** The welcome screen's acknowledgment plus "Set up". */ +export async function acceptAndSetUp(page: Page): Promise { + await expectScreen(page, "welcome"); + const box = page.locator('[data-slot="onboarding-disclaimer"]'); + if ((await box.getAttribute("data-state")) !== "checked") { + await box.click(); + } + await page.locator('[data-slot="onboarding-set-up"]').click(); + await expectScreen(page, "who"); +} + +/** The account payload, as the browser's session sees it. */ +export async function readMe(page: Page): Promise { + const res = await page.request.get("/api/auth/me"); + expect(res.status(), "reading the account payload").toBe(200); + return ((await res.json()) as { data: MePayload }).data; +} + +export interface MePayload { + modules: Record; + onboarding: { + steps: Array<{ id: string; status: string }>; + needs: { recordTarget: string | null; areas: string[] }; + completedAt: string | null; + firstResult: { task: string; completedAt: string | null } | null; + }; + accountAccess?: { + accounts: Array<{ accountId: string; recordKind: string }>; + }; +} + +/** The dashboard layout's tile ids, in order. */ +export async function readTileOrder(page: Page): Promise { + const res = await page.request.get("/api/dashboard/widgets"); + expect(res.status(), "reading the dashboard layout").toBe(200); + const { data } = (await res.json()) as { + data: { widgets: Array<{ id: string; order: number }> }; + }; + return [...data.widgets].sort((a, b) => a.order - b.order).map((w) => w.id); +} + +/** One answer through the answers route, on the browser's own session. */ +export async function answer(page: Page, body: unknown): Promise { + const res = await page.request.patch("/api/onboarding/answers", { + data: body, + }); + expect(res.status(), `answering ${JSON.stringify(body)}`).toBe(200); +} + +export async function complete(page: Page): Promise { + const res = await page.request.post("/api/onboarding/complete", { data: {} }); + expect(res.status(), "completing the flow").toBe(200); +} + +/** + * Answer every question through the API so a later screen can be visited + * directly. The answers name a glucose area (so Q6 applies) and a wearable + * (so the first-result task is a connection the screen can wait for). + */ +export async function answerEverything(page: Page): Promise { + await answer(page, { step: "who", recordTarget: "me" }); + await answer(page, { step: "areas", areas: ["glucose", "weight-body"] }); + await answer(page, { step: "medication", medication: "yes" }); + await answer(page, { step: "sources", sources: ["oura"] }); + await answer(page, { step: "visit", visit: "within-a-month" }); +} diff --git a/e2e/setup-flow-locale-overflow.spec.ts b/e2e/setup-flow-locale-overflow.spec.ts new file mode 100644 index 000000000..81977d71c --- /dev/null +++ b/e2e/setup-flow-locale-overflow.spec.ts @@ -0,0 +1,239 @@ +/** + * v1.39 (C2) — every setup screen, in every locale, with 30% longer strings. + * + * The design spec asks for 30–50% string headroom in every layout, and the + * seven bundles are the floor, not the ceiling: a German label that fits + * today is one revision from not fitting. So this spec does what + * `locale-switch.spec.ts` does (set the locale cookie, render, refuse raw + * keys) and then what `dialog-horizontal-overflow.spec.ts` does (measure + * real overflow in the page), with one step between them: every text node + * on the screen is lengthened by 30% in place before the measurement, so + * the check is against the headroom rather than against today's copy. + * + * Three claims per screen, locale and width: the page does not scroll + * sideways, nothing that clips its text (`overflow: hidden`) is actually + * clipping, and every control sits inside the viewport. The state is driven + * through the API once per test and every screen is visited by URL, which + * the step machine allows for a flow that reached the end. + */ +import type { Page } from "@playwright/test"; + +import { + E2E_SETUP_LOCALE, + SETUP_LOCALE_STORAGE_STATE_PATH, +} from "./setup/global-setup"; +import { resetSetupFlow } from "./setup/setup-flow-fixture"; +import { expect, test } from "./setup/test"; +import { + answer, + answerEverything, + complete, + expectScreen, + SHELL, + type SetupScreen, +} from "./setup-flow-helpers"; + +const LOCALES = ["en", "de", "es", "fr", "it", "pl", "ko"] as const; + +const VIEWPORTS = [ + { label: "desktop 1280", width: 1280, height: 800 }, + { label: "phone 390", width: 390, height: 844 }, +]; + +const SCREENS: Array<{ screen: SetupScreen; path: string }> = [ + { screen: "who", path: "/onboarding/who" }, + { screen: "areas", path: "/onboarding/areas" }, + { screen: "medication", path: "/onboarding/medication" }, + { screen: "sources", path: "/onboarding/sources" }, + { screen: "visit", path: "/onboarding/visit" }, + { screen: "units", path: "/onboarding/units" }, + { screen: "confirm", path: "/onboarding/confirm" }, + { screen: "first-result", path: "/onboarding/first-result" }, + { screen: "done", path: "/onboarding/done" }, +]; + +/** + * A rendered i18n key is a lookup that fell through. The flow's keys are + * three or more dotted segments (`onboarding.flow.who.options.me`, + * `onboarding.steps.first-result`), lowercase and kebab as often as camel — + * so the test is the dotted shape itself, not a camelCase segment inside it + * (which the locale-switch smoke test requires and which most of these keys + * do not have). No sentence on these screens carries such a run. + */ +const RAW_KEY = /\b[a-z]+(?:\.[a-zA-Z][\w-]*){2,}\b/; + +/** + * Lengthen every text node under the shell by 30%, then measure. Padding + * lengthens every WORD by 30%, so the longest unbreakable run grows in the + * same proportion as the sentence — which is what a longer translation does. + * Appending the whole surplus to one word would manufacture an unbreakable + * run no locale produces; adding only spaces would wrap for free and prove + * nothing. + */ +async function padAndMeasure(page: Page) { + return page.evaluate((shellSelector) => { + const shell = document.querySelector(shellSelector); + if (!shell) return null; + + const walker = document.createTreeWalker(shell, NodeFilter.SHOW_TEXT); + let node: Node | null = walker.nextNode(); + let padded = 0; + while (node) { + const text = node.textContent ?? ""; + const letters = text.replace(/\s+/g, "").length; + if ( + letters >= 3 && + node.parentElement?.closest("script,style") === null + ) { + node.textContent = text.replace(/\S+/g, (word) => + word.length >= 2 + ? word + "x".repeat(Math.ceil(word.length * 0.3)) + : word, + ); + padded += 1; + } + node = walker.nextNode(); + } + + const doc = document.documentElement; + const clippers = [...shell.querySelectorAll("*")] + .filter((el) => { + // Screen-reader-only text is a 1 px clipped box on purpose; it is + // read, not seen, and cannot truncate. + if (el.classList.contains("sr-only") || el.clientWidth <= 1) { + return false; + } + // A select's value ellipsises by design (shadcn `SelectValue`, app + // wide): the full text is the option in the list and the control's + // accessible name, and a trigger that grew with its longest option + // would move every neighbour. Not a layout that lost headroom. + if (el.dataset.slot === "select-value") return false; + const overflowX = getComputedStyle(el).overflowX; + return overflowX === "hidden" || overflowX === "clip"; + }) + .filter((el) => el.scrollWidth > el.clientWidth + 1) + .map((el) => ({ + slot: el.dataset.slot ?? el.tagName.toLowerCase(), + text: (el.textContent ?? "").trim().slice(0, 40), + scrollWidth: el.scrollWidth, + clientWidth: el.clientWidth, + })); + /** + * A name for the box that overflowed, so a red run says WHAT rather than + * only that the page grew: tag, `data-slot`, the first classes, the text + * it starts with, and its edges. The CI runner's fallback fonts render a + * pixel or two wider than a Mac's, so the culprit is often invisible + * locally and the message is the whole diagnosis. + */ + const describe = (el: HTMLElement, r: DOMRect) => ({ + tag: el.tagName.toLowerCase(), + slot: el.dataset.slot ?? null, + classes: el.className.toString().split(/\s+/).slice(0, 4).join(" "), + text: (el.textContent ?? "").trim().slice(0, 40), + left: Math.round(r.left), + right: Math.round(r.right), + width: Math.round(r.width), + }); + const shellRight = shell.getBoundingClientRect().right; + // Every box that reaches past the shell's own right edge, widest first, + // deepest first among equals (the leaf is the culprit, its ancestors + // merely grew with it). + const widest = [...shell.querySelectorAll("*")] + .map((el) => ({ el, r: el.getBoundingClientRect() })) + .filter(({ r }) => r.width > 0 && r.right > shellRight + 1) + .sort( + (a, b) => + b.r.right - a.r.right || b.el.children.length - a.el.children.length, + ) + .reverse() + .slice(0, 6) + .map(({ el, r }) => describe(el, r)); + const outside = [ + ...shell.querySelectorAll( + "button, input, select, textarea, a[href], [tabindex]:not([tabindex='-1'])", + ), + ] + .map((el) => ({ el, r: el.getBoundingClientRect() })) + .filter(({ r }) => r.width > 0 && r.height > 0) + .filter(({ r }) => r.right > window.innerWidth + 1 || r.left < -1) + .map(({ el, r }) => describe(el, r)); + + return { + padded, + pageScrollWidth: doc.scrollWidth, + pageClientWidth: doc.clientWidth, + shellScrollWidth: shell.scrollWidth, + shellClientWidth: shell.clientWidth, + widest, + clippers, + outside, + }; + }, SHELL); +} + +// Serial: every test here drives the ONE account through the API, and two +// workers doing that at once would each find the other's answers. +test.describe.configure({ mode: "serial" }); + +test.describe("setup flow — every locale at 30% longer strings", () => { + test.use({ storageState: SETUP_LOCALE_STORAGE_STATE_PATH }); + + for (const locale of LOCALES) { + for (const vp of VIEWPORTS) { + test(`${locale} fits at ${vp.label}`, async ({ page, context }) => { + test.setTimeout(180_000); + await resetSetupFlow(E2E_SETUP_LOCALE.username); + await answerEverything(page); + await answer(page, { + step: "units", + units: { glucoseUnit: "mg/dL", unitPreference: "metric" }, + }); + await complete(page); + await answer(page, { step: "first-result", status: "skipped" }); + + const baseURL = + test.info().project.use.baseURL ?? "http://localhost:3000"; + await context.addCookies([ + { name: "healthlog-locale", value: locale, url: baseURL }, + ]); + await page.setViewportSize({ width: vp.width, height: vp.height }); + + for (const item of SCREENS) { + await page.goto(item.path, { waitUntil: "domcontentloaded" }); + await expectScreen(page, item.screen); + await expect(page.locator("h1")).toBeVisible(); + + // No raw key before the padding touches the text. + const body = await page.locator(SHELL).innerText(); + const match = body.match(RAW_KEY); + expect( + match?.[0] ?? null, + `raw i18n key on ${item.screen} in ${locale}`, + ).toBeNull(); + + const result = await padAndMeasure(page); + const label = `${item.screen} ${locale} @${vp.label}`; + expect(result, `${label}: shell not found`).not.toBeNull(); + expect( + result!.padded, + `${label}: nothing was padded, so this measured nothing`, + ).toBeGreaterThan(3); + const culprits = JSON.stringify(result!.widest); + expect( + result!.pageScrollWidth, + `${label}: the page scrolls sideways; past the shell's edge: ${culprits}`, + ).toBeLessThanOrEqual(result!.pageClientWidth + 1); + expect( + result!.shellScrollWidth, + `${label}: the shell scrolls sideways; past the shell's edge: ${culprits}`, + ).toBeLessThanOrEqual(result!.shellClientWidth + 1); + expect(result!.clippers, `${label}: text is clipped`).toEqual([]); + expect( + result!.outside, + `${label}: control(s) outside the viewport`, + ).toEqual([]); + } + }); + } + } +}); diff --git a/e2e/setup-flow-medication.spec.ts b/e2e/setup-flow-medication.spec.ts new file mode 100644 index 000000000..9f2972597 --- /dev/null +++ b/e2e/setup-flow-medication.spec.ts @@ -0,0 +1,124 @@ +/** + * v1.39 (C2) — the setup flow, path 2: a daily medication with a reminder. + * + * Q3 "yes" is the one answer that makes the first medication the task the + * flow ends on, ahead of a reading; every other question is passed, which is + * the spec's own claim that only Q1 is required. The medication wizard runs + * inline on the first-result screen, and the result stays on that screen + * rather than landing on the medication's page. + */ +import { + E2E_SETUP_MEDICATION, + SETUP_MEDICATION_STORAGE_STATE_PATH, +} from "./setup/global-setup"; +import { resetSetupFlow } from "./setup/setup-flow-fixture"; +import { expect, test } from "./setup/test"; +import { + clickNext, + clickSave, + expectStep, + fillStep1Name, + fillStep3Dose, + pickCadenceRow, + pickTreatmentRow, +} from "./medications-wizard-helpers"; +import { + acceptAndSetUp, + choose, + expectScreen, + next, + readMe, + readTileOrder, + skip, +} from "./setup-flow-helpers"; + +test.describe("setup flow — a daily medication with a reminder", () => { + test.use({ storageState: SETUP_MEDICATION_STORAGE_STATE_PATH }); + + test.beforeEach(async () => { + await resetSetupFlow(E2E_SETUP_MEDICATION.username); + }); + + test("ends on the first medication, added inline", async ({ page }) => { + test.setTimeout(120_000); + + await page.goto("/onboarding"); + await acceptAndSetUp(page); + + await choose(page, "who", ["me"]); + await next(page); + await expectScreen(page, "areas"); + await skip(page); + + await expectScreen(page, "medication"); + await choose(page, "medication", ["yes"]); + await next(page); + + await expectScreen(page, "sources"); + await skip(page); + await expectScreen(page, "visit"); + await skip(page); + + // No unit-bearing area was ticked, so Q6 is not in this flow. + await expectScreen(page, "confirm"); + await next(page); + + await expectScreen(page, "first-result"); + await expect(page.locator("section[data-task]")).toHaveAttribute( + "data-task", + "add-medication", + ); + await page.locator('[data-slot="onboarding-add-medication"]').click(); + await expect( + page.locator('[data-slot="medication-wizard-dialog"]'), + ).toBeVisible({ timeout: 10_000 }); + + await fillStep1Name(page, { name: "Setup journey tablet" }); + await clickNext(page); + await expectStep(page, 2); + await pickTreatmentRow(page, "other"); + await clickNext(page); + await expectStep(page, 3); + await fillStep3Dose(page, { amount: "500" }); + await clickNext(page); + await expectStep(page, 4); + await clickNext(page); + await expectStep(page, 5, 8); + await pickCadenceRow(page, "daily"); + await expectStep(page, 5, 7); + await clickNext(page); + // Times of day arrive with 08:00 already on the plan — one dose a day. + await expectStep(page, 6, 7); + await clickNext(page); + await expectStep(page, 7, 7); + await clickSave(page); + + // The result stays here: the wizard does not navigate away. + await expect( + page.locator('[data-slot="onboarding-first-result-done"]'), + ).toBeVisible({ timeout: 20_000 }); + expect(new URL(page.url()).pathname).toBe("/onboarding/first-result"); + await next(page); + + await expectScreen(page, "done"); + await page.locator('[data-slot="onboarding-open-dashboard"]').click(); + await expect.poll(() => new URL(page.url()).pathname).toBe("/"); + await expect(page.locator('[data-testid="onboarding-card"]')).toBeVisible({ + timeout: 15_000, + }); + + const me = await readMe(page); + expect(me.modules.medications).not.toBe(false); + expect(me.modules.glucose).toBe(false); + expect(me.onboarding.firstResult?.task).toBe("add-medication"); + expect(me.onboarding.firstResult?.completedAt).not.toBeNull(); + + // The medication tile leads the dashboard. + const order = await readTileOrder(page); + expect(order[0]).toBe("medications"); + + const meds = await page.request.get("/api/medications"); + const { data } = (await meds.json()) as { data: Array<{ name: string }> }; + expect(data.map((m) => m.name)).toContain("Setup journey tablet"); + }); +}); diff --git a/e2e/setup-flow-visit.spec.ts b/e2e/setup-flow-visit.spec.ts new file mode 100644 index 000000000..400bc777a --- /dev/null +++ b/e2e/setup-flow-visit.spec.ts @@ -0,0 +1,102 @@ +/** + * v1.39 (C2) — the setup flow, path 3: a doctor's visit within a month, with + * lab results. + * + * The visit answer is what switches the doctor report on and puts "prepare + * the visit" on the checklist; the labs area has no inline form, so the + * first-result screen points at the labs page and takes the person's word + * that the first entry was made. + */ +import { + E2E_SETUP_VISIT, + SETUP_VISIT_STORAGE_STATE_PATH, +} from "./setup/global-setup"; +import { resetSetupFlow } from "./setup/setup-flow-fixture"; +import { expect, test } from "./setup/test"; +import { + acceptAndSetUp, + choose, + expectScreen, + next, + readMe, +} from "./setup-flow-helpers"; + +test.describe("setup flow — a visit within a month, with labs", () => { + test.use({ storageState: SETUP_VISIT_STORAGE_STATE_PATH }); + + test.beforeEach(async () => { + await resetSetupFlow(E2E_SETUP_VISIT.username); + }); + + test("switches the report on and puts the visit on the checklist", async ({ + page, + }) => { + test.setTimeout(90_000); + + await page.goto("/onboarding"); + await acceptAndSetUp(page); + + await choose(page, "who", ["me"]); + await next(page); + await expectScreen(page, "areas"); + await choose(page, "areas", ["labs"]); + await next(page); + await expectScreen(page, "medication"); + await choose(page, "medication", ["no"]); + await next(page); + await expectScreen(page, "sources"); + await choose(page, "sources", ["file"]); + await next(page); + await expectScreen(page, "visit"); + await choose(page, "visit", ["within-a-month"]); + await next(page); + + await expectScreen(page, "confirm"); + await next(page); + + await expectScreen(page, "first-result"); + const screen = page.locator("section[data-task]"); + await expect(screen).toHaveAttribute("data-task", "log-reading"); + await expect(screen).toHaveAttribute("data-target", "labs"); + await expect( + page.locator('[data-slot="onboarding-task-reading-link"]'), + ).toBeVisible(); + await page.locator('[data-slot="onboarding-reading-done"]').click(); + await expect( + page.locator('[data-slot="onboarding-first-result-done"]'), + ).toBeVisible(); + await next(page); + + await expectScreen(page, "done"); + await page.locator('[data-slot="onboarding-open-dashboard"]').click(); + await expect.poll(() => new URL(page.url()).pathname).toBe("/"); + + // The checklist opens with the visit row on it, not yet done. + const checklist = page.locator('[data-testid="onboarding-card"]'); + await expect(checklist).toBeVisible({ timeout: 15_000 }); + await expect(checklist.locator("#getting-started-body")).toBeVisible(); + const visitRow = checklist.locator('li[data-item-id="visit"]'); + await expect(visitRow).toHaveAttribute("data-done", "false"); + await expect(visitRow.locator('a[href="/checkups"]')).toBeVisible(); + + // Book the visit; the row reads the visits list's own body and flips. + const booked = await page.request.post("/api/encounters", { + data: { + occurredAt: new Date(Date.now() + 7 * 86_400_000).toISOString(), + status: "PLANNED", + }, + }); + expect(booked.status(), "booking the visit").toBe(201); + await page.reload({ waitUntil: "domcontentloaded" }); + await expect( + page.locator('[data-testid="onboarding-card"] li[data-item-id="visit"]'), + ).toHaveAttribute("data-done", "true", { timeout: 15_000 }); + + const me = await readMe(page); + expect(me.modules.labs).not.toBe(false); + expect(me.modules.doctorReport).not.toBe(false); + expect(me.modules.medications).toBe(false); + expect(me.onboarding.firstResult?.task).toBe("log-reading"); + expect(me.onboarding.firstResult?.completedAt).not.toBeNull(); + }); +}); diff --git a/e2e/setup/global-setup.ts b/e2e/setup/global-setup.ts index 8823334ec..13bfccc33 100644 --- a/e2e/setup/global-setup.ts +++ b/e2e/setup/global-setup.ts @@ -424,6 +424,103 @@ export const SEAM_BANNERS_STORAGE_STATE_PATH = resolve( "e2e/setup/storageStateSeamBanners.json", ); +/** + * v1.39 (C2) — the setup flow's own accounts, one per journey. + * + * Each journey walks the questions from the welcome screen to the dashboard + * and asserts what the walk wrote: the module map, the tile order, the + * checklist, the reading or medication or profile the first-result step + * produced. Two journeys sharing an account would each read the other's + * answers, so every path has its own — and the two screen sweeps (axe, and + * the string-headroom check across every locale) have theirs, because they + * drive the same state through the API while a journey may be mid-walk. + * + * Seeded with `onboarding_completed_at` NULL, which is what makes the shell + * land them on `/onboarding` in the first place; the fixture puts each one + * back to that state before every test. + */ +export const E2E_SETUP_BP = { + email: "e2e-setup-bp@healthlog.test", + username: "e2e-setup-bp", + password: "Bp7!Wq3nZ9xLm2Kd", + role: "USER", +} as const; + +export const E2E_SETUP_MEDICATION = { + email: "e2e-setup-medication@healthlog.test", + username: "e2e-setup-medication", + password: "Md4!Rt8vC2yPn6Hs", + role: "USER", +} as const; + +export const E2E_SETUP_VISIT = { + email: "e2e-setup-visit@healthlog.test", + username: "e2e-setup-visit", + password: "Vs9!Kq2mX7bTd4Lp", + role: "USER", +} as const; + +/** + * The child-profile journey's account. It creates a managed profile on the + * confirm screen, which resolves `requireFreshMfa`, so like the guardian it + * carries a confirmed factor — stamped AFTER its login capture, because the + * password-only capture cannot complete a TOTP login. + */ +export const E2E_SETUP_CHILD = { + email: "e2e-setup-child@healthlog.test", + username: "e2e-setup-child", + password: "Ch3!Ns6wQ8kZr5Vb", + role: "USER", +} as const; + +export const E2E_SETUP_A11Y = { + email: "e2e-setup-a11y@healthlog.test", + username: "e2e-setup-a11y", + password: "Ay5!Ht9pL3cWn7Xq", + role: "USER", +} as const; + +export const E2E_SETUP_LOCALE = { + email: "e2e-setup-locale@healthlog.test", + username: "e2e-setup-locale", + password: "Lc2!Vm7dR4jYs8Nt", + role: "USER", +} as const; + +export const SETUP_BP_STORAGE_STATE_PATH = resolve( + process.cwd(), + "e2e/setup/storageStateSetupBp.json", +); +export const SETUP_MEDICATION_STORAGE_STATE_PATH = resolve( + process.cwd(), + "e2e/setup/storageStateSetupMedication.json", +); +export const SETUP_VISIT_STORAGE_STATE_PATH = resolve( + process.cwd(), + "e2e/setup/storageStateSetupVisit.json", +); +export const SETUP_CHILD_STORAGE_STATE_PATH = resolve( + process.cwd(), + "e2e/setup/storageStateSetupChild.json", +); +export const SETUP_A11Y_STORAGE_STATE_PATH = resolve( + process.cwd(), + "e2e/setup/storageStateSetupA11y.json", +); +export const SETUP_LOCALE_STORAGE_STATE_PATH = resolve( + process.cwd(), + "e2e/setup/storageStateSetupLocale.json", +); + +const SETUP_FLOW_ACCOUNTS = [ + E2E_SETUP_BP, + E2E_SETUP_MEDICATION, + E2E_SETUP_VISIT, + E2E_SETUP_CHILD, + E2E_SETUP_A11Y, + E2E_SETUP_LOCALE, +] as const; + async function hashPassword(password: string): Promise { return hash(password, { memoryCost: 19456, @@ -1038,6 +1135,45 @@ export default async function globalSetup(config: FullConfig): Promise { // can reuse them via `test.use({ storageState })`. This is the documented // Playwright pattern (`docs/auth.md`) and the only way to avoid the // per-spec login + rate-limit dance. + // v1.39 (C2) — the setup flow's accounts. `onboarding_completed_at` NULL + // is the whole point: it is what lands them on `/onboarding`. The tour + // flag stays true for the reason every other fixture sets it. Whatever a + // previous run wrote — the answers row, the module map, the seeded + // dashboard, a managed profile — is cleared by the journeys' own fixture + // before each test; here the row itself is made to exist. + for (const account of SETUP_FLOW_ACCOUNTS) { + await pool.query( + `INSERT INTO users + (id, username, email, password_hash, role, created_at, updated_at, + onboarding_completed_at, onboarding_tour_completed) + VALUES ($1, $2, $3, $4, 'USER', $5, $5, NULL, true) + ON CONFLICT (username) DO UPDATE SET + email = EXCLUDED.email, + password_hash = EXCLUDED.password_hash, + updated_at = EXCLUDED.updated_at, + onboarding_completed_at = NULL, + onboarding_tour_completed = true, + totp_confirmed_at = NULL`, + [ + cuid(), + account.username, + account.email, + await hashPassword(account.password), + now, + ], + ); + } + await pool.query( + `DELETE FROM users + WHERE managed_profile_at IS NOT NULL + AND id IN ( + SELECT g.grantor_id FROM account_grants g + JOIN users u ON u.id = g.grantee_id + WHERE u.username = $1 + )`, + [E2E_SETUP_CHILD.username], + ); + const baseURL = config.projects[0]?.use.baseURL ?? process.env.E2E_BASE_URL ?? @@ -1117,6 +1253,19 @@ export default async function globalSetup(config: FullConfig): Promise { `UPDATE users SET totp_confirmed_at = $2 WHERE username = $1`, [E2E_GUARDIAN.username, now], ); + // v1.39 (C2) — the setup flow's jars, one per journey and one per sweep. + // The child journey's factor is stamped after its capture, for the reason + // the guardian's is: the password-only capture cannot pass a TOTP login. + await capture(E2E_SETUP_BP, SETUP_BP_STORAGE_STATE_PATH); + await capture(E2E_SETUP_MEDICATION, SETUP_MEDICATION_STORAGE_STATE_PATH); + await capture(E2E_SETUP_VISIT, SETUP_VISIT_STORAGE_STATE_PATH); + await capture(E2E_SETUP_A11Y, SETUP_A11Y_STORAGE_STATE_PATH); + await capture(E2E_SETUP_LOCALE, SETUP_LOCALE_STORAGE_STATE_PATH); + await capture(E2E_SETUP_CHILD, SETUP_CHILD_STORAGE_STATE_PATH); + await pool.query( + `UPDATE users SET totp_confirmed_at = $2 WHERE username = $1`, + [E2E_SETUP_CHILD.username, now], + ); } finally { await pool.end(); } diff --git a/e2e/setup/setup-flow-fixture.ts b/e2e/setup/setup-flow-fixture.ts new file mode 100644 index 000000000..c8b6954de --- /dev/null +++ b/e2e/setup/setup-flow-fixture.ts @@ -0,0 +1,118 @@ +/** + * v1.39 (C2) — the state the setup-flow journeys start from, and nothing else. + * + * Each journey owns one account and walks it from the welcome screen to the + * dashboard. What a walk leaves behind — the answers row, the completion + * stamp, the module map, the seeded dashboard, the reading or medication the + * first-result step produced, a managed profile — is exactly what the next + * run would then find already done, so every journey resets its own account + * before it starts. Seeded through `pg`, the same transport `global-setup.ts` + * and the other fixtures use, because there is no product endpoint that + * un-completes an account. + * + * Idempotent, and scoped to the one account it is given. + */ +import pg from "pg"; + +function connect(): pg.Pool { + const url = process.env.DATABASE_URL; + if (!url) { + throw new Error("[setup-flow-fixture] DATABASE_URL is not set"); + } + return new pg.Pool({ connectionString: url }); +} + +/** + * Put one account back before its first run: no answers, no completion, no + * module map, no dashboard seed, no rows the first-result step could have + * written, and a clean write bucket for the answers route. + */ +export async function resetSetupFlow(username: string): Promise { + const pool = connect(); + try { + const { rows } = await pool.query<{ id: string }>( + `SELECT id FROM users WHERE username = $1`, + [username], + ); + const id = rows[0]?.id; + if (!id) { + throw new Error(`[setup-flow-fixture] no account named ${username}`); + } + await pool.query(`DELETE FROM onboarding_records WHERE user_id = $1`, [id]); + await pool.query( + `UPDATE users + SET onboarding_completed_at = NULL, + module_preferences_json = NULL, + dashboard_widgets_json = NULL, + glucose_unit = NULL, + unit_preference = NULL + WHERE id = $1`, + [id], + ); + await pool.query(`DELETE FROM measurements WHERE user_id = $1`, [id]); + await pool.query(`DELETE FROM medications WHERE user_id = $1`, [id]); + await pool.query(`DELETE FROM encounters WHERE user_id = $1`, [id]); + await pool.query( + `DELETE FROM rate_limits + WHERE key IN ( + 'onboarding-answers:' || $1, + 'onboarding-restart:' || $1, + 'managed-profile:create:' || $1 + )`, + [id], + ); + } finally { + await pool.end(); + } +} + +/** + * Remove every managed profile this account looks after. A managed profile + * is a real account row, so a journey that created one and left it behind + * would find the guardian looking after two records on the next run. + */ +export async function clearManagedProfilesOf(username: string): Promise { + const pool = connect(); + try { + await pool.query( + `DELETE FROM users + WHERE managed_profile_at IS NOT NULL + AND id IN ( + SELECT g.grantor_id FROM account_grants g + JOIN users u ON u.id = g.grantee_id + WHERE u.username = $1 + )`, + [username], + ); + } finally { + await pool.end(); + } +} + +/** + * Make the account's step-up fresh, exactly as `managed-profile-fixture.ts` + * does for the guardian: creating a managed profile resolves + * `requireFreshMfa`, which needs a confirmed factor on the account and a + * session stamp newer than five minutes. Not a bypass — the route still runs + * its gate; this is the state in which the surface is usable at all. + */ +export async function stampFreshMfa(username: string): Promise { + const pool = connect(); + try { + const stamped = await pool.query( + `UPDATE sessions SET mfa_verified_at = NOW() + WHERE user_id = (SELECT id FROM users WHERE username = $1)`, + [username], + ); + if ((stamped.rowCount ?? 0) === 0) { + throw new Error(`[setup-flow-fixture] no session for ${username}`); + } + await pool.query( + `UPDATE users SET totp_confirmed_at = COALESCE(totp_confirmed_at, NOW()) + WHERE username = $1`, + [username], + ); + } finally { + await pool.end(); + } +} diff --git a/e2e/vaccinations.spec.ts b/e2e/vaccinations.spec.ts index e46fc2b95..fbf2f2199 100644 --- a/e2e/vaccinations.spec.ts +++ b/e2e/vaccinations.spec.ts @@ -14,7 +14,7 @@ * Vorsorge reminder that shows on `/checkups`; logging the next dose moves * its due date forward through the real satisfy matcher. * 4. Module off — turning the module off in settings drops the nav entry and - * the direct visit lands on the module-off redirect, not a crash. + * the direct visit lands on the module-off notice, not a crash. * 5. a11y — axe over the list and the capture form, serious/critical only. * * The flows mutate the one seeded account, so — like `visits.spec.ts` — this @@ -248,7 +248,7 @@ test.describe("vaccinations", () => { .toBeGreaterThan(dueBefore); }); - test("turning the module off drops the nav entry and the direct visit redirects", async ({ + test("turning the module off drops the nav entry and the direct visit shows the notice", async ({ page, }) => { await page.goto("/vaccinations"); @@ -271,10 +271,16 @@ test.describe("vaccinations", () => { 0, ); - // A direct visit no longer renders the surface: the page redirects home, - // so the hero never paints and the dashboard's own landmark does. + // A direct visit no longer renders the surface. It used to redirect + // home; since the setup flow switches modules off for a new record, a + // module page answers with the shared notice that names the switch and + // offers the Modules settings, so a bookmark or a report link explains + // itself instead of bouncing. The hero never paints. await page.goto("/vaccinations"); - await expect(page).toHaveURL(/\/$|\/dashboard/, { timeout: 15_000 }); + await expect(page).toHaveURL(/\/vaccinations$/, { timeout: 15_000 }); + await expect( + page.locator('[data-slot="module-off-open-settings"]'), + ).toBeVisible({ timeout: 15_000 }); await expect( page.locator('[data-tour-id="vaccinations-hero"]'), ).toHaveCount(0); diff --git a/messages/de.json b/messages/de.json index caa40fff9..bc23d2f08 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4487,51 +4487,8 @@ "back": "Zurück", "skip": "Überspringen", "next": "Weiter", - "step1of4": "Schritt 1 von 4", - "step2of4": "Schritt 2 von 4", - "step3of4": "Schritt 3 von 4", - "step4of4": "Schritt 4 von 4" - }, - "goals": { - "title": "Was möchtest du erfassen?", - "body": "Wähle die Bereiche, die dir wichtig sind — wir zeigen sie zuerst auf deinem Dashboard an, und sie helfen dir zu entscheiden, was du als Nächstes einrichtest.", - "helpHint": "Ein bis drei Schwerpunkte funktionieren erfahrungsgemäß am besten — mehr geht aber jederzeit.", - "options": { - "weightManagement": "Gewicht im Griff", - "bpTracking": "Blutdruck", - "glucoseTracking": "Blutzucker", - "sleepImprovement": "Besser schlafen", - "medicationCompliance": "Medikamente", - "generalWellness": "Allgemeine Form" - } - }, - "source": { - "title": "Quelle verbinden", - "body": "Werte automatisch übernehmen oder weiter manuell eintragen. Hier wird nichts für dich eingerichtet — jede Karte zeigt nur, wo du sie verbindest. Geräte-Quellen brauchen eine kostenlose Entwickler-App auf deinem eigenen Konto (etwa 5 Minuten); die Anleitung auf jeder Karte führt dich durch.", - "recommended": "Empfohlen", - "manual": { - "title": "Manuelle Eingabe", - "body": "Werte direkt erfassen, wann es dir passt. Funktioniert auf jedem Gerät, kein Konto bei Dritten nötig." - }, - "withings": { - "title": "Withings", - "body": "Waagen, Blutdruckmesser und Tracker von Withings synchronisieren automatisch, sobald verbunden.", - "cta": "Withings verbinden", - "setupCta": "Einrichten", - "setupHint": "Braucht zuerst deine eigene, kostenlose Withings-Entwickler-App — etwa 5 Minuten, einmalig." - }, - "more": { - "title": "Weitere Quellen", - "body": "Verbinde eine davon in den Einstellungen, wann immer du bereit bist — die meisten brauchen zuerst eine kostenlose Entwickler-App auf deinem eigenen Konto.", - "appleHealth": "Apple Health", - "whoop": "WHOOP", - "oura": "Oura", - "polar": "Polar", - "nightscout": "Nightscout", - "fitbit": "Fitbit", - "appleHealthHint": "Apple Health hat keine Web-Verbindung — schließe das Onboarding hier ab und richte Apple Health danach in der HealthLog-App auf deinem iPhone ein." - }, - "import": "Bestehende Daten importieren" + "stepOf": "Schritt {current} von {total}", + "stepsLabel": "Schritte der Einrichtung" }, "baseline": { "title": "Kurz zu dir", @@ -4547,8 +4504,7 @@ "genderFemale": "Weiblich", "genderOther": "Divers", "dateOfBirthLabel": "Geburtsdatum", - "dateOfBirthHint": "Wird ausschließlich für altersbezogene Auswertungen genutzt.", - "saveCta": "Speichern und weiter" + "dateOfBirthHint": "Wird ausschließlich für altersbezogene Auswertungen genutzt." }, "done": { "title": "Alles bereit", @@ -4562,34 +4518,12 @@ }, "welcomeBack": { "title": "Willkommen zurück", - "body": "Das Onboarding hast du bereits abgeschlossen. Möchtest du etwas anpassen, findest du alles in den Einstellungen.", "cta": "Zum Dashboard" }, "disclaimer": { "acknowledge": "Mir ist klar: HealthLog ist ein privates Tracking-Tool, keine klinische Bewertung und keine Diagnose. Bei Sorgen wende ich mich an meine Ärztin oder meinen Arzt, nicht an diese App.", "learnMore": "Mehr erfahren" }, - "welcome": { - "title": "Willkommen bei HealthLog", - "carouselLabel": "Begrüßungs-Karussell", - "slideOf": "Folie {current} von {total}", - "prevSlide": "Vorherige Folie", - "nextSlide": "Nächste Folie", - "gotoSlide": "Zu Folie {index} springen", - "cta": "Los geht's", - "slide1": { - "title": "Alles, was zählt", - "body": "Gewicht, Blutdruck, Glukose, Schlaf, Stimmung — alles in einem Verlauf, der dir gehört." - }, - "slide2": { - "title": "Verbinde deine Quellen", - "body": "Synchronisiere mit Apple Health und Withings oder trage Werte manuell ein. Deine Daten bleiben auf deinem Server." - }, - "slide3": { - "title": "Erkenne das Muster", - "body": "Tageszusammenfassungen, Trend-Diagramme und ein Coach, der zeigt was sich verändert hat — alles auf deinem Gerät." - } - }, "anamnesis": { "title": "Zu deiner Gesundheit (optional)", "subtitle": "Etwas Kontext, damit HealthLog deine Werte fairer einordnet.", @@ -4620,6 +4554,161 @@ "keylessLine": "HealthLog ist auch ohne KI vollständig nutzbar. Deine Kacheln, Diagramme, Trends und Exporte funktionieren alle — KI ergänzt nur den Klartext obendrauf.", "setupCta": "Einrichten (etwa eine Minute)", "sharedKeyNote": "Diese Instanz stellt bereits einen gemeinsamen KI-Anbieter bereit, deshalb funktionieren Insights für dich sofort — keine Einrichtung nötig." + }, + "steps": { + "who": "Für wen diese Akte ist", + "areas": "Worauf du achten willst", + "medication": "Medikamente", + "sources": "Woher die Werte kommen", + "visit": "Arztbesuch", + "units": "Einheiten", + "confirm": "Bestätigen", + "first-result": "Erstes Ergebnis", + "done": "Fertig" + }, + "flow": { + "welcome": { + "title": "Willkommen bei HealthLog", + "body": "Fünf kurze Fragen entscheiden, was HealthLog dir zuerst zeigt. Das dauert etwa zwei Minuten.", + "changeLater": "Nichts ist endgültig: Jede Wahl lässt sich später unter Einstellungen ändern.", + "setUp": "Einrichten", + "skip": "Vorerst überspringen", + "againBody": "Deine Einrichtung ist abgeschlossen. Beantworte die Fragen erneut, um zu ändern, was HealthLog zuerst zeigt, oder geh zum Dashboard.", + "setUpAgain": "Erneut einrichten" + }, + "who": { + "title": "Für wen ist diese Akte?", + "body": "Die folgenden Antworten gelten für diese Akte.", + "options": { + "me": "Für mich", + "someone-else": "Für jemanden, um den ich mich kümmere", + "both": "Beides" + }, + "hints": { + "someone-else": "Zum Beispiel ein Kind oder ein Elternteil. Das Profil wird am Ende angelegt.", + "both": "Richte jetzt deine eigene Akte ein; das Profil für die andere Person wird am Ende angeboten." + } + }, + "areas": { + "title": "Worauf möchtest du achten?", + "body": "Wähle beliebig viele. Sie entscheiden, welche Module eingeschaltet sind und was das Dashboard zuerst zeigt.", + "options": { + "blood-pressure": "Blutdruck", + "weight-body": "Gewicht und Körper", + "glucose": "Blutzucker", + "sleep": "Schlaf und Erholung", + "mood": "Stimmung und seelisches Wohlbefinden", + "cycle": "Zyklus", + "activity": "Aktivität und Training", + "labs": "Laborwerte", + "illness": "Krankheiten und Symptome" + } + }, + "medication": { + "title": "Nimmst du Medikamente nach einem festen Plan?", + "body": "Ein Plan schaltet das Medikamenten-Modul und Erinnerungen ein.", + "options": { + "yes": "Ja", + "no": "Nein", + "sometimes": "Manchmal" + } + }, + "sources": { + "title": "Woher kommen deine Werte heute?", + "body": "Wähle beliebig viele. Am Ende wird eine Verbindung angeboten; keine ist Pflicht.", + "options": { + "manual": "Ich tippe sie ein", + "apple-health": "Apple Health auf meinem iPhone", + "withings": "Withings", + "oura": "Oura", + "whoop": "WHOOP", + "polar": "Polar", + "fitbit": "Fitbit", + "strava": "Strava", + "nightscout": "Nightscout", + "file": "Eine Datei, die ich schon habe" + }, + "hints": { + "apple-health": "Wird von der iOS-App synchronisiert oder aus einem Export importiert.", + "file": "Wird unter Einstellungen importiert, sobald du hier fertig bist." + } + }, + "visit": { + "title": "Steht ein Arztbesuch an?", + "body": "Ein Besuch schaltet den Arztbericht ein und setzt die Vorbereitung auf deine Checkliste.", + "options": { + "within-a-month": "Ja, innerhalb eines Monats", + "later": "Später", + "no": "Nein" + } + }, + "units": { + "title": "Welche Einheiten verwendest du?", + "body": "Werte werden einmal gespeichert und in der Einheit angezeigt, die du wählst. Du kannst sie später unter Einstellungen ändern.", + "glucoseLegend": "Blutzucker", + "weightLegend": "Gewicht und Größe", + "options": { + "metric": "kg und cm", + "imperial": "lb und ft/in" + } + }, + "confirm": { + "title": "Das schalten deine Antworten ein", + "body": "Ein Blick, dann ist HealthLog eingerichtet. Nichts davon ist endgültig.", + "modulesTitle": "Module", + "switchedOn": "Eingeschaltet: {modules}.", + "nothingExtra": "Deine Antworten schalten kein zusätzliches Modul ein; die Kernwerte sind immer da.", + "alwaysOn": "Immer an: {modules} und die Kernwerte (Gewicht, Blutdruck, Puls).", + "everythingElse": "Alles andere bleibt einen Klick entfernt unter", + "modulesLink": "Einstellungen, Module", + "glucoseUnit": "Blutzucker", + "unitPreference": "Einheiten", + "language": "Sprache", + "timezone": "Zeitzone", + "changeInSettings": "Unter Einstellungen, Konto ändern", + "cta": "Bestätigen und weiter", + "managedTitle": "Die Person, um die du dich kümmerst", + "managedBody": "Ihre Akte hat keinen eigenen Login; du betreust sie aus deinem Konto. Zum Anlegen ist ein zweiter Faktor auf deinem Konto nötig.", + "managedCreate": "Profil anlegen und abschließen", + "managedLater": "Du kannst es auch später unter Einstellungen, Geteilter Zugriff anlegen.", + "finishWithoutProfile": "Ohne Profil abschließen" + }, + "first-result": { + "connect-source": { + "title": "{target} verbinden", + "body": "Eine Verbindung, aus deinen Antworten gewählt. Danach kommen die Werte von selbst.", + "howTo": "Die Verbindung wird auf der Integrationsseite hergestellt. Komm danach hierher zurück; dieser Bildschirm merkt, wenn sie steht.", + "cta": "{target} verbinden", + "checking": "Verbindung wird geprüft.", + "waiting": "Noch nicht verbunden.", + "done": "{target} ist verbunden.", + "syncing": "Die erste Synchronisierung läuft. Die Werte erscheinen auf dem Dashboard, sobald sie ankommen." + }, + "add-medication": { + "title": "Dein erstes Medikament hinzufügen", + "body": "Name, Dosis und Plan; die Erinnerung folgt dem Plan, den du festlegst.", + "howTo": "Das Medikament wird in wenigen kurzen Schritten angelegt. Weitere kannst du später auf der Medikamentenseite hinzufügen.", + "cta": "Medikament hinzufügen", + "done": "{name} ist angelegt.", + "doneUnnamed": "Dein erstes Medikament ist angelegt.", + "nextDose": "Nächste Dosis: {when}.", + "reminders": "Erinnerungen folgen dem Plan, den du festgelegt hast." + }, + "log-reading": { + "title": "Einen Wert eintragen: {target}", + "body": "Ein Wert bringt die erste Zahl auf dein Dashboard. Der Trend zeichnet sich ab dem fünften von selbst.", + "done": "Dein erster Wert ist auf seiner Kachel.", + "doneElsewhere": "Dein erster Eintrag für {target} ist erfasst.", + "elsewhere": "{target} hat eine eigene Seite. Öffne sie, mach den ersten Eintrag und komm dann zurück, um ihn als erledigt zu markieren.", + "open": "{target} öffnen", + "markDone": "Ich habe den Eintrag gemacht" + } + }, + "done": { + "tourCta": "Kurze Tour machen", + "openRecord": "Akte von {name} öffnen", + "createProfile": "Profil für die Person anlegen, um die du dich kümmerst" + } } }, "gettingStarted": { @@ -4646,7 +4735,14 @@ "notificationsCta": "Benachrichtigungen öffnen", "insightsTitle": "Insights aktivieren (optional)", "insightsDescription": "Füge ein lokales Modell oder deinen eigenen KI-Schlüssel hinzu, um das tägliche Briefing und Coach freizuschalten.", - "insightsCta": "Insights aktivieren" + "insightsCta": "Insights aktivieren", + "visitTitle": "Auf den Arztbesuch vorbereiten", + "visitDescription": "Trag den Termin in den Kalender ein; der Arztbericht bündelt, was du mitbringst.", + "visitCta": "Besuche öffnen", + "dataSourceNamedTitle": "{source} verbinden", + "managedProfileTitle": "Profil der betreuten Person anlegen", + "managedProfileDescription": "Deine Antworten galten der Akte einer anderen Person; sie wird unter Geteilter Zugriff angelegt.", + "managedProfileCta": "Profil anlegen" } }, "trendHints": { @@ -6460,7 +6556,15 @@ }, "dangerZoneDetail": "Dein Konto und deine Anmeldedaten bleiben erhalten.", "deleteAccountCardDetail": "Dieser Vorgang kann nicht rückgängig gemacht werden.", - "passkeyStepUpRequired": "Aus Sicherheitsgründen bitte erneut anmelden, bevor du einen Passkey entfernst." + "passkeyStepUpRequired": "Aus Sicherheitsgründen bitte erneut anmelden, bevor du einen Passkey entfernst.", + "setup": { + "title": "Einrichtung", + "description": "Die Fragen, die entscheiden, was HealthLog zuerst zeigt, und die Erste-Schritte-Checkliste auf dem Dashboard.", + "setUpAgain": "Erneut einrichten", + "showChecklist": "Checkliste wieder anzeigen", + "checklistShown": "Die Checkliste ist wieder auf dem Dashboard.", + "checklistNothingHidden": "Die Checkliste war nicht ausgeblendet; sie öffnet sich auf dem Dashboard." + } }, "admin": { "title": "Administration", @@ -8732,7 +8836,8 @@ "unavailable": { "description": "Der Betreiber hat {module} für alle Konten auf diesem Server ausgeschaltet. Keine eigene Einstellung holt es zurück.", "title": "Auf diesem Server nicht verfügbar" - } + }, + "openModules": "Einstellungen, Module öffnen" }, "vaccinations": { "title": "Impfungen", diff --git a/messages/en.json b/messages/en.json index d58ec0a2c..dc66aa61b 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4487,51 +4487,8 @@ "back": "Back", "skip": "Skip", "next": "Continue", - "step1of4": "Step 1 of 4", - "step2of4": "Step 2 of 4", - "step3of4": "Step 3 of 4", - "step4of4": "Step 4 of 4" - }, - "goals": { - "title": "What would you like to track?", - "body": "Pick the areas you care about — we'll surface them first on your dashboard, and they help you decide what to set up next.", - "helpHint": "One to three priorities tend to work best — you can always add more later.", - "options": { - "weightManagement": "Weight management", - "bpTracking": "Blood pressure", - "glucoseTracking": "Glucose", - "sleepImprovement": "Better sleep", - "medicationCompliance": "Medications", - "generalWellness": "General wellness" - } - }, - "source": { - "title": "Connect a source", - "body": "Bring in measurements automatically, or stay on manual entry. Nothing here is configured for you — each card just shows where to connect it. Device sources need a free developer app registered on your own account (about 5 minutes); the setup guide on each card walks through it.", - "recommended": "Recommended", - "manual": { - "title": "Manual entry", - "body": "Log readings yourself, whenever it fits. Works on any device, no third-party account required." - }, - "withings": { - "title": "Withings", - "body": "Scales, blood-pressure cuffs and trackers from Withings sync automatically once connected.", - "cta": "Connect Withings", - "setupCta": "Set up", - "setupHint": "Needs your own free Withings developer app first — about 5 minutes, one time." - }, - "more": { - "title": "More sources", - "body": "Connect any of these in Settings whenever you're ready — most need a free developer app registered on your own account first.", - "appleHealth": "Apple Health", - "whoop": "WHOOP", - "oura": "Oura", - "polar": "Polar", - "nightscout": "Nightscout", - "fitbit": "Fitbit", - "appleHealthHint": "Apple Health has no web connection — finish onboarding here, then open the HealthLog app on your iPhone to complete Apple Health setup." - }, - "import": "Import existing history" + "stepOf": "Step {current} of {total}", + "stepsLabel": "Setup steps" }, "baseline": { "title": "A bit about you", @@ -4547,8 +4504,7 @@ "genderFemale": "Female", "genderOther": "Other", "dateOfBirthLabel": "Date of birth", - "dateOfBirthHint": "Used only for age-aware analytics.", - "saveCta": "Save and continue" + "dateOfBirthHint": "Used only for age-aware analytics." }, "done": { "title": "You're all set", @@ -4562,34 +4518,12 @@ }, "welcomeBack": { "title": "Welcome back", - "body": "You've already finished onboarding. To adjust anything, head to Settings — everything you set up is editable from there.", "cta": "Open dashboard" }, "disclaimer": { "acknowledge": "I understand: HealthLog is a private tracking tool, not a clinical assessment or diagnosis. If I'm worried I'll talk to my doctor, not this app.", "learnMore": "Learn more" }, - "welcome": { - "title": "Welcome to HealthLog", - "carouselLabel": "Welcome carousel", - "slideOf": "Slide {current} of {total}", - "prevSlide": "Previous slide", - "nextSlide": "Next slide", - "gotoSlide": "Go to slide {index}", - "cta": "Get started", - "slide1": { - "title": "Track every signal", - "body": "Weight, blood pressure, glucose, sleep, mood — keep your full picture in one log you own." - }, - "slide2": { - "title": "Connect your sources", - "body": "Sync with Apple Health and Withings, or add readings by hand. Your data stays on your server." - }, - "slide3": { - "title": "See the pattern", - "body": "Daily summaries, trend charts, and a coach that surfaces what changed — all on your own device." - } - }, "anamnesis": { "title": "About your health (optional)", "subtitle": "A little context so HealthLog reads your numbers more fairly.", @@ -4620,6 +4554,161 @@ "keylessLine": "HealthLog is fully useful without AI. Your tiles, charts, trends and exports all work — AI only adds the plain-language read on top.", "setupCta": "Set it up (about a minute)", "sharedKeyNote": "This instance already provides a shared AI provider, so insights work for you right now — no setup needed." + }, + "steps": { + "who": "Who this record is for", + "areas": "What to keep an eye on", + "medication": "Medication", + "sources": "Where readings come from", + "visit": "Doctor's visit", + "units": "Units", + "confirm": "Confirm", + "first-result": "First result", + "done": "Done" + }, + "flow": { + "welcome": { + "title": "Welcome to HealthLog", + "body": "Five short questions decide what HealthLog shows you first. It takes about two minutes.", + "changeLater": "Nothing is final: every choice can be changed later under Settings.", + "setUp": "Set up", + "skip": "Skip for now", + "againBody": "Your setup is finished. Answer the questions again to change what HealthLog shows first, or go to the dashboard.", + "setUpAgain": "Set up again" + }, + "who": { + "title": "Who is this record for?", + "body": "The answers that follow apply to this record.", + "options": { + "me": "Me", + "someone-else": "Someone I look after", + "both": "Both" + }, + "hints": { + "someone-else": "A child or a parent, for example. Their profile is created at the end.", + "both": "Set up your own record now; the profile for the other person is offered at the end." + } + }, + "areas": { + "title": "What do you want to keep an eye on?", + "body": "Pick any number. These decide which modules are switched on and what the dashboard shows first.", + "options": { + "blood-pressure": "Blood pressure", + "weight-body": "Weight and body", + "glucose": "Blood glucose", + "sleep": "Sleep and recovery", + "mood": "Mood and mental wellbeing", + "cycle": "Cycle", + "activity": "Activity and workouts", + "labs": "Lab results", + "illness": "Illnesses and symptoms" + } + }, + "medication": { + "title": "Do you take medication on a schedule?", + "body": "A schedule switches on the medication module and reminders.", + "options": { + "yes": "Yes", + "no": "No", + "sometimes": "Sometimes" + } + }, + "sources": { + "title": "Where do your readings come from today?", + "body": "Pick any number. One connection is offered at the end; none is required.", + "options": { + "manual": "I type them in", + "apple-health": "Apple Health on my iPhone", + "withings": "Withings", + "oura": "Oura", + "whoop": "WHOOP", + "polar": "Polar", + "fitbit": "Fitbit", + "strava": "Strava", + "nightscout": "Nightscout", + "file": "A file I already have" + }, + "hints": { + "apple-health": "Synced by the iOS app, or imported from an export.", + "file": "Imported under Settings once you are done here." + } + }, + "visit": { + "title": "Is there a doctor's visit coming up?", + "body": "A visit switches on the doctor report and adds preparing for it to your checklist.", + "options": { + "within-a-month": "Yes, within a month", + "later": "Later", + "no": "No" + } + }, + "units": { + "title": "Which units do you use?", + "body": "Readings are stored once and shown in the unit you choose. You can change it later under Settings.", + "glucoseLegend": "Blood glucose", + "weightLegend": "Weight and height", + "options": { + "metric": "kg and cm", + "imperial": "lb and ft/in" + } + }, + "confirm": { + "title": "Here is what your answers switch on", + "body": "One look, then HealthLog is set up. Nothing here is final.", + "modulesTitle": "Modules", + "switchedOn": "Switched on: {modules}.", + "nothingExtra": "Your answers switch on no extra module; the core readings are always there.", + "alwaysOn": "Always on: {modules}, and the core readings (weight, blood pressure, pulse).", + "everythingElse": "Everything else stays one click away under", + "modulesLink": "Settings, Modules", + "glucoseUnit": "Blood glucose", + "unitPreference": "Units", + "language": "Language", + "timezone": "Time zone", + "changeInSettings": "Change these under Settings, Account", + "cta": "Confirm and continue", + "managedTitle": "The person you look after", + "managedBody": "Their record has no login of its own; you look after it from your account. A second factor on your account is required to create it.", + "managedCreate": "Create the profile and finish", + "managedLater": "You can also create it later under Settings, Shared access.", + "finishWithoutProfile": "Finish without the profile" + }, + "first-result": { + "connect-source": { + "title": "Connect {target}", + "body": "One connection, chosen from your answers. Readings then arrive on their own.", + "howTo": "The connection is made on the integrations page. Come back here afterwards; this screen notices when it is done.", + "cta": "Connect {target}", + "checking": "Checking the connection.", + "waiting": "Not connected yet.", + "done": "{target} is connected.", + "syncing": "The first sync is in progress. Readings appear on the dashboard as they arrive." + }, + "add-medication": { + "title": "Add your first medication", + "body": "Name, dose and schedule; the reminder follows the schedule you set.", + "howTo": "The medication is added in a few short steps. You can add more later on the medications page.", + "cta": "Add a medication", + "done": "{name} is added.", + "doneUnnamed": "Your first medication is added.", + "nextDose": "Next dose: {when}.", + "reminders": "Reminders follow the schedule you set." + }, + "log-reading": { + "title": "Enter one reading: {target}", + "body": "One reading puts the first value on your dashboard. The trend draws itself from the fifth.", + "done": "Your first reading is on its tile.", + "doneElsewhere": "Your first entry for {target} is recorded.", + "elsewhere": "{target} has its own page. Open it, make the first entry, then come back and mark it done.", + "open": "Open {target}", + "markDone": "I have made the entry" + } + }, + "done": { + "tourCta": "Take a short tour", + "openRecord": "Open the record of {name}", + "createProfile": "Create the profile for the person you look after" + } } }, "gettingStarted": { @@ -4646,7 +4735,14 @@ "notificationsCta": "Open notifications", "insightsTitle": "Turn on insights (optional)", "insightsDescription": "Add a local model or your own AI key to unlock the daily briefing and Coach.", - "insightsCta": "Turn on insights" + "insightsCta": "Turn on insights", + "visitTitle": "Prepare for the visit", + "visitDescription": "Put the appointment on the calendar; the doctor report bundles what to bring.", + "visitCta": "Open visits", + "dataSourceNamedTitle": "Connect {source}", + "managedProfileTitle": "Create the profile you look after", + "managedProfileDescription": "Your answers were given for another person's record; it is created under Shared access.", + "managedProfileCta": "Create the profile" } }, "trendHints": { @@ -6460,7 +6556,15 @@ }, "dangerZoneDetail": "Your account and the credentials you sign in with are kept.", "deleteAccountCardDetail": "This cannot be undone.", - "passkeyStepUpRequired": "For your security, sign in again before removing a passkey." + "passkeyStepUpRequired": "For your security, sign in again before removing a passkey.", + "setup": { + "title": "Setup", + "description": "The questions that decide what HealthLog shows first, and the getting-started checklist on the dashboard.", + "setUpAgain": "Set up again", + "showChecklist": "Show the checklist again", + "checklistShown": "The checklist is back on the dashboard.", + "checklistNothingHidden": "The checklist was not hidden; it opens on the dashboard." + } }, "admin": { "title": "Administration", @@ -8732,7 +8836,8 @@ "unavailable": { "description": "The operator switched {module} off for every account on this server. No setting of your own can bring it back.", "title": "Not available on this server" - } + }, + "openModules": "Open Settings, Modules" }, "vaccinations": { "title": "Vaccinations", diff --git a/messages/es.json b/messages/es.json index 4a8802a5f..e5fddd66f 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4487,51 +4487,8 @@ "back": "Atrás", "skip": "Omitir", "next": "Continuar", - "step1of4": "Paso 1 de 4", - "step2of4": "Paso 2 de 4", - "step3of4": "Paso 3 de 4", - "step4of4": "Paso 4 de 4" - }, - "goals": { - "title": "¿Qué te gustaría registrar?", - "body": "Elige las áreas que te interesan: las mostraremos primero en tu panel y te ayudarán a decidir qué configurar a continuación.", - "helpHint": "Una a tres prioridades suelen funcionar mejor — siempre podrás añadir más más adelante.", - "options": { - "weightManagement": "Control del peso", - "bpTracking": "Presión arterial", - "glucoseTracking": "Glucosa", - "sleepImprovement": "Mejor sueño", - "medicationCompliance": "Medicamentos", - "generalWellness": "Bienestar general" - } - }, - "source": { - "title": "Conectar una fuente", - "body": "Importa las mediciones automáticamente o sigue con la entrada manual. Aquí no se configura nada por ti: cada tarjeta solo muestra dónde conectarla. Las fuentes de dispositivo necesitan una app de desarrollador gratuita en tu propia cuenta (unos 5 minutos); la guía de cada tarjeta te lo explica.", - "recommended": "Recomendado", - "manual": { - "title": "Entrada manual", - "body": "Registra tus mediciones tú mismo cuando te venga bien. Funciona en cualquier dispositivo, sin cuenta de terceros." - }, - "withings": { - "title": "Withings", - "body": "Las básculas, los tensiómetros y los trackers de Withings se sincronizan automáticamente una vez conectados.", - "cta": "Conectar Withings", - "setupCta": "Configurar", - "setupHint": "Necesita primero tu propia app de desarrollador de Withings, gratuita — unos 5 minutos, una sola vez." - }, - "more": { - "title": "Más fuentes", - "body": "Conecta cualquiera de ellas en Ajustes cuando quieras — la mayoría necesita antes una app de desarrollador gratuita en tu propia cuenta.", - "appleHealth": "Apple Health", - "whoop": "WHOOP", - "oura": "Oura", - "polar": "Polar", - "nightscout": "Nightscout", - "fitbit": "Fitbit", - "appleHealthHint": "Apple Health no tiene conexión web — termina aquí la puesta en marcha y luego abre la app HealthLog en tu iPhone para completar la configuración de Apple Health." - }, - "import": "Importar historial existente" + "stepOf": "Paso {current} de {total}", + "stepsLabel": "Pasos de la configuración" }, "baseline": { "title": "Cuéntanos sobre ti", @@ -4547,8 +4504,7 @@ "genderFemale": "Mujer", "genderOther": "Otro", "dateOfBirthLabel": "Fecha de nacimiento", - "dateOfBirthHint": "Se utiliza solo para análisis sensibles a la edad.", - "saveCta": "Guardar y continuar" + "dateOfBirthHint": "Se utiliza solo para análisis sensibles a la edad." }, "done": { "title": "Todo listo", @@ -4562,34 +4518,12 @@ }, "welcomeBack": { "title": "Bienvenido de nuevo", - "body": "Ya completaste la configuración inicial. Para cambiar algo, abre Ajustes — todo lo que configuraste se puede editar desde ahí.", "cta": "Abrir el panel" }, "disclaimer": { "acknowledge": "Lo entiendo: HealthLog es una herramienta privada de seguimiento, no una valoración clínica ni un diagnóstico. Si me preocupa algo, hablaré con mi médico, no con esta app.", "learnMore": "Más información" }, - "welcome": { - "title": "Bienvenido a HealthLog", - "carouselLabel": "Carrusel de bienvenida", - "slideOf": "Diapositiva {current} de {total}", - "prevSlide": "Diapositiva anterior", - "nextSlide": "Diapositiva siguiente", - "gotoSlide": "Ir a la diapositiva {index}", - "cta": "Empezar", - "slide1": { - "title": "Registra cada señal", - "body": "Peso, presión, glucosa, sueño, ánimo — todo en un único registro que es tuyo." - }, - "slide2": { - "title": "Conecta tus fuentes", - "body": "Sincroniza con Apple Health y Withings o añade datos a mano. Tus datos se quedan en tu servidor." - }, - "slide3": { - "title": "Descubre los patrones", - "body": "Resúmenes diarios, gráficas de tendencias y un coach que destaca lo que cambió — todo en tu propio dispositivo." - } - }, "anamnesis": { "title": "Sobre tu salud (opcional)", "subtitle": "Un poco de contexto para que HealthLog interprete tus valores de forma más justa.", @@ -4620,6 +4554,161 @@ "keylessLine": "HealthLog es plenamente útil sin IA. Tus paneles, gráficos, tendencias y exportaciones funcionan — la IA solo añade la lectura en lenguaje claro por encima.", "setupCta": "Configúralo (un minuto aprox.)", "sharedKeyNote": "Esta instancia ya ofrece un proveedor de IA compartido, así que los insights funcionan para ti ahora mismo — sin configuración." + }, + "steps": { + "who": "Para quién es este registro", + "areas": "Qué quieres vigilar", + "medication": "Medicación", + "sources": "De dónde vienen las mediciones", + "visit": "Visita médica", + "units": "Unidades", + "confirm": "Confirmar", + "first-result": "Primer resultado", + "done": "Listo" + }, + "flow": { + "welcome": { + "title": "Bienvenido a HealthLog", + "body": "Cinco preguntas breves deciden qué te muestra HealthLog primero. Lleva unos dos minutos.", + "changeLater": "Nada es definitivo: cada elección se puede cambiar después en Ajustes.", + "setUp": "Configurar", + "skip": "Omitir por ahora", + "againBody": "Tu configuración está terminada. Responde de nuevo las preguntas para cambiar lo que HealthLog muestra primero, o ve al panel.", + "setUpAgain": "Configurar de nuevo" + }, + "who": { + "title": "¿Para quién es este registro?", + "body": "Las respuestas siguientes se aplican a este registro.", + "options": { + "me": "Para mí", + "someone-else": "Para alguien a mi cargo", + "both": "Ambos" + }, + "hints": { + "someone-else": "Un hijo o un padre, por ejemplo. Su perfil se crea al final.", + "both": "Configura ahora tu propio registro; el perfil de la otra persona se ofrece al final." + } + }, + "areas": { + "title": "¿Qué quieres vigilar?", + "body": "Elige las que quieras. Deciden qué módulos se activan y qué muestra primero el panel.", + "options": { + "blood-pressure": "Presión arterial", + "weight-body": "Peso y cuerpo", + "glucose": "Glucosa en sangre", + "sleep": "Sueño y recuperación", + "mood": "Ánimo y bienestar mental", + "cycle": "Ciclo", + "activity": "Actividad y entrenamientos", + "labs": "Resultados de laboratorio", + "illness": "Enfermedades y síntomas" + } + }, + "medication": { + "title": "¿Tomas medicación con un horario fijo?", + "body": "Un horario activa el módulo de medicación y los recordatorios.", + "options": { + "yes": "Sí", + "no": "No", + "sometimes": "A veces" + } + }, + "sources": { + "title": "¿De dónde vienen hoy tus mediciones?", + "body": "Elige las que quieras. Al final se ofrece una conexión; ninguna es obligatoria.", + "options": { + "manual": "Las escribo a mano", + "apple-health": "Apple Health en mi iPhone", + "withings": "Withings", + "oura": "Oura", + "whoop": "WHOOP", + "polar": "Polar", + "fitbit": "Fitbit", + "strava": "Strava", + "nightscout": "Nightscout", + "file": "Un archivo que ya tengo" + }, + "hints": { + "apple-health": "Sincronizado por la app de iOS o importado desde una exportación.", + "file": "Se importa en Ajustes cuando termines aquí." + } + }, + "visit": { + "title": "¿Tienes una visita médica próximamente?", + "body": "Una visita activa el informe médico y añade su preparación a tu lista de tareas.", + "options": { + "within-a-month": "Sí, en menos de un mes", + "later": "Más adelante", + "no": "No" + } + }, + "units": { + "title": "¿Qué unidades usas?", + "body": "Las mediciones se guardan una vez y se muestran en la unidad que elijas. Puedes cambiarla después en Ajustes.", + "glucoseLegend": "Glucosa en sangre", + "weightLegend": "Peso y altura", + "options": { + "metric": "kg y cm", + "imperial": "lb y ft/in" + } + }, + "confirm": { + "title": "Esto es lo que activan tus respuestas", + "body": "Un vistazo y HealthLog queda configurado. Nada de esto es definitivo.", + "modulesTitle": "Módulos", + "switchedOn": "Activados: {modules}.", + "nothingExtra": "Tus respuestas no activan ningún módulo adicional; las mediciones básicas siempre están.", + "alwaysOn": "Siempre activos: {modules} y las mediciones básicas (peso, presión arterial, pulso).", + "everythingElse": "Todo lo demás queda a un clic en", + "modulesLink": "Ajustes, Módulos", + "glucoseUnit": "Glucosa en sangre", + "unitPreference": "Unidades", + "language": "Idioma", + "timezone": "Zona horaria", + "changeInSettings": "Cámbialo en Ajustes, Cuenta", + "cta": "Confirmar y continuar", + "managedTitle": "La persona a tu cargo", + "managedBody": "Su registro no tiene inicio de sesión propio; lo gestionas desde tu cuenta. Para crearlo hace falta un segundo factor en tu cuenta.", + "managedCreate": "Crear el perfil y terminar", + "managedLater": "También puedes crearlo después en Ajustes, Acceso compartido.", + "finishWithoutProfile": "Terminar sin el perfil" + }, + "first-result": { + "connect-source": { + "title": "Conectar {target}", + "body": "Una conexión, elegida según tus respuestas. Después las mediciones llegan solas.", + "howTo": "La conexión se hace en la página de integraciones. Vuelve aquí después; esta pantalla detecta cuándo está lista.", + "cta": "Conectar {target}", + "checking": "Comprobando la conexión.", + "waiting": "Aún no conectado.", + "done": "{target} está conectado.", + "syncing": "La primera sincronización está en marcha. Las mediciones aparecen en el panel a medida que llegan." + }, + "add-medication": { + "title": "Añade tu primer medicamento", + "body": "Nombre, dosis y horario; el recordatorio sigue el horario que fijes.", + "howTo": "El medicamento se añade en unos pocos pasos. Puedes añadir más después en la página de medicación.", + "cta": "Añadir un medicamento", + "done": "{name} está añadido.", + "doneUnnamed": "Tu primer medicamento está añadido.", + "nextDose": "Próxima dosis: {when}.", + "reminders": "Los recordatorios siguen el horario que fijaste." + }, + "log-reading": { + "title": "Registra una medición: {target}", + "body": "Una medición pone el primer valor en tu panel. La tendencia se dibuja sola a partir de la quinta.", + "done": "Tu primera medición está en su tarjeta.", + "doneElsewhere": "Tu primera entrada de {target} está registrada.", + "elsewhere": "{target} tiene su propia página. Ábrela, haz la primera entrada y vuelve aquí para marcarla como hecha.", + "open": "Abrir {target}", + "markDone": "Ya hice la entrada" + } + }, + "done": { + "tourCta": "Hacer un recorrido breve", + "openRecord": "Abrir el registro de {name}", + "createProfile": "Crear el perfil de la persona a tu cargo" + } } }, "gettingStarted": { @@ -4646,7 +4735,14 @@ "notificationsCta": "Abrir notificaciones", "insightsTitle": "Activar insights (opcional)", "insightsDescription": "Añade un modelo local o tu propia clave de IA para desbloquear el resumen diario y Coach.", - "insightsCta": "Activar insights" + "insightsCta": "Activar insights", + "visitTitle": "Prepararse para la visita", + "visitDescription": "Anota la cita en el calendario; el informe médico reúne lo que debes llevar.", + "visitCta": "Abrir visitas", + "dataSourceNamedTitle": "Conectar {source}", + "managedProfileTitle": "Crear el perfil de la persona a tu cargo", + "managedProfileDescription": "Tus respuestas eran para el registro de otra persona; se crea en Acceso compartido.", + "managedProfileCta": "Crear el perfil" } }, "trendHints": { @@ -6460,7 +6556,15 @@ }, "dangerZoneDetail": "Tu cuenta y las credenciales con las que inicias sesión se conservan.", "deleteAccountCardDetail": "Esto no se puede deshacer.", - "passkeyStepUpRequired": "Por seguridad, vuelve a iniciar sesión antes de quitar una clave de acceso." + "passkeyStepUpRequired": "Por seguridad, vuelve a iniciar sesión antes de quitar una clave de acceso.", + "setup": { + "title": "Configuración inicial", + "description": "Las preguntas que deciden qué muestra HealthLog primero, y la lista de primeros pasos del panel.", + "setUpAgain": "Configurar de nuevo", + "showChecklist": "Mostrar la lista de nuevo", + "checklistShown": "La lista vuelve a estar en el panel.", + "checklistNothingHidden": "La lista no estaba oculta; se abre en el panel." + } }, "admin": { "title": "Administration", @@ -8732,7 +8836,8 @@ "unavailable": { "description": "El operador ha desactivado {module} para todas las cuentas de este servidor. Ningún ajuste tuyo puede recuperarlo.", "title": "No disponible en este servidor" - } + }, + "openModules": "Abrir Ajustes, Módulos" }, "vaccinations": { "title": "Vacunas", diff --git a/messages/fr.json b/messages/fr.json index b30e60b9a..1640aa7e9 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4487,51 +4487,8 @@ "back": "Retour", "skip": "Ignorer", "next": "Continuer", - "step1of4": "Étape 1 sur 4", - "step2of4": "Étape 2 sur 4", - "step3of4": "Étape 3 sur 4", - "step4of4": "Étape 4 sur 4" - }, - "goals": { - "title": "Que souhaitez-vous suivre ?", - "body": "Sélectionnez les domaines qui comptent pour vous : nous les afficherons en premier sur votre tableau de bord, et ils vous aident à décider quoi configurer ensuite.", - "helpHint": "Un à trois axes prioritaires fonctionnent généralement le mieux, mais vous pourrez en ajouter plus tard.", - "options": { - "weightManagement": "Gestion du poids", - "bpTracking": "Tension artérielle", - "glucoseTracking": "Glycémie", - "sleepImprovement": "Mieux dormir", - "medicationCompliance": "Médicaments", - "generalWellness": "Bien-être général" - } - }, - "source": { - "title": "Connecter une source", - "body": "Importez les mesures automatiquement ou conservez la saisie manuelle. Rien n'est configuré à votre place : chaque carte indique simplement où la connecter. Les sources d'appareil nécessitent une application développeur gratuite sur votre propre compte (environ 5 minutes) ; le guide de chaque carte vous accompagne.", - "recommended": "Recommandé", - "manual": { - "title": "Saisie manuelle", - "body": "Enregistrez vos mesures vous-même, quand cela vous convient. Fonctionne sur tout appareil, aucun compte tiers requis." - }, - "withings": { - "title": "Withings", - "body": "Balances, tensiomètres et trackers Withings se synchronisent automatiquement une fois connectés.", - "cta": "Connecter Withings", - "setupCta": "Configurer", - "setupHint": "Nécessite d'abord votre propre application développeur Withings, gratuite — environ 5 minutes, une seule fois." - }, - "more": { - "title": "Autres sources", - "body": "Connectez l'une d'elles dans les Réglages quand vous le souhaitez — la plupart nécessitent d'abord une application développeur gratuite sur votre propre compte.", - "appleHealth": "Apple Health", - "whoop": "WHOOP", - "oura": "Oura", - "polar": "Polar", - "nightscout": "Nightscout", - "fitbit": "Fitbit", - "appleHealthHint": "Apple Health n'a pas de connexion web — terminez d'abord la configuration ici, puis ouvrez l'app HealthLog sur votre iPhone pour finaliser la configuration d'Apple Health." - }, - "import": "Importer un historique existant" + "stepOf": "Étape {current} sur {total}", + "stepsLabel": "Étapes de la configuration" }, "baseline": { "title": "Quelques informations", @@ -4547,8 +4504,7 @@ "genderFemale": "Femme", "genderOther": "Autre", "dateOfBirthLabel": "Date de naissance", - "dateOfBirthHint": "Utilisée uniquement pour les analyses qui tiennent compte de l'âge.", - "saveCta": "Enregistrer et continuer" + "dateOfBirthHint": "Utilisée uniquement pour les analyses qui tiennent compte de l'âge." }, "done": { "title": "Tout est prêt", @@ -4562,34 +4518,12 @@ }, "welcomeBack": { "title": "Bon retour", - "body": "Vous avez déjà terminé la configuration. Pour modifier un élément, ouvrez les paramètres — tout reste éditable.", "cta": "Ouvrir le tableau de bord" }, "disclaimer": { "acknowledge": "Je comprends : HealthLog est un outil de suivi privé, pas une évaluation clinique ni un diagnostic. En cas d'inquiétude, je consulte mon médecin, pas cette application.", "learnMore": "En savoir plus" }, - "welcome": { - "title": "Bienvenue dans HealthLog", - "carouselLabel": "Carrousel de bienvenue", - "slideOf": "Diapositive {current} sur {total}", - "prevSlide": "Diapositive précédente", - "nextSlide": "Diapositive suivante", - "gotoSlide": "Aller à la diapositive {index}", - "cta": "Commencer", - "slide1": { - "title": "Notez chaque signal", - "body": "Poids, tension, glycémie, sommeil, humeur — un seul journal qui vous appartient." - }, - "slide2": { - "title": "Connectez vos sources", - "body": "Synchronisez avec Apple Health et Withings, ou ajoutez vos mesures à la main. Vos données restent sur votre serveur." - }, - "slide3": { - "title": "Repérez les tendances", - "body": "Résumés quotidiens, graphiques de tendances et un coach qui met en avant ce qui a changé — le tout sur votre appareil." - } - }, "anamnesis": { "title": "À propos de votre santé (facultatif)", "subtitle": "Un peu de contexte pour que HealthLog interprète vos valeurs plus justement.", @@ -4620,6 +4554,161 @@ "keylessLine": "HealthLog est pleinement utile sans IA. Vos tuiles, graphiques, tendances et exports fonctionnent tous — l'IA n'ajoute que la lecture en langage clair par-dessus.", "setupCta": "Configurez-le (environ une minute)", "sharedKeyNote": "Cette instance fournit déjà un fournisseur d'IA partagé : les insights fonctionnent pour vous dès maintenant — aucune configuration nécessaire." + }, + "steps": { + "who": "À qui appartient ce dossier", + "areas": "Ce que vous voulez suivre", + "medication": "Médicaments", + "sources": "D'où viennent les mesures", + "visit": "Visite médicale", + "units": "Unités", + "confirm": "Confirmer", + "first-result": "Premier résultat", + "done": "Terminé" + }, + "flow": { + "welcome": { + "title": "Bienvenue dans HealthLog", + "body": "Cinq questions rapides décident de ce que HealthLog vous montre en premier. Cela prend environ deux minutes.", + "changeLater": "Rien n'est définitif : chaque choix peut être modifié plus tard dans les réglages.", + "setUp": "Configurer", + "skip": "Passer pour l'instant", + "againBody": "Votre configuration est terminée. Répondez à nouveau aux questions pour changer ce que HealthLog montre en premier, ou allez au tableau de bord.", + "setUpAgain": "Reconfigurer" + }, + "who": { + "title": "À qui appartient ce dossier ?", + "body": "Les réponses qui suivent s'appliquent à ce dossier.", + "options": { + "me": "À moi", + "someone-else": "À une personne dont je m'occupe", + "both": "Les deux" + }, + "hints": { + "someone-else": "Un enfant ou un parent, par exemple. Son profil est créé à la fin.", + "both": "Configurez d'abord votre propre dossier ; le profil de l'autre personne vous est proposé à la fin." + } + }, + "areas": { + "title": "Que voulez-vous suivre ?", + "body": "Choisissez autant d'options que vous voulez. Elles décident des modules activés et de ce que le tableau de bord montre en premier.", + "options": { + "blood-pressure": "Tension artérielle", + "weight-body": "Poids et corps", + "glucose": "Glycémie", + "sleep": "Sommeil et récupération", + "mood": "Humeur et bien-être mental", + "cycle": "Cycle", + "activity": "Activité et entraînements", + "labs": "Résultats d'analyses", + "illness": "Maladies et symptômes" + } + }, + "medication": { + "title": "Prenez-vous des médicaments selon un horaire fixe ?", + "body": "Un horaire active le module médicaments et les rappels.", + "options": { + "yes": "Oui", + "no": "Non", + "sometimes": "Parfois" + } + }, + "sources": { + "title": "D'où viennent vos mesures aujourd'hui ?", + "body": "Choisissez autant d'options que vous voulez. Une connexion est proposée à la fin ; aucune n'est obligatoire.", + "options": { + "manual": "Je les saisis moi-même", + "apple-health": "Apple Santé sur mon iPhone", + "withings": "Withings", + "oura": "Oura", + "whoop": "WHOOP", + "polar": "Polar", + "fitbit": "Fitbit", + "strava": "Strava", + "nightscout": "Nightscout", + "file": "Un fichier que j'ai déjà" + }, + "hints": { + "apple-health": "Synchronisé par l'app iOS, ou importé depuis un export.", + "file": "À importer dans les réglages une fois cette étape terminée." + } + }, + "visit": { + "title": "Une visite médicale est-elle prévue ?", + "body": "Une visite active le rapport médical et ajoute sa préparation à votre liste de départ.", + "options": { + "within-a-month": "Oui, d'ici un mois", + "later": "Plus tard", + "no": "Non" + } + }, + "units": { + "title": "Quelles unités utilisez-vous ?", + "body": "Les mesures sont enregistrées une fois et affichées dans l'unité de votre choix. Vous pourrez la changer plus tard dans les réglages.", + "glucoseLegend": "Glycémie", + "weightLegend": "Poids et taille", + "options": { + "metric": "kg et cm", + "imperial": "lb et ft/in" + } + }, + "confirm": { + "title": "Voici ce que vos réponses activent", + "body": "Un coup d'œil, et HealthLog est configuré. Rien ici n'est définitif.", + "modulesTitle": "Modules", + "switchedOn": "Activés : {modules}.", + "nothingExtra": "Vos réponses n'activent aucun module supplémentaire ; les mesures de base sont toujours là.", + "alwaysOn": "Toujours actifs : {modules}, et les mesures de base (poids, tension, pouls).", + "everythingElse": "Tout le reste reste à un clic dans", + "modulesLink": "Réglages, Modules", + "glucoseUnit": "Glycémie", + "unitPreference": "Unités", + "language": "Langue", + "timezone": "Fuseau horaire", + "changeInSettings": "À modifier dans Réglages, Compte", + "cta": "Confirmer et continuer", + "managedTitle": "La personne dont vous vous occupez", + "managedBody": "Son dossier n'a pas d'identifiant propre ; vous le gérez depuis votre compte. Un second facteur sur votre compte est nécessaire pour le créer.", + "managedCreate": "Créer le profil et terminer", + "managedLater": "Vous pouvez aussi le créer plus tard dans Réglages, Accès partagé.", + "finishWithoutProfile": "Terminer sans le profil" + }, + "first-result": { + "connect-source": { + "title": "Connecter {target}", + "body": "Une seule connexion, choisie d'après vos réponses. Les mesures arrivent ensuite d'elles-mêmes.", + "howTo": "La connexion se fait sur la page des intégrations. Revenez ici ensuite ; cet écran détecte quand elle est établie.", + "cta": "Connecter {target}", + "checking": "Vérification de la connexion.", + "waiting": "Pas encore connecté.", + "done": "{target} est connecté.", + "syncing": "La première synchronisation est en cours. Les mesures apparaissent sur le tableau de bord au fur et à mesure." + }, + "add-medication": { + "title": "Ajoutez votre premier médicament", + "body": "Nom, dose et horaire ; le rappel suit l'horaire que vous définissez.", + "howTo": "Le médicament s'ajoute en quelques étapes courtes. Vous pourrez en ajouter d'autres plus tard sur la page des médicaments.", + "cta": "Ajouter un médicament", + "done": "{name} est ajouté.", + "doneUnnamed": "Votre premier médicament est ajouté.", + "nextDose": "Prochaine prise : {when}.", + "reminders": "Les rappels suivent l'horaire que vous avez défini." + }, + "log-reading": { + "title": "Saisir une mesure : {target}", + "body": "Une mesure place la première valeur sur votre tableau de bord. La tendance se dessine d'elle-même à partir de la cinquième.", + "done": "Votre première mesure est sur sa tuile.", + "doneElsewhere": "Votre première entrée pour {target} est enregistrée.", + "elsewhere": "{target} a sa propre page. Ouvrez-la, faites la première entrée, puis revenez ici la marquer comme faite.", + "open": "Ouvrir {target}", + "markDone": "J'ai fait l'entrée" + } + }, + "done": { + "tourCta": "Faire une courte visite guidée", + "openRecord": "Ouvrir le dossier de {name}", + "createProfile": "Créer le profil de la personne dont vous vous occupez" + } } }, "gettingStarted": { @@ -4646,7 +4735,14 @@ "notificationsCta": "Ouvrir les notifications", "insightsTitle": "Activer les insights (facultatif)", "insightsDescription": "Ajoutez un modèle local ou votre propre clé IA pour débloquer le briefing quotidien et Coach.", - "insightsCta": "Activer les insights" + "insightsCta": "Activer les insights", + "visitTitle": "Préparer la visite", + "visitDescription": "Notez le rendez-vous dans le calendrier ; le rapport médical rassemble ce qu'il faut apporter.", + "visitCta": "Ouvrir les visites", + "dataSourceNamedTitle": "Connecter {source}", + "managedProfileTitle": "Créer le profil de la personne dont vous vous occupez", + "managedProfileDescription": "Vos réponses concernaient le dossier d'une autre personne ; il se crée dans Accès partagé.", + "managedProfileCta": "Créer le profil" } }, "trendHints": { @@ -6460,7 +6556,15 @@ }, "dangerZoneDetail": "Votre compte et vos identifiants de connexion sont conservés.", "deleteAccountCardDetail": "Cette action est irréversible.", - "passkeyStepUpRequired": "Par sécurité, reconnecte-toi avant de supprimer une clé d'accès." + "passkeyStepUpRequired": "Par sécurité, reconnecte-toi avant de supprimer une clé d'accès.", + "setup": { + "title": "Configuration", + "description": "Les questions qui décident de ce que HealthLog montre en premier, et la liste de départ du tableau de bord.", + "setUpAgain": "Reconfigurer", + "showChecklist": "Réafficher la liste de départ", + "checklistShown": "La liste de départ est de retour sur le tableau de bord.", + "checklistNothingHidden": "La liste n'était pas masquée ; elle s'ouvre sur le tableau de bord." + } }, "admin": { "title": "Administration", @@ -8732,7 +8836,8 @@ "unavailable": { "description": "L'exploitant a désactivé {module} pour tous les comptes de ce serveur. Aucun réglage de votre côté ne peut le rétablir.", "title": "Non disponible sur ce serveur" - } + }, + "openModules": "Ouvrir Réglages, Modules" }, "vaccinations": { "title": "Vaccinations", diff --git a/messages/it.json b/messages/it.json index 407352537..d965b3827 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4487,51 +4487,8 @@ "back": "Indietro", "skip": "Salta", "next": "Continua", - "step1of4": "Passo 1 di 4", - "step2of4": "Passo 2 di 4", - "step3of4": "Passo 3 di 4", - "step4of4": "Passo 4 di 4" - }, - "goals": { - "title": "Cosa vuoi monitorare?", - "body": "Scegli gli ambiti che ti interessano: li mostreremo per primi nella tua dashboard e ti aiutano a decidere cosa configurare in seguito.", - "helpHint": "Da una a tre priorità funzionano di solito meglio — potrai aggiungerne altre in qualsiasi momento.", - "options": { - "weightManagement": "Gestione del peso", - "bpTracking": "Pressione arteriosa", - "glucoseTracking": "Glicemia", - "sleepImprovement": "Sonno migliore", - "medicationCompliance": "Farmaci", - "generalWellness": "Benessere generale" - } - }, - "source": { - "title": "Connetti una sorgente", - "body": "Importa le misurazioni in automatico oppure resta sull'inserimento manuale. Qui non viene configurato nulla al posto tuo: ogni scheda mostra solo dove collegarla. Le fonti su dispositivo richiedono un'app sviluppatore gratuita sul tuo account (circa 5 minuti); la guida su ogni scheda ti accompagna.", - "recommended": "Consigliato", - "manual": { - "title": "Inserimento manuale", - "body": "Registra tu stesso le misurazioni, quando ti è comodo. Funziona su qualsiasi dispositivo, senza account di terzi." - }, - "withings": { - "title": "Withings", - "body": "Bilance, misuratori di pressione e tracker Withings si sincronizzano automaticamente una volta collegati.", - "cta": "Connetti Withings", - "setupCta": "Configura", - "setupHint": "Richiede prima la tua app sviluppatore Withings gratuita — circa 5 minuti, una tantum." - }, - "more": { - "title": "Altre fonti", - "body": "Collega una di queste nelle Impostazioni quando vuoi — la maggior parte richiede prima un'app sviluppatore gratuita sul tuo account.", - "appleHealth": "Apple Health", - "whoop": "WHOOP", - "oura": "Oura", - "polar": "Polar", - "nightscout": "Nightscout", - "fitbit": "Fitbit", - "appleHealthHint": "Apple Health non ha una connessione web — completa qui la configurazione iniziale, poi apri l'app HealthLog sul tuo iPhone per completare la configurazione di Apple Health." - }, - "import": "Importa la cronologia esistente" + "stepOf": "Passaggio {current} di {total}", + "stepsLabel": "Passaggi della configurazione" }, "baseline": { "title": "Qualcosa su di te", @@ -4547,8 +4504,7 @@ "genderFemale": "Femminile", "genderOther": "Altro", "dateOfBirthLabel": "Data di nascita", - "dateOfBirthHint": "Usata solo per le analisi che tengono conto dell'età.", - "saveCta": "Salva e continua" + "dateOfBirthHint": "Usata solo per le analisi che tengono conto dell'età." }, "done": { "title": "Tutto pronto", @@ -4562,34 +4518,12 @@ }, "welcomeBack": { "title": "Bentornato", - "body": "Hai già completato l'onboarding. Per modificare qualcosa apri le impostazioni — tutto ciò che hai configurato è ancora modificabile.", "cta": "Apri la dashboard" }, "disclaimer": { "acknowledge": "Ho capito: HealthLog è uno strumento di monitoraggio privato, non una valutazione clinica né una diagnosi. Se sono preoccupato ne parlo con il medico, non con questa app.", "learnMore": "Scopri di più" }, - "welcome": { - "title": "Benvenuto in HealthLog", - "carouselLabel": "Carosello di benvenuto", - "slideOf": "Diapositiva {current} di {total}", - "prevSlide": "Diapositiva precedente", - "nextSlide": "Diapositiva successiva", - "gotoSlide": "Vai alla diapositiva {index}", - "cta": "Inizia", - "slide1": { - "title": "Tieni traccia di ogni segnale", - "body": "Peso, pressione, glicemia, sonno, umore — un unico registro che è tuo." - }, - "slide2": { - "title": "Collega le tue sorgenti", - "body": "Sincronizza con Apple Health e Withings o aggiungi le misurazioni a mano. I tuoi dati restano sul tuo server." - }, - "slide3": { - "title": "Scopri lo schema", - "body": "Riepiloghi giornalieri, grafici di tendenza e un coach che evidenzia i cambiamenti — tutto sul tuo dispositivo." - } - }, "anamnesis": { "title": "Sulla tua salute (facoltativo)", "subtitle": "Un po' di contesto perché HealthLog interpreti i tuoi valori in modo più equo.", @@ -4620,6 +4554,161 @@ "keylessLine": "HealthLog è pienamente utile anche senza IA. Le tue schede, i grafici, i trend e le esportazioni funzionano tutti — l'IA aggiunge solo la lettura in linguaggio chiaro.", "setupCta": "Configuralo (circa un minuto)", "sharedKeyNote": "Questa istanza fornisce già un provider IA condiviso, quindi gli insight funzionano per te fin da subito — nessuna configurazione necessaria." + }, + "steps": { + "who": "A chi appartiene questa cartella", + "areas": "Cosa vuoi tenere d'occhio", + "medication": "Farmaci", + "sources": "Da dove arrivano le misurazioni", + "visit": "Visita medica", + "units": "Unità", + "confirm": "Conferma", + "first-result": "Primo risultato", + "done": "Fatto" + }, + "flow": { + "welcome": { + "title": "Benvenuto in HealthLog", + "body": "Cinque domande brevi decidono cosa HealthLog ti mostra per primo. Ci vogliono circa due minuti.", + "changeLater": "Niente è definitivo: ogni scelta si può cambiare in seguito nelle Impostazioni.", + "setUp": "Configura", + "skip": "Salta per ora", + "againBody": "La configurazione è completa. Rispondi di nuovo alle domande per cambiare cosa HealthLog mostra per primo, oppure vai alla dashboard.", + "setUpAgain": "Configura di nuovo" + }, + "who": { + "title": "A chi appartiene questa cartella?", + "body": "Le risposte che seguono valgono per questa cartella.", + "options": { + "me": "A me", + "someone-else": "A una persona di cui mi prendo cura", + "both": "Entrambi" + }, + "hints": { + "someone-else": "Un figlio o un genitore, per esempio. Il suo profilo viene creato alla fine.", + "both": "Configura ora la tua cartella; il profilo dell'altra persona viene proposto alla fine." + } + }, + "areas": { + "title": "Cosa vuoi tenere d'occhio?", + "body": "Scegli quante voci vuoi. Decidono quali moduli sono attivi e cosa la dashboard mostra per primo.", + "options": { + "blood-pressure": "Pressione arteriosa", + "weight-body": "Peso e corpo", + "glucose": "Glicemia", + "sleep": "Sonno e recupero", + "mood": "Umore e benessere mentale", + "cycle": "Ciclo", + "activity": "Attività e allenamenti", + "labs": "Esami di laboratorio", + "illness": "Malattie e sintomi" + } + }, + "medication": { + "title": "Prendi farmaci con un orario fisso?", + "body": "Un orario attiva il modulo farmaci e i promemoria.", + "options": { + "yes": "Sì", + "no": "No", + "sometimes": "A volte" + } + }, + "sources": { + "title": "Da dove arrivano oggi le tue misurazioni?", + "body": "Scegli quante voci vuoi. Alla fine viene proposta una connessione; nessuna è obbligatoria.", + "options": { + "manual": "Le inserisco a mano", + "apple-health": "Apple Salute sul mio iPhone", + "withings": "Withings", + "oura": "Oura", + "whoop": "WHOOP", + "polar": "Polar", + "fitbit": "Fitbit", + "strava": "Strava", + "nightscout": "Nightscout", + "file": "Un file che ho già" + }, + "hints": { + "apple-health": "Sincronizzato dall'app iOS, oppure importato da un'esportazione.", + "file": "Si importa nelle Impostazioni quando hai finito qui." + } + }, + "visit": { + "title": "Hai una visita medica in arrivo?", + "body": "Una visita attiva il referto per il medico e aggiunge la preparazione alla tua lista iniziale.", + "options": { + "within-a-month": "Sì, entro un mese", + "later": "Più avanti", + "no": "No" + } + }, + "units": { + "title": "Quali unità usi?", + "body": "Le misurazioni vengono salvate una volta e mostrate nell'unità che scegli. Puoi cambiarla in seguito nelle Impostazioni.", + "glucoseLegend": "Glicemia", + "weightLegend": "Peso e altezza", + "options": { + "metric": "kg e cm", + "imperial": "lb e ft/in" + } + }, + "confirm": { + "title": "Ecco cosa attivano le tue risposte", + "body": "Uno sguardo e HealthLog è configurato. Niente di tutto questo è definitivo.", + "modulesTitle": "Moduli", + "switchedOn": "Attivati: {modules}.", + "nothingExtra": "Le tue risposte non attivano moduli aggiuntivi; le misurazioni di base ci sono sempre.", + "alwaysOn": "Sempre attivi: {modules} e le misurazioni di base (peso, pressione, polso).", + "everythingElse": "Tutto il resto resta a un clic in", + "modulesLink": "Impostazioni, Moduli", + "glucoseUnit": "Glicemia", + "unitPreference": "Unità", + "language": "Lingua", + "timezone": "Fuso orario", + "changeInSettings": "Modificali in Impostazioni, Account", + "cta": "Conferma e continua", + "managedTitle": "La persona di cui ti prendi cura", + "managedBody": "La sua cartella non ha un accesso proprio; la gestisci dal tuo account. Per crearla serve un secondo fattore sul tuo account.", + "managedCreate": "Crea il profilo e concludi", + "managedLater": "Puoi crearlo anche in seguito in Impostazioni, Accesso condiviso.", + "finishWithoutProfile": "Concludi senza il profilo" + }, + "first-result": { + "connect-source": { + "title": "Collega {target}", + "body": "Una sola connessione, scelta in base alle tue risposte. Poi le misurazioni arrivano da sole.", + "howTo": "La connessione si fa nella pagina delle integrazioni. Poi torna qui; questa schermata si accorge quando è pronta.", + "cta": "Collega {target}", + "checking": "Verifica della connessione in corso.", + "waiting": "Non ancora collegato.", + "done": "{target} è collegato.", + "syncing": "La prima sincronizzazione è in corso. Le misurazioni compaiono nella dashboard man mano che arrivano." + }, + "add-medication": { + "title": "Aggiungi il tuo primo farmaco", + "body": "Nome, dose e orario; il promemoria segue l'orario che imposti.", + "howTo": "Il farmaco si aggiunge in pochi brevi passaggi. Potrai aggiungerne altri in seguito nella pagina dei farmaci.", + "cta": "Aggiungi un farmaco", + "done": "{name} è stato aggiunto.", + "doneUnnamed": "Il tuo primo farmaco è stato aggiunto.", + "nextDose": "Prossima dose: {when}.", + "reminders": "I promemoria seguono l'orario che hai impostato." + }, + "log-reading": { + "title": "Inserisci una misurazione: {target}", + "body": "Una misurazione porta il primo valore sulla tua dashboard. Dalla quinta in poi la tendenza si disegna da sola.", + "done": "La tua prima misurazione è sul suo riquadro.", + "doneElsewhere": "La tua prima voce per {target} è registrata.", + "elsewhere": "{target} ha una pagina propria. Aprila, inserisci la prima voce, poi torna qui e segnala come fatta.", + "open": "Apri {target}", + "markDone": "Ho inserito la voce" + } + }, + "done": { + "tourCta": "Fai un breve tour", + "openRecord": "Apri la cartella di {name}", + "createProfile": "Crea il profilo della persona di cui ti prendi cura" + } } }, "gettingStarted": { @@ -4646,7 +4735,14 @@ "notificationsCta": "Apri notifiche", "insightsTitle": "Attiva gli insight (facoltativo)", "insightsDescription": "Aggiungi un modello locale o la tua chiave IA per sbloccare il briefing quotidiano e Coach.", - "insightsCta": "Attiva gli insight" + "insightsCta": "Attiva gli insight", + "visitTitle": "Prepara la visita", + "visitDescription": "Segna l'appuntamento nel calendario; il referto per il medico raccoglie cosa portare.", + "visitCta": "Apri le visite", + "dataSourceNamedTitle": "Collega {source}", + "managedProfileTitle": "Crea il profilo della persona di cui ti prendi cura", + "managedProfileDescription": "Le tue risposte riguardavano la cartella di un'altra persona; si crea in Accesso condiviso.", + "managedProfileCta": "Crea il profilo" } }, "trendHints": { @@ -6460,7 +6556,15 @@ }, "dangerZoneDetail": "Il tuo account e le credenziali con cui accedi restano.", "deleteAccountCardDetail": "Questa azione non può essere annullata.", - "passkeyStepUpRequired": "Per sicurezza, accedi di nuovo prima di rimuovere una passkey." + "passkeyStepUpRequired": "Per sicurezza, accedi di nuovo prima di rimuovere una passkey.", + "setup": { + "title": "Configurazione iniziale", + "description": "Le domande che decidono cosa HealthLog mostra per primo, e la lista dei primi passi nella dashboard.", + "setUpAgain": "Configura di nuovo", + "showChecklist": "Mostra di nuovo la lista", + "checklistShown": "La lista è di nuovo nella dashboard.", + "checklistNothingHidden": "La lista non era nascosta; si apre nella dashboard." + } }, "admin": { "title": "Administration", @@ -8732,7 +8836,8 @@ "unavailable": { "description": "Chi gestisce il server ha disattivato {module} per tutti gli account. Nessuna impostazione tua può riportarlo indietro.", "title": "Non disponibile su questo server" - } + }, + "openModules": "Apri Impostazioni, Moduli" }, "vaccinations": { "title": "Vaccinazioni", diff --git a/messages/ko.json b/messages/ko.json index 2e364231e..4dabc847b 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -4487,51 +4487,8 @@ "back": "이전", "skip": "건너뛰기", "next": "계속", - "step1of4": "1/4단계", - "step2of4": "2/4단계", - "step3of4": "3/4단계", - "step4of4": "4/4단계" - }, - "goals": { - "title": "무엇을 기록하고 싶으세요?", - "body": "관심 있는 영역을 골라 주세요. 대시보드에 먼저 보여 드리고, 다음에 무엇을 설정할지 정하는 데도 도움이 돼요.", - "helpHint": "우선순위는 1~3개가 가장 잘 맞아요 — 나중에 언제든 더 추가할 수 있어요.", - "options": { - "weightManagement": "체중 관리", - "bpTracking": "혈압", - "glucoseTracking": "혈당", - "sleepImprovement": "더 나은 수면", - "medicationCompliance": "복약", - "generalWellness": "전반적인 건강" - } - }, - "source": { - "title": "소스 연결", - "body": "측정값을 자동으로 가져오거나, 직접 입력만 써도 돼요. 여기서 대신 설정해 주는 건 없고, 카드마다 어디서 연결하는지만 알려 줘요. 기기 소스는 본인 계정에 무료 개발자 앱을 등록해야 하고(약 5분), 카드의 설정 가이드가 순서를 알려 줘요.", - "recommended": "추천", - "manual": { - "title": "직접 입력", - "body": "편할 때 직접 기록해요. 어떤 기기에서도 되고, 외부 계정도 필요 없어요." - }, - "withings": { - "title": "Withings", - "body": "Withings 체중계, 혈압계, 트래커는 연결해 두면 자동으로 동기화돼요.", - "cta": "Withings 연결", - "setupCta": "설정", - "setupHint": "먼저 본인의 무료 Withings 개발자 앱이 필요해요 — 처음 한 번, 약 5분." - }, - "more": { - "title": "다른 소스", - "body": "준비되면 언제든 설정에서 연결할 수 있어요 — 대부분 본인 계정에 무료 개발자 앱을 먼저 등록해야 해요.", - "appleHealth": "Apple Health", - "whoop": "WHOOP", - "oura": "Oura", - "polar": "Polar", - "nightscout": "Nightscout", - "fitbit": "Fitbit", - "appleHealthHint": "Apple Health는 웹으로 연결할 수 없어요 — 여기서 시작 설정을 마친 뒤 iPhone에서 HealthLog 앱을 열어 Apple Health 설정을 끝내 주세요." - }, - "import": "기존 기록 불러오기" + "stepOf": "{total}단계 중 {current}단계", + "stepsLabel": "설정 단계" }, "baseline": { "title": "나에 대해 조금만", @@ -4547,8 +4504,7 @@ "genderFemale": "여성", "genderOther": "기타", "dateOfBirthLabel": "생년월일", - "dateOfBirthHint": "나이를 반영한 분석에만 써요.", - "saveCta": "저장하고 계속" + "dateOfBirthHint": "나이를 반영한 분석에만 써요." }, "done": { "title": "준비 끝났어요", @@ -4562,34 +4518,12 @@ }, "welcomeBack": { "title": "다시 오셨네요", - "body": "시작 설정은 이미 마쳤어요. 바꾸고 싶은 게 있으면 설정으로 가 보세요 — 설정한 건 모두 거기서 수정할 수 있어요.", "cta": "대시보드 열기" }, "disclaimer": { "acknowledge": "이해했어요. HealthLog는 개인 기록 도구일 뿐, 임상 평가나 진단이 아니에요. 걱정되는 일이 있으면 이 앱이 아니라 의사와 상의할게요.", "learnMore": "자세히 보기" }, - "welcome": { - "title": "HealthLog에 오신 걸 환영해요", - "carouselLabel": "환영 캐러셀", - "slideOf": "{current}/{total}번째 슬라이드", - "prevSlide": "이전 슬라이드", - "nextSlide": "다음 슬라이드", - "gotoSlide": "{index}번 슬라이드로 이동", - "cta": "시작하기", - "slide1": { - "title": "모든 신호를 기록해요", - "body": "체중, 혈압, 혈당, 수면, 기분 — 내가 가진 하나의 기록에 전체 그림을 담아요." - }, - "slide2": { - "title": "소스를 연결해요", - "body": "Apple Health, Withings와 동기화하거나 직접 입력해요. 데이터는 내 서버에 남아요." - }, - "slide3": { - "title": "패턴이 보여요", - "body": "하루 요약, 추이 차트, 무엇이 달라졌는지 짚어 주는 코치 — 모두 내 기기 안에서." - } - }, "anamnesis": { "title": "건강에 대해 (선택)", "subtitle": "HealthLog가 내 숫자를 더 공정하게 읽도록 약간의 맥락을 알려 주세요.", @@ -4620,6 +4554,161 @@ "keylessLine": "AI 없이도 HealthLog는 충분히 쓸모 있어요. 타일, 차트, 추이, 내보내기 모두 그대로 작동하고 — AI는 그 위에 쉬운 말 해설을 더할 뿐이에요.", "setupCta": "설정하기 (1분 정도)", "sharedKeyNote": "이 인스턴스에는 이미 공용 AI 제공자가 있어서 지금 바로 인사이트를 쓸 수 있어요 — 따로 설정할 게 없어요." + }, + "steps": { + "who": "이 기록의 대상", + "areas": "관심 있게 볼 항목", + "medication": "복약", + "sources": "측정값의 출처", + "visit": "진료 예정", + "units": "단위", + "confirm": "확인", + "first-result": "첫 결과", + "done": "완료" + }, + "flow": { + "welcome": { + "title": "HealthLog에 오신 것을 환영합니다", + "body": "짧은 질문 다섯 개로 HealthLog가 먼저 보여 줄 내용이 정해집니다. 약 2분이 걸립니다.", + "changeLater": "확정되는 것은 없습니다. 모든 선택은 나중에 설정에서 바꿀 수 있습니다.", + "setUp": "설정 시작", + "skip": "지금은 건너뛰기", + "againBody": "설정이 완료되었습니다. HealthLog가 먼저 보여 줄 내용을 바꾸려면 질문에 다시 답하고, 아니면 대시보드로 이동하세요.", + "setUpAgain": "다시 설정하기" + }, + "who": { + "title": "이 기록은 누구의 것인가요?", + "body": "이어지는 답변은 이 기록에 적용됩니다.", + "options": { + "me": "나", + "someone-else": "내가 돌보는 사람", + "both": "둘 다" + }, + "hints": { + "someone-else": "예를 들어 자녀나 부모님입니다. 그 사람의 프로필은 마지막에 만들어집니다.", + "both": "지금은 내 기록을 설정하고, 다른 사람의 프로필은 마지막에 안내됩니다." + } + }, + "areas": { + "title": "무엇을 관심 있게 보고 싶으신가요?", + "body": "원하는 만큼 고르세요. 어떤 모듈을 켤지와 대시보드에 먼저 보일 내용이 정해집니다.", + "options": { + "blood-pressure": "혈압", + "weight-body": "체중과 신체", + "glucose": "혈당", + "sleep": "수면과 회복", + "mood": "기분과 정신 건강", + "cycle": "생리 주기", + "activity": "활동과 운동", + "labs": "검사 결과", + "illness": "질병과 증상" + } + }, + "medication": { + "title": "정해진 일정에 따라 약을 복용하시나요?", + "body": "일정이 있으면 복약 모듈과 알림이 켜집니다.", + "options": { + "yes": "예", + "no": "아니요", + "sometimes": "가끔" + } + }, + "sources": { + "title": "현재 측정값은 어디에서 오나요?", + "body": "원하는 만큼 고르세요. 마지막에 연결 하나를 안내하며, 필수는 아닙니다.", + "options": { + "manual": "직접 입력합니다", + "apple-health": "iPhone의 Apple 건강", + "withings": "Withings", + "oura": "Oura", + "whoop": "WHOOP", + "polar": "Polar", + "fitbit": "Fitbit", + "strava": "Strava", + "nightscout": "Nightscout", + "file": "이미 가지고 있는 파일" + }, + "hints": { + "apple-health": "iOS 앱이 동기화하거나 내보내기 파일에서 가져옵니다.", + "file": "여기서 마친 뒤 설정에서 가져옵니다." + } + }, + "visit": { + "title": "예정된 진료가 있나요?", + "body": "진료가 있으면 진료 보고서가 켜지고 준비 항목이 체크리스트에 추가됩니다.", + "options": { + "within-a-month": "예, 한 달 안에", + "later": "나중에", + "no": "아니요" + } + }, + "units": { + "title": "어떤 단위를 사용하시나요?", + "body": "측정값은 한 번 저장되고 선택한 단위로 표시됩니다. 나중에 설정에서 바꿀 수 있습니다.", + "glucoseLegend": "혈당", + "weightLegend": "체중과 키", + "options": { + "metric": "kg과 cm", + "imperial": "lb와 ft/in" + } + }, + "confirm": { + "title": "답변으로 켜지는 항목입니다", + "body": "한 번 확인하면 HealthLog 설정이 끝납니다. 여기서 확정되는 것은 없습니다.", + "modulesTitle": "모듈", + "switchedOn": "켜짐: {modules}.", + "nothingExtra": "답변으로 추가 모듈이 켜지지는 않습니다. 기본 측정값은 항상 있습니다.", + "alwaysOn": "항상 켜짐: {modules}, 그리고 기본 측정값(체중, 혈압, 맥박).", + "everythingElse": "나머지는 한 번의 클릭으로 열 수 있습니다:", + "modulesLink": "설정, 모듈", + "glucoseUnit": "혈당", + "unitPreference": "단위", + "language": "언어", + "timezone": "시간대", + "changeInSettings": "설정, 계정에서 변경", + "cta": "확인하고 계속", + "managedTitle": "내가 돌보는 사람", + "managedBody": "이 기록에는 별도의 로그인이 없으며 내 계정에서 관리합니다. 만들려면 내 계정에 2차 인증이 필요합니다.", + "managedCreate": "프로필 만들고 마치기", + "managedLater": "나중에 설정, 공유 접근에서 만들 수도 있습니다.", + "finishWithoutProfile": "프로필 없이 마치기" + }, + "first-result": { + "connect-source": { + "title": "{target} 연결", + "body": "답변을 바탕으로 고른 연결 하나입니다. 이후 측정값은 저절로 들어옵니다.", + "howTo": "연결은 연동 페이지에서 합니다. 마친 뒤 이곳으로 돌아오면 이 화면이 연결을 감지합니다.", + "cta": "{target} 연결", + "checking": "연결을 확인하는 중입니다.", + "waiting": "아직 연결되지 않았습니다.", + "done": "{target}이(가) 연결되었습니다.", + "syncing": "첫 동기화가 진행 중입니다. 측정값이 도착하는 대로 대시보드에 나타납니다." + }, + "add-medication": { + "title": "첫 약 추가하기", + "body": "이름, 용량, 일정을 입력하면 알림이 그 일정을 따릅니다.", + "howTo": "약은 몇 단계로 짧게 추가합니다. 나중에 복약 페이지에서 더 추가할 수 있습니다.", + "cta": "약 추가", + "done": "{name}이(가) 추가되었습니다.", + "doneUnnamed": "첫 약이 추가되었습니다.", + "nextDose": "다음 복용: {when}.", + "reminders": "알림은 설정한 일정을 따릅니다." + }, + "log-reading": { + "title": "측정값 하나 입력: {target}", + "body": "측정값 하나로 대시보드에 첫 값이 표시됩니다. 다섯 번째부터는 추세가 저절로 그려집니다.", + "done": "첫 측정값이 타일에 표시되었습니다.", + "doneElsewhere": "{target}의 첫 기록이 저장되었습니다.", + "elsewhere": "{target}에는 전용 페이지가 있습니다. 열어서 첫 기록을 남긴 뒤 여기로 돌아와 완료로 표시하세요.", + "open": "{target} 열기", + "markDone": "기록을 남겼습니다" + } + }, + "done": { + "tourCta": "짧은 둘러보기", + "openRecord": "{name}의 기록 열기", + "createProfile": "내가 돌보는 사람의 프로필 만들기" + } } }, "gettingStarted": { @@ -4646,7 +4735,14 @@ "notificationsCta": "알림 열기", "insightsTitle": "인사이트 켜기(선택)", "insightsDescription": "로컬 모델이나 내 AI 키를 넣으면 일일 브리핑과 코치가 열려요.", - "insightsCta": "인사이트 켜기" + "insightsCta": "인사이트 켜기", + "visitTitle": "진료 준비하기", + "visitDescription": "예약을 달력에 넣으세요. 진료 보고서가 가져갈 내용을 모아 줍니다.", + "visitCta": "진료 열기", + "dataSourceNamedTitle": "{source} 연결", + "managedProfileTitle": "돌보는 사람의 프로필 만들기", + "managedProfileDescription": "답변은 다른 사람의 기록에 대한 것이었습니다. 프로필은 공유 접근에서 만듭니다.", + "managedProfileCta": "프로필 만들기" } }, "trendHints": { @@ -6460,7 +6556,15 @@ }, "dangerZoneDetail": "계정과 로그인에 쓰는 인증 정보는 그대로 남아요.", "deleteAccountCardDetail": "되돌릴 수 없어요.", - "passkeyStepUpRequired": "보안을 위해 패스키를 삭제하기 전에 다시 로그인해 주세요." + "passkeyStepUpRequired": "보안을 위해 패스키를 삭제하기 전에 다시 로그인해 주세요.", + "setup": { + "title": "초기 설정", + "description": "HealthLog가 먼저 보여 줄 내용을 정하는 질문과 대시보드의 시작 체크리스트입니다.", + "setUpAgain": "다시 설정하기", + "showChecklist": "체크리스트 다시 표시", + "checklistShown": "체크리스트가 대시보드에 다시 표시됩니다.", + "checklistNothingHidden": "체크리스트는 숨겨져 있지 않았습니다. 대시보드에서 열립니다." + } }, "admin": { "title": "관리자", @@ -8732,7 +8836,8 @@ "unavailable": { "description": "운영자가 이 서버의 모든 계정에서 {module} 모듈을 껐어요. 개인 설정으로는 되돌릴 수 없어요.", "title": "이 서버에서는 사용할 수 없어요" - } + }, + "openModules": "설정, 모듈 열기" }, "vaccinations": { "title": "예방접종", diff --git a/messages/pl.json b/messages/pl.json index c09076960..9769c0838 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4487,51 +4487,8 @@ "back": "Wstecz", "skip": "Pomiń", "next": "Dalej", - "step1of4": "Krok 1 z 4", - "step2of4": "Krok 2 z 4", - "step3of4": "Krok 3 z 4", - "step4of4": "Krok 4 z 4" - }, - "goals": { - "title": "Co chcesz śledzić?", - "body": "Wybierz obszary, które są dla Ciebie ważne — pokażemy je najpierw na pulpicie i pomogą Ci zdecydować, co skonfigurować dalej.", - "helpHint": "Od jednego do trzech priorytetów zwykle sprawdza się najlepiej — możesz dodać kolejne w dowolnej chwili.", - "options": { - "weightManagement": "Kontrola wagi", - "bpTracking": "Ciśnienie", - "glucoseTracking": "Glukoza", - "sleepImprovement": "Lepszy sen", - "medicationCompliance": "Leki", - "generalWellness": "Ogólna forma" - } - }, - "source": { - "title": "Połącz źródło", - "body": "Pobieraj pomiary automatycznie lub zostań przy wpisywaniu ręcznym. Nic nie jest tu konfigurowane za Ciebie — każda karta pokazuje tylko, gdzie to połączyć. Źródła sprzętowe wymagają darmowej aplikacji deweloperskiej na Twoim własnym koncie (około 5 minut); przewodnik na każdej karcie prowadzi krok po kroku.", - "recommended": "Polecane", - "manual": { - "title": "Wpis ręczny", - "body": "Rejestruj pomiary samodzielnie, kiedy Ci pasuje. Działa na każdym urządzeniu, bez konta u podmiotu trzeciego." - }, - "withings": { - "title": "Withings", - "body": "Wagi, ciśnieniomierze i trackery Withings synchronizują się automatycznie po połączeniu.", - "cta": "Połącz Withings", - "setupCta": "Skonfiguruj", - "setupHint": "Najpierw wymaga własnej, darmowej aplikacji deweloperskiej Withings — około 5 minut, jednorazowo." - }, - "more": { - "title": "Więcej źródeł", - "body": "Połącz dowolne z nich w Ustawieniach, gdy zechcesz — większość wymaga najpierw darmowej aplikacji deweloperskiej na Twoim własnym koncie.", - "appleHealth": "Apple Health", - "whoop": "WHOOP", - "oura": "Oura", - "polar": "Polar", - "nightscout": "Nightscout", - "fitbit": "Fitbit", - "appleHealthHint": "Apple Health nie ma połączenia przez przeglądarkę — dokończ tu wstępną konfigurację, a następnie otwórz aplikację HealthLog na iPhonie, aby dokończyć konfigurację Apple Health." - }, - "import": "Importuj istniejącą historię" + "stepOf": "Krok {current} z {total}", + "stepsLabel": "Kroki konfiguracji" }, "baseline": { "title": "Kilka słów o Tobie", @@ -4547,8 +4504,7 @@ "genderFemale": "Kobieta", "genderOther": "Inna", "dateOfBirthLabel": "Data urodzenia", - "dateOfBirthHint": "Używana wyłącznie do analiz zależnych od wieku.", - "saveCta": "Zapisz i kontynuuj" + "dateOfBirthHint": "Używana wyłącznie do analiz zależnych od wieku." }, "done": { "title": "Wszystko gotowe", @@ -4562,34 +4518,12 @@ }, "welcomeBack": { "title": "Witaj ponownie", - "body": "Wstępne kroki masz już za sobą. Aby coś zmienić, otwórz ustawienia — wszystko, co skonfigurowałeś, da się tam edytować.", "cta": "Otwórz pulpit" }, "disclaimer": { "acknowledge": "Rozumiem: HealthLog to prywatne narzędzie do śledzenia, a nie ocena kliniczna ani diagnoza. Gdy się niepokoję, rozmawiam z lekarzem, a nie z tą aplikacją.", "learnMore": "Dowiedz się więcej" }, - "welcome": { - "title": "Witaj w HealthLog", - "carouselLabel": "Karuzela powitalna", - "slideOf": "Slajd {current} z {total}", - "prevSlide": "Poprzedni slajd", - "nextSlide": "Następny slajd", - "gotoSlide": "Przejdź do slajdu {index}", - "cta": "Zaczynamy", - "slide1": { - "title": "Śledź każdy sygnał", - "body": "Waga, ciśnienie, glukoza, sen, nastrój — wszystko w jednym dzienniku, który należy do Ciebie." - }, - "slide2": { - "title": "Połącz swoje źródła", - "body": "Synchronizuj z Apple Health i Withings lub dodawaj odczyty ręcznie. Twoje dane zostają na Twoim serwerze." - }, - "slide3": { - "title": "Zobacz wzorce", - "body": "Codzienne podsumowania, wykresy trendów i coach, który wskazuje co się zmieniło — wszystko na Twoim urządzeniu." - } - }, "anamnesis": { "title": "O Twoim zdrowiu (opcjonalnie)", "subtitle": "Trochę kontekstu, aby HealthLog rzetelniej odczytywał Twoje wyniki.", @@ -4620,6 +4554,161 @@ "keylessLine": "HealthLog jest w pełni użyteczny bez AI. Twoje kafelki, wykresy, trendy i eksporty działają — AI dokłada tylko odczyt prostym językiem.", "setupCta": "Skonfiguruj (około minuty)", "sharedKeyNote": "Ta instancja udostępnia już wspólnego dostawcę AI, więc insighty działają dla Ciebie od razu — bez konfiguracji." + }, + "steps": { + "who": "Dla kogo jest ta kartoteka", + "areas": "Co chcesz obserwować", + "medication": "Leki", + "sources": "Skąd pochodzą pomiary", + "visit": "Wizyta u lekarza", + "units": "Jednostki", + "confirm": "Potwierdzenie", + "first-result": "Pierwszy wynik", + "done": "Gotowe" + }, + "flow": { + "welcome": { + "title": "Witaj w HealthLog", + "body": "Pięć krótkich pytań decyduje o tym, co HealthLog pokaże Ci najpierw. Zajmuje to około dwóch minut.", + "changeLater": "Nic nie jest ostateczne: każdy wybór można później zmienić w Ustawieniach.", + "setUp": "Skonfiguruj", + "skip": "Pomiń na razie", + "againBody": "Konfiguracja jest zakończona. Odpowiedz na pytania ponownie, aby zmienić, co HealthLog pokazuje najpierw, albo przejdź do panelu.", + "setUpAgain": "Skonfiguruj ponownie" + }, + "who": { + "title": "Dla kogo jest ta kartoteka?", + "body": "Kolejne odpowiedzi dotyczą tej kartoteki.", + "options": { + "me": "Dla mnie", + "someone-else": "Dla osoby, którą się opiekuję", + "both": "Dla obojga" + }, + "hints": { + "someone-else": "Na przykład dziecko lub rodzic. Profil tej osoby powstaje na końcu.", + "both": "Skonfiguruj teraz własną kartotekę; profil drugiej osoby zostanie zaproponowany na końcu." + } + }, + "areas": { + "title": "Co chcesz obserwować?", + "body": "Wybierz dowolną liczbę. Decydują o tym, które moduły są włączone i co panel pokazuje najpierw.", + "options": { + "blood-pressure": "Ciśnienie krwi", + "weight-body": "Waga i ciało", + "glucose": "Glukoza we krwi", + "sleep": "Sen i regeneracja", + "mood": "Nastrój i dobrostan psychiczny", + "cycle": "Cykl", + "activity": "Aktywność i treningi", + "labs": "Wyniki badań", + "illness": "Choroby i objawy" + } + }, + "medication": { + "title": "Czy bierzesz leki według stałego harmonogramu?", + "body": "Harmonogram włącza moduł leków i przypomnienia.", + "options": { + "yes": "Tak", + "no": "Nie", + "sometimes": "Czasami" + } + }, + "sources": { + "title": "Skąd dziś pochodzą Twoje pomiary?", + "body": "Wybierz dowolną liczbę. Na końcu zostanie zaproponowane jedno połączenie; żadne nie jest wymagane.", + "options": { + "manual": "Wpisuję je ręcznie", + "apple-health": "Apple Health na moim iPhonie", + "withings": "Withings", + "oura": "Oura", + "whoop": "WHOOP", + "polar": "Polar", + "fitbit": "Fitbit", + "strava": "Strava", + "nightscout": "Nightscout", + "file": "Plik, który już mam" + }, + "hints": { + "apple-health": "Synchronizowane przez aplikację iOS lub importowane z eksportu.", + "file": "Import w Ustawieniach, gdy skończysz tutaj." + } + }, + "visit": { + "title": "Czy zbliża się wizyta u lekarza?", + "body": "Wizyta włącza raport dla lekarza i dodaje przygotowanie do Twojej listy startowej.", + "options": { + "within-a-month": "Tak, w ciągu miesiąca", + "later": "Później", + "no": "Nie" + } + }, + "units": { + "title": "Jakich jednostek używasz?", + "body": "Pomiary są zapisywane raz i wyświetlane w wybranej jednostce. Możesz ją później zmienić w Ustawieniach.", + "glucoseLegend": "Glukoza we krwi", + "weightLegend": "Waga i wzrost", + "options": { + "metric": "kg i cm", + "imperial": "lb i ft/in" + } + }, + "confirm": { + "title": "Oto co włączają Twoje odpowiedzi", + "body": "Jedno spojrzenie i HealthLog jest skonfigurowany. Nic tutaj nie jest ostateczne.", + "modulesTitle": "Moduły", + "switchedOn": "Włączone: {modules}.", + "nothingExtra": "Twoje odpowiedzi nie włączają żadnego dodatkowego modułu; podstawowe pomiary są zawsze dostępne.", + "alwaysOn": "Zawsze włączone: {modules} oraz podstawowe pomiary (waga, ciśnienie, tętno).", + "everythingElse": "Cała reszta jest o jedno kliknięcie w", + "modulesLink": "Ustawienia, Moduły", + "glucoseUnit": "Glukoza we krwi", + "unitPreference": "Jednostki", + "language": "Język", + "timezone": "Strefa czasowa", + "changeInSettings": "Zmień w Ustawieniach, Konto", + "cta": "Potwierdź i kontynuuj", + "managedTitle": "Osoba, którą się opiekujesz", + "managedBody": "Jej kartoteka nie ma własnego logowania; zarządzasz nią ze swojego konta. Do jej utworzenia potrzebny jest drugi składnik na Twoim koncie.", + "managedCreate": "Utwórz profil i zakończ", + "managedLater": "Możesz też utworzyć go później w Ustawieniach, Dostęp współdzielony.", + "finishWithoutProfile": "Zakończ bez profilu" + }, + "first-result": { + "connect-source": { + "title": "Połącz {target}", + "body": "Jedno połączenie, wybrane na podstawie Twoich odpowiedzi. Potem pomiary przychodzą same.", + "howTo": "Połączenie nawiązuje się na stronie integracji. Wróć tutaj później; ten ekran zauważy, kiedy będzie gotowe.", + "cta": "Połącz {target}", + "checking": "Sprawdzanie połączenia.", + "waiting": "Jeszcze nie połączono.", + "done": "{target} jest połączone.", + "syncing": "Trwa pierwsza synchronizacja. Pomiary pojawią się na panelu, gdy tylko nadejdą." + }, + "add-medication": { + "title": "Dodaj swój pierwszy lek", + "body": "Nazwa, dawka i harmonogram; przypomnienie podąża za ustawionym harmonogramem.", + "howTo": "Lek dodaje się w kilku krótkich krokach. Kolejne możesz dodać później na stronie leków.", + "cta": "Dodaj lek", + "done": "{name} został dodany.", + "doneUnnamed": "Twój pierwszy lek został dodany.", + "nextDose": "Następna dawka: {when}.", + "reminders": "Przypomnienia podążają za ustawionym harmonogramem." + }, + "log-reading": { + "title": "Wpisz jeden pomiar: {target}", + "body": "Jeden pomiar umieszcza pierwszą wartość na Twoim panelu. Trend rysuje się sam od piątego.", + "done": "Twój pierwszy pomiar jest na swoim kafelku.", + "doneElsewhere": "Twój pierwszy wpis dla {target} jest zapisany.", + "elsewhere": "{target} ma własną stronę. Otwórz ją, zrób pierwszy wpis, a potem wróć tutaj i oznacz go jako wykonany.", + "open": "Otwórz {target}", + "markDone": "Wpis zrobiony" + } + }, + "done": { + "tourCta": "Zrób krótką wycieczkę", + "openRecord": "Otwórz kartotekę: {name}", + "createProfile": "Utwórz profil osoby, którą się opiekujesz" + } } }, "gettingStarted": { @@ -4646,7 +4735,14 @@ "notificationsCta": "Otwórz powiadomienia", "insightsTitle": "Włącz insighty (opcjonalnie)", "insightsDescription": "Dodaj model lokalny lub własny klucz AI, aby odblokować codzienny briefing i Coach.", - "insightsCta": "Włącz insighty" + "insightsCta": "Włącz insighty", + "visitTitle": "Przygotuj się do wizyty", + "visitDescription": "Wpisz termin do kalendarza; raport dla lekarza zbiera to, co warto zabrać.", + "visitCta": "Otwórz wizyty", + "dataSourceNamedTitle": "Połącz {source}", + "managedProfileTitle": "Utwórz profil osoby, którą się opiekujesz", + "managedProfileDescription": "Twoje odpowiedzi dotyczyły kartoteki innej osoby; tworzy się ją w Dostępie współdzielonym.", + "managedProfileCta": "Utwórz profil" } }, "trendHints": { @@ -6460,7 +6556,15 @@ }, "dangerZoneDetail": "Konto i dane logowania zostają zachowane.", "deleteAccountCardDetail": "Tej operacji nie można cofnąć.", - "passkeyStepUpRequired": "Ze względów bezpieczeństwa zaloguj się ponownie przed usunięciem klucza dostępu." + "passkeyStepUpRequired": "Ze względów bezpieczeństwa zaloguj się ponownie przed usunięciem klucza dostępu.", + "setup": { + "title": "Konfiguracja", + "description": "Pytania, które decydują o tym, co HealthLog pokazuje najpierw, oraz lista startowa na panelu.", + "setUpAgain": "Skonfiguruj ponownie", + "showChecklist": "Pokaż listę ponownie", + "checklistShown": "Lista wróciła na panel.", + "checklistNothingHidden": "Lista nie była ukryta; otwiera się na panelu." + } }, "admin": { "title": "Administracja", @@ -8732,7 +8836,8 @@ "unavailable": { "description": "Operator wyłączył {module} dla wszystkich kont na tym serwerze. Żadne twoje ustawienie tego nie przywróci.", "title": "Niedostępne na tym serwerze" - } + }, + "openModules": "Otwórz Ustawienia, Moduły" }, "vaccinations": { "title": "Szczepienia", diff --git a/package.json b/package.json index dd10a3656..bbd705680 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.38.17", + "version": "1.38.18", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/playwright.config.ts b/playwright.config.ts index 1becdc716..a6231feed 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -222,6 +222,17 @@ export default defineConfig({ // through stable attributes and the account's own API, not a mobile // layout, so it runs in one project. "notification-dispatch-journey.spec.ts", + // v1.39 (C2) — the setup-flow journeys each own one account and walk + // it from the welcome screen to the dashboard, resetting it first. A + // second project would walk the same account from a second browser + // and each would find the other's answers. The two sweeps set their + // own viewports inside the desktop project for the same reason. + "setup-flow-bp.spec.ts", + "setup-flow-medication.spec.ts", + "setup-flow-visit.spec.ts", + "setup-flow-child.spec.ts", + "setup-flow-a11y.spec.ts", + "setup-flow-locale-overflow.spec.ts", ], use: { // Pixel 5 — Chromium-based mobile profile so CI only needs diff --git a/prisma/migrations/0339_retire_wizard_columns/migration.sql b/prisma/migrations/0339_retire_wizard_columns/migration.sql new file mode 100644 index 000000000..4647080d1 --- /dev/null +++ b/prisma/migrations/0339_retire_wizard_columns/migration.sql @@ -0,0 +1,11 @@ +-- v1.39 (C2) — retire the five-step wizard's two columns. +-- +-- `onboarding_step` held the wizard's checkpoint (0..4) and `onboarding_goals` +-- the six goal slugs whose only durable effect was a one-time dashboard seed. +-- The needs-based flow keeps its state on `onboarding_records` and seeds the +-- dashboard from the answers there, so nothing reads or writes either column +-- any more. `onboarding_completed_at` stays: it is the first-run redirect's +-- gate and the needs flow keeps stamping it. +ALTER TABLE "users" + DROP COLUMN IF EXISTS "onboarding_step", + DROP COLUMN IF EXISTS "onboarding_goals"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 755ae4121..71bcfeb6d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -385,29 +385,9 @@ model User { // contract for every other self-hoster. documentsAutoAiRead Boolean @default(false) @map("documents_auto_ai_read") - // Onboarding + // Onboarding. The first-run redirect's gate; the needs-based setup flow + // (`OnboardingRecord`) keeps its own ledger and stamps this on confirm. onboardingCompletedAt DateTime? @map("onboarding_completed_at") - // v1.4.25 W14b — last persisted onboarding wizard step. - // Encodes resume-from-where-you-left-off semantics for the rebuilt - // multi-step wizard at `/onboarding/[step]`. Values mirror the - // wizard's step indices: - // 0 = welcome - // 1 = goals - // 2 = source - // 3 = baseline - // 4 = done (triggers `onboardingCompletedAt` flip on the same - // round-trip — the column stays at 4 for completed users) - // Null = legacy pre-W14b user; the wizard treats null as 0 on mount. - onboardingStep Int @default(0) @map("onboarding_step") - // v1.17.1 — goal slugs chosen during onboarding (GoalsChipPicker). - // Seeds dashboard tile pinning + reminder suggestions; NOT a clinical - // target store (targets are fully derived from height/age/gender + - // clinical refs in /api/insights/targets). Empty [] = skipped / no - // preference. The slug set is the closed enum - // `ONBOARDING_GOAL_SLUGS` in src/lib/onboarding/goals.ts; a String[] - // (not a relation) mirrors `insightsExcludeMetrics` precedent — the - // set is small and fixed, never queried relationally. - onboardingGoals String[] @default([]) @map("onboarding_goals") // v1.4.15 Phase B5: tracks whether the user has finished (or // explicitly dismissed) the spotlight tour that overlays the // dashboard on first arrival. Distinct from the wizard at @@ -8380,9 +8360,9 @@ model VaccinationDocumentLink { /// covers free text a person wrote (`UserHealthProfile.aboutMeEncrypted`, the /// Coach transcript, the emergency note) and identifiers (the KVNR), and an /// answer drawn from nine fixed chips is neither. It is the same class of -/// value as `modulePreferencesJson` and `onboardingGoals`, both of which sit -/// in plaintext beside it. Readers are fail-soft rather than schema-validated -/// at the database edge — see `src/lib/onboarding/needs.ts`. +/// value as `modulePreferencesJson`, which sits in plaintext beside it. +/// Readers are fail-soft rather than schema-validated at the database edge — +/// see `src/lib/onboarding/needs.ts`. model OnboardingRecord { id String @id @default(cuid()) userId String @unique @map("user_id") diff --git a/public/sw.js b/public/sw.js index bce54c3fc..40593f0ee 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.38.17"; + /* @sw-version-fallback */ "v1.38.18"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` diff --git a/scripts/migrate-demo-edge01.sh b/scripts/migrate-demo-edge01.sh index e91c66d92..5c9521e0e 100755 --- a/scripts/migrate-demo-edge01.sh +++ b/scripts/migrate-demo-edge01.sh @@ -339,7 +339,7 @@ readonly USERS_COLS="id, username, email, password_hash, role, created_at, updat glucose_unit, unit_preference, ai_provider, ai_model, ai_base_url, ai_anthropic_key_encrypted, ai_local_key_encrypted, ai_openai_key_encrypted, ai_provider_chain, - onboarding_completed_at, onboarding_step, onboarding_tour_completed, + onboarding_completed_at, onboarding_tour_completed, healthkit_config_json, healthkit_last_synced_at, display_name, last_report_practice_name, coach_prefs_json, source_priority_json, last_synced_at, mood_reminder_enabled, doctor_report_prefs_json, notification_prefs, @@ -379,7 +379,7 @@ readonly USERS_SELECT="id, username, email, password_hash, NULL::text AS ai_local_key_encrypted, NULL::text AS ai_openai_key_encrypted, ai_provider_chain, - onboarding_completed_at, onboarding_step, onboarding_tour_completed, + onboarding_completed_at, onboarding_tour_completed, healthkit_config_json, healthkit_last_synced_at, display_name, last_report_practice_name, coach_prefs_json, source_priority_json, last_synced_at, mood_reminder_enabled, doctor_report_prefs_json, notification_prefs, diff --git a/src/__tests__/i18n-english-leak-guard.test.ts b/src/__tests__/i18n-english-leak-guard.test.ts index 4c9f77b68..dc9d79fac 100644 --- a/src/__tests__/i18n-english-leak-guard.test.ts +++ b/src/__tests__/i18n-english-leak-guard.test.ts @@ -112,7 +112,6 @@ const LEGIT_IDENTICAL = new Set([ "measurements.sourceAppleHealth", "measurements.sourceGoogleHealth", "medications.sourceAppleHealth", - "onboarding.source.more.appleHealth", "settings.ai.providerChain.types.anthropic", "settings.ai.providerChain.types.codex", "settings.ai.providerSelect.anthropic", diff --git a/src/__tests__/proxy-demo-mode-allowlist.test.ts b/src/__tests__/proxy-demo-mode-allowlist.test.ts index bf43fe9d2..d51c61082 100644 --- a/src/__tests__/proxy-demo-mode-allowlist.test.ts +++ b/src/__tests__/proxy-demo-mode-allowlist.test.ts @@ -1,5 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { NextRequest } from "next/server"; +import { globSync, readFileSync } from "node:fs"; +import { join, sep } from "node:path"; /** * Demo-mode mutation allowlist. On a `DEMO_MODE=true` deploy the proxy @@ -73,7 +75,35 @@ describe("proxy.ts DEMO_MODE mutation allowlist", () => { expect(proxy(makeRequest("/api/onboarding/answers", "DELETE")).status).toBe( 403, ); - expect(proxy(makeRequest("/api/onboarding/step", "POST")).status).toBe(403); + expect(proxy(makeRequest("/api/onboarding/tour", "DELETE")).status).toBe( + 403, + ); + }); + + it("admits every mutating onboarding route, read off the route files", () => { + // v1.39 (C2) — the demo has to be able to walk the whole flow, and the + // flow's welcome and exit make writes the C1 allowlist did not carry: the + // disclaimer acknowledgment (both welcome buttons await it) and the tour + // checkpoint. A hand-kept list of three paths could not see the other + // two, so this reads the route files instead: every exported mutating + // handler under `src/app/api/onboarding` must pass the proxy. + const onboardingApi = join(process.cwd(), "src/app/api/onboarding"); + const routes = globSync("**/route.ts", { cwd: onboardingApi }) + .map((p) => p.split(sep).join("/")) + .sort(); + expect(routes.length, "the onboarding route files").toBeGreaterThan(3); + const missing: string[] = []; + for (const rel of routes) { + const source = readFileSync(join(onboardingApi, rel), "utf8"); + const path = `/api/onboarding/${rel.replace(/\/route\.ts$/, "")}`; + for (const method of ["POST", "PATCH", "PUT", "DELETE"] as const) { + if (!new RegExp(`export const ${method}\\b`).test(source)) continue; + if (proxy(makeRequest(path, method)).status === 403) { + missing.push(`${method} ${path}`); + } + } + } + expect(missing, "onboarding writes the demo cannot make").toEqual([]); }); it("still blocks a health-data mutation (POST /api/measurements)", () => { diff --git a/src/__tests__/session-surface-guard.test.ts b/src/__tests__/session-surface-guard.test.ts index dd822043e..855b7c650 100644 --- a/src/__tests__/session-surface-guard.test.ts +++ b/src/__tests__/session-surface-guard.test.ts @@ -129,11 +129,15 @@ describe("S1 — the direct session-resolution set is frozen", () => { "RSC prefetch of the session user's mood list, skipped under a switch", "app/page.tsx": "RSC prefetch of the session user's dashboard snapshot, skipped under a switch", - // Onboarding routing. Reads the actor's own onboarding step to redirect; - // renders no record data. + // Onboarding routing. Reads the actor's OWN setup state through + // `loadOnboardingFlowState` (a `getSession` wrapper, listed in the regex + // below) to guard the step URL and to render the screens; the three + // writes behind them refuse under a switch, so the pages read the same + // record the routes write. "app/onboarding/[step]/page.tsx": - "redirects on the actor's onboarding step", - "app/onboarding/page.tsx": "redirects on the actor's onboarding step", + "guards the step URL on the actor's own setup state", + "app/onboarding/page.tsx": + "resumes or welcomes on the actor's own setup state", }; /** @@ -147,10 +151,12 @@ describe("S1 — the direct session-resolution set is frozen", () => { * `getUnswitchedSession` (`lib/auth/acting-carrier.ts`) is listed for the * reason a wrapper is the easiest way to disappear from a guard: it returns * `getSession()`'s answer or null, so a caller of it derives an identity - * exactly as much as a caller of the thing it wraps. + * exactly as much as a caller of the thing it wraps. v1.39 (C2) — + * `loadOnboardingFlowState` (`lib/onboarding/load-flow-state.ts`) is the + * same shape: `getSession()` plus the caller's own setup row. */ const SESSION_DERIVING_HELPERS = - /\b(getSession|getUnswitchedSession|getSessionUserLocale|validateSessionFromCookieValue|validateSessionWithCreatedAt)\s*\(/; + /\b(getSession|getUnswitchedSession|getSessionUserLocale|validateSessionFromCookieValue|validateSessionWithCreatedAt|loadOnboardingFlowState)\s*\(/; /** * Reaching for the cookie by name is the way around the helpers. Both the diff --git a/src/__tests__/success-affordance-guard.test.ts b/src/__tests__/success-affordance-guard.test.ts index 6df0bd37e..f1c58ba81 100644 --- a/src/__tests__/success-affordance-guard.test.ts +++ b/src/__tests__/success-affordance-guard.test.ts @@ -275,7 +275,6 @@ const PINNED_AFFORDANCES: Record< "src/components/mood/mood-form.tsx": { "toast.success": 1 }, "src/components/mood/mood-list.tsx": { "toast.success": 3 }, "src/components/onboarding/done-screen.tsx": { CheckCircle2: 2 }, - "src/components/onboarding/source-card-grid.tsx": { CheckCircle2: 2 }, "src/components/records/allergy-form.tsx": { "toast.success": 1 }, "src/components/records/allergy-manager.tsx": { "toast.success": 1 }, "src/components/records/conditions-manager.tsx": { "toast.success": 1 }, diff --git a/src/app/api/onboarding/__tests__/step.test.ts b/src/app/api/onboarding/__tests__/step.test.ts deleted file mode 100644 index 5856891f4..000000000 --- a/src/app/api/onboarding/__tests__/step.test.ts +++ /dev/null @@ -1,399 +0,0 @@ -/** - * v1.4.25 W21 Fix-M — concurrency regression for - * `POST /api/onboarding/step`. - * - * The step write was fetch-then-update: two parallel tabs both - * reading `onboardingStep = 2` and both POSTing `step: 3` would both - * succeed because the second write didn't re-check the precondition. - * The route now claims the row via `updateMany` with the precondition - * embedded in the WHERE clause, so exactly one update lands and the - * loser sees `count = 0` and returns 409. - */ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { NextRequest } from "next/server"; - -vi.mock("@/lib/db", () => ({ - prisma: { - user: { - findUnique: vi.fn(), - findUniqueOrThrow: vi.fn(), - update: vi.fn(), - updateMany: vi.fn(), - }, - }, - toJson: (v: unknown) => v, -})); - -vi.mock("@/lib/auth/session", () => ({ - getSession: vi.fn(), - setOnboardingPendingCookie: vi.fn().mockResolvedValue(undefined), -})); - -vi.mock("@/lib/auth/audit", () => ({ - auditLog: vi.fn().mockResolvedValue(undefined), -})); - -vi.mock("@/lib/rate-limit", () => ({ - checkRateLimit: vi.fn().mockResolvedValue({ - allowed: true, - limit: 60, - remaining: 29, - resetAt: Date.now() + 60_000, - }), - rateLimitHeaders: vi.fn(() => ({})), -})); - -vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); - -vi.mock("@/lib/db-compat", () => ({ - ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), -})); - -vi.mock("next/headers", () => ({ - headers: vi.fn(async () => ({ get: () => null })), - cookies: vi.fn(async () => ({ - get: () => undefined, - set: () => {}, - delete: () => {}, - })), -})); - -import { POST } from "../step/route"; -import { prisma } from "@/lib/db"; -import { getSession } from "@/lib/auth/session"; -import { checkRateLimit } from "@/lib/rate-limit"; - -const SESSION_OK = { - session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, - user: { id: "user-1", username: "testuser", role: "USER" as const }, -}; - -function req(body: unknown): NextRequest { - return new NextRequest("http://localhost/api/onboarding/step", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(body), - }); -} - -beforeEach(() => { - vi.resetAllMocks(); - vi.mocked(checkRateLimit).mockResolvedValue({ - allowed: true, - limit: 60, - remaining: 29, - resetAt: Date.now() + 60_000, - }); -}); - -describe("POST /api/onboarding/step — happy path", () => { - it("advances 0 → 1 on the first wizard submission", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(prisma.user.findUnique).mockResolvedValue({ - onboardingStep: 0, - onboardingCompletedAt: null, - } as never); - vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never); - vi.mocked(prisma.user.findUniqueOrThrow).mockResolvedValue({ - onboardingStep: 1, - onboardingCompletedAt: null, - } as never); - - const res = await POST(req({ step: 1 })); - expect(res.status).toBe(200); - const body = (await res.json()) as { data: { step: number } }; - expect(body.data.step).toBe(1); - }); - - it("marks completion on step 4 (sets onboardingCompletedAt)", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(prisma.user.findUnique).mockResolvedValue({ - onboardingStep: 3, - onboardingCompletedAt: null, - } as never); - vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never); - vi.mocked(prisma.user.findUniqueOrThrow).mockResolvedValue({ - onboardingStep: 4, - onboardingCompletedAt: new Date("2026-05-14T12:00:00Z"), - } as never); - - const res = await POST(req({ step: 4 })); - expect(res.status).toBe(200); - const updateCall = vi.mocked(prisma.user.updateMany).mock.calls[0][0]; - expect(updateCall.data).toMatchObject({ - onboardingStep: 4, - }); - expect(updateCall.data).toHaveProperty("onboardingCompletedAt"); - }); -}); - -describe("POST /api/onboarding/step — guard rails", () => { - it("returns 401 when unauthenticated", async () => { - vi.mocked(getSession).mockResolvedValue(null); - const res = await POST(req({ step: 1 })); - expect(res.status).toBe(401); - }); - - it("returns 422 for an out-of-range step value", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - const res = await POST(req({ step: 99 })); - expect(res.status).toBe(422); - }); - - it("returns 409 when the user has already completed onboarding", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(prisma.user.findUnique).mockResolvedValue({ - onboardingStep: 4, - onboardingCompletedAt: new Date("2026-05-01T12:00:00Z"), - } as never); - - const res = await POST(req({ step: 1 })); - expect(res.status).toBe(409); - expect(prisma.user.updateMany).not.toHaveBeenCalled(); - }); - - it("returns 409 when the requested step is out of order", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(prisma.user.findUnique).mockResolvedValue({ - onboardingStep: 1, - onboardingCompletedAt: null, - } as never); - - // The route sees current = 1; submitting step 3 (skipping 2) must - // be rejected before the write. - const res = await POST(req({ step: 3 })); - expect(res.status).toBe(409); - expect(prisma.user.updateMany).not.toHaveBeenCalled(); - }); -}); - -describe("POST /api/onboarding/step — concurrent write race", () => { - it("returns 409 when the conditional update affects zero rows", async () => { - // Regression for Fix-M / code-M7: two parallel tabs both read - // `onboardingStep = 2`, both POST `step: 3`. The first lands; - // when the second runs the conditional updateMany, the WHERE - // clause no longer matches (the column moved to 3) and Prisma - // returns `count = 0`. The route must surface 409 so the client - // can refresh and retry instead of double-advancing the wizard. - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(prisma.user.findUnique).mockResolvedValue({ - onboardingStep: 2, - onboardingCompletedAt: null, - } as never); - vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 0 } as never); - - const res = await POST(req({ step: 3 })); - expect(res.status).toBe(409); - // findUniqueOrThrow must NOT be called when the claim fails — - // the route returns before re-reading the row. - expect(prisma.user.findUniqueOrThrow).not.toHaveBeenCalled(); - }); - - it("includes the precondition in the updateMany WHERE clause", async () => { - // The whole point of the conditional update is that the WHERE - // clause re-asserts the precondition we read. Pin the contract - // so a future refactor can't quietly drop the guard. - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(prisma.user.findUnique).mockResolvedValue({ - onboardingStep: 2, - onboardingCompletedAt: null, - } as never); - vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never); - vi.mocked(prisma.user.findUniqueOrThrow).mockResolvedValue({ - onboardingStep: 3, - onboardingCompletedAt: null, - } as never); - - await POST(req({ step: 3 })); - const whereArg = vi.mocked(prisma.user.updateMany).mock.calls[0][0] - .where as Record; - expect(whereArg).toMatchObject({ - id: "user-1", - onboardingCompletedAt: null, - }); - // Strict-equality precondition — Migration 0060 dropped the - // legacy nullable path so every advance uses `{ in: [current] }`. - expect(whereArg.onboardingStep).toEqual({ in: [2] }); - }); - - it("advances a step-0 row to step 1 using the strict-equality precondition", async () => { - // Migration 0060 (v1.4.25 W21 Fix-O) backfilled NULL → 0 and - // flipped `onboarding_step` to NOT NULL, so the conditional - // update always uses the strict-equality form — there is no - // legacy null branch left for the route to widen. - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(prisma.user.findUnique).mockResolvedValue({ - onboardingStep: 0, - onboardingCompletedAt: null, - } as never); - vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never); - vi.mocked(prisma.user.findUniqueOrThrow).mockResolvedValue({ - onboardingStep: 1, - onboardingCompletedAt: null, - } as never); - - const res = await POST(req({ step: 1 })); - expect(res.status).toBe(200); - const whereArg = vi.mocked(prisma.user.updateMany).mock.calls[0][0] - .where as Record; - expect(whereArg.onboardingStep).toEqual({ in: [0] }); - }); -}); - -describe("POST /api/onboarding/step — rate limit", () => { - it("returns 429 when the per-user rate limit is exceeded", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(checkRateLimit).mockResolvedValueOnce({ - allowed: false, - limit: 60, - remaining: 0, - resetAt: Date.now() + 60_000, - }); - const res = await POST(req({ step: 1 })); - expect(res.status).toBe(429); - }); -}); - -describe("POST /api/onboarding/step — goal persistence (v1.17.1)", () => { - it("persists the selected goal slugs on the step-2 submit", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(prisma.user.findUnique).mockResolvedValue({ - onboardingStep: 1, - onboardingCompletedAt: null, - } as never); - vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never); - vi.mocked(prisma.user.findUniqueOrThrow).mockResolvedValue({ - onboardingStep: 2, - onboardingCompletedAt: null, - } as never); - - const res = await POST( - req({ step: 2, goals: ["weight-management", "bp-tracking"] }), - ); - expect(res.status).toBe(200); - const data = vi.mocked(prisma.user.updateMany).mock.calls[0][0].data; - expect(data).toMatchObject({ - onboardingStep: 2, - onboardingGoals: ["weight-management", "bp-tracking"], - }); - }); - - it("persists an explicit empty array on skip (no preference)", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(prisma.user.findUnique).mockResolvedValue({ - onboardingStep: 1, - onboardingCompletedAt: null, - } as never); - vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never); - vi.mocked(prisma.user.findUniqueOrThrow).mockResolvedValue({ - onboardingStep: 2, - onboardingCompletedAt: null, - } as never); - - await POST(req({ step: 2, goals: [] })); - const data = vi.mocked(prisma.user.updateMany).mock.calls[0][0].data; - expect(data).toMatchObject({ onboardingGoals: [] }); - }); - - it("rejects an unknown goal slug with 422", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - const res = await POST(req({ step: 2, goals: ["not-a-real-goal"] })); - expect(res.status).toBe(422); - expect(prisma.user.updateMany).not.toHaveBeenCalled(); - }); - - it("does NOT write onboardingGoals when the field is omitted", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(prisma.user.findUnique).mockResolvedValue({ - onboardingStep: 1, - onboardingCompletedAt: null, - } as never); - vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never); - vi.mocked(prisma.user.findUniqueOrThrow).mockResolvedValue({ - onboardingStep: 2, - onboardingCompletedAt: null, - } as never); - - await POST(req({ step: 2 })); - const data = vi.mocked(prisma.user.updateMany).mock.calls[0][0].data; - expect(data).not.toHaveProperty("onboardingGoals"); - }); -}); - -describe("POST /api/onboarding/step — dashboard seeding on completion", () => { - it("seeds the dashboard from stored goals when dashboardWidgetsJson is null", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - // 1st findUnique = the fresh step-read; 2nd = the seed-read. - vi.mocked(prisma.user.findUnique) - .mockResolvedValueOnce({ - onboardingStep: 3, - onboardingCompletedAt: null, - } as never) - .mockResolvedValueOnce({ - onboardingGoals: ["glucose-tracking"], - dashboardWidgetsJson: null, - } as never); - // 1st updateMany = the step claim; 2nd = the gated seed write. - vi.mocked(prisma.user.updateMany) - .mockResolvedValueOnce({ count: 1 } as never) - .mockResolvedValueOnce({ count: 1 } as never); - vi.mocked(prisma.user.findUniqueOrThrow).mockResolvedValue({ - onboardingStep: 4, - onboardingCompletedAt: new Date("2026-06-14T12:00:00Z"), - } as never); - - const res = await POST(req({ step: 4 })); - expect(res.status).toBe(200); - // The second updateMany is the seed; its WHERE pins the null gate - // and its data carries a dashboard layout. - expect(prisma.user.updateMany).toHaveBeenCalledTimes(2); - const seedCall = vi.mocked(prisma.user.updateMany).mock.calls[1][0]; - expect(seedCall.data).toHaveProperty("dashboardWidgetsJson"); - expect(seedCall.where).toHaveProperty("dashboardWidgetsJson"); - }); - - it("does NOT seed when dashboardWidgetsJson is already set (gate holds)", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(prisma.user.findUnique) - .mockResolvedValueOnce({ - onboardingStep: 3, - onboardingCompletedAt: null, - } as never) - .mockResolvedValueOnce({ - onboardingGoals: ["glucose-tracking"], - dashboardWidgetsJson: { version: 1, widgets: [] }, - } as never); - vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never); - vi.mocked(prisma.user.findUniqueOrThrow).mockResolvedValue({ - onboardingStep: 4, - onboardingCompletedAt: new Date("2026-06-14T12:00:00Z"), - } as never); - - const res = await POST(req({ step: 4 })); - expect(res.status).toBe(200); - // Only the step-claim updateMany fired — the seed was gated out - // because the user already has a layout. - expect(prisma.user.updateMany).toHaveBeenCalledTimes(1); - }); - - it("does NOT seed when stored goals are empty (no preference)", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(prisma.user.findUnique) - .mockResolvedValueOnce({ - onboardingStep: 3, - onboardingCompletedAt: null, - } as never) - .mockResolvedValueOnce({ - onboardingGoals: [], - dashboardWidgetsJson: null, - } as never); - vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never); - vi.mocked(prisma.user.findUniqueOrThrow).mockResolvedValue({ - onboardingStep: 4, - onboardingCompletedAt: new Date("2026-06-14T12:00:00Z"), - } as never); - - await POST(req({ step: 4 })); - expect(prisma.user.updateMany).toHaveBeenCalledTimes(1); - }); -}); diff --git a/src/app/api/onboarding/complete/route.ts b/src/app/api/onboarding/complete/route.ts index 1a6d0e699..ebb1b82d0 100644 --- a/src/app/api/onboarding/complete/route.ts +++ b/src/app/api/onboarding/complete/route.ts @@ -1,7 +1,9 @@ import { prisma, toJson } from "@/lib/db"; +import { Prisma } from "@/generated/prisma/client"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { + apiError, apiSuccess, apiValidationError, getClientIp, @@ -17,6 +19,7 @@ import { mergeDerivedModulePreferences, OWNED_MODULE_KEYS, } from "@/lib/modules/registry"; +import { buildNeedsSeededDashboardLayout } from "@/lib/onboarding/dashboard-seed"; import { everyOnboardingQuestionSettled, readHeldUnitPreferences, @@ -28,6 +31,7 @@ import { readOnboardingRecordState, toOnboardingStateDto, } from "@/lib/onboarding/needs-store"; +import { readManagedProfileForGuardian } from "@/lib/managed-profiles/lifecycle"; import { writeRecordModulePreferences } from "@/lib/record-settings/modules"; import { NextRequest } from "next/server"; import { z } from "zod/v4"; @@ -37,22 +41,35 @@ import { onboardingCompleteSchema } from "@/lib/validations/onboarding"; * Complete the onboarding flow. Saves optional profile data and marks * onboarding as completed. * - * v1.39 (C1) — this is also the confirm screen's endpoint for the needs-based - * flow, and the two live side by side rather than one replacing the other. - * The legacy half is unchanged: the optional profile fields, the completion - * stamp on `User.onboardingCompletedAt`, and the cleared pending cookie. The - * needs half runs only for a record that actually FINISHED the questions — - * every one of them answered or deliberately passed — and is what turns them - * into a module - * map — ONCE, guarded by `OnboardingRecord.modulesDerivedAt`, because a - * re-derivation would re-apply the questionnaire over decisions taken in - * Settings since. `POST /api/onboarding/restart` clears that marker when the - * person asks for the questions again. + * v1.39 — the confirm screen's endpoint for the needs-based flow, and the + * one completion write there is. The stamp half is what it always was: the + * optional profile fields, the completion stamp on + * `User.onboardingCompletedAt`, and the cleared pending cookie — the welcome + * screen's "skip for now" is this route with an empty body. The needs half + * runs only for a record that actually FINISHED the questions — every one + * of them answered or deliberately passed — and is what turns them into a + * module map and a dashboard order — ONCE, guarded by + * `OnboardingRecord.modulesDerivedAt`, because a re-derivation would + * re-apply the questionnaire over decisions taken in Settings since. `POST + * /api/onboarding/restart` clears that marker when the person asks for the + * questions again. * - * The goal slugs of today (`src/lib/onboarding/goals.ts`, the four-step wizard - * at `POST /api/onboarding/step`) are untouched and stay live until C2 - * replaces that surface. They decide TILE ORDER; the answers below decide - * WHICH MODULES ARE ON. Neither reads the other. + * v1.39 (C2) — the dashboard order joins the derivation. The five-step + * wizard's goal slugs used to seed it from their own route; the answers now + * carry both halves of Q2 — which modules are on, and what is on top — and + * the seed keeps the wizard's one contract: only while the layout column is + * still unset. + * + * `managedRecordId` (C2) — "someone I look after". The answers were given + * FOR the profile the confirm screen just created, so the derivation and the + * seed land on THAT record: the same access rule the guardian's own + * `PATCH /api/record-settings/modules` applies (an active guardian grant on a + * managed record), the same `writeRecordModulePreferences`, the same audit + * event. The caller's own record is stamped complete and its derivation + * marker set — latched, so a later confirm cannot derive the child's answers + * onto the guardian — and its module map is not touched. A guardian's own + * dashboard and modules are theirs; the child's answers say nothing about + * them. */ export const POST = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); @@ -98,6 +115,15 @@ export const POST = apiHandler(async (request: NextRequest) => { data.displayName = result.data.displayName; } + // The managed arm is checked BEFORE anything is stamped: a refused record + // must leave the flow exactly where it was, so the person can fix the + // answer or the profile and confirm again. + const managedRecordId = result.data.managedRecordId ?? null; + if (managedRecordId !== null) { + const refusal = await refuseManagedRecord(user.id, managedRecordId); + if (refusal) return refusal; + } + await prisma.user.update({ where: { id: user.id }, data, @@ -111,11 +137,57 @@ export const POST = apiHandler(async (request: NextRequest) => { user.id, readHeldUnitPreferences(user), request, + managedRecordId, ); return apiSuccess({ completed: true, ...(onboarding ? { onboarding } : {}) }); }); +/** + * Whether `managedRecordId` may receive this caller's answers: the record + * must be a managed profile the caller actively guards, and the answer to Q1 + * must be "someone-else" — the one answer that says the questions were about + * another person. A `null` means proceed. + */ +async function refuseManagedRecord( + userId: string, + managedRecordId: string, +): Promise { + const guarded = await readManagedProfileForGuardian({ + profileId: managedRecordId, + guardianId: userId, + }); + if (!guarded) { + // 404 for every case at once — missing, not a managed profile, not + // guarded by the caller — as the id-addressed managed-profile route + // answers (`managed_profile.not_found`): one answer, no existence oracle. + annotate({ + action: { name: "onboarding.needs.complete" }, + meta: { outcome: "record_not_found" }, + }); + return apiError("Managed record not found", 404, { + errorCode: "onboarding.complete.recordNotFound", + }); + } + const record = await prisma.onboardingRecord.findUnique({ + where: { userId }, + select: ONBOARDING_RECORD_SELECT, + }); + const state = readOnboardingRecordState(record); + if (state.needs.recordTarget !== "someone-else") { + annotate({ + action: { name: "onboarding.needs.complete" }, + meta: { outcome: "record_target_mismatch" }, + }); + return apiError( + "The answers were not given for somebody else's record", + 422, + { errorCode: "onboarding.complete.recordTargetMismatch" }, + ); + } + return null; +} + /** * The needs half: derive the module map once, mark the confirm step, stamp the * flow's own completion. @@ -128,6 +200,7 @@ async function completeNeedsFlow( userId: string, held: HeldUnitPreferences, request: NextRequest, + managedRecordId: string | null, ) { const record = await prisma.onboardingRecord.findUnique({ where: { userId }, @@ -165,9 +238,23 @@ async function completeNeedsFlow( return toOnboardingStateDto(record, held); } + const now = new Date(); let derived = false; + let dashboardSeeded = false; let keptForData: string[] = []; - if (record.modulesDerivedAt === null) { + // "Someone I look after" with no profile to apply the answers to: the + // person declined (or could not, without a second factor) to create it on + // the confirm screen. There is no record the answers describe yet, and + // they must not describe the guardian's — so the flow is stamped complete + // and LATCHED without deriving or seeding anything. The profile created + // later under Settings starts from the defaults. Enforced here, on the + // server, whatever the client sent. + const answersForNobody = + state.needs.recordTarget === "someone-else" && managedRecordId === null; + if (record.modulesDerivedAt === null && !answersForNobody) { + // The record the answers are about: the managed profile for "someone I + // look after" (verified above), otherwise the caller's own. + const targetId = managedRecordId ?? userId; const defaults = deriveOnboardingModuleDefaults({ recordTarget: state.needs.recordTarget, areas: state.needs.areas, @@ -179,7 +266,7 @@ async function completeNeedsFlow( }); const row = await prisma.user.findUnique({ - where: { id: userId }, + where: { id: targetId }, select: { modulePreferencesJson: true }, }); // Only the modules the answers would switch OFF are worth asking about, @@ -191,7 +278,7 @@ async function completeNeedsFlow( ); const holdsData = await modulesHoldingRecordData( prisma, - userId, + targetId, wouldSwitchOff, ); const merged = mergeDerivedModulePreferences( @@ -207,7 +294,7 @@ async function completeNeedsFlow( // unanswered cycle from recorded sex, and an untick is not a request to // retract that. await writeRecordModulePreferences({ - recordId: userId, + recordId: targetId, modulePreferences: merged, ...(defaults.cycleTracking ? { cycleTrackingEnabled: true } : {}), }); @@ -223,16 +310,74 @@ async function completeNeedsFlow( ...(defaults.cycleTracking ? ["cycleTrackingEnabled"] : []), ]; await auditLog("user.modules.update", { - userId, + userId: targetId, ipAddress: getClientIp(request), - details: { changed, keptForData: [...holdsData], source: "onboarding" }, + details: { + changed, + keptForData: [...holdsData], + source: "onboarding", + ...(managedRecordId ? { guardianId: userId } : {}), + }, }); keptForData = [...holdsData]; derived = true; + + // The ordering half of Q2. ONE-TIME and conditional: `updateMany` carries + // the `dashboardWidgetsJson IS NULL` precondition in its WHERE, so a + // layout somebody already arranged — or a concurrent layout save that + // lands first — leaves `count = 0` and the seed is skipped rather than + // overwriting it. An answer set that speaks to no tile builds `null` and + // writes nothing, which leaves the default layout in place. + // `AnyNull`, not `JsonNull`: an unset column is a SQL NULL, which + // `JsonNull` (a JSON `null` value) does not match — the retired step route + // carried that filter and its seed never landed on a fresh account. + const seededLayout = buildNeedsSeededDashboardLayout(state.needs); + if (seededLayout) { + const seeded = await prisma.user.updateMany({ + where: { + id: targetId, + dashboardWidgetsJson: { equals: Prisma.AnyNull }, + }, + data: { dashboardWidgetsJson: toJson(seededLayout) }, + }); + dashboardSeeded = seeded.count === 1; + } + + // The managed record's own setup row: the answers it was configured + // from, every step settled, both stamps set. Its account payload then + // reads a finished setup rather than nine pending steps, and a guardian + // who opens the questions from inside that record later is offered a + // re-run, not a first run. + if (managedRecordId) { + // `needsJson` is the guardian's answers as given, `recordTarget` + // "someone-else" included: from inside the child's record that reads + // as "for somebody else", which is true of how it was set up. Nothing + // acts on it there — the three writes refuse under a switch — so it is + // kept as the honest record of the flow that configured the row. + const settled = steps.map((step) => ({ + ...step, + status: "done" as const, + })); + await prisma.onboardingRecord.upsert({ + where: { userId: managedRecordId }, + create: { + userId: managedRecordId, + needsJson: toJson(state.needs), + stepsJson: toJson(settled), + completedAt: now, + modulesDerivedAt: now, + }, + update: { + needsJson: toJson(state.needs), + stepsJson: toJson(settled), + completedAt: now, + modulesDerivedAt: now, + }, + }); + } } - const now = new Date(); const confirmed = steps.map((step) => step.id === "confirm" && step.status === "pending" ? { ...step, status: "done" as const } @@ -246,7 +391,9 @@ async function completeNeedsFlow( // or a replay of the confirm quietly moves the instant the setup // finished at. completedAt: record.completedAt ?? now, - ...(derived ? { modulesDerivedAt: now } : {}), + // Latched on a derivation, and on the no-record arm as well: a later + // confirm must not apply the child's answers to the guardian either. + ...(derived || answersForNobody ? { modulesDerivedAt: now } : {}), }, select: ONBOARDING_RECORD_SELECT, }); @@ -254,8 +401,14 @@ async function completeNeedsFlow( annotate({ action: { name: "onboarding.needs.complete" }, meta: { - outcome: derived ? "derived" : "already_derived", + outcome: answersForNobody + ? "no_record_for_answers" + : derived + ? "derived" + : "already_derived", keptForData: keptForData.length, + dashboard_seeded: dashboardSeeded, + ...(managedRecordId ? { target: "managed_record" } : {}), }, }); diff --git a/src/app/api/onboarding/restart/route.ts b/src/app/api/onboarding/restart/route.ts index a64a23914..1f6e44062 100644 --- a/src/app/api/onboarding/restart/route.ts +++ b/src/app/api/onboarding/restart/route.ts @@ -17,9 +17,13 @@ * - clear `User.onboardingCompletedAt`. That column gates the first-run * redirect in `src/proxy.ts`; clearing it would drop the person back into * the wizard of today rather than into the questions they asked for. - * - forget a first result that really happened. The task produced a reading, - * a medication or a connection; a re-run of the questions does not unmake - * it. + * + * What it DOES clear, since v1.39 (C2): the first result. The reading, the + * medication or the connection the task produced stays where it landed — the + * ledger entry is only the record of the OFFER. Kept, it painted the re-run's + * first-result screen as already done, "Next" then skipped the write, and the + * step stayed pending: the flow resumed there forever and the checklist never + * went away. A re-run offers a task again and records what it produces again. * * `requireAuth()` for the reason the answers route gives: the caller's own * record, refused outright under an acting-account switch. @@ -36,6 +40,7 @@ import { } from "@/lib/api-response"; import { auditLog } from "@/lib/auth/audit"; import { prisma, toJson } from "@/lib/db"; +import { Prisma } from "@/generated/prisma/client"; import { annotate } from "@/lib/logging/context"; import { defaultOnboardingSteps, @@ -93,6 +98,7 @@ export const POST = apiHandler(async (request: NextRequest) => { stepsJson: toJson(steps), completedAt: null, modulesDerivedAt: null, + firstResultJson: Prisma.DbNull, }, select: ONBOARDING_RECORD_SELECT, }); diff --git a/src/app/api/onboarding/step/route.ts b/src/app/api/onboarding/step/route.ts deleted file mode 100644 index c21dc2cc8..000000000 --- a/src/app/api/onboarding/step/route.ts +++ /dev/null @@ -1,244 +0,0 @@ -import type { NextRequest } from "next/server"; - -import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { - apiError, - apiSuccess, - apiValidationError, - getClientIp, - safeJson, - sanitiseZodIssues, -} from "@/lib/api-response"; -import { auditLog } from "@/lib/auth/audit"; -import { setOnboardingPendingCookie } from "@/lib/auth/session"; -import { prisma, toJson } from "@/lib/db"; -import { Prisma } from "@/generated/prisma/client"; -import { annotate } from "@/lib/logging/context"; -import { checkRateLimit } from "@/lib/rate-limit"; -import { buildGoalSeededDashboardLayout } from "@/lib/onboarding/goals"; -import { onboardingStepSchema } from "@/lib/validations/onboarding"; - -/** - * v1.4.25 W14b — POST /api/onboarding/step. - * - * Persists the user's progress through the rebuilt - * `/onboarding/[step]` wizard. Body: `{ step: number }` where `step` - * is 1..4. The endpoint enforces a strict step-by-step contract: - * - * - Submitted step must equal `current + 1` (no skipping ahead). - * - Submitting step 4 marks completion — `onboardingCompletedAt` - * flips from null to NOW() in the same write and the - * `hl_onboarding` proxy cookie is cleared. - * - Already-completed users (`onboardingCompletedAt != null`) - * receive a 409. Replays must call this endpoint only while the - * wizard is in-progress. - * - * Companion to: - * - `GET ` not implemented (the User row is already loaded into - * every server-rendered step page). - * - `POST /api/onboarding/complete` remains the legacy v1.4.20 - * completion path used by the old single-file wizard; the new - * flow uses this endpoint with `step: 4` instead. - * - * Rate limit: 30 writes / 10 min / user — generous for a 4-step flow - * but tight enough to defang a stuck retry loop. - */ - -// v1.17.1 — the optional goal slugs from the GoalsChipPicker (step 2 submit) -// ride the same body. Validated against the closed slug enum so an unknown -// slug 422s; persisted to `User.onboardingGoals` field-by-field (no mass -// assignment) and, on completion (step 4), used to seed the dashboard layout -// when the user never customized it. Omitted leaves the stored goals -// untouched. The schema itself lives in `@/lib/validations/onboarding` so the -// OpenAPI registry reads the same object this parse does. - -export const POST = apiHandler(async (request: NextRequest) => { - const { user } = await requireAuth(); - annotate({ action: { name: "onboarding.step" } }); - - const rl = await checkRateLimit( - `onboarding-step:${user.id}`, - 30, - 10 * 60 * 1000, - ); - if (!rl.allowed) { - annotate({ - action: { name: "onboarding.step" }, - meta: { outcome: "rate_limited" }, - }); - return apiError("Too many onboarding writes, try again later", 429, { - errorCode: "onboarding.step.rateLimited", - }); - } - - const { data: body, error: jsonError } = await safeJson(request, { - maxBytes: 64 * 1024, - }); - if (jsonError) return jsonError; - - const parsed = onboardingStepSchema.safeParse(body); - if (!parsed.success) { - annotate({ - action: { name: "onboarding.step" }, - meta: { outcome: "validation_failed" }, - }); - return apiValidationError( - "Invalid step payload", - sanitiseZodIssues(parsed.error.issues), - 422, - { - errorCode: "onboarding.step.invalid", - }, - ); - } - const { step, goals } = parsed.data; - - // Re-fetch the fresh User row so concurrent step submissions in - // separate tabs can't race past each other — the session.user - // snapshot might be a request-old. - const fresh = await prisma.user.findUnique({ - where: { id: user.id }, - select: { - onboardingStep: true, - onboardingCompletedAt: true, - }, - }); - if (!fresh) { - return apiError("User not found", 404, { - errorCode: "onboarding.user.notFound", - }); - } - - if (fresh.onboardingCompletedAt) { - annotate({ - action: { name: "onboarding.step" }, - meta: { outcome: "already_completed" }, - }); - return apiError("Onboarding already completed", 409, { - errorCode: "onboarding.step.completed", - }); - } - - const current = fresh.onboardingStep; - if (step !== current + 1) { - annotate({ - action: { name: "onboarding.step" }, - meta: { outcome: "out_of_order", current, requested: step }, - }); - return apiError(`Step out of order — current step is ${current}`, 409, { - errorCode: "onboarding.step.outOfOrder", - }); - } - - const completing = step === 4; - - // Conditional update — the WHERE clause re-asserts the - // precondition we read above so the write can only land on a row - // whose state still matches what we validated. Two parallel tabs - // both reading `onboardingStep = 2` and both POSTing `step: 3` - // would otherwise both succeed (the read-then-write race); with - // this guard exactly one update sees `count = 1`, the second sees - // `count = 0` and returns 409. `onboardingCompletedAt: null` also - // catches a race with `POST /api/onboarding/complete`. Migration - // 0060 (v1.4.25 W21) backfilled NULL → 0 and flipped the column - // to NOT NULL, so the legacy null branch this code carried before - // is dropped — every row now matches the strict-equality form. - const claimed = await prisma.user.updateMany({ - where: { - id: user.id, - onboardingCompletedAt: null, - onboardingStep: { in: [current] }, - }, - // Field-by-field assembly — no mass assignment. `goals` only - // lands when the client sent it (the step-2 submit); it is already - // validated against the closed slug enum by the Zod schema, so the - // array reaching Prisma can never contain an out-of-set value. - data: { - onboardingStep: step, - ...(completing ? { onboardingCompletedAt: new Date() } : {}), - ...(goals !== undefined ? { onboardingGoals: goals } : {}), - }, - }); - if (claimed.count !== 1) { - annotate({ - action: { name: "onboarding.step" }, - meta: { outcome: "concurrent_write", current, requested: step }, - }); - return apiError( - "Onboarding step changed concurrently, refresh and retry", - 409, - { errorCode: "onboarding.step.concurrent" }, - ); - } - const updated = await prisma.user.findUniqueOrThrow({ - where: { id: user.id }, - select: { - onboardingStep: true, - onboardingCompletedAt: true, - }, - }); - - let goalsSeeded = false; - if (completing) { - // Mirror /api/onboarding/complete — clear the proxy-readable - // pending cookie so the next navigation drops the /onboarding - // redirect immediately. - await setOnboardingPendingCookie(false); - - // v1.17.1 — seed the dashboard from the stored goal selection. - // ONE-TIME, gated on `dashboardWidgetsJson == null` so a user who - // already arranged tiles is never clobbered. The seed promotes the - // goal-mapped tiles to the top and forces them visible; an empty / - // general-wellness-only selection produces null and leaves the - // column untouched (default layout). The resulting layout is what - // both the web dashboard and the iOS widgets contract read — - // server-authoritative, no client recompute. - const seedRow = await prisma.user.findUnique({ - where: { id: user.id }, - select: { onboardingGoals: true, dashboardWidgetsJson: true }, - }); - if (seedRow && seedRow.dashboardWidgetsJson == null) { - const seededLayout = buildGoalSeededDashboardLayout( - seedRow.onboardingGoals, - ); - if (seededLayout) { - // updateMany so the `dashboardWidgetsJson: null` precondition - // rides in the WHERE clause — a concurrent Settings → Dashboard - // save that lands first leaves `count = 0` and the seed is - // skipped rather than overwriting the user's fresh layout. - const seeded = await prisma.user.updateMany({ - where: { - id: user.id, - dashboardWidgetsJson: { equals: Prisma.JsonNull }, - }, - data: { dashboardWidgetsJson: toJson(seededLayout) }, - }); - goalsSeeded = seeded.count === 1; - } - } - } - - await auditLog("onboarding.step", { - userId: user.id, - ipAddress: getClientIp(request), - details: { - step, - completed: completing, - }, - }); - - annotate({ - action: { name: "onboarding.step" }, - meta: { - outcome: completing ? "completed" : "advanced", - step, - ...(goals !== undefined ? { goals_count: goals.length } : {}), - ...(completing ? { goals_seeded: goalsSeeded } : {}), - }, - }); - - return apiSuccess({ - step: updated.onboardingStep, - onboardingCompletedAt: updated.onboardingCompletedAt, - }); -}); diff --git a/src/app/onboarding/[step]/page.tsx b/src/app/onboarding/[step]/page.tsx index 14b825d5d..73f4e8efe 100644 --- a/src/app/onboarding/[step]/page.tsx +++ b/src/app/onboarding/[step]/page.tsx @@ -1,182 +1,111 @@ -import { redirect, notFound } from "next/navigation"; -import Link from "next/link"; +import type { Metadata } from "next"; +import { notFound, redirect } from "next/navigation"; import { OnboardingShell } from "@/components/onboarding/onboarding-shell"; -import { WelcomeCarousel } from "@/components/onboarding/welcome-carousel"; -import { GoalsChipPicker } from "@/components/onboarding/goals-chip-picker"; -import { SourceCardGrid } from "@/components/onboarding/source-card-grid"; -import { BaselineForm } from "@/components/onboarding/baseline-form"; +import { ConfirmScreen } from "@/components/onboarding/confirm-screen"; import { DoneScreen } from "@/components/onboarding/done-screen"; -import { Button } from "@/components/ui/button"; -import { getSession } from "@/lib/auth/session"; +import { FirstResultScreen } from "@/components/onboarding/first-result-screen"; +import { QuestionScreen } from "@/components/onboarding/question-screen"; +import { UnitsScreen } from "@/components/onboarding/units-screen"; import { getServerTranslator } from "@/lib/i18n/server-translator"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; +import { loadOnboardingFlowState } from "@/lib/onboarding/load-flow-state"; +import { + ONBOARDING_STEP_IDS, + type OnboardingStateDto, + type OnboardingStepId, +} from "@/lib/onboarding/needs"; +import { isQuestionStepId } from "@/lib/onboarding/question-config"; +import { + canVisitScreen, + resumeScreen, + stepCounter, +} from "@/lib/onboarding/wizard-steps"; /** - * Onboarding wizard step page. + * v1.39 (C2) — one setup screen per step id. * - * Routes: - * /onboarding/0 welcome → carousel / value-prop intro - * /onboarding/1 goals → "what do you want to track?" - * /onboarding/2 source → device/import source cards (see - * source-card-grid.tsx for the shipped set) - * /onboarding/3 baseline → first measurement or sync confirmation - * /onboarding/4 done → success screen + return to dashboard + * /onboarding/who … /onboarding/visit the five questions + * /onboarding/units Q6, when it applies + * /onboarding/confirm the module map and the profile + * /onboarding/first-result the one task the flow ends on + * /onboarding/done the exit * - * Step gating: - * - Unauthenticated → `/auth/login` (the proxy also enforces this, - * but the server-side check keeps the contract explicit). - * - `onboardingCompletedAt != null` → `/` (no replaying the wizard - * by URL once the user has finished). - * - Out-of-order requests (e.g. user lands on `/onboarding/3` while - * `onboardingStep == 1`) redirect to the user's current step. The - * "current step" is `User.onboardingStep ?? 0`. + * The URL is guarded by the step machine: going back is always allowed, + * jumping ahead bounces to the screen the flow actually owes, and a screen + * this flow does not contain (units for someone with no unit-bearing area) + * bounces the same way. Everything lives under `/onboarding/`, which is what + * the proxy's first-run redirect lets through — a segment outside it would + * loop. * - * The user MAY navigate backwards (e.g. `/onboarding/0` while on step - * 2). Backwards navigation is non-destructive — the shell's "Back" - * button uses it. Only forward jumps are blocked. + * Server component: the state is loaded once per request (`cache`), the + * screens that hold a selection are the client parts. */ - -const VALID_STEPS = [0, 1, 2, 3, 4] as const; -type Step = (typeof VALID_STEPS)[number]; - export const dynamicParams = false; export function generateStaticParams() { - return VALID_STEPS.map((step) => ({ step: String(step) })); + return ONBOARDING_STEP_IDS.map((step) => ({ step })); } interface PageProps { params: Promise<{ step: string }>; } -export default async function OnboardingStepPage({ params }: PageProps) { - const { step: stepParam } = await params; - const parsedStep = Number.parseInt(stepParam, 10); +function asStepId(value: string): OnboardingStepId | null { + return (ONBOARDING_STEP_IDS as readonly string[]).includes(value) + ? (value as OnboardingStepId) + : null; +} - if ( - !Number.isFinite(parsedStep) || - !VALID_STEPS.includes(parsedStep as Step) - ) { - notFound(); - } - const requested = parsedStep as Step; - - const session = await getSession(); - if (!session) { - redirect("/auth/login"); - } - const { user } = session; - - const completed = user.onboardingCompletedAt != null; - - // Completed users hitting any mid-flow step (1, 2, 3) are bounced - // back to the dashboard — replaying half the wizard is never useful. - // Step 0 stays accessible as the welcome-back surface (banner instead - // of carousel) and step 4 stays accessible as the success screen so - // a re-landing user has a clean "Open dashboard" exit. - if (completed && requested > 0 && requested < 4) { - redirect("/"); - } - - const current = clampCurrentStep(user.onboardingStep); - if (!completed && requested > current) { - redirect(`/onboarding/${current}`); - } - - const locale = await resolveServerLocale({ userLocale: user.locale }); +export async function generateMetadata({ + params, +}: PageProps): Promise { + const { step } = await params; + const screen = asStepId(step); + const flow = screen ? await loadOnboardingFlowState() : null; + if (!screen || !flow) return {}; + const locale = await resolveServerLocale({ userLocale: flow.userLocale }); const { t } = getServerTranslator(locale); + const counter = stepCounter(flow.state, screen); + const name = t(`onboarding.steps.${screen}`); + return { + title: counter ? `${t("onboarding.shell.stepOf", counter)}: ${name}` : name, + }; +} - // Welcome (step 0) - // * Fresh user → value-prop carousel. - // * Returning user (onboarding already completed) → welcome-back - // banner with "Open dashboard" CTA. The maintainer note 2026-05-14: the - // "restart onboarding" affordance lives in Settings → Account in - // v1.4.26; for now the banner is informational only. - if (requested === 0) { - return ( - - {completed ? ( -
- {/* Onboarding hero H1: intentionally semibold, not the app-wide bold PageHeader H1 (UI-STANDARDS §5 hero exception). Do not sweep to font-bold. */} -

- {t("onboarding.welcomeBack.title")} -

-

- {t("onboarding.welcomeBack.body")} -

-
- -
-
- ) : ( - - )} -
- ); +export default async function OnboardingStepPage({ params }: PageProps) { + const { step } = await params; + const screen = asStepId(step); + if (!screen) notFound(); + + const flow = await loadOnboardingFlowState(); + if (!flow) redirect("/auth/login"); + const { state, userLocale } = flow; + + if (!canVisitScreen(state, screen)) { + const resume = resumeScreen(state); + redirect(resume === "welcome" ? "/onboarding" : `/onboarding/${resume}`); } - // Goals (step 1) — multi-select chip grid. Component owns its own - // Back/Skip/Next row so the shell drops every footer href to avoid - // duplicate controls. The user id is threaded as a prop so the - // client hydration reads localStorage synchronously in its state - // initializer (avoids the setState-in-effect anti-pattern). - if (requested === 1) { - return ( - - - - ); - } - - // Source (step 2) — card grid. Manual is the implicit default; the - // connectable sources (Apple Health, WHOOP, Oura, Polar, Nightscout, - // Fitbit/Pixel via Google Health) live in `source-card-grid.tsx`'s - // SOURCE_CARDS / MORE_SOURCES lists — refer to that component for the - // current shipped set rather than duplicating it here. The selection - // is non-binding: nothing here auto-configures a sync. - if (requested === 2) { - return ( - - - - ); - } - - // Baseline (step 3) — profile form. PUT /api/auth/profile saves the - // four fields, then POST step:4 advances + flips - // `onboardingCompletedAt`. - if (requested === 3) { - return ( - - - - ); - } - - // Done (step 4) — success screen + dashboard CTA. Reachable both - // after first-completion (POST step:4) and again if a completed - // user lands here by URL. return ( - - + + {renderScreen(screen, state)} ); } -function clampCurrentStep(value: number | null | undefined): Step { - if (value == null || !Number.isFinite(value)) return 0; - const floor = Math.floor(value); - if (floor <= 0) return 0; - if (floor >= 4) return 4; - return floor as Step; +function renderScreen(screen: OnboardingStepId, state: OnboardingStateDto) { + if (isQuestionStepId(screen)) { + return ; + } + if (screen === "units") { + return ; + } + if (screen === "confirm") { + return ; + } + if (screen === "first-result") { + return ; + } + return ; } diff --git a/src/app/onboarding/__tests__/page.test.tsx b/src/app/onboarding/__tests__/page.test.tsx index 1a43f2256..64e546bd2 100644 --- a/src/app/onboarding/__tests__/page.test.tsx +++ b/src/app/onboarding/__tests__/page.test.tsx @@ -1,19 +1,24 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { ReactElement } from "react"; + +import { + defaultOnboardingSteps, + emptyOnboardingNeeds, + type OnboardingStateDto, + type OnboardingStepId, + type OnboardingStepStatus, +} from "@/lib/onboarding/needs"; /** - * v1.4.25 W14b-Content — onboarding root redirect tests. + * v1.39 (C2) — the setup flow's front door. * - * The v1.4.20 single-file wizard was deleted in this commit; the root - * `/onboarding` page is now a server-side redirect into the new - * `/onboarding/[step]` flow. These tests cover the three redirect - * branches: missing session, mid-flow user, completed user. + * Three ways in, decided by the step machine: a record that never entered + * the flow sees the welcome screen, a record mid-way is sent to the step it + * owes, and a finished record sees the welcome's "again" variant. No session + * mirrors the proxy's gate. */ const redirectMock = vi.fn((href: string) => { - // next/navigation `redirect()` throws a special sentinel inside the - // server-component renderer to short-circuit rendering. Mimic that - // by throwing an error tagged with the href so each test can - // unambiguously assert the redirect target. const err = new Error(`__redirect__:${href}`); (err as Error & { __redirect__: string }).__redirect__ = href; throw err; @@ -23,79 +28,119 @@ vi.mock("next/navigation", () => ({ redirect: (href: string) => redirectMock(href), })); -const getSessionMock = vi.fn(); -vi.mock("@/lib/auth/session", () => ({ - getSession: () => getSessionMock(), +const loadMock = vi.fn(); +vi.mock("@/lib/onboarding/load-flow-state", () => ({ + loadOnboardingFlowState: () => loadMock(), +})); + +// The shell resolves a locale from cookies; the tree is inspected, not +// rendered, so a plain passthrough is all the test needs. +vi.mock("@/components/onboarding/onboarding-shell", () => ({ + OnboardingShell: ({ children }: { children: ReactElement }) => children, })); import OnboardingRootPage from "../page"; +import { WelcomeScreen } from "@/components/onboarding/welcome-screen"; beforeEach(() => { redirectMock.mockClear(); - getSessionMock.mockReset(); + loadMock.mockReset(); }); -function makeSession(opts: { - onboardingStep?: number | null; - onboardingCompletedAt?: Date | null; -}) { +function state( + statuses: Partial> = {}, + needs: Partial = {}, + completedAt: string | null = null, +): OnboardingStateDto { return { - session: { id: "sess-1", expiresAt: new Date(Date.now() + 86400000) }, - user: { - id: "user-1", - onboardingStep: opts.onboardingStep ?? null, - onboardingCompletedAt: opts.onboardingCompletedAt ?? null, - }, + steps: defaultOnboardingSteps().map((step) => ({ + ...step, + status: statuses[step.id] ?? step.status, + })), + needs: { ...emptyOnboardingNeeds(), ...needs }, + completedAt, + firstResult: null, }; } -async function runRedirect(): Promise { +type ShellElement = ReactElement<{ + children: ReactElement<{ variant: string }>; +}>; + +async function run(): Promise< + { redirect: string } | { element: ShellElement } +> { try { - await OnboardingRootPage(); + const element = (await OnboardingRootPage()) as ShellElement; + return { element }; } catch (e) { const tagged = e as Error & { __redirect__?: string }; - if (tagged.__redirect__) return tagged.__redirect__; + if (tagged.__redirect__) return { redirect: tagged.__redirect__ }; throw e; } - throw new Error("expected redirect, none thrown"); } -describe(" root redirect", () => { - it("redirects to /auth/login when no session", async () => { - getSessionMock.mockResolvedValueOnce(null); - const href = await runRedirect(); - expect(href).toBe("/auth/login"); +function welcomeVariant(element: ShellElement): string { + const child = element.props.children; + expect(child.type).toBe(WelcomeScreen); + return child.props.variant; +} + +describe("", () => { + it("redirects to /auth/login when there is no session", async () => { + loadMock.mockResolvedValueOnce(null); + expect(await run()).toEqual({ redirect: "/auth/login" }); }); - it("redirects fresh user (onboardingStep null) to /onboarding/0", async () => { - getSessionMock.mockResolvedValueOnce(makeSession({})); - const href = await runRedirect(); - expect(href).toBe("/onboarding/0"); + it("shows the welcome screen to a record that never entered the flow", async () => { + loadMock.mockResolvedValueOnce({ + userId: "u1", + userLocale: "en", + state: state(), + }); + const result = await run(); + expect("element" in result && welcomeVariant(result.element)).toBe("fresh"); }); - it("redirects mid-flow user to /onboarding/", async () => { - getSessionMock.mockResolvedValueOnce(makeSession({ onboardingStep: 2 })); - const href = await runRedirect(); - expect(href).toBe("/onboarding/2"); + it("sends a record mid-way to the step it owes", async () => { + loadMock.mockResolvedValueOnce({ + userId: "u1", + userLocale: "en", + state: state({ who: "done", areas: "done" }, { recordTarget: "me" }), + }); + expect(await run()).toEqual({ redirect: "/onboarding/medication" }); }); - it("clamps an out-of-range onboardingStep into the 0..4 window", async () => { - getSessionMock.mockResolvedValueOnce(makeSession({ onboardingStep: 99 })); - const href = await runRedirect(); - expect(href).toBe("/onboarding/4"); + it("sends a confirmed flow with an open first result to that screen", async () => { + loadMock.mockResolvedValueOnce({ + userId: "u1", + userLocale: "en", + state: state( + { + who: "done", + areas: "done", + medication: "done", + sources: "done", + visit: "done", + confirm: "done", + }, + { recordTarget: "me", areas: ["blood-pressure"] }, + "2026-09-10T08:00:00.000Z", + ), + }); + expect(await run()).toEqual({ redirect: "/onboarding/first-result" }); }); - it("redirects a completed user to /onboarding/", async () => { - getSessionMock.mockResolvedValueOnce( - makeSession({ - onboardingStep: 4, - onboardingCompletedAt: new Date("2026-05-01"), - }), - ); - const href = await runRedirect(); - // The step page handles the welcome-back banner at step 0 and the - // done-screen at step 4 — the root page just bounces into the - // current step regardless of completion state. - expect(href).toBe("/onboarding/4"); + it("offers the questions again to a finished record", async () => { + const done = Object.fromEntries( + defaultOnboardingSteps().map((s) => [s.id, "done" as const]), + ) as Record; + loadMock.mockResolvedValueOnce({ + userId: "u1", + userLocale: "en", + state: state(done, { recordTarget: "me" }, "2026-09-10T08:00:00.000Z"), + }); + const result = await run(); + expect("element" in result && welcomeVariant(result.element)).toBe("again"); }); }); diff --git a/src/app/onboarding/layout.tsx b/src/app/onboarding/layout.tsx index 5a233e2b0..1f4ae68a5 100644 --- a/src/app/onboarding/layout.tsx +++ b/src/app/onboarding/layout.tsx @@ -1,14 +1,11 @@ import type { ReactNode } from "react"; /** - * v1.4.25 W14b — onboarding route-segment layout. + * The setup flow's route-segment layout. * - * Intentionally pass-through. The `OnboardingShell` chrome lives inside - * each `[step]/page.tsx` so the existing `/onboarding` root page - * (`page.tsx` — the legacy v1.4.20 single-file wizard) keeps rendering - * unchanged while the rebuilt multi-step flow ships beside it. The - * Content agent that follows W14b-Foundation will swap the root page's - * redirect target once every step has real content. + * Pass-through on purpose: the `OnboardingShell` chrome is rendered by each + * page, because the shell's counter and step list depend on the flow state + * the page loads, and a layout cannot read what its page decided. */ export default function OnboardingLayout({ children, diff --git a/src/app/onboarding/page.tsx b/src/app/onboarding/page.tsx index 680293ecc..7acc07fe8 100644 --- a/src/app/onboarding/page.tsx +++ b/src/app/onboarding/page.tsx @@ -1,44 +1,49 @@ import { redirect } from "next/navigation"; -import { getSession } from "@/lib/auth/session"; +import { OnboardingShell } from "@/components/onboarding/onboarding-shell"; +import { WelcomeScreen } from "@/components/onboarding/welcome-screen"; +import { loadOnboardingFlowState } from "@/lib/onboarding/load-flow-state"; +import { isOnboardingSettled } from "@/lib/onboarding/needs"; +import { resumeScreen } from "@/lib/onboarding/wizard-steps"; /** - * v1.4.25 W14b — onboarding root redirect. + * v1.39 (C2) — the setup flow's front door. * - * The proxy redirect (`src/proxy.ts:179`) lands every still-pending user - * on `/onboarding`. Until W14b shipped, this file rendered the v1.4.20 - * single-file 3-step wizard inline. The new flow lives under - * `/onboarding/[step]/page.tsx`, so this root page now resolves to a - * server-side redirect into the right step: + * The proxy lands every account still pending its first run here, and + * Settings → "Set up again" does too. What is shown depends on where the + * flow stands, read off the same ledger every screen reads: * - * - No session → `/auth/login` (the proxy enforces - * this too; we mirror it to keep the contract explicit). - * - `onboardingCompletedAt != null` (returning user) - * → `/onboarding/0` so the welcome-back - * banner can show; the dashboard is - * one click away from there. - * - Otherwise → `/onboarding/` where - * `current = user.onboardingStep ?? 0`. + * - never entered → the welcome screen ("Set up" / "Skip for now") + * - a step still owed → redirect to that step + * - finished → the welcome screen's "again" variant, which + * offers the questions once more or the dashboard * - * The previous wizard remained the entry point through the - * W14b-Foundation phase to keep an unbroken flow; this commit swaps it - * out now that every step page renders real UI. + * No session mirrors the proxy's own gate (`/onboarding` is a public path, + * so the page has to say it too). */ export default async function OnboardingRootPage() { - const session = await getSession(); - if (!session) { + const flow = await loadOnboardingFlowState(); + if (!flow) { redirect("/auth/login"); } - const { user } = session; + const { state, userLocale } = flow; - const current = clampCurrentStep(user.onboardingStep); - redirect(`/onboarding/${current}`); -} + const resume = resumeScreen(state); + if (resume !== "welcome") { + const finished = state.completedAt !== null && isOnboardingSettled(state); + if (!finished) { + redirect(`/onboarding/${resume}`); + } + return ( + + + + ); + } -function clampCurrentStep(value: number | null | undefined): 0 | 1 | 2 | 3 | 4 { - if (value == null || !Number.isFinite(value)) return 0; - const floor = Math.floor(value); - if (floor <= 0) return 0; - if (floor >= 4) return 4; - return floor as 0 | 1 | 2 | 3 | 4; + return ( + + + + ); } diff --git a/src/components/layout/auth-shell.tsx b/src/components/layout/auth-shell.tsx index 1e48d0367..c49a352dc 100644 --- a/src/components/layout/auth-shell.tsx +++ b/src/components/layout/auth-shell.tsx @@ -27,6 +27,8 @@ import { DemoBanner } from "./demo-banner"; import { OfflineBanner } from "./offline-banner"; import { SharedRecordBanner } from "./shared-record-banner"; import { SharedRecordUnavailable } from "./shared-record-unavailable"; +import { ModulePageGate } from "@/components/layout/module-page-gate"; +import { isOnboardingPathname } from "@/lib/onboarding/wizard-steps"; import { RecordScopeHydrationGate } from "./record-scope-hydration-gate"; import { SidebarNav } from "./sidebar-nav"; import { TopBar } from "./top-bar"; @@ -129,7 +131,10 @@ export function AuthShell({ inSharedRecord && !isDestinationInSharedRecord(pathname, sections) && !isRecordSettingsPath; - const isOnboardingPage = pathname === "/onboarding"; + // The whole setup flow, not only its front door: every screen lives under + // `/onboarding/` (v1.39 C2), and an exact match here put the + // questions inside the full app chrome, sidebar and all. + const isOnboardingPage = isOnboardingPathname(pathname); const showUnlockNotifier = isAuthenticated && !isPublicPage && !!user?.id; // v1.9.0 — the document-level scrollbar-gutter (globals.css) is reserved @@ -466,7 +471,11 @@ export function AuthShell({ data-slot="main-content-wrapper" className="mx-auto max-w-screen-xl px-4 pt-6 pb-20 md:px-6" > - {outsideSharedRecord ? : children} + {outsideSharedRecord ? ( + + ) : ( + {children} + )} diff --git a/src/components/layout/module-page-gate.tsx b/src/components/layout/module-page-gate.tsx new file mode 100644 index 000000000..9046f72e9 --- /dev/null +++ b/src/components/layout/module-page-gate.tsx @@ -0,0 +1,63 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import type { ReactNode } from "react"; + +import { ModuleDisabledNotice } from "@/components/layout/module-disabled-notice"; +import { + isNavDestinationActive, + NAV_DESTINATIONS, +} from "@/components/layout/nav-model"; +import { Button } from "@/components/ui/button"; +import { useAuth } from "@/hooks/use-auth"; +import { useTranslations } from "@/lib/i18n/context"; + +/** + * v1.39 (C2) — the page of a module that is switched off answers with an + * empty state that names the switch (design spec §After the flow), in one + * place for every module page. + * + * The setup flow switches modules OFF for a new record — an explicit `false` + * for every module the answers did not name — and the navigation drops the + * entry. A person who reaches the page anyway (a bookmark, a link in a + * report, the tour) used to get the page's own empty state, which said + * "nothing here yet" about a module that is not there at all. This maps the + * route to the module through the navigation model's own gate, so a module + * page and its nav entry can never disagree about which switch they follow, + * and renders the shared notice with the one action that can help: the + * Modules settings, offered only when the record's own switch is what is off. + * + * Paint, not enforcement: every route behind a module refuses on its own + * through the server gate. Mounted inside the shell, which renders children + * only after the account payload resolved, so the map is never read on a + * hydration render. + */ +export function ModulePageGate({ children }: { children: ReactNode }) { + const pathname = usePathname(); + const { user } = useAuth(); + const { t } = useTranslations(); + + const destination = NAV_DESTINATIONS.find( + (d) => d.requiresModule && isNavDestinationActive(d.href, pathname), + ); + const moduleKey = destination?.requiresModule; + const off = moduleKey !== undefined && user?.modules?.[moduleKey] === false; + + if (!off || !destination || !moduleKey) return <>{children}; + + const Icon = destination.icon; + return ( + } + action={ + + } + /> + ); +} diff --git a/src/components/medications/wizard/medication-wizard-dialog.tsx b/src/components/medications/wizard/medication-wizard-dialog.tsx index c2f4c0b80..3fcc56182 100644 --- a/src/components/medications/wizard/medication-wizard-dialog.tsx +++ b/src/components/medications/wizard/medication-wizard-dialog.tsx @@ -86,6 +86,12 @@ export interface MedicationWizardDialogProps { initial?: MedicationPayload; /** Fires with the medication id on a successful create / save. */ onSuccess?: (id: string) => void; + /** + * v1.39 (C2) — whether a create lands on the new medication's page. The + * medications list wants that; the setup flow's first-result screen mounts + * the wizard inline and shows the result where it stands, so it opts out. + */ + navigateOnCreate?: boolean; } const STEP_ICONS = { @@ -118,6 +124,7 @@ function WizardDialogShell({ mode, initial, onSuccess, + navigateOnCreate = true, }: MedicationWizardDialogProps) { const { t, locale } = useTranslations(); const router = useRouter(); @@ -331,7 +338,7 @@ function WizardDialogShell({ toast.success(t("common.saved")); onSuccess?.(json.data.id); onOpenChange(false); - if (mode === "create") { + if (mode === "create" && navigateOnCreate) { router.push(`/medications/${json.data.id}`); } return; @@ -342,7 +349,17 @@ function WizardDialogShell({ } finally { setSubmitting(false); } - }, [initial, mode, onOpenChange, onSuccess, payload, queryClient, router, t]); + }, [ + initial, + mode, + navigateOnCreate, + onOpenChange, + onSuccess, + payload, + queryClient, + router, + t, + ]); const Icon = STEP_ICONS[step]; diff --git a/src/components/onboarding/__tests__/done-screen.test.tsx b/src/components/onboarding/__tests__/done-screen.test.tsx index 72038c37b..628d87837 100644 --- a/src/components/onboarding/__tests__/done-screen.test.tsx +++ b/src/components/onboarding/__tests__/done-screen.test.tsx @@ -17,12 +17,22 @@ const aiProviderState = vi.hoisted(() => ({ vi.mock("@tanstack/react-query", () => ({ useQuery: () => ({ data: aiProviderState.data }), + useMutation: () => ({ mutate: () => {}, isPending: false }), + useQueryClient: () => ({ invalidateQueries: async () => {} }), +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: () => {}, replace: () => {} }), })); vi.mock("@/hooks/use-auth", () => ({ useAuth: () => ({ user: { id: "u1" } }), })); +vi.mock("@/hooks/use-account-switch", () => ({ + useAccountSwitch: () => ({ mutate: () => {}, isPending: false }), +})); + vi.mock("@/components/onboarding/tour-launcher", () => ({ setTourReferrer: () => {}, })); @@ -42,12 +52,28 @@ vi.mock("next/link", () => ({ ), })); +import { + defaultOnboardingSteps, + emptyOnboardingNeeds, + type OnboardingStateDto, +} from "@/lib/onboarding/needs"; import { DoneScreen } from "../done-screen"; -function render() { +function state( + recordTarget: OnboardingStateDto["needs"]["recordTarget"] = "me", +): OnboardingStateDto { + return { + steps: defaultOnboardingSteps(), + needs: { ...emptyOnboardingNeeds(), recordTarget }, + completedAt: "2026-09-10T08:00:00.000Z", + firstResult: null, + }; +} + +function render(recordTarget?: OnboardingStateDto["needs"]["recordTarget"]) { return renderToStaticMarkup( - + , ); } @@ -71,7 +97,18 @@ describe(" AI panel", () => { const html = render(); expect(html).toContain('href="/settings/integrations"'); expect(html).toContain('href="/measurements"'); - expect(html).toContain('href="/"'); + expect(html).toContain('data-slot="onboarding-open-dashboard"'); + }); + + it("offers the tour rather than launching it", () => { + aiProviderState.data = undefined; + expect(render()).toContain('data-slot="onboarding-take-tour"'); + }); + + it("offers the managed profile only to an answer that asked for it later", () => { + aiProviderState.data = undefined; + expect(render("both")).toContain('href="/settings/access"'); + expect(render("me")).not.toContain('href="/settings/access"'); }); it("shows the shared-key note only when the operator key serves the user", () => { diff --git a/src/components/onboarding/__tests__/question-screen.test.tsx b/src/components/onboarding/__tests__/question-screen.test.tsx new file mode 100644 index 000000000..142c18e1d --- /dev/null +++ b/src/components/onboarding/__tests__/question-screen.test.tsx @@ -0,0 +1,76 @@ +/** + * v1.39 (C2) — the chip question screen, as the server paints it. + * + * Vitest runs without a DOM here, so what is pinned is the first paint: the + * stored answer is pre-selected, Q1 cannot be passed and cannot continue + * without an answer, every other question can be passed, and a real radio or + * checkbox sits inside each chip so the browser owns the grouping. The click + * path is the e2e journey's. + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { + defaultOnboardingSteps, + emptyOnboardingNeeds, + type OnboardingStateDto, +} from "@/lib/onboarding/needs"; +import { QuestionScreen } from "../question-screen"; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), +})); + +function state( + needs: Partial, +): OnboardingStateDto { + return { + steps: defaultOnboardingSteps(), + needs: { ...emptyOnboardingNeeds(), ...needs }, + completedAt: null, + firstResult: null, + }; +} + +function render(step: "who" | "areas" | "visit", needs = {}) { + return renderToStaticMarkup( + + + + + , + ); +} + +describe("", () => { + it("pre-selects the stored answer", () => { + const html = render("areas", { areas: ["glucose", "labs"] }); + expect(html).toContain('data-value="glucose" data-checked="true"'); + expect(html).toContain('data-value="labs" data-checked="true"'); + expect(html).toContain('data-value="mood" data-checked="false"'); + }); + + it("uses radios for one answer and checkboxes for many", () => { + expect(render("who")).toContain('type="radio"'); + expect(render("who")).not.toContain('type="checkbox"'); + expect(render("areas")).toContain('type="checkbox"'); + }); + + it("gives Q1 no skip and no way on without an answer", () => { + const html = render("who"); + expect(html).not.toContain('data-slot="onboarding-skip"'); + expect(html).toMatch(/data-slot="onboarding-next"[^>]*disabled/); + }); + + it("lets every other question be passed", () => { + expect(render("visit")).toContain('data-slot="onboarding-skip"'); + }); + + it("renders no raw key", () => { + for (const step of ["who", "areas", "visit"] as const) { + expect(render(step)).not.toMatch(/onboarding\.flow\./); + } + }); +}); diff --git a/src/components/onboarding/__tests__/source-card-grid.test.tsx b/src/components/onboarding/__tests__/source-card-grid.test.tsx deleted file mode 100644 index f419960c4..000000000 --- a/src/components/onboarding/__tests__/source-card-grid.test.tsx +++ /dev/null @@ -1,134 +0,0 @@ -import { describe, it, expect, vi } from "vitest"; -import { renderToStaticMarkup } from "react-dom/server"; - -import { I18nProvider } from "@/lib/i18n/context"; - -/** - * v1.29.x — UX audit H1: the wizard's Withings card must not launch an - * OAuth handshake that can only 400 for a brand-new account (Withings - * credentials are per-user BYO with no env fallback). The card reads the - * consolidated integrations-status envelope and swaps its CTA + href - * depending on whether the account already has its own credentials saved. - */ - -const integrationStatusState = vi.hoisted(() => ({ - data: undefined as - | { - threshold: number; - integrations: Array<{ - integration: string; - configured?: boolean; - connected?: boolean; - }>; - } - | undefined, -})); - -vi.mock("@tanstack/react-query", () => ({ - useQuery: () => ({ data: integrationStatusState.data }), - useQueryClient: () => ({ invalidateQueries: vi.fn() }), -})); - -vi.mock("@/hooks/use-auth", () => ({ - useAuth: () => ({ isAuthenticated: true }), -})); - -vi.mock("next/navigation", () => ({ - useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), -})); - -vi.mock("next/link", () => ({ - default: ({ - href, - children, - ...rest - }: { - href: string; - children: React.ReactNode; - }) => ( - - {children} - - ), -})); - -import { SourceCardGrid } from "../source-card-grid"; - -function render() { - return renderToStaticMarkup( - - - , - ); -} - -describe(" Withings card", () => { - it("points at Settings instead of launching OAuth when unconfigured", () => { - integrationStatusState.data = { - threshold: 3, - integrations: [{ integration: "withings", configured: false }], - }; - const html = render(); - expect(html).toContain('data-testid="source-card-withings-setup"'); - expect(html).toContain('href="/settings/integrations#withings"'); - expect(html).not.toContain('href="/api/withings/connect"'); - expect(html).toContain("Set up"); - }); - - it("also redirects to Settings when the status envelope hasn't loaded yet", () => { - integrationStatusState.data = undefined; - const html = render(); - expect(html).toContain('href="/settings/integrations#withings"'); - expect(html).not.toContain('href="/api/withings/connect"'); - }); - - it("opens the real OAuth connect flow once credentials are configured", () => { - integrationStatusState.data = { - threshold: 3, - integrations: [{ integration: "withings", configured: true }], - }; - const html = render(); - expect(html).toContain('href="/api/withings/connect"'); - expect(html).not.toContain('data-testid="source-card-withings-setup"'); - expect(html).toContain("Connect Withings"); - }); - - // 2026-07-17 UX-onboarding audit M6 — the connect CTA opens OAuth in a - // new tab; the callback flips the connection in THAT tab while this - // wizard tab used to render the same unchanged button forever. Once - // `useIntegrationStatuses` (refetches on window focus) reports - // `connected: true`, the card must swap to a badge instead of a dead - // "Connect Withings" button that would just reopen a redundant flow. - it("shows a connected badge instead of the connect button once Withings is linked", () => { - integrationStatusState.data = { - threshold: 3, - integrations: [ - { integration: "withings", configured: true, connected: true }, - ], - }; - const html = render(); - expect(html).toContain('data-testid="source-card-withings-connected"'); - expect(html).not.toContain('href="/api/withings/connect"'); - expect(html).not.toContain("Connect Withings"); - }); -}); - -// #778 — the step listed Apple Health next to the web connectors with nothing -// saying its setup happens in the iOS app afterwards, so it read like one more -// OAuth flow to hunt for. -describe(" Apple Health note", () => { - it("says Apple Health setup completes in the iPhone app, not on the web", () => { - integrationStatusState.data = undefined; - const html = render(); - expect(html).toContain('data-testid="source-more-apple-health-note"'); - expect(html).toContain("open the HealthLog app on your iPhone"); - expect(html).toContain("no web connection"); - }); - - it("keeps the Apple Health chip tappable so the note is not a dead end", () => { - integrationStatusState.data = undefined; - const html = render(); - expect(html).toContain('data-testid="source-more-apple-health"'); - expect(html).toContain('href="/settings/integrations"'); - }); -}); diff --git a/src/components/onboarding/baseline-fields.tsx b/src/components/onboarding/baseline-fields.tsx index 5eaf113e5..a2190b783 100644 --- a/src/components/onboarding/baseline-fields.tsx +++ b/src/components/onboarding/baseline-fields.tsx @@ -95,8 +95,16 @@ export function BaselineFields({ /> + {/* `min-w-0` on both cells: a grid item's automatic minimum is its + min-content, and the sex select's value is `whitespace-nowrap` — a + locale whose longest option is a third longer, or a runner whose + fallback fonts render two pixels wider, then pinned the track past + the card at 390 px (the string-headroom sweep caught it on CI). With + the minimum released the track takes the free space and the value + ellipsises, which is the select primitive's own design. */}
Promise; + /** True while the completion the parent owns is in flight. */ + confirming?: boolean; +}) { const { t } = useTranslations(); - const router = useRouter(); - const queryClient = useQueryClient(); const { preference } = useUnitDisplay(); const heightAdapter = resolveHeightUnitAdapter(preference); - const [form, setForm] = useState(EMPTY_FORM); + const [form, setForm] = useState(() => ({ + displayName: "", + height: heightAdapter.toDraft(initial.heightCm), + // The payload's ISO instant, as the date field's YYYY-MM-DD. + dateOfBirth: initial.dateOfBirth ? initial.dateOfBirth.slice(0, 10) : "", + gender: initial.gender ?? "", + })); const [saving, setSaving] = useState(false); // Refusals from the last save, one sentence per field. Cleared on // every attempt so a slot never keeps a reason the server no longer @@ -148,7 +151,7 @@ export function BaselineForm() { } async function advance(opts: { saveProfile: boolean }) { - if (saving) return; + if (saving || confirming) return; setSaving(true); setFieldErrors({}); try { @@ -188,9 +191,7 @@ export function BaselineForm() { await apiPut("/api/coach/about-me", aboutMeBody); } } - await apiPost("/api/onboarding/step", { step: 4 }); - await queryClient.invalidateQueries({ queryKey: queryKeys.auth() }); - router.push("/onboarding/4"); + await onConfirmed(); } catch (err) { toast.error(localizedApiError(err, t, "onboarding.errorGeneric")); setSaving(false); @@ -199,16 +200,12 @@ export function BaselineForm() { return (
-
- {/* Onboarding hero H1: intentionally semibold, not the app-wide bold PageHeader H1 (UI-STANDARDS §5 hero exception). Do not sweep to font-bold. */} -

+
+ {/* A section of the confirm screen, which owns the page's h1. */} +

{t("onboarding.baseline.title")} -

-

+

+

{t("onboarding.baseline.body")}

@@ -226,33 +223,13 @@ export function BaselineForm() { disabled={saving} /> -
- -
- - -
-
+ void advance({ saveProfile: false })} + onNext={() => void advance({ saveProfile: true })} + nextLabel={t("onboarding.flow.confirm.cta")} + pending={saving || confirming} + />
); } diff --git a/src/components/onboarding/choice-chip.tsx b/src/components/onboarding/choice-chip.tsx new file mode 100644 index 000000000..f2062ca48 --- /dev/null +++ b/src/components/onboarding/choice-chip.tsx @@ -0,0 +1,82 @@ +"use client"; + +import type { LucideIcon } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +/** + * One answer chip of a setup question: a real radio or checkbox inside a + * label, so the browser owns selection, grouping and keyboard movement + * (arrow keys across a radio group, space on a checkbox), and the chip is + * only the paint. + * + * The input is visually hidden and the label carries the focus ring through + * `has-[:focus-visible]`, because a hidden control with no visible focus is + * how a keyboard user loses their place in a chip grid — the five-step + * wizard's picker had exactly that gap. + */ +export function ChoiceChip({ + name, + value, + kind, + checked, + onChange, + label, + hint, + Icon, +}: { + name: string; + value: string; + kind: "single" | "multi"; + checked: boolean; + onChange: (value: string) => void; + label: string; + hint?: string; + Icon?: LucideIcon; +}) { + return ( + + ); +} diff --git a/src/components/onboarding/confirm-screen.tsx b/src/components/onboarding/confirm-screen.tsx new file mode 100644 index 000000000..9a0b053ca --- /dev/null +++ b/src/components/onboarding/confirm-screen.tsx @@ -0,0 +1,238 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { toast } from "sonner"; + +import { BaselineForm } from "@/components/onboarding/baseline-form"; +import { StepHeading } from "@/components/onboarding/step-heading"; +import { + screenHref, + useOnboardingAnswer, + useOnboardingComplete, +} from "@/components/onboarding/use-onboarding-flow"; +import { ManagedProfileCreateForm } from "@/components/settings/access/managed-profile-create-form"; +import { Button } from "@/components/ui/button"; +import { useAuth } from "@/hooks/use-auth"; +import { localizedApiError } from "@/lib/api/localized-error"; +import { localeLabels } from "@/lib/i18n/config"; +import { useTranslations } from "@/lib/i18n/context"; +import { MODULE_REGISTRY } from "@/lib/modules/registry"; +import { confirmedModules } from "@/lib/onboarding/confirm-summary"; +import type { OnboardingStateDto } from "@/lib/onboarding/needs"; +import { + firstResultApplies, + nextScreen, + previousScreen, + questionsToPassBeforeConfirm, +} from "@/lib/onboarding/wizard-steps"; + +/** + * v1.39 (C2) — the confirm screen. + * + * Three things on one screen, all of them confirmations rather than + * questions (design spec §The screens, 3): + * + * 1. What the answers switch on — read off the registry's derivation + * (`confirmedModules`), never computed here, so the list confirmed is + * the list `POST /api/onboarding/complete` writes. + * 2. The preferences the account already holds: units, language, timezone. + * Shown once, changeable in Settings, never asked again. + * 3. The profile. For a record of one's own, the baseline fields (height, + * date of birth, sex, display name) with their per-field refusals; for + * "someone I look after", the managed-profile form instead, because + * that record is created HERE, after the questions, per the + * maintainer's answer to spec question 2. + * + * Confirming posts the completion — which derives the module map once and + * stamps the account past the first-run redirect — and then goes wherever + * the step machine says: the first-result screen when there is a task to + * offer, otherwise done. When there is no task, the first-result step is + * passed explicitly so the ledger reads as settled rather than owed. + */ +export function ConfirmScreen({ state }: { state: OnboardingStateDto }) { + const { t, locale } = useTranslations(); + const router = useRouter(); + const { user } = useAuth(); + const complete = useOnboardingComplete(); + const answer = useOnboardingAnswer(); + const [finishing, setFinishing] = useState(false); + + const needs = state.needs; + const forSomeoneElse = needs.recordTarget === "someone-else"; + const { chosen, alwaysOn } = confirmedModules(needs); + const back = previousScreen(state, "confirm"); + + const glucoseUnit = needs.units.glucoseUnit ?? user?.glucoseUnit ?? null; + const unitPreference = + needs.units.unitPreference ?? user?.unitPreference ?? "metric"; + + /** + * Complete the flow. `managedRecordId` is the profile "someone I look + * after" just created: the route then applies the derivation to THAT + * record and stamps this one complete without deriving, so the guardian's + * own modules are never re-ordered around the child's answers. + */ + async function finish(managedRecordId?: string) { + if (finishing) return; + setFinishing(true); + try { + // A question this flow never showed — Q6 for somebody with no + // unit-bearing area — is passed on the ledger before the completion, + // because the route derives only once EVERY question is answered or + // passed, and a step nobody was asked cannot be answered. + for (const id of questionsToPassBeforeConfirm(state)) { + await answer.mutateAsync({ step: id, status: "skipped" }); + } + const { onboarding } = await complete.mutateAsync( + managedRecordId ? { managedRecordId } : undefined, + ); + let written = onboarding ?? state; + if (!firstResultApplies(written)) { + // Nothing to offer, so the step is passed rather than left owed: + // a pending step would keep the setup reading as unfinished forever. + written = await answer.mutateAsync({ + step: "first-result", + status: "skipped", + }); + } + router.push(screenHref(nextScreen(written, "confirm") ?? "done")); + } catch (err) { + toast.error(localizedApiError(err, t, "onboarding.errorGeneric")); + setFinishing(false); + } + } + + const moduleNames = (keys: readonly (keyof typeof MODULE_REGISTRY)[]) => + keys.map((key) => t(MODULE_REGISTRY[key].labelKey)).join(", "); + + return ( +
+ + +
+

+ {t("onboarding.flow.confirm.modulesTitle")} +

+

+ {chosen.length > 0 + ? t("onboarding.flow.confirm.switchedOn", { + modules: moduleNames(chosen), + }) + : t("onboarding.flow.confirm.nothingExtra")} +

+

+ {t("onboarding.flow.confirm.alwaysOn", { + modules: moduleNames(alwaysOn), + })} +

+

+ {t("onboarding.flow.confirm.everythingElse")}{" "} + + {t("onboarding.flow.confirm.modulesLink")} + +

+
+ +
+ {/* Grouped dt/dd pairs only: a stray child breaks the list's semantics. */} + {glucoseUnit ? ( + <> +
+ {t("onboarding.flow.confirm.glucoseUnit")} +
+
{glucoseUnit}
+ + ) : null} +
+ {t("onboarding.flow.confirm.unitPreference")} +
+
{t(`onboarding.flow.units.options.${unitPreference}`)}
+
+ {t("onboarding.flow.confirm.language")} +
+
{localeLabels[locale]}
+ {user?.timezone ? ( + <> +
+ {t("onboarding.flow.confirm.timezone")} +
+
{user.timezone}
+ + ) : null} +
+

+ + {t("onboarding.flow.confirm.changeInSettings")} + +

+ + {forSomeoneElse ? ( +
+
+

+ {t("onboarding.flow.confirm.managedTitle")} +

+

+ {t("onboarding.flow.confirm.managedBody")} +

+
+ void finish(profile.id)} + /> +

{t("onboarding.flow.confirm.managedLater")}

+
+ {back ? ( + + ) : ( +
+
+ ) : ( + + )} +
+ ); +} diff --git a/src/components/onboarding/done-screen.tsx b/src/components/onboarding/done-screen.tsx index bb8c56689..53b78ca92 100644 --- a/src/components/onboarding/done-screen.tsx +++ b/src/components/onboarding/done-screen.tsx @@ -1,25 +1,34 @@ "use client"; import Link from "next/link"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; +import { useRouter } from "next/navigation"; import { useQuery } from "@tanstack/react-query"; import { CheckCircle2, + Compass, FileUp, PlusCircle, Plug, Settings2, Sparkles, + UserPlus, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { MedicalDisclaimer } from "@/components/common/medical-disclaimer"; import { SampleBriefingCard } from "@/components/onboarding/sample-briefing-card"; +import { StepHeading } from "@/components/onboarding/step-heading"; +import { useOnboardingAnswer } from "@/components/onboarding/use-onboarding-flow"; import { useAuth } from "@/hooks/use-auth"; +import { useAccountSwitch } from "@/hooks/use-account-switch"; import { setTourReferrer } from "@/components/onboarding/tour-launcher"; import { queryKeys } from "@/lib/query-keys"; import { apiGet } from "@/lib/api/api-fetch"; import { useTranslations } from "@/lib/i18n/context"; +import { markChecklistExpanded } from "@/lib/onboarding/checklist-storage"; +import type { OnboardingStateDto } from "@/lib/onboarding/needs"; +import { accountLabel } from "@/lib/sharing/account-access-view"; interface AiProviderStatus { /** Origin of the provider that would serve this user, if any. */ @@ -27,33 +36,70 @@ interface AiProviderStatus { } /** - * v1.4.25 W14b-Content — onboarding step 4 (done). + * v1.39 (C2) — the setup flow's exit. * - * Confirms the user has finished onboarding. `onboardingCompletedAt` - * was flipped server-side by the baseline form's `POST step:4` write, - * so this page is purely presentational — no further mutation, no - * cookie flips, no API calls. + * The dashboard is one click away, already ordered, and the checklist opens + * on arrival (design spec §The screens, 5). The tour is an OFFER here rather + * than an automatic launch: pressing "Take a short tour" sets the referrer + * the shell-level `` reads, and nothing else does, so somebody + * who wants the dashboard gets the dashboard. * - * The single CTA returns the user to the dashboard. The link goes - * through `next/link` so the regular client-side navigation kicks in - * (the proxy redirect was cleared with the same step-API write that - * landed the user here). + * Two record-shaped exits ride the answer to Q1: "both" is offered the + * profile for the person they look after, and "someone I look after" — whose + * profile was created on the confirm screen — is offered that record, through + * the same switch the account menu uses, so their first entry lands in the + * right record. * - * v1.18.6 — on mount we write the per-user wizard-return marker so the - * shell-level `` auto-opens the module tour immediately - * on the next dashboard arrival (sequenced after the post-wizard - * grace), instead of waiting out the 24 h fallback. Whichever exit the - * user picks from this screen, the next `/` mount finds the marker. + * The step is marked done on arrival, once. Everything else on this screen + * is presentational: the module map was derived on confirm, the cookie + * cleared there too. + * + * The AI panel leads with the payoff (a static, clearly-labelled SAMPLE + * briefing — no model call, no egress, no consent), then the honest + * local-first ladder and the "useful without AI" release valve. Value-first, + * never a gate: every exit below stays, and setup is a single optional + * deep-link. */ -export function DoneScreen() { +export function DoneScreen({ state }: { state: OnboardingStateDto }) { const { t } = useTranslations(); + const router = useRouter(); const { user } = useAuth(); const [sampleOpen, setSampleOpen] = useState(false); + const answer = useOnboardingAnswer(); + const accountSwitch = useAccountSwitch(); + // Mark the step once. The write is idempotent, but a strict-mode double + // mount would still send it twice; the ref keeps it to one. + const marked = useRef(false); + const donePending = + state.steps.find((step) => step.id === "done")?.status === "pending"; useEffect(() => { + if (!donePending || marked.current) return; + marked.current = true; + answer.mutate({ step: "done" }); + // `answer` is a fresh object every render; the mutation it wraps is not. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [donePending]); + + const recordTarget = state.needs.recordTarget; + const managedRecord = + recordTarget === "someone-else" + ? (user?.accountAccess?.accounts.find( + (entry) => entry.recordKind === "managed", + ) ?? null) + : null; + + function openDashboard() { + markChecklistExpanded(); + router.push("/"); + } + + function takeTour() { if (user?.id) setTourReferrer(user.id); - }, [user?.id]); + markChecklistExpanded(); + router.push("/"); + } // The shared-key note is the ONLY honest divergence in the panel: on a // deployment that ships an operator key, insights already work for the @@ -80,22 +126,16 @@ export function DoneScreen() { -
- {/* Onboarding hero H1: intentionally semibold, not the app-wide bold PageHeader H1 (UI-STANDARDS §5 hero exception). Do not sweep to font-bold. */} -

+ - {t("onboarding.done.title")} -

-

- {t("onboarding.done.body")} -

-

+ title={t("onboarding.done.title")} + description={t("onboarding.done.body")} + /> +

{t("onboarding.done.learning")}

-
+
{/* v1.28 — the flagship AI value, made reachable at the one screen every fresh user passes through. The daily briefing / Coach need @@ -201,14 +241,62 @@ export function DoneScreen() { {t("onboarding.ai.setupCta")}
- + + {managedRecord ? ( + + ) : null} + {recordTarget === "both" ? ( + + ) : null} + - -
{/* 2026-07-17 UX-onboarding audit M5 — the module system (default-on @@ -238,14 +323,14 @@ export function DoneScreen() {
{t("onboarding.done.importCta")} {t("onboarding.done.modulesCta")} diff --git a/src/components/onboarding/first-result-screen.tsx b/src/components/onboarding/first-result-screen.tsx new file mode 100644 index 000000000..d77894ab6 --- /dev/null +++ b/src/components/onboarding/first-result-screen.tsx @@ -0,0 +1,525 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useQuery } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { Loader2 } from "lucide-react"; + +import { MeasurementForm } from "@/components/measurements/measurement-form"; +import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/components/measurements/measurement-list-meta"; +import { MedicationWizardDialog } from "@/components/medications/wizard/medication-wizard-dialog"; +import { WrittenOutcomeLine } from "@/components/outcome/written-outcome-line"; +import { StepActions } from "@/components/onboarding/step-actions"; +import { StepHeading } from "@/components/onboarding/step-heading"; +import { + screenHref, + useOnboardingAnswer, +} from "@/components/onboarding/use-onboarding-flow"; +import { + pickStatus, + useIntegrationStatuses, + type IntegrationKey, +} from "@/components/settings/integrations/shared"; +import { Button } from "@/components/ui/button"; +import { useAuth } from "@/hooks/use-auth"; +import { useUnitDisplay } from "@/hooks/use-unit-display"; +import { apiGet } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; +import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import type { OnboardingAreaKey } from "@/lib/modules/registry"; +import { + AREA_PAGE_HREF, + AREA_READING_TARGETS, + SOURCE_INTEGRATION, +} from "@/lib/onboarding/first-result-config"; +import type { OnboardingStateDto } from "@/lib/onboarding/needs"; +import { questionOptionLabelKey } from "@/lib/onboarding/question-config"; +import { + chooseFirstResultTask, + isBrowserConnectableSource, + previousScreen, + type BrowserConnectableSource, +} from "@/lib/onboarding/wizard-steps"; +import { queryKeys } from "@/lib/query-keys"; + +/** + * v1.39 (C2) — the one task the flow ends on. + * + * Exactly one of three, chosen by the step machine's fixed priority: connect + * the wearable named in Q4, add the first medication, or log one reading for + * the first Q2 area. The form is inline where a form exists (the measurement + * form, the medication wizard); a connection goes through the integrations + * page, because the OAuth handshake cannot run inside this screen, and this + * screen watches the status envelope for it to come back connected. + * + * On completion the screen shows what the task produced — the reading on its + * tile, the medication with its next dose, the connection with "first sync in + * progress" — and records the result on the ledger with the server's own + * instant. The offer itself is not recorded on arrival: a person who leaves + * mid-task then resumes HERE rather than on the done screen, and the ledger + * reads as owed rather than as an offer nobody acted on. + */ +type Offer = NonNullable>; + +export function FirstResultScreen({ state }: { state: OnboardingStateDto }) { + const { t } = useTranslations(); + const router = useRouter(); + const answer = useOnboardingAnswer(); + const offer = chooseFirstResultTask(state.needs); + const back = previousScreen(state, "first-result"); + + // Completed only when the LEDGER says so as well: a restart puts the step + // back to pending, and a stored result from the previous run must not paint + // this screen as done, or "Next" would go to done without ever writing the + // step and the flow would resume here forever. + const [completed, setCompleted] = useState( + state.steps.find((step) => step.id === "first-result")?.status === "done" && + state.firstResult?.task === offer?.task && + state.firstResult?.completedAt !== null, + ); + + async function complete(target: string | null) { + if (!offer) return; + try { + await answer.mutateAsync({ + step: "first-result", + firstResult: { task: offer.task, target, completed: true }, + }); + setCompleted(true); + } catch (err) { + toast.error(localizedApiError(err, t, "onboarding.errorGeneric")); + } + } + + async function skip() { + try { + await answer.mutateAsync({ step: "first-result", status: "skipped" }); + router.push(screenHref("done")); + } catch (err) { + toast.error(localizedApiError(err, t, "onboarding.errorGeneric")); + } + } + + // The page guard only renders this screen while the machine puts it in the + // order, so an offer is always there; the arm keeps the type total. + useEffect(() => { + if (!offer) router.replace(screenHref("done")); + }, [offer, router]); + if (!offer) return null; + + return ( +
+ + + + + void skip()} + onNext={completed ? () => router.push(screenHref("done")) : undefined} + pending={answer.isPending} + /> +
+ ); +} + +function targetLabel( + t: (key: string, params?: Record) => string, + offer: Offer, +): string { + if (!offer.target) return ""; + if (offer.task === "connect-source") { + return t(questionOptionLabelKey("sources", offer.target)); + } + return t(questionOptionLabelKey("areas", offer.target)); +} + +function TaskBody({ + offer, + completed, + onComplete, +}: { + offer: Offer; + completed: boolean; + onComplete: (target: string | null) => Promise; +}) { + switch (offer.task) { + case "connect-source": + return offer.target && isBrowserConnectableSource(offer.target) ? ( + + ) : null; + case "add-medication": + return ( + + ); + case "log-reading": + return offer.target ? ( + + ) : null; + } +} + +/* ── connect a source ──────────────────────────────────────────────────── */ + +function ConnectSourceTask({ + source, + completed, + onComplete, +}: { + source: BrowserConnectableSource; + completed: boolean; + onComplete: (target: string | null) => Promise; +}) { + const { t } = useTranslations(); + const { anchor, statusKey } = SOURCE_INTEGRATION[source]; + const label = t(questionOptionLabelKey("sources", source)); + // Refetches on window focus, so coming back from the integrations tab + // after the handshake picks the fresh verdict up on its own. + const statuses = useIntegrationStatuses(true); + const status = pickStatus(statuses.data, statusKey as IntegrationKey); + const connected = status?.connected === true || status?.state === "connected"; + + // Record the result once the connection is there — once. `completed` flips + // only after the write resolves, and the status envelope can re-render this + // in between, so a ref latches the first call. + const recorded = useRef(completed); + useEffect(() => { + if (connected && !recorded.current) { + recorded.current = true; + void onComplete(source); + } + }, [connected, onComplete, source]); + + if (completed || connected) { + return ( + + ); + } + + return ( +
+

+ {t("onboarding.flow.first-result.connect-source.howTo", { + target: label, + })} +

+ +

+ {statuses.isLoading + ? t("onboarding.flow.first-result.connect-source.checking") + : t("onboarding.flow.first-result.connect-source.waiting")} +

+
+ ); +} + +/* ── add a medication ──────────────────────────────────────────────────── */ + +interface MedicationDetail { + name: string; + nextDueAt: string | null; +} + +function AddMedicationTask({ + completed, + onComplete, +}: { + completed: boolean; + onComplete: (target: string | null) => Promise; +}) { + const { t } = useTranslations(); + const fmt = useFormatters(); + const [open, setOpen] = useState(false); + // The id stays on this screen: the ledger's `target` is a source or area + // key and is null for the medication task (see `OnboardingFirstResult`), + // so a revisit after completion shows the unnamed result. + const [medicationId, setMedicationId] = useState(null); + + const detail = useQuery({ + queryKey: queryKeys.medicationDetail(medicationId ?? ""), + queryFn: () => apiGet(`/api/medications/${medicationId}`), + enabled: completed && medicationId !== null, + }); + + if (completed) { + return ( + + ); + } + + return ( +
+

+ {t("onboarding.flow.first-result.add-medication.howTo")} +

+ + { + setOpen(false); + setMedicationId(id); + void onComplete(null); + }} + /> +
+ ); +} + +/* ── log one reading ───────────────────────────────────────────────────── */ + +function LogReadingTask({ + area, + completed, + onComplete, +}: { + area: OnboardingAreaKey; + completed: boolean; + onComplete: (target: string | null) => Promise; +}) { + const { t } = useTranslations(); + const target = AREA_READING_TARGETS[area]; + const href = AREA_PAGE_HREF[area]; + const label = t(questionOptionLabelKey("areas", area)); + + if (completed && target) { + return ; + } + if (completed) { + return ( + + ); + } + + if (target) { + return ( +
+ void onComplete(area)} + /> +
+ ); + } + + // An area with its own surface: point there, and let the person say when + // it is done — nothing here can see a mood entry or an episode land. + return ( +
+

+ {t("onboarding.flow.first-result.log-reading.elsewhere", { + target: label, + })} +

+
+ + +
+
+ ); +} + +interface MeasurementRow { + type: string; + value: number; + measuredAt: string; +} + +function LatestReadingTile({ + area, + storedTypes, +}: { + area: OnboardingAreaKey; + storedTypes: readonly string[]; +}) { + const { t } = useTranslations(); + const fmt = useFormatters(); + const { user } = useAuth(); + const units = useUnitDisplay(); + const glucoseUnit = resolveGlucoseUnit(user?.glucoseUnit); + + const latest = useQuery({ + queryKey: queryKeys.onboardingLatestReading(area), + queryFn: async () => + Promise.all( + storedTypes.map((type) => + // The list route answers `{ measurements, meta }`, not a bare array. + apiGet<{ measurements: MeasurementRow[] }>( + `/api/measurements?type=${encodeURIComponent(type)}&limit=1`, + ).then((page) => page.measurements[0] ?? null), + ), + ), + }); + + const rows = (latest.data ?? []).filter( + (row): row is MeasurementRow => row !== null, + ); + + if (latest.isPending) { + return ( +
+
+ ); + } + + if (rows.length === 0) { + return ( + + ); + } + + let value: string; + let unit: string; + if (area === "blood-pressure") { + const sys = rows.find((r) => r.type === "BLOOD_PRESSURE_SYS"); + const dia = rows.find((r) => r.type === "BLOOD_PRESSURE_DIA"); + value = `${sys ? fmt.integer(sys.value) : "–"}/${dia ? fmt.integer(dia.value) : "–"}`; + unit = "mmHg"; + } else if (area === "glucose") { + const row = rows[0]; + value = fmt.number( + convertGlucose(row.value, glucoseUnit), + glucoseUnit === "mmol/L" ? 1 : 0, + ); + unit = glucoseUnit; + } else if (area === "sleep") { + // Stored in minutes since v1.4.23. + const minutes = Math.round(rows[0].value); + value = `${Math.floor(minutes / 60)}:${String(minutes % 60).padStart(2, "0")}`; + unit = t("measurements.unitHours"); + } else { + const row = rows[0]; + value = fmt.number( + units.toDisplay(row.type, row.value), + units.decimalsFor(row.type), + ); + unit = units.unitFor(row.type); + } + const label = t(MEASUREMENT_TYPE_LABEL_KEYS[rows[0].type] ?? ""); + + return ( + + ); +} + +/* ── the result ────────────────────────────────────────────────────────── */ + +/** + * What the task produced. The written-outcome line is the one success + * affordance the codebase renders (`src/components/outcome/`); the value + * itself sits under it in content colour, because it is the reading, the + * medication or the connection the person came for. + */ +function ResultTile({ title, detail }: { title: string; detail?: string }) { + return ( +
+ + {detail ?

{detail}

: null} +
+ ); +} diff --git a/src/components/onboarding/getting-started-checklist.tsx b/src/components/onboarding/getting-started-checklist.tsx index 61ed029e8..09d678d29 100644 --- a/src/components/onboarding/getting-started-checklist.tsx +++ b/src/components/onboarding/getting-started-checklist.tsx @@ -10,7 +10,9 @@ import { ChevronDown, Pill, Sparkles, + Stethoscope, User2, + UserPlus, Wifi, X, } from "lucide-react"; @@ -25,14 +27,18 @@ import { buildChecklist, checklistProgress, shouldShowChecklist, + upcomingVisitCountFrom, visibleChecklist, type ChecklistItemId, } from "@/lib/onboarding/checklist"; import { apiGet } from "@/lib/api/api-fetch"; - -const DISMISSED_ITEMS_KEY = "healthlog-getting-started-dismissed"; -const DISMISSED_ALL_KEY = "healthlog-getting-started-hidden"; -const EXPANDED_KEY = "healthlog-getting-started-expanded"; +import { + CHECKLIST_DISMISSED_ALL_KEY as DISMISSED_ALL_KEY, + CHECKLIST_DISMISSED_ITEMS_KEY as DISMISSED_ITEMS_KEY, + CHECKLIST_EXPANDED_KEY as EXPANDED_KEY, +} from "@/lib/onboarding/checklist-storage"; +import { questionOptionLabelKey } from "@/lib/onboarding/question-config"; +import { isBrowserConnectableSource } from "@/lib/onboarding/wizard-steps"; const ITEM_ICONS: Record = { profile: User2, @@ -41,6 +47,8 @@ const ITEM_ICONS: Record = { dataSource: Wifi, notifications: Bell, insights: Sparkles, + visit: Stethoscope, + managedProfile: UserPlus, }; const ITEM_LABEL_KEYS: Record< @@ -77,6 +85,16 @@ const ITEM_LABEL_KEYS: Record< description: "gettingStarted.items.insightsDescription", cta: "gettingStarted.items.insightsCta", }, + visit: { + title: "gettingStarted.items.visitTitle", + description: "gettingStarted.items.visitDescription", + cta: "gettingStarted.items.visitCta", + }, + managedProfile: { + title: "gettingStarted.items.managedProfileTitle", + description: "gettingStarted.items.managedProfileDescription", + cta: "gettingStarted.items.managedProfileCta", + }, }; interface IntegrationsStatus { @@ -288,6 +306,32 @@ export function GettingStartedChecklist() { enabled: checklistRelevant, }); + // v1.39 (C2) — the "prepare the visit" row, only for the answer that asked + // for it: a visit within a month. Reads the visits list's own meta count so + // the row flips done the moment one is on the calendar. + // Answers given for somebody else's record (Q1 "someone I look after") are + // the child's, not this dashboard's: no visit row, no named source here. + const answersAreOwn = user?.onboarding?.needs.recordTarget !== "someone-else"; + const visitAsked = + answersAreOwn && user?.onboarding?.needs.visit === "within-a-month"; + const { data: upcomingVisitCount } = useQuery({ + queryKey: queryKeys.onboardingUpcomingVisits(), + queryFn: async () => + upcomingVisitCountFrom( + await apiGet<{ upcoming: unknown[] }>("/api/encounters"), + ), + enabled: checklistRelevant && visitAsked, + }); + + // The named source: "Connect Oura" rather than "Connect a data source" + // for somebody who said their readings come from one. + const namedSource = answersAreOwn + ? (user?.onboarding?.needs.sources.find(isBrowserConnectableSource) ?? null) + : null; + const managedProfileCount = (user?.accountAccess?.accounts ?? []).filter( + (entry) => entry.recordKind === "managed", + ).length; + const medicationCount = medsData?.length ?? 0; const dataSourceConnected = (integrationsData?.integrations ?? []).some( (integration) => @@ -312,6 +356,8 @@ export function GettingStartedChecklist() { notificationsConfigured, insightsConfigured, dismissedIds, + upcomingVisitCount: upcomingVisitCount ?? 0, + managedProfileCount, // 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. The payload resolves them for the @@ -331,6 +377,8 @@ export function GettingStartedChecklist() { notificationsConfigured, insightsConfigured, dismissedIds, + upcomingVisitCount, + managedProfileCount, ], ); @@ -473,6 +521,8 @@ export function GettingStartedChecklist() { return (
  • - {t(labels.title)} + {item.id === "dataSource" && namedSource + ? t("gettingStarted.items.dataSourceNamedTitle", { + source: t( + questionOptionLabelKey("sources", namedSource), + ), + }) + : t(labels.title)}

    {t(labels.description)} diff --git a/src/components/onboarding/goals-chip-picker.tsx b/src/components/onboarding/goals-chip-picker.tsx deleted file mode 100644 index c83877eba..000000000 --- a/src/components/onboarding/goals-chip-picker.tsx +++ /dev/null @@ -1,228 +0,0 @@ -"use client"; - -import { useCallback, useState } from "react"; -import Link from "next/link"; -import { useRouter } from "next/navigation"; -import { useQueryClient } from "@tanstack/react-query"; -import { toast } from "sonner"; -import { - Brain, - Droplet, - HeartPulse, - Moon, - Pill, - Scale, - type LucideIcon, -} from "lucide-react"; - -import { Button } from "@/components/ui/button"; -import { useTranslations } from "@/lib/i18n/context"; -import { cn } from "@/lib/utils"; -import { apiPost } from "@/lib/api/api-fetch"; -import { localizedApiError } from "@/lib/api/localized-error"; -import { queryKeys } from "@/lib/query-keys"; - -/** - * v1.4.25 W14b-Content — onboarding step 1 (goals). - * - * Multi-select chip picker for the metrics the user wants to track. - * The goal slugs are the closed enum `ONBOARDING_GOAL_SLUGS` - * (`src/lib/onboarding/goals.ts`). Selection lives in component state - * while the step is open; on advance it is persisted server-side to - * `User.onboardingGoals` (v1.17.1), which seeds the dashboard tile - * layout on completion and feeds reminder suggestions. - * - * CTAs: - * - Back → `/onboarding/0` - * - Skip → advance with empty goal set (explicit "no preference") - * - Next → advance with chosen goal set - * - * Both Skip and Next call POST `/api/onboarding/step` with - * `{ step: 2, goals }` and `router.push("/onboarding/2")`. - */ - -export type OnboardingGoalSlug = - | "weight-management" - | "bp-tracking" - | "glucose-tracking" - | "sleep-improvement" - | "medication-compliance" - | "general-wellness"; - -interface GoalOption { - slug: OnboardingGoalSlug; - Icon: LucideIcon; - labelKey: string; -} - -const GOAL_OPTIONS: ReadonlyArray = [ - { - slug: "weight-management", - Icon: Scale, - labelKey: "onboarding.goals.options.weightManagement", - }, - { - slug: "bp-tracking", - Icon: HeartPulse, - labelKey: "onboarding.goals.options.bpTracking", - }, - { - slug: "glucose-tracking", - Icon: Droplet, - labelKey: "onboarding.goals.options.glucoseTracking", - }, - { - slug: "sleep-improvement", - Icon: Moon, - labelKey: "onboarding.goals.options.sleepImprovement", - }, - { - slug: "medication-compliance", - Icon: Pill, - labelKey: "onboarding.goals.options.medicationCompliance", - }, - { - slug: "general-wellness", - Icon: Brain, - labelKey: "onboarding.goals.options.generalWellness", - }, -]; - -export interface GoalsChipPickerProps { - /** - * The current user's id. Threaded in from the server step page - * (`getSession().user.id`) so the v1.4.26 column write can reuse - * the same call site without a prop reshuffle. - */ - userId: string; -} - -export function GoalsChipPicker({ userId: _userId }: GoalsChipPickerProps) { - const { t } = useTranslations(); - const router = useRouter(); - const queryClient = useQueryClient(); - - const [selected, setSelected] = useState>( - () => new Set(), - ); - const [advancing, setAdvancing] = useState(false); - - const toggle = useCallback((slug: OnboardingGoalSlug) => { - setSelected((prev) => { - const next = new Set(prev); - if (next.has(slug)) next.delete(slug); - else next.add(slug); - return next; - }); - }, []); - - async function advance() { - if (advancing) return; - setAdvancing(true); - try { - // v1.17.1 — persist the chosen goal slugs alongside the step - // write. Skip sends an empty array (explicit "no preference"), - // not omission, so a resume can read the deliberate empty choice. - // The server seeds the dashboard from these on completion. - await apiPost("/api/onboarding/step", { - step: 2, - goals: Array.from(selected), - }); - await queryClient.invalidateQueries({ queryKey: queryKeys.auth() }); - router.push("/onboarding/2"); - } catch (err) { - toast.error(localizedApiError(err, t, "onboarding.errorGeneric")); - setAdvancing(false); - } - } - - return ( -

    -
    - {/* Onboarding hero H1: intentionally semibold, not the app-wide bold PageHeader H1 (UI-STANDARDS §5 hero exception). Do not sweep to font-bold. */} -

    - {t("onboarding.goals.title")} -

    -

    - {t("onboarding.goals.body")} -

    -

    - {t("onboarding.goals.helpHint")} -

    -
    - -
    - {t("onboarding.goals.title")} - {GOAL_OPTIONS.map((option) => { - const checked = selected.has(option.slug); - const Icon = option.Icon; - return ( - - ); - })} -
    - -
    - -
    - - -
    -
    -
    - ); -} diff --git a/src/components/onboarding/onboarding-shell.tsx b/src/components/onboarding/onboarding-shell.tsx index 1084ba693..257010ad6 100644 --- a/src/components/onboarding/onboarding-shell.tsx +++ b/src/components/onboarding/onboarding-shell.tsx @@ -1,171 +1,114 @@ -import Link from "next/link"; import { cn } from "@/lib/utils"; import { Logo } from "@/components/ui/logo"; -import { Button } from "@/components/ui/button"; import { getServerTranslator } from "@/lib/i18n/server-translator"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; +import type { + OnboardingStateDto, + OnboardingStepId, +} from "@/lib/onboarding/needs"; +import { + screenOrder, + stepCounter, + type OnboardingScreenId, +} from "@/lib/onboarding/wizard-steps"; /** - * v1.4.25 W14b — onboarding wizard chrome. + * v1.39 (C2) — the chrome around every setup screen. * - * Renders the shared frame around every step in the rebuilt - * `/onboarding/[step]` flow: logo, "Step N of 4" label, four-dot - * progress strip, the step body slot, and the back / skip / next - * action row pinned to the bottom safe-area. + * Logo, the "Step 2 of 6" counter, the step list, and the body slot. The + * counter and the list are both read off the step machine, so the shell + * shows exactly the screens THIS flow has — six questions for somebody who + * ticked glucose, five for somebody who did not, a first-result screen only + * when there is a task to offer. * - * Step encoding (mirrors `User.onboardingStep`): - * 0 = welcome (no progress dot; intro screen, primary action is "next") - * 1 = goals (dot 1 active) - * 2 = source (dot 2 active) - * 3 = baseline (dot 3 active) - * 4 = done (dot 4 active; "done" copy; primary CTA = return to dashboard) + * The step list is a real ordered list (design spec §Principles 6), each + * entry with its name for a screen reader and `aria-current="step"` on the + * one in view; the dots are the visual rendering of that list, not a + * separate progress bar. The counter is `aria-live` so a step change is + * announced without the heading having to carry the number. * - * Server component — every step page is also a server component and - * the shell carries no client state. Back / skip / next are plain - * ``s wrapping ` - ) : ( -
  • ); } - -function clampStep(step: number): 0 | 1 | 2 | 3 | 4 { - if (!Number.isFinite(step)) return 0; - const rounded = Math.floor(step); - if (rounded < 0) return 0; - if (rounded > 4) return 4; - return rounded as 0 | 1 | 2 | 3 | 4; -} diff --git a/src/components/onboarding/question-screen.tsx b/src/components/onboarding/question-screen.tsx new file mode 100644 index 000000000..a151437ee --- /dev/null +++ b/src/components/onboarding/question-screen.tsx @@ -0,0 +1,174 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { toast } from "sonner"; +import { + Activity, + Brain, + CalendarHeart, + Droplets, + FlaskConical, + HeartPulse, + Moon, + Scale, + Thermometer, + type LucideIcon, +} from "lucide-react"; + +import { ChoiceChip } from "@/components/onboarding/choice-chip"; +import { StepActions } from "@/components/onboarding/step-actions"; +import { StepHeading } from "@/components/onboarding/step-heading"; +import { + screenHref, + useOnboardingAnswer, +} from "@/components/onboarding/use-onboarding-flow"; +import { localizedApiError } from "@/lib/api/localized-error"; +import { useTranslations } from "@/lib/i18n/context"; +import { + hasEnteredOnboardingFlow, + type OnboardingStateDto, +} from "@/lib/onboarding/needs"; +import { + QUESTIONS, + questionAnswerBody, + questionOptionLabelKey, + questionPrefill, + questionSkipBody, + type QuestionStepId, +} from "@/lib/onboarding/question-config"; +import { nextScreen, previousScreen } from "@/lib/onboarding/wizard-steps"; + +/** + * v1.39 (C2) — one question per screen, Q1 to Q5. + * + * The chip set is the whole screen (design spec §Principles 3: one screen, + * one decision). The selection lives in component state while the screen is + * open, pre-filled from the stored answers so a returning person sees what + * they said; "Next" persists it through the answers route and navigates from + * the state the server hands back, so "where next" is the step machine's + * reading of the ledger the server wrote, not this screen's guess. + * + * Q1 is required and has no skip; every other question may be passed, and a + * pass records the pass without touching the stored answer. + */ +const AREA_ICONS: Readonly> = { + "blood-pressure": HeartPulse, + "weight-body": Scale, + glucose: Droplets, + sleep: Moon, + mood: Brain, + cycle: CalendarHeart, + activity: Activity, + labs: FlaskConical, + illness: Thermometer, +}; + +/** Answers that carry a second line under the label. */ +const OPTION_HINTS: Readonly> = { + "who.someone-else": "onboarding.flow.who.hints.someone-else", + "who.both": "onboarding.flow.who.hints.both", + "sources.apple-health": "onboarding.flow.sources.hints.apple-health", + "sources.file": "onboarding.flow.sources.hints.file", +}; + +export function QuestionScreen({ + step, + state, +}: { + step: QuestionStepId; + state: OnboardingStateDto; +}) { + const { t } = useTranslations(); + const router = useRouter(); + const question = QUESTIONS[step]; + const answer = useOnboardingAnswer(); + + const [selected, setSelected] = useState(() => + questionPrefill(step, state.needs), + ); + + const choose = (value: string) => { + setSelected((prev) => { + if (question.kind === "single") return [value]; + return prev.includes(value) + ? prev.filter((v) => v !== value) + : [...prev, value]; + }); + }; + + const body = questionAnswerBody(step, selected); + // Back from Q1 leads to the welcome screen only while the flow has not + // been entered: once Q1 is answered the front door resumes at the step + // owed, so a Back link there would bounce straight back here. + const previous = previousScreen(state, step); + const back = + previous === "welcome" && hasEnteredOnboardingFlow(state) ? null : previous; + + async function persist(input: NonNullable) { + try { + const written = await answer.mutateAsync(input); + router.push(screenHref(nextScreen(written, step) ?? "confirm")); + } catch (err) { + toast.error(localizedApiError(err, t, "onboarding.errorGeneric")); + } + } + + const titleId = `onboarding-${step}-title`; + + return ( +
    + + +
    + + {t(`onboarding.flow.${step}.title`)} + + {question.options.map((option) => { + const hintKey = OPTION_HINTS[`${step}.${option}`]; + return ( + + ); + })} +
    + + + void persist( + questionSkipBody(step as Exclude), + ) + : undefined + } + onNext={() => { + if (body) void persist(body); + }} + nextDisabled={body === null} + pending={answer.isPending} + /> +
    + ); +} diff --git a/src/components/onboarding/source-card-grid.tsx b/src/components/onboarding/source-card-grid.tsx deleted file mode 100644 index 4e872c0a6..000000000 --- a/src/components/onboarding/source-card-grid.tsx +++ /dev/null @@ -1,425 +0,0 @@ -"use client"; - -import { useState } from "react"; -import Link from "next/link"; -import { useRouter } from "next/navigation"; -import { useQueryClient } from "@tanstack/react-query"; -import { toast } from "sonner"; -import { - CheckCircle2, - ClipboardCheck, - FileUp, - Plug, - Watch, - Wifi, - type LucideIcon, -} from "lucide-react"; - -import { Button } from "@/components/ui/button"; -import { - pickStatus, - useIntegrationStatuses, -} from "@/components/settings/integrations/shared"; -import { useAuth } from "@/hooks/use-auth"; -import { useTranslations } from "@/lib/i18n/context"; -import { cn } from "@/lib/utils"; -import { apiPost } from "@/lib/api/api-fetch"; -import { localizedApiError } from "@/lib/api/localized-error"; -import { queryKeys } from "@/lib/query-keys"; - -/** - * Onboarding step 2 (source). - * - * Presents the data sources HealthLog ships today. The headline cards - * cover the two paths most people start on: - * 1. Manual entry (enabled, recommended — works on any device) - * 2. Withings — opens OAuth `/api/withings/connect` in a new tab once - * the account has its own Withings developer app configured; the - * callback flips the connection server-side. Before that, the card - * is honest about the prerequisite (v1.29.x, UX audit H1/H2): the - * CTA reads "Set up in Settings" and points at the credentials form - * instead of launching an OAuth handshake that can only 400. - * - * Below them a calm "more sources" row lists the remaining shipped - * integrations (Apple Health, WHOOP, Oura, Polar, Nightscout, Fitbit), - * each linking to Settings → Integrations where it is actually - * connected. We don't try to run six OAuth flows inside the wizard; - * the row sets the expectation and hands off. A discreet import link - * points at the Apple Health export.zip path — the cold-start escape - * hatch for users with existing history. - * - * Garmin is omitted: not shipped, so a card would over-claim. - * - * Selection is non-binding — Manual is the implicit default and the - * Next CTA always advances. Nothing here is auto-configured; the step - * only signposts where each source is connected. - */ - -interface SourceCard { - slug: "manual" | "withings"; - Icon: LucideIcon; - titleKey: string; - bodyKey: string; - /** - * One of: - * - "enabled-select": tap selects the card (Manual) - * - "enabled-oauth": tap opens OAuth in a new tab (Withings) - */ - state: "enabled-select" | "enabled-oauth"; - /** External href for `enabled-oauth`. */ - href?: string; - /** Optional recommended badge. */ - recommendedKey?: string; -} - -const SOURCE_CARDS: ReadonlyArray = [ - { - slug: "manual", - Icon: ClipboardCheck, - titleKey: "onboarding.source.manual.title", - bodyKey: "onboarding.source.manual.body", - state: "enabled-select", - recommendedKey: "onboarding.source.recommended", - }, - { - slug: "withings", - Icon: Watch, - titleKey: "onboarding.source.withings.title", - bodyKey: "onboarding.source.withings.body", - state: "enabled-oauth", - href: "/api/withings/connect", - }, -]; - -/** - * The remaining shipped integrations. Listed compactly so the step - * stays calm — the full connect flow for each lives in Settings. - */ -const MORE_SOURCES: ReadonlyArray<{ slug: string; labelKey: string }> = [ - { slug: "apple-health", labelKey: "onboarding.source.more.appleHealth" }, - { slug: "whoop", labelKey: "onboarding.source.more.whoop" }, - { slug: "oura", labelKey: "onboarding.source.more.oura" }, - { slug: "polar", labelKey: "onboarding.source.more.polar" }, - { slug: "nightscout", labelKey: "onboarding.source.more.nightscout" }, - { slug: "fitbit", labelKey: "onboarding.source.more.fitbit" }, -]; - -export function SourceCardGrid() { - const { t } = useTranslations(); - const router = useRouter(); - const queryClient = useQueryClient(); - const { isAuthenticated } = useAuth(); - - const [selected, setSelected] = useState("manual"); - const [advancing, setAdvancing] = useState(false); - - // v1.29.x — the wizard's "Connect Withings" card must not launch an OAuth - // handshake that can only fail: Withings credentials are per-user BYO with - // no env fallback, and a brand-new account never has them yet. Read the - // same consolidated envelope Settings → Integrations uses so the card - // knows whether the handshake can actually succeed. - const { data: integrationStatus } = useIntegrationStatuses(isAuthenticated); - const withingsConfigured = - pickStatus(integrationStatus, "withings")?.configured ?? false; - // 2026-07-17 UX-onboarding audit M6 — the connect CTA opens the OAuth - // handshake in a new tab; the callback lands THAT tab on - // Settings → Integrations while this wizard tab never learned the - // connection succeeded. `useIntegrationStatuses` already refetches on - // window focus, so returning to this tab after the OAuth round trip - // picks up the fresh `connected` flag for free — the only missing piece - // was rendering it as a badge instead of leaving the button looking - // untouched. - const withingsConnected = - pickStatus(integrationStatus, "withings")?.connected ?? false; - - async function advance() { - if (advancing) return; - setAdvancing(true); - try { - await apiPost("/api/onboarding/step", { step: 3 }); - await queryClient.invalidateQueries({ queryKey: queryKeys.auth() }); - router.push("/onboarding/3"); - } catch (err) { - toast.error(localizedApiError(err, t, "onboarding.errorGeneric")); - setAdvancing(false); - } - } - - return ( -
    -
    - {/* Onboarding hero H1: intentionally semibold, not the app-wide bold PageHeader H1 (UI-STANDARDS §5 hero exception). Do not sweep to font-bold. */} -

    - {t("onboarding.source.title")} -

    -

    - {t("onboarding.source.body")} -

    -
    - -
    - {SOURCE_CARDS.map((card) => ( - setSelected(card.slug)} - titleLabel={t(card.titleKey)} - bodyLabel={t(card.bodyKey)} - recommendedLabel={ - card.recommendedKey ? t(card.recommendedKey) : null - } - withingsCtaLabel={t("onboarding.source.withings.cta")} - withingsConfigured={withingsConfigured} - withingsConnected={withingsConnected} - withingsConnectedLabel={t("settings.integrationPill.connected")} - withingsSetupCtaLabel={t("onboarding.source.withings.setupCta")} - withingsSetupHint={t("onboarding.source.withings.setupHint")} - /> - ))} -
    - -
    -
    - -
    -

    - {t("onboarding.source.more.title")} -

    -

    - {t("onboarding.source.more.body")} -

    -
    -
    -
      - {MORE_SOURCES.map((item) => ( -
    • - -
    • - ))} -
    - {/* #778 — Apple Health looked like one more web connector here, but - there is nothing to connect on the web: the sync is set up in the - iOS app after onboarding. Say so, so nobody hunts Settings for an - OAuth flow that does not exist. The chip stays tappable (it lands - on the Integrations page, whose Apple Health card walks through - the iPhone-app setup) and Next / Skip advance as always. */} -

    - {t("onboarding.source.more.appleHealthHint")} -

    -
    - - - - {t("onboarding.source.import")} - - -
    - -
    - - -
    -
    -
    - ); -} - -interface SourceCardItemProps { - card: SourceCard; - selected: boolean; - onSelect: () => void; - titleLabel: string; - bodyLabel: string; - recommendedLabel: string | null; - withingsCtaLabel: string; - /** Whether the account already has its own Withings developer-app - * credentials saved — i.e. whether `/api/withings/connect` can - * actually succeed right now. */ - withingsConfigured: boolean; - /** Whether Withings is already connected (OAuth completed in the tab the - * connect CTA opened). Replaces the connect button with a badge so - * returning to this wizard tab shows the outcome instead of an - * unchanged card. */ - withingsConnected: boolean; - /** Localised "Connected" label for the badge above. */ - withingsConnectedLabel: string; - /** CTA label shown instead of "Connect Withings" when unconfigured. */ - withingsSetupCtaLabel: string; - /** One-sentence explanation of the BYO credential prerequisite. */ - withingsSetupHint: string; -} - -function SourceCardItem({ - card, - selected, - onSelect, - titleLabel, - bodyLabel, - recommendedLabel, - withingsCtaLabel, - withingsConfigured, - withingsConnected, - withingsConnectedLabel, - withingsSetupCtaLabel, - withingsSetupHint, -}: SourceCardItemProps) { - const Icon = card.Icon; - - const baseClasses = cn( - "relative flex flex-col gap-3 rounded-lg border p-4 text-left transition-colors duration-150 ease-out motion-reduce:transition-none", - "bg-card border-border", - ); - - const headerRow = ( -
    - -
    -

    {titleLabel}

    - {recommendedLabel ? ( - - {recommendedLabel} - - ) : null} -
    -
    - ); - - const body = ( -

    {bodyLabel}

    - ); - - if (card.state === "enabled-oauth") { - return ( -
    - {headerRow} - {body} - {withingsConnected ? ( -
    -
    - ) : withingsConfigured ? ( - - ) : ( - // No per-user Withings credentials yet — an OAuth handshake here - // can only 400 (Withings has no env fallback). Point at the - // credentials form instead of launching a doomed connect flow. -
    -

    {withingsSetupHint}

    - -
    - )} -
    - ); - } - - // enabled-select (Manual) - return ( - - ); -} diff --git a/src/components/onboarding/step-actions.tsx b/src/components/onboarding/step-actions.tsx new file mode 100644 index 000000000..c8168041f --- /dev/null +++ b/src/components/onboarding/step-actions.tsx @@ -0,0 +1,86 @@ +"use client"; + +import Link from "next/link"; +import { Loader2 } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { useTranslations } from "@/lib/i18n/context"; + +/** + * The action row every setup screen ends on: back on the left, skip and the + * primary on the right. One shape, so the buttons sit in the same place on + * every screen and a keyboard user's tab order never changes between them. + * + * Back is a link (a plain navigation to a screen that already has its + * answer), skip and next are buttons (they persist something first). Each + * one is optional: the welcome screen has no back, the confirm screen has no + * skip, the done screen has only the primary. + */ +export function StepActions({ + backHref, + onSkip, + onNext, + nextLabel, + nextDisabled = false, + pending = false, +}: { + backHref?: string; + onSkip?: () => void; + onNext?: () => void; + nextLabel?: string; + nextDisabled?: boolean; + pending?: boolean; +}) { + const { t } = useTranslations(); + + return ( +
    + {backHref ? ( + + ) : ( +
    + ); +} diff --git a/src/components/onboarding/step-heading.tsx b/src/components/onboarding/step-heading.tsx new file mode 100644 index 000000000..85b4645d7 --- /dev/null +++ b/src/components/onboarding/step-heading.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { useEffect, useRef, type ReactNode } from "react"; + +/** + * The `h1` of every setup screen, and the one place focus goes when a screen + * arrives. + * + * Moving focus to the heading on each step is what lets a screen-reader user + * follow the flow: without it, a client-side navigation leaves focus on the + * button that was just pressed, which no longer exists, and the new question + * is never announced. `preventScroll` keeps the viewport where the layout + * put it — the heading is at the top of the column anyway. + * + * Semibold rather than the app-wide bold PageHeader H1: the onboarding hero + * is the documented exception (UI-STANDARDS §5). Do not sweep to font-bold. + */ +export function StepHeading({ + id, + title, + description, +}: { + id: string; + title: ReactNode; + description?: ReactNode; +}) { + const ref = useRef(null); + + useEffect(() => { + ref.current?.focus({ preventScroll: true }); + }, []); + + return ( +
    +

    + {title} +

    + {description ? ( +

    + {description} +

    + ) : null} +
    + ); +} diff --git a/src/components/onboarding/units-screen.tsx b/src/components/onboarding/units-screen.tsx new file mode 100644 index 000000000..b2fec48bd --- /dev/null +++ b/src/components/onboarding/units-screen.tsx @@ -0,0 +1,131 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { toast } from "sonner"; + +import { ChoiceChip } from "@/components/onboarding/choice-chip"; +import { StepActions } from "@/components/onboarding/step-actions"; +import { StepHeading } from "@/components/onboarding/step-heading"; +import { + screenHref, + useOnboardingAnswer, +} from "@/components/onboarding/use-onboarding-flow"; +import { useAuth } from "@/hooks/use-auth"; +import { localizedApiError } from "@/lib/api/localized-error"; +import { useTranslations } from "@/lib/i18n/context"; +import type { OnboardingStateDto } from "@/lib/onboarding/needs"; +import { + unitQuestionsFor, + unitsAnswerBody, +} from "@/lib/onboarding/question-config"; +import { nextScreen, previousScreen } from "@/lib/onboarding/wizard-steps"; + +type GlucoseUnit = "mg/dL" | "mmol/L"; +type UnitPreference = "metric" | "imperial"; + +/** + * v1.39 (C2) — Q6, the optional units question. + * + * Shown only while an area that needs a unit is among the answers and the + * account does not already hold the preference (the step machine decides; + * this screen is never reached otherwise). Each of the two questions is + * asked only for the area that needs it, and each is pre-filled from what + * the flow recorded or, failing that, from the account's own column — so + * confirming what the account already had sends nothing for that field. + */ +export function UnitsScreen({ state }: { state: OnboardingStateDto }) { + const { t } = useTranslations(); + const router = useRouter(); + const { user } = useAuth(); + const answer = useOnboardingAnswer(); + const asked = unitQuestionsFor(state.needs); + + const [glucoseUnit, setGlucoseUnit] = useState( + () => + state.needs.units.glucoseUnit ?? + (user?.glucoseUnit === "mmol/L" || user?.glucoseUnit === "mg/dL" + ? user.glucoseUnit + : null), + ); + const [unitPreference, setUnitPreference] = useState( + () => state.needs.units.unitPreference ?? user?.unitPreference ?? null, + ); + + const back = previousScreen(state, "units"); + + async function persist( + input: + ReturnType | { step: "units"; status: "skipped" }, + ) { + try { + const written = await answer.mutateAsync(input); + router.push(screenHref(nextScreen(written, "units") ?? "confirm")); + } catch (err) { + toast.error(localizedApiError(err, t, "onboarding.errorGeneric")); + } + } + + return ( +
    + + + {asked.glucose ? ( +
    + + {t("onboarding.flow.units.glucoseLegend")} + + {(["mg/dL", "mmol/L"] as const).map((option) => ( + setGlucoseUnit(option)} + label={option} + /> + ))} +
    + ) : null} + + {asked.weight ? ( +
    + + {t("onboarding.flow.units.weightLegend")} + + {(["metric", "imperial"] as const).map((option) => ( + setUnitPreference(option)} + label={t(`onboarding.flow.units.options.${option}`)} + /> + ))} +
    + ) : null} + + void persist({ step: "units", status: "skipped" })} + onNext={() => + void persist(unitsAnswerBody({ asked, glucoseUnit, unitPreference })) + } + pending={answer.isPending} + /> +
    + ); +} diff --git a/src/components/onboarding/use-onboarding-flow.ts b/src/components/onboarding/use-onboarding-flow.ts new file mode 100644 index 000000000..235513334 --- /dev/null +++ b/src/components/onboarding/use-onboarding-flow.ts @@ -0,0 +1,87 @@ +"use client"; + +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiPatch, apiPost } from "@/lib/api/api-fetch"; +import type { OnboardingStateDto } from "@/lib/onboarding/needs"; +import { queryKeys } from "@/lib/query-keys"; +import type { OnboardingAnswerInput } from "@/lib/validations/onboarding-needs"; + +interface OnboardingStateEnvelope { + onboarding: OnboardingStateDto; +} + +/** + * The three writes the setup screens make, as mutations that hand back the + * published state. + * + * Every one of them returns the flow's state after the write, and every + * screen navigates from THAT rather than from what it thinks it just did — + * the step machine reads the same ledger the server wrote, so "where next" + * is never a client-side guess. `authMe` is invalidated because the account + * payload carries the same state and the checklist reads it from there. + */ +export function useOnboardingAnswer() { + const queryClient = useQueryClient(); + return useMutation({ + mutationKey: queryKeys.onboardingAnswerMutation(), + mutationFn: async (input: OnboardingAnswerInput) => { + const { onboarding } = await apiPatch( + "/api/onboarding/answers", + input, + ); + return onboarding; + }, + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: queryKeys.authMe() }); + }, + }); +} + +interface CompleteEnvelope { + completed: true; + onboarding?: OnboardingStateDto; +} + +/** + * `POST /api/onboarding/complete` — the confirm screen, and "skip for now". + * + * Profile values never ride this body: they go through `PUT /api/auth/profile` + * (the canonical write, with its per-field refusals). The one field the + * screen sends is `managedRecordId`, after "someone I look after" created the + * profile, so the derivation lands on that record and not on the caller's. + */ +export function useOnboardingComplete() { + const queryClient = useQueryClient(); + return useMutation({ + mutationKey: queryKeys.onboardingCompleteMutation(), + mutationFn: async (input?: { managedRecordId: string }) => + apiPost("/api/onboarding/complete", input ?? {}), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: queryKeys.authMe() }); + }, + }); +} + +/** `POST /api/onboarding/restart` — "Set up again". */ +export function useOnboardingRestart() { + const queryClient = useQueryClient(); + return useMutation({ + mutationKey: queryKeys.onboardingRestartMutation(), + mutationFn: async () => { + const { onboarding } = await apiPost( + "/api/onboarding/restart", + {}, + ); + return onboarding; + }, + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: queryKeys.authMe() }); + }, + }); +} + +/** The route of one setup screen. */ +export function screenHref(screen: string): string { + return screen === "welcome" ? "/onboarding" : `/onboarding/${screen}`; +} diff --git a/src/components/onboarding/welcome-carousel.tsx b/src/components/onboarding/welcome-carousel.tsx deleted file mode 100644 index 12c3d3ab3..000000000 --- a/src/components/onboarding/welcome-carousel.tsx +++ /dev/null @@ -1,340 +0,0 @@ -"use client"; - -import { useCallback, useEffect, useId, useRef, useState } from "react"; -import { useRouter } from "next/navigation"; -import { toast } from "sonner"; -import { useQueryClient } from "@tanstack/react-query"; -import { - ChevronLeft, - ChevronRight, - HeartPulse, - Plug, - Sparkles, - type LucideIcon, -} from "lucide-react"; - -import Link from "next/link"; - -import { Button } from "@/components/ui/button"; -import { Checkbox } from "@/components/ui/checkbox"; -import { useAuth } from "@/hooks/use-auth"; -import { useTranslations } from "@/lib/i18n/context"; -import { scrollBehaviorForUser } from "@/lib/motion"; -import { cn } from "@/lib/utils"; -import { apiPost } from "@/lib/api/api-fetch"; -import { localizedApiError } from "@/lib/api/localized-error"; -import { queryKeys } from "@/lib/query-keys"; -import { - DISCLAIMER_VERSION, - isDisclaimerAcknowledgmentCurrent, -} from "@/lib/onboarding/disclaimer"; - -/** - * v1.4.25 W14b-Content — onboarding welcome carousel. - * - * Three value-prop slides shown on `/onboarding/0` before any data is - * asked of the user. Mirrors the Withings / Strava pattern surfaced in - * the W14b research file (§2.1, §2.5): pitch the product, then ask. - * - * Slides: - * 1. Tagline — "Your health data, owned by you." - * 2. Multi-source — Apple Health + Withings + manual, one view. - * 3. AI Coach + Insights — evidence-grounded, your data only. - * - * The carousel is a CSS scroll-snap rail; arrow buttons + dot pager - * mirror the active slide. `prefers-reduced-motion` disables the smooth - * scroll behaviour (per WCAG 2.3.3 / research §4.6). - * - * The primary CTA ("Get started") POSTs `/api/onboarding/step` with - * `{ step: 1 }`. On success it invalidates the `auth` query (so the - * user-shape's `onboardingStep` re-reads) and pushes to `/onboarding/1`. - */ - -interface CarouselSlide { - id: "tagline" | "sources" | "coach"; - Icon: LucideIcon; - titleKey: string; - bodyKey: string; -} - -const SLIDES: ReadonlyArray = [ - { - id: "tagline", - Icon: HeartPulse, - titleKey: "onboarding.welcome.slide1.title", - bodyKey: "onboarding.welcome.slide1.body", - }, - { - id: "sources", - Icon: Plug, - titleKey: "onboarding.welcome.slide2.title", - bodyKey: "onboarding.welcome.slide2.body", - }, - { - id: "coach", - Icon: Sparkles, - titleKey: "onboarding.welcome.slide3.title", - bodyKey: "onboarding.welcome.slide3.body", - }, -]; - -export function WelcomeCarousel() { - const { t } = useTranslations(); - const router = useRouter(); - const queryClient = useQueryClient(); - const { user } = useAuth(); - - const railRef = useRef(null); - const liveRegionId = useId(); - const disclaimerId = useId(); - const [active, setActive] = useState(0); - const [advancing, setAdvancing] = useState(false); - // v1.18.6 (DISC-02) — the one-time medical-disclaimer acknowledgment gates - // "Get started". Pre-checked only when the account acknowledged the - // CURRENT disclaimer version (a re-walk of step 0), so a returning user is - // not re-asked — but a revised disclaimer re-prompts instead of riding an - // acknowledgment given for older text. - const disclaimerCurrent = isDisclaimerAcknowledgmentCurrent( - user?.disclaimerAcknowledgedAt, - user?.disclaimerAcknowledgedVersion, - ); - const [acknowledged, setAcknowledged] = useState(() => disclaimerCurrent); - - // Track which slide is centred in the rail. We observe each slide - // with an IntersectionObserver — simpler and more reliable than - // computing scroll-positions, and it handles touch flings naturally. - useEffect(() => { - const rail = railRef.current; - if (!rail) return; - - const slides = Array.from( - rail.querySelectorAll("[data-slide-index]"), - ); - if (slides.length === 0) return; - - const io = new IntersectionObserver( - (entries) => { - // Pick the entry whose intersection ratio is highest — that's - // the slide currently snapped into view. - let best: { idx: number; ratio: number } | null = null; - for (const e of entries) { - const idx = Number((e.target as HTMLElement).dataset.slideIndex); - if (!Number.isFinite(idx)) continue; - if (!best || e.intersectionRatio > best.ratio) { - best = { idx, ratio: e.intersectionRatio }; - } - } - if (best && best.ratio > 0.5) { - setActive(best.idx); - } - }, - { root: rail, threshold: [0.5, 0.75, 1] }, - ); - - for (const s of slides) io.observe(s); - return () => io.disconnect(); - }, []); - - const scrollToSlide = useCallback((idx: number) => { - const rail = railRef.current; - if (!rail) return; - const target = rail.querySelector( - `[data-slide-index="${idx}"]`, - ); - if (!target) return; - target.scrollIntoView({ - behavior: scrollBehaviorForUser(), - inline: "start", - block: "nearest", - }); - }, []); - - async function handleGetStarted() { - if (advancing || !acknowledged) return; - setAdvancing(true); - try { - // v1.18.6 (DISC-02) — record the acknowledgment before advancing. - // Skipped only when the account already acknowledged the CURRENT - // version (re-walk); an older-version acknowledgment re-records. - if (!disclaimerCurrent) { - await apiPost("/api/onboarding/disclaimer", { - version: DISCLAIMER_VERSION, - }); - } - await apiPost("/api/onboarding/step", { step: 1 }); - await queryClient.invalidateQueries({ queryKey: queryKeys.auth() }); - router.push("/onboarding/1"); - } catch (err) { - toast.error(localizedApiError(err, t, "onboarding.errorGeneric")); - setAdvancing(false); - } - } - - return ( -
    - {/* Onboarding hero H1: intentionally semibold, not the app-wide bold PageHeader H1 (UI-STANDARDS §5 hero exception). Do not sweep to font-bold. */} -

    - {t("onboarding.welcome.title")} -

    - -
    -
    - {SLIDES.map((slide, idx) => ( -
    - -

    - {t(slide.titleKey)} -

    -

    - {t(slide.bodyKey)} -

    -
    - ))} -
    - - {/* Single live region announces slide changes to AT users — - slides themselves carry `aria-label="slide N of total"` so a - second sr-only mirror would double-announce on every move. - The carousel-level region wins because it's tied to the - chevron + dot pager via `aria-controls`. */} -

    - {t("onboarding.welcome.slideOf", { - current: active + 1, - total: SLIDES.length, - })} -

    -
    - -
    - - -
    - {SLIDES.map((slide, idx) => ( - - ))} -
    - - -
    - - {/* v1.18.6 (DISC-02) — one-time medical-disclaimer acknowledgment. This - replaces the per-page / per-chart disclaimer banners removed app-wide - in the same release; the full legal text stays reachable on the - public privacy page (link below). */} -
    - setAcknowledged(next === true)} - className="mt-0.5" - /> - -
    - -
    - -
    -
    - ); -} diff --git a/src/components/onboarding/welcome-screen.tsx b/src/components/onboarding/welcome-screen.tsx new file mode 100644 index 000000000..736f4b69d --- /dev/null +++ b/src/components/onboarding/welcome-screen.tsx @@ -0,0 +1,193 @@ +"use client"; + +import { useId, useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useMutation } from "@tanstack/react-query"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { StepHeading } from "@/components/onboarding/step-heading"; +import { + screenHref, + useOnboardingComplete, + useOnboardingRestart, +} from "@/components/onboarding/use-onboarding-flow"; +import { useAuth } from "@/hooks/use-auth"; +import { apiPost } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; +import { useTranslations } from "@/lib/i18n/context"; +import { markChecklistExpanded } from "@/lib/onboarding/checklist-storage"; +import { + DISCLAIMER_VERSION, + isDisclaimerAcknowledgmentCurrent, +} from "@/lib/onboarding/disclaimer"; +import { queryKeys } from "@/lib/query-keys"; + +/** + * v1.39 (C2) — the screen before the first question. + * + * One sentence on what happens and how long it takes, "Set up", and "Skip for + * now" (design spec §The screens, 1). The skip is a real exit: it stamps the + * account as past the first-run redirect and lands on the dashboard with the + * checklist open, and the questions stay one click away in Settings. + * + * The one-time medical disclaimer gates BOTH buttons. It used to gate only + * "Get started", which was fine while the wizard was the only way past this + * screen; a skip that reached the dashboard without it would be a way round + * the acknowledgment. Pre-checked only when the account acknowledged the + * CURRENT version, so a revised disclaimer re-prompts. + * + * `variant="again"` is the same screen for an account whose setup is + * finished: it offers the questions again (through the restart route, which + * resets the ledger and keeps the answers as prefill) or the dashboard. + */ +export function WelcomeScreen({ variant }: { variant: "fresh" | "again" }) { + const { t } = useTranslations(); + const router = useRouter(); + const { user } = useAuth(); + const disclaimerId = useId(); + + const disclaimerCurrent = isDisclaimerAcknowledgmentCurrent( + user?.disclaimerAcknowledgedAt, + user?.disclaimerAcknowledgedVersion, + ); + const [acknowledged, setAcknowledged] = useState(() => disclaimerCurrent); + + const acknowledge = useMutation({ + mutationKey: queryKeys.onboardingDisclaimerMutation(), + mutationFn: async () => { + if (disclaimerCurrent) return; + await apiPost("/api/onboarding/disclaimer", { + version: DISCLAIMER_VERSION, + }); + }, + }); + const complete = useOnboardingComplete(); + const restart = useOnboardingRestart(); + const pending = + acknowledge.isPending || complete.isPending || restart.isPending; + + const fail = (err: unknown) => + toast.error(localizedApiError(err, t, "onboarding.errorGeneric")); + + async function setUp() { + if (pending || !acknowledged) return; + try { + await acknowledge.mutateAsync(); + router.push(screenHref("who")); + } catch (err) { + fail(err); + } + } + + async function skipForNow() { + if (pending || !acknowledged) return; + try { + await acknowledge.mutateAsync(); + await complete.mutateAsync(undefined); + markChecklistExpanded(); + router.push("/"); + } catch (err) { + fail(err); + } + } + + async function setUpAgain() { + if (pending) return; + try { + await restart.mutateAsync(); + router.push(screenHref("who")); + } catch (err) { + fail(err); + } + } + + if (variant === "again") { + return ( +
    + +
    + + +
    +
    + ); + } + + return ( +
    + + +

    {t("onboarding.flow.welcome.changeLater")}

    + + {/* One-time medical-disclaimer acknowledgment; the full text stays + reachable on the public privacy page. */} +
    + setAcknowledged(next === true)} + className="mt-0.5" + data-slot="onboarding-disclaimer" + /> + +
    + +
    + + +
    +
    + ); +} diff --git a/src/components/profile/__tests__/height-entry-surfaces.test.tsx b/src/components/profile/__tests__/height-entry-surfaces.test.tsx index 97af6e93a..86661e422 100644 --- a/src/components/profile/__tests__/height-entry-surfaces.test.tsx +++ b/src/components/profile/__tests__/height-entry-surfaces.test.tsx @@ -73,7 +73,12 @@ function renderUnder( const SURFACES = [ { name: "onboarding baseline step", - node: , + node: ( + {}} + /> + ), firstInputId: "ob-baseline-height", metricLabel: "Height (cm)", }, diff --git a/src/components/settings/access/managed-profile-create-form.tsx b/src/components/settings/access/managed-profile-create-form.tsx index 9b6e91606..ee62c9a2e 100644 --- a/src/components/settings/access/managed-profile-create-form.tsx +++ b/src/components/settings/access/managed-profile-create-form.tsx @@ -15,6 +15,7 @@ import { useTranslations } from "@/lib/i18n/context"; import { useCreateManagedProfile, type ManagedProfileGender, + type ManagedProfileView, } from "@/lib/queries/use-managed-profiles"; import { DEFAULT_TIMEZONE, @@ -73,7 +74,19 @@ import { * invitation form does, rather than the generic "could not be created", which * would send somebody looking for a problem with what they typed. */ -export function ManagedProfileCreateForm() { +export function ManagedProfileCreateForm({ + submitLabel, + onCreated, +}: { + /** The button's label when the form sits somewhere other than the panel. */ + submitLabel?: string; + /** + * v1.39 (C2) — fired after a successful creation, with the record. The + * setup flow's confirm screen mounts this form and continues from here; + * the sharing panel leaves it unset and shows the confirmation line. + */ + onCreated?: (profile: ManagedProfileView) => void; +} = {}) { const { t, locale: actorLocale } = useTranslations(); const create = useCreateManagedProfile(); @@ -116,6 +129,7 @@ export function ManagedProfileCreateForm() { setGender(null); setTimezone(browserTimezone()); setCreated(profile.displayName ?? trimmedName); + onCreated?.(profile); }, // Nothing is cleared here, on purpose: a refused creation is one the // person is being asked to retry, and a form that emptied itself @@ -258,7 +272,7 @@ export function ManagedProfileCreateForm() { > {create.isPending ? t("recordSharing.managed.creating") - : t("recordSharing.managed.create")} + : (submitLabel ?? t("recordSharing.managed.create"))} {error && ( diff --git a/src/components/settings/account-section/index.tsx b/src/components/settings/account-section/index.tsx index 21335c2b7..e07b9c7fa 100644 --- a/src/components/settings/account-section/index.tsx +++ b/src/components/settings/account-section/index.tsx @@ -48,6 +48,7 @@ import { type StatusMessage, } from "./account-section-utils"; import { AvatarSection } from "./avatar-section"; +import { SetupCard } from "./setup-card"; export { resolveInitialTimezone } from "./account-section-utils"; @@ -330,6 +331,10 @@ export function AccountSection() { {/* Profile photo card */} + {/* v1.39 (C2) — the way back into the first-run experience: the setup + questions again, and the dashboard checklist this browser hid. */} + +
    diff --git a/src/components/settings/account-section/setup-card.tsx b/src/components/settings/account-section/setup-card.tsx new file mode 100644 index 000000000..47d19d28d --- /dev/null +++ b/src/components/settings/account-section/setup-card.tsx @@ -0,0 +1,91 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { toast } from "sonner"; +import { ListChecks, Loader2 } from "lucide-react"; + +import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; + +import { Button } from "@/components/ui/button"; +import { SettingsCard } from "@/components/settings/settings-card"; +import { SettingsCardActions } from "@/components/settings/_card-actions"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { useOnboardingRestart } from "@/components/onboarding/use-onboarding-flow"; +import { localizedApiError } from "@/lib/api/localized-error"; +import { useTranslations } from "@/lib/i18n/context"; +import { resetChecklistDismissals } from "@/lib/onboarding/checklist-storage"; + +/** + * v1.39 (C2) — Settings → Account → Setup. + * + * The two ways back into the first-run experience (design spec §Principles 4 + * and §After the flow). "Set up again" asks the questions once more through + * the restart route, which resets the ledger and keeps the answers as + * prefill — and writes no module state, so a module switched on by hand is + * never switched off by a re-run. "Show the checklist again" forgets what + * this browser hid; the checklist's dismissals live in the browser, so the + * button lives on the same origin. + */ +export function SetupCard() { + const { t } = useTranslations(); + const router = useRouter(); + const restart = useOnboardingRestart(); + + async function setUpAgain() { + if (restart.isPending) return; + try { + await restart.mutateAsync(); + router.push("/onboarding/who"); + } catch (err) { + toast.error(localizedApiError(err, t, "onboarding.errorGeneric")); + } + } + + function showChecklist() { + // Something was hidden and is back: a written outcome. Nothing was + // hidden: an empty one — there was nothing to bring back. + if (resetChecklistDismissals()) { + toastWrittenOutcome("success", t("settings.setup.checklistShown")); + } else { + toastWrittenOutcome("empty", t("settings.setup.checklistNothingHidden")); + } + } + + return ( + + + + + + + + ); +} diff --git a/src/components/settings/gesundheitsakte-section.tsx b/src/components/settings/gesundheitsakte-section.tsx index 34424799d..0bee41ea5 100644 --- a/src/components/settings/gesundheitsakte-section.tsx +++ b/src/components/settings/gesundheitsakte-section.tsx @@ -43,6 +43,8 @@ export function GesundheitsakteSection() {
    diff --git a/src/components/ui/section-heading.tsx b/src/components/ui/section-heading.tsx index 9022b1f00..df567211c 100644 --- a/src/components/ui/section-heading.tsx +++ b/src/components/ui/section-heading.tsx @@ -53,6 +53,12 @@ interface SectionHeadingProps { action?: ReactNode; /** Optional id on the `

    `, e.g. for an `aria-labelledby` link. */ id?: string; + /** + * Stable spotlight-tour anchor, rendered as `data-tour-id` on the root. + * The nav model carries the same field for its entries; a stop whose + * anchor is a settings group heading names it here. + */ + tourId?: string; className?: string; } @@ -62,11 +68,13 @@ export function SectionHeading({ subtitle, action, id, + tourId, className, }: SectionHeadingProps) { return (
    (), + upcomingVisitCount: 0, + managedProfileCount: 0, onboarding: null, ...overrides, }; @@ -141,6 +147,113 @@ describe("buildChecklist", () => { }); }); +describe("upcomingVisitCountFrom (the visits list's wire shape)", () => { + it("counts the upcoming array of the body `GET /api/encounters` publishes", () => { + // The route answers `{ upcoming, past }` as data and publishes no meta; + // the row read `meta.upcoming` once and could never flip to done. + const body: EncounterListDTO = { upcoming: [], past: [] }; + expect(upcomingVisitCountFrom(body)).toBe(0); + const two: EncounterListDTO = { + upcoming: [{ id: "v1" }, { id: "v2" }] as unknown as EncounterDTO[], + past: [], + }; + expect(upcomingVisitCountFrom(two)).toBe(2); + }); + + it("reads a missing or malformed body as no visit", () => { + expect(upcomingVisitCountFrom(null)).toBe(0); + expect(upcomingVisitCountFrom(undefined)).toBe(0); + expect( + upcomingVisitCountFrom({ upcoming: 3 as unknown as unknown[] }), + ).toBe(0); + }); +}); + +describe("answers given for somebody else's record (v1.39 C2)", () => { + it("adds the profile row and reads none of the child's answers as the guardian's", () => { + const items = buildChecklist( + inputs({ + onboarding: onboardingState( + {}, + { + recordTarget: "someone-else", + visit: "within-a-month", + sources: ["oura"], + }, + ), + }), + ); + const ids = items.map((i) => i.id); + expect(ids).toContain("managedProfile"); + expect(ids).not.toContain("visit"); + expect(items.find((i) => i.id === "managedProfile")).toMatchObject({ + done: false, + href: "/settings/access", + }); + expect(ids[1]).toBe("managedProfile"); + }); + + it("is done once a managed profile exists, and offered to both as well", () => { + const both = buildChecklist( + inputs({ + onboarding: onboardingState({}, { recordTarget: "both" }), + managedProfileCount: 1, + }), + ); + expect(both.find((i) => i.id === "managedProfile")?.done).toBe(true); + const me = buildChecklist( + inputs({ onboarding: onboardingState({}, { recordTarget: "me" }) }), + ); + expect(me.map((i) => i.id)).not.toContain("managedProfile"); + }); +}); + +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", + "managedProfile", + ]); + }); +}); + describe("visibleChecklist + checklistProgress", () => { it("hides per-item dismissed rows from the visible list", () => { const items = buildChecklist( @@ -180,6 +293,8 @@ describe("visibleChecklist + checklistProgress", () => { notificationsConfigured: false, insightsConfigured: false, dismissedIds: new Set(), + upcomingVisitCount: 0, + managedProfileCount: 0, }); const progress = checklistProgress(items); expect(progress.percent).toBe(0); @@ -332,7 +447,7 @@ describe("checklistOrderFromNeeds", () => { 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_ITEM_IDS, + ...CHECKLIST_BASE_ITEM_IDS, ]); }); @@ -347,7 +462,7 @@ describe("buildChecklist, ordered by the setup answers", () => { ); // Re-ordering the dashboard under somebody mid-question would be movement // they did not ask for. - expect(items.map((i) => i.id)).toEqual([...CHECKLIST_ITEM_IDS]); + expect(items.map((i) => i.id)).toEqual([...CHECKLIST_BASE_ITEM_IDS]); }); it("orders the rows from the answers once the flow is confirmed", () => { @@ -365,7 +480,7 @@ describe("buildChecklist, ordered by the setup answers", () => { "dataSource", ]); expect([...items.map((i) => i.id)].sort()).toEqual( - [...CHECKLIST_ITEM_IDS].sort(), + [...CHECKLIST_BASE_ITEM_IDS].sort(), ); }); diff --git a/src/lib/onboarding/__tests__/confirm-summary.test.ts b/src/lib/onboarding/__tests__/confirm-summary.test.ts new file mode 100644 index 000000000..3ced0603a --- /dev/null +++ b/src/lib/onboarding/__tests__/confirm-summary.test.ts @@ -0,0 +1,68 @@ +/** + * v1.39 (C2) — the confirm screen's list is the registry's derivation and + * nothing else. + */ +import { describe, expect, it } from "vitest"; + +import { + deriveOnboardingModuleDefaults, + ONBOARDING_ALWAYS_ON_MODULES, + OWNED_MODULE_KEYS, +} from "@/lib/modules/registry"; + +import { confirmedModules } from "../confirm-summary"; +import { emptyOnboardingNeeds } from "../needs"; + +describe("confirmedModules", () => { + it("lists exactly what the derivation switches on, the always-on trio aside", () => { + const needs = { + ...emptyOnboardingNeeds(), + recordTarget: "me" as const, + areas: ["glucose" as const, "sleep" as const], + medication: "yes" as const, + visit: "within-a-month" as const, + }; + const derived = deriveOnboardingModuleDefaults(needs); + const expected = OWNED_MODULE_KEYS.filter( + (key) => + derived.preferences[key] && !ONBOARDING_ALWAYS_ON_MODULES.includes(key), + ); + const { chosen, alwaysOn } = confirmedModules(needs); + expect(chosen).toEqual(expected); + expect(chosen).toContain("medications"); + expect(chosen).toContain("doctorReport"); + // Registry order, so the two lists read in the same order everywhere. + expect(alwaysOn).toEqual( + OWNED_MODULE_KEYS.filter((key) => + ONBOARDING_ALWAYS_ON_MODULES.includes(key), + ), + ); + }); + + it("names cycle when the area was ticked, though the map never owns it", () => { + const { chosen } = confirmedModules({ + ...emptyOnboardingNeeds(), + recordTarget: "me", + areas: ["cycle"], + }); + expect(chosen).toContain("cycle"); + }); + + it("reads a skipped question as the route does — nothing switched on for it", () => { + const { chosen } = confirmedModules({ + ...emptyOnboardingNeeds(), + recordTarget: "me", + }); + expect(chosen).not.toContain("medications"); + expect(chosen).not.toContain("doctorReport"); + }); + + it("switches on the immunization log for a record somebody else runs", () => { + expect( + confirmedModules({ + ...emptyOnboardingNeeds(), + recordTarget: "someone-else", + }).chosen, + ).toContain("vaccinations"); + }); +}); diff --git a/src/lib/onboarding/__tests__/dashboard-seed.test.ts b/src/lib/onboarding/__tests__/dashboard-seed.test.ts new file mode 100644 index 000000000..3aefce98b --- /dev/null +++ b/src/lib/onboarding/__tests__/dashboard-seed.test.ts @@ -0,0 +1,102 @@ +/** + * v1.39 (C2) — the dashboard order the setup answers imply. + * + * The ordering half of Q2. Pins that every area maps only to tiles the + * layout knows, that the seed promotes and forces visible without dropping a + * tile, and that answers which speak to no tile seed nothing at all. + */ +import { describe, expect, it } from "vitest"; + +import { + DEFAULT_DASHBOARD_LAYOUT, + type DashboardWidgetId, +} from "@/lib/dashboard-layout"; +import { ONBOARDING_AREA_KEYS } from "@/lib/modules/registry"; + +import { + AREA_WIDGET_SEED_MAP, + buildNeedsSeededDashboardLayout, + promotedWidgetsFor, +} from "../dashboard-seed"; + +describe("AREA_WIDGET_SEED_MAP", () => { + it("names every area, and only tiles the layout actually knows", () => { + const known = new Set( + DEFAULT_DASHBOARD_LAYOUT.widgets.map((w) => w.id as DashboardWidgetId), + ); + for (const area of ONBOARDING_AREA_KEYS) { + expect(AREA_WIDGET_SEED_MAP[area]).toBeDefined(); + for (const id of AREA_WIDGET_SEED_MAP[area]) { + expect(known.has(id), `${area} → ${id}`).toBe(true); + } + } + }); +}); + +describe("buildNeedsSeededDashboardLayout", () => { + it("seeds nothing for answers that speak to no tile", () => { + expect( + buildNeedsSeededDashboardLayout({ areas: [], medication: null }), + ).toBeNull(); + expect( + buildNeedsSeededDashboardLayout({ + areas: ["labs", "illness", "cycle"], + medication: "no", + }), + ).toBeNull(); + }); + + it("promotes glucose to the top and forces it visible", () => { + const layout = buildNeedsSeededDashboardLayout({ + areas: ["glucose"], + medication: null, + }); + expect(layout).not.toBeNull(); + expect(layout!.widgets.find((w) => w.id === "glucose")).toMatchObject({ + order: 0, + visible: true, + tileVisible: true, + }); + }); + + it("promotes every tile of a multi-area answer, and the medication tile for a schedule", () => { + const layout = buildNeedsSeededDashboardLayout({ + areas: ["weight-body", "blood-pressure"], + medication: "sometimes", + }); + const promoted = new Set( + promotedWidgetsFor({ + areas: ["weight-body", "blood-pressure"], + medication: "sometimes", + }), + ); + expect(promoted).toEqual( + new Set([ + "weight", + "bodyFat", + "bp", + "bpInTarget", + "pulse", + "medications", + ]), + ); + for (const id of promoted) { + const w = layout!.widgets.find((x) => x.id === id); + expect(w?.visible).toBe(true); + expect(w?.tileVisible).toBe(true); + expect(w!.order).toBeLessThan(promoted.size); + } + }); + + it("keeps a dense, complete widget set", () => { + const layout = buildNeedsSeededDashboardLayout({ + areas: ["sleep"], + medication: "no", + }); + expect(layout!.widgets.length).toBe( + DEFAULT_DASHBOARD_LAYOUT.widgets.length, + ); + const orders = layout!.widgets.map((w) => w.order).sort((a, b) => a - b); + expect(orders).toEqual(orders.map((_, i) => i)); + }); +}); diff --git a/src/lib/onboarding/__tests__/first-result-config.test.ts b/src/lib/onboarding/__tests__/first-result-config.test.ts new file mode 100644 index 000000000..40e8aa56f --- /dev/null +++ b/src/lib/onboarding/__tests__/first-result-config.test.ts @@ -0,0 +1,40 @@ +/** + * v1.39 (C2) — every Q2 area and every connectable Q4 source has a target + * on a surface that exists, so the first-result screen can never be handed + * a task it cannot render. + */ +import { describe, expect, it } from "vitest"; + +import { ONBOARDING_AREA_KEYS } from "@/lib/modules/registry"; +import { NAV_DESTINATIONS } from "@/components/layout/nav-model"; + +import { + AREA_PAGE_HREF, + AREA_READING_TARGETS, + SOURCE_INTEGRATION, +} from "../first-result-config"; +import { BROWSER_CONNECTABLE_SOURCES } from "../wizard-steps"; + +describe("first-result targets", () => { + it("covers every area with exactly one of a form or a page", () => { + for (const area of ONBOARDING_AREA_KEYS) { + const hasForm = area in AREA_READING_TARGETS; + const hasPage = area in AREA_PAGE_HREF; + expect(hasForm !== hasPage, area).toBe(true); + } + }); + + it("points every linked area at a destination the navigation knows", () => { + const hrefs = new Set(NAV_DESTINATIONS.map((d) => d.href)); + for (const href of Object.values(AREA_PAGE_HREF)) { + expect(hrefs.has(href), href).toBe(true); + } + }); + + it("names an integration for every source the browser can connect", () => { + for (const source of BROWSER_CONNECTABLE_SOURCES) { + expect(SOURCE_INTEGRATION[source].anchor).toBeTruthy(); + expect(SOURCE_INTEGRATION[source].statusKey).toBeTruthy(); + } + }); +}); diff --git a/src/lib/onboarding/__tests__/goals.test.ts b/src/lib/onboarding/__tests__/goals.test.ts deleted file mode 100644 index 039be8b02..000000000 --- a/src/lib/onboarding/__tests__/goals.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -/** - * v1.17.1 — onboarding goal slug enum + dashboard seeding. - * - * Goals are a personalization seed, not a clinical target store: the - * only leverage they have is which dashboard tiles get promoted and - * made visible. These tests pin (a) the closed slug guard and (b) the - * one-time seed builder's promote/forces-visible/no-op contracts. - */ -import { describe, it, expect } from "vitest"; - -import { - ONBOARDING_GOAL_SLUGS, - GOAL_WIDGET_SEED_MAP, - isOnboardingGoalSlug, - buildGoalSeededDashboardLayout, -} from "../goals"; -import { - DEFAULT_DASHBOARD_LAYOUT, - type DashboardWidgetId, -} from "@/lib/dashboard-layout"; - -describe("isOnboardingGoalSlug", () => { - it("accepts every member of the closed slug set", () => { - for (const slug of ONBOARDING_GOAL_SLUGS) { - expect(isOnboardingGoalSlug(slug)).toBe(true); - } - }); - - it("rejects unknown / non-string values", () => { - expect(isOnboardingGoalSlug("not-a-goal")).toBe(false); - expect(isOnboardingGoalSlug("")).toBe(false); - expect(isOnboardingGoalSlug(null)).toBe(false); - expect(isOnboardingGoalSlug(42)).toBe(false); - }); -}); - -describe("GOAL_WIDGET_SEED_MAP", () => { - it("only maps to ids the dashboard layout actually knows", () => { - const known = new Set( - DEFAULT_DASHBOARD_LAYOUT.widgets.map((w) => w.id as DashboardWidgetId), - ); - for (const ids of Object.values(GOAL_WIDGET_SEED_MAP)) { - for (const id of ids) expect(known.has(id)).toBe(true); - } - }); - - it("leaves general-wellness without a tile preference", () => { - expect(GOAL_WIDGET_SEED_MAP["general-wellness"]).toEqual([]); - }); -}); - -describe("buildGoalSeededDashboardLayout", () => { - it("returns null for an empty selection (no seed)", () => { - expect(buildGoalSeededDashboardLayout([])).toBeNull(); - }); - - it("returns null for a general-wellness-only selection", () => { - expect(buildGoalSeededDashboardLayout(["general-wellness"])).toBeNull(); - }); - - it("ignores unknown slugs and seeds nothing when only unknowns are passed", () => { - expect(buildGoalSeededDashboardLayout(["bogus", "nope"])).toBeNull(); - }); - - it("promotes glucose to the top and forces it visible", () => { - const layout = buildGoalSeededDashboardLayout(["glucose-tracking"]); - expect(layout).not.toBeNull(); - const widgets = layout!.widgets; - // glucose is default-invisible + ordered low; the seed lifts it to - // order 0 and turns both surfaces on. - const glucose = widgets.find((w) => w.id === "glucose"); - expect(glucose).toMatchObject({ - order: 0, - visible: true, - tileVisible: true, - }); - }); - - it("promotes every mapped tile for a multi-goal selection", () => { - const layout = buildGoalSeededDashboardLayout([ - "weight-management", - "bp-tracking", - ]); - expect(layout).not.toBeNull(); - const widgets = layout!.widgets; - const promotedIds = new Set(["weight", "bodyFat", "bp", "bpInTarget"]); - // Every promoted id sits in the leading block and is visible. - for (const id of promotedIds) { - const w = widgets.find((x) => x.id === id); - expect(w?.visible).toBe(true); - expect(w?.tileVisible).toBe(true); - expect(w!.order).toBeLessThan(promotedIds.size); - } - }); - - it("keeps a dense, complete widget set (no tiles dropped)", () => { - const layout = buildGoalSeededDashboardLayout(["sleep-improvement"]); - expect(layout!.widgets.length).toBe( - DEFAULT_DASHBOARD_LAYOUT.widgets.length, - ); - const orders = layout!.widgets.map((w) => w.order).sort((a, b) => a - b); - expect(orders).toEqual(orders.map((_, i) => i)); - }); -}); diff --git a/src/lib/onboarding/__tests__/question-config.test.ts b/src/lib/onboarding/__tests__/question-config.test.ts new file mode 100644 index 000000000..0151f4204 --- /dev/null +++ b/src/lib/onboarding/__tests__/question-config.test.ts @@ -0,0 +1,137 @@ +/** + * v1.39 (C2) — the chip questions as data, and the two translations the + * screen relies on: stored answers → chips to pre-select, and a selection → + * the strict body the answers route accepts. + */ +import { describe, expect, it } from "vitest"; + +import { ONBOARDING_AREA_KEYS } from "@/lib/modules/registry"; +import { onboardingAnswerSchema } from "@/lib/validations/onboarding-needs"; + +import { emptyOnboardingNeeds, ONBOARDING_SOURCE_KEYS } from "../needs"; +import { + QUESTION_STEP_IDS, + QUESTIONS, + questionAnswerBody, + questionPrefill, + questionSkipBody, + unitQuestionsFor, + unitsAnswerBody, +} from "../question-config"; + +describe("QUESTIONS", () => { + it("offers every value of each server vocabulary, in the server's order", () => { + expect(QUESTIONS.areas.options).toEqual(ONBOARDING_AREA_KEYS); + expect(QUESTIONS.sources.options).toEqual(ONBOARDING_SOURCE_KEYS); + expect(QUESTIONS.who.options).toEqual(["me", "someone-else", "both"]); + }); + + it("makes Q1 the one question that cannot be passed", () => { + expect(QUESTIONS.who.skippable).toBe(false); + for (const step of QUESTION_STEP_IDS.filter((s) => s !== "who")) { + expect(QUESTIONS[step].skippable).toBe(true); + } + }); +}); + +describe("questionPrefill", () => { + it("reads the stored answer of each step back as a selection", () => { + const needs = { + ...emptyOnboardingNeeds(), + recordTarget: "both" as const, + areas: ["glucose" as const, "mood" as const], + medication: "sometimes" as const, + sources: ["oura" as const], + visit: "later" as const, + }; + expect(questionPrefill("who", needs)).toEqual(["both"]); + expect(questionPrefill("areas", needs)).toEqual(["glucose", "mood"]); + expect(questionPrefill("medication", needs)).toEqual(["sometimes"]); + expect(questionPrefill("sources", needs)).toEqual(["oura"]); + expect(questionPrefill("visit", needs)).toEqual(["later"]); + }); + + it("is empty for a question never answered", () => { + const needs = emptyOnboardingNeeds(); + for (const step of QUESTION_STEP_IDS) { + expect(questionPrefill(step, needs)).toEqual([]); + } + }); +}); + +describe("questionAnswerBody", () => { + it("builds a body the answers route accepts, for every step", () => { + const bodies = [ + questionAnswerBody("who", ["me"]), + questionAnswerBody("areas", ["glucose", "labs"]), + questionAnswerBody("medication", ["yes"]), + questionAnswerBody("sources", ["manual", "withings"]), + questionAnswerBody("visit", ["within-a-month"]), + ]; + for (const body of bodies) { + expect(body).not.toBeNull(); + expect(onboardingAnswerSchema.safeParse(body).success).toBe(true); + } + }); + + it("is null for a single-answer question with nothing chosen", () => { + expect(questionAnswerBody("who", [])).toBeNull(); + expect(questionAnswerBody("medication", [])).toBeNull(); + expect(questionAnswerBody("visit", [])).toBeNull(); + }); + + it("sends an empty list for a many-answer question with nothing chosen", () => { + expect(questionAnswerBody("areas", [])).toEqual({ + step: "areas", + areas: [], + }); + }); + + it("drops a value the step's vocabulary does not contain", () => { + expect(questionAnswerBody("areas", ["glucose", "no-such-area"])).toEqual({ + step: "areas", + areas: ["glucose"], + }); + expect(questionAnswerBody("who", ["nobody"])).toBeNull(); + }); + + it("passes a step with the skip arm and nothing else", () => { + const body = questionSkipBody("visit"); + expect(body).toEqual({ step: "visit", status: "skipped" }); + expect(onboardingAnswerSchema.safeParse(body).success).toBe(true); + }); +}); + +describe("units", () => { + it("asks only about the units the ticked areas need", () => { + expect( + unitQuestionsFor({ ...emptyOnboardingNeeds(), areas: ["glucose"] }), + ).toEqual({ glucose: true, weight: false }); + expect( + unitQuestionsFor({ + ...emptyOnboardingNeeds(), + areas: ["weight-body", "glucose"], + }), + ).toEqual({ glucose: true, weight: true }); + }); + + it("sends only the units that were asked and chosen", () => { + const body = unitsAnswerBody({ + asked: { glucose: true, weight: false }, + glucoseUnit: "mmol/L", + unitPreference: "imperial", + }); + expect(body).toEqual({ step: "units", units: { glucoseUnit: "mmol/L" } }); + expect(onboardingAnswerSchema.safeParse(body).success).toBe(true); + }); + + it("sends an empty answer when nothing was chosen, which leaves the columns alone", () => { + expect( + unitsAnswerBody({ + asked: { glucose: true, weight: true }, + glucoseUnit: null, + unitPreference: null, + }), + ).toEqual({ step: "units", units: {} }); + }); +}); diff --git a/src/lib/onboarding/__tests__/wizard-steps.test.ts b/src/lib/onboarding/__tests__/wizard-steps.test.ts new file mode 100644 index 000000000..bbb82e687 --- /dev/null +++ b/src/lib/onboarding/__tests__/wizard-steps.test.ts @@ -0,0 +1,445 @@ +/** + * v1.39 (C2) — the step machine behind the setup screens. + * + * Pure, so every question the screens ask — which screen is next, where a + * returning person resumes, whether a URL may be visited, what the counter + * says — is answered here without a browser. The server publishes the ledger + * and the answers; this file turns them into a route. + */ +import { describe, expect, it } from "vitest"; + +import { + defaultOnboardingSteps, + emptyOnboardingNeeds, + type OnboardingNeeds, + type OnboardingStateDto, + type OnboardingStepId, + type OnboardingStepStatus, +} from "../needs"; +import { + BROWSER_CONNECTABLE_SOURCES, + canVisitScreen, + isOnboardingPathname, + questionsToPassBeforeConfirm, + chooseFirstResultTask, + firstResultApplies, + nextScreen, + previousScreen, + questionScreens, + resumeScreen, + screenOrder, + stepCounter, + unitsQuestionApplies, +} from "../wizard-steps"; + +function state( + statuses: Partial> = {}, + needs: Partial = {}, + extra: Partial> = {}, +): OnboardingStateDto { + return { + steps: defaultOnboardingSteps().map((step) => ({ + ...step, + status: statuses[step.id] ?? step.status, + })), + needs: { ...emptyOnboardingNeeds(), ...needs }, + completedAt: extra.completedAt ?? null, + firstResult: extra.firstResult ?? null, + }; +} + +/** Every question answered, no units question in play. */ +const QUESTIONS_DONE = { + who: "done", + areas: "done", + medication: "done", + sources: "done", + visit: "done", +} as const; + +describe("unitsQuestionApplies", () => { + it("is off while no area needs a unit", () => { + expect(unitsQuestionApplies(state({}, { areas: ["mood", "sleep"] }))).toBe( + false, + ); + }); + + it("is on for glucose or weight while the account holds no preference", () => { + expect(unitsQuestionApplies(state({}, { areas: ["glucose"] }))).toBe(true); + expect(unitsQuestionApplies(state({}, { areas: ["weight-body"] }))).toBe( + true, + ); + }); + + it("is off when the server resolved units from the account's own columns", () => { + // `units: done` with no recorded answer is exactly what + // `resolveOnboardingSteps` publishes for an account that holds both. + expect( + unitsQuestionApplies(state({ units: "done" }, { areas: ["glucose"] })), + ).toBe(false); + }); + + it("stays in the order once the flow itself recorded the answer", () => { + expect( + unitsQuestionApplies( + state( + { units: "done" }, + { + areas: ["glucose"], + units: { glucoseUnit: "mmol/L", unitPreference: null }, + }, + ), + ), + ).toBe(true); + }); + + it("stays in the order after a deliberate skip, so it can be revisited", () => { + expect( + unitsQuestionApplies(state({ units: "skipped" }, { areas: ["glucose"] })), + ).toBe(true); + }); +}); + +describe("questionScreens", () => { + it("is the five questions, plus units only when it applies", () => { + expect(questionScreens(state())).toEqual([ + "who", + "areas", + "medication", + "sources", + "visit", + ]); + expect(questionScreens(state({}, { areas: ["glucose"] }))).toEqual([ + "who", + "areas", + "medication", + "sources", + "visit", + "units", + ]); + }); +}); + +describe("chooseFirstResultTask", () => { + it("prefers a connection the browser can complete, in the person's own order", () => { + expect( + chooseFirstResultTask({ + ...emptyOnboardingNeeds(), + sources: ["manual", "apple-health", "oura", "withings"], + medication: "yes", + areas: ["blood-pressure"], + }), + ).toEqual({ task: "connect-source", target: "oura" }); + }); + + it("never offers a source the browser cannot connect", () => { + for (const source of ["manual", "apple-health", "file"] as const) { + expect(BROWSER_CONNECTABLE_SOURCES).not.toContain(source); + expect( + chooseFirstResultTask({ ...emptyOnboardingNeeds(), sources: [source] }), + ).toBeNull(); + } + }); + + it("offers the first medication only for a daily schedule", () => { + expect( + chooseFirstResultTask({ + ...emptyOnboardingNeeds(), + medication: "yes", + areas: ["mood"], + }), + ).toEqual({ task: "add-medication", target: null }); + expect( + chooseFirstResultTask({ + ...emptyOnboardingNeeds(), + medication: "sometimes", + areas: ["mood"], + }), + ).toEqual({ task: "log-reading", target: "mood" }); + }); + + it("falls back to one reading for the first area, and to nothing", () => { + expect( + chooseFirstResultTask({ + ...emptyOnboardingNeeds(), + areas: ["weight-body", "glucose"], + }), + ).toEqual({ task: "log-reading", target: "weight-body" }); + expect(chooseFirstResultTask(emptyOnboardingNeeds())).toBeNull(); + }); +}); + +describe("firstResultApplies", () => { + it("is off when there is no task to offer", () => { + expect(firstResultApplies(state(QUESTIONS_DONE))).toBe(false); + }); + + it("is off for a record somebody else will run", () => { + // The three tasks write the CALLER's record, and the routes refuse under + // a switch — so for "someone I look after" the profile is created on the + // confirm screen and the flow ends there. + expect( + firstResultApplies( + state(QUESTIONS_DONE, { + recordTarget: "someone-else", + areas: ["blood-pressure"], + }), + ), + ).toBe(false); + expect( + firstResultApplies( + state(QUESTIONS_DONE, { + recordTarget: "both", + areas: ["blood-pressure"], + }), + ), + ).toBe(true); + }); +}); + +describe("screenOrder", () => { + it("runs welcome, the questions, confirm, the first result, done", () => { + expect( + screenOrder( + state( + {}, + { recordTarget: "me", areas: ["glucose"], medication: "yes" }, + ), + ), + ).toEqual([ + "welcome", + "who", + "areas", + "medication", + "sources", + "visit", + "units", + "confirm", + "first-result", + "done", + ]); + }); + + it("drops the first-result screen when nothing can be offered", () => { + expect(screenOrder(state({}, { recordTarget: "me" }))).toEqual([ + "welcome", + "who", + "areas", + "medication", + "sources", + "visit", + "confirm", + "done", + ]); + }); +}); + +describe("resumeScreen", () => { + it("starts a record that never entered the flow on the welcome screen", () => { + expect(resumeScreen(state())).toBe("welcome"); + }); + + it("resumes at the first question still pending", () => { + expect(resumeScreen(state({ who: "done" }, { recordTarget: "me" }))).toBe( + "areas", + ); + expect( + resumeScreen( + state( + { who: "done", areas: "skipped", medication: "done" }, + { recordTarget: "me", medication: "no" }, + ), + ), + ).toBe("sources"); + }); + + it("puts a restarted flow back on the first question even though the answers remain", () => { + // `POST /api/onboarding/restart` resets the ledger and keeps the answers + // as prefill: every step pending, Q1 answered. + expect( + resumeScreen( + state( + {}, + { recordTarget: "me", areas: ["glucose"], medication: "yes" }, + ), + ), + ).toBe("who"); + }); + + it("goes to confirm once every question is settled", () => { + expect(resumeScreen(state(QUESTIONS_DONE, { recordTarget: "me" }))).toBe( + "confirm", + ); + }); + + it("skips a units question the account already answers", () => { + expect( + resumeScreen( + state( + { ...QUESTIONS_DONE, units: "done" }, + { recordTarget: "me", areas: ["glucose"] }, + ), + ), + ).toBe("confirm"); + }); + + it("stops on the units question while it is still owed", () => { + expect( + resumeScreen( + state(QUESTIONS_DONE, { recordTarget: "me", areas: ["glucose"] }), + ), + ).toBe("units"); + }); + + it("offers the first result after confirm, then done", () => { + const confirmed = state( + { ...QUESTIONS_DONE, confirm: "done" }, + { recordTarget: "me", medication: "yes" }, + { completedAt: "2026-09-10T08:00:00.000Z" }, + ); + expect(resumeScreen(confirmed)).toBe("first-result"); + expect( + resumeScreen({ + ...confirmed, + steps: confirmed.steps.map((s) => + s.id === "first-result" ? { ...s, status: "skipped" as const } : s, + ), + }), + ).toBe("done"); + }); + + it("goes straight to done after confirm when no task applies", () => { + expect( + resumeScreen( + state( + { ...QUESTIONS_DONE, confirm: "done" }, + { recordTarget: "me" }, + { completedAt: "2026-09-10T08:00:00.000Z" }, + ), + ), + ).toBe("done"); + }); +}); + +describe("nextScreen / previousScreen", () => { + const s = state({}, { recordTarget: "me", areas: ["weight-body"] }); + + it("walks the order in both directions", () => { + expect(nextScreen(s, "welcome")).toBe("who"); + expect(nextScreen(s, "visit")).toBe("units"); + expect(nextScreen(s, "units")).toBe("confirm"); + expect(nextScreen(s, "confirm")).toBe("first-result"); + expect(nextScreen(s, "done")).toBeNull(); + expect(previousScreen(s, "who")).toBe("welcome"); + expect(previousScreen(s, "welcome")).toBeNull(); + expect(previousScreen(s, "confirm")).toBe("units"); + }); + + it("steps over the units question when it does not apply", () => { + const noUnits = state({}, { recordTarget: "me", areas: ["mood"] }); + expect(nextScreen(noUnits, "visit")).toBe("confirm"); + expect(previousScreen(noUnits, "confirm")).toBe("visit"); + }); + + it("answers null for a screen that is not in this flow's order", () => { + const noUnits = state({}, { recordTarget: "me", areas: ["mood"] }); + expect(nextScreen(noUnits, "units")).toBeNull(); + }); +}); + +describe("canVisitScreen", () => { + const midway = state( + { who: "done", areas: "done" }, + { recordTarget: "me", areas: ["glucose"] }, + ); + + it("allows every screen up to the resume point and refuses the ones ahead", () => { + expect(canVisitScreen(midway, "welcome")).toBe(true); + expect(canVisitScreen(midway, "who")).toBe(true); + expect(canVisitScreen(midway, "medication")).toBe(true); + expect(canVisitScreen(midway, "sources")).toBe(false); + expect(canVisitScreen(midway, "confirm")).toBe(false); + expect(canVisitScreen(midway, "done")).toBe(false); + }); + + it("always admits the first question, which is where the welcome screen goes", () => { + expect(canVisitScreen(state(), "who")).toBe(true); + expect(canVisitScreen(state(), "areas")).toBe(false); + }); + + it("refuses a screen the order does not contain", () => { + const noUnits = state(QUESTIONS_DONE, { + recordTarget: "me", + areas: ["mood"], + }); + expect(canVisitScreen(noUnits, "units")).toBe(false); + expect(canVisitScreen(noUnits, "first-result")).toBe(false); + }); +}); + +describe("questionsToPassBeforeConfirm", () => { + it("passes the units question the flow never showed, and nothing shown", () => { + // No unit-bearing area: Q6 is hidden, still pending, and the completion + // route would otherwise answer "incomplete" — the first real run's find. + expect( + questionsToPassBeforeConfirm( + state(QUESTIONS_DONE, { recordTarget: "me", areas: ["mood"] }), + ), + ).toEqual(["units"]); + // Units shown and answered: nothing to pass. + expect( + questionsToPassBeforeConfirm( + state( + { ...QUESTIONS_DONE, units: "done" }, + { + recordTarget: "me", + areas: ["glucose"], + units: { glucoseUnit: "mg/dL", unitPreference: null }, + }, + ), + ), + ).toEqual([]); + }); + + it("never passes a question the person still owes on screen", () => { + expect( + questionsToPassBeforeConfirm( + state({ who: "done" }, { recordTarget: "me", areas: ["mood"] }), + ), + ).toEqual(["units"]); + }); + + it("leaves the first-result step to the confirm screen's own rule", () => { + expect( + questionsToPassBeforeConfirm( + state(QUESTIONS_DONE, { recordTarget: "me" }), + ), + ).not.toContain("first-result"); + }); +}); + +describe("isOnboardingPathname", () => { + it("covers the front door and every step, and nothing that only starts alike", () => { + expect(isOnboardingPathname("/onboarding")).toBe(true); + expect(isOnboardingPathname("/onboarding/who")).toBe(true); + expect(isOnboardingPathname("/onboarding/first-result")).toBe(true); + expect(isOnboardingPathname("/onboarding-export")).toBe(false); + expect(isOnboardingPathname("/")).toBe(false); + }); +}); + +describe("stepCounter", () => { + it("counts the questions only, at the size this flow actually has", () => { + expect(stepCounter(state(), "who")).toEqual({ current: 1, total: 5 }); + expect(stepCounter(state({}, { areas: ["glucose"] }), "units")).toEqual({ + current: 6, + total: 6, + }); + }); + + it("has nothing to say on the screens that are not questions", () => { + expect(stepCounter(state(), "welcome")).toBeNull(); + expect(stepCounter(state(), "confirm")).toBeNull(); + expect(stepCounter(state(), "done")).toBeNull(); + }); +}); diff --git a/src/lib/onboarding/checklist-storage.ts b/src/lib/onboarding/checklist-storage.ts new file mode 100644 index 000000000..df4669783 --- /dev/null +++ b/src/lib/onboarding/checklist-storage.ts @@ -0,0 +1,56 @@ +/** + * The getting-started checklist's per-browser state. + * + * Three keys, all of them conveniences rather than record: which rows this + * browser hid, whether the whole card was hidden, and whether it is open. + * They were private to the card until the setup flow needed two of them — + * the done screen and "skip for now" open the list, and Settings brings a + * hidden list back — and a second copy of a storage key is how two surfaces + * end up writing different ones. + * + * Every access is wrapped: storage can be full, disabled, or absent, and the + * card renders correctly with no stored value. + */ +export const CHECKLIST_DISMISSED_ITEMS_KEY = + "healthlog-getting-started-dismissed"; +export const CHECKLIST_DISMISSED_ALL_KEY = "healthlog-getting-started-hidden"; +export const CHECKLIST_EXPANDED_KEY = "healthlog-getting-started-expanded"; + +function storage(): Storage | null { + if (typeof window === "undefined") return null; + try { + return window.localStorage; + } catch { + return null; + } +} + +/** Open the checklist on the next dashboard visit. */ +export function markChecklistExpanded(): void { + try { + storage()?.setItem(CHECKLIST_EXPANDED_KEY, "1"); + } catch { + /* storage may be full or disabled */ + } +} + +/** + * "Show the checklist again": forget every dismissal and open the list. + * Returns whether anything was stored to forget, so the caller can say + * "it is back" rather than "done" to somebody who never hid it. + */ +export function resetChecklistDismissals(): boolean { + const store = storage(); + if (!store) return false; + try { + const hadState = + store.getItem(CHECKLIST_DISMISSED_ALL_KEY) === "1" || + (store.getItem(CHECKLIST_DISMISSED_ITEMS_KEY) ?? "[]") !== "[]"; + store.removeItem(CHECKLIST_DISMISSED_ITEMS_KEY); + store.removeItem(CHECKLIST_DISMISSED_ALL_KEY); + store.setItem(CHECKLIST_EXPANDED_KEY, "1"); + return hadState; + } catch { + return false; + } +} diff --git a/src/lib/onboarding/checklist.ts b/src/lib/onboarding/checklist.ts index b67450e7d..c0fda5294 100644 --- a/src/lib/onboarding/checklist.ts +++ b/src/lib/onboarding/checklist.ts @@ -20,10 +20,21 @@ export const CHECKLIST_ITEM_IDS = [ "dataSource", "notifications", "insights", + // v1.39 (C2) — present only for an answer that named a visit within a + // month (design spec §After the flow: "prepare the visit"). + "visit", + // v1.39 (C2) — "someone I look after" (or "both") with no managed profile + // yet: the answers were given for a record that does not exist, and this + // row is where it gets created. + "managedProfile", ] as const; export type ChecklistItemId = (typeof CHECKLIST_ITEM_IDS)[number]; +/** The rows every record has; the visit row joins only when asked for. */ +export const CHECKLIST_BASE_ITEM_IDS: readonly ChecklistItemId[] = + CHECKLIST_ITEM_IDS.filter((id) => id !== "visit" && id !== "managedProfile"); + export interface ChecklistItem { id: ChecklistItemId; done: boolean; @@ -71,6 +82,17 @@ export interface ChecklistInputs { insightsConfigured: boolean; /** Dismissed item ids (per-item localStorage state). */ dismissedIds: ReadonlySet; + /** + * v1.39 (C2) — visits booked from today on, for the "prepare the visit" + * row. Read only while the row can exist, so a caller that never asked the + * question passes zero. + */ + upcomingVisitCount: number; + /** + * v1.39 (C2) — managed profiles this account looks after, for the row that + * asks a guardian to create the one their answers were about. + */ + managedProfileCount: number; /** * v1.39 (C1) — the needs-based setup state from `GET /api/auth/me`, or null * for a record that never entered the flow. @@ -103,6 +125,19 @@ export function checklistOrderFromNeeds( if (!promoted.includes(id)) promoted.push(id); }; + // Answers given for somebody else's record say nothing about the + // guardian's own dashboard: the one row they promote is the profile. + if (needs.recordTarget === "someone-else") { + promote("managedProfile"); + return [ + "profile", + ...promoted, + ...CHECKLIST_ITEM_IDS.filter( + (id) => id !== "profile" && !promoted.includes(id), + ), + ]; + } + if (needs.medication === "yes" || needs.medication === "sometimes") { promote("medication"); } @@ -112,6 +147,7 @@ export function checklistOrderFromNeeds( promote("dataSource"); } if (needs.areas.length > 0) promote("measurement"); + if (needs.visit === "within-a-month") promote("visit"); return [ "profile", @@ -125,8 +161,8 @@ export function checklistOrderFromNeeds( /** * Compute the ordered checklist for the dashboard hero. Stable order: * profile → measurement → medication → dataSource → notifications → - * insights. Each item carries the deep-link the row's CTA should - * navigate to. + * insights, plus the visit row for an answer that named one. Each item + * carries the deep-link the row's CTA should navigate to. */ export function buildChecklist(inputs: ChecklistInputs): ChecklistItem[] { const profileDone = isProfileComplete(inputs.profile); @@ -169,6 +205,30 @@ export function buildChecklist(inputs: ChecklistInputs): ChecklistItem[] { }, ]; if (!inputs.onboarding) return items; + // The visit row exists only for the answer that asked for it; every other + // record keeps the six rows it had. Done once a visit is on the calendar. + const target = inputs.onboarding.needs.recordTarget; + if ( + target !== "someone-else" && + inputs.onboarding.needs.visit === "within-a-month" + ) { + items.push({ + id: "visit", + done: inputs.upcomingVisitCount >= 1, + href: "/checkups", + dismissed: inputs.dismissedIds.has("visit"), + }); + } + // The profile the answers were about. Offered to "both" as well, which + // the spec says gets the profile at the end. + if (target === "someone-else" || target === "both") { + items.push({ + id: "managedProfile", + done: inputs.managedProfileCount >= 1, + href: "/settings/access", + dismissed: inputs.dismissedIds.has("managedProfile"), + }); + } // 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. @@ -251,6 +311,19 @@ export function shouldShowChecklist(args: { return visible.some((item) => !item.done); } +/** + * The upcoming-visit count off the visits list's own body. `GET + * /api/encounters` answers `{ upcoming, past }` as DATA — there is no `meta` + * on that wire, and a reader that looked for one counted zero forever (the + * "prepare the visit" row could never flip). Pure, so the shape is pinned + * against the DTO the route publishes. + */ +export function upcomingVisitCountFrom( + body: { upcoming: readonly unknown[] } | null | undefined, +): number { + return Array.isArray(body?.upcoming) ? body.upcoming.length : 0; +} + /** * Profile is "complete" once height, date of birth and gender are all * set. Display name is captured automatically at signup, so it doesn't diff --git a/src/lib/onboarding/confirm-summary.ts b/src/lib/onboarding/confirm-summary.ts new file mode 100644 index 000000000..1dfb28c9b --- /dev/null +++ b/src/lib/onboarding/confirm-summary.ts @@ -0,0 +1,52 @@ +/** + * v1.39 (C2) — what the confirm screen says is switched on. + * + * A READ of the registry's derivation, never a second mapping: the list the + * person confirms is `deriveOnboardingModuleDefaults` applied to the same + * answers `POST /api/onboarding/complete` will apply it to, with the same + * conservative defaults for a skipped question. A wizard that computed its + * own list would drift from the one the route actually writes, and the + * confirm screen would then be confirming something else. + * + * What the route writes on top of this — the merge that never retracts a + * module switched on by hand or one holding data — only ever ADDS to the + * list, which is why the sentence beside it says "everything else stays + * under Settings" rather than "is off". + */ +import { + deriveOnboardingModuleDefaults, + MODULE_KEYS, + ONBOARDING_ALWAYS_ON_MODULES, + type ModuleKey, +} from "@/lib/modules/registry"; + +import type { OnboardingNeeds } from "./needs"; + +export interface ConfirmedModules { + /** What the answers switch on, in registry order — the always-on trio aside. */ + chosen: ModuleKey[]; + /** On whatever the answers say. */ + alwaysOn: ModuleKey[]; +} + +export function confirmedModules(needs: OnboardingNeeds): ConfirmedModules { + const derived = deriveOnboardingModuleDefaults({ + // Q1 is required and a confirm screen is never reached without it; the + // fallback only keeps the type total. + recordTarget: needs.recordTarget ?? "me", + areas: needs.areas, + // The same defaults the route applies to a skipped question. + medication: needs.medication ?? "no", + visit: needs.visit ?? "no", + }); + const on = new Set(); + for (const [key, value] of Object.entries(derived.preferences)) { + if (value) on.add(key as ModuleKey); + } + if (derived.cycleTracking) on.add("cycle"); + const alwaysOn = new Set(ONBOARDING_ALWAYS_ON_MODULES); + return { + chosen: MODULE_KEYS.filter((key) => on.has(key) && !alwaysOn.has(key)), + alwaysOn: MODULE_KEYS.filter((key) => alwaysOn.has(key)), + }; +} diff --git a/src/lib/onboarding/dashboard-seed.ts b/src/lib/onboarding/dashboard-seed.ts new file mode 100644 index 000000000..9ae090e27 --- /dev/null +++ b/src/lib/onboarding/dashboard-seed.ts @@ -0,0 +1,100 @@ +/** + * v1.39 (C2) — the dashboard tile order the setup answers imply. + * + * "Enables the matching modules and orders the dashboard" (design spec §The + * questions, Q2): the module half lives in the registry's derivation, this + * is the ordering half. Each area names the tiles it is about; the seed + * promotes them to the top of the default layout and forces them visible on + * both surfaces, and leaves every other tile exactly where the default puts + * it. A daily medication schedule promotes the medication tile the same way. + * + * ONE-TIME, and the caller gates it: the completion route writes the seed + * only while `User.dashboardWidgetsJson` is still unset, so a person who + * already arranged their tiles is never clobbered, and a concurrent layout + * save that lands first wins. Not a clinical target store — targets are + * derived elsewhere; this only decides what is on top. + * + * Replaces the six goal slugs of the five-step wizard, whose only durable + * effect was this same seed. + */ +import { + DEFAULT_DASHBOARD_LAYOUT, + serializeDashboardLayout, + type DashboardLayout, + type DashboardWidgetId, +} from "@/lib/dashboard-layout"; +import type { OnboardingAreaKey } from "@/lib/modules/registry"; + +import type { OnboardingNeeds } from "./needs"; + +/** + * Area → the tiles to promote. An area whose surface is not a dashboard tile + * (cycle, labs, illness) maps to nothing; its module is still switched on. + * Ids must be members of `DASHBOARD_WIDGET_IDS`; the seed never invents one. + */ +export const AREA_WIDGET_SEED_MAP: Readonly< + Record +> = Object.freeze({ + "blood-pressure": ["bp", "bpInTarget", "pulse"], + "weight-body": ["weight", "bodyFat"], + glucose: ["glucose"], + sleep: ["sleep"], + mood: ["mood"], + cycle: [], + activity: ["steps"], + labs: [], + illness: [], +}); + +/** Q3 "yes" or "sometimes" promotes the medication tile. */ +const MEDICATION_WIDGET_SEED: readonly DashboardWidgetId[] = ["medications"]; + +/** + * The tiles the answers promote, in the default layout's order. Empty when + * the answers speak to no tile, which is the "leave the column unset" case. + */ +export function promotedWidgetsFor( + needs: Pick, +): DashboardWidgetId[] { + const promoted = new Set(); + for (const area of needs.areas) { + for (const id of AREA_WIDGET_SEED_MAP[area] ?? []) promoted.add(id); + } + if (needs.medication === "yes" || needs.medication === "sometimes") { + for (const id of MEDICATION_WIDGET_SEED) promoted.add(id); + } + return DEFAULT_DASHBOARD_LAYOUT.widgets + .map((w) => w.id as DashboardWidgetId) + .filter((id) => promoted.has(id)); +} + +/** + * Build the seeded layout, or `null` when there is nothing to promote so the + * caller can skip the write entirely and leave the column unset (the default + * layout). Every tile keeps its default config; the promoted ones move to the + * front, in their default relative order, and are forced visible on both + * surfaces. The result is dense: `order` is 0..n-1 with no tile dropped. + */ +export function buildNeedsSeededDashboardLayout( + needs: Pick, +): DashboardLayout | null { + const promoted = new Set(promotedWidgetsFor(needs)); + if (promoted.size === 0) return null; + + const base = DEFAULT_DASHBOARD_LAYOUT.widgets; + const front = base.filter((w) => promoted.has(w.id as DashboardWidgetId)); + const rest = base.filter((w) => !promoted.has(w.id as DashboardWidgetId)); + + const ordered = [...front, ...rest].map((w, index) => ({ + ...w, + ...(promoted.has(w.id as DashboardWidgetId) + ? { visible: true, tileVisible: true } + : {}), + order: index, + })); + + return serializeDashboardLayout({ + ...DEFAULT_DASHBOARD_LAYOUT, + widgets: ordered, + }); +} diff --git a/src/lib/onboarding/first-result-config.ts b/src/lib/onboarding/first-result-config.ts new file mode 100644 index 000000000..5b10a99bc --- /dev/null +++ b/src/lib/onboarding/first-result-config.ts @@ -0,0 +1,65 @@ +/** + * v1.39 (C2) — what each first-result task needs to render, as data. + * + * The task itself is chosen by the step machine (`chooseFirstResultTask`); + * this file says what a task's target maps to on the surfaces that already + * exist: which measurement form and which stored types a Q2 area means, + * which integration a Q4 source is, and which page carries an area the + * measurement form cannot log. + */ +import type { OnboardingAreaKey } from "@/lib/modules/registry"; + +import type { BrowserConnectableSource } from "./wizard-steps"; + +export interface AreaReadingTarget { + /** The `defaultType` the measurement form opens on. */ + formType: string; + /** The stored types the reading lands as, read back for the result tile. */ + storedTypes: readonly string[]; +} + +/** + * The areas one reading in the measurement form can answer. Blood pressure + * is one form mode over two stored rows; the rest are one type each. Mood, + * cycle, labs and illness have their own surfaces and are linked instead + * (`AREA_PAGE_HREF`). + */ +export const AREA_READING_TARGETS: Readonly< + Partial> +> = Object.freeze({ + "blood-pressure": { + formType: "BLOOD_PRESSURE", + storedTypes: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"], + }, + "weight-body": { formType: "WEIGHT", storedTypes: ["WEIGHT"] }, + glucose: { formType: "BLOOD_GLUCOSE", storedTypes: ["BLOOD_GLUCOSE"] }, + sleep: { formType: "SLEEP_DURATION", storedTypes: ["SLEEP_DURATION"] }, + activity: { formType: "ACTIVITY_STEPS", storedTypes: ["ACTIVITY_STEPS"] }, +}); + +/** Where an area the form cannot log is logged instead. */ +export const AREA_PAGE_HREF: Readonly< + Partial> +> = Object.freeze({ + mood: "/mood", + cycle: "/cycle", + labs: "/labs", + illness: "/illness", +}); + +/** + * The connections panel's anchor for each connectable source — the `id` of + * the provider card on Settings → Integrations, and the key its status is + * published under on `/api/integrations/status`. + */ +export const SOURCE_INTEGRATION: Readonly< + Record +> = Object.freeze({ + withings: { anchor: "withings", statusKey: "withings" }, + oura: { anchor: "oura", statusKey: "oura" }, + whoop: { anchor: "whoop", statusKey: "whoop" }, + polar: { anchor: "polar", statusKey: "polar" }, + fitbit: { anchor: "fitbit", statusKey: "fitbit" }, + strava: { anchor: "strava", statusKey: "strava" }, + nightscout: { anchor: "nightscout", statusKey: "nightscout" }, +}); diff --git a/src/lib/onboarding/goals.ts b/src/lib/onboarding/goals.ts deleted file mode 100644 index bf5385d91..000000000 --- a/src/lib/onboarding/goals.ts +++ /dev/null @@ -1,112 +0,0 @@ -/** - * v1.17.1 — onboarding goal slugs + dashboard seeding. - * - * The closed set of goal slugs the GoalsChipPicker offers, plus the - * server-authoritative seeding that turns a goal selection into a - * personalization signal: which dashboard tiles get promoted to the - * top and made visible. Goals are NOT a clinical target store — the - * targets surface (`/api/insights/targets`) is fully derived from - * height/age/gender + clinical references. Their only leverage is the - * one-time dashboard tile pinning below, applied by - * `/api/onboarding/step` while the layout is still unset. Nothing else - * reads `User.onboardingGoals` — the column is collected, seeded from - * once, and then inert. - * - * One home for the slug enum so the API validation - * (`/api/onboarding/step`), the client picker, and the seeding all - * read the same source — same-key drift was the v1.4.16 dashboard-enum - * bug class. - */ -import { - DEFAULT_DASHBOARD_LAYOUT, - serializeDashboardLayout, - type DashboardLayout, - type DashboardWidgetId, -} from "@/lib/dashboard-layout"; - -/** Closed slug set — mirrors `OnboardingGoalSlug` in GoalsChipPicker. */ -export const ONBOARDING_GOAL_SLUGS = [ - "weight-management", - "bp-tracking", - "glucose-tracking", - "sleep-improvement", - "medication-compliance", - "general-wellness", -] as const; - -export type OnboardingGoalSlug = (typeof ONBOARDING_GOAL_SLUGS)[number]; - -export function isOnboardingGoalSlug( - value: unknown, -): value is OnboardingGoalSlug { - return ( - typeof value === "string" && - (ONBOARDING_GOAL_SLUGS as readonly string[]).includes(value) - ); -} - -/** - * Slug → dashboard widget ids to promote. `general-wellness` maps to - * nothing — it carries no tile preference, so a general-wellness-only - * selection leaves the default layout untouched. Ids must be members - * of `DASHBOARD_WIDGET_IDS`; the seed never invents an id. - */ -export const GOAL_WIDGET_SEED_MAP: Record< - OnboardingGoalSlug, - readonly DashboardWidgetId[] -> = { - "weight-management": ["weight", "bodyFat"], - "bp-tracking": ["bp", "bpInTarget"], - "glucose-tracking": ["glucose"], - "sleep-improvement": ["sleep"], - "medication-compliance": ["medications"], - "general-wellness": [], -}; - -/** - * Build the seeded dashboard layout for a goal selection. - * - * Returns a layout where the goal-mapped tiles are promoted to the top - * of the order (preserving their relative order within the default - * layout) and forced visible on both surfaces; every other tile keeps - * its default config, shifted down. Returns `null` when there is - * nothing to promote (empty selection or general-wellness only) so the - * caller can skip the write entirely and leave the column null → - * default layout. - * - * This is a ONE-TIME seed: the caller MUST gate it on - * `User.dashboardWidgetsJson == null` so it never clobbers a user who - * already arranged tiles. - */ -export function buildGoalSeededDashboardLayout( - goals: readonly string[] | null | undefined, -): DashboardLayout | null { - if (!goals || goals.length === 0) return null; - const promoted = new Set(); - for (const slug of goals) { - if (!isOnboardingGoalSlug(slug)) continue; - for (const id of GOAL_WIDGET_SEED_MAP[slug]) promoted.add(id); - } - if (promoted.size === 0) return null; - - const base = DEFAULT_DASHBOARD_LAYOUT.widgets; - const promotedWidgets = base.filter((w) => - promoted.has(w.id as DashboardWidgetId), - ); - const rest = base.filter((w) => !promoted.has(w.id as DashboardWidgetId)); - - const ordered = [...promotedWidgets, ...rest].map((w, index) => ({ - ...w, - // Force the goal-mapped tiles visible on both surfaces; leave the - // rest exactly as the default layout had them. - ...(promoted.has(w.id as DashboardWidgetId) - ? { visible: true, tileVisible: true } - : {}), - order: index, - })); - - return serializeDashboardLayout({ - ...DEFAULT_DASHBOARD_LAYOUT, - widgets: ordered, - }); -} diff --git a/src/lib/onboarding/load-flow-state.ts b/src/lib/onboarding/load-flow-state.ts new file mode 100644 index 000000000..75debf729 --- /dev/null +++ b/src/lib/onboarding/load-flow-state.ts @@ -0,0 +1,40 @@ +/** + * v1.39 (C2) — the setup flow's state, for the server-rendered pages. + * + * One read per request: the page that guards a URL and the metadata that + * titles it both ask for the same row, and `cache` makes the second ask free. + * The DTO is the same one `GET /api/auth/me` publishes (`toOnboardingStateDto` + * with the account's own unit columns), so the screen the server paints and + * the screen the client reasons about after hydration are built from one + * shape. + * + * The caller's OWN record, always. The flow's three writes refuse under an + * acting-account switch, so a page that read the switched record's state would + * show questions nobody could answer. + */ +import { cache } from "react"; + +import { getSession } from "@/lib/auth/session"; +import { prisma } from "@/lib/db"; +import { readHeldUnitPreferences, type OnboardingStateDto } from "./needs"; +import { loadOnboardingRecordRow, toOnboardingStateDto } from "./needs-store"; + +export interface OnboardingFlowPageState { + userId: string; + userLocale: string | null; + state: OnboardingStateDto; +} + +export const loadOnboardingFlowState = cache( + async (): Promise => { + const session = await getSession(); + if (!session) return null; + const { user } = session; + const row = await loadOnboardingRecordRow(prisma, user.id); + return { + userId: user.id, + userLocale: user.locale ?? null, + state: toOnboardingStateDto(row, readHeldUnitPreferences(user)), + }; + }, +); diff --git a/src/lib/onboarding/needs.ts b/src/lib/onboarding/needs.ts index 59430ad96..1e99d69f5 100644 --- a/src/lib/onboarding/needs.ts +++ b/src/lib/onboarding/needs.ts @@ -12,18 +12,16 @@ * pulls it in without dragging the server graph along, and the browser reads * the same step ids the server writes. * - * ── Coexistence with the wizard of today ─────────────────────────────────── + * ── The completion stamp ─────────────────────────────────────────────────── * - * Nothing here retires anything. `User.onboardingStep`, `User.onboardingGoals` - * and the six goal slugs in `./goals.ts` keep their meaning and keep driving - * the four-step wizard and its one-time dashboard seed until C2 replaces that - * surface. The two states are disjoint by construction: the goal slugs decide - * TILE ORDER, these answers decide WHICH MODULES ARE ON, and neither reads the - * other. `User.onboardingCompletedAt` also 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. + * `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, diff --git a/src/lib/onboarding/question-config.ts b/src/lib/onboarding/question-config.ts new file mode 100644 index 000000000..547b28cd0 --- /dev/null +++ b/src/lib/onboarding/question-config.ts @@ -0,0 +1,186 @@ +/** + * v1.39 (C2) — the five chip questions, as data. + * + * Which step asks what, with which answers, whether one answer or many, and + * whether it may be skipped — and the two pure translations the screen needs: + * the stored answers into a selection to prefill, and a selection into the + * body `PATCH /api/onboarding/answers` accepts. Both are here rather than in + * the component so they can be pinned without a DOM, and so the body that + * reaches the wire is built field by field from a closed vocabulary rather + * than from whatever the chips happened to hold. + * + * The answer vocabularies are the server's (`./needs`, the registry); this + * file only orders them and names their labels. Units (Q6) is not a chip + * set and has its own screen. + */ +import { ONBOARDING_AREA_KEYS } from "@/lib/modules/registry"; +import type { OnboardingAnswerInput } from "@/lib/validations/onboarding-needs"; + +import { + ONBOARDING_MEDICATION_ANSWERS, + ONBOARDING_RECORD_TARGETS, + ONBOARDING_SOURCE_KEYS, + ONBOARDING_VISIT_ANSWERS, + type OnboardingNeeds, +} from "./needs"; + +export const QUESTION_STEP_IDS = [ + "who", + "areas", + "medication", + "sources", + "visit", +] as const; + +export type QuestionStepId = (typeof QUESTION_STEP_IDS)[number]; + +export function isQuestionStepId(value: string): value is QuestionStepId { + return (QUESTION_STEP_IDS as readonly string[]).includes(value); +} + +export interface QuestionDefinition { + /** One answer (radio) or any number (checkbox). */ + kind: "single" | "multi"; + /** Q1 is the one question that cannot be passed. */ + skippable: boolean; + /** The answer values, in the order they are offered. */ + options: readonly string[]; +} + +export const QUESTIONS: Readonly> = + Object.freeze({ + who: { + kind: "single", + skippable: false, + options: ONBOARDING_RECORD_TARGETS, + }, + areas: { kind: "multi", skippable: true, options: ONBOARDING_AREA_KEYS }, + medication: { + kind: "single", + skippable: true, + options: ONBOARDING_MEDICATION_ANSWERS, + }, + sources: { + kind: "multi", + skippable: true, + options: ONBOARDING_SOURCE_KEYS, + }, + visit: { + kind: "single", + skippable: true, + options: ONBOARDING_VISIT_ANSWERS, + }, + }); + +/** The i18n key of one answer chip. */ +export function questionOptionLabelKey( + step: QuestionStepId, + option: string, +): string { + return `onboarding.flow.${step}.options.${option}`; +} + +/** The stored answers of one step, as the chips to pre-select. */ +export function questionPrefill( + step: QuestionStepId, + needs: OnboardingNeeds, +): string[] { + switch (step) { + case "who": + return needs.recordTarget ? [needs.recordTarget] : []; + case "areas": + return [...needs.areas]; + case "medication": + return needs.medication ? [needs.medication] : []; + case "sources": + return [...needs.sources]; + case "visit": + return needs.visit ? [needs.visit] : []; + } +} + +function only( + values: readonly T[], + selected: readonly string[], +): T[] { + return selected.filter((value): value is T => + (values as readonly string[]).includes(value), + ); +} + +/** + * The body for one answered step, or null when the selection cannot be an + * answer (Q1 with nothing chosen). Every arm names its field; a value that is + * not in the step's vocabulary is dropped rather than sent, so the strict + * server schema never sees a chip this file did not offer. + */ +export function questionAnswerBody( + step: QuestionStepId, + selected: readonly string[], +): OnboardingAnswerInput | null { + switch (step) { + case "who": { + const [recordTarget] = only(ONBOARDING_RECORD_TARGETS, selected); + return recordTarget ? { step, recordTarget } : null; + } + case "areas": + return { step, areas: only(ONBOARDING_AREA_KEYS, selected) }; + case "medication": { + const [medication] = only(ONBOARDING_MEDICATION_ANSWERS, selected); + return medication ? { step, medication } : null; + } + case "sources": + return { step, sources: only(ONBOARDING_SOURCE_KEYS, selected) }; + case "visit": { + const [visit] = only(ONBOARDING_VISIT_ANSWERS, selected); + return visit ? { step, visit } : null; + } + } +} + +/** The body that passes one skippable step without answering it. */ +export function questionSkipBody( + step: Exclude, +): OnboardingAnswerInput { + return { step, status: "skipped" }; +} + +/** + * Q6 — which of the two unit questions this flow asks. Glucose needs a + * glucose unit, weight needs the metric/imperial preference; an area not + * ticked is not asked about, so a person who chose only glucose never sees + * "kg or lb". + */ +export function unitQuestionsFor(needs: OnboardingNeeds): { + glucose: boolean; + weight: boolean; +} { + return { + glucose: needs.areas.includes("glucose"), + weight: needs.areas.includes("weight-body"), + }; +} + +/** + * The Q6 body. Only the questions that were asked reach the wire, and only + * with a chosen value: an omitted field leaves the account's own column + * alone, which is what "never re-ask a value the account holds" needs when + * a person confirms the one they already had. + */ +export function unitsAnswerBody(input: { + asked: { glucose: boolean; weight: boolean }; + glucoseUnit: "mg/dL" | "mmol/L" | null; + unitPreference: "metric" | "imperial" | null; +}): OnboardingAnswerInput { + const units: { + glucoseUnit?: "mg/dL" | "mmol/L"; + unitPreference?: "metric" | "imperial"; + } = {}; + if (input.asked.glucose && input.glucoseUnit) { + units.glucoseUnit = input.glucoseUnit; + } + if (input.asked.weight && input.unitPreference) { + units.unitPreference = input.unitPreference; + } + return { step: "units", units }; +} diff --git a/src/lib/onboarding/wizard-steps.ts b/src/lib/onboarding/wizard-steps.ts new file mode 100644 index 000000000..71c5912a7 --- /dev/null +++ b/src/lib/onboarding/wizard-steps.ts @@ -0,0 +1,263 @@ +/** + * v1.39 (C2) — the step machine behind the setup screens. + * + * The server publishes the ledger (`steps`) and the answers (`needs`); this + * file turns them into a route. Pure and browser-free, so the page that + * guards a URL, the screen that decides where "Next" goes, and the shell + * that prints "2 of 6" all ask the same function and cannot disagree. + * + * One vocabulary is added on top of the server's: `welcome`, the screen + * before the first question. It is not a step the server records — a person + * who has not answered Q1 has not entered the flow, whichever screen they are + * looking at — so it exists only here. + */ +import type { OnboardingSourceKey } from "./needs"; +import { + hasEnteredOnboardingFlow, + ONBOARDING_SKIPPABLE_STEP_IDS, + type OnboardingFirstResultTask, + type OnboardingNeeds, + type OnboardingStateDto, + type OnboardingStepId, +} from "./needs"; + +export type OnboardingScreenId = "welcome" | OnboardingStepId; + +/** + * The Q4 sources whose connection the browser can finish on its own — an + * OAuth handshake or a URL-plus-secret form on Settings → Integrations. + * Apple Health needs the phone, and "a file" is an import rather than a + * connection, so neither can be the one task the flow ends on. + */ +export const BROWSER_CONNECTABLE_SOURCES = [ + "withings", + "oura", + "whoop", + "polar", + "fitbit", + "strava", + "nightscout", +] as const satisfies readonly OnboardingSourceKey[]; + +export type BrowserConnectableSource = + (typeof BROWSER_CONNECTABLE_SOURCES)[number]; + +export function isBrowserConnectableSource( + source: string, +): source is BrowserConnectableSource { + return (BROWSER_CONNECTABLE_SOURCES as readonly string[]).includes(source); +} + +type FlowState = Pick; + +function status(state: FlowState, id: OnboardingStepId) { + return state.steps.find((step) => step.id === id)?.status ?? "pending"; +} + +const UNIT_AREAS = new Set([ + "glucose", + "weight-body", +]); + +/** + * Whether Q6 is part of this flow at all. + * + * Two conditions from the spec: an area that needs a unit is among the + * answers, and the account does not already hold the preferences. The + * second is read off the published ledger rather than off the account — + * the server resolves `units` to `done` when both columns are held — with + * one refinement: a `done` the FLOW itself produced (an answer is recorded + * beside it) keeps the screen in the order, so a person can go back to it, + * and a deliberate skip does the same. + */ +export function unitsQuestionApplies(state: FlowState): boolean { + if (!state.needs.areas.some((area) => UNIT_AREAS.has(area))) return false; + const flowAnswered = + state.needs.units.glucoseUnit !== null || + state.needs.units.unitPreference !== null; + return !(status(state, "units") === "done" && !flowAnswered); +} + +const FIXED_QUESTIONS: readonly OnboardingStepId[] = [ + "who", + "areas", + "medication", + "sources", + "visit", +]; + +/** The question screens this flow shows, in order. */ +export function questionScreens(state: FlowState): OnboardingStepId[] { + return unitsQuestionApplies(state) + ? [...FIXED_QUESTIONS, "units"] + : [...FIXED_QUESTIONS]; +} + +export interface FirstResultOffer { + task: OnboardingFirstResultTask; + /** A source key for a connection, an area key for a reading, else null. */ + target: string | null; +} + +/** + * The one task the flow ends on, by the spec's fixed priority: a connection + * the browser can complete → the first medication with a reminder, if the + * answer to Q3 was a daily schedule → one reading for the first Q2 area. + * Everything the priority passes over goes to the checklist instead. + */ +export function chooseFirstResultTask( + needs: OnboardingNeeds, +): FirstResultOffer | null { + const connectable = needs.sources.find(isBrowserConnectableSource); + if (connectable) return { task: "connect-source", target: connectable }; + if (needs.medication === "yes") + return { task: "add-medication", target: null }; + const area = needs.areas[0]; + if (area) return { task: "log-reading", target: area }; + return null; +} + +/** + * Whether the first-result screen is part of this flow. + * + * Not for "someone I look after": the three tasks write the caller's own + * record and the routes refuse under a switch, so the profile is created on + * the confirm screen and the flow ends there — the new record's first entry + * happens inside that record, from the done screen's link into it. + */ +export function firstResultApplies(state: FlowState): boolean { + if (state.needs.recordTarget === "someone-else") return false; + return chooseFirstResultTask(state.needs) !== null; +} + +/** Every screen this flow shows, in order. */ +export function screenOrder(state: FlowState): OnboardingScreenId[] { + return [ + "welcome", + ...questionScreens(state), + "confirm", + ...(firstResultApplies(state) ? (["first-result"] as const) : []), + "done", + ]; +} + +/** + * Where a person lands when they come back. + * + * The first screen still owed, in order: a record that never entered the + * flow starts at the welcome, a question still pending is asked, an + * unconfirmed set of answers goes to confirm, an offered task not yet acted + * on goes to the first result, and everything else is done. A restart puts + * every step back to pending, so a restarted flow resumes on Q1 with the old + * answers as prefill — which is the point of keeping them. + */ +export function resumeScreen(state: FlowState): OnboardingScreenId { + if ( + !hasEnteredOnboardingFlow({ + ...state, + completedAt: null, + firstResult: null, + }) + ) { + return "welcome"; + } + for (const id of questionScreens(state)) { + if (status(state, id) === "pending") return id; + } + if (status(state, "confirm") === "pending") return "confirm"; + if ( + firstResultApplies(state) && + status(state, "first-result") === "pending" + ) { + return "first-result"; + } + return "done"; +} + +export function nextScreen( + state: FlowState, + current: OnboardingScreenId, +): OnboardingScreenId | null { + const order = screenOrder(state); + const index = order.indexOf(current); + if (index === -1) return null; + return order[index + 1] ?? null; +} + +export function previousScreen( + state: FlowState, + current: OnboardingScreenId, +): OnboardingScreenId | null { + const order = screenOrder(state); + const index = order.indexOf(current); + if (index <= 0) return null; + return order[index - 1] ?? null; +} + +/** + * May this URL be shown? Everything up to the resume point, nothing past it: + * going back is always allowed, jumping ahead is bounced to where the flow + * actually is. A screen the order does not contain (units for someone who + * ticked no unit-bearing area) is refused too, so a stale link cannot show + * a question whose answer would mean nothing. + * + * The first question is the one exception: it is where the welcome screen's + * "Set up" goes, and a record that has not entered the flow resumes on the + * welcome, so without this arm nobody could ever start. + */ +export function canVisitScreen( + state: FlowState, + screen: OnboardingScreenId, +): boolean { + const order = screenOrder(state); + const index = order.indexOf(screen); + if (index === -1) return false; + return index <= Math.max(order.indexOf(resumeScreen(state)), 1); +} + +/** + * The questions the confirm screen must pass on the ledger before it + * completes: every skippable question this flow never showed and that is + * still pending. The completion route derives only once EVERY question is + * answered or passed, and Q6 is a question the machine hides for a flow with + * no unit-bearing area — so without this the route answers "incomplete" to + * three flows out of four, which is exactly what the first real run found. + * The first-result step is not a question and is passed by the confirm + * screen on its own rule. + */ +export function questionsToPassBeforeConfirm( + state: FlowState, +): Exclude<(typeof ONBOARDING_SKIPPABLE_STEP_IDS)[number], "first-result">[] { + const shown = new Set(questionScreens(state)); + return ONBOARDING_SKIPPABLE_STEP_IDS.filter( + (id): id is Exclude => + id !== "first-result" && + !shown.has(id) && + status(state, id) === "pending", + ); +} + +/** + * Whether a pathname is the setup flow's. The auth shell paints these screens + * without the app chrome, and it once matched only the front door — every + * question screen then rendered with the sidebar. Pure, so the shell's + * decision is pinned without a browser. + */ +export function isOnboardingPathname(pathname: string): boolean { + return pathname === "/onboarding" || pathname.startsWith("/onboarding/"); +} + +/** + * "2 of 6" — for the question screens only. The welcome, confirm, first + * result and done screens carry no counter: the count is a promise about how + * many questions are left, and those screens are not questions. + */ +export function stepCounter( + state: FlowState, + screen: OnboardingScreenId, +): { current: number; total: number } | null { + const questions = questionScreens(state); + const index = (questions as readonly string[]).indexOf(screen); + if (index === -1) return null; + return { current: index + 1, total: questions.length }; +} diff --git a/src/lib/openapi/error-codes.ts b/src/lib/openapi/error-codes.ts index 0c00ef15b..98fede930 100644 --- a/src/lib/openapi/error-codes.ts +++ b/src/lib/openapi/error-codes.ts @@ -238,16 +238,12 @@ export const ERROR_CODE_CATALOGUE: Readonly> = "onboarding.answers.invalid", "onboarding.answers.rateLimited", "onboarding.complete.invalid", + "onboarding.complete.recordNotFound", + "onboarding.complete.recordTargetMismatch", "onboarding.disclaimer.invalid", "onboarding.restart.invalid", "onboarding.restart.rateLimited", - "onboarding.step.completed", - "onboarding.step.concurrent", - "onboarding.step.invalid", - "onboarding.step.outOfOrder", - "onboarding.step.rateLimited", "onboarding.tour.invalid", - "onboarding.user.notFound", ], personal_records: ["personal_records.invalid_query"], practitioner: ["practitioner.invalid"], diff --git a/src/lib/openapi/routes/onboarding.ts b/src/lib/openapi/routes/onboarding.ts index f023d8be4..d306cdc26 100644 --- a/src/lib/openapi/routes/onboarding.ts +++ b/src/lib/openapi/routes/onboarding.ts @@ -1,19 +1,16 @@ /** - * OpenAPI route table — the onboarding wizard and the module tour. + * OpenAPI route table — the setup flow and the module tour. * * Part of the OpenAPI route table; aggregated in `./index.ts`. - * The request schemas come from `src/lib/onboarding/tour-progress.ts` and - * `src/lib/validations/onboarding.ts` so the wire contract stays + * The request schemas come from `src/lib/onboarding/tour-progress.ts`, + * `src/lib/validations/onboarding.ts` and + * `src/lib/validations/onboarding-needs.ts` so the wire contract stays * single-source with the runtime parsers. * - * Two generations of the same flow live side by side here, and a client - * should pick one. `POST /api/onboarding/step` is the current wizard: four - * ordered checkpoints, the fourth of which completes. `POST - * /api/onboarding/complete` is the older single-shot path that stamps the - * completion and takes whatever profile fields it was given. They write the - * same column and refuse to co-operate — the step route's conditional update - * requires `onboardingCompletedAt: null`, so a `complete` call mid-wizard - * makes every remaining step 409. + * v1.39 — one flow. The needs-based setup (`answers` → `complete` → + * `restart`) replaced the four-checkpoint wizard, whose `POST + * /api/onboarding/step` is gone with it; `complete` is the one completion + * write, and it now also seeds the dashboard order from the answers. * * v1.18.6 — the resumable module-tour contract the iOS client mirrors: * a fire-and-forget progress checkpoint plus the coarse completion @@ -34,10 +31,7 @@ import { } from "@/lib/onboarding/needs"; import { tourProgressSchema } from "@/lib/onboarding/tour-progress"; import { ONBOARDING_AREA_KEYS } from "@/lib/modules/registry"; -import { - onboardingCompleteSchema, - onboardingStepSchema, -} from "@/lib/validations/onboarding"; +import { onboardingCompleteSchema } from "@/lib/validations/onboarding"; import { onboardingAnswerSchema, onboardingRestartSchema, @@ -216,26 +210,10 @@ const disclaimerAckResponse = z description: "The persisted disclaimer acknowledgment version.", }); -const onboardingStepRequest = onboardingStepSchema.meta({ - id: "OnboardingStepRequest", - description: - "The wizard step being COMPLETED, 1–4. It must equal the stored step plus one — the server does not clamp or skip. `goals` rides the step-2 submit; every slug is checked against the closed set, so one unknown slug fails the whole request rather than being dropped.", -}); - -const onboardingStepResponse = z - .object({ - step: z.number().int().describe("The stored step after the write."), - onboardingCompletedAt: z.iso - .datetime({ offset: true }) - .nullable() - .describe("Non-null once step 4 landed."), - }) - .meta({ id: "OnboardingStepResponse" }); - const onboardingCompleteRequest = onboardingCompleteSchema.meta({ id: "OnboardingCompleteRequest", description: - "Optional profile fields to save alongside the completion stamp. Every field is optional and only a truthy value is written — sending `heightCm: 0` or an empty `displayName` leaves the column alone rather than clearing it. There is no way to CLEAR a field through this endpoint. `dateOfBirth` is a free string parsed with `new Date(...)`: an unparseable value is silently ignored, not refused.", + "The completion stamp's body. `managedRecordId` (v1.39) names the managed record the answers were given for — see the field. The profile fields are the legacy half: every one optional, only a truthy value is written — sending `heightCm: 0` or an empty `displayName` leaves the column alone rather than clearing it, and there is no way to CLEAR a field through this endpoint. `dateOfBirth` is a free string parsed with `new Date(...)`: an unparseable value is silently ignored, not refused. The web flow writes its profile through `PUT /api/auth/profile` instead.", }); export const onboardingPaths: NonNullable = { @@ -319,66 +297,12 @@ export const onboardingPaths: NonNullable = { }, }, }, - "/api/onboarding/step": { - post: { - tags: ["Onboarding"], - summary: "Advance the onboarding wizard by one step", - description: - "Persists progress through the four-step wizard. The contract is strictly ordered: the submitted `step` must be exactly the stored step plus one, so a client cannot skip ahead or replay a step it already sent.\n\nSubmitting step 4 completes onboarding — the completion instant is stamped in the same write, the proxy-readable pending cookie is cleared so the next navigation stops redirecting to `/onboarding`, and the stored goal selection seeds the dashboard layout. That seed is ONE-TIME and conditional: it only runs while the layout column is still unset, so a person who already arranged their tiles is never clobbered, and a concurrent layout save that lands first wins.\n\nThe write is guarded on the state it validated, so two tabs submitting the same step do not both succeed — exactly one lands and the other gets 409.\n\nRate-limited to 30 writes per 10 minutes per user, which is generous for a four-step flow and tight enough to defang a stuck retry loop. Every accepted call writes an audit row. Cookie or wildcard Bearer; `userId` is never read from the body.", - requestBody: { - required: true, - content: { "application/json": { schema: onboardingStepRequest } }, - }, - responses: { - "200": { - description: "The stored step and completion stamp after the write.", - content: { - "application/json": { - schema: dataEnvelope( - onboardingStepResponse, - "OnboardingStepEnvelope", - ), - }, - }, - }, - "404": { - description: - "The session's user row no longer exists. `meta.errorCode` = `onboarding.user.notFound`.", - content: { "application/json": { schema: errorEnvelope } }, - }, - "409": { - description: - "The write was refused and nothing changed. `meta.errorCode` distinguishes three cases: `onboarding.step.completed` — onboarding is already finished, so the wizard has nothing left to advance; `onboarding.step.outOfOrder` — the submitted step is not the stored step plus one, and the message names the current step; `onboarding.step.concurrent` — the row moved between the check and the write, which is what a second tab sees.", - content: { "application/json": { schema: errorEnvelope } }, - }, - "413": { - description: "Body exceeds 64 KiB.", - content: { "application/json": { schema: errorEnvelope } }, - }, - "415": { - description: "`Content-Type` is not `application/json`.", - content: { "application/json": { schema: errorEnvelope } }, - }, - ...stdResponses, - "422": { - description: - "The body did not validate. `meta.errorCode` = `onboarding.step.invalid`. Single-message, not the multi-issue envelope — the per-field detail is not on the wire.", - content: { "application/json": { schema: errorEnvelope } }, - }, - "429": { - description: - "More than 30 onboarding writes in 10 minutes. `meta.errorCode` = `onboarding.step.rateLimited`.", - content: { "application/json": { schema: errorEnvelope } }, - }, - }, - }, - }, "/api/onboarding/complete": { post: { tags: ["Onboarding"], 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`.\n\nv1.39 — this is also the needs-based flow's confirm endpoint, and the two halves live side by side. For a record that FINISHED the questions — every one of them answered or deliberately passed — 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`. A half-answered flow derives nothing and is not stamped as complete: the answers become a module map, and a flow somebody abandoned after the first screen would have every remaining question read as its conservative default. The same gate is what keeps the LEGACY wizard's completion out of the derivation, since that wizard never puts these questions.\n\nThe response then carries `onboarding` beside `completed`; a caller that never entered the needs flow gets the fixed acknowledgement it always did.\n\nThe 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 enforces no rate limit of its own. Its legacy half writes no audit row; the needs half writes one (`user.modules.update`) when it derives, because that is the same column the dedicated modules route audits. It also does NOT seed the dashboard from the goal selection — that only happens on the step route's completing call.\n\nCalling this mid-wizard makes every remaining `POST /api/onboarding/step` answer 409 `onboarding.step.completed`, because that route's guarded update requires the completion stamp to still be null.\n\nCookie or wildcard Bearer; `userId` is never read from the body.", + "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`.\n\nv1.39 — this is the needs-based flow's confirm endpoint. For a record that FINISHED the questions — every one of them answered or deliberately passed — 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 and holds no data in that domain. 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`. In the same derivation the dashboard order is seeded from the answers — the tiles of the chosen areas (and the medication tile for a schedule) move to the front and are made visible — but only while the layout column is still unset, so a person who already arranged their tiles is never clobbered. A half-answered flow derives nothing and is not stamped as complete.\n\nThe response carries `onboarding` beside `completed`; a caller that never entered the needs flow gets the fixed acknowledgement alone — it stamps the completion whatever the answers say, re-stamps on every call rather than refusing a second one, and enforces no rate limit of its own. The legacy half writes no audit row; the needs half writes one (`user.modules.update`) when it derives, because that is the same column the dedicated modules route audits.\n\nCookie or wildcard Bearer; `userId` is never read from the body.\n\n`managedRecordId` — \"someone I look after\". The answers were given for the profile the confirm screen created, so the derivation and the seed land on THAT record through the same record-keyed write the guardian's modules route uses, its setup row is written as finished, and the caller's own record is stamped complete and latched with its map untouched. A managed record the caller does not actively guard, a record that is not a managed profile, or an id that does not exist all answer 404 with nothing stamped; a body naming a record when Q1 was not `someone-else` is a 422. With Q1 = `someone-else` and NO record id, the flow completes and latches without deriving anything onto anybody: there is no record the answers describe yet, and they never describe the guardian's.", requestBody: { required: true, content: { "application/json": { schema: onboardingCompleteRequest } }, @@ -399,6 +323,11 @@ export const onboardingPaths: NonNullable = { }, }, }, + "404": { + description: + "`managedRecordId` names no managed profile the caller actively guards — missing, not a managed record, or not theirs; one answer for all three. `meta.errorCode` = `onboarding.complete.recordNotFound`. Nothing was stamped.", + content: { "application/json": { schema: errorEnvelope } }, + }, "413": { description: "Body exceeds 64 KiB.", content: { "application/json": { schema: errorEnvelope } }, @@ -410,7 +339,7 @@ export const onboardingPaths: NonNullable = { ...stdResponses, "422": { description: - "The body did not validate. `meta.errorCode` = `onboarding.complete.invalid`. Single-message, not the multi-issue envelope.", + "The body did not validate (`meta.errorCode` = `onboarding.complete.invalid`; single-message, not the multi-issue envelope), or `managedRecordId` was sent for answers that were not given for somebody else's record — Q1 was `me` or `both` (`meta.errorCode` = `onboarding.complete.recordTargetMismatch`; nothing stamped).", content: { "application/json": { schema: errorEnvelope } }, }, }, diff --git a/src/lib/query-keys/index.ts b/src/lib/query-keys/index.ts index dbec8a3e2..3fc184256 100644 --- a/src/lib/query-keys/index.ts +++ b/src/lib/query-keys/index.ts @@ -36,6 +36,7 @@ import { medicationKeys } from "./medications"; import { mentalHealthKeys } from "./mental-health"; import { moodKeys } from "./mood"; import { nutrientKeys } from "./nutrients"; +import { onboardingKeys } from "./onboarding"; import { recordSettingsKeys } from "./record-settings"; import { profileKeys } from "./profile"; import { settingsKeys } from "./settings"; @@ -68,6 +69,7 @@ export const queryKeys = { ...encounterKeys, ...customMetrics, ...nutrientKeys, + ...onboardingKeys, ...recordSettingsKeys, ...profileKeys, ...sharingKeys, diff --git a/src/lib/query-keys/onboarding.ts b/src/lib/query-keys/onboarding.ts new file mode 100644 index 000000000..9dedec5e8 --- /dev/null +++ b/src/lib/query-keys/onboarding.ts @@ -0,0 +1,36 @@ +/** + * Query keys — the needs-based setup flow (v1.39 C2). + * Part of the centralized factory; aggregated in `./index.ts`. + * + * The flow has no read of its own: its state rides `GET /api/auth/me` as + * `onboarding`, so every screen reads `queryKeys.authMe()` and every write + * below invalidates it. What lives here are the mutation keys, one per + * endpoint, so a screen that persists an answer and a settings card that + * restarts the flow cannot collide on an anonymous mutation. + */ +export const onboardingKeys = { + /** `PATCH /api/onboarding/answers` — one step, answered or skipped. */ + onboardingAnswerMutation: () => ["onboarding", "answer"] as const, + /** `POST /api/onboarding/complete` — the confirm screen, and "skip for now". */ + onboardingCompleteMutation: () => ["onboarding", "complete"] as const, + /** `POST /api/onboarding/restart` — "Set up again", from Settings. */ + onboardingRestartMutation: () => ["onboarding", "restart"] as const, + /** `POST /api/onboarding/disclaimer` — the one-time acknowledgment. */ + onboardingDisclaimerMutation: () => ["onboarding", "disclaimer"] as const, + /** + * The latest reading of one area, read back after the first-result step + * logged it so the screen can show the value on its tile. Rides under the + * `["measurements"]` prefix so `measurementDependentKeys` evicts it. + */ + onboardingLatestReading: (area: string) => + ["measurements", "onboarding-latest", area] as const, + /** + * The upcoming-visit count the checklist's "prepare the visit" row reads + * off the visits list's meta. Its own key under the `["encounters"]` + * prefix: the list surfaces cache the rows under `encounters()`, and a + * second `queryFn` shape on the same key would poison that cell, while + * the prefix still lets `encounterDependentKeys` evict this one. + */ + onboardingUpcomingVisits: () => + ["encounters", "onboarding-upcoming"] as const, +}; diff --git a/src/lib/validations/onboarding.ts b/src/lib/validations/onboarding.ts index 41ad776a8..4bb14265b 100644 --- a/src/lib/validations/onboarding.ts +++ b/src/lib/validations/onboarding.ts @@ -1,48 +1,46 @@ /** - * Request schemas for the two onboarding write endpoints. + * Request schema for the completion write. * - * They live outside the route files for the same reason + * It lives outside the route file for the same reason * `src/lib/validations/about-me.ts` does: a route module may only export * handlers plus the Next.js route config, so the OpenAPI registry cannot - * import a schema declared inside one. Keeping the shapes here lets the + * import a schema declared inside one. Keeping the shape here lets the * published contract and the runtime parser be the same object rather than * two hand-kept copies. * - * Prisma-free by construction — `@/lib/onboarding/goals` is pure data — so - * the generator script can pull them in without dragging the server graph - * along. + * Prisma-free by construction, so the generator script can pull it in + * without dragging the server graph along. */ import { z } from "zod/v4"; -import { ONBOARDING_GOAL_SLUGS } from "@/lib/onboarding/goals"; - /** - * `POST /api/onboarding/step` — the step-by-step wizard checkpoint. - * - * `step` is the step being COMPLETED and must equal the stored step plus one; - * the route refuses anything else with 409 rather than clamping. `goals` - * rides the step-2 submit and is validated against the closed slug set, so an - * unknown slug fails the whole request instead of being silently dropped. - */ -export const onboardingStepSchema = z.object({ - step: z.number().int().min(1).max(4), - goals: z - .array(z.enum(ONBOARDING_GOAL_SLUGS)) - .max(ONBOARDING_GOAL_SLUGS.length) - .optional(), -}); - -export type OnboardingStepInput = z.infer; - -/** - * `POST /api/onboarding/complete` — the legacy single-shot completion path. + * `POST /api/onboarding/complete` — the completion stamp. * * Every field is optional: the endpoint's job is the completion stamp, and - * the profile fields are whatever the wizard happened to collect. A field + * the profile fields are whatever the caller happened to collect. A field * that parses but is falsy (an empty `displayName`, `heightCm: 0`) is not - * written — the route only assigns truthy values. + * written — the route only assigns truthy values. The web flow sends an + * empty body and writes its profile through `PUT /api/auth/profile`. */ export const onboardingCompleteSchema = z.object({ + /** + * v1.39 (C2) — the managed record the answers were given FOR. Sent by the + * confirm screen after "someone I look after" created the profile: the + * module map and the dashboard order derived from the caller's answers are + * applied to that record, and the caller's own record is stamped complete + * without deriving. The caller must be a guardian of it, and the answer to + * Q1 must be "someone-else"; anything else is refused rather than applied + * to the wrong record. + */ + managedRecordId: z + .string() + .trim() + .min(1) + .max(64) + .optional() + .describe( + "The managed record the setup answers were given FOR — the profile \"someone I look after\" created on the confirm screen. When present, the module map and the dashboard order derived from the caller's answers are applied to THAT record and its own setup row is written as finished; the caller's own record is stamped complete and its derivation latched without any module being touched. Honoured only for a managed profile the caller actively guards (otherwise 404 `onboarding.complete.recordNotFound`) and only when the answer to Q1 was `someone-else` (otherwise 422 `onboarding.complete.recordTargetMismatch`); nothing is stamped on a refusal. Absent with Q1 = `someone-else`, the flow completes without deriving anything anywhere.", + ), displayName: z.string().trim().min(1).max(50).optional(), heightCm: z.number().min(50).max(300).optional(), dateOfBirth: z.string().optional(), diff --git a/src/proxy.ts b/src/proxy.ts index 97a86c93a..5184878e7 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -166,6 +166,14 @@ const DEMO_MUTATION_ALLOWLIST: ReadonlyArray<{ path: string; method: string }> = { path: "/api/onboarding/answers", method: "PATCH" }, { path: "/api/onboarding/complete", method: "POST" }, { path: "/api/onboarding/restart", method: "POST" }, + // v1.39 (C2) — the two writes the flow's welcome and exit make: the + // one-time disclaimer acknowledgment gates both welcome buttons, and the + // tour's progress checkpoint is what the done screen's offer leads to. + // Without the first, a demo account on a fresh seed (or after a version + // bump of the disclaimer) got a 403 from "Set up" and "Skip for now" both. + // Neither creates health data. + { path: "/api/onboarding/disclaimer", method: "POST" }, + { path: "/api/onboarding/tour", method: "POST" }, ]; // Legacy route redirects (German → English) diff --git a/tests/integration/onboarding-needs.test.ts b/tests/integration/onboarding-needs.test.ts index 68b1b1280..962af237c 100644 --- a/tests/integration/onboarding-needs.test.ts +++ b/tests/integration/onboarding-needs.test.ts @@ -52,6 +52,12 @@ vi.mock("@/lib/db-compat", () => ({ })); import { acceptGrant, inviteGrant } from "@/lib/sharing/grants"; +import { + DEFAULT_DASHBOARD_LAYOUT, + resolveDashboardLayout, + serializeDashboardLayout, +} from "@/lib/dashboard-layout"; +import { toJson } from "@/lib/db"; import { emptyOnboardingNeeds } from "@/lib/onboarding/needs"; import type { OnboardingStateDto, @@ -355,6 +361,58 @@ describe("POST /api/onboarding/complete", () => { expect((await readState(second)).completedAt).toBe(state.completedAt); }); + it("seeds the dashboard order from the answers, and only while the layout is unset", async () => { + const user = await makeUser("seed"); + await signIn(user.id); + await answerTheQuestions(); + expect((await postComplete()).status).toBe(200); + + const stored = async () => + ( + await getPrismaClient().user.findUniqueOrThrow({ + where: { id: user.id }, + select: { dashboardWidgetsJson: true }, + }) + ).dashboardWidgetsJson; + + // Blood pressure and a daily medication were answered: their tiles lead, + // visible on both surfaces, and nothing is dropped. + const seeded = await stored(); + expect(seeded).not.toBeNull(); + const layout = resolveDashboardLayout(seeded); + const order = [...layout.widgets] + .sort((a, b) => a.order - b.order) + .map((w) => w.id); + expect(new Set(order.slice(0, 4))).toEqual( + new Set(["bp", "bpInTarget", "pulse", "medications"]), + ); + for (const id of ["bp", "bpInTarget", "pulse", "medications"]) { + const widget = layout.widgets.find((w) => w.id === id); + expect(widget?.visible, id).toBe(true); + expect(widget?.tileVisible, id).toBe(true); + } + expect(layout.widgets.length).toBe(DEFAULT_DASHBOARD_LAYOUT.widgets.length); + + // A layout somebody arranged is never clobbered: put the default back + // (weight first), ask the questions again, confirm again — the seed sees + // a set column and writes nothing. + await getPrismaClient().user.update({ + where: { id: user.id }, + data: { + dashboardWidgetsJson: toJson( + serializeDashboardLayout(DEFAULT_DASHBOARD_LAYOUT), + ), + }, + }); + expect((await postRestart()).status).toBe(200); + await answerTheQuestions(); + expect((await postComplete()).status).toBe(200); + const kept = resolveDashboardLayout(await stored()); + expect([...kept.widgets].sort((a, b) => a.order - b.order)[0]?.id).toBe( + "weight", + ); + }); + it("never switches off a module the person switched on by hand", async () => { const user = await makeUser("handmade"); await signIn(user.id); @@ -480,7 +538,187 @@ describe("POST /api/onboarding/complete", () => { }); }); +describe("POST /api/onboarding/complete for somebody else's record", () => { + async function guardianWithChild() { + const guardian = await makeUser("guardian"); + const { createManagedProfile } = + await import("@/lib/managed-profiles/create"); + const { profile } = await createManagedProfile({ + creatorId: guardian.id, + displayName: "Child record", + dateOfBirth: null, + locale: "en", + timezone: "UTC", + }); + await signIn(guardian.id); + await patchAnswer({ step: "who", recordTarget: "someone-else" }); + await patchAnswer({ step: "areas", areas: ["blood-pressure"] }); + await patchAnswer({ step: "medication", medication: "no" }); + await patchAnswer({ step: "sources", sources: ["manual"] }); + await patchAnswer({ step: "visit", visit: "no" }); + await patchAnswer({ step: "units", status: "skipped" }); + return { guardian, profile }; + } + + it("derives onto the managed record and leaves the guardian's own map alone", async () => { + // The review's H1: a parent setting up a child's record must not have + // their own modules switched off around the child's answers. The + // derivation is the same function, applied through the same record-keyed + // write the guardian's modules route uses — to the child. + const { guardian, profile } = await guardianWithChild(); + + const res = await postComplete({ managedRecordId: profile.id }); + expect(res.status).toBe(200); + + const child = await modulePrefs(profile.id); + expect(child.vaccinations).toBe(true); + expect(child.insights).toBe(true); + expect(child.glucose).toBe(false); + expect(child.medications).toBe(false); + expect(child.doctorReport).toBe(false); + + // Untouched: a fresh account carries no map at all, and still does. + expect(await modulePrefs(guardian.id)).toEqual({}); + + // The guardian's own flow is complete and its derivation latched, so a + // later confirm cannot derive the child's answers onto the guardian. + const own = await getPrismaClient().onboardingRecord.findUniqueOrThrow({ + where: { userId: guardian.id }, + }); + expect(own.completedAt).not.toBeNull(); + expect(own.modulesDerivedAt).not.toBeNull(); + expect(statusOf(await readState(res), "confirm")).toBe("done"); + + // The child's own setup row reads as finished, from the same answers. + const theirs = await getPrismaClient().onboardingRecord.findUniqueOrThrow({ + where: { userId: profile.id }, + }); + expect(theirs.completedAt).not.toBeNull(); + expect(theirs.modulesDerivedAt).not.toBeNull(); + const dashboard = await getPrismaClient().user.findUniqueOrThrow({ + where: { id: profile.id }, + select: { dashboardWidgetsJson: true }, + }); + expect(dashboard.dashboardWidgetsJson).not.toBeNull(); + + // A second confirm derives nowhere: the guardian is latched, and the + // child keeps what it has. + expect((await postComplete({ managedRecordId: profile.id })).status).toBe( + 200, + ); + expect(await modulePrefs(guardian.id)).toEqual({}); + }); + + it("derives nowhere when the answers were for somebody else and no profile exists", async () => { + // H1' — "Finish without the profile" (no second factor, or a change of + // mind). There is no record the answers describe, and they must not + // describe the guardian's: the flow completes and latches, nothing is + // derived or seeded on the guardian, whatever the client sends. + const guardian = await makeUser("guardian-no-profile"); + await signIn(guardian.id); + await patchAnswer({ step: "who", recordTarget: "someone-else" }); + await patchAnswer({ step: "areas", areas: ["blood-pressure"] }); + await patchAnswer({ step: "medication", medication: "yes" }); + await patchAnswer({ step: "sources", sources: ["manual"] }); + await patchAnswer({ step: "visit", visit: "within-a-month" }); + await patchAnswer({ step: "units", status: "skipped" }); + + const res = await postComplete(); + expect(res.status).toBe(200); + expect(await modulePrefs(guardian.id)).toEqual({}); + const row = await getPrismaClient().user.findUniqueOrThrow({ + where: { id: guardian.id }, + select: { dashboardWidgetsJson: true, onboardingCompletedAt: true }, + }); + expect(row.dashboardWidgetsJson).toBeNull(); + expect(row.onboardingCompletedAt).not.toBeNull(); + const own = await getPrismaClient().onboardingRecord.findUniqueOrThrow({ + where: { userId: guardian.id }, + }); + expect(own.completedAt).not.toBeNull(); + // Latched: a second confirm, with or without a record, derives nothing. + expect(own.modulesDerivedAt).not.toBeNull(); + expect((await postComplete()).status).toBe(200); + expect(await modulePrefs(guardian.id)).toEqual({}); + }); + + it("answers 404 for a record the caller does not guard, and touches nothing", async () => { + // M7 — a record addressed by id answers 404 for missing, not managed and + // not guarded alike, as `GET /api/managed-profiles/{id}` does through + // the same helper: one answer, no existence oracle. This asserted 403 + // once; the id-addressed convention is 404. + const { guardian } = await guardianWithChild(); + const stranger = await makeUser("stranger-guardian"); + const { createManagedProfile } = + await import("@/lib/managed-profiles/create"); + const { profile: theirs } = await createManagedProfile({ + creatorId: stranger.id, + displayName: "Somebody else's child", + dateOfBirth: null, + locale: "en", + timezone: "UTC", + }); + + const res = await postComplete({ managedRecordId: theirs.id }); + expect(res.status).toBe(404); + expect( + (await postComplete({ managedRecordId: "no-such-record" })).status, + ).toBe(404); + expect(await modulePrefs(theirs.id)).toEqual({}); + expect(await modulePrefs(guardian.id)).toEqual({}); + const own = await getPrismaClient().onboardingRecord.findUniqueOrThrow({ + where: { userId: guardian.id }, + }); + expect(own.completedAt).toBeNull(); + // Nothing stamped the first-run gate either. + const row = await getPrismaClient().user.findUniqueOrThrow({ + where: { id: guardian.id }, + select: { onboardingCompletedAt: true }, + }); + expect(row.onboardingCompletedAt).toBeNull(); + }); + + it("refuses a managed record when the answers were given for the caller", async () => { + const guardian = await makeUser("guardian-me"); + const { createManagedProfile } = + await import("@/lib/managed-profiles/create"); + const { profile } = await createManagedProfile({ + creatorId: guardian.id, + displayName: "Child record", + dateOfBirth: null, + locale: "en", + timezone: "UTC", + }); + await signIn(guardian.id); + await answerTheQuestions(); // Q1 = "me" + + const res = await postComplete({ managedRecordId: profile.id }); + expect(res.status).toBe(422); + expect(await modulePrefs(profile.id)).toEqual({}); + expect(await modulePrefs(guardian.id)).toEqual({}); + }); +}); + describe("POST /api/onboarding/restart", () => { + it("clears the first result so a re-run offers the task again", async () => { + // M2: kept, the old result painted the re-run's first-result screen as + // done, "Next" skipped the write, and the step stayed pending forever. + const user = await makeUser("restart-first-result"); + await signIn(user.id); + await answerTheQuestions(); + expect((await postComplete()).status).toBe(200); + await patchAnswer({ + step: "first-result", + firstResult: { task: "add-medication", target: null, completed: true }, + }); + expect((await readMe()).firstResult?.completedAt).not.toBeNull(); + + expect((await postRestart()).status).toBe(200); + const after = await readMe(); + expect(after.firstResult).toBeNull(); + expect(statusOf(after, "first-result")).toBe("pending"); + }); + it("resets the steps and leaves the modules where they are", async () => { const user = await makeUser("restart"); await signIn(user.id); diff --git a/tests/integration/settings-data-wipe.test.ts b/tests/integration/settings-data-wipe.test.ts index 72b9e174b..48ef8eb19 100644 --- a/tests/integration/settings-data-wipe.test.ts +++ b/tests/integration/settings-data-wipe.test.ts @@ -104,7 +104,6 @@ describe("DELETE /api/settings/data leaves nothing it promised to delete", () => stravaAthleteId: "12345", thresholdsJson: { sys: 130 }, onboardingCompletedAt: new Date(), - onboardingStep: 4, passwordHash: "argon2-placeholder", locale: "en", unitPreference: "metric",