feat(achievements): dedicated /achievements page with locked/unlocked list

Pre-v1.4.15 the page silently dropped locked achievements, so the user
only saw what they had already earned. Now every badge renders — locked
ones are grayed out with a "Locked" badge, criterion hint
({current} / {target}), and a progress bar. Unlocked entries keep the
existing primary-gradient card with the "Completed on …" footer.

Achievements are grouped by a new `category` axis (medication, vitals,
security, engagement) — derived in `getAchievementCategory()` so a
metric is always in exactly one bucket. Inside a category, unlocked
items sort first; among locked items the closest-to-unlock one ranks
first so the user always sees their immediate next goal at the top.

The sidebar link to /achievements was already in place under the
user-area group; this commit only adds the new locked/grouped render
plus an e2e smoke spec that walks the desktop sidebar → click →
heading visible path.

i18n: 9 new keys under `achievements.*` (locked, criterionHint,
progressPercent, categories.{medication,vitals,security,engagement},
dashboardCard.{title,viewAll,empty}) — EN + DE. C4's parity audit will
sweep later.

Tests: +9 unit (groupByCategory ordering + page render in EN/DE) +
1 e2e (sidebar → /achievements). 909 / 909 unit pass.

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:17:04 +02:00
1 parent db5a49d593
commit 34c967c14a
9 files changed
+773 -55

No files matched your search

@@ -0,0 +1,198 @@
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 — /achievements page contract.
*
* Two surfaces under test:
*
* 1. `groupByCategory` — pure helper, no provider needed. Locked items
* go below unlocked ones, and inside locked-items the closest-to-
* unlock sorts first so the user always sees their immediate next
* goal at the top of each category.
* 2. The page render itself — locked entries paint the muted card with
* a "Locked" badge + criterion hint + progress bar, unlocked ones
* paint the highlighted variant with the "Completed on …" footer.
* Both share `<AchievementCard>`; the regression we're guarding
* against is "page silently dropped locked entries" which is what
* shipped pre-v1.4.15.
*
* The page uses TanStack Query under the hood — we mock it via a small
* `useQuery` shim so the SSR test renders the loaded state directly,
* matching the `onboarding` page's existing test pattern.
*/
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
isAuthenticated: true,
isLoading: false,
user: { id: "u1", username: "test", role: "USER" },
}),
}));
const mockData = {
summary: {
unlockedCount: 1,
totalCount: 3,
earnedPoints: 8,
totalPoints: 100,
completionPercent: 33,
nextAchievement: null,
},
achievements: [
{
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",
},
{
id: "intake-total-10",
metric: "totalTakenIntakes",
category: "medication",
titleKey: "achievements.badges.intakeTotal10.title",
descriptionKey: "achievements.badges.intakeTotal10.description",
icon: "Pill",
format: "count",
target: 10,
current: 4,
points: 24,
unlocked: false,
progressPercent: 40,
completedAt: null,
},
{
id: "passkey-created-1",
metric: "passkeyCreatedCount",
category: "security",
titleKey: "achievements.badges.passkeyCreated1.title",
descriptionKey: "achievements.badges.passkeyCreated1.description",
icon: "KeyRound",
format: "count",
target: 1,
current: 0,
points: 40,
unlocked: false,
progressPercent: 0,
completedAt: null,
},
] satisfies AchievementProgress[],
metrics: {
totalTakenIntakes: 1,
overIntakeCount: 0,
skippedIntakeCount: 0,
bmiGreenStreak: 0,
bpGreenStreak: 0,
pulseGreenStreak: 0,
onTimePerfectDayStreak: 0,
compliance80DayStreak: 0,
passkeyCreatedCount: 0,
passkeyLoginCount: 0,
passwordLoginCount: 0,
loginDayStreak: 0,
bugReportCount: 0,
},
};
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({ data: mockData, isLoading: false }),
}));
import AchievementsPage, { groupByCategory } from "../page";
function render(locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<AchievementsPage />
</I18nProvider>,
);
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("groupByCategory", () => {
it("buckets achievements by category and orders unlocked first", () => {
const grouped = groupByCategory(mockData.achievements);
expect(grouped.map((g) => g.category)).toEqual(["medication", "security"]);
const medication = grouped.find((g) => g.category === "medication");
expect(medication?.items.map((i) => i.id)).toEqual([
"intake-total-1",
"intake-total-10",
]);
});
it("inside the locked subset, sorts closest-to-unlock first", () => {
const grouped = groupByCategory([
{
...mockData.achievements[1], // medication, 40%
},
{
...mockData.achievements[2], // security, 0% — different category
},
{
...mockData.achievements[1],
id: "intake-total-50",
progressPercent: 5,
},
]);
const medication = grouped.find((g) => g.category === "medication");
expect(medication?.items.map((i) => i.progressPercent)).toEqual([40, 5]);
});
});
describe("<AchievementsPage>", () => {
it("renders an unlocked card with the completed-on footer", () => {
const html = render();
expect(html).toContain("First intake");
// Unlocked badge text
expect(html).toContain("Completed on");
// Unlocked styling marker — the unlocked variant uses the primary
// gradient background; the locked variant uses opacity-70.
expect(html).toContain("achievement-card-unlocked");
});
it("renders locked entries grayed out with a criterion hint and progress", () => {
const html = render();
// Locked entry must still appear — pre-v1.4.15 the page silently
// dropped locked entries, which is what this guard prevents.
expect(html).toContain("achievement-card-locked");
expect(html).toContain("Intake starter"); // intake-total-10 title
// "Locked" badge label
expect(html).toContain("Locked");
// Criterion hint "{current} / {target}" — for intake-total-10 the
// mock has current=4 / target=10. Using the formatted version
// (English uses the bare number) so the assertion is precise.
expect(html).toContain("4 / 10");
});
it("groups by category with localized headings and counts", () => {
const html = render();
expect(html).toContain("Medication");
expect(html).toContain("Account &amp; security");
// Category counts: medication 1/2, security 0/1
expect(html).toContain("1 / 2");
expect(html).toContain("0 / 1");
});
it("renders the German category headings when locale is de", () => {
const html = render("de");
expect(html).toContain("Medikation");
expect(html).toContain("Konto &amp; Sicherheit");
expect(html).toContain("Gesperrt"); // German "Locked"
});
});
+183 -53
View File
@@ -11,6 +11,7 @@ import {
Heart,
KeyRound,
Loader2,
Lock,
LogIn,
Pill,
ShieldCheck,
@@ -25,10 +26,12 @@ import { Badge } from "@/components/ui/badge";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { formatDate } from "@/lib/format";
import type {
AchievementMetrics,
AchievementProgress,
AchievementSummary,
import {
ACHIEVEMENT_CATEGORY_ORDER,
type AchievementCategory,
type AchievementMetrics,
type AchievementProgress,
type AchievementSummary,
} from "@/lib/gamification/achievements";
interface AchievementsData {
@@ -54,6 +57,13 @@ const iconMap: Record<string, LucideIcon> = {
SkipForward,
};
const CATEGORY_LABEL_KEY: Record<AchievementCategory, string> = {
medication: "achievements.categories.medication",
vitals: "achievements.categories.vitals",
security: "achievements.categories.security",
engagement: "achievements.categories.engagement",
};
function formatMetric(
format: AchievementProgress["format"],
value: number,
@@ -65,6 +75,136 @@ function formatMetric(
return t("achievements.metricCount", { count: value });
}
/**
* Pure helper: bucket the achievements by their declared category in
* ACHIEVEMENT_CATEGORY_ORDER's order. Categories with no badges (a
* theoretical future state) are dropped from the output. Inside a
* category, unlocked badges sort to the top so the user's progress is
* always visible without scrolling.
*/
export function groupByCategory(
achievements: AchievementProgress[],
): Array<{ category: AchievementCategory; items: AchievementProgress[] }> {
const buckets = new Map<AchievementCategory, AchievementProgress[]>();
for (const item of achievements) {
const list = buckets.get(item.category) ?? [];
list.push(item);
buckets.set(item.category, list);
}
for (const list of buckets.values()) {
list.sort((a, b) => {
if (a.unlocked !== b.unlocked) return a.unlocked ? -1 : 1;
// For locked items show the closest-to-unlock first; for unlocked
// items keep them in insertion (definition) order — completedAt is
// not always populated and would push newer unlocks below older
// ones in a way that hides recent progress.
if (a.unlocked && b.unlocked) return 0;
return b.progressPercent - a.progressPercent;
});
}
return ACHIEVEMENT_CATEGORY_ORDER.filter((category) =>
buckets.has(category),
).map((category) => ({
category,
items: buckets.get(category) ?? [],
}));
}
interface AchievementCardProps {
achievement: AchievementProgress;
t: ReturnType<typeof useTranslations>["t"];
}
function AchievementCard({ achievement, t }: AchievementCardProps) {
const Icon = iconMap[achievement.icon] ?? Star;
const unlocked = achievement.unlocked;
return (
<div
data-slot={
unlocked ? "achievement-card-unlocked" : "achievement-card-locked"
}
className={
unlocked
? "border-primary/30 from-primary/8 to-primary/0 rounded-xl border bg-gradient-to-br p-3"
: "border-border bg-card/50 rounded-xl border p-3 opacity-70"
}
>
<div className="mb-2 flex items-start justify-between gap-2">
<div className="flex items-center gap-3">
<div
className={
unlocked
? "bg-primary/15 text-primary rounded-lg p-2"
: "bg-muted text-muted-foreground rounded-lg p-2"
}
>
<Icon className="h-3.5 w-3.5" />
</div>
<div className="min-w-0">
<h3 className="text-sm font-semibold">{t(achievement.titleKey)}</h3>
<p className="text-muted-foreground line-clamp-2 text-xs">
{t(achievement.descriptionKey)}
</p>
</div>
</div>
<Badge
variant={unlocked ? "default" : "secondary"}
className="shrink-0"
>
{unlocked ? (
t("achievements.completed")
) : (
<>
<Lock className="mr-1 h-3 w-3" aria-hidden="true" />
{t("achievements.locked")}
</>
)}
</Badge>
</div>
{!unlocked && (
<div className="mt-2 space-y-1.5">
<p className="text-muted-foreground text-xs">
{t("achievements.criterionHint", {
current: formatMetric(
achievement.format,
achievement.current,
t,
),
target: formatMetric(achievement.format, achievement.target, t),
})}
</p>
<Progress value={achievement.progressPercent} className="h-1.5" />
</div>
)}
<div className="text-muted-foreground mt-3 flex items-center justify-between text-xs">
<span className="font-medium">
{t("achievements.pointsValue", {
points: achievement.points,
})}
</span>
{unlocked ? (
<span>
{achievement.completedAt
? t("achievements.completedOn", {
date: formatDate(achievement.completedAt),
})
: t("achievements.goalReached")}
</span>
) : (
<span>
{t("achievements.progressPercent", {
percent: achievement.progressPercent,
})}
</span>
)}
</div>
</div>
);
}
export default function AchievementsPage() {
const { isAuthenticated, isLoading: authLoading } = useAuth();
const { t } = useTranslations();
@@ -103,7 +243,7 @@ export default function AchievementsPage() {
const summary = data?.summary;
const achievements = data?.achievements ?? [];
const unlockedAchievements = achievements.filter((item) => item.unlocked);
const grouped = groupByCategory(achievements);
const remainingUnlocks = Math.max(
0,
(summary?.totalCount ?? 0) - (summary?.unlockedCount ?? 0),
@@ -199,59 +339,49 @@ export default function AchievementsPage() {
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{unlockedAchievements.map((achievement) => {
const Icon = iconMap[achievement.icon] ?? Star;
return (
<div
key={achievement.id}
className="border-primary/30 from-primary/8 to-primary/0 rounded-xl border bg-gradient-to-br p-3"
>
<div className="mb-2 flex items-start justify-between gap-2">
<div className="flex items-center gap-3">
<div className="bg-primary/15 text-primary rounded-lg p-2">
<Icon className="h-3.5 w-3.5" />
</div>
<div className="min-w-0">
<h2 className="text-sm font-semibold">
{t(achievement.titleKey)}
</h2>
<p className="text-muted-foreground line-clamp-2 text-xs">
{t(achievement.descriptionKey)}
</p>
</div>
</div>
<Badge variant="default" className="shrink-0">
{t("achievements.completed")}
</Badge>
</div>
<div className="text-muted-foreground mt-3 flex items-center justify-between text-xs">
<span className="font-medium">
{t("achievements.pointsValue", {
points: achievement.points,
})}
</span>
<span>
{achievement.completedAt
? t("achievements.completedOn", {
date: formatDate(achievement.completedAt),
})
: t("achievements.goalReached")}
</span>
</div>
</div>
);
})}
</div>
{unlockedAchievements.length === 0 && (
{grouped.length === 0 ? (
<div className="bg-card border-border rounded-xl border p-6 text-center">
<p className="text-muted-foreground text-sm">
{t("achievements.noneUnlockedYet")}
</p>
</div>
) : (
<div className="space-y-6">
{grouped.map(({ category, items }) => {
const unlockedInCategory = items.filter(
(item) => item.unlocked,
).length;
return (
<section
key={category}
aria-labelledby={`achievements-category-${category}`}
data-slot="achievements-category"
data-category={category}
>
<div className="mb-3 flex items-baseline justify-between gap-3">
<h2
id={`achievements-category-${category}`}
className="text-base font-semibold tracking-tight"
>
{t(CATEGORY_LABEL_KEY[category])}
</h2>
<span className="text-muted-foreground text-xs tabular-nums">
{unlockedInCategory} / {items.length}
</span>
</div>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{items.map((achievement) => (
<AchievementCard
key={achievement.id}
achievement={achievement}
t={t}
/>
))}
</div>
</section>
);
})}
</div>
)}
</div>
);
+56
View File
@@ -17,6 +17,19 @@ export type AchievementMetricKey =
| "loginDayStreak"
| "bugReportCount";
/**
* Achievement categories — used by the /achievements UI to visually group
* the badges. Pure presentation: the computation logic in this file does
* not branch on category, and the metric → category mapping is derived in
* `getAchievementCategory` below so a metric is always in exactly one
* group.
*/
export type AchievementCategory =
| "medication"
| "vitals"
| "security"
| "engagement";
export interface AchievementDefinition {
id: string;
metric: AchievementMetricKey;
@@ -28,6 +41,47 @@ export interface AchievementDefinition {
format: "count" | "days" | "percent";
}
/**
* Stable metric → category mapping. Add a metric here when it joins the
* `AchievementMetricKey` union; the page falls back to "engagement" if
* a future definition slips through, so there is no silent drop-off.
*/
export function getAchievementCategory(
metric: AchievementMetricKey,
): AchievementCategory {
switch (metric) {
case "totalTakenIntakes":
case "overIntakeCount":
case "skippedIntakeCount":
case "onTimePerfectDayStreak":
case "compliance80DayStreak":
return "medication";
case "bmiGreenStreak":
case "bpGreenStreak":
case "pulseGreenStreak":
return "vitals";
case "passkeyCreatedCount":
case "passkeyLoginCount":
case "passwordLoginCount":
return "security";
case "loginDayStreak":
case "bugReportCount":
return "engagement";
}
}
/**
* Stable category render order on the /achievements page. Medication is
* the densest category (16 / 38 today) so it goes first; engagement is
* the smallest, last.
*/
export const ACHIEVEMENT_CATEGORY_ORDER: readonly AchievementCategory[] = [
"medication",
"vitals",
"security",
"engagement",
] as const;
export interface AchievementMetrics {
totalTakenIntakes: number;
overIntakeCount: number;
@@ -47,6 +101,7 @@ export interface AchievementMetrics {
export interface AchievementProgress {
id: string;
metric: AchievementMetricKey;
category: AchievementCategory;
titleKey: string;
descriptionKey: string;
icon: string;
@@ -335,6 +390,7 @@ export function evaluateAchievementsWithCompletionDates(
return {
id: definition.id,
metric: definition.metric,
category: getAchievementCategory(definition.metric),
titleKey: definition.tTitle,
descriptionKey: definition.tDescription,
icon: definition.icon,