Files
HealthLog/src/components/onboarding/first-result-screen.tsx
T
Marc-André Bombeck da58d16642 fix(onboarding): the connect step reads the sync verdict
The step decided "connected" from the ledger's `state` and had one piece of
copy for every outcome: "the first sync is in progress". That sentence was
false for a connection delivering for months, and worse for one the person
has to repair — the screen said everything was fine while the grant was gone.

`connectSourceView` resolves the envelope entry into one of four tiles and
says whether the flow may record its result. Only `pending_first_sync` and
`fresh` may; `stale` is acknowledged without being claimed as new; `stalled`,
`failing`, `reauth_required` and `parked` name the state and link at the page
that can change it. An unresolved envelope paints no tile at all.

A source the instance cannot connect — a BYO provider with no client pair, a
shared-app provider with no app — now points at the credentials instead of
offering a "Connect" button onto a card that has none.
2026-09-11 09:15:32 +02:00

638 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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,
connectSourceView,
SOURCE_INTEGRATION,
type ConnectSourceSlot,
} 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");
// 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 (
<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} 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,
onComplete,
}: {
offer: Offer;
completed: boolean;
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} 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 fmt = useFormatters();
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);
// The liveness truth is the server-resolved verdict and nothing else. The
// ledger's `state` is what the last ATTEMPT did, and its "no row" default
// read as `connected` until v1.38.19 — which is how a brand-new account was
// told its wearable was connected, and had this step stamped as achieved on
// its behalf. `null` means the envelope has not resolved: no claim, no tile.
const view = connectSourceView(source, status);
const settled = view?.settled ?? false;
// Record the result once the connection is really 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 (settled && !recorded.current) {
recorded.current = true;
void onComplete(source);
}
}, [settled, onComplete, source]);
const settingsHref = `/settings/integrations#${anchor}`;
if (!view) {
// Nothing is known yet. The heading is the person's own answer, so it
// stays; the body says only that the check is running.
return (
<p
className="text-muted-foreground text-sm"
role="status"
data-slot="onboarding-task-checking"
>
{t("onboarding.flow.first-result.connect-source.checking")}
</p>
);
}
const key = `onboarding.flow.first-result.connect-source.state.${view.verdict}`;
const when = view.when
? fmt.dateTime(view.when)
: t("onboarding.flow.first-result.connect-source.noDate");
if (view.slot === "result") {
return (
<ResultTile
title={t(`${key}.title`, { target: label })}
detail={t(`${key}.detail`, { when })}
connect={{ slot: view.slot, state: view.verdict }}
/>
);
}
if (view.slot === "attention") {
return (
<ConnectNoticeTile
slot={view.slot}
state={view.verdict}
testSlot="onboarding-task-attention"
title={t(`${key}.title`, { target: label })}
detail={t(`${key}.detail`, { when })}
href={settingsHref}
cta={t("onboarding.flow.first-result.connect-source.openSettings")}
/>
);
}
if (view.slot === "credentials") {
return (
<ConnectNoticeTile
slot={view.slot}
state={view.verdict}
testSlot="onboarding-task-credentials"
title={t(
"onboarding.flow.first-result.connect-source.needsCredentials.title",
{ target: label },
)}
detail={t(
"onboarding.flow.first-result.connect-source.needsCredentials.detail",
)}
href={settingsHref}
cta={t("onboarding.flow.first-result.connect-source.openSettings")}
/>
);
}
return (
<div
className="bg-card border-border space-y-4 rounded-xl border p-4 md:p-6"
data-slot="onboarding-task-connect"
data-connect-slot={view.slot}
data-connect-state={view.verdict}
>
<p className="text-sm">
{t("onboarding.flow.first-result.connect-source.howTo", {
target: label,
})}
</p>
<Button asChild className="min-h-11">
<Link href={settingsHref}>
{t("onboarding.flow.first-result.connect-source.cta", {
target: label,
})}
</Link>
</Button>
<p className="text-muted-foreground text-xs" aria-live="polite">
{t("onboarding.flow.first-result.connect-source.waiting")}
</p>
</div>
);
}
/**
* A connection the flow cannot treat as its result: one that needs repairing
* (`attention`) or one the instance cannot start at all (`credentials`). Both
* say which state it is in and link at the one page that can change it —
* never a "Connect" button onto a card that has none.
*/
function ConnectNoticeTile({
slot,
state,
testSlot,
title,
detail,
href,
cta,
}: {
slot: ConnectSourceSlot;
state: string;
testSlot: string;
title: string;
detail: string;
href: string;
cta: string;
}) {
return (
<div
className="bg-card border-border space-y-3 rounded-xl border p-4 md:p-6"
data-slot={testSlot}
data-connect-slot={slot}
data-connect-state={state}
>
<p className="text-sm font-medium">{title}</p>
<p className="text-muted-foreground text-sm">{detail}</p>
<Button asChild variant="outline" className="min-h-11">
<Link href={href}>{cta}</Link>
</Button>
</div>
);
}
/* ── add a medication ──────────────────────────────────────────────────── */
interface MedicationDetail {
name: string;
nextDueAt: string | null;
}
function AddMedicationTask({
completed,
onComplete,
}: {
completed: boolean;
onComplete: (target: string | null) => Promise<void>;
}) {
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<string | null>(null);
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"
navigateOnCreate={false}
onSuccess={(id) => {
setOpen(false);
setMedicationId(id);
void onComplete(null);
}}
/>
</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) =>
// 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 (
<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 ────────────────────────────────────────────────────────── */
/**
* 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,
connect,
}: {
title: string;
detail?: string;
/** Set by the connect arm so a journey can read which verdict it painted. */
connect?: { slot: ConnectSourceSlot; state: string };
}) {
return (
<div
className="bg-card border-border space-y-2 rounded-xl border p-4 md:p-6"
data-slot="onboarding-first-result-done"
data-connect-slot={connect?.slot}
data-connect-state={connect?.state}
>
<WrittenOutcomeLine
outcome="success"
message={title}
testId="onboarding-first-result-outcome"
/>
{detail ? <p className="text-sm font-medium">{detail}</p> : null}
</div>
);
}