date-fns 4 + @date-fns/tz time zone math (DST-safe): working versions (tested 2026-10-15)
Schedule and format across time zones with date-fns v4 and TZDate from @date-fns/tz: local-midnight boundaries, DST transitions and UTC storage.
passed4 passed · 0 failed2026-10-15 11:52 UTC- Status
- passed
- Last passed
- 2026-10-15 11:52 UTC
- Tests
- 4 / 4
- Language
- TypeScript
- Runtime
- node v22.23.3
pinned versions4
date-fns@4.4.0@date-fns/tz@1.5.0tsx@4.23.15@types/node@24.19.2
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 38730348195
The code
Exactly the files that passed. Your agent can pull them with get_recipe("date-fns-tz-scheduling") (install), or see them on GitHub.
package.json
{
"name": "date-fns-tz-scheduling",
"private": true,
"type": "module",
"scripts": {
"test": "node --import tsx --test test/*.test.ts"
},
"dependencies": {
"date-fns": "4.4.0",
"@date-fns/tz": "1.5.0"
},
"devDependencies": {
"tsx": "4.23.15",
"@types/node": "24.19.2"
}
}
src/schedule.ts
import { addDays, differenceInHours, format, startOfDay } from "date-fns";
import { TZDate, tz } from "@date-fns/tz";
// "9am every day in the user's zone", stored as UTC ISO strings
export function dailyAt(zone: string, startIso: string, hour: number, days: number) {
const start = startOfDay(new TZDate(startIso, zone));
return Array.from({ length: days }, (_, i) => {
const d = addDays(start, i);
d.setHours(hour, 0, 0, 0); // wall-clock hour in `zone`, even across DST
return new Date(d.getTime()).toISOString();
});
}
export const formatIn = (iso: string, zone: string, pattern = "yyyy-MM-dd HH:mm") => format(new TZDate(iso, zone), pattern);
// start of "today" for a user, as a UTC instant (for DB range queries)
export const startOfDayIn = (iso: string, zone: string) => new Date(startOfDay(iso, { in: tz(zone) }).getTime()).toISOString();
export const hoursBetweenMidnights = (zone: string, dayIso: string) => {
const a = startOfDay(new TZDate(dayIso, zone));
return differenceInHours(addDays(a, 1), a);
};
test/schedule.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import { dailyAt, formatIn, hoursBetweenMidnights, startOfDayIn } from "../src/schedule.ts";
test("9am New York stays 9am across the November DST change", () => {
assert.deepEqual(dailyAt("America/New_York", "2026-10-31T12:00:00Z", 9, 3), [
"2026-10-31T13:00:00.000Z", // EDT (UTC-4)
"2026-11-01T14:00:00.000Z", // EST (UTC-5)
"2026-11-02T14:00:00.000Z",
]);
});
test("format in a zone", () => {
assert.equal(formatIn("2026-07-01T12:00:00Z", "Asia/Tokyo"), "2026-07-01 21:00");
});
test("start of day for a user zone as UTC", () => {
assert.equal(startOfDayIn("2026-10-15T05:00:00Z", "America/Anchorage"), "2026-10-14T08:00:00.000Z");
});
test("DST days are 23 or 25 hours long", () => {
assert.equal(hoursBetweenMidnights("America/New_York", "2026-03-08T12:00:00Z"), 23);
assert.equal(hoursBetweenMidnights("America/New_York", "2026-11-01T12:00:00Z"), 25);
});
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.