Calendar

recurrence.js 115 lines

Files · 6
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115export function parseLocalDate(str) {
  const [y, m, d] = str.split("-").map(Number);
  return new Date(y, m - 1, d);
}

export function fmtDate(d) {
  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}

function addDays(d, n) {
  const r = new Date(d);
  r.setDate(r.getDate() + n);
  return r;
}

export function generateRecurrenceDates(rec, rangeStart, rangeEnd) {
  if (
    !validDate(rec.start_date) ||
    !validDate(rangeStart) ||
    !validDate(rangeEnd) ||
    !rec.frequency
  )
    return [];
  const dates = [];
  const start = parseLocalDate(rec.start_date);
  const effEnd = rec.end_date ? parseLocalDate(rec.end_date) : null;
  const vStart = parseLocalDate(rangeStart);
  const vEnd = parseLocalDate(rangeEnd);
  const endBound = effEnd && effEnd < vEnd ? effEnd : vEnd;
  const interval = Math.max(1, Number(rec.interval_val) || 1);
  const freq = rec.frequency;

  // Iterate day-by-day through range and check pattern match.
  // For month views (~42 days) and reasonable materialization windows this is efficient.
  let d = new Date(Math.max(vStart.getTime(), start.getTime()));
  while (d <= endBound) {
    if (matchesPattern(d, start, freq, interval, rec)) {
      dates.push(fmtDate(d));
    }
    d = addDays(d, 1);
  }
  return dates;
}

function matchesPattern(d, start, freq, interval, rec) {
  if (freq === "daily") {
    const diff = daysBetweenUTC(start, d);
    return diff >= 0 && diff % interval === 0;
  }
  if (freq === "weekly") {
    const configured = rec.days_of_week ? JSON.parse(rec.days_of_week) : [];
    const dow = configured.length ? configured : [start.getDay()];
    if (!dow.includes(d.getDay())) return false;
    const weeks = weeksBetweenUTC(start, d);
    return weeks >= 0 && weeks % interval === 0;
  }
  if (freq === "monthly") {
    const monthsDiff =
      (d.getFullYear() - start.getFullYear()) * 12 +
      (d.getMonth() - start.getMonth());
    if (monthsDiff < 0 || monthsDiff % interval !== 0) return false;
    const targetDay = start.getDate();
    const maxDay = new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate();
    return d.getDate() === Math.min(targetDay, maxDay);
  }
  if (freq === "yearly") {
    const yearsDiff = d.getFullYear() - start.getFullYear();
    if (yearsDiff < 0 || yearsDiff % interval !== 0) return false;
    if (d.getMonth() !== start.getMonth()) return false;
    const targetDay = start.getDate();
    const maxDay = new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate();
    return d.getDate() === Math.min(targetDay, maxDay);
  }
  return false;
}

function daysBetweenUTC(a, b) {
  const ua = Date.UTC(a.getFullYear(), a.getMonth(), a.getDate());
  const ub = Date.UTC(b.getFullYear(), b.getMonth(), b.getDate());
  return Math.round((ub - ua) / 86400000);
}

function weeksBetweenUTC(a, b) {
  const aSun = Date.UTC(
    a.getFullYear(),
    a.getMonth(),
    a.getDate() - a.getDay(),
  );
  const bSun = Date.UTC(
    b.getFullYear(),
    b.getMonth(),
    b.getDate() - b.getDay(),
  );
  return Math.round((bSun - aSun) / (7 * 86400000));
}

export function validDate(value) {
  if (!/^\d{4}-\d{2}-\d{2}$/.test(value || "")) return false;
  const date = parseLocalDate(value);
  return !Number.isNaN(date.getTime()) && fmtDate(date) === value;
}
export function validateRecurrence(rec) {
  if (!["daily", "weekly", "monthly", "yearly"].includes(rec.frequency))
    return "Choose a valid repeat frequency.";
  if (!validDate(rec.startDate)) return "Choose a valid start date.";
  if (rec.endDate && (!validDate(rec.endDate) || rec.endDate < rec.startDate))
    return "End date must be on or after the start date.";
  if (!Number.isInteger(rec.interval) || rec.interval < 1)
    return "Repeat interval must be a positive integer.";
  if (
    rec.daysOfWeek?.some((day) => !Number.isInteger(day) || day < 0 || day > 6)
  )
    return "Weekdays must be between Sunday and Saturday.";
  return "";
}