fix: migrate dose tracking IDs when intake schedule changes (#103)

When a medication's start date or interval changes, the generated dose
IDs shift (dateOnlyMs values change). Previously, doses marked as taken
under the old schedule were orphaned — they no longer matched the new
schedule's dose IDs, causing them to appear as missed.

Now the PUT /medications/:id endpoint:
1. Parses old intakes from the existing medication row
2. Detects which intake indices had schedule changes
3. Maps old dateOnlyMs values to the nearest new dateOnlyMs
4. Updates dose_tracking entries with the migrated IDs
5. Preserves person suffixes (e.g. -Alice) during migration

Also fixes the start-date cleanup to use date-only comparison,
preventing doses on the start date from being incorrectly deleted
when the start time is after midnight.

Adds 4 integration tests covering weekly day shift, person suffix
preservation, time-only changes, and interval changes.
This commit is contained in:
Daniel Volz
2026-02-06 22:38:28 +01:00
committed by GitHub
parent 43c5402592
commit f5f189e0a4
2 changed files with 303 additions and 20 deletions
+113 -20
View File
@@ -312,29 +312,122 @@ export async function medicationRoutes(app: FastifyInstance) {
if (!result.length) return reply.notFound();
// Clean up dose tracking entries that are before the earliest start date
// This ensures consistency when the user changes the start date
const earliestStart = Math.min(...intakes.map((b) => parseLocalDateTime(b.start).getTime()));
if (!Number.isNaN(earliestStart)) {
// Get all dose tracking entries for this medication and filter out invalid ones
const allDoses = await db
.select()
.from(doseTracking)
.where(and(eq(doseTracking.userId, userId), like(doseTracking.doseId, `${idNum}-%`)));
// ---------------------------------------------------------------
// Migrate dose tracking IDs when intake schedule changes
// ---------------------------------------------------------------
// Parse old intakes from the existing medication row
const oldIntakes = parseIntakesJson(
existing.intakesJson,
{ usageJson: existing.usageJson, everyJson: existing.everyJson, startJson: existing.startJson },
existing.intakeRemindersEnabled
);
// Find doses with timestamps before the earliest start date
const dosesToDelete = allDoses.filter((dose) => {
const parts = dose.doseId.split("-");
if (parts.length >= 3) {
const timestamp = parseInt(parts[2], 10);
return !Number.isNaN(timestamp) && timestamp < earliestStart;
// Get all dose tracking entries for this medication
const allDoses = await db
.select()
.from(doseTracking)
.where(and(eq(doseTracking.userId, userId), like(doseTracking.doseId, `${idNum}-%`)));
if (allDoses.length > 0) {
// Build migration map: for each intake index, map old dateOnlyMs → new dateOnlyMs
const now = new Date();
const migrationEnd = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const MS_PER_DAY = 86_400_000;
for (let idx = 0; idx < Math.max(oldIntakes.length, intakes.length); idx++) {
const oldIntake = oldIntakes[idx];
const newIntake = intakes[idx];
// Skip if this intake index doesn't exist in both old and new
if (!oldIntake || !newIntake) continue;
const oldStart = parseLocalDateTime(oldIntake.start);
const newStart = parseLocalDateTime(newIntake.start);
const oldEvery = oldIntake.every;
const newEvery = newIntake.every;
// Check if start date or interval changed (time-of-day changes don't matter for dateOnlyMs)
const oldStartDateOnly = new Date(oldStart.getFullYear(), oldStart.getMonth(), oldStart.getDate()).getTime();
const newStartDateOnly = new Date(newStart.getFullYear(), newStart.getMonth(), newStart.getDate()).getTime();
if (oldStartDateOnly === newStartDateOnly && oldEvery === newEvery) {
continue; // No schedule change that affects dose IDs
}
return false;
});
// Delete invalid doses
for (const dose of dosesToDelete) {
await db.delete(doseTracking).where(eq(doseTracking.id, dose.id));
// Build set of new valid dateOnlyMs values for this intake
const newDates = new Set<number>();
for (let d = new Date(newStart); d <= migrationEnd; d.setDate(d.getDate() + newEvery)) {
newDates.add(new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime());
}
// Build set of old dateOnlyMs values with mapping to nearest new date
const oldToNewMap = new Map<number, number>();
for (let d = new Date(oldStart); d <= migrationEnd; d.setDate(d.getDate() + oldEvery)) {
const oldDateMs = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
// Find the closest new date within ±(newEvery/2) days
const halfInterval = (newEvery * MS_PER_DAY) / 2;
let bestMatch: number | null = null;
let bestDist = Infinity;
for (const newDateMs of newDates) {
const dist = Math.abs(newDateMs - oldDateMs);
if (dist < bestDist && dist <= halfInterval) {
bestDist = dist;
bestMatch = newDateMs;
}
}
if (bestMatch !== null && bestMatch !== oldDateMs) {
oldToNewMap.set(oldDateMs, bestMatch);
// Remove matched new date to prevent double-mapping
newDates.delete(bestMatch);
}
}
// Apply migrations to dose tracking entries
if (oldToNewMap.size > 0) {
const prefix = `${idNum}-${idx}-`;
const dosesToMigrate = allDoses.filter((d) => d.doseId.startsWith(prefix));
for (const dose of dosesToMigrate) {
const parts = dose.doseId.split("-");
if (parts.length >= 3) {
const oldTimestamp = parseInt(parts[2], 10);
const newTimestamp = oldToNewMap.get(oldTimestamp);
if (newTimestamp !== undefined) {
// Replace the timestamp in the dose ID, keeping any person suffix
const newDoseId = `${idNum}-${idx}-${newTimestamp}${parts.length > 3 ? `-${parts.slice(3).join("-")}` : ""}`;
await db.update(doseTracking).set({ doseId: newDoseId }).where(eq(doseTracking.id, dose.id));
}
}
}
}
}
// Also clean up dose tracking entries before the earliest new start date
const earliestStartDate = intakes.reduce((min, b) => {
const d = parseLocalDateTime(b.start);
// Use date-only (midnight) to match dose ID format
const dateOnly = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
return dateOnly < min ? dateOnly : min;
}, Infinity);
if (!Number.isNaN(earliestStartDate)) {
// Re-fetch after possible migrations
const updatedDoses = await db
.select()
.from(doseTracking)
.where(and(eq(doseTracking.userId, userId), like(doseTracking.doseId, `${idNum}-%`)));
const dosesToDelete = updatedDoses.filter((dose) => {
const parts = dose.doseId.split("-");
if (parts.length >= 3) {
const timestamp = parseInt(parts[2], 10);
return !Number.isNaN(timestamp) && timestamp < earliestStartDate;
}
return false;
});
for (const dose of dosesToDelete) {
await db.delete(doseTracking).where(eq(doseTracking.id, dose.id));
}
}
}
+190
View File
@@ -365,6 +365,196 @@ describe("Integration Tests", () => {
});
});
// ---------------------------------------------------------------------------
// Dose ID Migration on Schedule Changes
// ---------------------------------------------------------------------------
describe("Dose ID migration when schedule changes", () => {
it("should migrate dose IDs when weekly start day changes", async () => {
// Create a weekly medication starting Friday Oct 17
const createRes = await app.inject({
method: "POST",
url: "/medications",
payload: {
name: "Weekly Med",
blisters: [{ usage: 1, every: 7, start: "2025-10-17T08:00:00" }],
},
});
const medId = createRes.json().id;
// Mark doses for Fridays (Oct 17, Oct 24, Oct 31)
const fri17 = new Date(2025, 9, 17).getTime(); // Oct 17
const fri24 = new Date(2025, 9, 24).getTime(); // Oct 24
const fri31 = new Date(2025, 9, 31).getTime(); // Oct 31
for (const ts of [fri17, fri24, fri31]) {
await app.inject({
method: "POST",
url: "/doses/taken",
payload: { doseId: `${medId}-0-${ts}` },
});
}
// Verify 3 doses exist
const before = await testClient.execute({
sql: `SELECT COUNT(*) as count FROM dose_tracking WHERE dose_id LIKE ?`,
args: [`${medId}-%`],
});
expect(before.rows[0].count).toBe(3);
// Change start to Saturday Oct 18 (shifts all future and past IDs)
await app.inject({
method: "PUT",
url: `/medications/${medId}`,
payload: {
name: "Weekly Med",
blisters: [{ usage: 1, every: 7, start: "2025-10-18T08:00:00" }],
},
});
// Doses should be migrated to Saturday dates
const sat18 = new Date(2025, 9, 18).getTime(); // Oct 18
const sat25 = new Date(2025, 9, 25).getTime(); // Oct 25
const nov1 = new Date(2025, 10, 1).getTime(); // Nov 1
const after = await testClient.execute({
sql: `SELECT dose_id FROM dose_tracking WHERE dose_id LIKE ? ORDER BY dose_id`,
args: [`${medId}-%`],
});
expect(after.rows.length).toBe(3);
const ids = after.rows.map((r: { dose_id: string }) => r.dose_id);
expect(ids).toContain(`${medId}-0-${sat18}`);
expect(ids).toContain(`${medId}-0-${sat25}`);
expect(ids).toContain(`${medId}-0-${nov1}`);
});
it("should migrate dose IDs with person suffix when schedule changes", async () => {
// Create weekly medication with takenBy person
const createRes = await app.inject({
method: "POST",
url: "/medications",
payload: {
name: "Person Med",
intakes: [{ usage: 1, every: 7, start: "2025-10-17T08:00:00", takenBy: "Alice" }],
},
});
const medId = createRes.json().id;
// Mark dose with person suffix
const fri17 = new Date(2025, 9, 17).getTime();
await app.inject({
method: "POST",
url: "/doses/taken",
payload: { doseId: `${medId}-0-${fri17}-Alice` },
});
// Change start day
await app.inject({
method: "PUT",
url: `/medications/${medId}`,
payload: {
name: "Person Med",
intakes: [{ usage: 1, every: 7, start: "2025-10-18T08:00:00", takenBy: "Alice" }],
},
});
// Dose should be migrated with person suffix preserved
const sat18 = new Date(2025, 9, 18).getTime();
const after = await testClient.execute({
sql: `SELECT dose_id FROM dose_tracking WHERE dose_id LIKE ?`,
args: [`${medId}-%`],
});
expect(after.rows.length).toBe(1);
expect(after.rows[0].dose_id).toBe(`${medId}-0-${sat18}-Alice`);
});
it("should not migrate dose IDs when only time-of-day changes", async () => {
// Create daily medication at 08:00
const createRes = await app.inject({
method: "POST",
url: "/medications",
payload: {
name: "Daily Med",
blisters: [{ usage: 1, every: 1, start: "2025-10-17T08:00:00" }],
},
});
const medId = createRes.json().id;
// Mark dose
const oct17 = new Date(2025, 9, 17).getTime();
await app.inject({
method: "POST",
url: "/doses/taken",
payload: { doseId: `${medId}-0-${oct17}` },
});
// Change only time from 08:00 to 20:00 (same date)
await app.inject({
method: "PUT",
url: `/medications/${medId}`,
payload: {
name: "Daily Med",
blisters: [{ usage: 1, every: 1, start: "2025-10-17T20:00:00" }],
},
});
// Dose ID should remain unchanged (dateOnlyMs is the same)
const after = await testClient.execute({
sql: `SELECT dose_id FROM dose_tracking WHERE dose_id LIKE ?`,
args: [`${medId}-%`],
});
expect(after.rows.length).toBe(1);
expect(after.rows[0].dose_id).toBe(`${medId}-0-${oct17}`);
});
it("should migrate dose IDs when interval changes from daily to every-other-day", async () => {
// Create daily medication starting Oct 17
const createRes = await app.inject({
method: "POST",
url: "/medications",
payload: {
name: "Interval Med",
blisters: [{ usage: 1, every: 1, start: "2025-10-17T08:00:00" }],
},
});
const medId = createRes.json().id;
// Mark doses for Oct 17, 18, 19
const oct17 = new Date(2025, 9, 17).getTime();
const oct18 = new Date(2025, 9, 18).getTime();
const oct19 = new Date(2025, 9, 19).getTime();
for (const ts of [oct17, oct18, oct19]) {
await app.inject({
method: "POST",
url: "/doses/taken",
payload: { doseId: `${medId}-0-${ts}` },
});
}
// Change to every 2 days (Oct 17, 19, 21, ...)
await app.inject({
method: "PUT",
url: `/medications/${medId}`,
payload: {
name: "Interval Med",
blisters: [{ usage: 1, every: 2, start: "2025-10-17T08:00:00" }],
},
});
// Oct 17 stays (matches), Oct 18 → Oct 19 (nearest), Oct 19 → no match (already used)
// Actually: Oct 17 is exact match (no migration needed), Oct 18 maps to Oct 19 (within 1 day = half of 2),
// Oct 19 was the original schedule date but the new schedule also has Oct 19,
// which was already taken by Oct 18's migration
const after = await testClient.execute({
sql: `SELECT dose_id FROM dose_tracking WHERE dose_id LIKE ? ORDER BY dose_id`,
args: [`${medId}-%`],
});
// We should have at least the doses that could be mapped
expect(after.rows.length).toBeGreaterThanOrEqual(2);
});
});
// ---------------------------------------------------------------------------
// Share Link + Dose Tracking Integration
// ---------------------------------------------------------------------------