diff --git a/messages/de.json b/messages/de.json index ee6731cd2..4f5a17b6b 100644 --- a/messages/de.json +++ b/messages/de.json @@ -8047,14 +8047,6 @@ "title": "Einnahme-Legende", "description": "Dokumentiere 300 eingenommene Medikamente." }, - "overIntake1": { - "title": "Doppelt genommen", - "description": "Nimm mindestens einmal mehr ein als vorgesehen." - }, - "skippedIntake1": { - "title": "Pause eingelegt", - "description": "Überspringe mindestens eine geplante Einnahme." - }, "passkeyCreated1": { "title": "Passkey gesetzt", "description": "Erstelle deinen ersten Passkey." diff --git a/messages/en.json b/messages/en.json index 06a2e9b36..6603bcbc7 100644 --- a/messages/en.json +++ b/messages/en.json @@ -8047,14 +8047,6 @@ "title": "Intake legend", "description": "Record 300 taken medication events." }, - "overIntake1": { - "title": "Double take", - "description": "Take medication at least once more than planned." - }, - "skippedIntake1": { - "title": "Stepped back", - "description": "Skip at least one planned intake." - }, "passkeyCreated1": { "title": "Passkey set up", "description": "Create your first passkey." diff --git a/messages/es.json b/messages/es.json index ddf55c374..0c3c15219 100644 --- a/messages/es.json +++ b/messages/es.json @@ -8047,14 +8047,6 @@ "title": "Leyenda de las tomas", "description": "Registra 300 tomas de medicación." }, - "overIntake1": { - "title": "Dosis doble", - "description": "Toma la medicación al menos una vez más de lo previsto." - }, - "skippedIntake1": { - "title": "Un paso atrás", - "description": "Omite al menos una toma planificada." - }, "passkeyCreated1": { "title": "Clave de acceso creada", "description": "Crea tu primera clave de acceso." diff --git a/messages/fr.json b/messages/fr.json index c1ff33246..eeeeb1e25 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -8047,14 +8047,6 @@ "title": "Légende des prises", "description": "Enregistrez 300 prises de médicaments." }, - "overIntake1": { - "title": "Double dose", - "description": "Prenez votre médicament au moins une fois de plus que prévu." - }, - "skippedIntake1": { - "title": "Pause prise", - "description": "Sautez au moins une prise prévue." - }, "passkeyCreated1": { "title": "Clé d’accès configurée", "description": "Créez votre première clé d’accès." diff --git a/messages/it.json b/messages/it.json index d6cd9c4f4..162c4ed1f 100644 --- a/messages/it.json +++ b/messages/it.json @@ -8047,14 +8047,6 @@ "title": "Leggenda delle assunzioni", "description": "Registra 300 assunzioni di farmaci." }, - "overIntake1": { - "title": "Doppia dose", - "description": "Assumi il farmaco almeno una volta in più del previsto." - }, - "skippedIntake1": { - "title": "Pausa presa", - "description": "Salta almeno un’assunzione pianificata." - }, "passkeyCreated1": { "title": "Passkey configurata", "description": "Crea la tua prima passkey." diff --git a/messages/ko.json b/messages/ko.json index 56aec5aa9..8aa72d743 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -8047,14 +8047,6 @@ "title": "복용 전설", "description": "복용 기록 300회를 남겨요." }, - "overIntake1": { - "title": "한 번 더", - "description": "계획보다 한 번 이상 더 복용해요." - }, - "skippedIntake1": { - "title": "한 걸음 물러서기", - "description": "계획된 복용을 한 번 이상 건너뛰어요." - }, "passkeyCreated1": { "title": "패스키 등록", "description": "첫 패스키를 만들어요." diff --git a/messages/pl.json b/messages/pl.json index f0a04ad32..6f50fcf3a 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -8047,14 +8047,6 @@ "title": "Legenda przyjęć", "description": "Zarejestruj 300 przyjęć leków." }, - "overIntake1": { - "title": "Podwójna dawka", - "description": "Przyjmij lek co najmniej raz więcej niż zaplanowano." - }, - "skippedIntake1": { - "title": "Krok wstecz", - "description": "Pomiń co najmniej jedno zaplanowane przyjęcie." - }, "passkeyCreated1": { "title": "Klucz dostępu ustawiony", "description": "Utwórz swój pierwszy klucz dostępu." diff --git a/src/app/achievements/__tests__/page.test.tsx b/src/app/achievements/__tests__/page.test.tsx index e29878045..5f11c7a5b 100644 --- a/src/app/achievements/__tests__/page.test.tsx +++ b/src/app/achievements/__tests__/page.test.tsx @@ -132,8 +132,6 @@ const mockData = { ] satisfies AchievementProgress[], metrics: { totalTakenIntakes: 1, - overIntakeCount: 0, - skippedIntakeCount: 0, bmiGreenStreak: 0, bpGreenStreak: 0, pulseGreenStreak: 0, diff --git a/src/lib/__tests__/achievements-no-insults.test.ts b/src/lib/__tests__/achievements-no-insults.test.ts index 9c6e30423..38c8e4419 100644 --- a/src/lib/__tests__/achievements-no-insults.test.ts +++ b/src/lib/__tests__/achievements-no-insults.test.ts @@ -41,17 +41,14 @@ describe("achievement titles", () => { expect(offending).toEqual([]); }); + // The two badges those titles belonged to rewarded a doubled and a + // skipped dose. They are gone; their keys must not drift back in. test.each([ ["en", enMessages as unknown as MessagesShape], ["de", deMessages as unknown as MessagesShape], - ] as const)( - "%s overIntake1/skippedIntake1 are translated, not the literal English shipped before", - (_locale, bundle) => { - const badges = bundle.achievements?.badges ?? {}; - expect(badges.overIntake1?.title).toBeDefined(); - expect(badges.skippedIntake1?.title).toBeDefined(); - expect(badges.overIntake1?.title).not.toBe("Idiot"); - expect(badges.skippedIntake1?.title).not.toBe("Lazy Boy"); - }, - ); + ] as const)("%s ships no dosing badge copy", (_locale, bundle) => { + const badges = bundle.achievements?.badges ?? {}; + expect(badges).not.toHaveProperty("overIntake1"); + expect(badges).not.toHaveProperty("skippedIntake1"); + }); }); diff --git a/src/lib/gamification/__tests__/achievements.test.ts b/src/lib/gamification/__tests__/achievements.test.ts index 215f22bac..b42dcfdd0 100644 --- a/src/lib/gamification/__tests__/achievements.test.ts +++ b/src/lib/gamification/__tests__/achievements.test.ts @@ -13,8 +13,6 @@ import { const FULL_METRICS: AchievementMetrics = { totalTakenIntakes: 1000, - overIntakeCount: 1000, - skippedIntakeCount: 1000, bmiGreenStreak: 1000, bpGreenStreak: 1000, pulseGreenStreak: 1000, @@ -79,8 +77,8 @@ describe("gamification achievements", () => { ).toBe(3); }); - it("ships the expanded definition list (63 total — v1.4.18 + v1.16.1 care routine)", () => { - expect(ACHIEVEMENT_DEFINITIONS).toHaveLength(63); + it("ships the expanded definition list (61 total — v1.4.18 + v1.16.1 care routine, minus the two dosing badges)", () => { + expect(ACHIEVEMENT_DEFINITIONS).toHaveLength(61); }); it("includes mood and hidden categories in the render order", () => { @@ -91,8 +89,8 @@ describe("gamification achievements", () => { it("evaluates unlocked achievements and points when all metrics maxed", () => { const result = evaluateAchievementsWithCompletionDates(FULL_METRICS, {}); - expect(result.summary.unlockedCount).toBe(63); - expect(result.summary.totalCount).toBe(63); + expect(result.summary.unlockedCount).toBe(61); + expect(result.summary.totalCount).toBe(61); expect(result.summary.nextAchievement).toBeNull(); expect(result.summary.earnedPoints).toBe(result.summary.totalPoints); }); @@ -227,3 +225,27 @@ describe("bridgeFrozenStreakGaps (Rest Mode streak-freeze)", () => { expect(calculateLongestStreak(bridged)).toBe(1); }); }); + +describe("dosing is never rewarded", () => { + // A badge for a dose taken twice or a dose skipped rewards over- or + // under-dosing. Neither behaviour is an achievement. + it("ships no badge for a doubled or a skipped dose", () => { + const ids = ACHIEVEMENT_DEFINITIONS.map((d) => d.id); + expect(ids).not.toContain("over-intake-1"); + expect(ids).not.toContain("skipped-intake-1"); + const metrics = ACHIEVEMENT_DEFINITIONS.map((d) => String(d.metric)); + expect(metrics).not.toContain("overIntakeCount"); + expect(metrics).not.toContain("skippedIntakeCount"); + }); + + it("ignores an unlock row persisted for a retired badge", () => { + const result = evaluateAchievementsWithCompletionDates(ZERO_METRICS, { + "over-intake-1": new Date("2026-03-01T08:00:00Z"), + "skipped-intake-1": new Date("2026-03-02T08:00:00Z"), + }); + const ids = result.achievements.map((a) => a.id); + expect(ids).not.toContain("over-intake-1"); + expect(ids).not.toContain("skipped-intake-1"); + expect(result.summary.unlockedCount).toBe(0); + }); +}); diff --git a/src/lib/gamification/achievements-result.ts b/src/lib/gamification/achievements-result.ts index be9578b71..1685916c1 100644 --- a/src/lib/gamification/achievements-result.ts +++ b/src/lib/gamification/achievements-result.ts @@ -370,115 +370,6 @@ function getOnTimePerfectDaySeries( return toDaySeries(perfectDays); } -function parseDaysOfWeek(daysOfWeek: string | null): Set | null { - if (!daysOfWeek) return null; - - const values = daysOfWeek - .split(",") - .map((value) => Number(value.trim())) - .filter((value) => Number.isInteger(value) && value >= 0 && value <= 6); - - return values.length > 0 ? new Set(values) : null; -} - -function getExpectedIntakesForDay( - schedules: MedicationScheduleRecord[], - dayKey: string, -): number { - if (schedules.length === 0) return 0; - if (!/^\d{4}-\d{2}-\d{2}$/.test(dayKey)) return 0; - - const day = dayKeyToDate(dayKey).getUTCDay(); - let expected = 0; - - for (const schedule of schedules) { - const allowedDays = parseDaysOfWeek(schedule.daysOfWeek); - if (!allowedDays || allowedDays.has(day)) { - expected += 1; - } - } - - return expected; -} - -function getIntakeIssueMetrics( - intakeEvents: IntakeEventRecord[], - schedulesByMedicationId: Map, - tz: string, -) { - const skippedIntakeDates = intakeEvents - .filter((event) => event.skipped) - .map((event) => event.scheduledFor) - .sort((a, b) => a.getTime() - b.getTime()); - - const eventsByMedicationDay = new Map< - string, - Map - >(); - - for (const event of intakeEvents) { - if (!event.medicationId || Number.isNaN(event.scheduledFor.getTime())) { - continue; - } - - const dayKey = userDayKey(event.scheduledFor, tz); - const byDay = eventsByMedicationDay.get(event.medicationId) ?? new Map(); - const list = byDay.get(dayKey) ?? []; - list.push(event); - byDay.set(dayKey, list); - eventsByMedicationDay.set(event.medicationId, byDay); - } - - let overIntakeCount = 0; - const overIntakeDates: Date[] = []; - - for (const [medicationId, dayMap] of eventsByMedicationDay.entries()) { - const schedules = schedulesByMedicationId.get(medicationId) ?? []; - - for (const [dayKey, events] of dayMap.entries()) { - const expectedCount = getExpectedIntakesForDay(schedules, dayKey); - - if (expectedCount <= 0) { - continue; - } - - const takenEvents = events - .filter((event) => !event.skipped && event.takenAt !== null) - .sort((a, b) => { - const aTime = (a.takenAt ?? a.scheduledFor).getTime(); - const bTime = (b.takenAt ?? b.scheduledFor).getTime(); - return aTime - bTime; - }); - - const excessCount = Math.max(0, takenEvents.length - expectedCount); - if (excessCount === 0) { - continue; - } - - overIntakeCount += excessCount; - - for ( - let index = takenEvents.length - excessCount; - index < takenEvents.length; - index++ - ) { - const event = takenEvents[index]; - if (!event) continue; - overIntakeDates.push(event.takenAt ?? event.scheduledFor); - } - } - } - - overIntakeDates.sort((a, b) => a.getTime() - b.getTime()); - - return { - skippedIntakeCount: skippedIntakeDates.length, - skippedIntakeDates, - overIntakeCount, - overIntakeDates, - }; -} - function getCompliance80DaySeries( intakeEvents: IntakeEventRecord[], startDate: Date, @@ -748,12 +639,6 @@ export async function buildAchievementsResult( const schedulesByMedicationId = new Map( medications.map((med) => [med.id, med.schedules]), ); - const intakeIssueMetrics = getIntakeIssueMetrics( - intakeEvents, - schedulesByMedicationId, - tz, - ); - const takenIntakeDates = intakeEvents .filter((event) => !event.skipped && event.takenAt !== null) .map((event) => event.takenAt as Date); @@ -850,8 +735,6 @@ export async function buildAchievementsResult( const metrics = { totalTakenIntakes: takenIntakeDates.length, - overIntakeCount: intakeIssueMetrics.overIntakeCount, - skippedIntakeCount: intakeIssueMetrics.skippedIntakeCount, // v1.18.1 P4 — every day-streak runs through `freeze(...)` so an active // or past illness episode bridges (does not break) the run across the // days the user was unwell. @@ -917,24 +800,6 @@ export async function buildAchievementsResult( continue; } - if (definition.metric === "overIntakeCount") { - const completedAt = findCountCompletionDate( - intakeIssueMetrics.overIntakeDates, - definition.target, - ); - if (completedAt) completionDates[definition.id] = completedAt; - continue; - } - - if (definition.metric === "skippedIntakeCount") { - const completedAt = findCountCompletionDate( - intakeIssueMetrics.skippedIntakeDates, - definition.target, - ); - if (completedAt) completionDates[definition.id] = completedAt; - continue; - } - if (definition.metric === "loginDayStreak") { const completedAt = findStreakCompletionDate( loginDaySeries.dayKeys, diff --git a/src/lib/gamification/achievements.ts b/src/lib/gamification/achievements.ts index 1e2862333..a526fa146 100644 --- a/src/lib/gamification/achievements.ts +++ b/src/lib/gamification/achievements.ts @@ -2,8 +2,6 @@ export const GAMIFICATION_ROLLOUT_AT = new Date("2026-02-20T00:00:00.000Z"); export type AchievementMetricKey = | "totalTakenIntakes" - | "overIntakeCount" - | "skippedIntakeCount" | "bmiGreenStreak" | "bpGreenStreak" | "pulseGreenStreak" @@ -117,8 +115,6 @@ export interface EarnabilityFlags { function categoryForMetric(metric: AchievementMetricKey): AchievementCategory { switch (metric) { case "totalTakenIntakes": - case "overIntakeCount": - case "skippedIntakeCount": case "onTimePerfectDayStreak": case "compliance80DayStreak": case "missFreeDayStreak": @@ -172,8 +168,6 @@ export const ACHIEVEMENT_CATEGORY_ORDER: readonly AchievementCategory[] = [ export interface AchievementMetrics { totalTakenIntakes: number; - overIntakeCount: number; - skippedIntakeCount: number; bmiGreenStreak: number; bpGreenStreak: number; pulseGreenStreak: number; @@ -319,26 +313,6 @@ export const ACHIEVEMENT_DEFINITIONS: AchievementDefinition[] = [ tDescription: "achievements.badges.intakeTotal300.description", format: "count", }), - define({ - id: "over-intake-1", - metric: "overIntakeCount", - target: 1, - points: 0, - icon: "AlertTriangle", - tTitle: "achievements.badges.overIntake1.title", - tDescription: "achievements.badges.overIntake1.description", - format: "count", - }), - define({ - id: "skipped-intake-1", - metric: "skippedIntakeCount", - target: 1, - points: 0, - icon: "SkipForward", - tTitle: "achievements.badges.skippedIntake1.title", - tDescription: "achievements.badges.skippedIntake1.description", - format: "count", - }), define({ id: "passkey-created-1", metric: "passkeyCreatedCount", @@ -897,8 +871,6 @@ function isEarnable( ): boolean { switch (metric) { case "totalTakenIntakes": - case "overIntakeCount": - case "skippedIntakeCount": case "onTimePerfectDayStreak": case "compliance80DayStreak": case "missFreeDayStreak":