From 5a805e8d1752de18775296c94874958fc6e87333 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 10 Sep 2026 15:24:32 +0200 Subject: [PATCH] Add the setup flow's first-result screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exactly one task, by the step machine's fixed priority: connect the wearable named in Q4, add the first medication, or log one reading for the first area. The measurement form and the medication wizard render inline; a connection goes through the integrations page, because the OAuth handshake cannot run inside this screen, and the screen watches the status envelope until it comes 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 its first sync — and records the result on the ledger with the server's own instant. The offer is not recorded on arrival: somebody who leaves mid-task resumes here rather than on the done screen. --- messages/en.json | 31 + src/app/onboarding/[step]/page.tsx | 4 + .../onboarding/first-result-screen.tsx | 528 ++++++++++++++++++ .../__tests__/first-result-config.test.ts | 40 ++ src/lib/onboarding/first-result-config.ts | 65 +++ src/lib/onboarding/wizard-steps.ts | 2 +- 6 files changed, 669 insertions(+), 1 deletion(-) create mode 100644 src/components/onboarding/first-result-screen.tsx create mode 100644 src/lib/onboarding/__tests__/first-result-config.test.ts create mode 100644 src/lib/onboarding/first-result-config.ts diff --git a/messages/en.json b/messages/en.json index caa8d7be3..68091e25d 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4740,6 +4740,37 @@ "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" + } } } }, diff --git a/src/app/onboarding/[step]/page.tsx b/src/app/onboarding/[step]/page.tsx index fdb80a1c4..4dd9f08df 100644 --- a/src/app/onboarding/[step]/page.tsx +++ b/src/app/onboarding/[step]/page.tsx @@ -3,6 +3,7 @@ import { notFound, redirect } from "next/navigation"; import { OnboardingShell } from "@/components/onboarding/onboarding-shell"; import { ConfirmScreen } from "@/components/onboarding/confirm-screen"; +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"; @@ -102,5 +103,8 @@ function renderScreen(screen: OnboardingStepId, state: OnboardingStateDto) { if (screen === "confirm") { return ; } + if (screen === "first-result") { + return ; + } notFound(); } diff --git a/src/components/onboarding/first-result-screen.tsx b/src/components/onboarding/first-result-screen.tsx new file mode 100644 index 000000000..93d7c96e5 --- /dev/null +++ b/src/components/onboarding/first-result-screen.tsx @@ -0,0 +1,528 @@ +"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 { CheckCircle2, 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 { 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"); + + const [completed, setCompleted] = useState( + state.firstResult?.task === offer?.task && + state.firstResult?.completedAt !== null, + ); + const [resultTarget, setResultTarget] = useState( + state.firstResult?.target ?? null, + ); + + async function complete(target: string | null) { + if (!offer) return; + try { + await answer.mutateAsync({ + step: "first-result", + firstResult: { task: offer.task, target, completed: true }, + }); + setResultTarget(target); + 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, + resultTarget, + onComplete, +}: { + offer: Offer; + completed: boolean; + resultTarget: string | null; + 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, + medicationId, + onComplete, +}: { + completed: boolean; + medicationId: string | null; + onComplete: (target: string | null) => Promise; +}) { + const { t } = useTranslations(); + const fmt = useFormatters(); + const [open, setOpen] = useState(false); + + 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); + void onComplete(id); + }} + /> +
+ ); +} + +/* ── 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) => + apiGet( + `/api/measurements?type=${encodeURIComponent(type)}&limit=1`, + ).then((rows) => rows[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 ────────────────────────────────────────────────────────── */ + +function ResultTile({ title, detail }: { title: string; detail?: string }) { + return ( +
+ +
+

{title}

+ {detail ?

{detail}

: null} +
+
+ ); +} 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/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/wizard-steps.ts b/src/lib/onboarding/wizard-steps.ts index 35bc14172..d53b2210c 100644 --- a/src/lib/onboarding/wizard-steps.ts +++ b/src/lib/onboarding/wizard-steps.ts @@ -54,7 +54,7 @@ export type BrowserConnectableSource = (typeof BROWSER_CONNECTABLE_SOURCES)[number]; export function isBrowserConnectableSource( - source: OnboardingSourceKey, + source: string, ): source is BrowserConnectableSource { return (BROWSER_CONNECTABLE_SOURCES as readonly string[]).includes(source); }