571d94bf7e
## Package Type Feature - Add 'blister' and 'bottle' package types for medications - Bottle type uses totalPills for capacity and looseTablets for current stock - Blister type continues to use packCount/blistersPerPack/pillsPerBlister - Add doseUnit field for flexible dosing (mg, ml, IU, etc.) - Full UI support in medication form and detail modal ## Per-Intake TakenBy - Move takenBy from medication level to individual intakes - Each intake schedule can now be assigned to a different person - Update scheduler-utils to handle per-intake takenBy - Update SharedSchedule to filter by per-intake takenBy - Backward compatible with existing medication data ## UI Improvements - Add PasswordInput component with show/hide toggle - Centralize stockThresholds in AppContext for consistent status display - Fix SharedSchedule sync issues with per-intake takenBy - Improve mobile editing experience ## Technical - Add migrations 0004 and 0005 for schema changes - Update all relevant tests (1064 tests passing) - Maintain backward compatibility with ALTER migrations
644 lines
21 KiB
TypeScript
644 lines
21 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
|
|
// Import actual utility functions from scheduler-utils
|
|
import {
|
|
type Blister,
|
|
calculateDailyUsage,
|
|
calculateDepletionInfo,
|
|
cleanOldIntakeReminders,
|
|
createDefaultIntakeReminderState,
|
|
createDefaultReminderState,
|
|
formatInTimezone,
|
|
getCurrentHourInTimezone,
|
|
getMsUntilNextCheck,
|
|
getNextScheduledTime,
|
|
getTimezone,
|
|
getTodayInTimezone,
|
|
getTodaysIntakes,
|
|
getUpcomingIntakes,
|
|
type Intake,
|
|
parseBlisters,
|
|
parseIntakeReminderState,
|
|
parseReminderState,
|
|
parseTakenByJson,
|
|
} from "../utils/scheduler-utils.js";
|
|
|
|
// Helper to convert Blister to Intake for tests
|
|
function blisterToIntake(blister: Blister, takenBy: string | null = null, intakeRemindersEnabled = false): Intake {
|
|
return {
|
|
usage: blister.usage,
|
|
every: blister.every,
|
|
start: blister.start,
|
|
takenBy,
|
|
intakeRemindersEnabled,
|
|
};
|
|
}
|
|
|
|
describe("Scheduler Utils - Timezone Functions", () => {
|
|
let originalTz: string | undefined;
|
|
|
|
beforeEach(() => {
|
|
originalTz = process.env.TZ;
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (originalTz !== undefined) {
|
|
process.env.TZ = originalTz;
|
|
} else {
|
|
delete process.env.TZ;
|
|
}
|
|
});
|
|
|
|
describe("getTimezone", () => {
|
|
it("should return TZ env variable when set", () => {
|
|
process.env.TZ = "America/New_York";
|
|
expect(getTimezone()).toBe("America/New_York");
|
|
});
|
|
|
|
it("should return UTC when TZ not set", () => {
|
|
delete process.env.TZ;
|
|
expect(getTimezone()).toBe("UTC");
|
|
});
|
|
|
|
it("should handle Europe/Berlin timezone", () => {
|
|
process.env.TZ = "Europe/Berlin";
|
|
expect(getTimezone()).toBe("Europe/Berlin");
|
|
});
|
|
});
|
|
|
|
describe("formatInTimezone", () => {
|
|
it("should format date in given timezone", () => {
|
|
const date = new Date("2025-12-30T12:00:00.000Z");
|
|
const formatted = formatInTimezone(date, "UTC");
|
|
expect(formatted).toContain("30");
|
|
expect(formatted).toContain("12");
|
|
});
|
|
|
|
it("should use process.env.TZ when no tz provided", () => {
|
|
process.env.TZ = "UTC";
|
|
const date = new Date("2025-12-30T15:30:00.000Z");
|
|
const formatted = formatInTimezone(date);
|
|
expect(formatted).toContain("15:30");
|
|
});
|
|
});
|
|
|
|
describe("getCurrentHourInTimezone", () => {
|
|
it("should return a valid hour (0-23)", () => {
|
|
process.env.TZ = "UTC";
|
|
const hour = getCurrentHourInTimezone();
|
|
expect(hour).toBeGreaterThanOrEqual(0);
|
|
expect(hour).toBeLessThanOrEqual(23);
|
|
});
|
|
|
|
it("should respect timezone parameter", () => {
|
|
const hourUtc = getCurrentHourInTimezone("UTC");
|
|
expect(hourUtc).toBeGreaterThanOrEqual(0);
|
|
expect(hourUtc).toBeLessThanOrEqual(23);
|
|
});
|
|
});
|
|
|
|
describe("getTodayInTimezone", () => {
|
|
it("should return date in YYYY-MM-DD format", () => {
|
|
process.env.TZ = "UTC";
|
|
const today = getTodayInTimezone();
|
|
expect(today).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
|
});
|
|
|
|
it("should return a valid date", () => {
|
|
process.env.TZ = "UTC";
|
|
const today = getTodayInTimezone();
|
|
const date = new Date(today);
|
|
expect(date.toString()).not.toBe("Invalid Date");
|
|
});
|
|
|
|
it("should respect timezone parameter", () => {
|
|
const today = getTodayInTimezone("UTC");
|
|
expect(today).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
|
});
|
|
});
|
|
|
|
describe("getNextScheduledTime", () => {
|
|
it("should return a Date object", () => {
|
|
const next = getNextScheduledTime(6, "UTC");
|
|
expect(next).toBeInstanceOf(Date);
|
|
});
|
|
|
|
it("should return a time in the future", () => {
|
|
// Use hour 0 to minimize chance of being exactly at that hour
|
|
const next = getNextScheduledTime(0, "UTC");
|
|
expect(next.getTime()).toBeGreaterThan(Date.now() - 60 * 60 * 1000); // Within 1 hour of now or future
|
|
});
|
|
|
|
it("should schedule for the given hour", () => {
|
|
const next = getNextScheduledTime(10, "UTC");
|
|
const hourInUtc = parseInt(next.toLocaleString("en-US", { timeZone: "UTC", hour: "numeric", hour12: false }), 10);
|
|
expect(hourInUtc).toBe(10);
|
|
});
|
|
});
|
|
|
|
describe("getMsUntilNextCheck", () => {
|
|
it("should return a positive number (or very small negative within tolerance)", () => {
|
|
const ms = getMsUntilNextCheck(6, "UTC");
|
|
// Could be slightly negative if we're right at the scheduled time
|
|
expect(ms).toBeGreaterThan(-60000);
|
|
});
|
|
|
|
it("should be less than or equal to 24 hours", () => {
|
|
const ms = getMsUntilNextCheck(6, "UTC");
|
|
const maxMs = 24 * 60 * 60 * 1000 + 60000; // 24h + 1min tolerance
|
|
expect(ms).toBeLessThanOrEqual(maxMs);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("Scheduler Utils - Blister Parsing", () => {
|
|
describe("parseBlisters", () => {
|
|
it("should parse valid blister JSON arrays", () => {
|
|
const row = {
|
|
usageJson: "[1, 2, 0.5]",
|
|
everyJson: "[1, 2, 7]",
|
|
startJson: '["2025-01-01T08:00", "2025-01-01T20:00", "2025-01-01T12:00"]',
|
|
};
|
|
|
|
const blisters = parseBlisters(row);
|
|
|
|
expect(blisters).toHaveLength(3);
|
|
expect(blisters[0]).toEqual({ usage: 1, every: 1, start: "2025-01-01T08:00" });
|
|
expect(blisters[1]).toEqual({ usage: 2, every: 2, start: "2025-01-01T20:00" });
|
|
expect(blisters[2]).toEqual({ usage: 0.5, every: 7, start: "2025-01-01T12:00" });
|
|
});
|
|
|
|
it("should handle arrays of different lengths (use shortest)", () => {
|
|
const row = {
|
|
usageJson: "[1, 2]",
|
|
everyJson: "[1]",
|
|
startJson: '["2025-01-01T08:00", "2025-01-01T20:00", "2025-01-01T12:00"]',
|
|
};
|
|
|
|
const blisters = parseBlisters(row);
|
|
|
|
expect(blisters).toHaveLength(1);
|
|
expect(blisters[0]).toEqual({ usage: 1, every: 1, start: "2025-01-01T08:00" });
|
|
});
|
|
|
|
it("should return empty array for empty JSON arrays", () => {
|
|
const row = {
|
|
usageJson: "[]",
|
|
everyJson: "[]",
|
|
startJson: "[]",
|
|
};
|
|
|
|
const blisters = parseBlisters(row);
|
|
expect(blisters).toHaveLength(0);
|
|
});
|
|
|
|
it("should return empty array for invalid JSON", () => {
|
|
const row = {
|
|
usageJson: "invalid",
|
|
everyJson: "[1]",
|
|
startJson: '["2025-01-01T08:00"]',
|
|
};
|
|
|
|
const blisters = parseBlisters(row);
|
|
expect(blisters).toHaveLength(0);
|
|
});
|
|
|
|
it("should return empty array for non-array JSON", () => {
|
|
const row = {
|
|
usageJson: '{"usage": 1}',
|
|
everyJson: "[1]",
|
|
startJson: '["2025-01-01T08:00"]',
|
|
};
|
|
|
|
const blisters = parseBlisters(row);
|
|
expect(blisters).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe("parseTakenByJson", () => {
|
|
it("should return empty array for null input", () => {
|
|
expect(parseTakenByJson(null)).toEqual([]);
|
|
});
|
|
|
|
it("should return empty array for undefined input", () => {
|
|
expect(parseTakenByJson(undefined)).toEqual([]);
|
|
});
|
|
|
|
it("should return empty array for empty string", () => {
|
|
expect(parseTakenByJson("")).toEqual([]);
|
|
});
|
|
|
|
it("should parse valid JSON array of strings", () => {
|
|
expect(parseTakenByJson('["Alice", "Bob"]')).toEqual(["Alice", "Bob"]);
|
|
});
|
|
|
|
it("should return empty array for empty JSON array", () => {
|
|
expect(parseTakenByJson("[]")).toEqual([]);
|
|
});
|
|
|
|
it("should filter out non-string values", () => {
|
|
expect(parseTakenByJson('[1, "Alice", null, "Bob", true]')).toEqual(["Alice", "Bob"]);
|
|
});
|
|
|
|
it("should filter out empty strings", () => {
|
|
expect(parseTakenByJson('["Alice", "", "Bob", " "]')).toEqual(["Alice", "Bob"]);
|
|
});
|
|
|
|
it("should return empty array for invalid JSON", () => {
|
|
expect(parseTakenByJson("invalid json")).toEqual([]);
|
|
});
|
|
|
|
it("should return empty array for non-array JSON", () => {
|
|
expect(parseTakenByJson('{"name": "Alice"}')).toEqual([]);
|
|
expect(parseTakenByJson('"Alice"')).toEqual([]);
|
|
expect(parseTakenByJson("123")).toEqual([]);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("Scheduler Utils - Daily Usage Calculation", () => {
|
|
describe("calculateDailyUsage", () => {
|
|
it("should calculate daily usage for single daily dose", () => {
|
|
const blisters: Blister[] = [{ usage: 1, every: 1, start: "2025-01-01T08:00" }];
|
|
expect(calculateDailyUsage(blisters)).toBe(1);
|
|
});
|
|
|
|
it("should calculate daily usage for twice daily dose", () => {
|
|
const blisters: Blister[] = [
|
|
{ usage: 1, every: 1, start: "2025-01-01T08:00" },
|
|
{ usage: 1, every: 1, start: "2025-01-01T20:00" },
|
|
];
|
|
expect(calculateDailyUsage(blisters)).toBe(2);
|
|
});
|
|
|
|
it("should calculate daily usage for weekly dose", () => {
|
|
const blisters: Blister[] = [{ usage: 1, every: 7, start: "2025-01-01T08:00" }];
|
|
expect(calculateDailyUsage(blisters)).toBeCloseTo(1 / 7, 5);
|
|
});
|
|
|
|
it("should calculate daily usage for mixed schedules", () => {
|
|
const blisters: Blister[] = [
|
|
{ usage: 2, every: 1, start: "2025-01-01T08:00" }, // 2 per day
|
|
{ usage: 1, every: 2, start: "2025-01-01T20:00" }, // 0.5 per day
|
|
];
|
|
expect(calculateDailyUsage(blisters)).toBe(2.5);
|
|
});
|
|
|
|
it("should return 0 for empty blisters", () => {
|
|
expect(calculateDailyUsage([])).toBe(0);
|
|
});
|
|
|
|
it("should handle fractional usage amounts", () => {
|
|
const blisters: Blister[] = [{ usage: 0.5, every: 1, start: "2025-01-01T08:00" }];
|
|
expect(calculateDailyUsage(blisters)).toBe(0.5);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("Scheduler Utils - Depletion Calculation", () => {
|
|
describe("calculateDepletionInfo", () => {
|
|
it("should calculate days left correctly", () => {
|
|
const blisters: Blister[] = [{ usage: 1, every: 1, start: "2025-01-01T08:00" }];
|
|
const result = calculateDepletionInfo({ count: 30, blisters }, "en");
|
|
expect(result.daysLeft).toBe(30);
|
|
expect(result.depletionDate).toBeTruthy();
|
|
});
|
|
|
|
it("should calculate days left with multiple doses per day", () => {
|
|
const blisters: Blister[] = [
|
|
{ usage: 1, every: 1, start: "2025-01-01T08:00" },
|
|
{ usage: 1, every: 1, start: "2025-01-01T20:00" },
|
|
];
|
|
const result = calculateDepletionInfo({ count: 30, blisters }, "en");
|
|
expect(result.daysLeft).toBe(15);
|
|
});
|
|
|
|
it("should return null when no blisters configured", () => {
|
|
const result = calculateDepletionInfo({ count: 30, blisters: [] }, "en");
|
|
expect(result.daysLeft).toBeNull();
|
|
expect(result.depletionDate).toBeNull();
|
|
});
|
|
|
|
it("should return null when usage is zero", () => {
|
|
const blisters: Blister[] = [{ usage: 0, every: 1, start: "2025-01-01T08:00" }];
|
|
const result = calculateDepletionInfo({ count: 30, blisters }, "en");
|
|
expect(result.daysLeft).toBeNull();
|
|
});
|
|
|
|
it("should floor the days left", () => {
|
|
// 10 pills / 3 per day = 3.33... days -> floors to 3
|
|
const blisters: Blister[] = [{ usage: 3, every: 1, start: "2025-01-01T08:00" }];
|
|
const result = calculateDepletionInfo({ count: 10, blisters }, "en");
|
|
expect(result.daysLeft).toBe(3);
|
|
});
|
|
|
|
it("should handle German language", () => {
|
|
const blisters: Blister[] = [{ usage: 1, every: 1, start: "2025-01-01T08:00" }];
|
|
const result = calculateDepletionInfo({ count: 10, blisters }, "de");
|
|
expect(result.depletionDate).toBeTruthy();
|
|
// German locale should be used
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("Scheduler Utils - Upcoming Intakes", () => {
|
|
describe("getUpcomingIntakes", () => {
|
|
it("should return empty array when no intakes in window", () => {
|
|
// With parseLocalDateTime, times are treated as local - use same format for consistency
|
|
const intakes: Intake[] = [blisterToIntake({ usage: 1, every: 1, start: "2025-01-01T08:00:00" })];
|
|
// Set "now" to a time far from any scheduled intake (12:00 local)
|
|
const now = new Date(2025, 0, 1, 12, 0, 0).getTime();
|
|
|
|
const result = getUpcomingIntakes("TestMed", intakes, 15, [], null, "en-US", "UTC", now);
|
|
expect(result).toEqual([]);
|
|
});
|
|
|
|
it("should find intake within reminder window", () => {
|
|
// Schedule intake at 08:00 local, check at 07:45 local (15 minutes before)
|
|
const intakes: Intake[] = [blisterToIntake({ usage: 2, every: 1, start: "2025-01-01T08:00:00" }, "Alice")];
|
|
const now = new Date(2025, 0, 1, 7, 45, 0).getTime();
|
|
|
|
const result = getUpcomingIntakes("TestMed", intakes, 15, [], 500, "en-US", "UTC", now);
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].medName).toBe("TestMed");
|
|
expect(result[0].usage).toBe(2);
|
|
expect(result[0].takenBy).toBe("Alice");
|
|
expect(result[0].pillWeightMg).toBe(500);
|
|
});
|
|
|
|
it("should skip blisters with zero interval", () => {
|
|
const intakes: Intake[] = [blisterToIntake({ usage: 1, every: 0, start: "2025-01-01T08:00:00" })];
|
|
const now = new Date(2025, 0, 1, 7, 45, 0).getTime();
|
|
|
|
const result = getUpcomingIntakes("TestMed", intakes, 15, [], null, "en-US", "UTC", now);
|
|
expect(result).toEqual([]);
|
|
});
|
|
|
|
it("should handle multiple blisters", () => {
|
|
// Two intakes at 08:00 and 08:01 local
|
|
const intakes: Intake[] = [
|
|
blisterToIntake({ usage: 1, every: 1, start: "2025-01-01T08:00:00" }),
|
|
blisterToIntake({ usage: 2, every: 1, start: "2025-01-01T08:01:00" }),
|
|
];
|
|
const now = new Date(2025, 0, 1, 7, 45, 0).getTime();
|
|
|
|
const result = getUpcomingIntakes("TestMed", intakes, 15, [], null, "en-US", "UTC", now);
|
|
|
|
// Both should be found as they're within the window
|
|
expect(result.length).toBeGreaterThanOrEqual(1);
|
|
});
|
|
});
|
|
|
|
describe("getTodaysIntakes", () => {
|
|
it("should return all intakes for today", () => {
|
|
// Daily medication at 08:00 starting yesterday
|
|
// With parseLocalDateTime, "08:00:00.000Z" is treated as 08:00 local time
|
|
const intakes: Intake[] = [blisterToIntake({ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" })];
|
|
|
|
// Get intakes for today (today's intake should be at 08:00 local)
|
|
const result = getTodaysIntakes("TestMed", intakes, [], null, "en-US", "UTC");
|
|
|
|
expect(result.length).toBeGreaterThanOrEqual(1);
|
|
const intake = result.find((i) => i.intakeTime.getHours() === 8);
|
|
expect(intake).toBeDefined();
|
|
expect(intake?.medName).toBe("TestMed");
|
|
expect(intake?.usage).toBe(1);
|
|
});
|
|
|
|
it("should include past intakes from today", () => {
|
|
// Medication at 00:01 today (definitely in the past)
|
|
const todayMidnight = new Date();
|
|
todayMidnight.setUTCHours(0, 1, 0, 0);
|
|
|
|
const intakes: Intake[] = [
|
|
blisterToIntake(
|
|
{
|
|
usage: 2,
|
|
every: 1,
|
|
start: todayMidnight.toISOString(),
|
|
},
|
|
"Bob"
|
|
),
|
|
];
|
|
|
|
const result = getTodaysIntakes("PastMed", intakes, [], 250, "en-US", "UTC");
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].medName).toBe("PastMed");
|
|
expect(result[0].usage).toBe(2);
|
|
expect(result[0].takenBy).toBe("Bob");
|
|
expect(result[0].pillWeightMg).toBe(250);
|
|
});
|
|
|
|
it("should handle multiple intakes per day", () => {
|
|
// Two intakes today: morning and evening
|
|
const today = new Date();
|
|
const morning = new Date(today);
|
|
morning.setUTCHours(8, 0, 0, 0);
|
|
const evening = new Date(today);
|
|
evening.setUTCHours(20, 0, 0, 0);
|
|
|
|
const intakes: Intake[] = [
|
|
blisterToIntake({ usage: 1, every: 1, start: morning.toISOString() }),
|
|
blisterToIntake({ usage: 1, every: 1, start: evening.toISOString() }),
|
|
];
|
|
|
|
const result = getTodaysIntakes("MultiMed", intakes, [], null, "en-US", "UTC");
|
|
|
|
expect(result.length).toBeGreaterThanOrEqual(2);
|
|
});
|
|
|
|
it("should not include intakes from other days", () => {
|
|
// Weekly medication on a different day of week
|
|
const lastWeek = new Date();
|
|
lastWeek.setDate(lastWeek.getDate() - 7);
|
|
|
|
const intakes: Intake[] = [
|
|
blisterToIntake({
|
|
usage: 1,
|
|
every: 7,
|
|
start: lastWeek.toISOString(),
|
|
}),
|
|
];
|
|
|
|
// If today is not the same day of week, should return empty
|
|
const result = getTodaysIntakes("WeeklyMed", intakes, [], null, "en-US", "UTC");
|
|
|
|
// This test might return 0 or 1 depending on the day
|
|
expect(Array.isArray(result)).toBe(true);
|
|
});
|
|
|
|
it("should handle local time correctly (ignore Z suffix)", () => {
|
|
// With parseLocalDateTime, the Z suffix is ignored and time is treated as local server time
|
|
// The intakeTimeStr is then formatted for the target timezone (Europe/Berlin)
|
|
// So if server is in UTC, 14:00 server time becomes 15:00 Europe/Berlin time
|
|
const intakes: Intake[] = [
|
|
blisterToIntake({
|
|
usage: 1,
|
|
every: 1,
|
|
start: "2025-01-01T14:00:00.000Z", // Treated as 14:00 server local time
|
|
}),
|
|
];
|
|
|
|
const result = getTodaysIntakes("TzMed", intakes, [], null, "de-DE", "Europe/Berlin");
|
|
|
|
expect(Array.isArray(result)).toBe(true);
|
|
if (result.length > 0) {
|
|
// The intakeTimeStr should be a valid time format (HH:MM)
|
|
// Exact value depends on server timezone vs target timezone offset
|
|
expect(result[0].intakeTimeStr).toMatch(/^\d{2}:\d{2}$/);
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("Scheduler Utils - State Management", () => {
|
|
describe("createDefaultReminderState", () => {
|
|
it("should create default reminder state", () => {
|
|
const state = createDefaultReminderState();
|
|
expect(state.lastAutoEmailSent).toBeNull();
|
|
expect(state.lastAutoEmailDate).toBeNull();
|
|
expect(state.notifiedMedications).toEqual([]);
|
|
expect(state.nextScheduledCheck).toBeNull();
|
|
expect(state.lastNotificationType).toBeNull();
|
|
expect(state.lastNotificationChannel).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("createDefaultIntakeReminderState", () => {
|
|
it("should create default intake reminder state", () => {
|
|
const state = createDefaultIntakeReminderState();
|
|
expect(state.reminders).toEqual({});
|
|
});
|
|
});
|
|
|
|
describe("parseReminderState", () => {
|
|
it("should parse valid JSON", () => {
|
|
const json = JSON.stringify({
|
|
lastAutoEmailSent: "2025-12-30T10:00:00.000Z",
|
|
lastAutoEmailDate: "2025-12-30",
|
|
notifiedMedications: ["med1", "med2"],
|
|
nextScheduledCheck: "2025-12-31T06:00:00.000Z",
|
|
lastNotificationType: "stock",
|
|
lastNotificationChannel: "email",
|
|
});
|
|
|
|
const state = parseReminderState(json);
|
|
expect(state.lastAutoEmailSent).toBe("2025-12-30T10:00:00.000Z");
|
|
expect(state.lastAutoEmailDate).toBe("2025-12-30");
|
|
expect(state.notifiedMedications).toEqual(["med1", "med2"]);
|
|
expect(state.lastNotificationType).toBe("stock");
|
|
expect(state.lastNotificationChannel).toBe("email");
|
|
});
|
|
|
|
it("should handle partial state with defaults", () => {
|
|
const json = JSON.stringify({ lastAutoEmailSent: "2025-12-30T10:00:00.000Z" });
|
|
|
|
const state = parseReminderState(json);
|
|
expect(state.lastAutoEmailSent).toBe("2025-12-30T10:00:00.000Z");
|
|
expect(state.lastAutoEmailDate).toBeNull();
|
|
expect(state.notifiedMedications).toEqual([]);
|
|
});
|
|
|
|
it("should return defaults for invalid JSON", () => {
|
|
const state = parseReminderState("invalid json {{{");
|
|
expect(state.lastAutoEmailSent).toBeNull();
|
|
expect(state.notifiedMedications).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("parseIntakeReminderState", () => {
|
|
it("should parse valid new format JSON", () => {
|
|
const json = JSON.stringify({
|
|
reminders: {
|
|
"med1:123": { firstSentAt: 1000, lastSentAt: 2000, sendCount: 2 },
|
|
"med2:456": { firstSentAt: 3000, lastSentAt: 3000, sendCount: 1 },
|
|
},
|
|
});
|
|
|
|
const state = parseIntakeReminderState(json);
|
|
expect(Object.keys(state.reminders)).toHaveLength(2);
|
|
expect(state.reminders["med1:123"].sendCount).toBe(2);
|
|
});
|
|
|
|
it("should convert old array format to new format", () => {
|
|
const json = JSON.stringify({ sentReminders: ["med1:123", "med2:456"] });
|
|
|
|
const state = parseIntakeReminderState(json);
|
|
expect(Object.keys(state.reminders)).toHaveLength(2);
|
|
expect(state.reminders["med1:123"]).toBeDefined();
|
|
expect(state.reminders["med1:123"].sendCount).toBe(1);
|
|
});
|
|
|
|
it("should return defaults for invalid JSON", () => {
|
|
const state = parseIntakeReminderState("invalid");
|
|
expect(state.reminders).toEqual({});
|
|
});
|
|
|
|
it("should handle missing reminders field", () => {
|
|
const state = parseIntakeReminderState("{}");
|
|
expect(state.reminders).toEqual({});
|
|
});
|
|
});
|
|
|
|
describe("cleanOldIntakeReminders", () => {
|
|
it("should remove entries from past days (timezone-aware)", () => {
|
|
const tz = "Europe/Berlin";
|
|
const now = new Date();
|
|
const today = new Date(now.toLocaleString("en-US", { timeZone: tz }));
|
|
today.setHours(12, 0, 0, 0);
|
|
|
|
const yesterday = new Date(today);
|
|
yesterday.setDate(yesterday.getDate() - 1);
|
|
|
|
const reminders = {
|
|
[`med1:${yesterday.getTime()}`]: {
|
|
firstSentAt: yesterday.getTime(),
|
|
lastSentAt: yesterday.getTime(),
|
|
sendCount: 1,
|
|
},
|
|
[`med2:${today.getTime()}`]: { firstSentAt: today.getTime(), lastSentAt: today.getTime(), sendCount: 1 },
|
|
};
|
|
|
|
const cleaned = cleanOldIntakeReminders(reminders, tz);
|
|
|
|
expect(Object.keys(cleaned)).toHaveLength(1);
|
|
expect(cleaned[`med2:${today.getTime()}`]).toBeDefined();
|
|
});
|
|
|
|
it("should keep all entries from today", () => {
|
|
const tz = "Europe/Berlin";
|
|
const now = new Date();
|
|
const morning = new Date(now.toLocaleString("en-US", { timeZone: tz }));
|
|
morning.setHours(8, 0, 0, 0);
|
|
|
|
const evening = new Date(now.toLocaleString("en-US", { timeZone: tz }));
|
|
evening.setHours(20, 0, 0, 0);
|
|
|
|
const reminders = {
|
|
[`med1:${morning.getTime()}`]: { firstSentAt: morning.getTime(), lastSentAt: morning.getTime(), sendCount: 1 },
|
|
[`med2:${evening.getTime()}`]: { firstSentAt: evening.getTime(), lastSentAt: evening.getTime(), sendCount: 1 },
|
|
};
|
|
|
|
const cleaned = cleanOldIntakeReminders(reminders, tz);
|
|
expect(Object.keys(cleaned)).toHaveLength(2);
|
|
});
|
|
|
|
it("should handle empty reminders", () => {
|
|
const cleaned = cleanOldIntakeReminders({}, "Europe/Berlin");
|
|
expect(cleaned).toEqual({});
|
|
});
|
|
|
|
it("should handle malformed entries (invalid timestamp in key)", () => {
|
|
const reminders = {
|
|
"med1:invalid": { firstSentAt: 1000, lastSentAt: 1000, sendCount: 1 },
|
|
"med2:notanumber": { firstSentAt: 2000, lastSentAt: 2000, sendCount: 1 },
|
|
};
|
|
const cleaned = cleanOldIntakeReminders(reminders, "Europe/Berlin");
|
|
// NaN from parseInt will cause these to be filtered out (invalid < todayStart)
|
|
expect(Object.keys(cleaned)).toHaveLength(0);
|
|
});
|
|
});
|
|
});
|