diff --git a/backend/src/db/client.ts b/backend/src/db/client.ts index c17bb0a..1ad9ea3 100644 --- a/backend/src/db/client.ts +++ b/backend/src/db/client.ts @@ -165,6 +165,29 @@ export async function ensureDefaultUser(client: Client, authEnabled: boolean): P const MS_PER_DAY = 86_400_000; +/** + * Repair dose IDs that have a trailing hyphen caused by a frontend bug where + * `[].toString()` produced an empty string, resulting in IDs like "5-0-1729123200000-" + * instead of "5-0-1729123200000". This strips trailing hyphens from all dose IDs. + * + * This function is idempotent - safe to run on every startup. + */ +export async function repairTrailingHyphenDoseIds(client: Client): Promise<{ repaired: number; errors: string[] }> { + const errors: string[] = []; + let repaired = 0; + + try { + const result = await client.execute( + "UPDATE dose_tracking SET dose_id = RTRIM(dose_id, '-') WHERE dose_id LIKE '%-'" + ); + repaired = result.rowsAffected; + } catch (e: any) { + errors.push(`Trailing-hyphen repair failed: ${e.message}`); + } + + return { repaired, errors }; +} + /** * Repair orphaned dose tracking IDs that no longer match the current intake schedule. * This fixes dose IDs that became invalid when a medication's schedule was changed @@ -355,6 +378,15 @@ async function runMigrations() { } console.log(`[DB] Tables verified/created`); + // Repair dose IDs with trailing hyphens (from frontend takenBy bug) + const trailingResult = await repairTrailingHyphenDoseIds(client); + if (trailingResult.repaired > 0) { + console.log(`[DB] Repaired ${trailingResult.repaired} dose IDs with trailing hyphens`); + } + if (trailingResult.errors.length > 0) { + trailingResult.errors.forEach((err) => console.error(`[DB] Trailing-hyphen repair error:`, err)); + } + // Repair orphaned dose tracking IDs from past schedule changes const repairResult = await repairOrphanedDoseIds(client); if (repairResult.repaired > 0) { diff --git a/backend/src/test/database.test.ts b/backend/src/test/database.test.ts index 3b6e3a5..e2cc53c 100644 --- a/backend/src/test/database.test.ts +++ b/backend/src/test/database.test.ts @@ -14,6 +14,7 @@ import { ensureDefaultUser, getDbPaths, repairOrphanedDoseIds, + repairTrailingHyphenDoseIds, runAlterMigrations, runDrizzleMigrations, } from "../db/client.js"; @@ -890,4 +891,104 @@ describe("Database Client", () => { expect(doses.rows[0].dose_id).toBe(`1-0-${sat18}`); }); }); + + describe("repairTrailingHyphenDoseIds", () => { + let client: ReturnType; + + beforeEach(async () => { + client = createClient({ url: ":memory:" }); + const db = drizzle(client); + await migrate(db, { migrationsFolder }); + await client.execute("INSERT INTO users (id, username, auth_provider) VALUES (1, 'testuser', 'local')"); + }); + + it("should return 0 repairs when no dose IDs have trailing hyphens", async () => { + const ts = new Date(2025, 9, 17).getTime(); + await client.execute({ + sql: "INSERT INTO dose_tracking (user_id, dose_id) VALUES (1, ?)", + args: [`1-0-${ts}`], + }); + + const result = await repairTrailingHyphenDoseIds(client); + expect(result.repaired).toBe(0); + expect(result.errors).toHaveLength(0); + }); + + it("should strip trailing hyphen from dose IDs", async () => { + const ts = new Date(2025, 9, 17).getTime(); + await client.execute({ + sql: "INSERT INTO dose_tracking (user_id, dose_id) VALUES (1, ?)", + args: [`1-0-${ts}-`], + }); + + const result = await repairTrailingHyphenDoseIds(client); + expect(result.repaired).toBe(1); + expect(result.errors).toHaveLength(0); + + const doses = await client.execute("SELECT dose_id FROM dose_tracking"); + expect(doses.rows[0].dose_id).toBe(`1-0-${ts}`); + }); + + it("should not modify dose IDs with valid person suffixes", async () => { + const ts = new Date(2025, 9, 17).getTime(); + await client.execute({ + sql: "INSERT INTO dose_tracking (user_id, dose_id) VALUES (1, ?)", + args: [`1-0-${ts}-Alice`], + }); + + const result = await repairTrailingHyphenDoseIds(client); + expect(result.repaired).toBe(0); + + const doses = await client.execute("SELECT dose_id FROM dose_tracking"); + expect(doses.rows[0].dose_id).toBe(`1-0-${ts}-Alice`); + }); + + it("should handle multiple trailing hyphens", async () => { + const ts = new Date(2025, 9, 17).getTime(); + await client.execute({ + sql: "INSERT INTO dose_tracking (user_id, dose_id) VALUES (1, ?)", + args: [`1-0-${ts}--`], + }); + + const result = await repairTrailingHyphenDoseIds(client); + expect(result.repaired).toBe(1); + + const doses = await client.execute("SELECT dose_id FROM dose_tracking"); + expect(doses.rows[0].dose_id).toBe(`1-0-${ts}`); + }); + + it("should repair multiple affected rows at once", async () => { + const ts1 = new Date(2025, 9, 17).getTime(); + const ts2 = new Date(2025, 9, 24).getTime(); + const ts3 = new Date(2025, 9, 31).getTime(); + await client.execute({ + sql: "INSERT INTO dose_tracking (user_id, dose_id) VALUES (1, ?), (1, ?), (1, ?)", + args: [`1-0-${ts1}-`, `2-0-${ts2}-`, `1-0-${ts3}`], + }); + + const result = await repairTrailingHyphenDoseIds(client); + expect(result.repaired).toBe(2); // Only 2 had trailing hyphens + expect(result.errors).toHaveLength(0); + + const doses = await client.execute("SELECT dose_id FROM dose_tracking ORDER BY dose_id"); + const ids = doses.rows.map((r) => r.dose_id); + expect(ids).toContain(`1-0-${ts1}`); + expect(ids).toContain(`2-0-${ts2}`); + expect(ids).toContain(`1-0-${ts3}`); + }); + + it("should be idempotent - running twice has no effect the second time", async () => { + const ts = new Date(2025, 9, 17).getTime(); + await client.execute({ + sql: "INSERT INTO dose_tracking (user_id, dose_id) VALUES (1, ?)", + args: [`1-0-${ts}-`], + }); + + const result1 = await repairTrailingHyphenDoseIds(client); + expect(result1.repaired).toBe(1); + + const result2 = await repairTrailingHyphenDoseIds(client); + expect(result2.repaired).toBe(0); + }); + }); }); diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index a8abfa5..c2440e8 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -4,7 +4,7 @@ import { useAuth } from "../components/Auth"; import { useAppContext } from "../context"; import type { Coverage } from "../types"; import { formatNumber, getExpiryClass, getSystemLocale } from "../utils/formatters"; -import { getStockStatus } from "../utils/schedule"; +import { expandDoseIds, getStockStatus } from "../utils/schedule"; // Helper for user-specific localStorage keys function userStorageKey(userId: number | undefined, key: string): string { @@ -490,11 +490,7 @@ export function DashboardPage() { {pastDays.length > 0 && (() => { const missedCount = missedPastDoseIds.length; - const totalPastDoses = pastDays.flatMap((d) => - d.meds.flatMap((m) => - m.doses.flatMap((dose) => (dose.takenBy ? [`${dose.id}-${dose.takenBy}`] : [dose.id])) - ) - ); + const totalPastDoses = pastDays.flatMap((d) => d.meds.flatMap((m) => expandDoseIds(m.doses))); return (
{ const allDoseIds = day.meds.flatMap((item) => - item.doses.flatMap((d) => (d.takenBy ? [`${d.id}-${d.takenBy}`] : [d.id])) + item.doses.flatMap((d) => { + const takenByArray = Array.isArray(d.takenBy) ? d.takenBy : []; + return takenByArray.length > 0 ? takenByArray.map((p) => `${d.id}-${p}`) : [d.id]; + }) ); const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id) || dismissedDoses.has(id)); @@ -586,7 +585,7 @@ export function DashboardPage() { const med = meds.find((m) => m.name === item.medName); const medCov = coverageByMed[item.medName]; const isEmpty = medCov ? medCov.medsLeft <= 0 : false; - const itemDoseIds = item.doses.flatMap((d) => (d.takenBy ? [`${d.id}-${d.takenBy}`] : [d.id])); + const itemDoseIds = expandDoseIds(item.doses); const allTaken = itemDoseIds.every((id) => takenDoses.has(id)); return (
@@ -676,9 +675,7 @@ export function DashboardPage() { {todayDay && (() => { const day = todayDay; - const allDoseIds = day.meds.flatMap((item) => - item.doses.flatMap((d) => (d.takenBy ? [`${d.id}-${d.takenBy}`] : [d.id])) - ); + const allDoseIds = day.meds.flatMap((item) => expandDoseIds(item.doses)); const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id)); const takenCount = allDoseIds.filter((id) => takenDoses.has(id)).length; @@ -737,7 +734,7 @@ export function DashboardPage() { : medCoverage ? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, stockThresholds) : null; - const itemDoseIds = item.doses.flatMap((d) => (d.takenBy ? [`${d.id}-${d.takenBy}`] : [d.id])); + const itemDoseIds = expandDoseIds(item.doses); const allTaken = itemDoseIds.every((id) => takenDoses.has(id)); return (
@@ -769,7 +766,7 @@ export function DashboardPage() {
{item.doses.map((dose) => { const isOverdue = dose.when < Date.now(); - 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 (
{ - const allDoseIds = day.meds.flatMap((item) => - item.doses.flatMap((d) => (d.takenBy ? [`${d.id}-${d.takenBy}`] : [d.id])) - ); + const allDoseIds = day.meds.flatMap((item) => expandDoseIds(item.doses)); const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id)); const takenCount = allDoseIds.filter((id) => takenDoses.has(id)).length; @@ -926,7 +921,7 @@ export function DashboardPage() { : medCoverage ? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, stockThresholds) : null; - const itemDoseIds = item.doses.flatMap((d) => (d.takenBy ? [`${d.id}-${d.takenBy}`] : [d.id])); + const itemDoseIds = expandDoseIds(item.doses); const allTaken = itemDoseIds.every((id) => takenDoses.has(id)); return (
diff --git a/frontend/src/pages/SchedulePage.tsx b/frontend/src/pages/SchedulePage.tsx index 12001dc..34b7002 100644 --- a/frontend/src/pages/SchedulePage.tsx +++ b/frontend/src/pages/SchedulePage.tsx @@ -3,6 +3,7 @@ import { MedicationAvatar } from "../components"; import { useAuth } from "../components/Auth"; import { useAppContext } from "../context"; import type { Coverage } from "../types"; +import { expandDoseIds } from "../utils/schedule"; // Helper for user-specific localStorage keys function userStorageKey(userId: number | undefined, key: string): string { @@ -125,12 +126,7 @@ export function SchedulePage() { {/* Past days (when expanded) */} {showPastDays && pastDays.map((day) => { - const allDoseIds = day.meds.flatMap((item) => - item.doses.flatMap((d) => { - const takenByArray = Array.isArray(d.takenBy) ? d.takenBy : []; - return takenByArray.length > 0 ? takenByArray.map((p) => `${d.id}-${p}`) : [d.id]; - }) - ); + const allDoseIds = day.meds.flatMap((item) => expandDoseIds(item.doses)); const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id)); const takenCount = allDoseIds.filter((id) => takenDoses.has(id)).length; const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr); @@ -172,10 +168,7 @@ export function SchedulePage() { const med = meds.find((m) => m.name === item.medName); const medCov = coverageByMed[item.medName]; const isEmpty = medCov ? medCov.medsLeft <= 0 : false; - const itemDoseIds = item.doses.flatMap((d) => { - const takenByArray = Array.isArray(d.takenBy) ? d.takenBy : []; - return takenByArray.length > 0 ? takenByArray.map((p) => `${d.id}-${p}`) : [d.id]; - }); + const itemDoseIds = expandDoseIds(item.doses); const allTaken = itemDoseIds.every((id) => takenDoses.has(id)); return (
@@ -277,10 +270,7 @@ export function SchedulePage() { : medCoverage ? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings) : null; - const itemDoseIds = item.doses.flatMap((d) => { - const takenByArray = Array.isArray(d.takenBy) ? d.takenBy : []; - return takenByArray.length > 0 ? takenByArray.map((p) => `${d.id}-${p}`) : [d.id]; - }); + const itemDoseIds = expandDoseIds(item.doses); const allTaken = itemDoseIds.every((id) => takenDoses.has(id)); return (
diff --git a/frontend/src/test/utils/schedule.test.ts b/frontend/src/test/utils/schedule.test.ts index dc0c717..191d844 100644 --- a/frontend/src/test/utils/schedule.test.ts +++ b/frontend/src/test/utils/schedule.test.ts @@ -4,6 +4,7 @@ import { buildSchedulePreview, calculateCoverage, computeMissedPastDoseIds, + expandDoseIds, getNextReminderForMed, getReminderStatusText, getStockStatus, @@ -1147,3 +1148,52 @@ function groupEventsIntoPastDays( meds: Array.from(medMap.entries()).map(([medName, doses]) => ({ medName, doses })), })); } + +describe("expandDoseIds", () => { + it("returns base IDs when takenBy is empty array", () => { + const doses = [ + { id: "1-0-1729123200000", takenBy: [] as string[] }, + { id: "2-0-1729123200000", takenBy: [] as string[] }, + ]; + const result = expandDoseIds(doses); + expect(result).toEqual(["1-0-1729123200000", "2-0-1729123200000"]); + }); + + it("returns person-suffixed IDs when takenBy has entries", () => { + const doses = [{ id: "1-0-1729123200000", takenBy: ["Alice"] }]; + const result = expandDoseIds(doses); + expect(result).toEqual(["1-0-1729123200000-Alice"]); + }); + + it("returns multiple IDs for multiple takenBy entries", () => { + const doses = [{ id: "1-0-1729123200000", takenBy: ["Alice", "Bob"] }]; + const result = expandDoseIds(doses); + expect(result).toEqual(["1-0-1729123200000-Alice", "1-0-1729123200000-Bob"]); + }); + + it("handles mix of empty and non-empty takenBy", () => { + const doses = [ + { id: "1-0-1729123200000", takenBy: ["Alice"] }, + { id: "2-0-1729123200000", takenBy: [] as string[] }, + { id: "3-0-1729123200000", takenBy: ["Bob", "Carol"] }, + ]; + const result = expandDoseIds(doses); + expect(result).toEqual([ + "1-0-1729123200000-Alice", + "2-0-1729123200000", + "3-0-1729123200000-Bob", + "3-0-1729123200000-Carol", + ]); + }); + + it("returns empty array for empty doses", () => { + expect(expandDoseIds([])).toEqual([]); + }); + + it("handles non-array takenBy gracefully", () => { + // In case of unexpected data, treat non-array as empty + const doses = [{ id: "1-0-1729123200000", takenBy: null as unknown as string[] }]; + const result = expandDoseIds(doses); + expect(result).toEqual(["1-0-1729123200000"]); + }); +}); diff --git a/frontend/src/utils/schedule.ts b/frontend/src/utils/schedule.ts index 692ee18..d67f234 100644 --- a/frontend/src/utils/schedule.ts +++ b/frontend/src/utils/schedule.ts @@ -446,6 +446,18 @@ export function isDoseDismissed(doseId: string, dismissedUntilDate: string | und * @param dismissedDoses - Set of dose IDs individually dismissed * @returns Array of dose IDs that are missed */ +/** + * Compute the full set of dose IDs for a list of doses, correctly handling + * per-intake takenBy arrays. Empty arrays produce base IDs (no suffix), + * non-empty arrays produce one ID per person with a `-person` suffix. + */ +export function expandDoseIds(doses: ReadonlyArray<{ id: string; takenBy: string[] }>): string[] { + return doses.flatMap((d) => { + const takenByArray = Array.isArray(d.takenBy) ? d.takenBy : []; + return takenByArray.length > 0 ? takenByArray.map((p) => `${d.id}-${p}`) : [d.id]; + }); +} + export function computeMissedPastDoseIds( pastDays: ReadonlyArray<{ meds: ReadonlyArray<{