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
+11 -55
View File
@@ -8,6 +8,7 @@ import { useParams } from "react-router-dom";
import type { ExpiredLinkData, SharedScheduleData } from "../types";
import { getMedTotal } from "../types";
import { getSystemLocale } from "../utils/formatters";
import { isDoseDismissed } from "../utils/schedule";
import { loadCollapsedDaysFromStorage } from "../utils/storage";
import { MedicationAvatar } from "./MedicationAvatar";
@@ -395,7 +396,7 @@ export function SharedSchedule() {
}, [data]);
// Helper to check if a dose date is on or before the dismissedUntil date
function isDoseDismissed(doseTimestamp: number, medName: string): boolean {
function isDoseDismissedByName(doseTimestamp: number, medName: string): boolean {
const dismissedUntilDate = dismissedUntilByMed.get(medName);
if (!dismissedUntilDate) return false;
// Compare date strings (YYYY-MM-DD format sorts correctly)
@@ -404,39 +405,6 @@ export function SharedSchedule() {
return doseDateStr <= dismissedUntilDate;
}
// Build a map of medication name -> updatedAt timestamp
// Used to filter out doses from previous schedule configurations
const updatedAtByMed = useMemo(() => {
if (!data) return new Map<string, number>();
const map = new Map<string, number>();
for (const med of data.medications) {
if (med.updatedAt) {
const ts = typeof med.updatedAt === "number" ? med.updatedAt : new Date(med.updatedAt).getTime();
if (!Number.isNaN(ts)) {
map.set(med.name, ts);
}
}
}
return map;
}, [data]);
// 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"
// This matches the main app's isDoseFromPreviousSchedule logic in AppContext.tsx
function isDoseFromPreviousSchedule(doseId: string, medName: string): boolean {
const updatedAtTimestamp = updatedAtByMed.get(medName);
if (!updatedAtTimestamp) 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;
// If the dose was scheduled before the medication was updated, it's from a previous schedule
return doseTimestamp < updatedAtTimestamp;
}
// Calculate coverage for stock status colors (matches main app logic)
// This needs to account for taken doses and calculate depletion time
const { coverageByMed, depletionByMed } = useMemo(() => {
@@ -595,17 +563,12 @@ export function SharedSchedule() {
const medId = parts[0];
const med = data?.medications.find((m) => String(m.id) === medId);
if (med) {
const timestamp = parseInt(parts[2], 10);
if (isDoseDismissed(timestamp, med.name)) {
if (isDoseDismissed(id, med.dismissedUntil ?? undefined)) {
return false; // dismissed = not missed
}
// Check if this dose is from a previous schedule configuration
if (isDoseFromPreviousSchedule(id, med.name)) {
return false; // from previous schedule = not missed
}
}
}
return true; // not taken, not dismissed, not from previous schedule = missed
return true; // not taken, not dismissed = missed
}).length;
return (
<div
@@ -637,24 +600,19 @@ export function SharedSchedule() {
{/* Past days (when expanded) */}
{showPastDays &&
pastDays.map((day) => {
// Helper to check if a dose ID is "done" (taken, dismissed, or from previous schedule)
// Checks: per-dose dismissed flag, medication-level dismissedUntil, and updatedAt
// Helper to check if a dose ID is "done" (taken or dismissed)
// Checks: per-dose dismissed flag and medication-level dismissedUntil
const isDoseIdDone = (doseId: string) => {
if (takenDoses.has(doseId)) return true;
// Check if this dose is dismissed via per-dose flag from API
if (dismissedDoses.has(doseId)) return true;
// Check if dismissed via medication-level dismissedUntil date or from previous schedule
// Check if dismissed via medication-level dismissedUntil date
const parts = doseId.split("-");
if (parts.length >= 3) {
const medId = parts[0];
const med = data?.medications.find((m) => String(m.id) === medId);
if (med) {
const timestamp = parseInt(parts[2], 10);
if (isDoseDismissed(timestamp, med.name)) {
return true;
}
// Check if this dose is from a previous schedule configuration
if (isDoseFromPreviousSchedule(doseId, med.name)) {
if (isDoseDismissed(doseId, med.dismissedUntil ?? undefined)) {
return true;
}
}
@@ -753,13 +711,11 @@ export function SharedSchedule() {
</div>
<div className="doses-col">
{item.doses.map((dose) => {
// Check: medication-level dismissedUntil, per-dose dismissed flag, and previous schedule
const isMedLevelDismissed = isDoseDismissed(dose.when, dose.medName);
const isFromPreviousSchedule = isDoseFromPreviousSchedule(dose.id, dose.medName);
// Check: medication-level dismissedUntil and per-dose dismissed flag
const isMedLevelDismissed = isDoseDismissedByName(dose.when, dose.medName);
const isTaken = takenDoses.has(dose.id);
const isPerDoseDismissed = dismissedDoses.has(dose.id);
const isDone =
isTaken || isPerDoseDismissed || isMedLevelDismissed || isFromPreviousSchedule;
const isDone = isTaken || isPerDoseDismissed || isMedLevelDismissed;
return (
<div key={dose.id} className={`dose-item past ${isDone ? "all-taken" : ""}`}>
<span className="dose-time">{dose.timeStr}</span>
+5 -77
View File
@@ -5,7 +5,7 @@ import { useAuth } from "../components/Auth";
import { useCollapsedDays, useDoses, useMedications, useRefill, useSettings, useShare } from "../hooks";
import type { Coverage, Medication, ScheduleEvent, StockThresholds } from "../types";
import { getSystemLocale } from "../utils/formatters";
import { buildSchedulePreview, calculateCoverage } from "../utils/schedule";
import { buildSchedulePreview, calculateCoverage, computeMissedPastDoseIds, isDoseDismissed } from "../utils/schedule";
// =============================================================================
// Types
@@ -370,7 +370,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
timeStr: event.timeStr,
when: event.when,
usage: event.usage,
takenBy: event.takenBy || [],
takenBy: event.takenBy ? [event.takenBy] : [],
});
medEntry.lastWhen = Math.max(medEntry.lastWhen, event.when);
day.meds.set(event.medName, medEntry);
@@ -412,83 +412,11 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
.slice(0, scheduleDays);
}, [groupedSchedule, scheduleDays]);
// Build a map of medId -> dismissedUntil date string from medication records
// This is robust against timestamp changes from schedule updates or timezone fixes
const _dismissedUntilByMed = useMemo(() => {
const map = new Map<string, string>();
for (const med of medications.meds) {
if (med.dismissedUntil) {
map.set(String(med.id), med.dismissedUntil);
}
}
return map;
}, [medications.meds]);
// Helper to check if a dose date is on or before the dismissedUntil date
const isDoseDismissed = useCallback((doseId: string, dismissedUntilDate: string | undefined): boolean => {
if (!dismissedUntilDate) return false;
// 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 timestamp = parseInt(parts[2], 10);
if (Number.isNaN(timestamp)) return false;
// Compare date strings (YYYY-MM-DD format sorts correctly)
const doseDate = new Date(timestamp);
const doseDateStr = `${doseDate.getFullYear()}-${String(doseDate.getMonth() + 1).padStart(2, "0")}-${String(doseDate.getDate()).padStart(2, "0")}`;
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(
() => computeMissedPastDoseIds(pastDays, medications.meds, doses.takenDoses, doses.dismissedDoses),
[pastDays, medications.meds, doses.takenDoses, doses.dismissedDoses]
);
const missedPastDoseIds = useMemo(() => {
const totalPastDoses = pastDays.flatMap((d) =>
d.meds.flatMap((m) => {
// 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
if (isDoseDismissed(dose.id, dismissedUntilDate)) {
return [];
}
// Check if this dose is from a previous schedule configuration
// (scheduled before the medication was last updated)
if (isDoseFromPreviousSchedule(dose.id, medUpdatedAt)) {
return [];
}
const takenByArray = Array.isArray(dose.takenBy) ? dose.takenBy : [];
return takenByArray.length > 0 ? takenByArray.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, isDoseFromPreviousSchedule]);
// Modal helpers with browser history support
const openMedDetail = useCallback(
(med: Medication) => {
+5 -3
View File
@@ -617,7 +617,7 @@ export function DashboardPage() {
<div className="doses-col">
{item.doses.map((dose) => {
// If no takenBy, show single checkbox; otherwise show one per person
const people = dose.takenBy ? [dose.takenBy] : [null];
const people = dose.takenBy.length > 0 ? dose.takenBy : [null];
return (
<div key={dose.id} className="dose-item past">
<span className="dose-time">{dose.timeStr}</span>
@@ -833,7 +833,9 @@ export function DashboardPage() {
(() => {
const totalFutureDoses = futureDays.flatMap((d) =>
d.meds.flatMap((m) =>
m.doses.flatMap((dose) => (dose.takenBy ? [`${dose.id}-${dose.takenBy}`] : [dose.id]))
m.doses.flatMap((dose) =>
dose.takenBy.length > 0 ? dose.takenBy.map((p) => `${dose.id}-${p}`) : [dose.id]
)
)
);
const takenFutureDoses = totalFutureDoses.filter((id) => takenDoses.has(id)).length;
@@ -955,7 +957,7 @@ export function DashboardPage() {
</div>
<div className="doses-col">
{item.doses.map((dose) => {
const people = dose.takenBy ? [dose.takenBy] : [null];
const people = dose.takenBy.length > 0 ? dose.takenBy : [null];
const allTaken = people.every((person) => takenDoses.has(getDoseId(dose.id, person)));
return (
<div key={dose.id} className={`dose-item future ${allTaken ? "all-taken" : ""}`}>
+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 })),
}));
}
+63
View File
@@ -411,3 +411,66 @@ export function getReminderStatusText(
}
return { lines };
}
// =============================================================================
// Dose Dismissal & Missed Dose Computation
// =============================================================================
/**
* Check if a dose is dismissed based on its ID and a dismissedUntil date string.
* Extracts the date-only timestamp from the dose ID and compares it with the dismissedUntil date.
*
* @param doseId - Dose ID in format "medId-intakeIdx-dateOnlyMs" or "medId-intakeIdx-dateOnlyMs-person"
* @param dismissedUntilDate - YYYY-MM-DD formatted date string, or undefined if not dismissed
* @returns true if the dose date is on or before the dismissedUntil date
*/
export function isDoseDismissed(doseId: string, dismissedUntilDate: string | undefined): boolean {
if (!dismissedUntilDate) return false;
const parts = doseId.split("-");
if (parts.length < 3) return false;
const timestamp = parseInt(parts[2], 10);
if (Number.isNaN(timestamp)) return false;
const doseDate = new Date(timestamp);
const doseDateStr = `${doseDate.getFullYear()}-${String(doseDate.getMonth() + 1).padStart(2, "0")}-${String(doseDate.getDate()).padStart(2, "0")}`;
return doseDateStr <= dismissedUntilDate;
}
/**
* Compute the list of missed past dose IDs.
* A dose is "missed" if it is in the past, not taken, not individually dismissed,
* and not covered by the medication's dismissedUntil date.
*
* @param pastDays - Grouped schedule days that are in the past
* @param medications - Full medication list (used to look up dismissedUntil)
* @param takenDoses - Set of dose IDs marked as taken
* @param dismissedDoses - Set of dose IDs individually dismissed
* @returns Array of dose IDs that are missed
*/
export function computeMissedPastDoseIds(
pastDays: ReadonlyArray<{
meds: ReadonlyArray<{
medName: string;
doses: ReadonlyArray<{ id: string; takenBy: string[] }>;
}>;
}>,
medications: ReadonlyArray<{ name: string; dismissedUntil?: string | null }>,
takenDoses: Set<string>,
dismissedDoses: Set<string>
): string[] {
const totalPastDoses = pastDays.flatMap((d) =>
d.meds.flatMap((m) => {
const med = medications.find((med) => med.name === m.medName);
const dismissedUntilDate = med?.dismissedUntil ?? undefined;
return m.doses.flatMap((dose) => {
if (isDoseDismissed(dose.id, dismissedUntilDate)) {
return [];
}
const takenByArray = Array.isArray(dose.takenBy) ? dose.takenBy : [];
return takenByArray.length > 0 ? takenByArray.map((p: string) => `${dose.id}-${p}`) : [dose.id];
});
})
);
return totalPastDoses.filter((id) => !takenDoses.has(id) && !dismissedDoses.has(id));
}