mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
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:
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 & 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 & Sicherheit");
|
||||
expect(html).toContain("Gesperrt"); // German "Locked"
|
||||
});
|
||||
});
|
||||
+183
-53
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user