mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user