mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user