← 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 };