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:
@@ -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,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) => {
|
||||
|
||||
@@ -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" : ""}`}>
|
||||
|
||||
@@ -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 })),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user