/** * Shared utility functions for scheduler services. * Exported separately to allow testing without side effects. */ import { getDateLocale, type Language } from "../i18n/translations.js"; import { isLiquidContainerPackageType, isTubePackageType } from "./package-profiles.js"; export const CANONICAL_WEEKDAY_ORDER = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] as const; export type Weekday = (typeof CANONICAL_WEEKDAY_ORDER)[number]; export type IntakeScheduleMode = "interval" | "weekdays"; type ScheduleLike = { every: number; start: string; scheduleMode?: IntakeScheduleMode; weekdays?: Weekday[]; }; // Legacy type - individual blister schedule (DEPRECATED: use Intake instead) export type Blister = { usage: number; every: number; start: string; scheduleMode?: IntakeScheduleMode; weekdays?: Weekday[]; }; // New unified intake type with per-intake takenBy export type Intake = { usage: number; every: number; start: string; scheduleMode?: IntakeScheduleMode; weekdays?: Weekday[]; intakeUnit?: "ml" | "tsp" | "tbsp" | null; takenBy: string | null; // Person taking this specific intake (null = use medication-level takenBy) intakeRemindersEnabled: boolean; }; const isValidIntakeUnit = (value: unknown): value is "ml" | "tsp" | "tbsp" => value === "ml" || value === "tsp" || value === "tbsp"; const weekdayToJavascriptDay: Record = { mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6, sun: 0, }; function isWeekday(value: unknown): value is Weekday { return typeof value === "string" && CANONICAL_WEEKDAY_ORDER.includes(value as Weekday); } function normalizeScheduleMode(value: unknown): IntakeScheduleMode { return value === "weekdays" ? "weekdays" : "interval"; } function toDateOnly(date: Date): Date { return new Date(date.getFullYear(), date.getMonth(), date.getDate(), 0, 0, 0, 0); } function getLocalDateOrdinal(date: Date): number { return Math.floor(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) / 86_400_000); } function addLocalCalendarDays(date: Date, days: number): Date { const next = new Date(date); next.setDate(next.getDate() + days); return next; } export function getDateOnlyTimestamp(date: Date): number { return toDateOnly(date).getTime(); } export function getWeekdayFromDate(date: Date): Weekday { const weekday = CANONICAL_WEEKDAY_ORDER.find((entry) => weekdayToJavascriptDay[entry] === date.getDay()); return weekday ?? "mon"; } export function getWeekdayFromStart(start: string): Weekday { const startDate = parseLocalDateTime(start); if (Number.isNaN(startDate.getTime())) { return "mon"; } return getWeekdayFromDate(startDate); } export function normalizeWeekdays(value: unknown, start: string): Weekday[] { if (!Array.isArray(value)) { return [getWeekdayFromStart(start)]; } const uniqueWeekdays = new Set(); for (const weekday of value) { if (isWeekday(weekday)) { uniqueWeekdays.add(weekday); } } const normalized = CANONICAL_WEEKDAY_ORDER.filter((weekday) => uniqueWeekdays.has(weekday)); return normalized.length > 0 ? normalized : [getWeekdayFromStart(start)]; } function createOccurrenceAtDate(date: Date, startDate: Date): number { return new Date( date.getFullYear(), date.getMonth(), date.getDate(), startDate.getHours(), startDate.getMinutes(), startDate.getSeconds(), startDate.getMilliseconds() ).getTime(); } function getNormalizedWeekdays(schedule: ScheduleLike): Weekday[] { if (schedule.scheduleMode !== "weekdays") { return []; } if (schedule.weekdays && schedule.weekdays.length > 0) { return schedule.weekdays; } return [getWeekdayFromStart(schedule.start)]; } export function getAverageOccurrencesPerDay( schedule: Pick ): number { if (schedule.scheduleMode === "weekdays") { return getNormalizedWeekdays(schedule).length / 7; } return 1 / Math.max(1, schedule.every); } export function getMaxScheduledGapDays( schedule: Pick ): number { if (schedule.scheduleMode !== "weekdays") { return Math.max(1, schedule.every); } const weekdays = getNormalizedWeekdays(schedule).map((weekday) => CANONICAL_WEEKDAY_ORDER.indexOf(weekday)); if (weekdays.length === 0) { return 7; } let maxGap = 0; for (let index = 0; index < weekdays.length; index++) { const current = weekdays[index]; const next = weekdays[(index + 1) % weekdays.length]; const gap = index === weekdays.length - 1 ? next + 7 - current : next - current; if (gap > maxGap) { maxGap = gap; } } return maxGap || 7; } export function getScheduleMatchWindowMs( schedule: Pick ): number { return (getMaxScheduledGapDays(schedule) * 86_400_000) / 2; } export function getNextScheduledOccurrenceTime( schedule: Pick, fromMs: number, inclusive: boolean = true ): number | null { const startDate = parseLocalDateTime(schedule.start); const startTime = startDate.getTime(); if (Number.isNaN(startTime)) { return null; } const lowerBound = inclusive ? fromMs : fromMs + 1; if (schedule.scheduleMode !== "weekdays") { const intervalDays = Math.max(1, schedule.every); if (startTime >= lowerBound) { return startTime; } const lowerBoundDate = new Date(lowerBound); const startOrdinal = getLocalDateOrdinal(startDate); const lowerBoundOrdinal = getLocalDateOrdinal(lowerBoundDate); const daysBetween = Math.max(0, lowerBoundOrdinal - startOrdinal); const wholeIntervals = Math.floor(daysBetween / intervalDays); let candidate = addLocalCalendarDays(startDate, wholeIntervals * intervalDays); while (candidate.getTime() < lowerBound) { candidate = addLocalCalendarDays(candidate, intervalDays); } return candidate.getTime(); } const candidateStart = Math.max(lowerBound, startTime); const candidateDateOnly = toDateOnly(new Date(candidateStart)); let nextOccurrence: number | null = null; for (const weekday of getNormalizedWeekdays(schedule)) { const candidateDate = new Date(candidateDateOnly); const offsetDays = (weekdayToJavascriptDay[weekday] - candidateDate.getDay() + 7) % 7; candidateDate.setDate(candidateDate.getDate() + offsetDays); let occurrenceMs = createOccurrenceAtDate(candidateDate, startDate); if (occurrenceMs < candidateStart) { candidateDate.setDate(candidateDate.getDate() + 7); occurrenceMs = createOccurrenceAtDate(candidateDate, startDate); } if (nextOccurrence === null || occurrenceMs < nextOccurrence) { nextOccurrence = occurrenceMs; } } return nextOccurrence; } export function forEachScheduledOccurrenceInRange( schedule: Pick, rangeStartMs: number, rangeEndMs: number, callback: (occurrenceMs: number) => void ): void { if (!Number.isFinite(rangeStartMs) || !Number.isFinite(rangeEndMs) || rangeEndMs < rangeStartMs) { return; } const startDate = parseLocalDateTime(schedule.start); const startTime = startDate.getTime(); if (Number.isNaN(startTime) || rangeEndMs < startTime) { return; } if (schedule.scheduleMode !== "weekdays") { const intervalDays = Math.max(1, schedule.every); let occurrence = new Date(startDate); if (occurrence.getTime() < rangeStartMs) { const rangeStartDate = new Date(rangeStartMs); const startOrdinal = getLocalDateOrdinal(startDate); const rangeStartOrdinal = getLocalDateOrdinal(rangeStartDate); const daysBetween = Math.max(0, rangeStartOrdinal - startOrdinal); const wholeIntervals = Math.floor(daysBetween / intervalDays); occurrence = addLocalCalendarDays(startDate, wholeIntervals * intervalDays); while (occurrence.getTime() < rangeStartMs) { occurrence = addLocalCalendarDays(occurrence, intervalDays); } } for (let occurrenceMs = occurrence.getTime(); occurrenceMs <= rangeEndMs; ) { if (occurrenceMs >= rangeStartMs) { callback(occurrenceMs); } occurrence = addLocalCalendarDays(occurrence, intervalDays); occurrenceMs = occurrence.getTime(); } return; } const lowerBound = Math.max(rangeStartMs, startTime); const firstDateOnly = toDateOnly(new Date(lowerBound)); for (const weekday of getNormalizedWeekdays(schedule)) { const occurrenceDate = new Date(firstDateOnly); const offsetDays = (weekdayToJavascriptDay[weekday] - occurrenceDate.getDay() + 7) % 7; occurrenceDate.setDate(occurrenceDate.getDate() + offsetDays); let occurrenceMs = createOccurrenceAtDate(occurrenceDate, startDate); if (occurrenceMs < lowerBound) { occurrenceDate.setDate(occurrenceDate.getDate() + 7); occurrenceMs = createOccurrenceAtDate(occurrenceDate, startDate); } while (occurrenceMs <= rangeEndMs) { callback(occurrenceMs); occurrenceDate.setDate(occurrenceDate.getDate() + 7); occurrenceMs = createOccurrenceAtDate(occurrenceDate, startDate); } } } export function countScheduledOccurrencesInRange( schedule: Pick, rangeStartMs: number, rangeEndMs: number ): { count: number; lastOccurrenceMs: number | null } { let count = 0; let lastOccurrenceMs: number | null = null; forEachScheduledOccurrenceInRange(schedule, rangeStartMs, rangeEndMs, (occurrenceMs) => { count += 1; if (lastOccurrenceMs === null || occurrenceMs > lastOccurrenceMs) { lastOccurrenceMs = occurrenceMs; } }); return { count, lastOccurrenceMs }; } export function normalizeIntake( value: { usage?: unknown; every?: unknown; start?: unknown; scheduleMode?: unknown; weekdays?: unknown; intakeUnit?: unknown; takenBy?: unknown; intakeRemindersEnabled?: unknown; }, defaultIntakeRemindersEnabled: boolean = false ): Intake { const start = typeof value.start === "string" ? value.start : new Date().toISOString(); const scheduleMode = normalizeScheduleMode(value.scheduleMode); let every = 1; if (scheduleMode !== "weekdays") { if (typeof value.every === "number" && Number.isFinite(value.every) && value.every >= 1) { every = value.every; } } return { usage: typeof value.usage === "number" && Number.isFinite(value.usage) ? value.usage : 0, every, start, scheduleMode, weekdays: scheduleMode === "weekdays" ? normalizeWeekdays(value.weekdays, start) : [], intakeUnit: isValidIntakeUnit(value.intakeUnit) ? value.intakeUnit : null, takenBy: typeof value.takenBy === "string" && value.takenBy.trim() ? value.takenBy.trim() : null, intakeRemindersEnabled: typeof value.intakeRemindersEnabled === "boolean" ? value.intakeRemindersEnabled : defaultIntakeRemindersEnabled, }; } /** * Normalize intake usage for stock math. * * Stock semantics: * - tube: no automatic depletion (unknown per-application amount) * - liquid_container/liquid forms: convert tsp/tbsp to ml * - others: usage as-is */ export function normalizeIntakeUsageForStock( intake: Pick, medicationForm?: string | null, packageType?: string | null ): number { const usage = Number(intake.usage); if (!Number.isFinite(usage) || usage <= 0) return 0; if (isTubePackageType(packageType)) return 0; const isLiquidStock = isLiquidContainerPackageType(packageType) || medicationForm === "liquid"; if (!isLiquidStock) return usage; if (intake.intakeUnit === "tsp") return usage * 5; if (intake.intakeUnit === "tbsp") return usage * 15; return usage; } // ============================================================================= // Timezone utilities // ============================================================================= /** Get current timezone from TZ env variable or default to UTC */ export function getTimezone(): string { return process.env.TZ || "UTC"; } export function isValidTimezone(value: string): boolean { try { new Intl.DateTimeFormat("en-US", { timeZone: value }); return true; } catch { return false; } } export function getEffectiveTimezone(override?: string | null): string { const normalized = override?.trim() ?? ""; if (normalized && isValidTimezone(normalized)) { return normalized; } return getTimezone(); } /** Format a date in the configured timezone */ export function formatInTimezone(date: Date, tz?: string): string { return date.toLocaleString("de-DE", { timeZone: tz ?? getTimezone(), day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit", }); } /** Get current hour in the configured timezone */ export function getCurrentHourInTimezone(tz?: string): number { const now = new Date(); const timeStr = now.toLocaleString("en-US", { timeZone: tz ?? getTimezone(), hour: "numeric", hour12: false, }); return parseInt(timeStr, 10); } /** Get today's date string in the configured timezone (YYYY-MM-DD) */ export function getTodayInTimezone(tz?: string): string { const now = new Date(); const parts = now.toLocaleDateString("en-CA", { timeZone: tz ?? getTimezone() }).split("-"); return parts.join("-"); // YYYY-MM-DD format } /** Calculate the next scheduled time for a given reminder hour */ export function getNextScheduledTime(reminderHour: number, tz?: string): Date { const now = new Date(); const timezone = tz ?? getTimezone(); // Get current time components in the target timezone const formatter = new Intl.DateTimeFormat("en-US", { timeZone: timezone, year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false, }); const parts = formatter.formatToParts(now); const getPart = (type: string) => parts.find((p) => p.type === type)?.value || "0"; const currentHour = parseInt(getPart("hour"), 10); const currentMinute = parseInt(getPart("minute"), 10); // Calculate if we need tomorrow const needTomorrow = currentHour > reminderHour || (currentHour === reminderHour && currentMinute > 0); // Handle month overflow simply by adding a day to now if needed let targetDate: Date; if (needTomorrow) { targetDate = new Date(now.getTime() + 24 * 60 * 60 * 1000); } else { targetDate = new Date(now); } // Get the target date's date string in the timezone const targetFormatter = new Intl.DateTimeFormat("en-CA", { timeZone: timezone, year: "numeric", month: "2-digit", day: "2-digit", }); const [targetYear, targetMonth, targetDay] = targetFormatter.format(targetDate).split("-").map(Number); // Now we need to find the UTC time that corresponds to reminderHour:00 on targetDate in the target timezone // Use a search approach: start with a guess and adjust const guessUtc = new Date(Date.UTC(targetYear, targetMonth - 1, targetDay, reminderHour, 0, 0, 0)); // Check what hour this UTC time corresponds to in the target timezone const checkFormatter = new Intl.DateTimeFormat("en-US", { timeZone: timezone, hour: "2-digit", hour12: false, }); // Adjust based on the difference const guessHour = parseInt(checkFormatter.format(guessUtc), 10); const hourDiff = guessHour - reminderHour; // Apply correction (if guessHour is higher, we need to subtract time) const correctedUtc = new Date(guessUtc.getTime() - hourDiff * 60 * 60 * 1000); return correctedUtc; } /** Calculate milliseconds until next check at the given reminder hour */ export function getMsUntilNextCheck(reminderHour: number, tz?: string): number { const next = getNextScheduledTime(reminderHour, tz); const msUntilNext = next.getTime() - Date.now(); if (msUntilNext <= 0) { return msUntilNext + 24 * 60 * 60 * 1000; } return msUntilNext; } // ============================================================================= // Blister/medication parsing utilities // ============================================================================= /** * Parse an ISO datetime string to local timestamp. * Extracts date/time components directly from the string to avoid * timezone conversion issues with Z suffix. * * "2026-01-23T20:55:00" → treated as local time 20:55 * "2026-01-23T20:55:00.000Z" → also treated as local time 20:55 (Z ignored) */ export function parseLocalDateTime(isoString: string): Date { // Extract components: YYYY-MM-DDTHH:MM:SS (ignore Z and milliseconds) const match = isoString.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):?(\d{2})?/); if (!match) { // Fallback to Date parsing if format doesn't match return new Date(isoString); } const [, year, month, day, hour, minute, second] = match; // Create date using local time interpretation (no UTC conversion) return new Date( parseInt(year, 10), parseInt(month, 10) - 1, // Month is 0-indexed parseInt(day, 10), parseInt(hour, 10), parseInt(minute, 10), parseInt(second ?? "0", 10) ); } /** Parse blister schedules from JSON columns (DEPRECATED: use parseIntakesJson instead) */ export function parseBlisters(row: { usageJson: string; everyJson: string; startJson: string }): Blister[] { try { const usage = JSON.parse(row.usageJson) as number[]; const every = JSON.parse(row.everyJson) as number[]; const start = JSON.parse(row.startJson) as string[]; const len = Math.min(usage.length, every.length, start.length); const blisters: Blister[] = []; for (let i = 0; i < len; i++) { blisters.push({ usage: usage[i], every: every[i], start: start[i] }); } return blisters; } catch { return []; } } /** * Parse intakes from the new unified intakesJson format. * Falls back to legacy parallel arrays if intakesJson is empty. * @param intakesJson - The new unified JSON string * @param legacyRow - Optional legacy row with usageJson, everyJson, startJson for fallback * @param medicationIntakeRemindersEnabled - Medication-level intakeRemindersEnabled (fallback for legacy) */ export function parseIntakesJson( intakesJson: string | null | undefined, legacyRow?: { usageJson: string; everyJson: string; startJson: string }, medicationIntakeRemindersEnabled?: boolean ): Intake[] { // Try new format first if (intakesJson) { try { const parsed = JSON.parse(intakesJson); if (Array.isArray(parsed) && parsed.length > 0) { return parsed.map((intake: Record) => normalizeIntake(intake)); } } catch { // Fall through to legacy parsing } } // Fallback to legacy parallel arrays if (legacyRow) { const blisters = parseBlisters(legacyRow); return blisters.map((b) => normalizeIntake( { usage: b.usage, every: b.every, start: b.start, intakeUnit: null, takenBy: null, }, medicationIntakeRemindersEnabled ?? false ) ); } return []; } /** * Convert intakes to legacy blister format (for backward compatibility) */ export function intakesToBlisters(intakes: Intake[]): Blister[] { return intakes.map((i) => ({ usage: i.usage, every: i.every, start: i.start })); } /** Parse takenByJson to array of strings */ export function parseTakenByJson(takenByJson: string | null | undefined): string[] { if (!takenByJson) return []; try { const parsed = JSON.parse(takenByJson); return Array.isArray(parsed) ? parsed.filter((s: unknown) => typeof s === "string" && s.trim()) : []; } catch { return []; } } /** * Get all unique takenBy values from both medication-level and intake-level. * Used for filtering and sharing functionality. */ export function getAllTakenByForMedication(medicationTakenBy: string[], intakes: Intake[]): string[] { const allPeople = new Set(medicationTakenBy); for (const intake of intakes) { if (intake.takenBy) { allPeople.add(intake.takenBy); } } return Array.from(allPeople); } /** * Check if a person takes this medication (either via medication-level or intake-level takenBy). */ export function personTakesMedication(person: string, medicationTakenBy: string[], intakes: Intake[]): boolean { if (person === "all") return medicationTakenBy.length > 0 || intakes.some((intake) => intake.takenBy !== null); if (medicationTakenBy.includes(person)) return true; return intakes.some((intake) => intake.takenBy === person); } // ============================================================================= // Stock calculation utilities // ============================================================================= /** Calculate daily usage from blisters */ export function calculateDailyUsage(blisters: Blister[]): number { return blisters.reduce((sum, blister) => sum + blister.usage * getAverageOccurrencesPerDay(blister), 0); } /** Calculate depletion information for a medication */ export function calculateDepletionInfo( med: { count: number; blisters: Blister[] }, language: Language ): { daysLeft: number | null; depletionDate: string | null } { const dailyUsage = calculateDailyUsage(med.blisters); if (dailyUsage <= 0) return { daysLeft: null, depletionDate: null }; const daysLeft = Math.floor(med.count / dailyUsage); const depletionMs = Date.now() + daysLeft * 86_400_000; const depletionDate = new Date(depletionMs).toLocaleDateString(getDateLocale(language), { weekday: "short", day: "2-digit", month: "short", }); return { daysLeft, depletionDate }; } // ============================================================================= // Intake reminder utilities // ============================================================================= export type UpcomingIntake = { medName: string; medicationId?: number; blisterIndex?: number; usage: number; intakeTime: Date; intakeTimeStr: string; takenBy: string | null; // Single person for this intake (null = no specific person) pillWeightMg: number | null; doseUnit?: string; }; /** * Get all intakes for today (past and future) - used for repeat reminders. * Returns all intakes scheduled for today in user's timezone. * Now uses per-intake takenBy instead of medication-level. */ export function getTodaysIntakes( medName: string, intakes: Intake[], _medicationTakenBy: string[], // Medication-level takenBy as fallback pillWeightMg: number | null, locale: string, tz?: string, medicationId?: number, doseUnit?: string ): UpcomingIntake[] { const timezone = tz ?? getTimezone(); const now = new Date(); // Get start and end of today in user's timezone const todayStart = new Date(now.toLocaleString("en-US", { timeZone: timezone })); todayStart.setHours(0, 0, 0, 0); const todayEnd = new Date(now.toLocaleString("en-US", { timeZone: timezone })); todayEnd.setHours(23, 59, 59, 999); const result: UpcomingIntake[] = []; for (let blisterIdx = 0; blisterIdx < intakes.length; blisterIdx++) { const intake = intakes[blisterIdx]; // Determine takenBy for this intake // If intake has its own takenBy, use it; otherwise null (no specific person) const effectiveTakenBy = intake.takenBy || null; forEachScheduledOccurrenceInRange(intake, todayStart.getTime(), todayEnd.getTime(), (occurrenceMs) => { const intakeDate = new Date(occurrenceMs); result.push({ medName, medicationId, blisterIndex: blisterIdx, usage: intake.usage, intakeTime: intakeDate, intakeTimeStr: intakeDate.toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit", timeZone: timezone, }), takenBy: effectiveTakenBy, pillWeightMg, doseUnit, }); }); } return result.sort((left, right) => left.intakeTime.getTime() - right.intakeTime.getTime()); } /** * Get upcoming intakes that fall within the reminder window. * Returns intakes that should be notified about right now. * Now uses per-intake takenBy instead of medication-level. */ export function getUpcomingIntakes( medName: string, intakes: Intake[], minutesBefore: number, _medicationTakenBy: string[], // Medication-level takenBy as fallback pillWeightMg: number | null, locale: string, tz?: string, nowOverride?: number, medicationId?: number, doseUnit?: string ): UpcomingIntake[] { const now = nowOverride ?? Date.now(); const timezone = tz ?? getTimezone(); // Get the current minute (truncated to minute boundary for precise matching) const currentMinuteStart = Math.floor(now / 60000) * 60000; const currentMinuteEnd = currentMinuteStart + 60000; const upcoming: UpcomingIntake[] = []; for (let blisterIdx = 0; blisterIdx < intakes.length; blisterIdx++) { const intake = intakes[blisterIdx]; // Determine takenBy for this intake const effectiveTakenBy = intake.takenBy || null; const nextTime = getNextScheduledOccurrenceTime(intake, now, true); if (nextTime === null) continue; // Calculate when we should notify for this intake const notifyTime = nextTime - minutesBefore * 60 * 1000; // Match if: // 1. notifyTime falls within the current minute (normal case), OR // 2. notifyTime is in the past but intakeTime is still in the future (catch-up // for missed advance reminder window — e.g. scheduler was down during the // exact notification minute due to system sleep, restart, or heavy load) const isInCurrentMinute = notifyTime >= currentMinuteStart && notifyTime < currentMinuteEnd; const isMissedButStillUpcoming = notifyTime < currentMinuteStart && nextTime > now; if (isInCurrentMinute || isMissedButStillUpcoming) { const intakeDate = new Date(nextTime); upcoming.push({ medName, medicationId, blisterIndex: blisterIdx, usage: intake.usage, intakeTime: intakeDate, intakeTimeStr: intakeDate.toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit", timeZone: timezone, }), takenBy: effectiveTakenBy, pillWeightMg, doseUnit, }); } } return upcoming; } // ============================================================================= // State file utilities // ============================================================================= export type ReminderState = { lastAutoEmailSent: string | null; lastAutoEmailDate: string | null; lastStockSchedulerCheckDate: string | null; notifiedMedications: string[]; nextScheduledCheck: string | null; lastNotificationType: "stock" | "intake" | "prescription" | null; lastNotificationChannel: "email" | "push" | "both" | null; }; export type IntakeReminderEntry = { firstSentAt: number; // Timestamp when first reminder was sent lastSentAt: number; // Timestamp when last reminder was sent sendCount: number; // How many times NAGGING reminder was sent (not counting advance) advanceSent?: boolean; // Whether the advance reminder (15 min before) was sent }; export type IntakeReminderState = { reminders: Record; // key -> entry }; /** Create default reminder state */ export function createDefaultReminderState(): ReminderState { return { lastAutoEmailSent: null, lastAutoEmailDate: null, lastStockSchedulerCheckDate: null, notifiedMedications: [], nextScheduledCheck: null, lastNotificationType: null, lastNotificationChannel: null, }; } /** Create default intake reminder state */ export function createDefaultIntakeReminderState(): IntakeReminderState { return { reminders: {} }; } /** Parse reminder state from JSON string */ export function parseReminderState(json: string): ReminderState { try { const saved = JSON.parse(json); return { lastAutoEmailSent: saved.lastAutoEmailSent ?? null, lastAutoEmailDate: saved.lastAutoEmailDate ?? null, lastStockSchedulerCheckDate: saved.lastStockSchedulerCheckDate ?? null, notifiedMedications: saved.notifiedMedications ?? [], nextScheduledCheck: saved.nextScheduledCheck ?? null, lastNotificationType: saved.lastNotificationType ?? null, lastNotificationChannel: saved.lastNotificationChannel ?? null, }; } catch { return createDefaultReminderState(); } } /** Parse intake reminder state from JSON string (backward compatible) */ export function parseIntakeReminderState(json: string): IntakeReminderState { try { const saved = JSON.parse(json); // Backward compatibility: convert old array format to new map format if (Array.isArray(saved.sentReminders)) { const reminders: Record = {}; const now = Date.now(); for (const key of saved.sentReminders) { reminders[key] = { firstSentAt: now, lastSentAt: now, sendCount: 1, }; } return { reminders }; } // New format return { reminders: saved.reminders ?? {}, }; } catch { return createDefaultIntakeReminderState(); } } /** Clean up old intake reminder entries (older than given milliseconds) */ /** Clean up old intake reminder entries (using timezone-aware day check) */ export function cleanOldIntakeReminders( reminders: Record, tz: string ): Record { // Get start of today in user's timezone const now = new Date(); const todayStart = new Date(now.toLocaleString("en-US", { timeZone: tz })); todayStart.setHours(0, 0, 0, 0); const todayStartMs = todayStart.getTime(); // Keep only reminders from today onwards (based on dose timestamp in key) const cleaned: Record = {}; for (const [key, entry] of Object.entries(reminders)) { const timestamp = parseInt(key.split(":").pop() || "0", 10); if (timestamp >= todayStartMs) { cleaned[key] = entry; } } return cleaned; }