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); }