← back to AbramsOS
lib/recurrence.js
64 lines
// recurrence.js — pure date helpers for bills (cadence) and reorders (cadence_days).
// No DB, no side effects → unit-testable in isolation.
const CADENCE_DAYS = {
weekly: 7,
biweekly: 14,
};
const CADENCE_MONTHS = {
monthly: 1,
quarterly: 3,
semiannual: 6,
annual: 12,
};
// Parse a YYYY-MM-DD (or Date) into a UTC-noon Date so DST / timezone never shifts the day.
function toDate(d) {
if (d instanceof Date) return d;
const [y, m, day] = String(d).slice(0, 10).split('-').map(Number);
return new Date(Date.UTC(y, m - 1, day, 12, 0, 0));
}
function toISODate(d) {
return toDate(d).toISOString().slice(0, 10);
}
function addDays(d, n) {
const x = toDate(d);
x.setUTCDate(x.getUTCDate() + n);
return x;
}
function addMonths(d, n) {
const x = toDate(d);
const targetMonth = x.getUTCMonth() + n;
x.setUTCMonth(targetMonth);
return x;
}
// Given a due date and a cadence, return the NEXT due date (YYYY-MM-DD string).
// 'once' returns null (no recurrence). Unknown cadence → null.
function nextDue(fromDate, cadence) {
if (!fromDate || !cadence || cadence === 'once') return null;
if (CADENCE_DAYS[cadence]) return toISODate(addDays(fromDate, CADENCE_DAYS[cadence]));
if (CADENCE_MONTHS[cadence]) return toISODate(addMonths(fromDate, CADENCE_MONTHS[cadence]));
return null;
}
// For reorders: next due = last ordered + N days.
function nextDueDays(fromDate, days) {
if (!fromDate || !days || days <= 0) return null;
return toISODate(addDays(fromDate, days));
}
// Days until a date from now (integer, can be negative when overdue).
function daysUntil(d, now = new Date()) {
if (!d) return null;
const ms = toDate(d).getTime() - toDate(toISODate(now)).getTime();
return Math.round(ms / 86400000);
}
const CADENCES = ['once', 'weekly', 'biweekly', 'monthly', 'quarterly', 'semiannual', 'annual'];
module.exports = { nextDue, nextDueDays, daysUntil, addDays, addMonths, toISODate, CADENCES };