mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
1 parent
14cbc69a2d
commit
f4a2e13985
11 files changed
+1438
-252
No files matched your search
+69
-1
@@ -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
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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.");
|
||||
});
|
||||
});
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user