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
~/.cursor/mcp.json
{ "mcpServers": { "provenpin": { "url": "https://mcp.provenpin.com/mcp" } } }.vscode/mcp.json
{ "servers": { "provenpin": { "type": "http", "url": "https://mcp.provenpin.com/mcp" } } }~/.codeium/windsurf/mcp_config.json
{ "mcpServers": { "provenpin": { "serverUrl": "https://mcp.provenpin.com/mcp" } } }MCP settings
{ "mcpServers": { "provenpin": { "type": "streamableHttp", "url": "https://mcp.provenpin.com/mcp" } } }Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.