From 605d9b1fb4349e0072bf16de1664f1a03152b6bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 8 Oct 2026 18:12:13 +0200 Subject: [PATCH] Stop rewarding a doubled or a skipped dose The 'took it twice' and 'skipped a dose' badges rewarded over- and under-dosing. Both definitions, their metrics and their copy in every locale are gone. An unlock row already persisted for either id stays in the database and in backups, but no longer renders: the evaluator only walks the shipped catalogue. --- messages/de.json | 8 -- messages/en.json | 8 -- messages/es.json | 8 -- messages/fr.json | 8 -- messages/it.json | 8 -- messages/ko.json | 8 -- messages/pl.json | 8 -- src/app/achievements/__tests__/page.test.tsx | 2 - .../__tests__/achievements-no-insults.test.ts | 17 +-- .../__tests__/achievements.test.ts | 34 ++++- src/lib/gamification/achievements-result.ts | 135 ------------------ src/lib/gamification/achievements.ts | 28 ---- 12 files changed, 35 insertions(+), 237 deletions(-) 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":