fix: replace event count limit with time-based window for past schedule (#107)

The groupedSchedule useMemo used slice(0, 2000) to limit events. With daily
medications having start dates far in the past, thousands of past events would
fill all 2000 slots, pushing today and future events completely out of the
display. This caused the past schedule to only show weekly medications (fewer
events) while daily medications appeared missing.

Replace the fixed count limit with a time-based window: only past events
within the scheduleDays window (30/90/180 days) are included. All today and
future events are always included regardless.

Coverage calculations are not affected as they use schedule.events directly.
This commit is contained in:
Daniel Volz
2026-02-07 00:35:14 +01:00
committed by GitHub
parent a4313afc34
commit 73b3eb6686
2 changed files with 282 additions and 25 deletions
+34 -25
View File
@@ -351,38 +351,47 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
const groupedSchedule = useMemo(() => {
const days = new Map<string, { dateStr: string; date: Date; isPast: boolean; meds: Map<string, DayMedEntry> }>();
schedule.events.slice(0, 2000).forEach((event) => {
const day = days.get(event.dateStr) ?? {
dateStr: event.dateStr,
date: new Date(event.when),
isPast: event.isPast,
meds: new Map(),
};
const medEntry = day.meds.get(event.medName) ?? {
medName: event.medName,
total: 0,
doses: [],
lastWhen: event.when,
};
medEntry.total += event.usage;
medEntry.doses.push({
id: event.id,
timeStr: event.timeStr,
when: event.when,
usage: event.usage,
takenBy: event.takenBy ? [event.takenBy] : [],
// Limit past events to scheduleDays window to avoid overwhelming the UI.
// Without this, medications with start dates far in the past generate thousands
// of events that fill the display budget and push out today/future events.
const pastCutoff = new Date();
pastCutoff.setDate(pastCutoff.getDate() - scheduleDays);
pastCutoff.setHours(0, 0, 0, 0);
const pastCutoffMs = pastCutoff.getTime();
schedule.events
.filter((e) => !e.isPast || e.when >= pastCutoffMs)
.forEach((event) => {
const day = days.get(event.dateStr) ?? {
dateStr: event.dateStr,
date: new Date(event.when),
isPast: event.isPast,
meds: new Map(),
};
const medEntry = day.meds.get(event.medName) ?? {
medName: event.medName,
total: 0,
doses: [],
lastWhen: event.when,
};
medEntry.total += event.usage;
medEntry.doses.push({
id: event.id,
timeStr: event.timeStr,
when: event.when,
usage: event.usage,
takenBy: event.takenBy ? [event.takenBy] : [],
});
medEntry.lastWhen = Math.max(medEntry.lastWhen, event.when);
day.meds.set(event.medName, medEntry);
days.set(event.dateStr, day);
});
medEntry.lastWhen = Math.max(medEntry.lastWhen, event.when);
day.meds.set(event.medName, medEntry);
days.set(event.dateStr, day);
});
return Array.from(days.values()).map((d) => ({
dateStr: d.dateStr,
date: d.date,
isPast: d.isPast,
meds: Array.from(d.meds.values()),
}));
}, [schedule.events]);
}, [schedule.events, scheduleDays]);
const pastDays = useMemo(() => groupedSchedule.filter((d) => d.isPast), [groupedSchedule]);
+248
View File
@@ -1197,3 +1197,251 @@ describe("expandDoseIds", () => {
expect(result).toEqual(["1-0-1729123200000"]);
});
});
describe("past schedule windowing", () => {
// Reproduces the bug where daily medications with far-past start dates
// would fill all 2000 event slots, pushing today/future events out.
// The fix replaces slice(0, 2000) with a time-based window filter.
function buildGroupedScheduleFromEvents(
events: Array<{
dateStr: string;
when: number;
isPast: boolean;
medName: string;
id: string;
usage: number;
takenBy: string | null;
}>,
scheduleDays: number
) {
// Mirror the fixed groupedSchedule logic from AppContext.tsx
const pastCutoff = new Date();
pastCutoff.setDate(pastCutoff.getDate() - scheduleDays);
pastCutoff.setHours(0, 0, 0, 0);
const pastCutoffMs = pastCutoff.getTime();
type DayMedEntry = {
medName: string;
total: number;
doses: Array<{ id: string; takenBy: string[] }>;
lastWhen: number;
};
const days = new Map<string, { dateStr: string; date: Date; isPast: boolean; meds: Map<string, DayMedEntry> }>();
events
.filter((e) => !e.isPast || e.when >= pastCutoffMs)
.forEach((event) => {
const day = days.get(event.dateStr) ?? {
dateStr: event.dateStr,
date: new Date(event.when),
isPast: event.isPast,
meds: new Map(),
};
const medEntry = day.meds.get(event.medName) ?? {
medName: event.medName,
total: 0,
doses: [],
lastWhen: event.when,
};
medEntry.total += event.usage;
medEntry.doses.push({
id: event.id,
takenBy: event.takenBy ? [event.takenBy] : [],
});
medEntry.lastWhen = Math.max(medEntry.lastWhen, event.when);
day.meds.set(event.medName, medEntry);
days.set(event.dateStr, day);
});
return Array.from(days.values()).map((d) => ({
dateStr: d.dateStr,
date: d.date,
isPast: d.isPast,
meds: Array.from(d.meds.values()),
}));
}
it("includes daily meds within the scheduleDays window, not just weekly meds", () => {
const now = new Date();
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const events: Array<{
dateStr: string;
when: number;
isPast: boolean;
medName: string;
id: string;
usage: number;
takenBy: string | null;
}> = [];
// Simulate daily med starting 400 days ago (way beyond any window)
for (let i = 400; i >= 1; i--) {
const d = new Date(todayStart);
d.setDate(d.getDate() - i);
events.push({
dateStr: d.toLocaleDateString("en", { weekday: "short", day: "2-digit", month: "short" }),
when: d.getTime(),
isPast: true,
medName: "DailyMed",
id: `1-0-${d.getTime()}`,
usage: 1,
takenBy: "Daniel",
});
}
// Simulate weekly Friday med starting 400 days ago
for (let i = 400; i >= 1; i--) {
const d = new Date(todayStart);
d.setDate(d.getDate() - i);
if (d.getDay() !== 5) continue; // Only Fridays
events.push({
dateStr: d.toLocaleDateString("en", { weekday: "short", day: "2-digit", month: "short" }),
when: d.getTime(),
isPast: true,
medName: "WeeklyMed",
id: `2-0-${d.getTime()}`,
usage: 1,
takenBy: null,
});
}
// Add today event
events.push({
dateStr: todayStart.toLocaleDateString("en", { weekday: "short", day: "2-digit", month: "short" }),
when: todayStart.getTime(),
isPast: false,
medName: "DailyMed",
id: `1-0-${todayStart.getTime()}`,
usage: 1,
takenBy: "Daniel",
});
events.sort((a, b) => a.when - b.when);
const grouped = buildGroupedScheduleFromEvents(events, 30);
const pastDays = grouped.filter((d) => d.isPast);
const todayDays = grouped.filter((d) => !d.isPast);
// Past days should contain at most 30 days (scheduleDays window)
expect(pastDays.length).toBeLessThanOrEqual(30);
expect(pastDays.length).toBeGreaterThan(0);
// Today should be present (not pushed out by past events)
expect(todayDays.length).toBeGreaterThanOrEqual(1);
// Past days should include DailyMed (within the 30-day window)
const pastDaysWithDailyMed = pastDays.filter((d) => d.meds.some((m) => m.medName === "DailyMed"));
expect(pastDaysWithDailyMed.length).toBeGreaterThan(0);
// Past days should NOT only be Fridays (the bug)
const pastDateDays = pastDays.map((d) => d.date.getDay());
const uniqueDaysOfWeek = new Set(pastDateDays);
expect(uniqueDaysOfWeek.size).toBeGreaterThan(1); // Multiple days of week, not just Friday
});
it("old slice(0,2000) would have cut off today for large datasets", () => {
const now = new Date();
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const events: Array<{
dateStr: string;
when: number;
isPast: boolean;
medName: string;
id: string;
usage: number;
takenBy: string | null;
}> = [];
// Generate 3 daily meds × 2 intakes × 400 days = 2400 past events (> 2000 limit)
for (let medIdx = 0; medIdx < 3; medIdx++) {
for (let intakeIdx = 0; intakeIdx < 2; intakeIdx++) {
for (let i = 400; i >= 1; i--) {
const d = new Date(todayStart);
d.setDate(d.getDate() - i);
events.push({
dateStr: d.toLocaleDateString("en", { weekday: "short", day: "2-digit", month: "short" }),
when: d.getTime() + intakeIdx * 3600000, // offset intakes by 1 hour
isPast: true,
medName: `Med${medIdx}`,
id: `${medIdx}-${intakeIdx}-${d.getTime()}`,
usage: 1,
takenBy: null,
});
}
}
// Today event
events.push({
dateStr: todayStart.toLocaleDateString("en", { weekday: "short", day: "2-digit", month: "short" }),
when: todayStart.getTime(),
isPast: false,
medName: `Med${medIdx}`,
id: `${medIdx}-0-${todayStart.getTime()}`,
usage: 1,
takenBy: null,
});
}
events.sort((a, b) => a.when - b.when);
// OLD behavior: slice(0, 2000) would have cut off today
const oldSliced = events.slice(0, 2000);
const todayInOld = oldSliced.filter((e) => !e.isPast);
expect(todayInOld.length).toBe(0); // Today events are gone!
// NEW behavior: time-based window keeps today
const grouped = buildGroupedScheduleFromEvents(events, 30);
const todayDays = grouped.filter((d) => !d.isPast);
expect(todayDays.length).toBeGreaterThanOrEqual(1);
// All 3 meds should appear in today
const todayMeds = todayDays[0]?.meds.map((m) => m.medName).sort();
expect(todayMeds).toEqual(["Med0", "Med1", "Med2"]);
});
it("respects scheduleDays parameter for past window size", () => {
const now = new Date();
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const events: Array<{
dateStr: string;
when: number;
isPast: boolean;
medName: string;
id: string;
usage: number;
takenBy: string | null;
}> = [];
// Daily med for 200 days
for (let i = 200; i >= 1; i--) {
const d = new Date(todayStart);
d.setDate(d.getDate() - i);
events.push({
dateStr: d.toLocaleDateString("en", { weekday: "short", day: "2-digit", month: "short" }),
when: d.getTime(),
isPast: true,
medName: "TestMed",
id: `1-0-${d.getTime()}`,
usage: 1,
takenBy: null,
});
}
events.sort((a, b) => a.when - b.when);
// With 30 days window
const grouped30 = buildGroupedScheduleFromEvents(events, 30);
const past30 = grouped30.filter((d) => d.isPast);
expect(past30.length).toBeLessThanOrEqual(30);
// With 90 days window
const grouped90 = buildGroupedScheduleFromEvents(events, 90);
const past90 = grouped90.filter((d) => d.isPast);
expect(past90.length).toBeLessThanOrEqual(90);
expect(past90.length).toBeGreaterThan(past30.length);
// With 180 days window
const grouped180 = buildGroupedScheduleFromEvents(events, 180);
const past180 = grouped180.filter((d) => d.isPast);
expect(past180.length).toBeLessThanOrEqual(180);
expect(past180.length).toBeGreaterThan(past90.length);
});
});