Migrating a Scheduling Feature from Date to Temporal: DST, Durations, and Month Math

Migrating a Scheduling Feature from Date to Temporal: DST, Durations, and Month Math

pr0h0••13 min read•coding
AI Usage (93%)
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:

KindExample in a scheduling appTemporal type
InstantcreatedAt, cancelledAt, audit log entriesTemporal.Instant
Zoned date-timeThe occurrence of a meeting, as scheduled in the org's zoneTemporal.ZonedDateTime
Plain dateA holiday, a billing period end, a "due on" dateTemporal.PlainDate
Plain timeOffice hours 09:00–17:00Temporal.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

ExpressionResultLocal wall time preserved?
start.add({ hours: 24 })2025-03-09T13:00:00-04:00No — exact 24 hours
start.add({ days: 1 })2025-03-09T12:00:00-04:00Yes — same local hour
new Date(t + 86400000)13:00 localNo, 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
ScenarioInputExpected
Spring-forward gap2025-03-09T02:30 NY, compatible03:30:00-04:00
Spring-forward gapsame, disambiguation: 'reject'RangeError
Fall-back overlap2025-11-02T01:30 NY, compatible01:30:00-04:00
Fall-back overlapsame, 'later'01:30:00-05:00
Calendar day across DST2025-03-08T12:00 NY + { days: 1 }2025-03-09T12:00-04:00
Exact 24h across DSTsame + { hours: 24 }2025-03-09T13:00-04:00
Month clamp2025-01-31 + 1 month2025-02-28
Month clamp2024-01-31 + 1 month2024-02-29
Month reject2025-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

Share this post

More posts

Loading comments…