Functional Weave
Code in TypeScript

dates.recurrence@1.0.0

impl/typescript.ts

3,712 bytes · the TypeScript implementation · view raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

import { daysFromCivil, daysInMonth, epochDayFromIso, isoFromEpochDay, parseIsoDate } from "./dates_add_days.ts";  ← from dates.add-days ^1.0.0 · built alongside by fune
import { dayOfWeek } from "./dates_day_of_week.ts";  ← from dates.day-of-week ^1.0.0 · built alongside by fune
import { addMonths } from "./dates_add_months.ts";  ← from dates.add-months ^1.0.0 · built alongside by fune
import { type RecurrenceRule } from "./dates_recurrence_types.ts";

function checkPositive(name: string, value: number, low: number, high: number, kind: string): void {
  if (!Number.isInteger(value) || value < low || value > high) {
    throw new RangeError(`${name} must be ${low}-${high} for ${kind}, received ${value}`);
  }
}

/** The date of the `day`-th (clamped) or last-`weekday` in a month index counted from year 0. */
function dateInMonth(index: number, rule: RecurrenceRule): number {
  const year = Math.floor(index / 12);
  const month = index - year * 12 + 1;
  if (year > 9999) {
    throw new RangeError(`the schedule runs past 9999-12-31, outside the supported range 0001-01-01 to 9999-12-31`);
  }
  const length = daysInMonth(year, month);
  if (rule.kind === "monthly-on-day") return daysFromCivil(year, month, Math.min(rule.day as number, length));
  const last = daysFromCivil(year, month, length);
  const lastWeekday = dayOfWeek(isoFromEpochDay(last));
  return last - ((lastWeekday - (rule.weekday as number) + 7) % 7);
}

/**
 * The first `count` dates of a rule on or after `startIso`. Monthly dates are
 * clamped from the rule's day every time, never from the previous date, so
 * the 31st does not decay to the 28th after February.
 */
export function recurrence(rule: RecurrenceRule, startIso: string, count: number): readonly string[] {
  if (!Number.isInteger(count) || count < 0) {
    throw new RangeError(`count must be a non-negative integer, received ${count}`);
  }
  if (!Number.isInteger(rule.interval) || rule.interval < 1) {
    throw new RangeError(`interval must be a positive integer, received ${rule.interval}`);
  }
  const kind = rule.kind;
  if (kind === "monthly-on-day") {
    if (rule.day === null) throw new RangeError(`day must be 1-31 for monthly-on-day, received null`);
    checkPositive("day", rule.day, 1, 31, kind);
    if (rule.weekday !== null) throw new RangeError(`weekday does not apply to monthly-on-day`);
  } else if (kind === "last-weekday-of-month") {
    if (rule.weekday === null) throw new RangeError(`weekday must be 1-7 for last-weekday-of-month, received null`);
    checkPositive("weekday", rule.weekday, 1, 7, kind);
    if (rule.day !== null) throw new RangeError(`day does not apply to last-weekday-of-month`);
  } else if (kind === "every-days" || kind === "every-weeks" || kind === "yearly") {
    if (rule.day !== null) throw new RangeError(`day does not apply to ${kind}`);
    if (rule.weekday !== null) throw new RangeError(`weekday does not apply to ${kind}`);
  } else {
    throw new RangeError(`unknown recurrence kind "${kind}": expected monthly-on-day, last-weekday-of-month, every-days, every-weeks or yearly`);
  }

  const start = epochDayFromIso(startIso);
  const out: string[] = [];
  if (kind === "every-days" || kind === "every-weeks") {
    const step = kind === "every-weeks" ? 7 * rule.interval : rule.interval;
    for (let k = 0; k < count; k++) out.push(isoFromEpochDay(start + k * step));
  } else if (kind === "yearly") {
    // Always from the anchor, so 29 February comes back in leap years.
    for (let k = 0; k < count; k++) out.push(addMonths(startIso, 12 * rule.interval * k));
  } else {
    const date = parseIsoDate(startIso);
    let first = date.year * 12 + (date.month - 1);
    if (count > 0 && dateInMonth(first, rule) < start) first += 1;
    for (let k = 0; k < count; k++) out.push(isoFromEpochDay(dateInMonth(first + k * rule.interval, rule)));
  }
  return out;
}