Source: index.js

import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const MS_PER_DAY = 86_400_000;

/**
 * Converts a `Date` to a whole-day number, ignoring the time-of-day and the
 * local timezone offset. Two dates on the same local calendar day always
 * produce the same number, which makes day arithmetic immune to DST shifts.
 * @param date - The date to convert
 * @returns The number of days since the Unix epoch
 */
function toDayNumber(date) {
  return Math.floor(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) / MS_PER_DAY);
}

/**
 * Inverse of {@link toDayNumber}; returns local midnight on that calendar day.
 * @param dayNumber - The number of days since the Unix epoch
 * @returns A `Date` at local midnight
 */
function fromDayNumber(dayNumber) {
  const utc = new Date(dayNumber * MS_PER_DAY);
  return new Date(utc.getUTCFullYear(), utc.getUTCMonth(), utc.getUTCDate());
}

/**
 * Splits a CSV line into fields, honouring RFC 4180 double-quoted fields so
 * that names containing commas (`"Nike, Inc's Birthday",01/25,1964`) survive.
 * A doubled quote inside a quoted field is an escaped quote.
 * @param line - The raw CSV line
 * @returns The individual field values, unquoted and unescaped
 */
export function splitCSVFields(line) {
  const fields = [];
  let current = '';
  let quoted = false;
  for (let i = 0; i < line.length; i++) {
    const char = line[i];
    if (quoted) {
      if (char === '"') {
        if (line[i + 1] === '"') {
          current += '"';
          i++;
        } else {
          quoted = false;
        }
      } else {
        current += char;
      }
    } else if (char === '"' && current.trim() === '') {
      quoted = true;
      current = '';
    } else if (char === ',') {
      fields.push(current);
      current = '';
    } else {
      current += char;
    }
  }
  fields.push(current);
  return fields;
}

/**
 * Path to the CSV data bundled with this package.
 *
 * Resolved from the module's own location so it works from any working
 * directory, both from source (`src/data`) and from the built output
 * (`dist/../src/data`).
 *
 * On runtimes with no real filesystem - Cloudflare Workers being the one that
 * matters here - nothing resolves and this falls back to `./src/data`, the
 * relative default this package used before the constant existed. Workers
 * consumers should pass an explicit directory instead: the CSVs are bundled as
 * text modules, so the data lives at the bundle root (`/bundle/data`) rather
 * than anywhere near the module.
 */
export const DATA_DIR = (() => {
  // the historical default, kept as the fallback so a filesystem-less runtime
  // behaves exactly as it did before rather than pointing somewhere invented
  const fallback = './src/data';
  try {
    const here = path.dirname(fileURLToPath(import.meta.url));
    for (const candidate of [path.join(here, 'data'), path.join(here, '..', 'src', 'data')]) {
      if (fs.existsSync(candidate)) return candidate;
    }
  } catch {
    // no file: URL, or no fs; fall through
  }
  return fallback;
})();

/**
 * Represents a calendar entry with a name and date.
 * Base class for all entry types.
 */
export class Entry {
  constructor(name, source) {
    this.name = name;
    this.source = source;
  }

  /**
   * Gets the next occurrence of this entry from the given date.
   * @param fromDate - The date to calculate from (default: today)
   * @returns The next occurrence as a Date object
   */

  /**
   * Checks if this entry occurs on the given date.
   * @param date - The date to check
   * @returns true if the entry occurs on this date
   */

  /**
   * Gets all occurrences within a date range.
   * @param startDate - Start of the range
   * @param endDate - End of the range
   * @returns Array of dates when this entry occurs
   */
}

/**
 * Entry with a specific month and day (recurring annually).
 * Example: "New Year's Day,01/01"
 */
export class ExactDateEntry extends Entry {
  constructor(name, month, day, source) {
    super(name, source);
    this.month = month;
    this.day = day;
    this.source = source;
  }
  getNextOccurrence(fromDate = new Date()) {
    const year = fromDate.getFullYear();
    let nextDate = new Date(year, this.month - 1, this.day);
    if (nextDate <= fromDate) {
      nextDate = new Date(year + 1, this.month - 1, this.day);
    }
    return nextDate;
  }
  occursOn(date) {
    return date.getMonth() === this.month - 1 && date.getDate() === this.day;
  }
  getOccurrencesInRange(startDate, endDate) {
    const occurrences = [];
    const startYear = startDate.getFullYear();
    const endYear = endDate.getFullYear();
    for (let year = startYear; year <= endYear; year++) {
      const occurrence = new Date(year, this.month - 1, this.day);
      if (occurrence >= startDate && occurrence <= endDate) {
        occurrences.push(occurrence);
      }
    }
    return occurrences;
  }
}

/**
 * Entry with a specific year, month, and day (one-time event).
 * Example: "Afghanistan,08/19,1919"
 */
export class ExactDateWithYearEntry extends Entry {
  constructor(name, month, day, year, source) {
    super(name, source);
    this.month = month;
    this.day = day;
    this.year = year;
    this.source = source;
  }
  getNextOccurrence(fromDate = new Date()) {
    // For historical events, return the next anniversary
    const currentYear = fromDate.getFullYear();
    let anniversaryDate = new Date(currentYear, this.month - 1, this.day);
    if (anniversaryDate <= fromDate) {
      anniversaryDate = new Date(currentYear + 1, this.month - 1, this.day);
    }
    return anniversaryDate;
  }
  occursOn(date) {
    return date.getFullYear() === this.year && date.getMonth() === this.month - 1 && date.getDate() === this.day;
  }
  getOccurrencesInRange(startDate, endDate) {
    const eventDate = new Date(this.year, this.month - 1, this.day);
    if (eventDate >= startDate && eventDate <= endDate) {
      return [eventDate];
    }
    return [];
  }

  /**
   * Gets the anniversary of this event on a specific year.
   * @param year - The year to calculate the anniversary for
   * @returns The anniversary date
   */
  getAnniversary(year) {
    return new Date(year, this.month - 1, this.day);
  }

  /**
   * Gets the number of years since this event for a given date.
   * @param date - The date to calculate from (default: today)
   * @returns The number of years
   */
  getYearsSince(date = new Date()) {
    return date.getFullYear() - this.year;
  }
}

/**
 * Entry for a relative date (e.g., "3rd Monday in January").
 * Example: "Martin Luther King Jr. Day,3MondayJan"
 */
export class RelativeDateEntry extends Entry {
  static DAYS_OF_WEEK = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
  static MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];

  /** Sentinel {@link occurrence} value meaning "the last one in the month". */
  static LAST = -1;
  constructor(name, occurrence,
  // 1-5, or RelativeDateEntry.LAST (-1) for the last in the month
  dayOfWeek,
  // 0-6 (Sunday-Saturday)
  month,
  // 1-12
  source) {
    super(name, source);
    this.occurrence = occurrence;
    this.dayOfWeek = dayOfWeek;
    this.month = month;
    this.source = source;
  }

  /**
   * Parses a relative date string (e.g., "3MondayJan") into components.
   * A leading `L` means the last occurrence in the month ("LMondayMay"),
   * which is how holidays such as Memorial Day are actually defined.
   * @param dateStr - The date string to parse
   * @returns Object with occurrence, dayOfWeek, and month
   */
  static parseRelativeDate(dateStr) {
    const occurrenceMatch = dateStr.match(/^([1-5]|[Ll])/);
    if (!occurrenceMatch || !occurrenceMatch[1]) throw new Error('Invalid relative date format');
    const token = occurrenceMatch[1];
    const occurrence = token.toUpperCase() === 'L' ? RelativeDateEntry.LAST : parseInt(token, 10);
    let dayOfWeek = -1;
    for (let i = 0; i < this.DAYS_OF_WEEK.length; i++) {
      const dayName = this.DAYS_OF_WEEK[i];
      if (dayName && dateStr.includes(dayName)) {
        dayOfWeek = i;
        break;
      }
    }
    if (dayOfWeek === -1) throw new Error('Invalid day of week');
    let month = -1;
    for (let i = 0; i < this.MONTHS.length; i++) {
      const monthName = this.MONTHS[i];
      if (monthName && dateStr.endsWith(monthName)) {
        month = i + 1;
        break;
      }
    }
    if (month === -1) throw new Error('Invalid month');
    return {
      occurrence,
      dayOfWeek,
      month
    };
  }

  /**
   * Calculates the Nth occurrence of a weekday in a given month/year.
   * @param year - The year
   * @param month - The month (1-12)
   * @param dayOfWeek - Day of week (0-6, Sunday-Saturday)
   * @param occurrence - Which occurrence (1-5), or {@link RelativeDateEntry.LAST}
   * @returns The calculated date or null if it doesn't exist
   */
  static getNthWeekdayOfMonth(year, month, dayOfWeek, occurrence) {
    if (occurrence === RelativeDateEntry.LAST) {
      const lastDay = new Date(year, month, 0);
      const daysToSubtract = (lastDay.getDay() - dayOfWeek + 7) % 7;
      return new Date(year, month - 1, lastDay.getDate() - daysToSubtract);
    }
    const firstDay = new Date(year, month - 1, 1);
    const firstDayOfWeek = firstDay.getDay();
    let daysToAdd = (dayOfWeek - firstDayOfWeek + 7) % 7;
    daysToAdd += (occurrence - 1) * 7;
    const targetDate = new Date(year, month - 1, 1 + daysToAdd);

    // Check if the date is still in the same month
    if (targetDate.getMonth() !== month - 1) {
      return null;
    }
    return targetDate;
  }
  getNextOccurrence(fromDate = new Date()) {
    // a 5th weekday does not exist every year, so scan forward instead of
    // giving up after a single retry
    for (let offset = 0; offset <= 10; offset++) {
      const date = RelativeDateEntry.getNthWeekdayOfMonth(fromDate.getFullYear() + offset, this.month, this.dayOfWeek, this.occurrence);
      if (date && date > fromDate) return date;
    }
    return new Date(9999, 11, 31);
  }
  occursOn(date) {
    if (date.getMonth() !== this.month - 1) return false;
    if (date.getDay() !== this.dayOfWeek) return false;
    const expected = RelativeDateEntry.getNthWeekdayOfMonth(date.getFullYear(), this.month, this.dayOfWeek, this.occurrence);
    return expected !== null && expected.getDate() === date.getDate();
  }
  getOccurrencesInRange(startDate, endDate) {
    const occurrences = [];
    const startYear = startDate.getFullYear();
    const endYear = endDate.getFullYear();
    for (let year = startYear; year <= endYear; year++) {
      const occurrence = RelativeDateEntry.getNthWeekdayOfMonth(year, this.month, this.dayOfWeek, this.occurrence);
      if (occurrence && occurrence >= startDate && occurrence <= endDate) {
        occurrences.push(occurrence);
      }
    }
    return occurrences;
  }
}

/**
 * Entry for something that happens exactly once, on a known calendar day.
 * Unlike {@link ExactDateWithYearEntry} it has no annual anniversary: a total
 * solar eclipse or a comet perihelion simply happens and is then over.
 *
 * Example: `Total Solar Eclipse (Iceland/Spain),2026-08-12`
 *
 * Dates are calendar days, not instants. Astronomical rows store the UTC day.
 */
export class OneTimeEntry extends Entry {
  constructor(name, month, day, year, source) {
    super(name, source);
    this.month = month;
    this.day = day;
    this.year = year;
    this.source = source;
  }

  /** The event's calendar day, at local midnight. */
  get date() {
    return new Date(this.year, this.month - 1, this.day);
  }

  /**
   * Returns the event date. A one-time event has no future occurrence once it
   * has passed, so the returned date may be in the past; callers filtering on
   * `date >= fromDate` (as the query helpers do) drop it naturally.
   * @param _fromDate - Unused; present to satisfy the {@link Entry} contract
   * @returns The event date
   */
  getNextOccurrence(_fromDate = new Date()) {
    return this.date;
  }
  occursOn(date) {
    return date.getFullYear() === this.year && date.getMonth() === this.month - 1 && date.getDate() === this.day;
  }
  getOccurrencesInRange(startDate, endDate) {
    const date = this.date;
    return date >= startDate && date <= endDate ? [date] : [];
  }

  /**
   * Whether this event is already in the past.
   * @param fromDate - The date to compare against (default: today)
   * @returns true if the event day is before `fromDate`
   */
  hasOccurred(fromDate = new Date()) {
    return this.date < fromDate;
  }
}

/**
 * Entry that repeats on a fixed period measured in days, anchored to an epoch.
 * Built for cycles that do not fit a calendar: orbital periods, comet returns,
 * the saros and metonic cycles.
 *
 * Example: `Halley's Comet Perihelion,every:27758:1986-02-09`
 *
 * The period may be fractional (Mars orbits in 686.98 days); occurrences are
 * rounded to the nearest whole day.
 */
export class IntervalEntry extends Entry {
  constructor(name, epoch, intervalDays, source) {
    super(name, source);
    this.epoch = epoch;
    this.intervalDays = intervalDays;
    this.source = source;
    if (!(intervalDays > 0)) throw new Error('Interval must be a positive number of days');
  }

  /**
   * Gets the nth occurrence relative to the epoch.
   * @param n - Cycle index; 0 is the epoch itself, negatives run backwards
   * @returns The occurrence date
   */
  getOccurrence(n) {
    return fromDayNumber(Math.round(toDayNumber(this.epoch) + n * this.intervalDays));
  }

  /**
   * Gets the cycle index nearest to the given date.
   * @param date - The date to locate
   * @returns The (possibly fractional) number of cycles since the epoch
   */
  getCycleAt(date) {
    return (toDayNumber(date) - toDayNumber(this.epoch)) / this.intervalDays;
  }
  getNextOccurrence(fromDate = new Date()) {
    let n = Math.ceil(this.getCycleAt(fromDate));
    // rounding to whole days can put the candidate on or before fromDate
    while (this.getOccurrence(n) <= fromDate) n++;
    return this.getOccurrence(n);
  }
  occursOn(date) {
    const n = Math.round(this.getCycleAt(date));
    return toDayNumber(this.getOccurrence(n)) === toDayNumber(date);
  }
  getOccurrencesInRange(startDate, endDate) {
    const occurrences = [];
    for (let n = Math.floor(this.getCycleAt(startDate));; n++) {
      const occurrence = this.getOccurrence(n);
      if (occurrence > endDate) break;
      if (occurrence >= startDate) occurrences.push(occurrence);
    }
    return occurrences;
  }
}

/**
 * Entry covering a span of the year rather than a single day, recurring
 * annually. A span whose end falls before its start wraps into the next year,
 * which is how most sports seasons and two of the zodiac signs behave.
 *
 * Example: `NFL Regular Season,range:09/04-01/04`
 */
export class DateRangeEntry extends Entry {
  constructor(name, startMonth, startDay, endMonth, endDay, source) {
    super(name, source);
    this.startMonth = startMonth;
    this.startDay = startDay;
    this.endMonth = endMonth;
    this.endDay = endDay;
    this.source = source;
  }

  /** Whether the span crosses into the following calendar year. */
  get wrapsYear() {
    return this.endMonth < this.startMonth || this.endMonth === this.startMonth && this.endDay < this.startDay;
  }

  /**
   * Gets the concrete span for the year the run starts in.
   * @param year - The year the span starts in
   * @returns The start and end dates, both inclusive
   */
  getRangeFor(year) {
    return {
      start: new Date(year, this.startMonth - 1, this.startDay),
      end: new Date(year + (this.wrapsYear ? 1 : 0), this.endMonth - 1, this.endDay)
    };
  }

  /**
   * Returns the next start date, i.e. when the span next opens.
   * @param fromDate - The date to calculate from (default: today)
   * @returns The next start date
   */
  getNextOccurrence(fromDate = new Date()) {
    const year = fromDate.getFullYear();
    const start = new Date(year, this.startMonth - 1, this.startDay);
    return start > fromDate ? start : new Date(year + 1, this.startMonth - 1, this.startDay);
  }

  /**
   * Whether the given date falls anywhere inside the span, endpoints included.
   * @param date - The date to check
   * @returns true if the date is within the span
   */
  occursOn(date) {
    // a wrapping span can be entered from either the current or the prior year
    for (const year of [date.getFullYear(), date.getFullYear() - 1]) {
      const {
        start,
        end
      } = this.getRangeFor(year);
      if (date >= start && date <= end) return true;
    }
    return false;
  }

  /**
   * Gets the start dates of every span that opens within the given window.
   * @param startDate - Start of the window
   * @param endDate - End of the window
   * @returns Array of span start dates
   */
  getOccurrencesInRange(startDate, endDate) {
    const occurrences = [];
    for (let year = startDate.getFullYear(); year <= endDate.getFullYear(); year++) {
      const start = new Date(year, this.startMonth - 1, this.startDay);
      if (start >= startDate && start <= endDate) occurrences.push(start);
    }
    return occurrences;
  }
}

/**
 * Entry positioned a fixed number of days from Easter Sunday, which moves every
 * year. Covers the moveable feasts (Ash Wednesday, Good Friday, Pentecost) in
 * both the Gregorian (Western) and Julian (Orthodox) reckonings.
 *
 * Example: `Good Friday,easter-2` / `Orthodox Easter,orthodox-easter+0`
 */
export class EasterEntry extends Entry {
  constructor(name, offsetDays, calendar = 'gregorian', source) {
    super(name, source);
    this.offsetDays = offsetDays;
    this.calendar = calendar;
    this.source = source;
  }

  /**
   * Computes Easter Sunday, as a date in the Gregorian calendar.
   *
   * The Gregorian branch is the anonymous Meeus/Jones/Butcher algorithm. The
   * Julian branch computes Orthodox Easter on the Julian calendar and then
   * shifts it onto the Gregorian calendar by the offset for that century.
   * @param year - The year to compute for
   * @param calendar - Which reckoning to use (default: gregorian)
   * @returns Easter Sunday for that year
   */
  static computeEaster(year, calendar = 'gregorian') {
    if (calendar === 'julian') {
      const a = year % 4;
      const b = year % 7;
      const c = year % 19;
      const d = (19 * c + 15) % 30;
      const e = (2 * a + 4 * b - d + 34) % 7;
      const month = Math.floor((d + e + 114) / 31);
      const day = (d + e + 114) % 31 + 1;

      // Julian -> Gregorian: 2 days at the Julian year 200, +1 per skipped leap century
      const julianDate = new Date(year, month - 1, day);
      const shift = Math.floor(year / 100) - Math.floor(year / 400) - 2;
      julianDate.setDate(julianDate.getDate() + shift);
      return julianDate;
    }
    const a = year % 19;
    const b = Math.floor(year / 100);
    const c = year % 100;
    const d = Math.floor(b / 4);
    const e = b % 4;
    const f = Math.floor((b + 8) / 25);
    const g = Math.floor((b - f + 1) / 3);
    const h = (19 * a + b - d - g + 15) % 30;
    const i = Math.floor(c / 4);
    const k = c % 4;
    const l = (32 + 2 * e + 2 * i - h - k) % 7;
    const m = Math.floor((a + 11 * h + 22 * l) / 451);
    const month = Math.floor((h + l - 7 * m + 114) / 31);
    const day = (h + l - 7 * m + 114) % 31 + 1;
    return new Date(year, month - 1, day);
  }

  /**
   * Gets this entry's date in a given year.
   * @param year - The year to compute for
   * @returns The date, Easter plus this entry's offset
   */
  getDateFor(year) {
    const easter = EasterEntry.computeEaster(year, this.calendar);
    easter.setDate(easter.getDate() + this.offsetDays);
    return easter;
  }
  getNextOccurrence(fromDate = new Date()) {
    const year = fromDate.getFullYear();
    const thisYear = this.getDateFor(year);
    return thisYear > fromDate ? thisYear : this.getDateFor(year + 1);
  }
  occursOn(date) {
    return toDayNumber(this.getDateFor(date.getFullYear())) === toDayNumber(date);
  }
  getOccurrencesInRange(startDate, endDate) {
    const occurrences = [];
    // the offset can push a date into an adjacent year, so widen the sweep
    for (let year = startDate.getFullYear() - 1; year <= endDate.getFullYear() + 1; year++) {
      const date = this.getDateFor(year);
      if (date >= startDate && date <= endDate) occurrences.push(date);
    }
    return occurrences;
  }
}

/**
 * Parses a CSV file and returns an array of Entry objects.
 * Automatically detects the format of each line.
 * @param filePath - The path to the CSV file
 * @returns Array of Entry objects
 */
export function getEntries(filePath, dataDir = DATA_DIR) {
  const relative = path.relative(path.resolve(dataDir), path.resolve(filePath)).split(path.sep).join('/');
  const data = fs.readFileSync(filePath, 'utf-8');
  const lines = data.split('\n').map(line => line.replace(/^\uFEFF/, '').replace(/\r$/, '')).filter(line => line.trim() !== '' && !line.startsWith('#'));
  const entries = [];
  for (const line of lines) {
    const parsed = parseCSVLine(line, relative);
    if (parsed) {
      entries.push(parsed);
    }
  }
  return entries;
}

/**
 * Whether the month/day pair is a real calendar date. February is allowed 29
 * days because a recurring `02/29` entry is valid in leap years.
 * @param month - Month (1-12)
 * @param day - Day of month
 * @returns true if the combination exists
 */
function isValidMonthDay(month, day) {
  if (!Number.isInteger(month) || !Number.isInteger(day)) return false;
  if (month < 1 || month > 12 || day < 1) return false;
  const maxDay = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1];
  return day <= maxDay;
}

/**
 * Parses a single CSV line into an Entry object.
 *
 * Supported formats, detected from the second field:
 *
 * | Format                        | Type                       | Example                       |
 * | ----------------------------- | -------------------------- | ----------------------------- |
 * | `MM/DD`                       | {@link ExactDateEntry}     | `Pi Day,03/14`                |
 * | `MM/DD,YYYY`                  | {@link ExactDateWithYearEntry} | `Afghanistan,08/19,1919`  |
 * | `NWeekdayMonth`               | {@link RelativeDateEntry}  | `MLK Day,3MondayJan`          |
 * | `LWeekdayMonth`               | {@link RelativeDateEntry}  | `Memorial Day,LMondayMay`     |
 * | `YYYY-MM-DD`                  | {@link OneTimeEntry}       | `Solar Eclipse,2026-08-12`    |
 * | `every:DAYS:YYYY-MM-DD`       | {@link IntervalEntry}      | `Halley,every:27758:1986-02-09` |
 * | `range:MM/DD-MM/DD`           | {@link DateRangeEntry}     | `NFL Season,range:09/04-01/04` |
 * | `easter[+-]N`                 | {@link EasterEntry}        | `Good Friday,easter-2`        |
 * | `orthodox-easter[+-]N`        | {@link EasterEntry}        | `Orthodox Easter,orthodox-easter+0` |
 *
 * Names containing commas must be double-quoted.
 * @param line - The CSV line to parse
 * @param source - Optional provenance recorded on the entry
 * @returns An Entry object or null if invalid
 */
export function parseCSVLine(line, source) {
  const parts = splitCSVFields(line);
  if (parts.length < 2) return null;
  const name = parts[0]?.trim();
  const dateStr = parts[1]?.trim();
  if (!name || !dateStr) return null;

  // every:DAYS:YYYY-MM-DD - periodic cycle anchored to an epoch
  const intervalMatch = dateStr.match(/^every:(\d+(?:\.\d+)?):(\d{4})-(\d{2})-(\d{2})$/i);
  if (intervalMatch) {
    const [, daysStr, yearStr, monthStr, dayStr] = intervalMatch;
    const days = parseFloat(daysStr);
    const month = parseInt(monthStr, 10);
    const day = parseInt(dayStr, 10);
    if (!(days > 0) || !isValidMonthDay(month, day)) return null;
    const epoch = new Date(parseInt(yearStr, 10), month - 1, day);
    return new IntervalEntry(name, epoch, days, source);
  }

  // easter+N / orthodox-easter-N - moveable feasts
  const easterMatch = dateStr.match(/^(orthodox-)?easter(?:\s*([+-])\s*(\d+))?$/i);
  if (easterMatch) {
    const sign = easterMatch[2] === '-' ? -1 : 1;
    const offset = easterMatch[3] ? sign * parseInt(easterMatch[3], 10) : 0;
    return new EasterEntry(name, offset, easterMatch[1] ? 'julian' : 'gregorian', source);
  }

  // range:MM/DD-MM/DD - an annual span
  const rangeMatch = dateStr.match(/^range:(\d{1,2})\/(\d{1,2})-(\d{1,2})\/(\d{1,2})$/i);
  if (rangeMatch) {
    const [startMonth, startDay, endMonth, endDay] = rangeMatch.slice(1).map(value => parseInt(value, 10));
    if (!isValidMonthDay(startMonth, startDay) || !isValidMonthDay(endMonth, endDay)) {
      return null;
    }
    return new DateRangeEntry(name, startMonth, startDay, endMonth, endDay, source);
  }

  // YYYY-MM-DD - a single dated event
  const isoMatch = dateStr.match(/^(\d{4})-(\d{2})-(\d{2})$/);
  if (isoMatch) {
    const [, yearStr, monthStr, dayStr] = isoMatch;
    const month = parseInt(monthStr, 10);
    const day = parseInt(dayStr, 10);
    if (!isValidMonthDay(month, day)) return null;
    return new OneTimeEntry(name, month, day, parseInt(yearStr, 10), source);
  }

  // MM/DD or MM/DD,YYYY
  if (dateStr.includes('/')) {
    const segments = dateStr.split('/');
    // a bare MM/DD/YYYY would silently drop its year; require the CSV column
    if (segments.length !== 2) return null;
    const [monthStr, dayStr] = segments;
    if (!monthStr || !dayStr) return null;
    const month = parseInt(monthStr, 10);
    const day = parseInt(dayStr, 10);
    if (!isValidMonthDay(month, day)) return null;

    // Check if there's a year
    if (parts.length >= 3) {
      const yearStr = parts[2]?.trim();
      if (yearStr) {
        const year = parseInt(yearStr, 10);
        if (!isNaN(year)) {
          return new ExactDateWithYearEntry(name, month, day, year, source);
        }
      }
    }
    return new ExactDateEntry(name, month, day, source);
  }

  // NWeekdayMonth (e.g., "3MondayJan")
  try {
    const {
      occurrence,
      dayOfWeek,
      month
    } = RelativeDateEntry.parseRelativeDate(dateStr);
    return new RelativeDateEntry(name, occurrence, dayOfWeek, month, source);
  } catch {
    return null;
  }
}

/**
 * Reads all .csv files in the data directory and its subdirectories.
 * @param dataDir - The root data directory (default: './src/data')
 * @returns Array of all Entry objects
 */
export function getAllEntries(dataDir = DATA_DIR) {
  const allEntries = [];
  function readDirRecursively(dirPath) {
    const items = fs.readdirSync(dirPath, {
      withFileTypes: true
    });
    for (const item of items) {
      const fullPath = path.join(dirPath, item.name);
      if (item.isDirectory()) {
        readDirRecursively(fullPath);
      } else if (item.isFile() && item.name.endsWith('.csv')) {
        const entries = getEntries(fullPath, dataDir);
        allEntries.push(...entries);
      }
    }
  }
  readDirRecursively(dataDir);
  return allEntries;
}

/**
 * Gets entries that occur within the next N days.
 * @param entries - Array of entries to filter
 * @param days - Number of days to look ahead
 * @param fromDate - Starting date (default: today)
 * @returns Array of entries with their next occurrence dates
 */
export function getEntriesInNextDays(entries, days, fromDate = new Date()) {
  const endDate = new Date(fromDate);
  endDate.setDate(endDate.getDate() + days);
  return entries.map(entry => ({
    entry,
    date: entry.getNextOccurrence(fromDate)
  })).filter(({
    date
  }) => date >= fromDate && date <= endDate).sort((a, b) => a.date.getTime() - b.date.getTime());
}

/**
 * Gets entries that occur within the next N weeks.
 * @param entries - Array of entries to filter
 * @param weeks - Number of weeks to look ahead
 * @param fromDate - Starting date (default: today)
 * @returns Array of entries with their next occurrence dates
 */
export function getEntriesInNextWeeks(entries, weeks, fromDate = new Date()) {
  return getEntriesInNextDays(entries, weeks * 7, fromDate);
}

/**
 * Gets entries that occur within the next N months.
 * @param entries - Array of entries to filter
 * @param months - Number of months to look ahead
 * @param fromDate - Starting date (default: today)
 * @returns Array of entries with their next occurrence dates
 */
export function getEntriesInNextMonths(entries, months, fromDate = new Date()) {
  const endDate = new Date(fromDate);
  endDate.setMonth(endDate.getMonth() + months);
  return entries.map(entry => ({
    entry,
    date: entry.getNextOccurrence(fromDate)
  })).filter(({
    date
  }) => date >= fromDate && date <= endDate).sort((a, b) => a.date.getTime() - b.date.getTime());
}

/**
 * Gets entries that occur within the next N years.
 * @param entries - Array of entries to filter
 * @param years - Number of years to look ahead
 * @param fromDate - Starting date (default: today)
 * @returns Array of entries with their next occurrence dates
 */
export function getEntriesInNextYears(entries, years, fromDate = new Date()) {
  const endDate = new Date(fromDate);
  endDate.setFullYear(endDate.getFullYear() + years);
  return entries.map(entry => ({
    entry,
    date: entry.getNextOccurrence(fromDate)
  })).filter(({
    date
  }) => date >= fromDate && date <= endDate).sort((a, b) => a.date.getTime() - b.date.getTime());
}

/**
 * Gets entries that occur on a specific date.
 * @param entries - Array of entries to filter
 * @param date - The date to check
 * @returns Array of entries that occur on this date
 */
export function getEntriesOnDate(entries, date) {
  return entries.filter(entry => entry.occursOn(date));
}

/**
 * Gets entries whose anchor date is a specific month and day (any year).
 *
 * Only entries that own a fixed month/day are considered:
 * {@link ExactDateEntry}, {@link ExactDateWithYearEntry} and
 * {@link OneTimeEntry}. Entries whose date is computed
 * ({@link RelativeDateEntry}, {@link IntervalEntry}, {@link EasterEntry}) or
 * that span a window ({@link DateRangeEntry}) never match; use
 * {@link getEntriesOnDate} for those.
 * @param entries - Array of entries to filter
 * @param month - Month (1-12)
 * @param day - Day of month
 * @returns Array of entries anchored to this month/day
 */
export function getEntriesOnMonthDay(entries, month, day) {
  return entries.filter(entry => {
    if (entry instanceof ExactDateEntry || entry instanceof ExactDateWithYearEntry || entry instanceof OneTimeEntry) {
      return entry.month === month && entry.day === day;
    }
    return false;
  });
}

/**
 * Gets every occurrence of every entry that falls inside a date window.
 *
 * Unlike the `getEntriesInNext*` helpers, which only ever consider each entry's
 * single next occurrence, this expands recurring entries so a multi-year window
 * yields one result per year.
 * @param entries - Array of entries to expand
 * @param startDate - Start of the window (inclusive)
 * @param endDate - End of the window (inclusive)
 * @returns Entry/date pairs sorted by date
 */
export function getEntriesInRange(entries, startDate, endDate) {
  return entries.flatMap(entry => entry.getOccurrencesInRange(startDate, endDate).map(date => ({
    entry,
    date
  }))).sort((a, b) => a.date.getTime() - b.date.getTime());
}

/**
 * Gets entries that came from a given data file or directory.
 *
 * Sources are recorded as forward-slash paths relative to the data directory,
 * so `'sports/'` matches every sports file and `'cosmic/eclipses.csv'` matches
 * exactly one.
 * @param entries - Array of entries to filter
 * @param source - A source path or path prefix
 * @returns Array of entries whose source starts with the given prefix
 */
export function getEntriesBySource(entries, source) {
  return entries.filter(entry => entry.source?.startsWith(source) ?? false);
}

/**
 * Gets entries by type.
 * @param entries - Array of entries to filter
 * @param type - The entry type to filter by
 * @returns Array of entries of the specified type
 */
export function getEntriesByType(entries, type) {
  return entries.filter(entry => entry instanceof type);
}