ProvenPin Install

rrule recurring events across timezones and DST with luxon: working versions (tested 2026-10-15)

Generate weekly and monthly recurrences with rrule in floating wall-clock time and map them into real IANA zones with luxon, so 9am stays 9am across DST, plus toText, RFC 5545 round trip and BYSETPOS.

passed5 passed · 0 failed2026-10-15 20:10 UTC
Status
passed
Last passed
2026-10-15 20:10 UTC
Tests
5 / 5
Language
TypeScript
Runtime
node v22.23.3
pinned versions4
rrule@2.8.1luxon@3.7.2@types/node@26.6.5tsx@4.23.15
run it
npm install --no-audit --no-fund --ignore-scripts=false
npm test
test environment
node v22.23.3
linux 6.17.0-1022-azure
x64
runner: github-actions 38799498972

The code

Exactly the files that passed. Your agent can pull them with get_recipe("rrule-luxon-timezone-recurrence") (install), or see them on GitHub.

package.json
{
  "name": "rrule-luxon-timezone-recurrence",
  "private": true,
  "type": "module",
  "scripts": {
    "test": "node --import tsx --test test/*.test.ts"
  },
  "dependencies": {
    "rrule": "2.8.1",
    "luxon": "3.7.2"
  },
  "devDependencies": {
    "@types/node": "26.6.5",
    "tsx": "4.23.15"
  }
}
src/schedule.ts
import rrulePkg from "rrule";
import { DateTime } from "luxon";

// rrule 2.8 ships a UMD "main" with no "exports" map, so Node ESM only sees a default export.
export const { RRule, rrulestr } = rrulePkg;
export type RRule = InstanceType<typeof rrulePkg.RRule>;

/**
 * rrule works in "floating" wall-clock time: build the rule with UTC fields equal to the local
 * wall time, then reinterpret each result in the real zone with luxon. That keeps "9:00 every
 * Monday" at 9:00 local across DST changes, independent of the machine TZ.
 */
const floating = (dt: DateTime) => new Date(Date.UTC(dt.year, dt.month - 1, dt.day, dt.hour, dt.minute));
const toZone = (d: Date, zone: string) =>
  DateTime.fromObject(
    { year: d.getUTCFullYear(), month: d.getUTCMonth() + 1, day: d.getUTCDate(), hour: d.getUTCHours(), minute: d.getUTCMinutes() },
    { zone },
  );

export function weekly(startLocalISO: string, zone: string, days: number[], count: number) {
  const start = DateTime.fromISO(startLocalISO, { zone });
  return new RRule({ freq: RRule.WEEKLY, byweekday: days, dtstart: floating(start), count });
}

export function occurrences(rule: RRule, zone: string) {
  return rule.all().map((d) => toZone(d, zone).toISO({ suppressMilliseconds: true }));
}

export function between(rule: RRule, zone: string, fromISO: string, toISO: string) {
  const from = floating(DateTime.fromISO(fromISO, { zone }));
  const to = floating(DateTime.fromISO(toISO, { zone }));
  return rule.between(from, to, true).map((d) => toZone(d, zone).toUTC().toISO({ suppressMilliseconds: true }));
}

export const describe = (rule: RRule) => rule.toText();
export const serialize = (rule: RRule) => rule.toString();
export const parse = (s: string) => rrulestr(s) as RRule;

/** Last business day of each month: BYDAY=MO..FR with BYSETPOS=-1. */
export function lastWeekdayOfMonth(year: number, months: number) {
  const r = new RRule({ freq: RRule.MONTHLY, byweekday: [RRule.MO, RRule.TU, RRule.WE, RRule.TH, RRule.FR], bysetpos: -1, dtstart: new Date(Date.UTC(year, 0, 1)), count: months });
  return r.all().map((d) => d.toISOString().slice(0, 10));
}
test/schedule.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import { RRule, weekly, occurrences, between, describe, serialize, parse, lastWeekdayOfMonth } from "../src/schedule.ts";

test("9am Anchorage stays 9am across the Nov 1 DST change", () => {
  const rule = weekly("2026-10-26T09:00", "America/Anchorage", [RRule.MO.weekday], 3);
  assert.deepEqual(occurrences(rule, "America/Anchorage"), [
    "2026-10-26T09:00:00-08:00",
    "2026-11-02T09:00:00-09:00",
    "2026-11-09T09:00:00-09:00",
  ]);
});

test("same rule as UTC instants shifts by an hour after DST ends", () => {
  const rule = weekly("2026-10-26T09:00", "America/Anchorage", [RRule.MO.weekday], 10);
  assert.deepEqual(between(rule, "America/Anchorage", "2026-10-25T00:00", "2026-11-03T00:00"), [
    "2026-10-26T17:00:00Z",
    "2026-11-02T18:00:00Z",
  ]);
});

test("multi-day rule in another zone", () => {
  const rule = weekly("2027-03-08T18:30", "Europe/London", [RRule.MO.weekday, RRule.WE.weekday], 4);
  assert.deepEqual(occurrences(rule, "Europe/London"), [
    "2027-03-08T18:30:00+00:00",
    "2027-03-10T18:30:00+00:00",
    "2027-03-15T18:30:00+00:00",
    "2027-03-17T18:30:00+00:00",
  ]);
});

test("human text and RFC 5545 string round trip", () => {
  const rule = weekly("2026-10-26T09:00", "America/Anchorage", [RRule.MO.weekday, RRule.TH.weekday], 6);
  assert.equal(describe(rule), "every week on Monday, Thursday for 6 times");
  const s = serialize(rule);
  assert.match(s, /RRULE:FREQ=WEEKLY;BYDAY=MO,TH;COUNT=6/);
  assert.deepEqual(parse(s).all(), rule.all());
});

test("last weekday of month with BYSETPOS", () => {
  assert.deepEqual(lastWeekdayOfMonth(2026, 4), ["2026-01-30", "2026-02-27", "2026-03-31", "2026-04-30"]);
});

Connect to ProvenPin

Add the free MCP server so your agent can pull this recipe and others like it. Same setup as the docs.

terminal
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp

Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.