diff --git a/messages/de.json b/messages/de.json index 0b60b59cf..621dd6bfc 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index 73d1370e8..871bdf261 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/src/app/api/onboarding/complete/route.ts b/src/app/api/onboarding/complete/route.ts index 490db8728..99285fc88 100644 --- a/src/app/api/onboarding/complete/route.ts +++ b/src/app/api/onboarding/complete/route.ts @@ -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, diff --git a/src/app/onboarding/__tests__/page.test.tsx b/src/app/onboarding/__tests__/page.test.tsx new file mode 100644 index 000000000..52ad27e70 --- /dev/null +++ b/src/app/onboarding/__tests__/page.test.tsx @@ -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( + + + , + ); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe(" 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"'); + }); +}); diff --git a/src/app/onboarding/page.tsx b/src/app/onboarding/page.tsx index 32ee666a3..7e2d3d558 100644 --- a/src/app/onboarding/page.tsx +++ b/src/app/onboarding/page.tsx @@ -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>([]); + // Step 2 — first measurement is captured by `` + // 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("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 = {}; + 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 = {}; - 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 ( -
+
-

- {t("onboarding.welcome")} +

+ {t("onboarding.v2.title")}

- {t("onboarding.setupDescription")} + {t("onboarding.v2.subtitle")}

- {/* Progress indicator */} -
+ {/* Progress indicator — labelled for screen readers */} +
{Array.from({ length: TOTAL_STEPS }, (_, i) => (
))}
+

+ {t("onboarding.v2.stepOf", { current: step, total: TOTAL_STEPS })} +

- {/* Step 1: Profile */} + {/* ───── Step 1 — about you ───── */} {step === 1 && ( -
-

{t("settings.profile")}

-
- - setHeightCm(e.target.value)} - placeholder="175" - min={50} - max={300} - step={0.1} - /> -
-
- - + {t("onboarding.v2.step1.title")} + +

+ {t("onboarding.v2.step1.description")} +

+ + +
+ + setDisplayName(e.target.value)} + autoComplete="nickname" + placeholder={t("settings.username")} + maxLength={50} + />

- {t("settings.genderHint")} + {t("onboarding.v2.step1.displayNameHint")}

+ +
+ + +

+ {t("onboarding.v2.step1.languageHint")} +

+
+ +
+
+ + setHeightCm(e.target.value)} + placeholder="175" + min={50} + max={300} + step={0.1} + autoComplete="off" + /> +
+
+ + +
+
+
setDateOfBirth(e.target.value)} max={new Date().toISOString().slice(0, 10)} + autoComplete="bday" />

{t("settings.dateOfBirthHint")}

-
+ )} - {/* Step 2: Medications */} + {/* ───── Step 2 — first measurement ───── */} {step === 2 && ( -
-

- {t("onboarding.medicationsTitle")} -

-

- {t("onboarding.medicationsDescription")} -

-
- setMedName(e.target.value)} - placeholder={t("onboarding.medNamePlaceholder")} - className="flex-1" - /> - setMedDose(e.target.value)} - placeholder={t("onboarding.medDosePlaceholder")} - className="w-24" - /> - -
- {meds.length > 0 && ( - <> -
    - {meds.map((m, i) => ( -
  • - - {m.name} {m.dose && `(${m.dose})`} - - -
  • - ))} -
-

- {t("onboarding.medScheduleHint")} -

- - )} -
- )} - - {/* Step 3: Notifications teaser */} - {step === 3 && ( -
-

- {t("onboarding.notificationsTitle")} -

-

- {t("onboarding.notificationsDescription")} -

-
-

- 📱 Telegram — {t("onboarding.telegramHint")} + {t("onboarding.v2.step2.title")} + +

+ {t("onboarding.v2.step2.description")}

-

- 🔔 Web Push — {t("onboarding.webPushHint")} -

-
-

- {t("onboarding.notificationsLater")} -

-
- )} + - {/* Step 4: Targets */} - {step === 4 && ( -
-

- {t("onboarding.targetsTitle")} -

-

- {t("onboarding.targetsDescription")} -

-
-
- - setTargetSys(e.target.value)} - placeholder="130" - /> + {measurementLogged ? ( +
+ +

{t("onboarding.v2.step2.added")}

-
- - setTargetDia(e.target.value)} - placeholder="85" - /> -
-
-
- - setTargetWeight(e.target.value)} - placeholder="75" - step={0.1} - /> -
-
- )} - - {/* Navigation buttons */} -
- -
-
+

+ {t("onboarding.v2.step2.skipHint")} +

+ + )} + + {/* ───── Step 3 — pick a channel ───── */} + {step === 3 && ( +
+
+

+ {t("onboarding.v2.step3.title")} +

+

+ {t("onboarding.v2.step3.description")} +

+
+ +
+ + {t("onboarding.v2.step3.title")} + + {CHANNEL_OPTIONS.map((option) => { + const checked = channelChoice === option.id; + const Icon = option.Icon; + return ( + + ); + })} +
+
+ )} + + {/* ───── Navigation row ───── */} +
+ {step > 1 ? ( + + ) : ( + + )} + {step < TOTAL_STEPS ? ( - + {t("onboarding.v2.continue")} + + ) : ( - + {saving && } + {channelChoice === "NONE" + ? t("onboarding.v2.finish") + : t("onboarding.v2.step3.openSettings")} + )}
+ + {/* Single Skip per step. Step 3's "NONE" choice already covers + skipping notifications, so we hide the underline on step 3. */} + {step < TOTAL_STEPS ? ( + + ) : null}
); } diff --git a/src/app/page.tsx b/src/app/page.tsx index 6290f6843..ec9f8b2cb 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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() {
+ {/* 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. */} + + {/* Quick Entry Dialogs */} 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: ( , }); } @@ -780,6 +803,7 @@ export default function DashboardPage() { charts.push({ id: "sleep-chart", order: widgetOrder("sleep"), + count: sleepSummary?.count ?? 0, node: ( ))}
- {charts.map((entry) => entry.node)} + {charts.map((entry) => ( +
+ {entry.node} + {entry.count != null ? : null} +
+ ))} ); })()} diff --git a/src/components/charts/__tests__/trend-hint.test.tsx b/src/components/charts/__tests__/trend-hint.test.tsx new file mode 100644 index 000000000..9d677dedf --- /dev/null +++ b/src/components/charts/__tests__/trend-hint.test.tsx @@ -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( + {node}, + ); +} + +describe("", () => { + it("renders nothing for 0 readings", () => { + expect(render()).toBe(""); + }); + + it("renders nothing once 5+ readings exist", () => { + expect(render()).toBe(""); + expect(render()).toBe(""); + }); + + it("shows a singular wording when only 1 reading missing", () => { + const html = render(); + 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(); + expect(html).toContain("4 more readings to go."); + }); + + it("translates to German when locale is de", () => { + const html = render(, "de"); + expect(html).toContain("Erster Trend nach 5 Messungen"); + expect(html).toContain("Noch 2 Messungen bis zum Trend."); + }); +}); diff --git a/src/components/charts/trend-hint.tsx b/src/components/charts/trend-hint.tsx new file mode 100644 index 000000000..13f64f844 --- /dev/null +++ b/src/components/charts/trend-hint.tsx @@ -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 `` 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 ( + } + title={t("trendHints.title")} + description={description} + /> + ); +} diff --git a/src/components/onboarding/getting-started-checklist.tsx b/src/components/onboarding/getting-started-checklist.tsx new file mode 100644 index 000000000..3bd7b4340 --- /dev/null +++ b/src/components/onboarding/getting-started-checklist.tsx @@ -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 = { + 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; + }; +} + +interface WithingsStatusResponse { + data?: { connected?: boolean }; +} + +interface NotificationChannel { + type?: string; + enabled?: boolean; +} + +interface NotificationsResponse { + data?: { channels?: NotificationChannel[] }; +} + +function readDismissedSet(): Set { + 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>(() => + readDismissedSet(), + ); + const [dismissedAll, setDismissedAll] = useState(() => + 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({ + 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({ + 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({ + 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({ + 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 ( +
+
+
+

+ {t("gettingStarted.title")} +

+

+ {t("gettingStarted.subtitle")} +

+
+ +
+ + {/* Progress meter — non-decorative, exposed to screen readers. */} +
+
+ + {t("gettingStarted.progress", { + done: progress.done, + total: progress.total, + })} + + {progress.percent}% +
+
+
+
+
+ +
    + {visible.map((item) => { + const Icon = ITEM_ICONS[item.id]; + const labels = ITEM_LABEL_KEYS[item.id]; + return ( +
  • + +
    +

    + {t(labels.title)} +

    +

    + {t(labels.description)} +

    +
    + {!item.done ? ( + + ) : null} + +
  • + ); + })} +
+
+ ); +} diff --git a/src/lib/onboarding/__tests__/checklist.test.ts b/src/lib/onboarding/__tests__/checklist.test.ts new file mode 100644 index 000000000..2190c9658 --- /dev/null +++ b/src/lib/onboarding/__tests__/checklist.test.ts @@ -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[0]> = {}) { + return { + profile: completeProfile, + measurementCount: 0, + medicationCount: 0, + withingsConnected: false, + notificationsConfigured: false, + dismissedIds: new Set(), + ...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(["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(["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(["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" }); + }); +}); diff --git a/src/lib/onboarding/checklist.ts b/src/lib/onboarding/checklist.ts new file mode 100644 index 000000000..179d1fc9d --- /dev/null +++ b/src/lib/onboarding/checklist.ts @@ -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 (``) 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; +} + +/** + * 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 }; +}