feat(dashboard): RecentAchievementsCard component + tests (b4 follow-up)

Adds the actual `<RecentAchievementsCard>` component referenced by the
dashboard wiring in the previous commit. The previous commit's `-o`
pathspec scope accidentally dropped these new untracked files; without
them the dashboard import resolves to an empty module and breaks the
build. Splitting into two commits per `verification-before-completion`
rather than amending so the parallel-agent push race can't lose the
landing.

The card itself: TanStack Query against
`/api/gamification/achievements`, `pickRecentUnlocks(achievements, 3)`
to slice + sort by completedAt descending, locked entries dropped.
Empty state painted with a CTA + link to `/achievements`.

Test file: `pickRecentUnlocks` ordering / cap / no-date fallback +
empty-state CTA render + three-most-recent render shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-09 21:21:48 +02:00
1 parent a242047765
commit 81f5019b92
2 files changed
+335

No files matched your search

@@ -0,0 +1,171 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import type { AchievementProgress } from "@/lib/gamification/achievements";
/**
* v1.4.15 phase-B4 — `<RecentAchievementsCard>` contract.
*
* 1. `pickRecentUnlocks` — locked entries dropped, unlocked sorted by
* `completedAt` descending, capped at the requested limit. Pure.
* 2. Card render — empty state shows the discovery CTA + link to
* `/achievements`; populated state shows the most recent N items
* with their localized title + completedAt date.
*/
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ isAuthenticated: true }),
}));
let mockData: {
summary: unknown;
achievements: AchievementProgress[];
metrics: unknown;
} = {
summary: {},
achievements: [],
metrics: {},
};
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({ data: mockData }),
}));
import {
RecentAchievementsCard,
pickRecentUnlocks,
} from "../recent-achievements-card";
function render() {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<RecentAchievementsCard />
</I18nProvider>,
);
}
beforeEach(() => {
vi.clearAllMocks();
});
const baseAchievement: AchievementProgress = {
id: "intake-total-1",
metric: "totalTakenIntakes",
category: "medication",
titleKey: "achievements.badges.intakeTotal1.title",
descriptionKey: "achievements.badges.intakeTotal1.description",
icon: "Pill",
format: "count",
target: 1,
current: 1,
points: 8,
unlocked: true,
progressPercent: 100,
completedAt: "2026-04-15T12:00:00.000Z",
};
describe("pickRecentUnlocks", () => {
it("filters out locked entries", () => {
const result = pickRecentUnlocks(
[
{ ...baseAchievement, id: "a", unlocked: true },
{ ...baseAchievement, id: "b", unlocked: false },
],
5,
);
expect(result.map((item) => item.id)).toEqual(["a"]);
});
it("sorts by completedAt descending and caps at the limit", () => {
const result = pickRecentUnlocks(
[
{
...baseAchievement,
id: "older",
completedAt: "2026-01-01T00:00:00.000Z",
},
{
...baseAchievement,
id: "newer",
completedAt: "2026-04-01T00:00:00.000Z",
},
{
...baseAchievement,
id: "newest",
completedAt: "2026-04-15T00:00:00.000Z",
},
{
...baseAchievement,
id: "ancient",
completedAt: "2025-12-01T00:00:00.000Z",
},
],
3,
);
expect(result.map((item) => item.id)).toEqual(["newest", "newer", "older"]);
});
it("places entries without completedAt at the bottom", () => {
const result = pickRecentUnlocks(
[
{ ...baseAchievement, id: "no-date", completedAt: null },
{
...baseAchievement,
id: "dated",
completedAt: "2026-04-15T00:00:00.000Z",
},
],
5,
);
expect(result.map((item) => item.id)).toEqual(["dated", "no-date"]);
});
});
describe("<RecentAchievementsCard>", () => {
it("shows the empty-state CTA when no unlocks exist", () => {
mockData = { summary: {}, achievements: [], metrics: {} };
const html = render();
expect(html).toContain("No achievements yet");
// Link to /achievements so the user can discover the feature
expect(html).toContain('href="/achievements"');
expect(html).toContain("View all");
});
it("renders up to three most-recent unlocks with title + date", () => {
mockData = {
summary: {},
achievements: [
{
...baseAchievement,
id: "a",
completedAt: "2026-04-15T12:00:00.000Z",
},
{
...baseAchievement,
id: "b",
completedAt: "2026-04-10T12:00:00.000Z",
},
{
...baseAchievement,
id: "c",
completedAt: "2026-04-05T12:00:00.000Z",
},
{
...baseAchievement,
id: "d",
completedAt: "2026-04-01T12:00:00.000Z",
},
],
metrics: {},
};
const html = render();
// Card title (always painted)
expect(html).toContain("Recent unlocks");
// Three "recent-achievement-item" slots — capped at the limit
const matches = html.match(/data-slot="recent-achievement-item"/g) ?? [];
expect(matches.length).toBe(3);
// Empty-state copy must NOT appear once we have unlocks
expect(html).not.toContain("No achievements yet");
});
});
@@ -0,0 +1,164 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import {
Activity,
AlertTriangle,
Bug,
CalendarCheck,
Fingerprint,
Flame,
Heart,
KeyRound,
LogIn,
Pill,
ShieldCheck,
SkipForward,
Star,
Target,
Trophy,
type LucideIcon,
} from "lucide-react";
import Link from "next/link";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { formatDate } from "@/lib/format";
import type {
AchievementProgress,
AchievementSummary,
AchievementMetrics,
} from "@/lib/gamification/achievements";
const iconMap: Record<string, LucideIcon> = {
Activity,
Flame,
CalendarCheck,
Pill,
ShieldCheck,
Target,
Trophy,
Heart,
KeyRound,
Fingerprint,
LogIn,
Bug,
AlertTriangle,
SkipForward,
};
interface AchievementsData {
summary: AchievementSummary;
achievements: AchievementProgress[];
metrics: AchievementMetrics;
}
/**
* Pure helper — pick the N most-recently unlocked achievements.
* Achievements without a `completedAt` (legacy unlocks pre-persistence)
* sort to the bottom by definition order so newer entries always win.
* Exported for unit testing.
*/
export function pickRecentUnlocks(
achievements: AchievementProgress[],
limit: number,
): AchievementProgress[] {
const unlocked = achievements.filter((a) => a.unlocked);
unlocked.sort((a, b) => {
if (a.completedAt && b.completedAt) {
return b.completedAt.localeCompare(a.completedAt);
}
if (a.completedAt) return -1;
if (b.completedAt) return 1;
return 0;
});
return unlocked.slice(0, limit);
}
const RECENT_LIMIT = 3;
/**
* v1.4.15 phase-B4 — small dashboard card surfacing the user's three
* most-recently unlocked achievements. Empty-states with a CTA to
* /achievements when nothing is unlocked yet so the user discovers the
* feature exists. Visibility is controlled from Settings → Dashboard
* (`achievements` widget id).
*
* Reuses the same `/api/gamification/achievements` endpoint as the
* dedicated page; TanStack Query dedupes the request when both this
* card and the page render in the same session.
*/
export function RecentAchievementsCard() {
const { isAuthenticated } = useAuth();
const { t } = useTranslations();
const { data } = useQuery({
queryKey: ["gamification", "achievements"],
queryFn: async () => {
const res = await fetch("/api/gamification/achievements");
if (!res.ok) throw new Error("Failed to load achievements");
const json = await res.json();
return json.data as AchievementsData;
},
enabled: isAuthenticated,
});
const recent = pickRecentUnlocks(data?.achievements ?? [], RECENT_LIMIT);
return (
<div
data-slot="recent-achievements-card"
className="bg-card border-border space-y-3 rounded-xl border p-4"
>
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Trophy className="text-primary h-4 w-4" aria-hidden="true" />
<h2 className="text-sm font-semibold">
{t("achievements.dashboardCard.title")}
</h2>
</div>
<Link
href="/achievements"
className="text-primary hover:text-primary/80 text-xs font-medium underline-offset-4 hover:underline"
>
{t("achievements.dashboardCard.viewAll")}
</Link>
</div>
{recent.length === 0 ? (
<p className="text-muted-foreground py-2 text-sm">
{t("achievements.dashboardCard.empty")}
</p>
) : (
<ul className="space-y-2">
{recent.map((achievement) => {
const Icon = iconMap[achievement.icon] ?? Star;
return (
<li
key={achievement.id}
data-slot="recent-achievement-item"
className="border-border bg-background/40 flex items-center gap-3 rounded-md border p-2"
>
<div className="bg-primary/15 text-primary rounded-md p-1.5">
<Icon className="h-3.5 w-3.5" />
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">
{t(achievement.titleKey)}
</p>
<p className="text-muted-foreground line-clamp-1 text-xs">
{t(achievement.descriptionKey)}
</p>
</div>
{achievement.completedAt && (
<span className="text-muted-foreground shrink-0 text-xs tabular-nums">
{formatDate(achievement.completedAt)}
</span>
)}
</li>
);
})}
</ul>
)}
</div>
);
}