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 (
+
+ );
+ }
+
+ // 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 (
+
+ );
+}
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);
}