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
A month-grid calendar is deceptively hostile to legacy Date because everything it does is a civil operation β "the first of the month," "how many days does this month have," "which weekday does the 1st fall on," "pad the grid back to Monday" β yet Date forces every one of those through a UTC-instant representation that carries a time and a zone you do not want. The classic bug is building a grid on Date and finding that a cell renders the wrong day for users in certain zones, because midnight in the grid's assumed zone is the previous evening in theirs. The calendar you see depends on where you are standing, which is absurd for a wall calendar that should be the same everywhere.
Temporal.PlainDate is the right tool precisely because it has no time and no zone β it is a civil date and nothing else. That means the 1st of the month is unambiguously the 1st, daysInMonth is a plain integer, dayOfWeek is stable, and stepping day by day never risks a DST hiccup. The grid you compute is identical in every zone, which is exactly the property a calendar UI needs. The "trick" is simply to stop reaching for the instant type and use the civil type that models what a calendar actually is.
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
Building the grid is a matter of finding the month's first day, padding backward to the start of the week, and stepping forward a whole number of weeks until the month is covered. Temporal makes each step direct: PlainDate.from({ year, month, day: 1 }) is the first day, first.with({ day: first.daysInMonth }) is the last (there is deliberately no endOfMonth() β with plus daysInMonth expresses it clearly), and first.subtract({ days: first.dayOfWeek - 1 }) pads back to Monday using the ISO weekday numbering. From there you accumulate PlainDate cells in rows of seven until you have passed the last day, which yields the familiar six-row grid.
Two production details make the grid robust. First, make the first day of the week configurable, since a US audience expects Sunday-start and much of the world expects Monday-start; drive it from locale rather than hard-coding, and adjust the initial padding accordingly. Second, tag each cell with whether it belongs to the current month or is a leading/trailing day from an adjacent month, so the UI can grey out the overflow cells β this falls out naturally from comparing each cell's month to the target month. Because every cell is a PlainDate, downstream concerns like "is this today" or "is this date selectable" are simple compare/equals checks, and formatting for display happens only at render time.
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
The first pitfall is building the calendar on Date (or on instants), which makes the rendered grid depend on the viewer's zone and produces off-by-one day cells near midnight. Use PlainDate so the grid is civil and zone-independent. The second is hard-coding a Monday or Sunday week start; make it configurable and derive the leading pad from the chosen first day, or international users see a misaligned grid. The third is looking for an endOfMonth() helper that does not exist β compose it from daysInMonth with with({ day }), which is both available and explicit.
A subtler mistake is using the ISO weekday numbering inconsistently β Temporal's dayOfWeek is Monday=1β¦Sunday=7, so padding math must account for whichever first-day convention you chose. Another is generating the grid by millisecond stepping (+ 86_400_000), which is not just unnecessary on PlainDate but reintroduces the DST drift the civil type exists to avoid; step with add({ days: 1 }). Finally, keep formatting out of the grid construction: build a grid of PlainDate values and format each cell at render time, so the calendar logic stays testable and locale-independent while presentation stays a separate concern.
- 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.
Why use Temporal.PlainDate instead of Date for a calendar UI?
Because a calendar grid is entirely civil β first of the month, days in the month, which weekday the 1st falls on β and PlainDate models exactly that with no time or zone. Building the grid on Date routes those operations through a UTC instant, so cells can render the wrong day for users in some time zones. PlainDate produces an identical grid in every zone, which is what a wall calendar requires, and stepping with add({ days: 1 }) never risks a DST hiccup.
How do I get the last day of the month with Temporal?
There is no endOfMonth() method; compose it from daysInMonth: first.with({ day: first.daysInMonth }). daysInMonth is a plain integer that already accounts for leap years, and with({ day }) sets the day field directly. This is the idiomatic Temporal pattern and is clearer than a dedicated helper because it shows exactly how the last day is derived.
How do I pad a month grid to start on the right weekday?
Find the first day of the month and subtract (dayOfWeek - firstDay) days to reach the start of its week, using Temporal's ISO numbering where Monday is 1 and Sunday is 7. Make the first day of the week configurable from locale, since some audiences expect Sunday-start and others Monday-start, and adjust the pad accordingly. Then accumulate PlainDate cells in rows of seven until the month is covered.