fix: only count missed doses scheduled after medication update (#79)

When medication intake times change, dose IDs change (they include
timestamps). Previously, this caused all past doses to appear as
'missed' because the old 'taken' markers no longer matched.

Now doses are only counted as 'missed' if they were scheduled AFTER
the medication's last update (updatedAt). This means:
- Legitimately missed doses still show as missed (e.g., yesterday's
  dose not taken)
- Doses from before a schedule change are NOT counted as missed
  (they were from a previous schedule configuration)

Changes:
- AppContext: Add isDoseFromPreviousSchedule helper
- SchedulePage: Use context's missedPastDoseIds instead of local calc
- Update tests to include missedPastDoseIds in mocks
This commit is contained in:
Daniel Volz
2026-01-25 20:45:11 +01:00
committed by GitHub
parent 8685e802cd
commit e725700d10
4 changed files with 42 additions and 14 deletions
@@ -535,6 +535,7 @@ export function SharedSchedule() {
)
);
// Count missed doses (not taken AND not dismissed)
// Note: SharedSchedule doesn't have updatedAt info, so we only check dismissed status
const missedPastDoses = totalPastDoses.filter((id) => {
if (takenDoses.has(id)) return false;
// Check if this dose is dismissed
+31 -2
View File
@@ -419,12 +419,35 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
return doseDateStr <= dismissedUntilDate;
}, []);
// Helper to check if a dose was scheduled BEFORE the medication was last updated
// If so, it's from a previous schedule configuration and shouldn't count as "missed"
const isDoseFromPreviousSchedule = useCallback(
(doseId: string, medUpdatedAt: string | number | null | undefined): boolean => {
if (!medUpdatedAt) return false; // No updatedAt means it was never changed, all doses are valid
// Extract timestamp from dose ID (format: medId-blisterIdx-timestamp or medId-blisterIdx-timestamp-person)
const parts = doseId.split("-");
if (parts.length < 3) return false;
const doseTimestamp = parseInt(parts[2], 10);
if (Number.isNaN(doseTimestamp)) return false;
// Convert updatedAt to timestamp
const updatedAtTimestamp = typeof medUpdatedAt === "number" ? medUpdatedAt : new Date(medUpdatedAt).getTime();
if (Number.isNaN(updatedAtTimestamp)) return false;
// If the dose was scheduled before the medication was updated, it's from a previous schedule
return doseTimestamp < updatedAtTimestamp;
},
[]
);
const missedPastDoseIds = useMemo(() => {
const totalPastDoses = pastDays.flatMap((d) =>
d.meds.flatMap((m) => {
// Find the medication to get its dismissedUntil
// Find the medication to get its dismissedUntil and updatedAt
const med = medications.meds.find((med) => med.name === m.medName);
const dismissedUntilDate = med?.dismissedUntil ?? undefined;
const medUpdatedAt = med?.updatedAt;
return m.doses.flatMap((dose) => {
// Check if this dose is on or before the dismissed date for this medication
@@ -432,13 +455,19 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
return [];
}
// Check if this dose is from a previous schedule configuration
// (scheduled before the medication was last updated)
if (isDoseFromPreviousSchedule(dose.id, medUpdatedAt)) {
return [];
}
return (dose.takenBy || []).length > 0 ? dose.takenBy.map((p: string) => `${dose.id}-${p}`) : [dose.id];
});
})
);
// Also filter out doses that are marked as taken or individually dismissed (legacy)
return totalPastDoses.filter((id) => !doses.takenDoses.has(id) && !doses.dismissedDoses.has(id));
}, [pastDays, medications.meds, doses.takenDoses, doses.dismissedDoses, isDoseDismissed]);
}, [pastDays, medications.meds, doses.takenDoses, doses.dismissedDoses, isDoseDismissed, isDoseFromPreviousSchedule]);
// Modal helpers with browser history support
const openMedDetail = useCallback(
+7 -12
View File
@@ -63,6 +63,7 @@ export function SchedulePage() {
manuallyExpandedDays,
toggleDayCollapse,
openUserFilter,
missedPastDoseIds,
} = useAppContext();
return (
@@ -88,17 +89,11 @@ export function SchedulePage() {
{/* Past days toggle */}
{pastDays.length > 0 &&
(() => {
const totalPastDoses = pastDays.flatMap((d) =>
d.meds.flatMap((m) =>
m.doses.flatMap((dose) =>
(dose.takenBy || []).length > 0 ? dose.takenBy.map((p) => `${dose.id}-${p}`) : [dose.id]
)
)
);
const missedPastDoses = totalPastDoses.filter((id) => !takenDoses.has(id)).length;
// Use context's missedPastDoseIds which handles dismissed doses and previous schedule detection
const missedCount = missedPastDoseIds.length;
return (
<div
className={`past-days-toggle ${showPastDays ? "expanded" : ""} ${missedPastDoses > 0 ? "has-missed" : ""}`}
className={`past-days-toggle ${showPastDays ? "expanded" : ""} ${missedCount > 0 ? "has-missed" : ""}`}
onClick={() => setShowPastDays(!showPastDays)}
>
<span className="past-days-icon">{showPastDays ? "▼" : "▶"}</span>
@@ -108,12 +103,12 @@ export function SchedulePage() {
<span className="past-days-count">
({t("dashboard.schedules.pastDaysCount", { count: pastDays.length })})
</span>
{missedPastDoses > 0 && (
{missedCount > 0 && (
<span
className="past-days-warning"
title={t("dashboard.schedules.missedDoses", { count: missedPastDoses })}
title={t("dashboard.schedules.missedDoses", { count: missedCount })}
>
{missedPastDoses}
{missedCount}
</span>
)}
</div>
@@ -101,6 +101,7 @@ const createMockContext = (overrides = {}) => ({
manuallyExpandedDays: new Set(),
toggleDayCollapse: vi.fn(),
openUserFilter: vi.fn(),
missedPastDoseIds: [],
...overrides,
});
@@ -436,6 +437,7 @@ describe("SchedulePage with past days", () => {
futureDays: mockFutureDays,
coverageByMed: mockCoverageByMed,
showPastDays: false,
missedPastDoseIds: [`1-0-${Date.now() - 86400000}-John`], // One missed dose
});
});
@@ -467,6 +469,7 @@ describe("SchedulePage with past days", () => {
pastDays: mockPastDays,
showPastDays: false,
setShowPastDays,
missedPastDoseIds: [],
});
render(