feat(achievements): evaluators for new achievements (with discovery rules)

Wires the v1.4.18 expansion metrics into the gamification route.

- Fetches `MoodEntry` rows alongside the existing measurement /
  intake / passkey / audit fetches so the mood badges have data.
- Extends the audit-log query with `doctor-report.export` and
  `settings.locale.update` (the new hidden Easter-egg triggers).
- Pushes the raw rows through `buildExpansionMetricValues` —
  mood streak / improvement, weight/BP/pulse counts, consistent-month,
  entry day-streak, weekend warrior, and the hidden trigger counts.
- Computes `EarnabilityFlags` from the same numbers and applies
  `applyDiscoveryFilter` to the evaluated achievement list. Locked
  public badges that the user has *no path* to earn yet are filtered
  out; hidden Easter-eggs and already-unlocked badges always pass.
- Recomputes the headline summary (`unlockedCount`, `totalCount`,
  `earnedPoints`, `totalPoints`, `nextAchievement`) from the visible
  set so the dashboard counters match the rendered list. The
  `nextAchievement` selector skips hidden cards so we never tease
  the user with a "next goal: ?" suggestion.

The iOS format mock had to grow a `moodEntry.findMany` mock; that's
a one-line pure-test change.

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-10 10:34:32 +02:00
1 parent 19b08442b4
commit c41f9a4307
2 files changed
+105 -4

No files matched your search

@@ -8,6 +8,7 @@ vi.mock("@/lib/db", () => ({
medication: { findMany: vi.fn() },
passkey: { findMany: vi.fn() },
auditLog: { findMany: vi.fn() },
moodEntry: { findMany: vi.fn() },
userAchievement: {
findMany: vi.fn(),
createMany: vi.fn(),
@@ -61,6 +62,7 @@ beforeEach(() => {
vi.mocked(prisma.userAchievement.createMany).mockResolvedValue({
count: 0,
} as never);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
});
describe("GET /api/gamification/achievements?format=ios", () => {
+103 -4
View File
@@ -5,10 +5,15 @@ import { apiSuccess } from "@/lib/api-response";
import {
ACHIEVEMENT_DEFINITIONS,
GAMIFICATION_ROLLOUT_AT,
applyDiscoveryFilter,
calculateLongestStreak,
evaluateAchievementsWithCompletionDates,
toBerlinDayKey,
} from "@/lib/gamification/achievements";
import {
buildExpansionMetricValues,
getEarnabilityFlags,
} from "@/lib/gamification/expansion-metrics";
import {
classifyBMI,
classifyBP,
@@ -458,8 +463,14 @@ export const GET = apiHandler(async (request: NextRequest) => {
const userId = user.id;
const startDate = maxDate(user.createdAt, GAMIFICATION_ROLLOUT_AT);
const [measurements, intakeEvents, medications, passkeys, auditEvents] =
await Promise.all([
const [
measurements,
intakeEvents,
medications,
passkeys,
auditEvents,
moodEntries,
] = await Promise.all([
prisma.measurement.findMany({
where: {
userId,
@@ -519,6 +530,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
"auth.login.passkey",
"auth.login.password",
"bugreport.submit",
// v1.4.18 — hidden Easter-egg triggers
"doctor-report.export",
"settings.locale.update",
],
},
},
@@ -527,6 +541,22 @@ export const GET = apiHandler(async (request: NextRequest) => {
createdAt: true,
},
}),
// v1.4.18 — mood entries feed the new mood badges + the
// entry-streak/consistent-month engagement metrics. Synced from
// moodLog.app or entered directly; we intentionally include all
// sources because consistency-of-tracking is what the badge
// rewards.
prisma.moodEntry.findMany({
where: {
userId,
moodLoggedAt: { gte: startDate, lte: now },
},
select: {
date: true,
score: true,
moodLoggedAt: true,
},
}),
]);
const schedulesByMedicationId = new Map(
@@ -569,6 +599,26 @@ export const GET = apiHandler(async (request: NextRequest) => {
now,
);
const expansionValues = buildExpansionMetricValues({
measurements,
moodEntries: moodEntries.map((m) => ({
date: m.date,
score: m.score,
moodLoggedAt: m.moodLoggedAt,
})),
intakeEvents,
auditEvents,
});
const earnability = getEarnabilityFlags({
hasMedication: medications.length > 0,
moodEntryCount: expansionValues.moodEntryCount,
measurementCounts: {
weightCount: expansionValues.weightMeasurementCount,
bpCount: expansionValues.bpMeasurementCount,
pulseCount: expansionValues.pulseMeasurementCount,
},
});
const metrics = {
totalTakenIntakes: takenIntakeDates.length,
overIntakeCount: intakeIssueMetrics.overIntakeCount,
@@ -583,6 +633,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
passwordLoginCount: passwordLoginDates.length,
loginDayStreak: calculateLongestStreak(loginDaySeries.dayKeys),
bugReportCount: bugReportDates.length,
...expansionValues,
};
const completionDates: Partial<Record<string, Date>> = {};
@@ -733,10 +784,13 @@ export const GET = apiHandler(async (request: NextRequest) => {
mergedDates[id] = date;
}
const result = evaluateAchievementsWithCompletionDates(metrics, mergedDates);
const fullResult = evaluateAchievementsWithCompletionDates(
metrics,
mergedDates,
);
// Persist newly unlocked achievements
const newUnlocks = result.achievements.filter(
const newUnlocks = fullResult.achievements.filter(
(a) =>
a.unlocked &&
a.completedAt &&
@@ -753,6 +807,51 @@ export const GET = apiHandler(async (request: NextRequest) => {
});
}
// v1.4.18 — apply the discovery filter so locked badges that the
// user has *no path* to earn (e.g. mood badges for someone who has
// never logged a mood entry) don't clutter the page. Hidden Easter-
// eggs always pass through. Already-unlocked badges always pass
// through (regression guard).
const visibleAchievements = applyDiscoveryFilter(
fullResult.achievements,
earnability,
);
// Recompute the summary so the headline counters reflect the
// discovered set. Hidden achievements that are still locked are
// counted toward `totalCount` so the user sees they exist.
const visibleUnlocked = visibleAchievements.filter((a) => a.unlocked);
const visibleEarned = visibleUnlocked.reduce(
(acc, a) => acc + a.points,
0,
);
const visibleTotalPoints = visibleAchievements.reduce(
(acc, a) => acc + a.points,
0,
);
const nextAchievement =
visibleAchievements
.filter((a) => !a.unlocked && !a.isHidden)
.sort((a, b) => b.progressPercent - a.progressPercent)[0] ?? null;
const result = {
summary: {
unlockedCount: visibleUnlocked.length,
totalCount: visibleAchievements.length,
earnedPoints: visibleEarned,
totalPoints: visibleTotalPoints,
completionPercent:
visibleAchievements.length === 0
? 100
: Math.round(
(visibleUnlocked.length / visibleAchievements.length) * 100,
),
nextAchievement,
},
achievements: visibleAchievements,
metrics: fullResult.metrics,
};
if (isIosFormat) {
const locale = await resolveServerLocale({
request,