Add the setup flow's first-result screen

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.
This commit is contained in:
Marc-André Bombeck committed 2026-09-10 15:24:32 +02:00
1 parent 58bc91a0a3
commit 5a805e8d17
6 files changed
+669 -1

No files matched your search

+31
View File
@@ -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"
}
}
}
},
+4
View File
@@ -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 <ConfirmScreen state={state} />;
}
if (screen === "first-result") {
return <FirstResultScreen state={state} />;
}
notFound();
}
@@ -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<ReturnType<typeof chooseFirstResultTask>>;
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<string | null>(
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 (
<section
aria-labelledby="onboarding-first-result-title"
className="space-y-6"
data-task={offer.task}
data-target={offer.target ?? undefined}
>
<StepHeading
id="onboarding-first-result-title"
title={t(`onboarding.flow.first-result.${offer.task}.title`, {
target: targetLabel(t, offer),
})}
description={t(`onboarding.flow.first-result.${offer.task}.body`)}
/>
<TaskBody
offer={offer}
completed={completed}
resultTarget={resultTarget}
onComplete={complete}
/>
<StepActions
backHref={back ? screenHref(back) : undefined}
onSkip={completed ? undefined : () => void skip()}
onNext={completed ? () => router.push(screenHref("done")) : undefined}
pending={answer.isPending}
/>
</section>
);
}
function targetLabel(
t: (key: string, params?: Record<string, string | number>) => 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<void>;
}) {
switch (offer.task) {
case "connect-source":
return offer.target && isBrowserConnectableSource(offer.target) ? (
<ConnectSourceTask
source={offer.target}
completed={completed}
onComplete={onComplete}
/>
) : null;
case "add-medication":
return (
<AddMedicationTask
completed={completed}
medicationId={resultTarget}
onComplete={onComplete}
/>
);
case "log-reading":
return offer.target ? (
<LogReadingTask
area={offer.target as OnboardingAreaKey}
completed={completed}
onComplete={onComplete}
/>
) : null;
}
}
/* ── connect a source ──────────────────────────────────────────────────── */
function ConnectSourceTask({
source,
completed,
onComplete,
}: {
source: BrowserConnectableSource;
completed: boolean;
onComplete: (target: string | null) => Promise<void>;
}) {
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 (
<ResultTile
title={t("onboarding.flow.first-result.connect-source.done", {
target: label,
})}
detail={t("onboarding.flow.first-result.connect-source.syncing")}
/>
);
}
return (
<div
className="bg-card border-border space-y-4 rounded-xl border p-4 md:p-6"
data-slot="onboarding-task-connect"
>
<p className="text-sm">
{t("onboarding.flow.first-result.connect-source.howTo", {
target: label,
})}
</p>
<Button asChild className="min-h-11">
<Link href={`/settings/integrations#${anchor}`}>
{t("onboarding.flow.first-result.connect-source.cta", {
target: label,
})}
</Link>
</Button>
<p className="text-muted-foreground text-xs" aria-live="polite">
{statuses.isLoading
? t("onboarding.flow.first-result.connect-source.checking")
: t("onboarding.flow.first-result.connect-source.waiting")}
</p>
</div>
);
}
/* ── 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<void>;
}) {
const { t } = useTranslations();
const fmt = useFormatters();
const [open, setOpen] = useState(false);
const detail = useQuery({
queryKey: queryKeys.medicationDetail(medicationId ?? ""),
queryFn: () => apiGet<MedicationDetail>(`/api/medications/${medicationId}`),
enabled: completed && medicationId !== null,
});
if (completed) {
return (
<ResultTile
title={
detail.data
? t("onboarding.flow.first-result.add-medication.done", {
name: detail.data.name,
})
: t("onboarding.flow.first-result.add-medication.doneUnnamed")
}
detail={
detail.data?.nextDueAt
? t("onboarding.flow.first-result.add-medication.nextDose", {
when: fmt.dateTime(detail.data.nextDueAt),
})
: t("onboarding.flow.first-result.add-medication.reminders")
}
/>
);
}
return (
<div
className="bg-card border-border space-y-4 rounded-xl border p-4 md:p-6"
data-slot="onboarding-task-medication"
>
<p className="text-sm">
{t("onboarding.flow.first-result.add-medication.howTo")}
</p>
<Button
type="button"
className="min-h-11"
onClick={() => setOpen(true)}
data-slot="onboarding-add-medication"
>
{t("onboarding.flow.first-result.add-medication.cta")}
</Button>
<MedicationWizardDialog
open={open}
onOpenChange={setOpen}
mode="create"
onSuccess={(id) => {
setOpen(false);
void onComplete(id);
}}
/>
</div>
);
}
/* ── log one reading ───────────────────────────────────────────────────── */
function LogReadingTask({
area,
completed,
onComplete,
}: {
area: OnboardingAreaKey;
completed: boolean;
onComplete: (target: string | null) => Promise<void>;
}) {
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 <LatestReadingTile area={area} storedTypes={target.storedTypes} />;
}
if (completed) {
return (
<ResultTile
title={t("onboarding.flow.first-result.log-reading.doneElsewhere", {
target: label,
})}
/>
);
}
if (target) {
return (
<div
className="bg-card border-border rounded-xl border p-4 md:p-6"
data-slot="onboarding-task-reading"
>
<MeasurementForm
defaultType={target.formType}
onSuccess={() => void onComplete(area)}
/>
</div>
);
}
// 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 (
<div
className="bg-card border-border space-y-4 rounded-xl border p-4 md:p-6"
data-slot="onboarding-task-reading-link"
>
<p className="text-sm">
{t("onboarding.flow.first-result.log-reading.elsewhere", {
target: label,
})}
</p>
<div className="flex flex-wrap gap-2">
<Button asChild className="min-h-11">
<Link href={href ?? "/"}>
{t("onboarding.flow.first-result.log-reading.open", {
target: label,
})}
</Link>
</Button>
<Button
type="button"
variant="outline"
className="min-h-11"
onClick={() => void onComplete(area)}
data-slot="onboarding-reading-done"
>
{t("onboarding.flow.first-result.log-reading.markDone")}
</Button>
</div>
</div>
);
}
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<MeasurementRow[]>(
`/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 (
<div
className="text-muted-foreground flex items-center gap-2 text-sm"
role="status"
>
<Loader2
aria-hidden="true"
className="size-4 animate-spin motion-reduce:animate-none"
/>
{t("common.loading")}
</div>
);
}
if (rows.length === 0) {
return (
<ResultTile title={t("onboarding.flow.first-result.log-reading.done")} />
);
}
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 (
<ResultTile
title={t("onboarding.flow.first-result.log-reading.done")}
detail={`${area === "blood-pressure" ? t("measurements.typeBloodPressure") : label}: ${value} ${unit} · ${fmt.dateTime(rows[0].measuredAt)}`}
/>
);
}
/* ── the result ────────────────────────────────────────────────────────── */
function ResultTile({ title, detail }: { title: string; detail?: string }) {
return (
<div
className="bg-card border-border flex items-start gap-3 rounded-xl border p-4 md:p-6"
data-slot="onboarding-first-result-done"
role="status"
>
<span
aria-hidden="true"
className="bg-primary/10 text-primary flex size-9 shrink-0 items-center justify-center rounded-full"
>
<CheckCircle2 className="size-5" />
</span>
<div className="min-w-0 space-y-1">
<p className="text-sm font-medium">{title}</p>
{detail ? <p className="text-sm">{detail}</p> : null}
</div>
</div>
);
}
@@ -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();
}
});
});
+65
View File
@@ -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<Record<OnboardingAreaKey, AreaReadingTarget>>
> = 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<Record<OnboardingAreaKey, string>>
> = 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<BrowserConnectableSource, { anchor: string; statusKey: string }>
> = 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" },
});
+1 -1
View File
@@ -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);
}