
Migrating a Scheduling Feature from Date to Temporal: DST, Durations, and Month Math
On this page
Introduction
The scheduling bug that starts as a time-zone bug
This post is a migration log for a real scheduling feature: moving from Date to Temporal for instants, zoned date-times, DST transitions, durations, and month arithmetic. The starting point was the bug everyone hits first — a ticket that read "recurring meetings show up an hour early twice a year."
Someone adds a timeZone field, patches one comparison, and the bug moves somewhere else — into a "next billing date" calculation, or into a report that groups events by day. I've been through this twice now, and both times the root cause was the same: the code used a single type, Date, to represent four different things — an instant in time, a wall-clock time in a zone, a plain calendar date, and a duration. Date only does the first one honestly, and even then it forces a local-time interpretation on you at the edges.
What this migration covers: instants, zoned date-times, durations, and calendar math
What follows is migration notes, not an API tour. Four decisions account for nearly all the pain: picking the right Temporal type per value, handling DST transitions explicitly, separating exact from calendar durations, and turning month arithmetic into a visible product decision. Every example below runs against the current Temporal API.
A quick status check: Temporal is TC39 Stage 3. Firefox ships it by default; Chromium's implementation has been landing behind a flag; Node does not expose it in a stable release as far as I can tell. Check the compatibility table for your own targets instead of trusting my summary — this is the kind of claim that goes stale in a month. All code here ran through @js-temporal/polyfill.
Why Date Breaks Scheduling Code
Mutable values and implicit local-time conversion
Date is mutable, and its string parser has two different defaults depending on the shape of the string. Date-only ISO strings are parsed as UTC; date-time strings without an offset are parsed as local.
// TZ=America/New_York
new Date('2025-03-09').toISOString(); // '2025-03-09T00:00:00.000Z'
new Date('2025-03-09T00:00').toISOString(); // '2025-03-09T05:00:00.000Z'
Same calendar day, four hours apart. That difference is spec-defined, not a quirk, and it quietly breaks things when a frontend sends 2025-03-09 and the backend builds a Date from it.
Then there is getTimezoneOffset(), which returns minutes inverted — UTC minus local — so -300 in New York winter means five hours behind UTC. I've seen production code add it instead of subtracting.
DST transitions create missing and repeated wall-clock times
In America/New_York, 2025-03-09 02:30 does not exist. The clock goes 01:59:59 → 03:00:00. On 2025-11-02, 01:30 happens twice. A Date cannot represent either situation as a local wall time, because it has no zone attached — it only holds an epoch millisecond count plus the host's current offset rules.
Millisecond arithmetic hides calendar intent
The trap is that millisecond math is correct for instants and wrong for calendar intent, and the syntax is identical either way.
// TZ=America/New_York
const start = new Date('2025-03-08T12:00:00'); // local noon, EST
const next = new Date(start.getTime() + 24 * 60 * 60 * 1000);
console.log(next.toString());
// Sun Mar 09 2025 13:00:00 GMT-0400 (Eastern Daylight Time)
The code says "add a day." The result is 13:00. Nobody wrote 13:00 anywhere. The intent was a calendar day; the implementation was 86,400,000 milliseconds.
Establish a Migration Contract Before Touching Code
Classify each value as an instant, a zoned date-time, or a plain date/time
Before I touch a file, I list every temporal field in the feature and tag it:
| Kind | Example in a scheduling app | Temporal type |
|---|---|---|
| Instant | createdAt, cancelledAt, audit log entries | Temporal.Instant |
| Zoned date-time | The occurrence of a meeting, as scheduled in the org's zone | Temporal.ZonedDateTime |
| Plain date | A holiday, a billing period end, a "due on" date | Temporal.PlainDate |
| Plain time | Office hours 09:00–17:00 | Temporal.PlainTime |
| Duration | "Snooze 3 days", "runs for 90 minutes" | Temporal.Duration |
If a value's classification is genuinely ambiguous, that is a product question, not a code question. Stop and ask.
Decide what gets stored in the database and what crosses the API boundary
My rule: store instants as UTC, store zoned date-times as the pair (instant, IANA zone id), store plain dates as date. Never store a wall time without its zone. A Postgres timestamptz column is an instant; if the intent was "9am local in the user's zone," you need a second text column for the zone.
For the API, Instant serializes to 2025-03-09T07:30:00Z and ZonedDateTime to 2025-03-09T03:30:00-04:00[America/New_York]. The bracketed zone is the valuable part — it survives round-trips and tells the server what "9am" meant.
Check runtime support and choose a polyfill strategy
node -e "console.log(typeof Temporal)"
## undefined (Node does not ship Temporal in stable today)
Two polyfills are worth knowing: @js-temporal/polyfill (maintained alongside the proposal, spec-faithful, larger) and temporal-polyfill (smaller, tracks the final spec shape). I reach for @js-temporal/polyfill when I want the reference behavior and temporal-polyfill when bundle size matters. Either way, import from one module boundary so you can swap later.
Do not conditionally use native Temporal in some environments and a polyfill in others unless you have run the full test suite against both. Small behavioral differences in option handling turn into scheduling bugs nobody can reproduce.
Replace Date With the Right Temporal Type
Use Temporal.Instant for absolute event timestamps
const created = Temporal.Now.instant();
console.log(created.toString()); // '2025-03-08T17:00:00.123Z'
const fromDb = Temporal.Instant.from('2025-03-09T07:30:00Z');
console.log(fromDb.epochMilliseconds); // 1741505400000
Instant has no .year, no .hour, no zone. That's the point — it turns "just read the local hour off the timestamp" into a compile error instead of a bug.
Use Temporal.ZonedDateTime for local scheduling rules and display
const standup = Temporal.ZonedDateTime.from(
'2025-03-10T09:00:00[America/New_York]'
);
console.log(standup.toString());
// '2025-03-10T09:00:00-04:00[America/New_York]'
console.log(standup.toInstant().toString());
// '2025-03-10T13:00:00Z'
Use Temporal.PlainDate and Temporal.PlainTime for recurring local schedules
A "9am office hours, every weekday" rule has no instant. It is a PlainTime plus a zone, resolved at the moment you generate an occurrence. Same for holidays and due dates — those are PlainDate. Billing cycles map well to Temporal.PlainYearMonth; birthdays and anniversaries to Temporal.PlainMonthDay.
Wrap legacy Date values in adapter functions during the transition
Do not do a big-bang rewrite. Add a thin adapter file and route everything through it:
// temporal-adapters.ts
export const toInstant = (d: Date): Temporal.Instant =>
Temporal.Instant.fromEpochMilliseconds(d.getTime());
export const toZoned = (d: Date, timeZone: string): Temporal.ZonedDateTime =>
toInstant(d).toZonedDateTimeISO(timeZone);
// Trimmed for brevity — callers must pass the zone explicitly, never infer it.
export const toLegacyDate = (i: Temporal.Instant): Date =>
new Date(i.epochMilliseconds);
Note that toZoned takes the zone as a required argument. Half the value of this migration is refusing to let the host's zone leak into domain logic.
Handle DST Transitions Explicitly
Detect skipped and repeated local times around a real transition
Temporal.ZonedDateTime.from accepts a disambiguation option, and the default is not "throw."
const gap = Temporal.ZonedDateTime.from({
timeZone: 'America/New_York',
year: 2025, month: 3, day: 9, hour: 2, minute: 30,
});
console.log(gap.toString());
// '2025-03-09T03:30:00-04:00[America/New_York]' — shifted forward
Trying disambiguation: 'reject' throws a RangeError for that same input. For the ambiguous case:
const ambiguous = { timeZone: 'America/New_York', year: 2025, month: 11, day: 2, hour: 1, minute: 30 };
console.log(Temporal.ZonedDateTime.from(ambiguous).toString());
// '2025-11-02T01:30:00-04:00[America/New_York]' (compatible → earlier offset)
console.log(
Temporal.ZonedDateTime.from({ ...ambiguous, disambiguation: 'later' }).toString()
);
// '2025-11-02T01:30:00-05:00[America/New_York]'
Choose a disambiguation policy: earlier, later, reject, or compatible
'compatible' is the default and behaves like 'later' for gaps and 'earlier' for overlaps — matching what legacy Date did, which makes it the safe choice for a like-for-like migration. My own preference, and what I'd argue for in a review: use 'reject' for anything a human explicitly entered. If a user schedules something for 02:30 on spring-forward day, silently moving it to 03:30 is worse than showing an inline error.
'earlier' is right for "when does this window close" semantics where closing early is the safe direction.
Show a before-and-after example using a named IANA time zone
// Before — TZ=America/New_York, host-zone dependent
const d = new Date(2025, 2, 9, 2, 30);
console.log(d.toString());
// Sun Mar 09 2025 03:30:00 GMT-0400 — and different on a UTC machine
// After — explicit zone, explicit policy
const zdt = Temporal.ZonedDateTime.from({
timeZone: 'America/New_York',
year: 2025, month: 3, day: 9, hour: 2, minute: 30,
});
console.log(zdt.toString());
// '2025-03-09T03:30:00-04:00[America/New_York]'
The Date version above produces whatever the host says; the Temporal version produces the same output on every machine, and the policy is right there in the call.
Add tests for spring-forward and fall-back dates, not just offset math
Testing getTimezoneOffset() returns -300 proves nothing about your app. Test that scheduling a repeating weekly event across 2025-03-09 keeps the local hour at 09:00 while the UTC hour shifts by one. Test that a window closing at 02:30 on that date does whatever your policy says it should.
Migrate Duration Arithmetic
Separate exact durations from calendar durations
Temporal.Duration carries calendar units (years, months, weeks, days) alongside exact ones (hours, minutes, seconds, and below). A duration with both is only resolvable relative to a start point, and the API enforces that:
const oneMonth = Temporal.Duration.from({ months: 1 });
console.log(oneMonth.total({ unit: 'days', relativeTo: '2025-02-01[America/New_York]' }));
// 28
Remove the relativeTo and it throws — because a month is not a fixed number of days.
Stop using milliseconds for days, weeks, and months
24 * 60 * 60 * 1000 is an exact duration. { days: 1 } is a calendar duration. They diverge on exactly the days you care about.
Add durations in the correct context with date.add and zonedDateTime.add
const start = Temporal.ZonedDateTime.from('2025-03-08T12:00:00[America/New_York]');
console.log(start.add({ days: 1 }).toString());
// '2025-03-09T12:00:00-04:00[America/New_York]'
console.log(start.add({ hours: 24 }).toString());
// '2025-03-09T13:00:00-04:00[America/New_York]'
Show the output difference between 24 hours and 1 calendar day across DST
| Expression | Result | Local wall time preserved? |
|---|---|---|
start.add({ hours: 24 }) | 2025-03-09T13:00:00-04:00 | No — exact 24 hours |
start.add({ days: 1 }) | 2025-03-09T12:00:00-04:00 | Yes — same local hour |
new Date(t + 86400000) | 13:00 local | No, and implicit |
The rule I follow: if the user said "a day," use days. If the system said "24 hours" (cache TTL, rate limit window, session expiry), use hours.
Do Month Math With PlainDate and Calendar Awareness
Clamp end-of-month results deliberately
Temporal.PlainDate.from('2025-01-31').add({ months: 1 }).toString();
// '2025-02-28'
Temporal.PlainDate.from('2024-01-31').add({ months: 1 }).toString();
// '2024-02-29' (leap year)
Temporal.PlainDate.from('2025-01-31').add({ months: 1 }, { overflow: 'reject' });
// RangeError: overflow
That's a real improvement over Date, where setMonth rolls over instead of clamping:
// TZ=Europe/Berlin
const d = new Date(2025, 0, 31); // 31 Jan
d.setMonth(1);
console.log(d.toDateString());
// 'Mon Mar 03 2025'
February 31st became March 3rd. No error, no warning, and a subscription charged on the wrong day.
Decide whether day-of-month intent or last-day-of-month intent wins
"Subscribe on the 31st" is ambiguous in February. Two reasonable policies:
- Clamp: the 31st becomes the 28th/29th, then returns to the 31st in March. Good for reminders.
- Anchor to month end: the billing date becomes the last day of every month from then on. Good for invoicing.
Temporal does not pick for you, and it shouldn't. overflow: 'constrain' gives you the first; the second needs an explicit PlainYearMonth plus daysInMonth:
const period = Temporal.PlainYearMonth.from('2025-02');
const due = period.toPlainDate({ day: period.daysInMonth });
console.log(due.toString()); // '2025-02-28'
Test January 31 plus one month and similar boundary cases
Add these to your suite: Jan 31 + 1 month (clamp vs. reject), Feb 29 + 12 months, month-end anchoring across a leap year, and a quarterly recurrence starting on the 31st. These are cheap tests that catch real billing bugs.
Keep product decisions visible in code comments and tests
// Product decision (FIN-412): billing anchors to month end, not day-of-month.
// A subscription started on Jan 31 is charged Feb 28, then Mar 31.
function nextBillingDate(period: Temporal.PlainYearMonth): Temporal.PlainDate {
return period.toPlainDate({ day: period.daysInMonth });
}
A reviewer six months from now should not have to guess whether the clamp was intentional.
A Practical Migration Sequence
Add adapter functions around existing Date-based behavior
Start by wrapping, not replacing. Every Date that enters or leaves the scheduling module goes through temporal-adapters.ts.
Run old and new implementations side by side behind a feature flag
Keep the Date path alive. Compute both, compare, log mismatches:
function scheduleOccurrence(input: OccurrenceInput) {
const legacy = legacySchedule(input); // returns Date
const next = temporalSchedule(input); // returns ZonedDateTime
const matches = legacy.getTime() === next.toInstant().epochMilliseconds;
if (!matches) {
logger.warn("scheduling.mismatch", {
input, legacy: legacy.toISOString(), next: next.toString(),
});
}
return flags.temporalScheduling ? next : toLegacyDate(next.toInstant());
}
Compare outputs for real scheduling scenarios, then cut over
Run this against production-shaped inputs, not fixtures. The mismatches you find will cluster around DST weekends and month boundaries — which is what the exercise is for.
Remove Date where it no longer represents the domain
Once the flag is at 100% and the mismatch log is quiet for a full DST transition, delete the legacy path. Keep Date only where a library or platform API demands it.
Verification Checklist and Failure Cases
Test commands and table of expected outputs for DST and month math
npm install @js-temporal/polyfill
TZ=America/New_York node --experimental-strip-types verify-scheduling.ts
| Scenario | Input | Expected |
|---|---|---|
| Spring-forward gap | 2025-03-09T02:30 NY, compatible | 03:30:00-04:00 |
| Spring-forward gap | same, disambiguation: 'reject' | RangeError |
| Fall-back overlap | 2025-11-02T01:30 NY, compatible | 01:30:00-04:00 |
| Fall-back overlap | same, 'later' | 01:30:00-05:00 |
| Calendar day across DST | 2025-03-08T12:00 NY + { days: 1 } | 2025-03-09T12:00-04:00 |
| Exact 24h across DST | same + { hours: 24 } | 2025-03-09T13:00-04:00 |
| Month clamp | 2025-01-31 + 1 month | 2025-02-28 |
| Month clamp | 2024-01-31 + 1 month | 2024-02-29 |
| Month reject | 2025-01-31 + 1 month, overflow: 'reject' | RangeError |
Cover API serialization, database storage, and UI display boundaries
Three places bugs hide: (1) the API receiving a bare 2025-03-09 and treating it as midnight UTC; (2) the database storing a wall time without its zone id; (3) the UI formatting with the browser's zone instead of the event's. Each deserves one integration test.
List what still requires product decisions rather than library fixes
Temporal cannot tell you: which zone a new event defaults to, what happens when a user's zone changes after they schedule something, whether a monthly recurrence anchors to month end, or whether a 02:30 spring-forward input should be rejected. Write those answers down.
Conclusion
Temporal is not a drop-in replacement; it is a better domain model
You cannot sed-replace Date with Temporal.Instant and call it done. Temporal earns its keep by splitting one overloaded type into five honest ones and refusing to guess the zone. That only pays off if you actually make the classification decisions.
The migration succeeds when instants, zones, durations, and calendar intent are explicit
A scheduling feature is only migrated once a future reader can tell, from the types alone, whether a value is an absolute moment, a wall-clock rule, or a calendar day — and whether a duration is exact or calendar-based. Then the DST bug that started this stops being possible, because the code no longer has a place to put the ambiguity.
Further Reading
- TC39 Temporal proposal — the spec source of truth, including the disambiguation and overflow option semantics
- Temporal documentation (tc39.es) — cookbook-style examples for zoned date-times and durations
- MDN: Temporal — object-by-object reference and browser compatibility tables
- MDN: Date — the parsing rules for date-only vs. date-time strings
- IANA Time Zone Database — where the transition rules behind
America/New_Yorkactually come from
More posts

Updating action.yml from runs.using: node20 to node24 Without Breaking Your Action
With Node 20 removed from GitHub Actions runners, this guide walks through updating action.yml from runs.using: node20 to node24 — rebuilding the bundled toolchain, refreshing dependencies, retesting inputs and outputs, and cutting a release that keeps existing workflows green.

Systemd Now Accepts Rust: Where the C/Rust Boundary Still Matters
systemd's official acceptance of Rust for new components marks a real shift in core Linux infrastructure, but C remains the glue for its D-Bus APIs, varlink interfaces, and legacy subsystems that packagers and systems developers still have to navigate.

Where Provider Routing, Caching, and Batching Absorb the 80% LLM Price Cut
A breakdown of how provider routing, prompt caching, and request batching determine whether headline LLM price cuts actually reduce production inference costs.
Loading comments…