feat(v1.4): onboarding redesign — dashboard-first checklist, 3 focused steps, inline trend hints (#131)

Redesigns the first-run experience around three patterns we see in
Linear, Vercel and Notion: a short focused wizard, a persistent
getting-started checklist on the dashboard, and contextual hints
inside still-empty charts.

- Onboarding shrinks from 4 steps (one was marketing-only) to 3:
  About you (display name + locale + height + DOB + gender) → log
  your first measurement (real action via the existing form) → pick
  one notification channel (or skip).
- Every step except the first now has a Back button and exactly one
  Skip link with the honest "you can finish setup later from
  Settings" tail. The legacy duplicate Skip is removed.
- A new dashboard "Getting started" card lists the five quick wins
  (profile, first measurement, medication, Withings, notifications).
  Each row links to the right place. Items can be hidden one-by-one
  or the whole card can be dismissed; both states persist in
  localStorage. The card auto-hides once you finish onboarding and
  log your fifth measurement.
- Charts that have ≥1 but <5 readings now show a subtle inline hint
  ("First trend after 5 readings — N more to go") so the user knows
  the line will appear soon.
- Display name is captured during onboarding and saved to the
  profile.

All new strings are translated to English and German via the
existing i18n pipeline.

Co-authored-by: Marc-André Bombeck <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandMarc-André Bombeck authored and GitHub committed 2026-05-08 12:42:48 +02:00
1 parent 14cbc69a2d
commit f4a2e13985
11 files changed
+1438 -252

No files matched your search

+69 -1
View File
@@ -800,7 +800,75 @@
"errorMedication": "Das Medikament „{name}\" konnte nicht angelegt werden. Bitte erneut versuchen oder später unter „Medikamente\" hinzufügen.",
"errorTargets": "Deine Zielwerte konnten nicht gespeichert werden. Bitte erneut versuchen.",
"errorGeneric": "Beim Abschluss des Setups ist etwas schiefgelaufen. Bitte erneut versuchen.",
"medScheduleHint": "Wir legen ein Standard-Erinnerungsfenster an (08:00–09:00 täglich). Du kannst es jederzeit unter „Medikamente\" anpassen."
"medScheduleHint": "Wir legen ein Standard-Erinnerungsfenster an (08:00–09:00 täglich). Du kannst es jederzeit unter „Medikamente\" anpassen.",
"v2": {
"title": "Willkommen bei HealthLog",
"subtitle": "Drei schnelle Schritte. Alles weitere kannst du später in den Einstellungen erledigen.",
"stepOf": "Schritt {current} von {total}",
"back": "Zurück",
"continue": "Weiter",
"finish": "Setup abschließen",
"skipStep": "Schritt überspringen — du kannst das Setup später in den Einstellungen abschließen.",
"step1": {
"title": "Über dich",
"description": "Wird für Blutdruck-Zielwerte, BMI und passende Erinnerungen genutzt.",
"displayNameLabel": "Anzeigename",
"displayNameHint": "Erscheint in der Dashboard-Begrüßung und in Berichten.",
"languageLabel": "Sprache",
"languageHint": "Wird für App, PDFs und Benachrichtigungen verwendet."
},
"step2": {
"title": "Lege deine erste Messung an",
"description": "Gewicht, Blutdruck oder Puls — was du gerade zur Hand hast. Schon eine Messung lässt das Dashboard lebendig wirken.",
"added": "Top — weitere Messungen kannst du jederzeit über das Dashboard hinzufügen.",
"skipHint": "Keine Messung zur Hand? Überspringe und logge eine, wenn du soweit bist."
},
"step3": {
"title": "Wähle einen Benachrichtigungs-Kanal",
"description": "Wir senden nur, was du auch willst. Du kannst weitere Kanäle und Events später feinjustieren.",
"channelTelegramTitle": "Telegram-Bot",
"channelTelegramHint": "Erinnerungen mit Snooze-Buttons direkt in Telegram.",
"channelWebPushTitle": "Web Push",
"channelWebPushHint": "Native Browser-Benachrichtigungen auf diesem Gerät.",
"channelNtfyTitle": "ntfy.sh",
"channelNtfyHint": "Open-Source-Push an Handy oder Desktop, ohne Account.",
"channelNoneTitle": "Vorerst überspringen",
"channelNoneHint": "Ich richte Benachrichtigungen später in den Einstellungen ein.",
"openSettings": "In den Einstellungen fortsetzen"
},
"doneToast": "Alles eingerichtet — willkommen an Bord."
}
},
"gettingStarted": {
"title": "Erste Schritte",
"subtitle": "Quick Wins, damit du das Beste aus HealthLog herausholst.",
"progress": "{done} von {total} erledigt",
"dismiss": "Ausblenden",
"dismissAll": "Checkliste ausblenden",
"dismissTooltip": "Diesen Punkt ausblenden",
"restoreHint": "Du kannst die Checkliste in den Einstellungen wiederherstellen.",
"items": {
"profileTitle": "Profil einrichten",
"profileDescription": "Größe, Geburtsdatum und Geschlecht schalten personalisierte Zielwerte frei.",
"profileCta": "Profil öffnen",
"measurementTitle": "Erste Messung anlegen",
"measurementDescription": "Eine Messung reicht, um deine erste Trend-Kachel zu zeichnen.",
"measurementCta": "Messung hinzufügen",
"medicationTitle": "Medikament hinzufügen",
"medicationDescription": "Verfolge Compliance und erhalte Erinnerungen genau dann, wenn du sie brauchst.",
"medicationCta": "Medikament hinzufügen",
"withingsTitle": "Withings verbinden (optional)",
"withingsDescription": "Synchronisiere Gewicht, Blutdruck und Puls automatisch aus deinen Withings-Geräten.",
"withingsCta": "Verbinden",
"notificationsTitle": "Benachrichtigungen einrichten",
"notificationsDescription": "Wähle, wie HealthLog dich erinnert — Telegram, ntfy oder Web Push.",
"notificationsCta": "Benachrichtigungen öffnen"
}
},
"trendHints": {
"title": "Erster Trend nach 5 Messungen",
"remainingOne": "Noch 1 Messung bis zum Trend.",
"remainingMany": "Noch {count} Messungen bis zum Trend."
},
"settings": {
"title": "Einstellungen",
+69 -1
View File
@@ -800,7 +800,75 @@
"errorMedication": "Could not add the medication \"{name}\". Please try again or add it later in Medications.",
"errorTargets": "Could not save your target values. Please try again.",
"errorGeneric": "Something went wrong while finishing setup. Please try again.",
"medScheduleHint": "We'll add a default reminder window (08:00–09:00 daily). You can adjust it anytime in Medications."
"medScheduleHint": "We'll add a default reminder window (08:00–09:00 daily). You can adjust it anytime in Medications.",
"v2": {
"title": "Welcome to HealthLog",
"subtitle": "Three quick steps. You can finish anything later from Settings.",
"stepOf": "Step {current} of {total}",
"back": "Back",
"continue": "Continue",
"finish": "Finish setup",
"skipStep": "Skip this step — you can finish setup later from Settings.",
"step1": {
"title": "About you",
"description": "Used for blood-pressure targets, BMI, and the right reminders.",
"displayNameLabel": "Display name",
"displayNameHint": "Shown on the dashboard greeting and in reports.",
"languageLabel": "Language",
"languageHint": "We'll use this for the app, PDFs, and notifications."
},
"step2": {
"title": "Log your first measurement",
"description": "Weight, blood pressure, or pulse — whatever you have right now. The dashboard comes alive with one entry.",
"added": "Got it — you can add more anytime from the dashboard.",
"skipHint": "No measurement at hand? Skip and log one when you're ready."
},
"step3": {
"title": "Pick one notification channel",
"description": "We'll only send what you ask for. You can add more channels or fine-tune events later.",
"channelTelegramTitle": "Telegram bot",
"channelTelegramHint": "Reminders with snooze buttons inside Telegram.",
"channelWebPushTitle": "Web Push",
"channelWebPushHint": "Native browser notifications on this device.",
"channelNtfyTitle": "ntfy.sh",
"channelNtfyHint": "Open-source push to phone or desktop, no account.",
"channelNoneTitle": "Skip for now",
"channelNoneHint": "I'll set up notifications later from Settings.",
"openSettings": "Continue in Settings"
},
"doneToast": "You're all set — welcome aboard."
}
},
"gettingStarted": {
"title": "Getting started",
"subtitle": "Quick wins to get the most out of HealthLog.",
"progress": "{done} of {total} done",
"dismiss": "Dismiss",
"dismissAll": "Hide checklist",
"dismissTooltip": "Hide this item",
"restoreHint": "You can restore the checklist from Settings.",
"items": {
"profileTitle": "Set your profile",
"profileDescription": "Height, date of birth and gender unlock personalised targets.",
"profileCta": "Open profile",
"measurementTitle": "Log your first measurement",
"measurementDescription": "One reading is enough to draw your first trend tile.",
"measurementCta": "Add measurement",
"medicationTitle": "Add a medication",
"medicationDescription": "Track compliance and get reminders right when you need them.",
"medicationCta": "Add medication",
"withingsTitle": "Connect Withings (optional)",
"withingsDescription": "Auto-sync weight, BP and pulse from your Withings devices.",
"withingsCta": "Connect",
"notificationsTitle": "Configure notifications",
"notificationsDescription": "Choose how HealthLog should remind you — Telegram, ntfy or Web Push.",
"notificationsCta": "Open notifications"
}
},
"trendHints": {
"title": "First trend after 5 readings",
"remainingOne": "1 more reading to go.",
"remainingMany": "{count} more readings to go."
},
"settings": {
"title": "Settings",
+5
View File
@@ -6,6 +6,7 @@ import { NextRequest } from "next/server";
import { z } from "zod/v4";
const onboardingSchema = z.object({
displayName: z.string().trim().min(1).max(50).optional(),
heightCm: z.number().min(50).max(300).optional(),
dateOfBirth: z.string().optional(),
gender: z.enum(["MALE", "FEMALE"]).optional(),
@@ -46,6 +47,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
data.gender = result.data.gender;
}
if (result.data.displayName) {
data.displayName = result.data.displayName;
}
await prisma.user.update({
where: { id: user.id },
data,
+100
View File
@@ -0,0 +1,100 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
// Stub Next.js navigation — we never actually navigate in these tests.
vi.mock("next/navigation", () => ({
useRouter: () => ({ replace: vi.fn(), push: vi.fn() }),
}));
// Stub TanStack Query — the onboarding page only invalidates after
// finish, which we don't trigger here.
vi.mock("@tanstack/react-query", () => ({
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
useQuery: () => ({ data: null, isLoading: false }),
}));
// Stub the toaster — fires post-finish only.
vi.mock("sonner", () => ({
toast: { success: vi.fn(), error: vi.fn() },
}));
// The Logo pulls in an SVG icon set we don't need to assert on.
vi.mock("@/components/ui/logo", () => ({
Logo: () => null,
}));
// MeasurementForm is independently tested. Stub it so step 2's render
// doesn't drag the entire form's dependencies into this SSR test.
vi.mock("@/components/measurements/measurement-form", () => ({
MeasurementForm: () => null,
}));
import { I18nProvider } from "@/lib/i18n/context";
import OnboardingPage from "../page";
function render(locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<OnboardingPage />
</I18nProvider>,
);
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("<OnboardingPage> v2", () => {
it("renders three progress dots, not four", () => {
const html = render();
// The progress row holds exactly three dots — count `w-10` segments.
const dots = html.match(/h-1\.5 w-10 rounded-full/g) ?? [];
expect(dots.length).toBe(3);
});
it("shows the v2 welcome copy and step 1 of 3 indicator", () => {
const html = render();
expect(html).toContain("Welcome to HealthLog");
expect(html).toContain("Step 1 of 3");
expect(html).toContain("About you");
});
it("renders the German title when locale is de", () => {
const html = render("de");
expect(html).toContain("Willkommen bei HealthLog");
expect(html).toContain("Schritt 1 von 3");
});
it("includes a single skip link with the descriptive label", () => {
const html = render();
const skipMatches =
html.match(
/Skip this step — you can finish setup later from Settings./g,
) ?? [];
// Exactly one skip control on step 1; second skip from the legacy
// wizard is gone.
expect(skipMatches.length).toBe(1);
});
it("does not show a Back button on step 1 (first step)", () => {
const html = render();
expect(html).not.toContain(">Back<");
});
it("provides every step-1 form field via accessible labels", () => {
const html = render();
expect(html).toContain('for="ob-display-name"');
expect(html).toContain('for="ob-language"');
expect(html).toContain('for="ob-height"');
expect(html).toContain('for="ob-gender"');
expect(html).toContain('for="ob-dob"');
});
it("step-1 progressbar has correct aria attributes", () => {
const html = render();
expect(html).toContain('role="progressbar"');
expect(html).toContain('aria-valuemin="1"');
expect(html).toContain('aria-valuemax="3"');
expect(html).toContain('aria-valuenow="1"');
});
});
+328 -248
View File
@@ -4,93 +4,123 @@ import { useState } from "react";
import { useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
ArrowLeft,
ArrowRight,
Bell,
CheckCircle2,
Loader2,
MessageCircle,
Smartphone,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Logo } from "@/components/ui/logo";
import { Loader2, ChevronRight } from "lucide-react";
import { MeasurementForm } from "@/components/measurements/measurement-form";
import { useTranslations } from "@/lib/i18n/context";
import { locales, localeLabels, type Locale } from "@/lib/i18n/config";
import { cn } from "@/lib/utils";
const TOTAL_STEPS = 4;
const TOTAL_STEPS = 3;
type ChannelChoice = "TELEGRAM" | "WEB_PUSH" | "NTFY" | "NONE";
const CHANNEL_OPTIONS: ReadonlyArray<{
id: ChannelChoice;
Icon: typeof MessageCircle;
titleKey: string;
hintKey: string;
/** Hash on /settings to scroll to. NONE = skip, no hash. */
hash: string | null;
}> = [
{
id: "TELEGRAM",
Icon: MessageCircle,
titleKey: "onboarding.v2.step3.channelTelegramTitle",
hintKey: "onboarding.v2.step3.channelTelegramHint",
hash: "telegram",
},
{
id: "WEB_PUSH",
Icon: Bell,
titleKey: "onboarding.v2.step3.channelWebPushTitle",
hintKey: "onboarding.v2.step3.channelWebPushHint",
hash: "web-push",
},
{
id: "NTFY",
Icon: Smartphone,
titleKey: "onboarding.v2.step3.channelNtfyTitle",
hintKey: "onboarding.v2.step3.channelNtfyHint",
hash: "ntfy",
},
{
id: "NONE",
Icon: CheckCircle2,
titleKey: "onboarding.v2.step3.channelNoneTitle",
hintKey: "onboarding.v2.step3.channelNoneHint",
hash: null,
},
];
export default function OnboardingPage() {
const router = useRouter();
const queryClient = useQueryClient();
const { t } = useTranslations();
const { t, locale, setLocale } = useTranslations();
const [step, setStep] = useState(1);
const [step, setStep] = useState<1 | 2 | 3>(1);
const [saving, setSaving] = useState(false);
// Step 1: Profile
// Step 1 — about you
const [displayName, setDisplayName] = useState("");
const [heightCm, setHeightCm] = useState("");
const [dateOfBirth, setDateOfBirth] = useState("");
const [gender, setGender] = useState("");
// Step 2: Medications
const [medName, setMedName] = useState("");
const [medDose, setMedDose] = useState("");
const [meds, setMeds] = useState<Array<{ name: string; dose: string }>>([]);
// Step 2 — first measurement is captured by `<MeasurementForm>`
// directly; we just track whether the user has already submitted one
// so the UI can switch to a "got it" state.
const [measurementLogged, setMeasurementLogged] = useState(false);
// Step 3: Targets
const [targetSys, setTargetSys] = useState("");
const [targetDia, setTargetDia] = useState("");
const [targetWeight, setTargetWeight] = useState("");
// Step 3 — preferred notification channel.
const [channelChoice, setChannelChoice] = useState<ChannelChoice>("NONE");
async function finishOnboarding() {
function goBack() {
setStep((prev) => (prev > 1 ? ((prev - 1) as 1 | 2) : 1));
}
/**
* Persist the profile + complete onboarding. Always called once at
* the end of the wizard — independent of which steps were skipped —
* so `onboardingCompletedAt` is set even if the user skips
* everything except the wizard click-through.
*/
async function persistAndExit(opts?: { gotoSettingsHash?: string | null }) {
setSaving(true);
try {
const body: Record<string, unknown> = {};
if (displayName.trim()) body.displayName = displayName.trim();
if (heightCm) body.heightCm = parseFloat(heightCm);
if (dateOfBirth) body.dateOfBirth = dateOfBirth;
if (gender) body.gender = gender;
const profileRes = await fetch("/api/onboarding/complete", {
const res = await fetch("/api/onboarding/complete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!profileRes.ok) {
throw new Error(t("onboarding.errorProfile"));
}
// Create medications. Server validation requires at least one schedule,
// so we attach a sensible default — the user is informed via medScheduleHint
// and can refine it in Medications later. Phase 3.5 of the audit replaces
// this entire wizard with an empty-state-driven flow.
for (const med of meds) {
const medRes = await fetch("/api/medications", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: med.name,
dose: med.dose,
schedules: [{ windowStart: "08:00", windowEnd: "09:00" }],
}),
});
if (!medRes.ok) {
throw new Error(t("onboarding.errorMedication", { name: med.name }));
}
}
if (targetSys || targetDia || targetWeight) {
const targets: Record<string, unknown> = {};
if (targetSys) targets.bpSysTarget = parseInt(targetSys);
if (targetDia) targets.bpDiaTarget = parseInt(targetDia);
if (targetWeight) targets.weightTarget = parseFloat(targetWeight);
const targetsRes = await fetch("/api/insights/targets", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(targets),
});
if (!targetsRes.ok) {
throw new Error(t("onboarding.errorTargets"));
}
}
if (!res.ok) throw new Error(t("onboarding.errorProfile"));
await queryClient.invalidateQueries({ queryKey: ["auth"] });
router.replace("/");
toast.success(t("onboarding.v2.doneToast"));
if (opts?.gotoSettingsHash) {
router.replace(`/settings#${opts.gotoSettingsHash}`);
} else {
router.replace("/");
}
} catch (err) {
const message =
err instanceof Error ? err.message : t("onboarding.errorGeneric");
@@ -99,79 +129,145 @@ export default function OnboardingPage() {
}
}
function addMedication() {
if (!medName.trim()) return;
setMeds([...meds, { name: medName.trim(), dose: medDose.trim() }]);
setMedName("");
setMedDose("");
}
function nextStep() {
if (step < TOTAL_STEPS) {
setStep(step + 1);
} else {
finishOnboarding();
setStep((step + 1) as 1 | 2 | 3);
}
}
function finish() {
const target =
channelChoice !== "NONE"
? (CHANNEL_OPTIONS.find((c) => c.id === channelChoice)?.hash ?? null)
: null;
void persistAndExit({ gotoSettingsHash: target });
}
return (
<div className="mx-auto w-full max-w-md space-y-6">
<div className="mx-auto w-full max-w-md space-y-6 py-6">
<div className="text-center">
<Logo className="text-primary mx-auto mb-4" size={48} />
<h1 className="text-2xl font-bold tracking-tight">
{t("onboarding.welcome")}
<h1 className="text-2xl font-semibold tracking-tight">
{t("onboarding.v2.title")}
</h1>
<p className="text-muted-foreground mt-2 text-sm">
{t("onboarding.setupDescription")}
{t("onboarding.v2.subtitle")}
</p>
</div>
{/* Progress indicator */}
<div className="flex justify-center gap-2">
{/* Progress indicator — labelled for screen readers */}
<div
role="progressbar"
aria-valuemin={1}
aria-valuemax={TOTAL_STEPS}
aria-valuenow={step}
aria-label={t("onboarding.v2.stepOf", {
current: step,
total: TOTAL_STEPS,
})}
className="flex items-center justify-center gap-2"
>
{Array.from({ length: TOTAL_STEPS }, (_, i) => (
<div
key={i}
className={cn(
"h-1.5 w-8 rounded-full transition-colors",
"h-1.5 w-10 rounded-full transition-colors duration-150 ease-out motion-reduce:transition-none",
i + 1 <= step ? "bg-primary" : "bg-muted",
)}
/>
))}
</div>
<p className="text-muted-foreground -mt-3 text-center text-xs">
{t("onboarding.v2.stepOf", { current: step, total: TOTAL_STEPS })}
</p>
{/* Step 1: Profile */}
{/* ───── Step 1 — about you ───── */}
{step === 1 && (
<div className="bg-card border-border space-y-4 rounded-xl border p-6">
<h2 className="text-lg font-semibold">{t("settings.profile")}</h2>
<div className="space-y-2">
<Label htmlFor="ob-height">{t("settings.height")}</Label>
<Input
id="ob-height"
type="number"
value={heightCm}
onChange={(e) => setHeightCm(e.target.value)}
placeholder="175"
min={50}
max={300}
step={0.1}
/>
</div>
<div className="space-y-2">
<Label htmlFor="ob-gender">{t("settings.gender")}</Label>
<select
id="ob-gender"
value={gender}
onChange={(e) => setGender(e.target.value)}
className="border-input bg-background text-foreground ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-9 w-full rounded-md border px-3 py-1 text-sm focus-visible:ring-[3px] focus-visible:outline-none"
<section
aria-labelledby="ob-step1-title"
className="bg-card border-border space-y-5 rounded-xl border p-6"
>
<header className="space-y-1">
<h2
id="ob-step1-title"
className="text-lg font-semibold tracking-tight"
>
<option value="">{t("settings.genderNone")}</option>
<option value="MALE">{t("settings.genderMale")}</option>
<option value="FEMALE">{t("settings.genderFemale")}</option>
</select>
{t("onboarding.v2.step1.title")}
</h2>
<p className="text-muted-foreground text-sm">
{t("onboarding.v2.step1.description")}
</p>
</header>
<div className="space-y-2">
<Label htmlFor="ob-display-name">
{t("onboarding.v2.step1.displayNameLabel")}
</Label>
<Input
id="ob-display-name"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
autoComplete="nickname"
placeholder={t("settings.username")}
maxLength={50}
/>
<p className="text-muted-foreground text-xs">
{t("settings.genderHint")}
{t("onboarding.v2.step1.displayNameHint")}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="ob-language">
{t("onboarding.v2.step1.languageLabel")}
</Label>
<select
id="ob-language"
value={locale}
onChange={(e) => setLocale(e.target.value as Locale)}
className="border-input bg-background text-foreground ring-offset-background focus-visible:ring-ring flex h-9 w-full rounded-md border px-3 py-1 text-sm focus-visible:ring-[3px] focus-visible:outline-none"
>
{locales.map((loc) => (
<option key={loc} value={loc}>
{localeLabels[loc as Locale]}
</option>
))}
</select>
<p className="text-muted-foreground text-xs">
{t("onboarding.v2.step1.languageHint")}
</p>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="ob-height">{t("settings.height")}</Label>
<Input
id="ob-height"
type="number"
inputMode="decimal"
value={heightCm}
onChange={(e) => setHeightCm(e.target.value)}
placeholder="175"
min={50}
max={300}
step={0.1}
autoComplete="off"
/>
</div>
<div className="space-y-2">
<Label htmlFor="ob-gender">{t("settings.gender")}</Label>
<select
id="ob-gender"
value={gender}
onChange={(e) => setGender(e.target.value)}
className="border-input bg-background text-foreground ring-offset-background focus-visible:ring-ring flex h-9 w-full rounded-md border px-3 py-1 text-sm focus-visible:ring-[3px] focus-visible:outline-none"
>
<option value="">{t("settings.genderNone")}</option>
<option value="MALE">{t("settings.genderMale")}</option>
<option value="FEMALE">{t("settings.genderFemale")}</option>
</select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="ob-dob">{t("settings.dateOfBirth")}</Label>
<Input
@@ -180,181 +276,165 @@ export default function OnboardingPage() {
value={dateOfBirth}
onChange={(e) => setDateOfBirth(e.target.value)}
max={new Date().toISOString().slice(0, 10)}
autoComplete="bday"
/>
<p className="text-muted-foreground text-xs">
{t("settings.dateOfBirthHint")}
</p>
</div>
</div>
</section>
)}
{/* Step 2: Medications */}
{/* ───── Step 2 — first measurement ───── */}
{step === 2 && (
<div className="bg-card border-border space-y-4 rounded-xl border p-6">
<h2 className="text-lg font-semibold">
{t("onboarding.medicationsTitle")}
</h2>
<p className="text-muted-foreground text-xs">
{t("onboarding.medicationsDescription")}
</p>
<div className="flex gap-2">
<Input
value={medName}
onChange={(e) => setMedName(e.target.value)}
placeholder={t("onboarding.medNamePlaceholder")}
className="flex-1"
/>
<Input
value={medDose}
onChange={(e) => setMedDose(e.target.value)}
placeholder={t("onboarding.medDosePlaceholder")}
className="w-24"
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={addMedication}
<section
aria-labelledby="ob-step2-title"
className="bg-card border-border space-y-5 rounded-xl border p-6"
>
<header className="space-y-1">
<h2
id="ob-step2-title"
className="text-lg font-semibold tracking-tight"
>
+
</Button>
</div>
{meds.length > 0 && (
<>
<ul className="space-y-1 text-sm">
{meds.map((m, i) => (
<li
key={i}
className="bg-muted/50 flex items-center justify-between rounded px-3 py-1.5"
>
<span>
{m.name} {m.dose && `(${m.dose})`}
</span>
<button
type="button"
className="text-muted-foreground hover:text-destructive text-xs"
onClick={() => setMeds(meds.filter((_, j) => j !== i))}
>
✕
</button>
</li>
))}
</ul>
<p className="text-muted-foreground text-xs">
{t("onboarding.medScheduleHint")}
</p>
</>
)}
</div>
)}
{/* Step 3: Notifications teaser */}
{step === 3 && (
<div className="bg-card border-border space-y-4 rounded-xl border p-6">
<h2 className="text-lg font-semibold">
{t("onboarding.notificationsTitle")}
</h2>
<p className="text-muted-foreground text-sm">
{t("onboarding.notificationsDescription")}
</p>
<div className="bg-muted/50 space-y-2 rounded-lg p-4 text-sm">
<p>
📱 <strong>Telegram</strong> — {t("onboarding.telegramHint")}
{t("onboarding.v2.step2.title")}
</h2>
<p className="text-muted-foreground text-sm">
{t("onboarding.v2.step2.description")}
</p>
<p>
🔔 <strong>Web Push</strong> — {t("onboarding.webPushHint")}
</p>
</div>
<p className="text-muted-foreground text-xs">
{t("onboarding.notificationsLater")}
</p>
</div>
)}
</header>
{/* Step 4: Targets */}
{step === 4 && (
<div className="bg-card border-border space-y-4 rounded-xl border p-6">
<h2 className="text-lg font-semibold">
{t("onboarding.targetsTitle")}
</h2>
<p className="text-muted-foreground text-xs">
{t("onboarding.targetsDescription")}
</p>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label htmlFor="ob-sys">{t("onboarding.targetSys")}</Label>
<Input
id="ob-sys"
type="number"
value={targetSys}
onChange={(e) => setTargetSys(e.target.value)}
placeholder="130"
/>
{measurementLogged ? (
<div className="border-primary/40 bg-primary/5 text-foreground flex items-start gap-3 rounded-lg border p-4 text-sm">
<CheckCircle2 className="text-primary mt-0.5 size-5 shrink-0" />
<p>{t("onboarding.v2.step2.added")}</p>
</div>
<div className="space-y-1">
<Label htmlFor="ob-dia">{t("onboarding.targetDia")}</Label>
<Input
id="ob-dia"
type="number"
value={targetDia}
onChange={(e) => setTargetDia(e.target.value)}
placeholder="85"
/>
</div>
</div>
<div className="space-y-1">
<Label htmlFor="ob-weight">{t("onboarding.targetWeight")}</Label>
<Input
id="ob-weight"
type="number"
value={targetWeight}
onChange={(e) => setTargetWeight(e.target.value)}
placeholder="75"
step={0.1}
/>
</div>
</div>
)}
{/* Navigation buttons */}
<div className="flex gap-3">
<Button onClick={nextStep} className="flex-1" disabled={saving}>
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{step < TOTAL_STEPS ? (
<>
{t("common.next")}
<ChevronRight className="ml-1 h-4 w-4" />
</>
) : (
t("onboarding.completeSetup")
<MeasurementForm onSuccess={() => setMeasurementLogged(true)} />
)}
</Button>
</div>
<div className="text-center">
<p className="text-muted-foreground text-xs">
{t("onboarding.v2.step2.skipHint")}
</p>
</section>
)}
{/* ───── Step 3 — pick a channel ───── */}
{step === 3 && (
<section
aria-labelledby="ob-step3-title"
className="bg-card border-border space-y-5 rounded-xl border p-6"
>
<header className="space-y-1">
<h2
id="ob-step3-title"
className="text-lg font-semibold tracking-tight"
>
{t("onboarding.v2.step3.title")}
</h2>
<p className="text-muted-foreground text-sm">
{t("onboarding.v2.step3.description")}
</p>
</header>
<fieldset className="space-y-2">
<legend className="sr-only">
{t("onboarding.v2.step3.title")}
</legend>
{CHANNEL_OPTIONS.map((option) => {
const checked = channelChoice === option.id;
const Icon = option.Icon;
return (
<label
key={option.id}
className={cn(
"border-border hover:border-primary/50 flex cursor-pointer items-start gap-3 rounded-lg border p-3 transition-colors duration-150 ease-out motion-reduce:transition-none",
checked && "border-primary bg-primary/5",
)}
>
<input
type="radio"
name="ob-channel"
value={option.id}
checked={checked}
onChange={() => setChannelChoice(option.id)}
className="text-primary focus-visible:ring-ring mt-1 size-4 focus-visible:ring-[3px] focus-visible:outline-none"
/>
<span className="flex min-w-0 flex-1 items-start gap-3">
<span
aria-hidden="true"
className="bg-muted text-muted-foreground flex size-8 shrink-0 items-center justify-center rounded-md"
>
<Icon className="size-4" />
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium">
{t(option.titleKey)}
</span>
<span className="text-muted-foreground block text-xs">
{t(option.hintKey)}
</span>
</span>
</span>
</label>
);
})}
</fieldset>
</section>
)}
{/* ───── Navigation row ───── */}
<div className="flex items-center gap-3">
{step > 1 ? (
<Button
type="button"
variant="outline"
onClick={goBack}
disabled={saving}
>
<ArrowLeft className="mr-1 size-4" />
{t("onboarding.v2.back")}
</Button>
) : (
<span className="flex-1" />
)}
{step < TOTAL_STEPS ? (
<button
<Button
type="button"
onClick={nextStep}
className="ml-auto"
disabled={saving}
className="text-muted-foreground hover:text-foreground text-sm underline underline-offset-4 transition-colors"
>
{t("onboarding.skip")}
</button>
{t("onboarding.v2.continue")}
<ArrowRight className="ml-1 size-4" />
</Button>
) : (
<button
<Button
type="button"
onClick={() => {
// Skip targets and finish
finishOnboarding();
}}
onClick={finish}
className="ml-auto"
disabled={saving}
className="text-muted-foreground hover:text-foreground text-sm underline underline-offset-4 transition-colors"
>
{t("onboarding.skip")}
</button>
{saving && <Loader2 className="mr-2 size-4 animate-spin" />}
{channelChoice === "NONE"
? t("onboarding.v2.finish")
: t("onboarding.v2.step3.openSettings")}
</Button>
)}
</div>
{/* Single Skip per step. Step 3's "NONE" choice already covers
skipping notifications, so we hide the underline on step 3. */}
{step < TOTAL_STEPS ? (
<button
type="button"
onClick={nextStep}
disabled={saving}
className="text-muted-foreground hover:text-foreground mx-auto block text-sm underline-offset-4 transition-colors duration-150 ease-out hover:underline motion-reduce:transition-none"
>
{t("onboarding.v2.skipStep")}
</button>
) : null}
</div>
);
}
+32 -2
View File
@@ -40,6 +40,8 @@ import {
import { MeasurementForm } from "@/components/measurements/measurement-form";
import { MoodForm } from "@/components/mood/mood-form";
import { TrendCard } from "@/components/charts/trend-card";
import { TrendHint } from "@/components/charts/trend-hint";
import { GettingStartedChecklist } from "@/components/onboarding/getting-started-checklist";
const HealthChart = dynamic(
() =>
@@ -381,6 +383,12 @@ export default function DashboardPage() {
</DropdownMenu>
</div>
{/* v1.4: Getting-started checklist for brand-new users.
* Self-gates visibility on (onboardingCompletedAt == null
* || measurementCount < 5) and disappears once dismissed
* or fully complete. See B2 in the v1.4 discovery summary. */}
<GettingStartedChecklist />
{/* Quick Entry Dialogs */}
<Dialog
open={quickEntryDialog === "measurement"}
@@ -679,12 +687,23 @@ export default function DashboardPage() {
trendCards.sort((a, b) => a.order - b.order);
type ChartEntry = { id: string; order: number; node: React.ReactNode };
type ChartEntry = {
id: string;
order: number;
node: React.ReactNode;
/**
* Total raw readings for this metric. <5 surfaces a contextual
* "First trend after 5 readings" hint underneath the chart;
* undefined disables the hint (e.g. medications card).
*/
count?: number;
};
const charts: ChartEntry[] = [];
if (showWeightCard) {
charts.push({
id: "weight-chart",
order: widgetOrder("weight"),
count: w?.count ?? 0,
node: (
<HealthChart
key="weight-chart"
@@ -724,6 +743,7 @@ export default function DashboardPage() {
charts.push({
id: "bp-chart",
order: widgetOrder("bp"),
count: Math.max(sys?.count ?? 0, dia?.count ?? 0),
node: (
<HealthChart
key="bp-chart"
@@ -741,6 +761,7 @@ export default function DashboardPage() {
charts.push({
id: "pulse-chart",
order: widgetOrder("pulse"),
count: p?.count ?? 0,
node: (
<HealthChart
key="pulse-chart"
@@ -757,6 +778,7 @@ export default function DashboardPage() {
charts.push({
id: "bodyFat-chart",
order: widgetOrder("bodyFat"),
count: bf?.count ?? 0,
node: (
<HealthChart
key="bodyFat-chart"
@@ -773,6 +795,7 @@ export default function DashboardPage() {
charts.push({
id: "mood-chart",
order: widgetOrder("mood"),
count: moodSummary?.count ?? 0,
node: <MoodChart key="mood-chart" />,
});
}
@@ -780,6 +803,7 @@ export default function DashboardPage() {
charts.push({
id: "sleep-chart",
order: widgetOrder("sleep"),
count: sleepSummary?.count ?? 0,
node: (
<HealthChart
key="sleep-chart"
@@ -795,6 +819,7 @@ export default function DashboardPage() {
charts.push({
id: "steps-chart",
order: widgetOrder("steps"),
count: stepsSummary?.count ?? 0,
node: (
<HealthChart
key="steps-chart"
@@ -856,7 +881,12 @@ export default function DashboardPage() {
</div>
))}
</div>
{charts.map((entry) => entry.node)}
{charts.map((entry) => (
<div key={entry.id} className="space-y-2">
{entry.node}
{entry.count != null ? <TrendHint count={entry.count} /> : null}
</div>
))}
</>
);
})()}
@@ -0,0 +1,39 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { TrendHint } from "../trend-hint";
import { I18nProvider } from "@/lib/i18n/context";
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
describe("<TrendHint>", () => {
it("renders nothing for 0 readings", () => {
expect(render(<TrendHint count={0} />)).toBe("");
});
it("renders nothing once 5+ readings exist", () => {
expect(render(<TrendHint count={5} />)).toBe("");
expect(render(<TrendHint count={42} />)).toBe("");
});
it("shows a singular wording when only 1 reading missing", () => {
const html = render(<TrendHint count={4} />);
expect(html).toContain("First trend after 5 readings");
expect(html).toContain("1 more reading to go.");
});
it("shows a plural wording for >1 missing readings", () => {
const html = render(<TrendHint count={1} />);
expect(html).toContain("4 more readings to go.");
});
it("translates to German when locale is de", () => {
const html = render(<TrendHint count={3} />, "de");
expect(html).toContain("Erster Trend nach 5 Messungen");
expect(html).toContain("Noch 2 Messungen bis zum Trend.");
});
});
+41
View File
@@ -0,0 +1,41 @@
"use client";
import { TrendingUp } from "lucide-react";
import { EmptyState } from "@/components/ui/empty-state";
import { useTranslations } from "@/lib/i18n/context";
import { trendHintFor } from "@/lib/onboarding/checklist";
interface TrendHintProps {
/** Total readings logged for this metric (raw count, not days). */
count: number;
}
/**
* Tiny banner shown below or alongside a chart for users still in the
* "first week" of HealthLog: ≥1 but <5 readings of this metric. We
* mount the existing `<EmptyState variant="plain">` primitive so the
* shape matches every other empty-state in the app and inherits its
* polite-live-region semantics. Renders nothing once the user crosses
* 5 readings — no flicker.
*/
export function TrendHint({ count }: TrendHintProps) {
const { t } = useTranslations();
const result = trendHintFor(count);
if (result.kind === "hidden") return null;
const description =
result.remaining === 1
? t("trendHints.remainingOne")
: t("trendHints.remainingMany", { count: result.remaining });
return (
<EmptyState
variant="plain"
size="compact"
icon={<TrendingUp className="size-4" />}
title={t("trendHints.title")}
description={description}
/>
);
}
@@ -0,0 +1,366 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { Activity, Bell, Check, Pill, User2, Wifi, X } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import {
buildChecklist,
checklistProgress,
shouldShowChecklist,
visibleChecklist,
type ChecklistItemId,
} from "@/lib/onboarding/checklist";
const DISMISSED_ITEMS_KEY = "healthlog-getting-started-dismissed";
const DISMISSED_ALL_KEY = "healthlog-getting-started-hidden";
const ITEM_ICONS: Record<ChecklistItemId, LucideIcon> = {
profile: User2,
measurement: Activity,
medication: Pill,
withings: Wifi,
notifications: Bell,
};
const ITEM_LABEL_KEYS: Record<
ChecklistItemId,
{ title: string; description: string; cta: string }
> = {
profile: {
title: "gettingStarted.items.profileTitle",
description: "gettingStarted.items.profileDescription",
cta: "gettingStarted.items.profileCta",
},
measurement: {
title: "gettingStarted.items.measurementTitle",
description: "gettingStarted.items.measurementDescription",
cta: "gettingStarted.items.measurementCta",
},
medication: {
title: "gettingStarted.items.medicationTitle",
description: "gettingStarted.items.medicationDescription",
cta: "gettingStarted.items.medicationCta",
},
withings: {
title: "gettingStarted.items.withingsTitle",
description: "gettingStarted.items.withingsDescription",
cta: "gettingStarted.items.withingsCta",
},
notifications: {
title: "gettingStarted.items.notificationsTitle",
description: "gettingStarted.items.notificationsDescription",
cta: "gettingStarted.items.notificationsCta",
},
};
interface MedicationsResponse {
data?: { medications?: Array<{ id: string }> };
}
interface AnalyticsResponse {
data?: {
summaries?: Record<string, { count?: number } | undefined>;
};
}
interface WithingsStatusResponse {
data?: { connected?: boolean };
}
interface NotificationChannel {
type?: string;
enabled?: boolean;
}
interface NotificationsResponse {
data?: { channels?: NotificationChannel[] };
}
function readDismissedSet(): Set<ChecklistItemId> {
if (typeof window === "undefined") return new Set();
try {
const raw = window.localStorage.getItem(DISMISSED_ITEMS_KEY);
if (!raw) return new Set();
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
return new Set(
parsed.filter((id): id is ChecklistItemId => typeof id === "string"),
);
}
} catch {
/* ignore corrupt storage */
}
return new Set();
}
function readDismissedAll(): boolean {
if (typeof window === "undefined") return false;
try {
return window.localStorage.getItem(DISMISSED_ALL_KEY) === "1";
} catch {
return false;
}
}
/**
* Persistent dashboard checklist surfaced to brand-new users. Mirrors
* the Linear/Notion pattern: visible until the user has either
* completed every step or explicitly hidden it. Each row is one line
* with status icon + label + CTA + dismiss-x.
*/
export function GettingStartedChecklist() {
const { user } = useAuth();
const { t } = useTranslations();
const [dismissedIds, setDismissedIds] = useState<Set<ChecklistItemId>>(() =>
readDismissedSet(),
);
const [dismissedAll, setDismissedAll] = useState<boolean>(() =>
readDismissedAll(),
);
// Sync state to localStorage. Effect, not setState-in-effect.
useEffect(() => {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(
DISMISSED_ITEMS_KEY,
JSON.stringify([...dismissedIds]),
);
} catch {
/* storage may be full or disabled */
}
}, [dismissedIds]);
useEffect(() => {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(DISMISSED_ALL_KEY, dismissedAll ? "1" : "0");
} catch {
/* storage may be full or disabled */
}
}, [dismissedAll]);
// Light-weight queries: each fetch is small and cached by tanstack.
// We rely on the React Query cache the dashboard already uses for
// analytics, so this won't fire a second request when the dashboard
// and the checklist mount together.
const { data: analyticsRes } = useQuery<AnalyticsResponse>({
queryKey: ["analytics"],
queryFn: async () => {
const res = await fetch("/api/analytics");
if (!res.ok) throw new Error("Failed");
return res.json();
},
enabled: !!user,
});
const { data: medsRes } = useQuery<MedicationsResponse>({
queryKey: ["medications"],
queryFn: async () => {
const res = await fetch("/api/medications");
if (!res.ok) throw new Error("Failed");
return res.json();
},
enabled: !!user,
});
const { data: withingsRes } = useQuery<WithingsStatusResponse>({
queryKey: ["withings", "status"],
queryFn: async () => {
const res = await fetch("/api/withings/status");
if (!res.ok) throw new Error("Failed");
return res.json();
},
enabled: !!user,
});
const { data: notificationsRes } = useQuery<NotificationsResponse>({
queryKey: ["notifications", "preferences"],
queryFn: async () => {
const res = await fetch("/api/notifications/preferences");
if (!res.ok) throw new Error("Failed");
return res.json();
},
enabled: !!user,
});
const measurementCount = useMemo(() => {
const summaries = analyticsRes?.data?.summaries ?? {};
let total = 0;
for (const key of Object.keys(summaries)) {
total += summaries[key]?.count ?? 0;
}
return total;
}, [analyticsRes]);
const medicationCount = medsRes?.data?.medications?.length ?? 0;
const withingsConnected = withingsRes?.data?.connected === true;
const notificationsConfigured = (notificationsRes?.data?.channels ?? []).some(
(channel) => channel?.enabled === true,
);
const items = useMemo(
() =>
buildChecklist({
profile: {
heightCm: user?.heightCm ?? null,
dateOfBirth: user?.dateOfBirth ?? null,
gender: user?.gender ?? null,
},
measurementCount,
medicationCount,
withingsConnected,
notificationsConfigured,
dismissedIds,
}),
[
user?.heightCm,
user?.dateOfBirth,
user?.gender,
measurementCount,
medicationCount,
withingsConnected,
notificationsConfigured,
dismissedIds,
],
);
const visible = visibleChecklist(items);
const progress = checklistProgress(items);
const show = shouldShowChecklist({
onboardingCompletedAt: user?.onboardingCompletedAt ?? null,
measurementCount,
dismissedAll,
items,
});
if (!user || !show) return null;
return (
<section
aria-labelledby="getting-started-title"
className="bg-card border-border space-y-4 rounded-2xl border p-5 sm:p-6"
>
<header className="flex items-start justify-between gap-3">
<div className="space-y-1">
<h2
id="getting-started-title"
className="text-base font-semibold tracking-tight"
>
{t("gettingStarted.title")}
</h2>
<p className="text-muted-foreground text-sm">
{t("gettingStarted.subtitle")}
</p>
</div>
<Button
type="button"
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-foreground"
onClick={() => setDismissedAll(true)}
>
{t("gettingStarted.dismissAll")}
</Button>
</header>
{/* Progress meter — non-decorative, exposed to screen readers. */}
<div
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={progress.percent}
aria-label={t("gettingStarted.progress", {
done: progress.done,
total: progress.total,
})}
className="space-y-1"
>
<div className="text-muted-foreground flex justify-between text-xs">
<span>
{t("gettingStarted.progress", {
done: progress.done,
total: progress.total,
})}
</span>
<span className="font-mono tabular-nums">{progress.percent}%</span>
</div>
<div className="bg-muted h-1.5 overflow-hidden rounded-full">
<div
className="bg-primary h-full rounded-full transition-[width] duration-200 ease-out motion-reduce:transition-none"
style={{ width: `${progress.percent}%` }}
/>
</div>
</div>
<ul className="space-y-1.5">
{visible.map((item) => {
const Icon = ITEM_ICONS[item.id];
const labels = ITEM_LABEL_KEYS[item.id];
return (
<li
key={item.id}
className="hover:bg-accent/40 group flex items-center gap-3 rounded-md px-2 py-2"
>
<span
aria-hidden="true"
className={
item.done
? "bg-primary/20 text-primary flex size-7 shrink-0 items-center justify-center rounded-full"
: "bg-muted text-muted-foreground flex size-7 shrink-0 items-center justify-center rounded-full"
}
>
{item.done ? (
<Check className="size-4" />
) : (
<Icon className="size-4" />
)}
</span>
<div className="min-w-0 flex-1">
<p
className={
item.done
? "text-muted-foreground truncate text-sm line-through"
: "truncate text-sm font-medium"
}
>
{t(labels.title)}
</p>
<p className="text-muted-foreground truncate text-xs">
{t(labels.description)}
</p>
</div>
{!item.done ? (
<Button asChild size="sm" variant="outline">
<Link href={item.href}>{t(labels.cta)}</Link>
</Button>
) : null}
<button
type="button"
onClick={() =>
setDismissedIds((prev) => {
const next = new Set(prev);
next.add(item.id);
return next;
})
}
aria-label={t("gettingStarted.dismissTooltip")}
className="text-muted-foreground hover:text-foreground rounded p-1 transition-colors"
>
<X className="size-4" />
</button>
</li>
);
})}
</ul>
</section>
);
}
@@ -0,0 +1,217 @@
import { describe, expect, it } from "vitest";
import {
buildChecklist,
checklistProgress,
isProfileComplete,
shouldShowChecklist,
trendHintFor,
visibleChecklist,
type ChecklistItemId,
} from "../checklist";
const completeProfile = {
heightCm: 175,
dateOfBirth: "1990-01-01",
gender: "MALE",
};
function inputs(overrides: Partial<Parameters<typeof buildChecklist>[0]> = {}) {
return {
profile: completeProfile,
measurementCount: 0,
medicationCount: 0,
withingsConnected: false,
notificationsConfigured: false,
dismissedIds: new Set<ChecklistItemId>(),
...overrides,
};
}
describe("isProfileComplete", () => {
it("requires height, dob and gender all set", () => {
expect(isProfileComplete(completeProfile)).toBe(true);
expect(isProfileComplete({ ...completeProfile, heightCm: null })).toBe(
false,
);
expect(isProfileComplete({ ...completeProfile, dateOfBirth: null })).toBe(
false,
);
expect(isProfileComplete({ ...completeProfile, gender: "" })).toBe(false);
expect(isProfileComplete({ ...completeProfile, heightCm: 0 })).toBe(false);
});
});
describe("buildChecklist", () => {
it("emits the five canonical items in stable order", () => {
const items = buildChecklist(inputs());
expect(items.map((i) => i.id)).toEqual([
"profile",
"measurement",
"medication",
"withings",
"notifications",
]);
});
it("marks profile done when all three fields set", () => {
const items = buildChecklist(inputs());
expect(items.find((i) => i.id === "profile")?.done).toBe(true);
});
it("flags measurement done at first reading", () => {
const items = buildChecklist(inputs({ measurementCount: 1 }));
expect(items.find((i) => i.id === "measurement")?.done).toBe(true);
});
it("flags medication and withings independently", () => {
const items = buildChecklist(
inputs({ medicationCount: 2, withingsConnected: true }),
);
expect(items.find((i) => i.id === "medication")?.done).toBe(true);
expect(items.find((i) => i.id === "withings")?.done).toBe(true);
});
it("propagates per-item dismissal", () => {
const items = buildChecklist(
inputs({ dismissedIds: new Set<ChecklistItemId>(["medication"]) }),
);
expect(items.find((i) => i.id === "medication")?.dismissed).toBe(true);
expect(items.find((i) => i.id === "profile")?.dismissed).toBe(false);
});
it("attaches deep-link hrefs", () => {
const items = buildChecklist(inputs());
const hrefs = Object.fromEntries(items.map((i) => [i.id, i.href]));
expect(hrefs.profile).toBe("/settings#profile");
expect(hrefs.measurement).toBe("/measurements");
expect(hrefs.medication).toBe("/medications");
expect(hrefs.withings).toBe("/settings#withings");
expect(hrefs.notifications).toBe("/settings#notifications");
});
});
describe("visibleChecklist + checklistProgress", () => {
it("hides per-item dismissed rows from the visible list", () => {
const items = buildChecklist(
inputs({ dismissedIds: new Set<ChecklistItemId>(["withings"]) }),
);
const visible = visibleChecklist(items);
expect(visible.map((i) => i.id)).not.toContain("withings");
});
it("counts done items inside the visible subset", () => {
const items = buildChecklist(
inputs({
measurementCount: 3,
medicationCount: 1,
// dismiss the only one not done so percent jumps to 100
dismissedIds: new Set<ChecklistItemId>(["withings", "notifications"]),
}),
);
const progress = checklistProgress(items);
expect(progress.total).toBe(3);
expect(progress.done).toBe(3);
expect(progress.percent).toBe(100);
expect(progress.allDone).toBe(true);
});
it("returns 0% when nothing done", () => {
const items = buildChecklist({
profile: { heightCm: null, dateOfBirth: null, gender: null },
measurementCount: 0,
medicationCount: 0,
withingsConnected: false,
notificationsConfigured: false,
dismissedIds: new Set(),
});
const progress = checklistProgress(items);
expect(progress.percent).toBe(0);
expect(progress.allDone).toBe(false);
});
});
describe("shouldShowChecklist", () => {
it("hides when user fully dismissed the checklist", () => {
const items = buildChecklist(inputs());
expect(
shouldShowChecklist({
onboardingCompletedAt: null,
measurementCount: 0,
dismissedAll: true,
items,
}),
).toBe(false);
});
it("hides once user has finished onboarding AND has 5+ measurements", () => {
const items = buildChecklist(inputs({ measurementCount: 10 }));
expect(
shouldShowChecklist({
onboardingCompletedAt: "2026-01-01T00:00:00Z",
measurementCount: 10,
dismissedAll: false,
items,
}),
).toBe(false);
});
it("stays visible while onboarding is incomplete", () => {
const items = buildChecklist(inputs({ measurementCount: 7 }));
expect(
shouldShowChecklist({
onboardingCompletedAt: null,
measurementCount: 7,
dismissedAll: false,
items,
}),
).toBe(true);
});
it("stays visible while measurement count under 5 even after onboarding ack", () => {
const items = buildChecklist(inputs({ measurementCount: 2 }));
expect(
shouldShowChecklist({
onboardingCompletedAt: "2026-01-01T00:00:00Z",
measurementCount: 2,
dismissedAll: false,
items,
}),
).toBe(true);
});
it("hides when every visible item is done", () => {
const items = buildChecklist(
inputs({
measurementCount: 3,
medicationCount: 1,
withingsConnected: true,
notificationsConfigured: true,
}),
);
expect(
shouldShowChecklist({
onboardingCompletedAt: null,
measurementCount: 3,
dismissedAll: false,
items,
}),
).toBe(false);
});
});
describe("trendHintFor", () => {
it("hides at 0 readings (chart empty-state owns this)", () => {
expect(trendHintFor(0)).toEqual({ kind: "hidden" });
});
it("shows the right remainder between 1 and 4", () => {
expect(trendHintFor(1)).toEqual({ kind: "show", remaining: 4 });
expect(trendHintFor(4)).toEqual({ kind: "show", remaining: 1 });
});
it("hides once 5 readings reached", () => {
expect(trendHintFor(5)).toEqual({ kind: "hidden" });
expect(trendHintFor(99)).toEqual({ kind: "hidden" });
});
});
+172
View File
@@ -0,0 +1,172 @@
/**
* Pure progression logic for the v1.4 dashboard "Getting started"
* checklist. Decoupled from React so it can be unit-tested without a
* DOM. The component layer (`<GettingStartedChecklist>`) reads inputs
* (auth user + analytics summaries + medications count + Withings
* status + dismissed-ids set) and renders against the result.
*/
export const CHECKLIST_ITEM_IDS = [
"profile",
"measurement",
"medication",
"withings",
"notifications",
] as const;
export type ChecklistItemId = (typeof CHECKLIST_ITEM_IDS)[number];
export interface ChecklistItem {
id: ChecklistItemId;
done: boolean;
/** Pre-built href the CTA should jump to. Hash anchors target settings sections. */
href: string;
/** True if the user explicitly hid this row. */
dismissed: boolean;
}
export interface ChecklistInputs {
/** Profile completeness — height + dateOfBirth + gender all set. */
profile: {
heightCm: number | null;
dateOfBirth: string | null;
gender: string | null;
};
/** Total measurements logged across all types. */
measurementCount: number;
/** Number of medications the user has created. */
medicationCount: number;
/** True iff Withings OAuth is connected (status.connected). */
withingsConnected: boolean;
/**
* True iff the user has set up at least one notification channel
* (Telegram, ntfy, or Web Push).
*/
notificationsConfigured: boolean;
/** Dismissed item ids (per-item localStorage state). */
dismissedIds: ReadonlySet<ChecklistItemId>;
}
/**
* Compute the ordered checklist for the dashboard hero. Stable order:
* profile → measurement → medication → withings → notifications. Each
* item carries the deep-link the row's CTA should navigate to.
*/
export function buildChecklist(inputs: ChecklistInputs): ChecklistItem[] {
const profileDone = isProfileComplete(inputs.profile);
const items: ChecklistItem[] = [
{
id: "profile",
done: profileDone,
href: "/settings#profile",
dismissed: inputs.dismissedIds.has("profile"),
},
{
id: "measurement",
done: inputs.measurementCount >= 1,
href: "/measurements",
dismissed: inputs.dismissedIds.has("measurement"),
},
{
id: "medication",
done: inputs.medicationCount >= 1,
href: "/medications",
dismissed: inputs.dismissedIds.has("medication"),
},
{
id: "withings",
done: inputs.withingsConnected,
href: "/settings#withings",
dismissed: inputs.dismissedIds.has("withings"),
},
{
id: "notifications",
done: inputs.notificationsConfigured,
href: "/settings#notifications",
dismissed: inputs.dismissedIds.has("notifications"),
},
];
return items;
}
/**
* The checklist visible to the user — drops dismissed items and items
* already done **and** hidden by completion. We keep done items
* visible until the user completes the whole list, so they get the
* satisfaction of ticking the last box.
*/
export function visibleChecklist(items: ChecklistItem[]): ChecklistItem[] {
return items.filter((item) => !item.dismissed);
}
export interface ChecklistProgress {
total: number;
done: number;
/** Integer percentage 0-100. */
percent: number;
/** True iff every non-dismissed item is done. */
allDone: boolean;
}
export function checklistProgress(items: ChecklistItem[]): ChecklistProgress {
const visible = visibleChecklist(items);
const total = visible.length;
const done = visible.filter((item) => item.done).length;
const percent = total === 0 ? 100 : Math.round((done / total) * 100);
return { total, done, percent, allDone: total > 0 && done === total };
}
/**
* Should the dashboard render the checklist at all?
*
* Visible while the user is still in the setup phase:
* - `onboardingCompletedAt` is null OR
* - they have fewer than 5 measurements
*
* AND the user has not dismissed the entire checklist
* AND there is at least one undone, non-dismissed item.
*/
export function shouldShowChecklist(args: {
onboardingCompletedAt: string | null;
measurementCount: number;
dismissedAll: boolean;
items: ChecklistItem[];
}): boolean {
if (args.dismissedAll) return false;
const stillInSetup =
args.onboardingCompletedAt == null || args.measurementCount < 5;
if (!stillInSetup) return false;
const visible = visibleChecklist(args.items);
if (visible.length === 0) return false;
return visible.some((item) => !item.done);
}
/**
* Profile is "complete" once height, date of birth and gender are all
* set. Display name is captured automatically at signup, so it doesn't
* gate this item.
*/
export function isProfileComplete(
profile: ChecklistInputs["profile"],
): boolean {
return (
profile.heightCm != null &&
profile.heightCm > 0 &&
profile.dateOfBirth != null &&
profile.gender != null &&
profile.gender !== ""
);
}
/**
* Trend hint: when the user has between 1 and 4 readings of a metric we
* surface "First trend after 5 readings — N more to go". 0 readings is
* handled by the existing chart empty-state. ≥5 readings hides the
* hint entirely.
*/
export function trendHintFor(
count: number,
): { kind: "hidden" } | { kind: "show"; remaining: number } {
if (count < 1 || count >= 5) return { kind: "hidden" };
return { kind: "show", remaining: 5 - count };
}