fix: restore automatic intake auto-marking (#420)

This commit is contained in:
Daniel Volz
2026-03-12 21:32:51 +01:00
committed by GitHub
parent c13bfad16f
commit 3fda41e501
5 changed files with 212 additions and 25 deletions
@@ -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");
});
});
+3 -3
View File
@@ -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;
+49 -6
View File
@@ -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(),
+19 -2
View File
@@ -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;