diff --git a/backend/src/routes/medications.ts b/backend/src/routes/medications.ts index ed07dfa..f30353f 100644 --- a/backend/src/routes/medications.ts +++ b/backend/src/routes/medications.ts @@ -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(); + 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(); + 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)); + } } } diff --git a/backend/src/test/integration.test.ts b/backend/src/test/integration.test.ts index d126c19..7789fad 100644 --- a/backend/src/test/integration.test.ts +++ b/backend/src/test/integration.test.ts @@ -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 // ---------------------------------------------------------------------------