diff --git a/backend/src/services/intake-reminder-scheduler.ts b/backend/src/services/intake-reminder-scheduler.ts index 1b9dd44..9fe53e4 100644 --- a/backend/src/services/intake-reminder-scheduler.ts +++ b/backend/src/services/intake-reminder-scheduler.ts @@ -390,25 +390,14 @@ async function checkAndSendIntakeReminders(logger: ServiceLogger): Promise return; // No users with settings } - const intakeEligibleSettings = allUserSettings.filter((settings) => { - const emailEnabled = settings.emailEnabled && settings.notificationEmail && settings.emailIntakeReminders; - const shoutrrrEnabled = settings.shoutrrrEnabled && settings.shoutrrrUrl && settings.shoutrrrIntakeReminders; - return Boolean(emailEnabled || shoutrrrEnabled); - }); + logger.debug(`[IntakeReminder] Evaluating ${allUserSettings.length} intake profile(s) for auto-marking`); - if (intakeEligibleSettings.length === 0) { - logger.debug("[IntakeReminder] No intake notification channels enabled"); - return; - } - - logger.debug(`[IntakeReminder] Evaluating ${intakeEligibleSettings.length} intake reminder profile(s)`); - - for (const userSettings of intakeEligibleSettings) { + for (const userSettings of allUserSettings) { await checkAndSendIntakeRemindersForUser(userSettings, logger); } } -async function checkAndSendIntakeRemindersForUser( +export async function checkAndSendIntakeRemindersForUser( settings: UserSettings & { userId: number }, logger: ServiceLogger ): Promise { diff --git a/backend/src/test/intake-reminder-scheduler.test.ts b/backend/src/test/intake-reminder-scheduler.test.ts new file mode 100644 index 0000000..4fe6624 --- /dev/null +++ b/backend/src/test/intake-reminder-scheduler.test.ts @@ -0,0 +1,138 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { db } from "../db/client.js"; +import { checkAndSendIntakeRemindersForUser } from "../services/intake-reminder-scheduler.js"; + +vi.mock("../db/client.js", () => ({ + db: { + select: vi.fn(), + insert: vi.fn(), + }, +})); + +function createLogger() { + return { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; +} + +describe("checkAndSendIntakeRemindersForUser", () => { + const mockedDb = vi.mocked(db); + let originalTz: string | undefined; + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(2026, 0, 5, 10, 30, 0)); + originalTz = process.env.TZ; + process.env.TZ = Intl.DateTimeFormat().resolvedOptions().timeZone; + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + if (originalTz === undefined) { + delete process.env.TZ; + } else { + process.env.TZ = originalTz; + } + }); + + it("auto-marks due intakes in automatic mode even when all intake reminder channels are disabled", async () => { + const insertedRows: Array> = []; + const selectMock = vi.mocked(mockedDb.select); + const insertMock = vi.mocked(mockedDb.insert); + + selectMock + .mockImplementationOnce( + () => + ({ + from: () => ({ + where: () => ({ + limit: async () => [{ username: "auto-user" }], + }), + }), + }) as never + ) + .mockImplementationOnce( + () => + ({ + from: () => ({ + where: () => ({ + orderBy: async () => [ + { + id: 7, + userId: 11, + name: "Vitamin D", + genericName: null, + takenByJson: null, + pillWeightMg: null, + doseUnit: "mg", + isObsolete: false, + intakeRemindersEnabled: false, + intakesJson: JSON.stringify([ + { + usage: 1, + every: 1, + start: "2026-01-05T08:00:00.000Z", + takenBy: null, + intakeRemindersEnabled: false, + }, + ]), + usageJson: "[]", + everyJson: "[]", + startJson: "[]", + }, + ], + }), + }), + }) as never + ) + .mockImplementationOnce( + () => + ({ + from: () => ({ + where: async () => [], + }), + }) as never + ); + + insertMock.mockImplementation( + () => + ({ + values: async (row: Record) => { + insertedRows.push(row); + }, + }) as never + ); + + const logger = createLogger(); + + await checkAndSendIntakeRemindersForUser( + { + userId: 11, + language: "en", + stockCalculationMode: "automatic", + emailEnabled: false, + notificationEmail: null, + emailIntakeReminders: false, + shoutrrrEnabled: false, + shoutrrrUrl: null, + shoutrrrIntakeReminders: false, + repeatRemindersEnabled: false, + } as never, + logger as never + ); + + expect(insertedRows).toHaveLength(1); + expect(insertedRows[0]).toMatchObject({ + userId: 11, + doseId: `7-0-${new Date(2026, 0, 5).getTime()}`, + markedBy: null, + takenSource: "automatic", + dismissed: false, + }); + expect(logger.info).toHaveBeenCalledWith("[IntakeReminder] Auto-marked 1 due intake dose(s) as taken"); + }); +}); diff --git a/frontend/src/components/SharedSchedule.tsx b/frontend/src/components/SharedSchedule.tsx index 327a713..bc86b1c 100644 --- a/frontend/src/components/SharedSchedule.tsx +++ b/frontend/src/components/SharedSchedule.tsx @@ -11,7 +11,7 @@ import { useEscapeKey } from "../hooks"; import type { ExpiredLinkData, SharedScheduleData } from "../types"; import { getMedDisplayName, getMedTotal, isLiquidContainerPackageType, isTubePackageType } from "../types"; import { getSystemLocale } from "../utils/formatters"; -import { isDoseDismissed } from "../utils/schedule"; +import { isDoseDismissed, parseLocalDateTime } from "../utils/schedule"; import { loadCollapsedDaysFromStorage } from "../utils/storage"; import { MedicationAvatar } from "./MedicationAvatar"; @@ -434,7 +434,7 @@ export function SharedSchedule() { // Filter: only include intakes for this person (null = everyone, or matches share's takenBy) if (intake.takenBy !== null && intake.takenBy !== data.takenBy) return; - const startDate = new Date(intake.start); + const startDate = parseLocalDateTime(intake.start); if (Number.isNaN(startDate.getTime())) return; // Use the same iteration method as buildSchedulePreview (setDate instead of adding ms) @@ -576,7 +576,7 @@ export function SharedSchedule() { // Time-based: every scheduled dose counts as consumed once its time has passed intakes.forEach((intake, blisterIdx) => { const usageForStock = convertUsageForStock(intake.usage, med, intake.intakeUnit ?? "ml"); - const blisterStart = new Date(intake.start).getTime(); + const blisterStart = parseLocalDateTime(intake.start).getTime(); const period = Math.max(1, intake.every) * MS_PER_DAY; let effectiveStart: number; diff --git a/frontend/src/test/utils/schedule.test.ts b/frontend/src/test/utils/schedule.test.ts index a1093a3..8089f52 100644 --- a/frontend/src/test/utils/schedule.test.ts +++ b/frontend/src/test/utils/schedule.test.ts @@ -8,8 +8,21 @@ import { getReminderStatusText, getStockStatus, isDoseDismissed, + parseLocalDateTime, } from "../../utils/schedule"; +describe("parseLocalDateTime", () => { + it("treats Z-suffixed intake timestamps as local wall-clock times", () => { + const parsed = parseLocalDateTime("2026-01-23T20:55:00.000Z"); + + expect(parsed.getFullYear()).toBe(2026); + expect(parsed.getMonth()).toBe(0); + expect(parsed.getDate()).toBe(23); + expect(parsed.getHours()).toBe(20); + expect(parsed.getMinutes()).toBe(55); + }); +}); + describe("buildSchedulePreview", () => { beforeEach(() => { vi.setSystemTime(new Date("2024-03-15T12:00:00Z")); @@ -235,6 +248,36 @@ describe("buildSchedulePreview", () => { expect(date.getMilliseconds()).toBe(0); } }); + + it("keeps schedule IDs stable between local and Z-suffixed intake start strings", () => { + const medWithoutZ: Medication[] = [ + { + id: 1, + name: "TestMed", + packCount: 1, + blistersPerPack: 1, + pillsPerBlister: 30, + looseTablets: 0, + takenBy: [], + packageType: "blister", + blisters: [{ usage: 1, every: 1, start: "2024-03-10T09:00:00" }], + updatedAt: null, + }, + ]; + + const medWithZ: Medication[] = [ + { + ...medWithoutZ[0], + blisters: [{ usage: 1, every: 1, start: "2024-03-10T09:00:00.000Z" }], + }, + ]; + + const localResult = buildSchedulePreview(medWithoutZ, "en", true); + const zResult = buildSchedulePreview(medWithZ, "en", true); + + expect(zResult.events.map((event) => event.id)).toEqual(localResult.events.map((event) => event.id)); + expect(zResult.events.map((event) => event.when)).toEqual(localResult.events.map((event) => event.when)); + }); }); describe("calculateCoverage", () => { @@ -619,8 +662,8 @@ describe("calculateCoverage", () => { // time (15:40 + 24h = tomorrow 15:40), missing today's 15:42 dose entirely. // FIX: Align effectiveStart to the blister's schedule grid so that the first // dose counted is the next one on the schedule after the correction. - const correctionTime = new Date("2024-03-14T15:40:00Z"); // 2 min before dose - vi.setSystemTime(new Date("2024-03-14T15:45:00Z")); // 5 min after correction, 3 min after dose + const correctionTime = new Date(2024, 2, 14, 15, 40, 0); // 2 min before dose + vi.setSystemTime(new Date(2024, 2, 14, 15, 45, 0)); // 5 min after correction, 3 min after dose const meds: Medication[] = [ { @@ -638,7 +681,7 @@ describe("calculateCoverage", () => { { usage: 1, every: 1, - start: "2024-03-01T15:42:00Z", // Daily at 15:42 + start: "2024-03-01T15:42:00", // Daily at 15:42 local time }, ], updatedAt: correctionTime.toISOString(), @@ -657,8 +700,8 @@ describe("calculateCoverage", () => { it("stock correction shortly after a dose does not count that dose again", () => { // If correction happens shortly AFTER a dose, that dose is already reflected // in the stock count and should NOT be counted again. - const correctionTime = new Date("2024-03-14T15:45:00Z"); // 3 min AFTER the 15:42 dose - vi.setSystemTime(new Date("2024-03-14T16:00:00Z")); // 15 min after correction + const correctionTime = new Date(2024, 2, 14, 15, 45, 0); // 3 min AFTER the 15:42 dose + vi.setSystemTime(new Date(2024, 2, 14, 16, 0, 0)); // 15 min after correction const meds: Medication[] = [ { @@ -676,7 +719,7 @@ describe("calculateCoverage", () => { { usage: 1, every: 1, - start: "2024-03-01T15:42:00Z", // Daily at 15:42 + start: "2024-03-01T15:42:00", // Daily at 15:42 local time }, ], updatedAt: correctionTime.toISOString(), diff --git a/frontend/src/utils/schedule.ts b/frontend/src/utils/schedule.ts index bfa179c..522806d 100644 --- a/frontend/src/utils/schedule.ts +++ b/frontend/src/utils/schedule.ts @@ -14,6 +14,23 @@ import type { } from "../types"; import { getMedDisplayName, getMedTotal, isLiquidContainerPackageType, isTubePackageType } from "../types"; +export function parseLocalDateTime(isoString: string): Date { + const match = isoString.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):?(\d{2})?/); + if (!match) { + return new Date(isoString); + } + + const [, year, month, day, hour, minute, second] = match; + return new Date( + parseInt(year, 10), + parseInt(month, 10) - 1, + parseInt(day, 10), + parseInt(hour, 10), + parseInt(minute, 10), + parseInt(second ?? "0", 10) + ); +} + function normalizeIntakeUsageForStock(intake: Intake, med: Medication): number { const usage = Number(intake.usage); if (!Number.isFinite(usage) || usage <= 0) return 0; @@ -75,7 +92,7 @@ export function buildSchedulePreview( meds.forEach((med) => { const intakes = getIntakesForMed(med); intakes.forEach((intake, idx) => { - const start = new Date(intake.start); + const start = parseLocalDateTime(intake.start); if (Number.isNaN(start.getTime())) return; for (let d = new Date(start); d <= end; d.setDate(d.getDate() + intake.every)) { const isPast = d < todayStart; @@ -173,7 +190,7 @@ export function calculateCoverage( // This prevents double-counting: once the scheduled time arrives, the dose // was already counted via the early-taken path, not again via time. blisters.forEach((s, blisterIdx) => { - const blisterStart = new Date(s.start).getTime(); + const blisterStart = parseLocalDateTime(s.start).getTime(); const period = Math.max(1, s.every) * MS_PER_DAY; const intake = intakes[blisterIdx]; if (!intake) return;