diff --git a/src/components/gamification/__tests__/recent-achievements-card.test.tsx b/src/components/gamification/__tests__/recent-achievements-card.test.tsx new file mode 100644 index 000000000..7276c037c --- /dev/null +++ b/src/components/gamification/__tests__/recent-achievements-card.test.tsx @@ -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 — `` 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( + + + , + ); +} + +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("", () => { + 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"); + }); +}); diff --git a/src/components/gamification/recent-achievements-card.tsx b/src/components/gamification/recent-achievements-card.tsx new file mode 100644 index 000000000..1d116945a --- /dev/null +++ b/src/components/gamification/recent-achievements-card.tsx @@ -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 = { + 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 ( +
+
+
+
+ + {t("achievements.dashboardCard.viewAll")} + +
+ + {recent.length === 0 ? ( +

+ {t("achievements.dashboardCard.empty")} +

+ ) : ( +
    + {recent.map((achievement) => { + const Icon = iconMap[achievement.icon] ?? Star; + return ( +
  • +
    + +
    +
    +

    + {t(achievement.titleKey)} +

    +

    + {t(achievement.descriptionKey)} +

    +
    + {achievement.completedAt && ( + + {formatDate(achievement.completedAt)} + + )} +
  • + ); + })} +
+ )} +
+ ); +}