diff --git a/e2e/achievements.spec.ts b/e2e/achievements.spec.ts index 507869f11..2f6713548 100644 --- a/e2e/achievements.spec.ts +++ b/e2e/achievements.spec.ts @@ -11,6 +11,11 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup"; * `/api/gamification/achievements` so the assertion does not depend on * the seed user actually having any achievement data — the routing + * sidebar wiring is what matters here, not the data freshness. + * + * v1.4.18 — added a second describe-block exercising the four card + * states (unlocked / locked-earnable / hidden-locked / hidden-unlocked) + * and asserting that hidden cards never leak their real strings to the + * DOM. */ test.describe("achievements page is reachable from the sidebar", () => { test.use({ storageState: STORAGE_STATE_PATH }); @@ -84,3 +89,140 @@ test.describe("achievements page is reachable from the sidebar", () => { ).toBeVisible({ timeout: 5000 }); }); }); + +test.describe("achievements page renders all four card states", () => { + test.use({ storageState: STORAGE_STATE_PATH }); + + test.beforeEach(async ({ page }) => { + await page.route("**/api/gamification/achievements", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: { + summary: { + unlockedCount: 2, + totalCount: 4, + earnedPoints: 43, + totalPoints: 200, + completionPercent: 50, + nextAchievement: null, + }, + achievements: [ + // Unlocked public + { + 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: 5, + points: 8, + unlocked: true, + progressPercent: 100, + completedAt: "2026-04-15T12:00:00.000Z", + isHidden: false, + }, + // Locked-earnable public (mood streak — user has mood data) + { + id: "mood-streak-7", + metric: "moodDayStreak", + category: "mood", + titleKey: "achievements.badges.moodStreak7.title", + descriptionKey: + "achievements.badges.moodStreak7.description", + icon: "Smile", + format: "days", + target: 7, + current: 3, + points: 50, + unlocked: false, + progressPercent: 42, + completedAt: null, + isHidden: false, + }, + // Hidden + locked → opaque placeholder + { + id: "hidden-night-owl", + metric: "nightOwlCount", + category: "hidden", + titleKey: "achievements.badges.hiddenNightOwl.title", + descriptionKey: + "achievements.badges.hiddenNightOwl.description", + icon: "Moon", + format: "count", + target: 1, + current: 0, + points: 25, + unlocked: false, + progressPercent: 0, + completedAt: null, + isHidden: true, + }, + // Hidden + unlocked → real strings revealed + { + id: "hidden-doctor-pdf", + metric: "doctorPdfCount", + category: "hidden", + titleKey: "achievements.badges.hiddenDoctorPdf.title", + descriptionKey: + "achievements.badges.hiddenDoctorPdf.description", + icon: "FileText", + format: "count", + target: 1, + current: 1, + points: 35, + unlocked: true, + progressPercent: 100, + completedAt: "2026-04-30T10:00:00.000Z", + isHidden: true, + }, + ], + metrics: {}, + }, + error: null, + }), + }), + ); + }); + + test("renders unlocked, locked-earnable, hidden-locked and hidden-unlocked cards", async ({ + page, + }) => { + await page.goto("/achievements", { waitUntil: "domcontentloaded" }); + + // Page heading present + await expect( + page.getByRole("heading", { name: "Achievements" }), + ).toBeVisible({ timeout: 5000 }); + + // Unlocked public — title visible + await expect(page.getByText("First intake")).toBeVisible(); + + // Locked-earnable public — title visible AND criterion hint + // visible (3 / 7 days for mood-streak-7). + await expect(page.getByText("Mood diarist 7d")).toBeVisible(); + + // Hidden-locked — opaque placeholder visible. The real title / + // description must NOT appear in the rendered HTML. + await expect( + page.getByText("Hidden achievement").first(), + ).toBeVisible(); + const html = await page.content(); + expect(html).not.toContain("Night owl"); + expect(html).not.toContain("between 02:00"); + expect(html).not.toContain("nightOwlCount"); + + // Hidden-unlocked — title revealed once earned + await expect(page.getByText("House call")).toBeVisible(); + + // The opaque hidden card carries the data-slot for E2E hooks + await expect( + page.locator('[data-slot="achievement-card-hidden"]'), + ).toBeVisible(); + }); +}); diff --git a/src/lib/gamification/__tests__/expansion-metrics.test.ts b/src/lib/gamification/__tests__/expansion-metrics.test.ts new file mode 100644 index 000000000..f6b3ff7ab --- /dev/null +++ b/src/lib/gamification/__tests__/expansion-metrics.test.ts @@ -0,0 +1,279 @@ +import { describe, it, expect } from "vitest"; +import { + buildExpansionMetricValues, + countMeasurementsByType, + getEarnabilityFlags, + getEngagementMetrics, + getHiddenMetrics, + getMoodMetrics, +} from "@/lib/gamification/expansion-metrics"; + +describe("countMeasurementsByType", () => { + it("counts each type independently", () => { + const result = countMeasurementsByType([ + { type: "WEIGHT", measuredAt: new Date("2026-04-01T08:00:00Z") }, + { type: "WEIGHT", measuredAt: new Date("2026-04-02T08:00:00Z") }, + { + type: "BLOOD_PRESSURE_SYS", + measuredAt: new Date("2026-04-01T08:00:00Z"), + }, + { + type: "BLOOD_PRESSURE_DIA", + measuredAt: new Date("2026-04-01T08:00:00Z"), + }, + { type: "PULSE", measuredAt: new Date("2026-04-03T08:00:00Z") }, + ]); + // BLOOD_PRESSURE_DIA is intentionally not counted — the canonical + // BP entry is the SYS row so we don't double-count a single BP + // reading that's stored as two rows. + expect(result).toEqual({ weightCount: 2, bpCount: 1, pulseCount: 1 }); + }); + + it("returns zeros for an empty input", () => { + expect(countMeasurementsByType([])).toEqual({ + weightCount: 0, + bpCount: 0, + pulseCount: 0, + }); + }); +}); + +describe("getMoodMetrics", () => { + it("returns zeros when there are no entries", () => { + expect(getMoodMetrics([])).toEqual({ + moodEntryCount: 0, + moodDayStreak: 0, + moodImprovementHit: 0, + }); + }); + + it("computes count + longest day-streak", () => { + const entries = [ + // 4 consecutive days then a gap then 2 more — longest = 4 + { date: "2026-04-01", score: 3, moodLoggedAt: new Date() }, + { date: "2026-04-02", score: 3, moodLoggedAt: new Date() }, + { date: "2026-04-03", score: 4, moodLoggedAt: new Date() }, + { date: "2026-04-04", score: 4, moodLoggedAt: new Date() }, + { date: "2026-04-10", score: 4, moodLoggedAt: new Date() }, + { date: "2026-04-11", score: 4, moodLoggedAt: new Date() }, + ]; + const m = getMoodMetrics(entries); + expect(m.moodEntryCount).toBe(6); + expect(m.moodDayStreak).toBe(4); + }); + + it("hits the improvement metric when the recent 7-day mean is +1.0 above the prior 7-day mean", () => { + const entries = []; + // Prior week: mean 2.0 + for (let i = 0; i < 7; i++) { + entries.push({ + date: `2026-04-0${i + 1}`, + score: 2, + moodLoggedAt: new Date(), + }); + } + // Recent week: mean 3.5 — improvement of 1.5 + for (let i = 0; i < 7; i++) { + const day = i + 8; + entries.push({ + date: `2026-04-${String(day).padStart(2, "0")}`, + score: i % 2 === 0 ? 3 : 4, + moodLoggedAt: new Date(), + }); + } + const m = getMoodMetrics(entries); + expect(m.moodImprovementHit).toBe(1); + }); + + it("does not hit the improvement metric when improvement is below 1.0", () => { + const entries = []; + for (let i = 0; i < 7; i++) { + entries.push({ + date: `2026-04-0${i + 1}`, + score: 3, + moodLoggedAt: new Date(), + }); + } + for (let i = 0; i < 7; i++) { + const day = i + 8; + entries.push({ + date: `2026-04-${String(day).padStart(2, "0")}`, + // Mean 3.5 → +0.5 only + score: i % 2 === 0 ? 3 : 4, + moodLoggedAt: new Date(), + }); + } + expect(getMoodMetrics(entries).moodImprovementHit).toBe(0); + }); +}); + +describe("getEngagementMetrics", () => { + it("counts a consistent month with at least 25 distinct entry days", () => { + const measurements = []; + for (let day = 1; day <= 26; day++) { + measurements.push({ + type: "WEIGHT", + measuredAt: new Date( + `2026-04-${String(day).padStart(2, "0")}T08:00:00Z`, + ), + }); + } + const result = getEngagementMetrics({ + measurements, + moodEntries: [], + intakeEvents: [], + }); + expect(result.consistentMonthCount).toBe(1); + }); + + it("does not count a month with only 24 distinct days as consistent", () => { + const measurements = []; + for (let day = 1; day <= 24; day++) { + measurements.push({ + type: "WEIGHT", + measuredAt: new Date( + `2026-04-${String(day).padStart(2, "0")}T08:00:00Z`, + ), + }); + } + const result = getEngagementMetrics({ + measurements, + moodEntries: [], + intakeEvents: [], + }); + expect(result.consistentMonthCount).toBe(0); + }); + + it("computes the longest entry-day streak across measurement / mood / intake", () => { + const result = getEngagementMetrics({ + measurements: [ + { type: "WEIGHT", measuredAt: new Date("2026-04-01T08:00:00Z") }, + ], + moodEntries: [ + { date: "2026-04-02", score: 3, moodLoggedAt: new Date("2026-04-02T08:00:00Z") }, + { date: "2026-04-03", score: 3, moodLoggedAt: new Date("2026-04-03T08:00:00Z") }, + ], + intakeEvents: [ + { + scheduledFor: new Date("2026-04-04T08:00:00Z"), + takenAt: new Date("2026-04-04T08:30:00Z"), + skipped: false, + }, + ], + }); + // 4 consecutive days + expect(result.entryDayStreak).toBe(4); + }); + + it("counts consecutive Sat+Sun pairs for the weekend warrior badge", () => { + // Saturdays + Sundays April 4/5, 11/12, 18/19, 25/26 — 4 in a row + const measurements = [ + { type: "WEIGHT", measuredAt: new Date("2026-04-04T12:00:00Z") }, + { type: "WEIGHT", measuredAt: new Date("2026-04-05T12:00:00Z") }, + { type: "WEIGHT", measuredAt: new Date("2026-04-11T12:00:00Z") }, + { type: "WEIGHT", measuredAt: new Date("2026-04-12T12:00:00Z") }, + { type: "WEIGHT", measuredAt: new Date("2026-04-18T12:00:00Z") }, + { type: "WEIGHT", measuredAt: new Date("2026-04-19T12:00:00Z") }, + { type: "WEIGHT", measuredAt: new Date("2026-04-25T12:00:00Z") }, + { type: "WEIGHT", measuredAt: new Date("2026-04-26T12:00:00Z") }, + ]; + const result = getEngagementMetrics({ + measurements, + moodEntries: [], + intakeEvents: [], + }); + expect(result.weekendStreakCount).toBeGreaterThanOrEqual(4); + }); +}); + +describe("getHiddenMetrics", () => { + it("counts night-owl entries between 02:00 and 04:00 Berlin", () => { + const result = getHiddenMetrics({ + // 02:30 Berlin in summer = 00:30 UTC. Use UTC offsets that yield + // the right Berlin hour. April is CEST (UTC+2), so 00:30Z = 02:30 + // Berlin → night owl. + measurements: [ + { type: "WEIGHT", measuredAt: new Date("2026-04-15T00:30:00Z") }, + ], + moodEntries: [], + intakeEvents: [], + auditEvents: [], + }); + expect(result.nightOwlCount).toBe(1); + expect(result.earlyBirdCount).toBe(0); + }); + + it("counts early-bird entries between 04:00 and 06:00 Berlin", () => { + // 03:30 UTC = 05:30 Berlin in CEST → early-bird + const result = getHiddenMetrics({ + measurements: [ + { type: "WEIGHT", measuredAt: new Date("2026-04-15T03:30:00Z") }, + ], + moodEntries: [], + intakeEvents: [], + auditEvents: [], + }); + expect(result.earlyBirdCount).toBe(1); + expect(result.nightOwlCount).toBe(0); + }); + + it("counts February 29 entries on a leap year", () => { + const result = getHiddenMetrics({ + measurements: [ + { type: "WEIGHT", measuredAt: new Date("2024-02-29T12:00:00Z") }, + ], + moodEntries: [], + intakeEvents: [], + auditEvents: [], + }); + expect(result.leapDayCount).toBe(1); + }); + + it("counts doctor-PDF exports and locale-flips from audit events", () => { + const result = getHiddenMetrics({ + measurements: [], + moodEntries: [], + intakeEvents: [], + auditEvents: [ + { action: "doctor-report.export", createdAt: new Date() }, + { action: "doctor-report.export", createdAt: new Date() }, + { action: "settings.locale.update", createdAt: new Date() }, + { action: "auth.login.passkey", createdAt: new Date() }, // ignored + ], + }); + expect(result.doctorPdfCount).toBe(2); + expect(result.localeFlipCount).toBe(1); + }); +}); + +describe("getEarnabilityFlags", () => { + it("flags categories the user has data for", () => { + expect( + getEarnabilityFlags({ + hasMedication: true, + moodEntryCount: 3, + measurementCounts: { weightCount: 0, bpCount: 5, pulseCount: 0 }, + }), + ).toEqual({ + hasMedication: true, + hasMood: true, + hasWeight: false, + hasBp: true, + hasPulse: false, + }); + }); +}); + +describe("buildExpansionMetricValues — integration smoke", () => { + it("produces a fully-zeroed object for an empty user", () => { + const result = buildExpansionMetricValues({ + measurements: [], + moodEntries: [], + intakeEvents: [], + auditEvents: [], + }); + for (const value of Object.values(result)) { + expect(value).toBe(0); + } + }); +}); diff --git a/tests/integration/achievements-expansion.test.ts b/tests/integration/achievements-expansion.test.ts new file mode 100644 index 000000000..3c15aec35 --- /dev/null +++ b/tests/integration/achievements-expansion.test.ts @@ -0,0 +1,180 @@ +/** + * Integration test for the v1.4.18 achievement expansion. + * + * Drives a fresh Postgres user through the moments when expansion + * predicates fire, verifying: + * - mood entries persisted as `MoodEntry` rows are picked up by + * `getMoodMetrics` (the row shape uses YYYY-MM-DD strings, not + * Date objects, which is the most common drift surface) + * - measurement counts are split by `MeasurementType` correctly + * - earnability flags reflect what the user actually has + * + * We don't drive the full HTTP route here because the existing + * `medication_schedules.days_of_week` migration drift makes the route + * fail in the integration container; that's a pre-existing issue + * tracked separately. Instead we round-trip the prisma rows through + * `buildExpansionMetricValues` and `getEarnabilityFlags`, which is + * the same code-path the route exercises after the fetch. + */ +import { beforeEach, describe, expect, it } from "vitest"; + +import { + buildExpansionMetricValues, + getEarnabilityFlags, +} from "@/lib/gamification/expansion-metrics"; + +import { getPrismaClient, truncateAllTables } from "./setup"; + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); +}); + +describe("achievements expansion — fresh user, real prisma rows", () => { + it("recognises a single mood entry as `hasMood: true`", async () => { + const prisma = getPrismaClient(); + const user = await prisma.user.create({ + data: { + username: "mood-tracker", + email: "mood@example.test", + createdAt: new Date("2026-01-01T00:00:00Z"), + }, + }); + await prisma.moodEntry.create({ + data: { + userId: user.id, + date: "2026-04-15", + mood: "GUT", + score: 4, + moodLoggedAt: new Date("2026-04-15T08:00:00Z"), + }, + }); + + const moodEntries = await prisma.moodEntry.findMany({ + where: { userId: user.id }, + select: { date: true, score: true, moodLoggedAt: true }, + }); + + const result = buildExpansionMetricValues({ + measurements: [], + moodEntries, + intakeEvents: [], + auditEvents: [], + }); + + expect(result.moodEntryCount).toBe(1); + + const flags = getEarnabilityFlags({ + hasMedication: false, + moodEntryCount: result.moodEntryCount, + measurementCounts: { + weightCount: result.weightMeasurementCount, + bpCount: result.bpMeasurementCount, + pulseCount: result.pulseMeasurementCount, + }, + }); + expect(flags.hasMood).toBe(true); + expect(flags.hasWeight).toBe(false); + }); + + it("counts measurements by type from real DB rows", async () => { + const prisma = getPrismaClient(); + const user = await prisma.user.create({ + data: { + username: "many-readings", + email: "readings@example.test", + createdAt: new Date("2026-01-01T00:00:00Z"), + }, + }); + await prisma.measurement.createMany({ + data: [ + { + userId: user.id, + type: "WEIGHT", + value: 80, + unit: "kg", + measuredAt: new Date("2026-04-01T08:00:00Z"), + }, + { + userId: user.id, + type: "WEIGHT", + value: 80.5, + unit: "kg", + measuredAt: new Date("2026-04-02T08:00:00Z"), + }, + { + userId: user.id, + type: "BLOOD_PRESSURE_SYS", + value: 120, + unit: "mmHg", + measuredAt: new Date("2026-04-01T08:00:00Z"), + }, + { + userId: user.id, + type: "BLOOD_PRESSURE_DIA", + value: 80, + unit: "mmHg", + measuredAt: new Date("2026-04-01T08:00:00Z"), + }, + { + userId: user.id, + type: "PULSE", + value: 65, + unit: "bpm", + measuredAt: new Date("2026-04-03T08:00:00Z"), + }, + ], + }); + + const measurements = await prisma.measurement.findMany({ + where: { + userId: user.id, + type: { + in: ["WEIGHT", "BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA", "PULSE"], + }, + }, + select: { type: true, measuredAt: true }, + }); + + const result = buildExpansionMetricValues({ + measurements, + moodEntries: [], + intakeEvents: [], + auditEvents: [], + }); + + expect(result.weightMeasurementCount).toBe(2); + expect(result.bpMeasurementCount).toBe(1); // SYS canonical, DIA not double-counted + expect(result.pulseMeasurementCount).toBe(1); + }); + + it("fires the doctor-PDF hidden trigger from a real audit-log row", async () => { + const prisma = getPrismaClient(); + const user = await prisma.user.create({ + data: { + username: "exporter", + email: "exporter@example.test", + createdAt: new Date("2026-01-01T00:00:00Z"), + }, + }); + await prisma.auditLog.create({ + data: { + userId: user.id, + action: "doctor-report.export", + }, + }); + + const auditEvents = await prisma.auditLog.findMany({ + where: { userId: user.id, action: "doctor-report.export" }, + select: { action: true, createdAt: true }, + }); + + const result = buildExpansionMetricValues({ + measurements: [], + moodEntries: [], + intakeEvents: [], + auditEvents, + }); + + expect(result.doctorPdfCount).toBe(1); + }); +});