mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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.
This commit is contained in:
1 parent
d0e2238b65
commit
605d9b1fb4
12 files changed
+35
-237
No files matched your search
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -8047,14 +8047,6 @@
|
||||
"title": "복용 전설",
|
||||
"description": "복용 기록 300회를 남겨요."
|
||||
},
|
||||
"overIntake1": {
|
||||
"title": "한 번 더",
|
||||
"description": "계획보다 한 번 이상 더 복용해요."
|
||||
},
|
||||
"skippedIntake1": {
|
||||
"title": "한 걸음 물러서기",
|
||||
"description": "계획된 복용을 한 번 이상 건너뛰어요."
|
||||
},
|
||||
"passkeyCreated1": {
|
||||
"title": "패스키 등록",
|
||||
"description": "첫 패스키를 만들어요."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -132,8 +132,6 @@ const mockData = {
|
||||
] satisfies AchievementProgress[],
|
||||
metrics: {
|
||||
totalTakenIntakes: 1,
|
||||
overIntakeCount: 0,
|
||||
skippedIntakeCount: 0,
|
||||
bmiGreenStreak: 0,
|
||||
bpGreenStreak: 0,
|
||||
pulseGreenStreak: 0,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -370,115 +370,6 @@ function getOnTimePerfectDaySeries(
|
||||
return toDaySeries(perfectDays);
|
||||
}
|
||||
|
||||
function parseDaysOfWeek(daysOfWeek: string | null): Set<number> | 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<string, MedicationScheduleRecord[]>,
|
||||
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<string, IntakeEventRecord[]>
|
||||
>();
|
||||
|
||||
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,
|
||||
|
||||
@@ -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":
|
||||
|
||||
Reference in new issue
Block a user