How to Use Temporal.PlainDate for Calendar Apps
To build a calendar grid that never shifts by a day, represent each cell as a Temporal.PlainDate — a civil date with no time, offset, or timezone — and do all arithmetic in calendar days. Part of Getting Started with the Temporal API.
Why This Scenario Is Tricky
The legacy Date object stores a UTC millisecond timestamp and re-derives every field through the host's local timezone. A calendar grid built on Date therefore depends on where the code runs. The failure mode is concrete: DST spring-forward produces a 23-hour day and fall-back a 25-hour day, so iterating "add 24 hours per cell" can skip a date or repeat one, collapsing a 7-day week row into 6 cells or duplicating a Sunday.
The second failure is rendering. Even with correct dates, formatting a date through Intl while letting the host zone apply can shift the printed day. A user in UTC-05:00 viewing a date stored as UTC midnight sees the previous day. Temporal.PlainDate removes the entire class of bug because it has no time component to be reinterpreted — arithmetic is in whole calendar days and is identical on every machine.
Minimal Working Solution
Generate a month grid as rows of seven PlainDate instances. Arithmetic is whole-day and immutable, so the result is identical regardless of host zone.
import { Temporal } from '@js-temporal/polyfill';
export function monthGrid(year: number, month: number): Temporal.PlainDate[][] {
const first = Temporal.PlainDate.from({ year, month, day: 1 });
const last = first.with({ day: first.daysInMonth }); // no endOfMonth() exists
// ISO 8601 weekday: Monday=1 ... Sunday=7. Pad so the row starts on Monday.
let cursor = first.subtract({ days: first.dayOfWeek - 1 });
const weeks: Temporal.PlainDate[][] = [];
while (Temporal.PlainDate.compare(cursor, last) <= 0) {
const row: Temporal.PlainDate[] = [];
for (let i = 0; i < 7; i++) {
row.push(cursor);
cursor = cursor.add({ days: 1 }); // returns a new instance; cursor is immutable
}
weeks.push(row);
}
return weeks;
}
Full Production Version
Production code must validate input, reject impossible dates instead of silently clamping, and respect the user's locale for both the first day of the week and the calendar system.
import { Temporal } from '@js-temporal/polyfill';
export interface CalendarGrid {
weeks: Temporal.PlainDate[][];
year: number;
month: number;
}
/** Parse a date, throwing on impossible values instead of clamping them. */
export function safeParse(input: string | Temporal.PlainDateLike): Temporal.PlainDate {
// overflow:'reject' turns Feb 30 into a RangeError; the default 'constrain' clamps it.
return Temporal.PlainDate.from(input, { overflow: 'reject' });
}
/** Locale-aware first weekday: 1 (Mon) .. 7 (Sun); fall back to Monday. */
function firstWeekday(locale: string): number {
const info = (new Intl.Locale(locale) as any).weekInfo;
return info?.firstDay ?? 1; // weekInfo is missing on some older runtimes
}
export function buildGrid(year: number, month: number, locale = 'en-US'): CalendarGrid {
const first = Temporal.PlainDate.from({ year, month, day: 1 });
const last = first.with({ day: first.daysInMonth });
const weekStart = firstWeekday(locale);
// Distance from the configured week start back to the first-of-month weekday.
const lead = (first.dayOfWeek - weekStart + 7) % 7;
let cursor = first.subtract({ days: lead });
const weeks: Temporal.PlainDate[][] = [];
while (Temporal.PlainDate.compare(cursor, last) <= 0) {
const row: Temporal.PlainDate[] = [];
for (let i = 0; i < 7; i++) {
row.push(cursor);
cursor = cursor.add({ days: 1 });
}
weeks.push(row);
}
return { weeks, year, month };
}
/** Format one cell. timeZone:'UTC' keeps the civil date from shifting. */
export function formatCell(
date: Temporal.PlainDate,
locale: string,
calendar: Intl.DateTimeFormatOptions['calendar'] = 'gregory'
): string {
const fmt = new Intl.DateTimeFormat(locale, {
day: 'numeric',
month: 'short',
calendar,
timeZone: 'UTC', // neutralize zone — a PlainDate has no time to reinterpret
});
// Anchor at UTC midnight so no zone conversion can move the day.
return fmt.format(new Date(Date.UTC(date.year, date.month - 1, date.day)));
}
PlainDate cannot cross JSON directly. Serialize with .toString() to YYYY-MM-DD at the edge and rebuild with Temporal.PlainDate.from() on the client; use the ISO string as a useMemo key when navigating months. For event scheduling that needs real wall-clock times, convert to a ZonedDateTime — see Getting Started with the Temporal API.
Verification Snippet
These assertions prove the grid is well-formed and that arithmetic does not depend on the host zone.
import { Temporal } from '@js-temporal/polyfill';
const grid = buildGrid(2024, 2); // February 2024, a leap year
const flat = grid.weeks.flat();
// Every row is exactly 7 cells.
console.assert(grid.weeks.every(w => w.length === 7), 'rows must be 7 wide');
// Feb 29 exists in 2024 and appears exactly once.
const leaps = flat.filter(d => d.month === 2 && d.day === 29);
console.assert(leaps.length === 1, 'leap day present once');
// Reject catches an impossible date instead of clamping to Feb 29.
let threw = false;
try { safeParse('2023-02-29'); } catch { threw = true; }
console.assert(threw, '2023-02-29 must throw under overflow:reject');
Common Pitfalls
- Calling
firstDay.endOfMonth(). It does not exist. Wrong:firstDay.endOfMonth(). Right:firstDay.with({ day: firstDay.daysInMonth }). - Treating
dayOfWeekas 0-indexed. Wrong:firstDay.dayOfWeekused asSunday = 0. Right: it is ISO 8601,Monday = 1 … Sunday = 7; subtractdayOfWeek - 1for a Monday start. - Formatting without a fixed zone. Wrong:
new Intl.DateTimeFormat(locale).format(date). Right: passtimeZone: 'UTC'and anchor atDate.UTC(...)so the day never shifts. - Clamping bad input silently. Wrong:
Temporal.PlainDate.from('2023-02-29')returns Feb 28. Right: pass{ overflow: 'reject' }to surface the error.
FAQ
Does Temporal.PlainDate handle DST for calendar events?
No. PlainDate is intentionally timezone-agnostic and has no DST concept. For an event with a real wall-clock time, convert the date to a Temporal.ZonedDateTime in a specific IANA zone before computing the exact instant.
How should I serialize PlainDate for an API or database?
Use ISO 8601 YYYY-MM-DD strings via .toString(), and rebuild with Temporal.PlainDate.from(). The string is unambiguous and stable across machines, unlike a serialized Date.