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

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

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

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

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

No files matched your search

@@ -0,0 +1,39 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { TrendHint } from "../trend-hint";
import { I18nProvider } from "@/lib/i18n/context";
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
describe("<TrendHint>", () => {
it("renders nothing for 0 readings", () => {
expect(render(<TrendHint count={0} />)).toBe("");
});
it("renders nothing once 5+ readings exist", () => {
expect(render(<TrendHint count={5} />)).toBe("");
expect(render(<TrendHint count={42} />)).toBe("");
});
it("shows a singular wording when only 1 reading missing", () => {
const html = render(<TrendHint count={4} />);
expect(html).toContain("First trend after 5 readings");
expect(html).toContain("1 more reading to go.");
});
it("shows a plural wording for >1 missing readings", () => {
const html = render(<TrendHint count={1} />);
expect(html).toContain("4 more readings to go.");
});
it("translates to German when locale is de", () => {
const html = render(<TrendHint count={3} />, "de");
expect(html).toContain("Erster Trend nach 5 Messungen");
expect(html).toContain("Noch 2 Messungen bis zum Trend.");
});
});
+41
View File
@@ -0,0 +1,41 @@
"use client";
import { TrendingUp } from "lucide-react";
import { EmptyState } from "@/components/ui/empty-state";
import { useTranslations } from "@/lib/i18n/context";
import { trendHintFor } from "@/lib/onboarding/checklist";
interface TrendHintProps {
/** Total readings logged for this metric (raw count, not days). */
count: number;
}
/**
* Tiny banner shown below or alongside a chart for users still in the
* "first week" of HealthLog: ≥1 but <5 readings of this metric. We
* mount the existing `<EmptyState variant="plain">` primitive so the
* shape matches every other empty-state in the app and inherits its
* polite-live-region semantics. Renders nothing once the user crosses
* 5 readings — no flicker.
*/
export function TrendHint({ count }: TrendHintProps) {
const { t } = useTranslations();
const result = trendHintFor(count);
if (result.kind === "hidden") return null;
const description =
result.remaining === 1
? t("trendHints.remainingOne")
: t("trendHints.remainingMany", { count: result.remaining });
return (
<EmptyState
variant="plain"
size="compact"
icon={<TrendingUp className="size-4" />}
title={t("trendHints.title")}
description={description}
/>
);
}
@@ -0,0 +1,366 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { Activity, Bell, Check, Pill, User2, Wifi, X } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import {
buildChecklist,
checklistProgress,
shouldShowChecklist,
visibleChecklist,
type ChecklistItemId,
} from "@/lib/onboarding/checklist";
const DISMISSED_ITEMS_KEY = "healthlog-getting-started-dismissed";
const DISMISSED_ALL_KEY = "healthlog-getting-started-hidden";
const ITEM_ICONS: Record<ChecklistItemId, LucideIcon> = {
profile: User2,
measurement: Activity,
medication: Pill,
withings: Wifi,
notifications: Bell,
};
const ITEM_LABEL_KEYS: Record<
ChecklistItemId,
{ title: string; description: string; cta: string }
> = {
profile: {
title: "gettingStarted.items.profileTitle",
description: "gettingStarted.items.profileDescription",
cta: "gettingStarted.items.profileCta",
},
measurement: {
title: "gettingStarted.items.measurementTitle",
description: "gettingStarted.items.measurementDescription",
cta: "gettingStarted.items.measurementCta",
},
medication: {
title: "gettingStarted.items.medicationTitle",
description: "gettingStarted.items.medicationDescription",
cta: "gettingStarted.items.medicationCta",
},
withings: {
title: "gettingStarted.items.withingsTitle",
description: "gettingStarted.items.withingsDescription",
cta: "gettingStarted.items.withingsCta",
},
notifications: {
title: "gettingStarted.items.notificationsTitle",
description: "gettingStarted.items.notificationsDescription",
cta: "gettingStarted.items.notificationsCta",
},
};
interface MedicationsResponse {
data?: { medications?: Array<{ id: string }> };
}
interface AnalyticsResponse {
data?: {
summaries?: Record<string, { count?: number } | undefined>;
};
}
interface WithingsStatusResponse {
data?: { connected?: boolean };
}
interface NotificationChannel {
type?: string;
enabled?: boolean;
}
interface NotificationsResponse {
data?: { channels?: NotificationChannel[] };
}
function readDismissedSet(): Set<ChecklistItemId> {
if (typeof window === "undefined") return new Set();
try {
const raw = window.localStorage.getItem(DISMISSED_ITEMS_KEY);
if (!raw) return new Set();
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
return new Set(
parsed.filter((id): id is ChecklistItemId => typeof id === "string"),
);
}
} catch {
/* ignore corrupt storage */
}
return new Set();
}
function readDismissedAll(): boolean {
if (typeof window === "undefined") return false;
try {
return window.localStorage.getItem(DISMISSED_ALL_KEY) === "1";
} catch {
return false;
}
}
/**
* Persistent dashboard checklist surfaced to brand-new users. Mirrors
* the Linear/Notion pattern: visible until the user has either
* completed every step or explicitly hidden it. Each row is one line
* with status icon + label + CTA + dismiss-x.
*/
export function GettingStartedChecklist() {
const { user } = useAuth();
const { t } = useTranslations();
const [dismissedIds, setDismissedIds] = useState<Set<ChecklistItemId>>(() =>
readDismissedSet(),
);
const [dismissedAll, setDismissedAll] = useState<boolean>(() =>
readDismissedAll(),
);
// Sync state to localStorage. Effect, not setState-in-effect.
useEffect(() => {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(
DISMISSED_ITEMS_KEY,
JSON.stringify([...dismissedIds]),
);
} catch {
/* storage may be full or disabled */
}
}, [dismissedIds]);
useEffect(() => {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(DISMISSED_ALL_KEY, dismissedAll ? "1" : "0");
} catch {
/* storage may be full or disabled */
}
}, [dismissedAll]);
// Light-weight queries: each fetch is small and cached by tanstack.
// We rely on the React Query cache the dashboard already uses for
// analytics, so this won't fire a second request when the dashboard
// and the checklist mount together.
const { data: analyticsRes } = useQuery<AnalyticsResponse>({
queryKey: ["analytics"],
queryFn: async () => {
const res = await fetch("/api/analytics");
if (!res.ok) throw new Error("Failed");
return res.json();
},
enabled: !!user,
});
const { data: medsRes } = useQuery<MedicationsResponse>({
queryKey: ["medications"],
queryFn: async () => {
const res = await fetch("/api/medications");
if (!res.ok) throw new Error("Failed");
return res.json();
},
enabled: !!user,
});
const { data: withingsRes } = useQuery<WithingsStatusResponse>({
queryKey: ["withings", "status"],
queryFn: async () => {
const res = await fetch("/api/withings/status");
if (!res.ok) throw new Error("Failed");
return res.json();
},
enabled: !!user,
});
const { data: notificationsRes } = useQuery<NotificationsResponse>({
queryKey: ["notifications", "preferences"],
queryFn: async () => {
const res = await fetch("/api/notifications/preferences");
if (!res.ok) throw new Error("Failed");
return res.json();
},
enabled: !!user,
});
const measurementCount = useMemo(() => {
const summaries = analyticsRes?.data?.summaries ?? {};
let total = 0;
for (const key of Object.keys(summaries)) {
total += summaries[key]?.count ?? 0;
}
return total;
}, [analyticsRes]);
const medicationCount = medsRes?.data?.medications?.length ?? 0;
const withingsConnected = withingsRes?.data?.connected === true;
const notificationsConfigured = (notificationsRes?.data?.channels ?? []).some(
(channel) => channel?.enabled === true,
);
const items = useMemo(
() =>
buildChecklist({
profile: {
heightCm: user?.heightCm ?? null,
dateOfBirth: user?.dateOfBirth ?? null,
gender: user?.gender ?? null,
},
measurementCount,
medicationCount,
withingsConnected,
notificationsConfigured,
dismissedIds,
}),
[
user?.heightCm,
user?.dateOfBirth,
user?.gender,
measurementCount,
medicationCount,
withingsConnected,
notificationsConfigured,
dismissedIds,
],
);
const visible = visibleChecklist(items);
const progress = checklistProgress(items);
const show = shouldShowChecklist({
onboardingCompletedAt: user?.onboardingCompletedAt ?? null,
measurementCount,
dismissedAll,
items,
});
if (!user || !show) return null;
return (
<section
aria-labelledby="getting-started-title"
className="bg-card border-border space-y-4 rounded-2xl border p-5 sm:p-6"
>
<header className="flex items-start justify-between gap-3">
<div className="space-y-1">
<h2
id="getting-started-title"
className="text-base font-semibold tracking-tight"
>
{t("gettingStarted.title")}
</h2>
<p className="text-muted-foreground text-sm">
{t("gettingStarted.subtitle")}
</p>
</div>
<Button
type="button"
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-foreground"
onClick={() => setDismissedAll(true)}
>
{t("gettingStarted.dismissAll")}
</Button>
</header>
{/* Progress meter — non-decorative, exposed to screen readers. */}
<div
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={progress.percent}
aria-label={t("gettingStarted.progress", {
done: progress.done,
total: progress.total,
})}
className="space-y-1"
>
<div className="text-muted-foreground flex justify-between text-xs">
<span>
{t("gettingStarted.progress", {
done: progress.done,
total: progress.total,
})}
</span>
<span className="font-mono tabular-nums">{progress.percent}%</span>
</div>
<div className="bg-muted h-1.5 overflow-hidden rounded-full">
<div
className="bg-primary h-full rounded-full transition-[width] duration-200 ease-out motion-reduce:transition-none"
style={{ width: `${progress.percent}%` }}
/>
</div>
</div>
<ul className="space-y-1.5">
{visible.map((item) => {
const Icon = ITEM_ICONS[item.id];
const labels = ITEM_LABEL_KEYS[item.id];
return (
<li
key={item.id}
className="hover:bg-accent/40 group flex items-center gap-3 rounded-md px-2 py-2"
>
<span
aria-hidden="true"
className={
item.done
? "bg-primary/20 text-primary flex size-7 shrink-0 items-center justify-center rounded-full"
: "bg-muted text-muted-foreground flex size-7 shrink-0 items-center justify-center rounded-full"
}
>
{item.done ? (
<Check className="size-4" />
) : (
<Icon className="size-4" />
)}
</span>
<div className="min-w-0 flex-1">
<p
className={
item.done
? "text-muted-foreground truncate text-sm line-through"
: "truncate text-sm font-medium"
}
>
{t(labels.title)}
</p>
<p className="text-muted-foreground truncate text-xs">
{t(labels.description)}
</p>
</div>
{!item.done ? (
<Button asChild size="sm" variant="outline">
<Link href={item.href}>{t(labels.cta)}</Link>
</Button>
) : null}
<button
type="button"
onClick={() =>
setDismissedIds((prev) => {
const next = new Set(prev);
next.add(item.id);
return next;
})
}
aria-label={t("gettingStarted.dismissTooltip")}
className="text-muted-foreground hover:text-foreground rounded p-1 transition-colors"
>
<X className="size-4" />
</button>
</li>
);
})}
</ul>
</section>
);
}