fix: restore automatic intake auto-marking (#420)
This commit is contained in:
@@ -390,25 +390,14 @@ async function checkAndSendIntakeReminders(logger: ServiceLogger): Promise<void>
|
||||
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<void> {
|
||||
|
||||
@@ -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<Record<string, unknown>> = [];
|
||||
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<string, unknown>) => {
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user