fix: dose tracking broken for per-intake takenBy and after medication edits (#100)

- Remove broken isDoseFromPreviousSchedule that falsely dismissed all past doses
  after any medication edit (compared dateOnlyMs < updatedAt incorrectly)
- Fix takenBy normalization in AppContext: event.takenBy (string|null) was passed
  through as-is via || operator instead of being properly converted to string[]
- Fix DashboardPage: 5 locations treated dose.takenBy as single string instead of
  iterating the array, causing per-person dose tracking to silently fail
- Extract isDoseDismissed and computeMissedPastDoseIds as pure testable functions
  from AppContext.tsx into utils/schedule.ts
- Update SharedSchedule.tsx to use shared isDoseDismissed from utils
- Add 22 regression tests covering isDoseDismissed, computeMissedPastDoseIds,
  and full dose-tracking-survives-medication-edit workflows
- Add 'fix bugs, don't test around them' rule to copilot instructions
This commit is contained in:
Daniel Volz
2026-02-06 21:55:21 +01:00
committed by GitHub
parent 869b5774fb
commit 01deea1fa0
6 changed files with 659 additions and 135 deletions
+574
View File
@@ -3,9 +3,11 @@ import type { Coverage, Medication, StockThresholds } from "../../types";
import {
buildSchedulePreview,
calculateCoverage,
computeMissedPastDoseIds,
getNextReminderForMed,
getReminderStatusText,
getStockStatus,
isDoseDismissed,
} from "../../utils/schedule";
describe("buildSchedulePreview", () => {
@@ -151,6 +153,80 @@ describe("buildSchedulePreview", () => {
expect(result.events[i].when).toBeGreaterThanOrEqual(result.events[i - 1].when);
}
});
it("dose IDs remain stable when only intake time changes (regression test for dose tracking)", () => {
// This is a critical regression test: changing the time-of-day for an intake
// must NOT change dose IDs for past days, so that taken-dose tracking survives edits.
const medsWithMorningTime: Medication[] = [
{
id: 1,
name: "TestMed",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 30,
looseTablets: 0,
takenBy: [],
blisters: [{ usage: 1, every: 1, start: "2024-03-10T09:00:00" }],
updatedAt: null,
},
];
const medsWithEveningTime: Medication[] = [
{
id: 1,
name: "TestMed",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 30,
looseTablets: 0,
takenBy: [],
blisters: [{ usage: 1, every: 1, start: "2024-03-10T21:00:00" }],
updatedAt: new Date("2024-03-15T10:00:00Z").toISOString(),
},
];
const resultBefore = buildSchedulePreview(medsWithMorningTime, "en", true);
const resultAfter = buildSchedulePreview(medsWithEveningTime, "en", true);
// Get past dose IDs from both schedules
const pastIdsBefore = resultBefore.events.filter((e) => e.isPast).map((e) => e.id);
const pastIdsAfter = resultAfter.events.filter((e) => e.isPast).map((e) => e.id);
expect(pastIdsBefore.length).toBeGreaterThan(0);
// All past dose IDs must match — changing time must not break dose tracking
expect(pastIdsAfter).toEqual(pastIdsBefore);
});
it("dose IDs use date-only timestamp, not full datetime", () => {
const meds: Medication[] = [
{
id: 1,
name: "TestMed",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 30,
looseTablets: 0,
takenBy: [],
blisters: [{ usage: 1, every: 1, start: "2024-03-14T15:30:00" }],
updatedAt: null,
},
];
const result = buildSchedulePreview(meds, "en", true);
const pastEvents = result.events.filter((e) => e.isPast);
expect(pastEvents.length).toBeGreaterThan(0);
// Each dose ID should use a midnight timestamp (date-only), not the intake time
for (const event of pastEvents) {
const parts = event.id.split("-");
const timestampMs = parseInt(parts[2], 10);
const date = new Date(timestampMs);
expect(date.getHours()).toBe(0);
expect(date.getMinutes()).toBe(0);
expect(date.getSeconds()).toBe(0);
expect(date.getMilliseconds()).toBe(0);
}
});
});
describe("calculateCoverage", () => {
@@ -573,3 +649,501 @@ describe("getReminderStatusText", () => {
expect(result.lines.length).toBeGreaterThan(1);
});
});
// =============================================================================
// isDoseDismissed
// =============================================================================
describe("isDoseDismissed", () => {
it("returns false when dismissedUntilDate is undefined", () => {
expect(isDoseDismissed("1-0-1710028800000", undefined)).toBe(false);
});
it("returns false for dose IDs with fewer than 3 parts", () => {
expect(isDoseDismissed("1-0", "2024-03-15")).toBe(false);
expect(isDoseDismissed("1", "2024-03-15")).toBe(false);
expect(isDoseDismissed("", "2024-03-15")).toBe(false);
});
it("returns false for dose IDs with non-numeric timestamp", () => {
expect(isDoseDismissed("1-0-abc", "2024-03-15")).toBe(false);
});
it("returns true when dose date is before dismissedUntil", () => {
// March 10 midnight UTC = 1710028800000
const march10midnight = new Date("2024-03-10T00:00:00").getTime();
expect(isDoseDismissed(`1-0-${march10midnight}`, "2024-03-14")).toBe(true);
});
it("returns true when dose date equals dismissedUntil", () => {
const march14midnight = new Date("2024-03-14T00:00:00").getTime();
expect(isDoseDismissed(`1-0-${march14midnight}`, "2024-03-14")).toBe(true);
});
it("returns false when dose date is after dismissedUntil", () => {
const march15midnight = new Date("2024-03-15T00:00:00").getTime();
expect(isDoseDismissed(`1-0-${march15midnight}`, "2024-03-14")).toBe(false);
});
it("works with person-suffixed dose IDs", () => {
// Dose ID: medId-intakeIdx-timestamp-person
const march10midnight = new Date("2024-03-10T00:00:00").getTime();
expect(isDoseDismissed(`1-0-${march10midnight}-John`, "2024-03-14")).toBe(true);
expect(isDoseDismissed(`1-0-${march10midnight}-John`, "2024-03-09")).toBe(false);
});
it("handles single-digit months and days correctly", () => {
// January 5 = should produce "2024-01-05"
const jan5 = new Date("2024-01-05T00:00:00").getTime();
expect(isDoseDismissed(`1-0-${jan5}`, "2024-01-05")).toBe(true);
expect(isDoseDismissed(`1-0-${jan5}`, "2024-01-04")).toBe(false);
});
});
// =============================================================================
// computeMissedPastDoseIds
// =============================================================================
describe("computeMissedPastDoseIds", () => {
// Helper: create a past day with dose IDs
function makePastDay(
medName: string,
doses: Array<{ id: string; takenBy?: string[] }>
): { meds: Array<{ medName: string; doses: Array<{ id: string; takenBy: string[] }> }> } {
return {
meds: [
{
medName,
doses: doses.map((d) => ({ id: d.id, takenBy: d.takenBy ?? [] })),
},
],
};
}
it("returns all past dose IDs when none are taken or dismissed", () => {
const march10 = new Date("2024-03-10T00:00:00").getTime();
const march11 = new Date("2024-03-11T00:00:00").getTime();
const pastDays = [makePastDay("Aspirin", [{ id: `1-0-${march10}` }, { id: `1-0-${march11}` }])];
const meds = [{ name: "Aspirin" }];
const result = computeMissedPastDoseIds(pastDays, meds, new Set(), new Set());
expect(result).toEqual([`1-0-${march10}`, `1-0-${march11}`]);
});
it("excludes taken doses", () => {
const march10 = new Date("2024-03-10T00:00:00").getTime();
const march11 = new Date("2024-03-11T00:00:00").getTime();
const pastDays = [makePastDay("Aspirin", [{ id: `1-0-${march10}` }, { id: `1-0-${march11}` }])];
const meds = [{ name: "Aspirin" }];
const taken = new Set([`1-0-${march10}`]);
const result = computeMissedPastDoseIds(pastDays, meds, taken, new Set());
expect(result).toEqual([`1-0-${march11}`]);
});
it("excludes individually dismissed doses", () => {
const march10 = new Date("2024-03-10T00:00:00").getTime();
const march11 = new Date("2024-03-11T00:00:00").getTime();
const pastDays = [makePastDay("Aspirin", [{ id: `1-0-${march10}` }, { id: `1-0-${march11}` }])];
const meds = [{ name: "Aspirin" }];
const dismissed = new Set([`1-0-${march10}`]);
const result = computeMissedPastDoseIds(pastDays, meds, new Set(), dismissed);
expect(result).toEqual([`1-0-${march11}`]);
});
it("excludes doses on or before medication dismissedUntil date", () => {
const march10 = new Date("2024-03-10T00:00:00").getTime();
const march11 = new Date("2024-03-11T00:00:00").getTime();
const march13 = new Date("2024-03-13T00:00:00").getTime();
const march14 = new Date("2024-03-14T00:00:00").getTime();
const pastDays = [
makePastDay("Aspirin", [
{ id: `1-0-${march10}` },
{ id: `1-0-${march11}` },
{ id: `1-0-${march13}` },
{ id: `1-0-${march14}` },
]),
];
// Dismiss all doses up through March 12
const meds = [{ name: "Aspirin", dismissedUntil: "2024-03-12" }];
const result = computeMissedPastDoseIds(pastDays, meds, new Set(), new Set());
// March 10 & 11 are dismissed (≤ March 12), March 13 & 14 remain missed
expect(result).toEqual([`1-0-${march13}`, `1-0-${march14}`]);
});
it("expands takenBy people into separate dose IDs", () => {
const march10 = new Date("2024-03-10T00:00:00").getTime();
const pastDays = [makePastDay("SharedMed", [{ id: `1-0-${march10}`, takenBy: ["Alice", "Bob"] }])];
const meds = [{ name: "SharedMed" }];
const result = computeMissedPastDoseIds(pastDays, meds, new Set(), new Set());
expect(result).toEqual([`1-0-${march10}-Alice`, `1-0-${march10}-Bob`]);
});
it("excludes expanded person dose IDs that are taken", () => {
const march10 = new Date("2024-03-10T00:00:00").getTime();
const pastDays = [makePastDay("SharedMed", [{ id: `1-0-${march10}`, takenBy: ["Alice", "Bob"] }])];
const meds = [{ name: "SharedMed" }];
// Alice took it, Bob didn't
const taken = new Set([`1-0-${march10}-Alice`]);
const result = computeMissedPastDoseIds(pastDays, meds, taken, new Set());
expect(result).toEqual([`1-0-${march10}-Bob`]);
});
it("returns empty array when all doses are taken", () => {
const march10 = new Date("2024-03-10T00:00:00").getTime();
const pastDays = [makePastDay("Aspirin", [{ id: `1-0-${march10}` }])];
const meds = [{ name: "Aspirin" }];
const taken = new Set([`1-0-${march10}`]);
const result = computeMissedPastDoseIds(pastDays, meds, taken, new Set());
expect(result).toEqual([]);
});
it("handles multiple medications independently", () => {
const march10 = new Date("2024-03-10T00:00:00").getTime();
const pastDays = [
{
meds: [
{ medName: "Aspirin", doses: [{ id: `1-0-${march10}`, takenBy: [] as string[] }] },
{ medName: "Vitamin D", doses: [{ id: `2-0-${march10}`, takenBy: [] as string[] }] },
],
},
];
// Aspirin dismissed, Vitamin D not
const meds = [{ name: "Aspirin", dismissedUntil: "2024-03-15" }, { name: "Vitamin D" }];
const result = computeMissedPastDoseIds(pastDays, meds, new Set(), new Set());
// Only Vitamin D's dose remains missed
expect(result).toEqual([`2-0-${march10}`]);
});
});
// =============================================================================
// Dose Tracking Regression Tests
// =============================================================================
describe("dose tracking survives medication edits (regression)", () => {
beforeEach(() => {
vi.setSystemTime(new Date("2024-03-15T12:00:00Z"));
});
afterEach(() => {
vi.useRealTimers();
});
it("taken dose remains taken after changing intake time", () => {
// === BEFORE EDIT: medication with 09:00 morning intake ===
const medBefore: Medication[] = [
{
id: 1,
name: "TestMed",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 30,
looseTablets: 0,
takenBy: [],
blisters: [{ usage: 1, every: 1, start: "2024-03-10T09:00:00" }],
updatedAt: null,
},
];
const resultBefore = buildSchedulePreview(medBefore, "en", true);
const pastBefore = resultBefore.events.filter((e) => e.isPast);
expect(pastBefore.length).toBe(5); // March 10, 11, 12, 13, 14
// User marks March 12 dose as taken
const march12Dose = pastBefore.find((e) => {
const d = new Date(e.when);
return d.getDate() === 12;
})!;
expect(march12Dose).toBeDefined();
const takenDoses = new Set([march12Dose.id]);
// Compute missed doses BEFORE edit
const pastDaysBefore = groupEventsIntoPastDays(pastBefore);
const missedBefore = computeMissedPastDoseIds(pastDaysBefore, medBefore, takenDoses, new Set());
expect(missedBefore).not.toContain(march12Dose.id); // March 12 is taken
expect(missedBefore).toHaveLength(4); // March 10, 11, 13, 14 are missed
// === AFTER EDIT: change intake time to 21:00 evening + updatedAt set ===
const medAfter: Medication[] = [
{
id: 1,
name: "TestMed",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 30,
looseTablets: 0,
takenBy: [],
blisters: [{ usage: 1, every: 1, start: "2024-03-10T21:00:00" }],
updatedAt: new Date("2024-03-15T10:00:00Z").toISOString(),
},
];
const resultAfter = buildSchedulePreview(medAfter, "en", true);
const pastAfter = resultAfter.events.filter((e) => e.isPast);
expect(pastAfter.length).toBe(5); // Same 5 past days
// Compute missed doses AFTER edit — with same takenDoses set
const pastDaysAfter = groupEventsIntoPastDays(pastAfter);
const missedAfter = computeMissedPastDoseIds(pastDaysAfter, medAfter, takenDoses, new Set());
// === CRITICAL ASSERTION: March 12 is STILL marked as taken ===
expect(missedAfter).not.toContain(march12Dose.id);
// The other 4 days remain missed
expect(missedAfter).toHaveLength(4);
});
it("updatedAt does NOT filter out past doses as false dismissals", () => {
// This is the core bug that was fixed: isDoseFromPreviousSchedule compared
// dateOnlyMs < updatedAt, which falsely dismissed ALL past doses after ANY edit.
const med: Medication[] = [
{
id: 1,
name: "TestMed",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 30,
looseTablets: 0,
takenBy: [],
blisters: [{ usage: 1, every: 1, start: "2024-03-10T09:00:00" }],
updatedAt: new Date("2024-03-15T10:00:00Z").toISOString(), // Just edited!
},
];
const result = buildSchedulePreview(med, "en", true);
const pastEvents = result.events.filter((e) => e.isPast);
expect(pastEvents.length).toBe(5); // March 10-14
// No doses taken, no dismissals
const pastDays = groupEventsIntoPastDays(pastEvents);
const missed = computeMissedPastDoseIds(pastDays, med, new Set(), new Set());
// ALL 5 past days should be missed — updatedAt must NOT cause silent dismissals
expect(missed).toHaveLength(5);
});
it("today's doses are not counted as past/missed", () => {
const med: Medication[] = [
{
id: 1,
name: "TestMed",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 30,
looseTablets: 0,
takenBy: [],
blisters: [{ usage: 1, every: 1, start: "2024-03-15T09:00:00" }],
updatedAt: null,
},
];
const result = buildSchedulePreview(med, "en", true);
const todayEvents = result.events.filter((e) => !e.isPast);
const pastEvents = result.events.filter((e) => e.isPast);
// Today's dose should NOT be past
expect(todayEvents.length).toBeGreaterThan(0);
// No past events (schedule starts today)
expect(pastEvents.length).toBe(0);
// computeMissedPastDoseIds with empty pastDays => no missed
const missed = computeMissedPastDoseIds([], med, new Set(), new Set());
expect(missed).toEqual([]);
});
it("dismissedUntil correctly clears missed doses after medication edit", () => {
// Scenario: user edits medication, then clicks "Clear missed" which sets dismissedUntil
const med: Medication[] = [
{
id: 1,
name: "TestMed",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 30,
looseTablets: 0,
takenBy: [],
blisters: [{ usage: 1, every: 1, start: "2024-03-10T09:00:00" }],
updatedAt: new Date("2024-03-15T10:00:00Z").toISOString(),
dismissedUntil: "2024-03-14", // Dismissed through yesterday
},
];
const result = buildSchedulePreview(med, "en", true);
const pastEvents = result.events.filter((e) => e.isPast);
expect(pastEvents.length).toBe(5); // March 10-14
const pastDays = groupEventsIntoPastDays(pastEvents);
const missed = computeMissedPastDoseIds(pastDays, med, new Set(), new Set());
// All 5 past doses are on or before March 14 → all dismissed by dismissedUntil
expect(missed).toEqual([]);
});
it("multiple medications: edit one, other's tracking is unaffected", () => {
const meds: Medication[] = [
{
id: 1,
name: "Aspirin",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 30,
looseTablets: 0,
takenBy: [],
blisters: [{ usage: 1, every: 1, start: "2024-03-10T09:00:00" }],
updatedAt: new Date("2024-03-15T10:00:00Z").toISOString(), // Just edited!
},
{
id: 2,
name: "Vitamin D",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 30,
looseTablets: 0,
takenBy: [],
blisters: [{ usage: 1, every: 1, start: "2024-03-10T08:00:00" }],
updatedAt: null,
},
];
const result = buildSchedulePreview(meds, "en", true);
const pastEvents = result.events.filter((e) => e.isPast);
// Mark one dose of each medication as taken on March 12
const aspirinMarch12 = pastEvents.find((e) => e.medName === "Aspirin" && new Date(e.when).getDate() === 12)!;
const vitDMarch12 = pastEvents.find((e) => e.medName === "Vitamin D" && new Date(e.when).getDate() === 12)!;
expect(aspirinMarch12).toBeDefined();
expect(vitDMarch12).toBeDefined();
const takenDoses = new Set([aspirinMarch12.id, vitDMarch12.id]);
const pastDays = groupEventsIntoPastDays(pastEvents);
const missed = computeMissedPastDoseIds(pastDays, meds, takenDoses, new Set());
// Each med has 5 past days, 1 taken = 4 missed each = 8 total
expect(missed).toHaveLength(8);
// Neither March 12 dose ID should be in missed
expect(missed).not.toContain(aspirinMarch12.id);
expect(missed).not.toContain(vitDMarch12.id);
});
it("taken doses with per-intake takenBy survive time edit", () => {
// Using the modern intakes format: each person gets their own intake entry
// Alice = intake index 0, Bob = intake index 1
const medBefore: Medication[] = [
{
id: 1,
name: "SharedMed",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 30,
looseTablets: 0,
takenBy: ["Alice", "Bob"],
blisters: [],
intakes: [
{ usage: 1, every: 1, start: "2024-03-10T09:00:00", takenBy: "Alice", intakeRemindersEnabled: false },
{ usage: 1, every: 1, start: "2024-03-10T09:00:00", takenBy: "Bob", intakeRemindersEnabled: false },
],
updatedAt: null,
},
];
const resultBefore = buildSchedulePreview(medBefore, "en", true);
const pastBefore = resultBefore.events.filter((e) => e.isPast);
// 2 intakes × 5 days = 10 events
expect(pastBefore.length).toBe(10);
// Alice's March 12 dose (intake index 0)
const aliceMarch12 = pastBefore.find((e) => new Date(e.when).getDate() === 12 && e.id.startsWith("1-0-"))!;
expect(aliceMarch12).toBeDefined();
// Bob's March 12 dose (intake index 1)
const bobMarch12 = pastBefore.find((e) => new Date(e.when).getDate() === 12 && e.id.startsWith("1-1-"))!;
expect(bobMarch12).toBeDefined();
// Alice takes her dose on March 12 — getDoseId adds person suffix
// This matches how DashboardPage marks doses: getDoseId(dose.id, person)
const aliceDoseId = `${aliceMarch12.id}-Alice`;
const takenDoses = new Set([aliceDoseId]);
// Now edit: change both intakes to evening
const medAfter: Medication[] = [
{
id: 1,
name: "SharedMed",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 30,
looseTablets: 0,
takenBy: ["Alice", "Bob"],
blisters: [],
intakes: [
{ usage: 1, every: 1, start: "2024-03-10T21:00:00", takenBy: "Alice", intakeRemindersEnabled: false },
{ usage: 1, every: 1, start: "2024-03-10T21:00:00", takenBy: "Bob", intakeRemindersEnabled: false },
],
updatedAt: new Date("2024-03-15T10:00:00Z").toISOString(),
},
];
const resultAfter = buildSchedulePreview(medAfter, "en", true);
const pastAfter = resultAfter.events.filter((e) => e.isPast);
expect(pastAfter.length).toBe(10); // Still 10 events
const pastDays = groupEventsIntoPastDays(pastAfter);
const missed = computeMissedPastDoseIds(pastDays, medAfter, takenDoses, new Set());
// 10 events, each expanded with person suffix = 10 dose IDs, minus 1 taken = 9 missed
expect(missed).toHaveLength(9);
// Alice's March 12 dose (with -Alice suffix) should NOT be in missed
expect(missed).not.toContain(aliceDoseId);
// Bob's March 12 dose (with -Bob suffix) should still be missed
expect(missed).toContain(`${bobMarch12.id}-Bob`);
// Dose IDs should be different between Alice and Bob (different intake index)
expect(aliceMarch12.id).not.toBe(bobMarch12.id);
});
});
// =============================================================================
// Test Helpers
// =============================================================================
/**
* Group flat schedule events into the pastDays structure expected by computeMissedPastDoseIds.
* This mirrors the groupedSchedule logic in AppContext.tsx:
* - event.takenBy is `string | null` (per-intake person)
* - AppContext normalizes to `string[]`: `event.takenBy ? [event.takenBy] : []`
*/
function groupEventsIntoPastDays(
events: Array<{
id: string;
medName: string;
when: number;
usage: number;
isPast: boolean;
takenBy?: string | string[] | null;
}>
): Array<{ meds: Array<{ medName: string; doses: Array<{ id: string; takenBy: string[] }> }> }> {
const dayMap = new Map<string, Map<string, Array<{ id: string; takenBy: string[] }>>>();
for (const event of events) {
if (!event.isPast) continue;
const dateKey = new Date(event.when).toDateString();
if (!dayMap.has(dateKey)) dayMap.set(dateKey, new Map());
const medMap = dayMap.get(dateKey)!;
if (!medMap.has(event.medName)) medMap.set(event.medName, []);
// Mirror AppContext normalization: string|null → string[]
const takenBy = Array.isArray(event.takenBy)
? event.takenBy
: typeof event.takenBy === "string"
? [event.takenBy]
: [];
medMap.get(event.medName)!.push({ id: event.id, takenBy });
}
return Array.from(dayMap.values()).map((medMap) => ({
meds: Array.from(medMap.entries()).map(([medName, doses]) => ({ medName, doses })),
}));
}