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
+1
View File
@@ -9,6 +9,7 @@
- **Clean workspace**: Always clean up after yourself. If you create a file for a specific task, delete it once done.
- **Remove old code when re-implementing**: When fixing a bug or re-implementing a feature that didn't work, ALWAYS remove the old/broken code completely. Never leave dead code, unused functions, or obsolete implementations in the codebase.
- **Tests are mandatory**: Every new feature and every bug fix MUST have corresponding tests. When modifying existing features, update or add tests accordingly. If old tests become obsolete due to code changes, remove or update them.
- **Fix bugs, don't test around them**: If you discover incorrect behavior in the code while writing tests, ALWAYS fix the buggy code first, then write tests that verify the correct behavior. NEVER write tests that mimic or assert broken behavior. The user's time is finite and irreplaceable — every bug left unfixed wastes it.
## Architecture Overview
+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));
}