Agustin Barrientos
All posts

A Date Is Not a Day

Want more of these in your Google results?

A team in Berlin schedules an online workshop for Monday, Aug 31 at 7:00 AM their time, and a product team ships an event card for it. The card has a small, clear job. It shows the host’s start time, shows each viewer their own local time, and warns them when the session lands on a different calendar date for them than the host’s official one, so nobody marks the wrong day. For a viewer in Los Angeles that same 7:00 AM Monday moment is Sunday, Aug 30 at 10:00 PM, the night before. Everyone calls it “the Monday session”, so if the card stays silent that LA viewer blocks out Monday and misses a workshop that, for them, ran Sunday night.

That’s the bug. The timestamp is correct and the formatting is fine, yet the card gave the wrong answer to the one question it existed to answer, which is whether the viewer’s calendar date matches the host’s. That’s the whole product job, to warn when the two dates differ so nobody marks the wrong day. The fix comes down to which question you’re answering when you compare two dates, plus the small judgment that catches this in review before it ships. You might eventually drop some date-library code, but the first thing to drop is the idea that every date problem is a formatting problem. So treat this as neither a campaign to replace date-fns, Luxon, Day.js, or Moment, nor a promise that Temporal is native everywhere yet.

That judgment has a name. The senior move is to classify the kind of time before writing any code. An instant, a calendar date, a wall-clock time, and a duration are four different contracts, and the bug above is that the code committed to none of them. Once you see the four contracts, a whole class of date bugs turns from mysterious into obvious, and Temporal gives you a type for each one so the model is visible in the code instead of living in your head.

We’ll build the card with Temporal, the standards-track date and time API, running on a polyfill, because as of mid-2026, and worth re-checking when you read this, it isn’t yet at universal browser support. More on where it stands shortly, since the honest support picture is part of the lesson.

The same event, two cards, before we touch a line of code. The broken one on the left shows every viewer the host’s day; the correct one on the right converts to your zone and warns you when the date differs. With “Viewing from” on Los Angeles the broken card still says Monday, the host’s day, while the correct one shows your Sunday night, and switching to Berlin, Buenos Aires, or Tokyo makes both agree.

event-time-model
import { Temporal } from 'temporal-polyfill';
import type {
	Clock,
	EventTimeModel,
	EventTimeState,
	EventTimeViewModel,
	UserCalendarLabel,
} from './types';

/**
 * Maps a signed day distance in the user's calendar to the relative word the card shows.
 *
 * Falls back to `'date'` so anything beyond the near window renders as a formatted date rather than a misleading relative word.
 * The `'today'` case is exactly `daysUntil === 0`, which keeps the rendered word locked to the count it came from.
 *
 * @param daysUntil - The signed whole-day distance from the user's today to the event date.
 * @returns The relative word, or `'date'` when the event is outside the yesterday-to-tomorrow window.
 */
export function userCalendarLabel( daysUntil: number ): UserCalendarLabel {
	if ( daysUntil === -1 ) {
		return 'yesterday';
	}

	if ( daysUntil === 0 ) {
		return 'today';
	}

	if ( daysUntil === 1 ) {
		return 'tomorrow';
	}

	return 'date';
}

/**
 * Reads the user's calendar date "now" from an injected clock.
 *
 * Going through the clock rather than `Temporal.Now` directly is what lets a demo freeze "now".
 *
 * @param clock - The clock to read the current instant from.
 * @param timeZone - The IANA zone whose calendar date is wanted.
 * @returns The plain calendar date in that zone at the clock's instant.
 */
export function getTodayInZone( clock: Clock, timeZone: string ): Temporal.PlainDate {
	return clock.now.toZonedDateTimeISO( timeZone ).toPlainDate();
}

/**
 * Builds the product-facing cross-zone warning from the same-day boolean, or `null` when the dates match.
 *
 * The product's whole job is that nobody marks the wrong day, so the sentence names the real calendar date in each zone, not just the weekday.
 *
 * @param sameCalendarDay - Whether the event lands on one calendar date for both zones.
 * @param eventDate - The event's calendar date in the host's zone.
 * @param userDate - The event's calendar date in the user's zone.
 * @param locale - The BCP 47 locale the dates are formatted in.
 * @returns The warning sentence naming both dates, or `null` when both zones share the date.
 */
export function buildCrossZoneWarning(
	sameCalendarDay: boolean,
	eventDate: Temporal.PlainDate,
	userDate: Temporal.PlainDate,
	locale: string,
): string | null {
	if ( sameCalendarDay ) {
		return null;
	}

	const dateFields = { weekday: 'long', month: 'short', day: 'numeric' } as const;
	const hostDate = eventDate.toLocaleString( locale, dateFields );
	const yourDate = userDate.toLocaleString( locale, dateFields );

	return `Heads up: this session is listed as ${ hostDate } in the host's time zone, but it falls on ${ yourDate } for you.`;
}

/**
 * Derives everything the card renders from the parsed model and a clock.
 *
 * The instant is converted into both zones first, then the two plain dates are compared for the same-day question, and the calendar-day distance powers the today label.
 * No millisecond math touches the calendar-day count, so a DST day still counts as a single day.
 *
 * @param model - The parsed event inputs.
 * @param clock - The clock supplying "now" for the today label and countdown.
 * @param locale - The BCP 47 locale the cross-zone warning's dates are formatted in.
 * @returns The resolved view model the template reads from.
 */
export function deriveViewModel( model: EventTimeModel, clock: Clock, locale = 'en-US' ): EventTimeViewModel {
	const eventZonedDateTime = model.instant.toZonedDateTimeISO( model.eventZone );
	const userZonedDateTime = model.instant.toZonedDateTimeISO( model.userZone );
	const eventDateInEventZone = eventZonedDateTime.toPlainDate();
	const eventDateInUserZone = userZonedDateTime.toPlainDate();

	// The same-day question: one instant, two zones, compared as calendar dates.
	const sameCalendarDay = eventDateInEventZone.equals( eventDateInUserZone );

	// The today question runs off the same plain dates, against today in the user's zone.
	const todayInUserZone = getTodayInZone( clock, model.userZone );
	const daysUntilInUserCalendar = todayInUserZone.until(
		eventDateInUserZone,
		{ largestUnit: 'days' },
	).days;

	return {
		eventZonedDateTime,
		userZonedDateTime,
		eventDateInEventZone,
		eventDateInUserZone,
		sameCalendarDay,
		crossZoneWarning: buildCrossZoneWarning( sameCalendarDay, eventDateInEventZone, eventDateInUserZone, locale ),
		daysUntilInUserCalendar,
		userCalendarLabel: userCalendarLabel( daysUntilInUserCalendar ),
	};
}

/**
 * Parses the raw attributes into an `EventTimeModel`.
 *
 * This is the only place a string becomes a `Temporal.Instant`, so the parsing boundary stays in one spot.
 *
 * @param startsAt - The canonical start as an ISO 8601 instant string.
 * @param eventZone - The IANA zone that owns the event.
 * @param userZone - The IANA zone the user reads it in.
 * @returns The parsed model.
 */
export function parseEventTimeModel(
	startsAt: string,
	eventZone: string,
	userZone: string,
): EventTimeModel {
	return {
		instant: Temporal.Instant.from( startsAt ),
		eventZone,
		userZone,
	};
}

/**
 * Resolves the raw attributes into one of the three explicit states the card renders.
 *
 * A blank attribute is `missing-input`. A value that fails to parse, a malformed instant or an unknown IANA zone, is `invalid-input` carrying the real `RangeError` message, so a wrong assumption fails loudly instead of swallowing into the same blank as missing input.
 *
 * @param startsAt - The canonical start as an ISO 8601 instant string.
 * @param eventZone - The IANA zone that owns the event.
 * @param userZone - The IANA zone the user reads it in.
 * @param clock - The clock supplying "now" for the today label and countdown.
 * @param locale - The BCP 47 locale the warning and times are formatted in.
 * @returns The resolved state: missing input, invalid input with a message, or a ready view model.
 */
export function buildEventTimeState(
	startsAt: string,
	eventZone: string,
	userZone: string,
	clock: Clock,
	locale = 'en-US',
): EventTimeState {
	if ( ! startsAt || ! eventZone || ! userZone ) {
		return { status: 'missing-input' };
	}

	try {
		const model = parseEventTimeModel( startsAt, eventZone, userZone );
		return { status: 'ready', view: deriveViewModel( model, clock, locale ) };
	} catch ( error ) {
		const message = error instanceof Error ? error.message : 'The event time could not be parsed.';
		return { status: 'invalid-input', message };
	}
}

/**
 * Formats a zoned date-time for display, with explicit fields plus the zone name.
 *
 * The fields are listed one by one because the shorthand `dateStyle` and `timeStyle` together with `timeZoneName` throws a `TypeError: Invalid option`.
 *
 * @param value - The zoned date-time to format.
 * @param locale - The BCP 47 locale tag to format in.
 * @returns The localized string, including the short zone name.
 */
export function formatZonedDateTime( value: Temporal.ZonedDateTime, locale: string ): string {
	return value.toLocaleString( locale, {
		year: 'numeric',
		month: 'short',
		day: 'numeric',
		hour: 'numeric',
		minute: '2-digit',
		timeZoneName: 'short',
	} );
}

/**
 * Formats a zoned date-time for display with the same fields as `formatZonedDateTime` but with no zone name.
 *
 * It deliberately omits the zone, so the wall-clock reading shows as if it were the viewer's own local time.
 * That's exactly the silent cross-zone bug the opener demo dramatizes, where a foreign zone's clock is presented with no qualifier and the reader blocks the wrong day.
 * Real product code should prefer `formatZonedDateTime` so the zone is never hidden; this exists only to stage that mistake on purpose.
 *
 * @param value - The zoned date-time to format.
 * @param locale - The BCP 47 locale tag to format in.
 * @returns The localized string, with no zone name.
 */
export function formatWallClockDateTime( value: Temporal.ZonedDateTime, locale: string ): string {
	return value.toLocaleString( locale, {
		year: 'numeric',
		month: 'short',
		day: 'numeric',
		hour: 'numeric',
		minute: '2-digit',
	} );
}

/**
 * Formats a plain calendar date for display, with no time and no zone.
 *
 * Used for the `'date'` label, where a relative word would mislead, so the card shows the actual calendar date instead.
 *
 * @param value - The plain date to format.
 * @param locale - The BCP 47 locale tag to format in.
 * @returns The localized calendar date.
 */
export function formatPlainDate( value: Temporal.PlainDate, locale: string ): string {
	return value.toLocaleString( locale, {
		year: 'numeric',
		month: 'short',
		day: 'numeric',
	} );
}
The panel shows the component source. Styles live in the component's Lit static styles, scoped to its shadow root.

The bug nobody notices in code review

The warning the card shows, or doesn’t, comes down to one boolean, and the few lines below compute it wrong. The event is one instant. One half of the comparison is formatted in the host’s zone, which is right. The other half is supposed to be formatted in the user’s zone, but the code lets the device zone sneak in instead. The catch is in how the user’s zone gets resolved.

scratch.ts
// scratch.ts, the naive same-day check
const deviceTimeZone = simulatedDeviceTimeZone; // a visible control in the demo, not the host default
const calendarDayText = { year: 'numeric', month: '2-digit', day: '2-digit' } as const;

// The host's day, formatted in the event's own zone. This half is right.
const hostDay = new Intl.DateTimeFormat( 'en-CA', {
	timeZone: eventTimeZone,
	...calendarDayText,
} ).format( eventInstant );

// Broken: uses the simulated device zone instead of the selected user zone.
const userDay = new Intl.DateTimeFormat( 'en-CA', {
	timeZone: deviceTimeZone,
	...calendarDayText,
} ).format( eventInstant );

const sameCalendarDay = hostDay === userDay;

The host half is computed in the event’s zone on purpose. The user half falls back to the device zone instead of the selected user zone, and that’s the whole bug. The card raises its cross-zone warning when sameCalendarDay is false, so when this boolean comes back true by mistake the warning never fires. Both sides format the same correct instant, both use Intl.DateTimeFormat, and on a developer machine that happens to sit in a zone that agrees with the host it even looks right in review. The model is split. The two sides of the comparison rest on different zone assumptions, and nothing in the code makes that mismatch loud.

What makes it so easy to miss is that it self-masks in exactly the environments you’d test from. If the user side reads the host zone directly, the card only stays wrongly silent when the reader’s own laptop sits in a Monday zone, and it warns correctly for anyone whose device is already on Sunday. So the demo below never reads your real machine zone. It exposes a simulated device zone as a visible, pinned control, set to the host’s Europe/Berlin, and a separate user-zone selector set to America/Los_Angeles. The event instant is 2026-08-31T05:00:00Z (Aug 31, 2026, 5:00 AM UTC). At that moment Berlin has rolled to Monday the 31st and Los Angeles is still on Sunday the 30th, so the card should warn, and the broken row, reading the device zone, matches the host’s day and computes sameCalendarDay as true, showing no warning at all.

The demo lays three versions side by side and shows the raw same-day boolean each one computes, so the simpler fix is on the table from the start rather than hidden to make Temporal look necessary. With the simulated device zone left on Berlin, drag the user-zone selector to Tokyo or London and the broken row’s boolean lines up with the correct one again, because those zones really do share Monday with the host. The bug isn’t random. It misfires for exactly the users whose real calendar disagrees with the device’s, and that’s the population the warning was for.

event-day-naive
import { LitElement, html, css } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { Temporal } from 'temporal-polyfill';
import type { NaiveDayResult } from './types';

/**
 * The pre-Temporal baseline, laying three answers to the same-day question side by side.
 *
 * The first row is the bug the article opens on: the venue half is formatted in the event's zone on purpose, and the user half falls back to a simulated device zone instead of the selected user zone, so the two sides rest on different zone assumptions.
 * The second row passes the missing `timeZone` option and gets the right answer with plain Date and Intl.
 * The third row is the Temporal model, correct and reviewable.
 *
 * The device zone is a visible, pinned control, never a read of the reader's real host zone, so the broken row lies deterministically for every reader.
 *
 * @element event-day-naive
 */
@customElement( 'event-day-naive' )
export class EventDayNaive extends LitElement {

	/**
	 * The canonical event start as an ISO 8601 instant string.
	 */
	@property( { attribute: 'starts-at' } )
	startsAt = '2026-08-31T05:00:00Z';

	/**
	 * The IANA time zone that owns the event.
	 */
	@property( { attribute: 'event-time-zone' } )
	eventTimeZone = 'Europe/Berlin';

	/**
	 * The simulated device time zone the broken row reads instead of the selected user zone.
	 *
	 * Pinned to the host's `Europe/Berlin` so the broken user day matches the venue and the warning wrongly stays silent. It's a stand-in for `Intl.DateTimeFormat`'s default zone, never a read of where the reader runs it.
	 */
	@state()
	private deviceTimeZone = 'Europe/Berlin';

	/**
	 * The user zone the product actually selected, which the broken row ignores.
	 *
	 * Initialized to the neutral example `America/Los_Angeles`, a reader-controlled value, never a read of the host zone.
	 */
	@state()
	private userTimeZone = 'America/Los_Angeles';

	/**
	 * The zones offered in both selectors, a small set chosen so the divergence is easy to see.
	 */
	private readonly zones = [
		'Europe/Berlin',
		'America/Los_Angeles',
		'America/Argentina/Buenos_Aires',
		'Europe/London',
		'Asia/Tokyo',
	];

	/**
	 * Styles for the harness, scoped to its shadow root.
	 */
	static styles = css`
		:host {
			display: block;
			height: 100%;
			font-family: var( --font-sans, system-ui, sans-serif );
			color: var( --ink, #17171a );
		}

		.wrap {
			display: grid;
			gap: 18px;
			padding: 22px;
		}

		.controls {
			display: flex;
			flex-wrap: wrap;
			gap: 16px;
		}

		label {
			display: flex;
			flex-direction: column;
			gap: 6px;
		}

		.lbl {
			font-family: var( --font-mono, monospace );
			font-size: 10px;
			letter-spacing: 0.08em;
			text-transform: uppercase;
			color: var( --ink-faint, #71717b );
		}

		select {
			font-family: var( --font-sans, sans-serif );
			font-size: 14px;
			color: var( --ink, #17171a );
			background: var( --bg, #fcfcfb );
			border: 1px solid var( --line, #e7e6e2 );
			border-radius: 7px;
			padding: 8px 10px;
		}

		table {
			width: 100%;
			border-collapse: collapse;
			font-size: 13.5px;
		}

		th,
		td {
			text-align: left;
			padding: 9px 10px;
			border-bottom: 1px solid var( --line, #e7e6e2 );
		}

		th {
			font-family: var( --font-mono, monospace );
			font-size: 10px;
			letter-spacing: 0.06em;
			text-transform: uppercase;
			color: var( --ink-faint, #71717b );
			font-weight: 600;
		}

		.same.yes {
			color: color-mix( in srgb, #1f9d5b 65%, var( --ink, #17171a ) );
			font-weight: 600;
		}

		.same.no {
			color: color-mix( in srgb, #cc3b41 65%, var( --ink, #17171a ) );
			font-weight: 600;
		}

		.tag {
			display: inline-block;
			border-radius: 999px;
			padding: 2px 9px;
			font-family: var( --font-mono, monospace );
			font-size: 10.5px;
		}

		.tag.wrong {
			background: rgb( 204 59 65 / 0.12 );
			color: color-mix( in srgb, #cc3b41 65%, var( --ink, #17171a ) );
		}

		.tag.right {
			background: rgb( 31 157 91 / 0.12 );
			color: color-mix( in srgb, #1f9d5b 65%, var( --ink, #17171a ) );
		}

		.hint {
			margin: 0;
			font-family: var( --font-mono, monospace );
			font-size: 10.5px;
			line-height: 1.6;
			color: var( --ink-soft, #56565c );
		}
	`;

	/**
	 * Formats one instant as a same-locale day string in a given zone, the Date-and-Intl way the baseline uses.
	 *
	 * The comparison downstream only holds because both sides use this same locale and options. `Intl` output is locale-formatted text, not a guaranteed ISO string, so it must never be compared to a hardcoded constant.
	 *
	 * @param instant - The moment to format.
	 * @param timeZone - The zone to format it in.
	 * @returns The day string in that zone, in the `en-CA` format.
	 */
	private calendarDayTextInZone( instant: Date, timeZone: string ): string {
		return new Intl.DateTimeFormat( 'en-CA', {
			timeZone,
			year: 'numeric',
			month: '2-digit',
			day: '2-digit',
		} ).format( instant );
	}

	/**
	 * Computes the three approaches against the current zones.
	 *
	 * The broken row reads `deviceTimeZone` for the user side, the minimum-fix row reads `userTimeZone`, and the Temporal row compares plain dates after converting the instant into each zone.
	 *
	 * @returns The three labeled result rows, in display order.
	 */
	private get results(): NaiveDayResult[] {
		const date = new Date( this.startsAt );
		const hostDay = this.calendarDayTextInZone( date, this.eventTimeZone );

		// Broken: the user side falls back to the simulated device zone, not the selected user zone.
		const brokenUserDay = this.calendarDayTextInZone( date, this.deviceTimeZone );

		// Minimum fix: pass the selected user zone to the user side.
		const fixedUserDay = this.calendarDayTextInZone( date, this.userTimeZone );

		// Temporal model: convert the instant into each zone, then compare calendar dates.
		const instant = Temporal.Instant.from( this.startsAt );
		const eventDate = instant.toZonedDateTimeISO( this.eventTimeZone ).toPlainDate();
		const userDate = instant.toZonedDateTimeISO( this.userTimeZone ).toPlainDate();
		const temporalSameDay = eventDate.equals( userDate );

		const correctSameDay = hostDay === fixedUserDay;

		return [
			{
				approach: 'Broken Date + Intl',
				userDay: brokenUserDay,
				sameCalendarDay: hostDay === brokenUserDay,
				// Truthful even in the device-equals-user edge case: the broken row is only
				// "correct" when its same-day answer happens to match the right one, which it
				// does when the device zone and the selected user zone are the same.
				correct: ( hostDay === brokenUserDay ) === correctSameDay,
			},
			{
				approach: 'Minimum Date + Intl fix',
				userDay: fixedUserDay,
				sameCalendarDay: correctSameDay,
				correct: true,
			},
			{
				approach: 'Temporal model',
				userDay: userDate.toString(),
				sameCalendarDay: temporalSameDay,
				correct: true,
			},
		];
	}

	/**
	 * Updates the simulated device zone from its selector.
	 *
	 * @param event - The change event from the device-zone select.
	 */
	private onDeviceChange = ( event: Event ): void => {
		if ( event.target instanceof HTMLSelectElement ) {
			this.deviceTimeZone = event.target.value;
		}
	};

	/**
	 * Updates the selected user zone from its selector.
	 *
	 * @param event - The change event from the user-zone select.
	 */
	private onUserChange = ( event: Event ): void => {
		if ( event.target instanceof HTMLSelectElement ) {
			this.userTimeZone = event.target.value;
		}
	};

	/**
	 * Renders the two zone selectors and the three-row comparison table.
	 */
	render() {
		const results = this.results;
		const [ broken ] = results;
		// The broken row's verdict depends on both selectors, so the hint tracks the current controls rather than assuming the Berlin default.
		const brokenState = broken.correct
			? 'Right now it happens to match the correct answer, but only because the simulated device zone produces the same calendar-day answer as the selected user zone.'
			: 'Right now it\'s wrong, because the simulated device zone produces a different calendar-day answer than the selected user zone.';

		return html`
			<div class="wrap">
				<div class="controls">
					<label>
						<span class="lbl">Simulated device zone</span>
						<select @change=${ this.onDeviceChange }>
							${ this.zones.map(
								( zone ) => html`<option value=${ zone } ?selected=${ zone === this.deviceTimeZone }>${ zone }</option>`,
							) }
						</select>
					</label>
					<label>
						<span class="lbl">Selected user zone</span>
						<select @change=${ this.onUserChange }>
							${ this.zones.map(
								( zone ) => html`<option value=${ zone } ?selected=${ zone === this.userTimeZone }>${ zone }</option>`,
							) }
						</select>
					</label>
				</div>
				<table>
					<thead>
						<tr>
							<th>Approach</th>
							<th>User day</th>
							<th>Same day?</th>
							<th>Verdict</th>
						</tr>
					</thead>
					<tbody>
						${ results.map(
							( result ) => html`
								<tr>
									<td>${ result.approach }</td>
									<td>${ result.userDay }</td>
									<td class=${ result.sameCalendarDay ? 'same yes' : 'same no' }>
										${ result.sameCalendarDay ? 'yes' : 'no' }
									</td>
									<td>
										<span class=${ result.correct ? 'tag right' : 'tag wrong' }>
											${ result.correct ? 'correct' : 'wrong' }
										</span>
									</td>
								</tr>
							`,
						) }
					</tbody>
				</table>
				<p class="hint">
					The broken row formats the user side in the simulated device zone, not the selected user zone. ${ brokenState }
				</p>
			</div>
		`;
	}
}

declare global {
	interface HTMLElementTagNameMap {
		'event-day-naive': EventDayNaive;
	}
}
The panel shows the component source. Styles live in the component's Lit static styles, scoped to its shadow root.

That’s more interesting than “Date is bad”. The code looks intentional, it passes a casual read, and it fails for one nameable reason. It uses the device zone instead of the selected user zone.

The minimum fix isn’t the full model

A sharp reader pushes back here immediately, and they’re right. This bug is fixable with plain Date and Intl.DateTimeFormat by passing the one option you left out. Give the user side the zone the product actually selected.

ts
// The minimum fix: give the user side the selected zone too.
const userDay = new Intl.DateTimeFormat( 'en-CA', {
	timeZone: userTimeZone,
	...calendarDayText,
} ).format( eventInstant );

That works for the same-day comparison. With the user side formatted in the selected user zone, the two same-locale day strings line up correctly, and the same-day answer is right for the canonical input. You can see it in the demo above, where the middle row is exactly this fix and its verdict is “correct”. So let me be plain about it, because the article that pretends otherwise loses the reader’s trust. Date with Intl can answer the same-day question.

So why move to Temporal at all? Not because of a comparison Date can’t perform, but because of the model around it.

  • The Intl version compares formatted strings, not calendar-date values, so the comparison is only as trustworthy as the format string on each side.
  • The same-day question, the today question, and the countdown question each start duplicating their own parsing and formatting, and the duplication is where the next bug hides.
  • Date gives you no first-class PlainDate, Instant, or ZonedDateTime to name the product meanings, so the meaning lives in variable names and comments instead of types.
  • The day countdown across a daylight-saving boundary can’t be reviewed cleanly through millisecond math, as the third demo will show.
  • Temporal makes the product model visible in the types, which is the thing you’re actually reviewing.

Passing the missing timeZone option fixes this bug. Temporal fixes the review model. The rest of the build is about that second sentence.

A quick note on letting AI code this

An AI writes this kind of code well and fast, so hand it the card and let it. The value you bring is being a capable enough developer to know what good looks like for this exact problem and to ask the right questions to get there, the difference between a vague “show whether it’s the same day” and asking for the instant converted into the user’s zone, a PlainDate comparison for the same-day check, and a calendar-day count rather than elapsed milliseconds. That judgment is what tells a model that nailed the contracts from one that merely compiles, and it matters more as the models improve.

Two questions, then the contracts

The card answers two questions that look like one, and keeping them apart is most of the skill.

The first is the same-day question, the headline the card leads with. Does this one instant fall on the same calendar day in the venue’s zone and in the user’s zone? In code that’s eventDateInEventZone.equals( eventDateInUserZone ). It needs the event instant and two zones, and nothing else.

The second is the today question. Is this event today, or tomorrow, on the user’s calendar? In code that’s eventDateInUserZone.equals( todayInUserZone ), and it needs one more thing the first didn’t, a notion of “now” in the user’s zone. These read alike and answer different things, and conflating them is exactly the kind of mistake this card teaches you to catch. The canonical case is chosen so they visibly diverge. For the Los Angeles user the event isn’t the same calendar day as the venue, and it can still be today for them, which is the moment the two questions come apart on screen.

Underneath both sits the senior judgment, stated as a table. A junior sees an ISO string and reaches for new Date() and a formatter. A senior sees separate contracts and asks, for each, which Temporal type the product actually means.

Table Each kind of time the card handles, the instinct that gets it wrong, and the Temporal type that names it.
Product meaningWrong instinctTemporal modelSenior question
A precise momentDate everywhereTemporal.InstantIs this an instant in global time?
A venue-local event timeLocal machine DateTemporal.ZonedDateTimeWhat time zone owns the event?
A user’s calendar daygetDate()Temporal.PlainDate in the user’s zoneWhose calendar are we comparing?
A countdown in daysMilliseconds divided by daysTemporal.Duration via PlainDate.untilAre we counting elapsed time or calendar days?
Display textDate math mixed with formattingFormatting at the edgeIs this logic or presentation?

That table is the review checklist. New APIs don’t replace this judgment, they move it. With Temporal the question shifts from “which date helper formats this?” to “which Temporal type represents the thing the product actually means?”, and every row maps onto a type the build ships. Recurring wall-clock events, the “every Tuesday at 9am local” pattern, want a PlainTime plus a zone rule, and they’re left out here on purpose so the checklist matches the code we actually write. They’re a fine thing to add once the model is in your hands.

The bar we’re holding it to

The finished component has to:

  • parse the event start as an instant,
  • convert that instant into the event’s zone and into the user’s zone,
  • answer the same-day question as eventDateInEventZone.equals( eventDateInUserZone ), comparing calendar dates after the conversion, never before,
  • derive a user-calendar label, one of 'yesterday', 'today', 'tomorrow', or 'date', from the calendar-day distance, with the 'today' case landing exactly on a distance of zero, and keep it visibly distinct from the same-day answer,
  • count days as calendar days, not milliseconds,
  • keep the temporal logic out of the formatting,
  • make “now” injectable so a demo is deterministic,
  • and take the user zone as a required product input, not a silent machine default.

A few concrete cases to measure against:

  • Los Angeles user, Berlin event, instant 2026-08-31T05:00:00Z (Aug 31, 2026, 5:00 AM UTC): same-day “no”, the headline divergence.
  • Tokyo user, same event, same instant: same-day “yes”, both on Monday. Knowing that the conversion doesn’t change the answer here is also the skill.
  • Today label, Los Angeles user, “now” pinned to 2026-08-30T19:00:00Z (Aug 30, 2026, 7:00 PM UTC), which is noon in Los Angeles: the label resolves to 'today' even though the event isn’t the same calendar day as the host.
  • A day countdown across the 2026-03-08 US spring-forward: a naive millisecond count says two days, the calendar count says three.

What you’ll need

You’ll want to be comfortable with TypeScript and the DOM. No Temporal experience required, and no Lit experience either, since it’s a thin layer over web components and I’ll explain the Lit-specific bits inline. Beyond that you need Lit for the component wrapper, the temporal-polyfill package, and your browser devtools to poke at the values. No legacy date library is needed for the demo. The only date dependency here is the polyfill, used to teach the platform model before native support is universal.

A word on that support, since it’s the article’s central caveat and it belongs right next to the hook. Temporal is a TC39 Stage 4 proposal, approved and headed into the language, on its way to being merged into ECMA-262 and ECMA-402. It just isn’t yet at universal browser support. Chrome, Edge, and Firefox ship it, Node and Deno ship it, and Safari doesn’t ship it to stable yet, so on Safari and iOS today it lives behind a flag in Technology Preview. That puts global support around 66 percent as of this writing.

Browser supportTemporal71% of users
Chrome144
Edge144
Firefox139
SafariPreview
Safari iOSNo
Chrome Android144

Browser data updated September 24, 2026

As of the time of writing that’s under the rough 80 percent bar this project uses to decide a support table earns its place, and the honest read is to treat Temporal as polyfill-backed in production for now. The table above has the current picture. Size the cost to the surface you actually pull in. In this site’s demo bundle the class-API path that this card imports measured around 36 kB gzipped, against the roughly 20 kB the temporal-polyfill package advertises for its tree-shaken functional build, so treat the larger number as what the full class API costs here, not an official figure. It passes straight through to native Temporal when the engine provides it, and every method this build relies on runs correctly under it. The point isn’t to retire your date library today. You may still ship a date library or a polyfill for a while yet, and what should change immediately is how you review the model.

Try it yourself first

Before reading on, give it a go. Take a single instant and a pair of zones, convert the instant into each zone, and compare the resulting calendar dates rather than the raw instant. Then ask yourself the second question separately. Against “now” in the user’s zone, is the event today, tomorrow, or further out? Watch for the case where the same-day answer and the today answer are allowed to disagree, and let that gap stand, because that disagreement is what tells you the two questions were worth keeping apart.

Building it

Each step shows only the new or changed slice. The full types.ts and the framework-free event-time-model.ts come together at the very end, so you can read the model build as the literal sum of the steps; the Lit component that wraps them is folded in at the end, since its shell and CSS aren’t the lesson.

Step 1: The naive version, and why it lies

The split-zone version commits to nothing. It formats one correct instant two ways and compares the strings, and the only thing keeping it honest is whether the two format strings happen to agree on a zone. On the canonical input they don’t, so it lies, deterministically, for the Los Angeles user. The first demo above is that version, pinned so the lie doesn’t depend on where you run it.

Step 2: The minimum fix, stated honestly

Passing userTimeZone to the user side fixes the same-day comparison, as the middle row of that demo shows. Date with Intl answers the same-day question correctly once both sides agree on a zone. We move to Temporal for the model, the types, and the reviewability of the next two questions, where string-and-millisecond code starts to fray.

Step 3: Name the data model

Before any conversion, decide what each input is. This is the step a junior skips and a senior leads with. The startsAt value is an instant, the one moment the event happens, and treating it as a calendar date is the original sin. The event zone is a product rule. The user zone is a required display-and-comparison input. The types go in types.ts, and the relative-word union is its own named type so the today label has a real contract rather than a loose string.

types.ts
// types.ts
import type { Temporal } from 'temporal-polyfill';

/**
 * The relative word the card shows for the event on the user's own calendar.
 *
 * This is the second question the component answers, kept separate from the same-day question on purpose.
 * `'date'` is the fallback for anything outside the near window, so a value weeks out renders as a formatted date rather than a misleading relative word.
 */
export type UserCalendarLabel = 'yesterday' | 'today' | 'tomorrow' | 'date';

/**
 * The clock the model reads "now" from.
 *
 * Wrapping `Temporal.Now.instant()` behind this one field is what makes every demo deterministic: production reads the real instant, a demo passes a frozen one.
 */
export interface Clock {

	/**
	 * The instant treated as "now" for the today label and the calendar-day countdown.
	 */
	now: Temporal.Instant;
}

/**
 * The parsed inputs the model works from, before any zone conversion.
 *
 * `instant` is the one canonical moment the event happens, `eventZone` is the product rule for the venue, and `userZone` is the required display-and-comparison input.
 */
export interface EventTimeModel {

	/**
	 * The single canonical moment the event starts, parsed from the `startsAt` string.
	 */
	instant: Temporal.Instant;

	/**
	 * The IANA time zone that owns the event, a product rule rather than a machine read.
	 */
	eventZone: string;

	/**
	 * The IANA time zone the user reads the event in, a required input the product decides, never the host default.
	 */
	userZone: string;
}

Step 4: Convert before comparing

Now the move that fixes the model rather than the symptom. Parse the instant, convert it into each zone with toZonedDateTimeISO, take the PlainDate out of each, and compare those. The same-day question becomes one expression that says exactly what it means.

event-time-model.ts
// event-time-model.ts, the same-day question
const instant = Temporal.Instant.from( startsAt );
const eventZonedDateTime = instant.toZonedDateTimeISO( eventTimeZone );
const userZonedDateTime = instant.toZonedDateTimeISO( userTimeZone );
const eventDateInEventZone = eventZonedDateTime.toPlainDate();
const eventDateInUserZone = userZonedDateTime.toPlainDate();

// The same-day question: one instant, two zones, compared as calendar dates.
const sameCalendarDay = eventDateInEventZone.equals( eventDateInUserZone );

The difference from the opener is that there are no strings in the comparison. eventDateInUserZone is a calendar date, not a formatted line, and equals compares the year, month, and day directly. The zone is named before the date is compared, so a reviewer can see the conversion happen.

The second demo holds the event instant fixed and lets you switch the user zone. The host stays on Monday the 31st in Berlin, and the user’s calendar day flips as you move. Los Angeles reads Sunday the 30th, so same-day is “no”, while Buenos Aires, London, and Tokyo read Monday the 31st, so it’s “yes”. The instant never changed; only the zone you read it in did.

event-time-model
import { Temporal } from 'temporal-polyfill';
import type {
	Clock,
	EventTimeModel,
	EventTimeState,
	EventTimeViewModel,
	UserCalendarLabel,
} from './types';

/**
 * Maps a signed day distance in the user's calendar to the relative word the card shows.
 *
 * Falls back to `'date'` so anything beyond the near window renders as a formatted date rather than a misleading relative word.
 * The `'today'` case is exactly `daysUntil === 0`, which keeps the rendered word locked to the count it came from.
 *
 * @param daysUntil - The signed whole-day distance from the user's today to the event date.
 * @returns The relative word, or `'date'` when the event is outside the yesterday-to-tomorrow window.
 */
export function userCalendarLabel( daysUntil: number ): UserCalendarLabel {
	if ( daysUntil === -1 ) {
		return 'yesterday';
	}

	if ( daysUntil === 0 ) {
		return 'today';
	}

	if ( daysUntil === 1 ) {
		return 'tomorrow';
	}

	return 'date';
}

/**
 * Reads the user's calendar date "now" from an injected clock.
 *
 * Going through the clock rather than `Temporal.Now` directly is what lets a demo freeze "now".
 *
 * @param clock - The clock to read the current instant from.
 * @param timeZone - The IANA zone whose calendar date is wanted.
 * @returns The plain calendar date in that zone at the clock's instant.
 */
export function getTodayInZone( clock: Clock, timeZone: string ): Temporal.PlainDate {
	return clock.now.toZonedDateTimeISO( timeZone ).toPlainDate();
}

/**
 * Builds the product-facing cross-zone warning from the same-day boolean, or `null` when the dates match.
 *
 * The product's whole job is that nobody marks the wrong day, so the sentence names the real calendar date in each zone, not just the weekday.
 *
 * @param sameCalendarDay - Whether the event lands on one calendar date for both zones.
 * @param eventDate - The event's calendar date in the host's zone.
 * @param userDate - The event's calendar date in the user's zone.
 * @param locale - The BCP 47 locale the dates are formatted in.
 * @returns The warning sentence naming both dates, or `null` when both zones share the date.
 */
export function buildCrossZoneWarning(
	sameCalendarDay: boolean,
	eventDate: Temporal.PlainDate,
	userDate: Temporal.PlainDate,
	locale: string,
): string | null {
	if ( sameCalendarDay ) {
		return null;
	}

	const dateFields = { weekday: 'long', month: 'short', day: 'numeric' } as const;
	const hostDate = eventDate.toLocaleString( locale, dateFields );
	const yourDate = userDate.toLocaleString( locale, dateFields );

	return `Heads up: this session is listed as ${ hostDate } in the host's time zone, but it falls on ${ yourDate } for you.`;
}

/**
 * Derives everything the card renders from the parsed model and a clock.
 *
 * The instant is converted into both zones first, then the two plain dates are compared for the same-day question, and the calendar-day distance powers the today label.
 * No millisecond math touches the calendar-day count, so a DST day still counts as a single day.
 *
 * @param model - The parsed event inputs.
 * @param clock - The clock supplying "now" for the today label and countdown.
 * @param locale - The BCP 47 locale the cross-zone warning's dates are formatted in.
 * @returns The resolved view model the template reads from.
 */
export function deriveViewModel( model: EventTimeModel, clock: Clock, locale = 'en-US' ): EventTimeViewModel {
	const eventZonedDateTime = model.instant.toZonedDateTimeISO( model.eventZone );
	const userZonedDateTime = model.instant.toZonedDateTimeISO( model.userZone );
	const eventDateInEventZone = eventZonedDateTime.toPlainDate();
	const eventDateInUserZone = userZonedDateTime.toPlainDate();

	// The same-day question: one instant, two zones, compared as calendar dates.
	const sameCalendarDay = eventDateInEventZone.equals( eventDateInUserZone );

	// The today question runs off the same plain dates, against today in the user's zone.
	const todayInUserZone = getTodayInZone( clock, model.userZone );
	const daysUntilInUserCalendar = todayInUserZone.until(
		eventDateInUserZone,
		{ largestUnit: 'days' },
	).days;

	return {
		eventZonedDateTime,
		userZonedDateTime,
		eventDateInEventZone,
		eventDateInUserZone,
		sameCalendarDay,
		crossZoneWarning: buildCrossZoneWarning( sameCalendarDay, eventDateInEventZone, eventDateInUserZone, locale ),
		daysUntilInUserCalendar,
		userCalendarLabel: userCalendarLabel( daysUntilInUserCalendar ),
	};
}

/**
 * Parses the raw attributes into an `EventTimeModel`.
 *
 * This is the only place a string becomes a `Temporal.Instant`, so the parsing boundary stays in one spot.
 *
 * @param startsAt - The canonical start as an ISO 8601 instant string.
 * @param eventZone - The IANA zone that owns the event.
 * @param userZone - The IANA zone the user reads it in.
 * @returns The parsed model.
 */
export function parseEventTimeModel(
	startsAt: string,
	eventZone: string,
	userZone: string,
): EventTimeModel {
	return {
		instant: Temporal.Instant.from( startsAt ),
		eventZone,
		userZone,
	};
}

/**
 * Resolves the raw attributes into one of the three explicit states the card renders.
 *
 * A blank attribute is `missing-input`. A value that fails to parse, a malformed instant or an unknown IANA zone, is `invalid-input` carrying the real `RangeError` message, so a wrong assumption fails loudly instead of swallowing into the same blank as missing input.
 *
 * @param startsAt - The canonical start as an ISO 8601 instant string.
 * @param eventZone - The IANA zone that owns the event.
 * @param userZone - The IANA zone the user reads it in.
 * @param clock - The clock supplying "now" for the today label and countdown.
 * @param locale - The BCP 47 locale the warning and times are formatted in.
 * @returns The resolved state: missing input, invalid input with a message, or a ready view model.
 */
export function buildEventTimeState(
	startsAt: string,
	eventZone: string,
	userZone: string,
	clock: Clock,
	locale = 'en-US',
): EventTimeState {
	if ( ! startsAt || ! eventZone || ! userZone ) {
		return { status: 'missing-input' };
	}

	try {
		const model = parseEventTimeModel( startsAt, eventZone, userZone );
		return { status: 'ready', view: deriveViewModel( model, clock, locale ) };
	} catch ( error ) {
		const message = error instanceof Error ? error.message : 'The event time could not be parsed.';
		return { status: 'invalid-input', message };
	}
}

/**
 * Formats a zoned date-time for display, with explicit fields plus the zone name.
 *
 * The fields are listed one by one because the shorthand `dateStyle` and `timeStyle` together with `timeZoneName` throws a `TypeError: Invalid option`.
 *
 * @param value - The zoned date-time to format.
 * @param locale - The BCP 47 locale tag to format in.
 * @returns The localized string, including the short zone name.
 */
export function formatZonedDateTime( value: Temporal.ZonedDateTime, locale: string ): string {
	return value.toLocaleString( locale, {
		year: 'numeric',
		month: 'short',
		day: 'numeric',
		hour: 'numeric',
		minute: '2-digit',
		timeZoneName: 'short',
	} );
}

/**
 * Formats a zoned date-time for display with the same fields as `formatZonedDateTime` but with no zone name.
 *
 * It deliberately omits the zone, so the wall-clock reading shows as if it were the viewer's own local time.
 * That's exactly the silent cross-zone bug the opener demo dramatizes, where a foreign zone's clock is presented with no qualifier and the reader blocks the wrong day.
 * Real product code should prefer `formatZonedDateTime` so the zone is never hidden; this exists only to stage that mistake on purpose.
 *
 * @param value - The zoned date-time to format.
 * @param locale - The BCP 47 locale tag to format in.
 * @returns The localized string, with no zone name.
 */
export function formatWallClockDateTime( value: Temporal.ZonedDateTime, locale: string ): string {
	return value.toLocaleString( locale, {
		year: 'numeric',
		month: 'short',
		day: 'numeric',
		hour: 'numeric',
		minute: '2-digit',
	} );
}

/**
 * Formats a plain calendar date for display, with no time and no zone.
 *
 * Used for the `'date'` label, where a relative word would mislead, so the card shows the actual calendar date instead.
 *
 * @param value - The plain date to format.
 * @param locale - The BCP 47 locale tag to format in.
 * @returns The localized calendar date.
 */
export function formatPlainDate( value: Temporal.PlainDate, locale: string ): string {
	return value.toLocaleString( locale, {
		year: 'numeric',
		month: 'short',
		day: 'numeric',
	} );
}
The panel shows the component source. Styles live in the component's Lit static styles, scoped to its shadow root.

Step 5: Stop counting days with milliseconds

The countdown is where millisecond math quietly breaks, and it’s the cleanest illustration of “elapsed time is not calendar days”. The naive version subtracts two timestamps and divides.

ts
// The naive day count, wrong across a DST boundary.
const days = Math.floor( ( eventDate.getTime() - now.getTime() ) / 86_400_000 );

That assumes every day is 86,400,000 milliseconds, and on the day a zone springs forward it isn’t. The calendar version asks the calendar, with PlainDate.until.

event-time-model.ts
// event-time-model.ts, the calendar-day count
const daysUntilInUserCalendar = todayInUserZone.until(
	eventDateInUserZone,
	{ largestUnit: 'days' },
).days;

The default largestUnit already resolves to days here, so reading .days gives the full count; passing { largestUnit: 'days' } explicitly is for clarity at the call site.

The numbers make it concrete. Take a frozen “now” of 2026-03-07T20:00:00Z (Mar 7, 2026, 8:00 PM UTC) and an event at 2026-03-10T19:00:00Z (Mar 10, 2026, 7:00 PM UTC), both read in America/Los_Angeles, straddling the 2026-03-08 spring-forward. The real elapsed time is 71 hours, because the local day 2026-03-08 was only 23 hours long. The naive count, dividing by a fixed 24 hours, reports “starts in 2 days”. The calendar count reports the correct “starts in 3 days”. The transition falls right inside the user’s own zone here, so the countdown crosses it without borrowing any other zone to make the point.

The third demo runs both counts across that transition, with the worked instants on screen. Toggle it to the Europe/London fall-back week and the two counts agree at three, the clean negative control. The extra hour of a 25-hour day pads elapsed time the other way, so spring-forward is the case that undercounts.

event-time-model
import { Temporal } from 'temporal-polyfill';
import type {
	Clock,
	EventTimeModel,
	EventTimeState,
	EventTimeViewModel,
	UserCalendarLabel,
} from './types';

/**
 * Maps a signed day distance in the user's calendar to the relative word the card shows.
 *
 * Falls back to `'date'` so anything beyond the near window renders as a formatted date rather than a misleading relative word.
 * The `'today'` case is exactly `daysUntil === 0`, which keeps the rendered word locked to the count it came from.
 *
 * @param daysUntil - The signed whole-day distance from the user's today to the event date.
 * @returns The relative word, or `'date'` when the event is outside the yesterday-to-tomorrow window.
 */
export function userCalendarLabel( daysUntil: number ): UserCalendarLabel {
	if ( daysUntil === -1 ) {
		return 'yesterday';
	}

	if ( daysUntil === 0 ) {
		return 'today';
	}

	if ( daysUntil === 1 ) {
		return 'tomorrow';
	}

	return 'date';
}

/**
 * Reads the user's calendar date "now" from an injected clock.
 *
 * Going through the clock rather than `Temporal.Now` directly is what lets a demo freeze "now".
 *
 * @param clock - The clock to read the current instant from.
 * @param timeZone - The IANA zone whose calendar date is wanted.
 * @returns The plain calendar date in that zone at the clock's instant.
 */
export function getTodayInZone( clock: Clock, timeZone: string ): Temporal.PlainDate {
	return clock.now.toZonedDateTimeISO( timeZone ).toPlainDate();
}

/**
 * Builds the product-facing cross-zone warning from the same-day boolean, or `null` when the dates match.
 *
 * The product's whole job is that nobody marks the wrong day, so the sentence names the real calendar date in each zone, not just the weekday.
 *
 * @param sameCalendarDay - Whether the event lands on one calendar date for both zones.
 * @param eventDate - The event's calendar date in the host's zone.
 * @param userDate - The event's calendar date in the user's zone.
 * @param locale - The BCP 47 locale the dates are formatted in.
 * @returns The warning sentence naming both dates, or `null` when both zones share the date.
 */
export function buildCrossZoneWarning(
	sameCalendarDay: boolean,
	eventDate: Temporal.PlainDate,
	userDate: Temporal.PlainDate,
	locale: string,
): string | null {
	if ( sameCalendarDay ) {
		return null;
	}

	const dateFields = { weekday: 'long', month: 'short', day: 'numeric' } as const;
	const hostDate = eventDate.toLocaleString( locale, dateFields );
	const yourDate = userDate.toLocaleString( locale, dateFields );

	return `Heads up: this session is listed as ${ hostDate } in the host's time zone, but it falls on ${ yourDate } for you.`;
}

/**
 * Derives everything the card renders from the parsed model and a clock.
 *
 * The instant is converted into both zones first, then the two plain dates are compared for the same-day question, and the calendar-day distance powers the today label.
 * No millisecond math touches the calendar-day count, so a DST day still counts as a single day.
 *
 * @param model - The parsed event inputs.
 * @param clock - The clock supplying "now" for the today label and countdown.
 * @param locale - The BCP 47 locale the cross-zone warning's dates are formatted in.
 * @returns The resolved view model the template reads from.
 */
export function deriveViewModel( model: EventTimeModel, clock: Clock, locale = 'en-US' ): EventTimeViewModel {
	const eventZonedDateTime = model.instant.toZonedDateTimeISO( model.eventZone );
	const userZonedDateTime = model.instant.toZonedDateTimeISO( model.userZone );
	const eventDateInEventZone = eventZonedDateTime.toPlainDate();
	const eventDateInUserZone = userZonedDateTime.toPlainDate();

	// The same-day question: one instant, two zones, compared as calendar dates.
	const sameCalendarDay = eventDateInEventZone.equals( eventDateInUserZone );

	// The today question runs off the same plain dates, against today in the user's zone.
	const todayInUserZone = getTodayInZone( clock, model.userZone );
	const daysUntilInUserCalendar = todayInUserZone.until(
		eventDateInUserZone,
		{ largestUnit: 'days' },
	).days;

	return {
		eventZonedDateTime,
		userZonedDateTime,
		eventDateInEventZone,
		eventDateInUserZone,
		sameCalendarDay,
		crossZoneWarning: buildCrossZoneWarning( sameCalendarDay, eventDateInEventZone, eventDateInUserZone, locale ),
		daysUntilInUserCalendar,
		userCalendarLabel: userCalendarLabel( daysUntilInUserCalendar ),
	};
}

/**
 * Parses the raw attributes into an `EventTimeModel`.
 *
 * This is the only place a string becomes a `Temporal.Instant`, so the parsing boundary stays in one spot.
 *
 * @param startsAt - The canonical start as an ISO 8601 instant string.
 * @param eventZone - The IANA zone that owns the event.
 * @param userZone - The IANA zone the user reads it in.
 * @returns The parsed model.
 */
export function parseEventTimeModel(
	startsAt: string,
	eventZone: string,
	userZone: string,
): EventTimeModel {
	return {
		instant: Temporal.Instant.from( startsAt ),
		eventZone,
		userZone,
	};
}

/**
 * Resolves the raw attributes into one of the three explicit states the card renders.
 *
 * A blank attribute is `missing-input`. A value that fails to parse, a malformed instant or an unknown IANA zone, is `invalid-input` carrying the real `RangeError` message, so a wrong assumption fails loudly instead of swallowing into the same blank as missing input.
 *
 * @param startsAt - The canonical start as an ISO 8601 instant string.
 * @param eventZone - The IANA zone that owns the event.
 * @param userZone - The IANA zone the user reads it in.
 * @param clock - The clock supplying "now" for the today label and countdown.
 * @param locale - The BCP 47 locale the warning and times are formatted in.
 * @returns The resolved state: missing input, invalid input with a message, or a ready view model.
 */
export function buildEventTimeState(
	startsAt: string,
	eventZone: string,
	userZone: string,
	clock: Clock,
	locale = 'en-US',
): EventTimeState {
	if ( ! startsAt || ! eventZone || ! userZone ) {
		return { status: 'missing-input' };
	}

	try {
		const model = parseEventTimeModel( startsAt, eventZone, userZone );
		return { status: 'ready', view: deriveViewModel( model, clock, locale ) };
	} catch ( error ) {
		const message = error instanceof Error ? error.message : 'The event time could not be parsed.';
		return { status: 'invalid-input', message };
	}
}

/**
 * Formats a zoned date-time for display, with explicit fields plus the zone name.
 *
 * The fields are listed one by one because the shorthand `dateStyle` and `timeStyle` together with `timeZoneName` throws a `TypeError: Invalid option`.
 *
 * @param value - The zoned date-time to format.
 * @param locale - The BCP 47 locale tag to format in.
 * @returns The localized string, including the short zone name.
 */
export function formatZonedDateTime( value: Temporal.ZonedDateTime, locale: string ): string {
	return value.toLocaleString( locale, {
		year: 'numeric',
		month: 'short',
		day: 'numeric',
		hour: 'numeric',
		minute: '2-digit',
		timeZoneName: 'short',
	} );
}

/**
 * Formats a zoned date-time for display with the same fields as `formatZonedDateTime` but with no zone name.
 *
 * It deliberately omits the zone, so the wall-clock reading shows as if it were the viewer's own local time.
 * That's exactly the silent cross-zone bug the opener demo dramatizes, where a foreign zone's clock is presented with no qualifier and the reader blocks the wrong day.
 * Real product code should prefer `formatZonedDateTime` so the zone is never hidden; this exists only to stage that mistake on purpose.
 *
 * @param value - The zoned date-time to format.
 * @param locale - The BCP 47 locale tag to format in.
 * @returns The localized string, with no zone name.
 */
export function formatWallClockDateTime( value: Temporal.ZonedDateTime, locale: string ): string {
	return value.toLocaleString( locale, {
		year: 'numeric',
		month: 'short',
		day: 'numeric',
		hour: 'numeric',
		minute: '2-digit',
	} );
}

/**
 * Formats a plain calendar date for display, with no time and no zone.
 *
 * Used for the `'date'` label, where a relative word would mislead, so the card shows the actual calendar date instead.
 *
 * @param value - The plain date to format.
 * @param locale - The BCP 47 locale tag to format in.
 * @returns The localized calendar date.
 */
export function formatPlainDate( value: Temporal.PlainDate, locale: string ): string {
	return value.toLocaleString( locale, {
		year: 'numeric',
		month: 'short',
		day: 'numeric',
	} );
}
The panel shows the component source. Styles live in the component's Lit static styles, scoped to its shadow root.

Step 6: The today label, derived from the count

Now the second question gets its label, and the contract that keeps it honest is that the label is derived from the day count, never hand-set. The 'today' case is exactly a distance of zero, which is the same as eventDateInUserZone.equals( todayInUserZone ), so the today question and the rendered word can’t drift apart.

event-time-model.ts
// event-time-model.ts, the label function
/**
 * Maps a signed day distance in the user's calendar to the relative word the card shows.
 *
 * Falls back to `'date'` so anything beyond the near window renders as a formatted date rather than a misleading relative word.
 * The `'today'` case is exactly `daysUntil === 0`, which keeps the rendered word locked to the count it came from.
 *
 * @param daysUntil - The signed whole-day distance from the user's today to the event date.
 * @returns The relative word, or `'date'` when the event is outside the yesterday-to-tomorrow window.
 */
export function userCalendarLabel( daysUntil: number ): UserCalendarLabel {
	if ( daysUntil === -1 ) {
		return 'yesterday';
	}

	if ( daysUntil === 0 ) {
		return 'today';
	}

	if ( daysUntil === 1 ) {
		return 'tomorrow';
	}

	return 'date';
}

The “now” that feeds it comes through an injected clock, read once at the edge, so the today label and the countdown are reproducible. Production wraps Temporal.Now.instant(); a demo passes a frozen instant.

event-time-model.ts
// event-time-model.ts, the clock helper
/**
 * Reads the user's calendar date "now" from an injected clock.
 *
 * Going through the clock rather than `Temporal.Now` directly is what lets a demo freeze "now".
 *
 * @param clock - The clock to read the current instant from.
 * @param timeZone - The IANA zone whose calendar date is wanted.
 * @returns The plain calendar date in that zone at the clock's instant.
 */
export function getTodayInZone( clock: Clock, timeZone: string ): Temporal.PlainDate {
	return clock.now.toZonedDateTimeISO( timeZone ).toPlainDate();
}

With the clock frozen at 2026-08-30T19:00:00Z (Aug 30, 2026, 7:00 PM UTC), the Los Angeles user’s today is 2026-08-30, the event date in Los Angeles is also 2026-08-30, so the label is 'today', while the host is already on 2026-08-31 and the same-day answer is “no”. That’s the same-day-versus-today split made deterministic, and it’s the divergence the whole card is built around.

Step 7: Format at the edge

Keep the temporal math out of formatting, and do the formatting once, at the boundary, with toLocaleString. There’s one real gotcha worth knowing. The convenient shorthand of dateStyle and timeStyle together with timeZoneName throws a TypeError: Invalid option, so to show the zone name you list the fields explicitly.

event-time-model.ts
// event-time-model.ts, the formatting helper
/**
 * Formats a zoned date-time for display, with explicit fields plus the zone name.
 *
 * The fields are listed one by one because the shorthand `dateStyle` and `timeStyle` together with `timeZoneName` throws a `TypeError: Invalid option`.
 *
 * @param value - The zoned date-time to format.
 * @param locale - The BCP 47 locale tag to format in.
 * @returns The localized string, including the short zone name.
 */
export function formatZonedDateTime( value: Temporal.ZonedDateTime, locale: string ): string {
	return value.toLocaleString( locale, {
		year: 'numeric',
		month: 'short',
		day: 'numeric',
		hour: 'numeric',
		minute: '2-digit',
		timeZoneName: 'short',
	} );
}

Step 8: Wrap it in Lit

Now the component shell. I’m reaching for Lit because its decorators keep the element’s surface easy to read, and the three product inputs become typed properties. The one that matters most is userTimeZone. It’s a required input bound to an attribute, and it’s deliberately not defaulted to Temporal.Now.timeZoneId(), because letting the user’s machine zone silently decide product behavior is the failure this whole card exists to prevent.

event-time.ts
// event-time.ts, the public inputs
/**
 * The canonical start of the event as an ISO 8601 instant string.
 * It's an instant on purpose, the one moment the event happens, not a calendar date, so every zone reads it consistently.
 * It's a required input with no default; until a consumer sets it the card renders the neutral missing-input state.
 */
@property( { attribute: 'starts-at' } )
startsAt!: string;

/**
 * The IANA time zone that owns the event, a product rule for the venue.
 * It's a required input with no default, the same as the other two, so the venue zone is always a product decision.
 */
@property( { attribute: 'event-time-zone' } )
eventTimeZone!: string;

/**
 * The IANA time zone the user reads the event in.
 * This is a required input with no default, never the host zone, because letting the user's machine silently decide product behavior is the bug this component exists to avoid.
 * A consumer that hasn't set it yet renders the neutral missing-input placeholder rather than leaking the rendering machine's zone.
 */
@property( { attribute: 'user-time-zone' } )
userTimeZone!: string;

The clock is a property rather than an attribute, since a Temporal.Instant isn’t a string. Its default reads the real instant, but only once per component update, and that distinction is worth being precise about.

event-time.ts
// event-time.ts, the injectable clock
/**
 * The clock the today label and the countdown read "now" from.
 *
 * Defaults to a clock backed by `Temporal.Now.instant()`, read once per component update. A fresh-reading getter doesn't re-render Lit on its own, so the label only recomputes when the component updates; if it must change while the card is open, schedule an update at the next local midnight or pass a clock driven from app state.
 * A demo replaces it with a frozen instant so the rendered label and countdown are deterministic. It's a property, not an attribute, because a `Temporal.Instant` isn't a string.
 */
@property( { attribute: false } )
clock: Clock = {
	get now(): Temporal.Instant {
		return Temporal.Now.instant();
	},
};

That getter is a trap worth naming, because the original comment on it claimed the card “stays current”. It doesn’t. A getter that reads the live instant doesn’t make Lit re-render, so this demo freezes “now” the moment it updates and then holds it. That’s fine for a worked example, and exactly what you want for a deterministic screenshot. A production card that shows a live relative label, a “starts in 3 days” that has to tick over to “2 days” on its own, needs a refresh policy the component here doesn’t ship: a timer that schedules an update at the next local midnight, or a clock fed from app state that changes and triggers the update for you. Time code doesn’t end at computing the answer once.

Before the component can render, it has to handle the inputs being incomplete or wrong, and this is a place the card gets to practice what the model preaches. A blank attribute and an unparseable instant aren’t the same thing, so they get different answers. The component models its own failure states the same way it models time, with a small discriminated union.

types.ts
// types.ts
export type EventTimeState =
	| { status: 'missing-input' }
	| { status: 'invalid-input'; message: string }
	| { status: 'ready'; view: EventTimeViewModel };

The temptation is the one-line try { ... } catch { return null } that turns a bad zone and a missing attribute into the same blank card. That contradicts the whole lesson, because a wrong temporal assumption should fail loudly instead of getting swallowed. So missing-input shows a neutral prompt and invalid-input carries the real parse message, while ready carries the resolved view model. The model module owns the resolution and surfaces a typed state rather than throwing into a bare catch.

event-time-model.ts
// event-time-model.ts, resolving the three states
export function buildEventTimeState(
	startsAt: string,
	eventZone: string,
	userZone: string,
	clock: Clock,
	locale = 'en-US',
): EventTimeState {
	if ( ! startsAt || ! eventZone || ! userZone ) {
		return { status: 'missing-input' };
	}

	try {
		const model = parseEventTimeModel( startsAt, eventZone, userZone );
		return { status: 'ready', view: deriveViewModel( model, clock, locale ) };
	} catch ( error ) {
		const message = error instanceof Error ? error.message : 'The event time could not be parsed.';
		return { status: 'invalid-input', message };
	}
}

The component resolves that state once per update, in Lit’s willUpdate hook, and both render and the change event read the one stored value. That’s the same “derive once, then read” discipline the model preaches, applied to the component itself, so the rendered card and the dispatched event can never disagree.

event-time.ts
// event-time.ts, derive once and store the state
private state: EventTimeState = { status: 'missing-input' };

protected willUpdate(): void {
	this.state = buildEventTimeState( this.startsAt, this.eventTimeZone, this.userTimeZone, this.clock, this.locale );
}

The render fragment is where the two product answers land side by side. The cross-zone warning is the headline, shown only when the calendar dates differ, and the today label is the second answer, kept visibly separate so the two questions never blur into one.

event-time.ts
// event-time.ts, the two answers in the ready state
${ view.crossZoneWarning
	? html`<p class="warning">${ view.crossZoneWarning }</p>`
	: null }
<p class="label">
	On your calendar this is <span class="word">${ userDay }</span>.
</p>

The complete component, including the static styles CSS and the three-state render that also handles the missing and invalid cases, is long, and the Lit shell and its styling aren’t the lesson here. The whole file is folded in at the end, where you can expand it to read or copy. The pieces worth carrying away are the model module and its types, assembled in full just below.

Step 9: Put it together

That’s the whole build. The assembled types.ts, with the view model, the state union, and the event payload the component dispatches:

types.ts
// types.ts
import type { Temporal } from 'temporal-polyfill';

/**
 * The relative word the card shows for the event on the user's own calendar.
 *
 * This is the second question the component answers, kept separate from the same-day question on purpose.
 * `'date'` is the fallback for anything outside the near window, so a value weeks out renders as a formatted date rather than a misleading relative word.
 */
export type UserCalendarLabel = 'yesterday' | 'today' | 'tomorrow' | 'date';

/**
 * The clock the model reads "now" from.
 *
 * Wrapping `Temporal.Now.instant()` behind this one field is what makes every demo deterministic: production reads the real instant, a demo passes a frozen one.
 */
export interface Clock {

	/**
	 * The instant treated as "now" for the today label and the calendar-day countdown.
	 */
	now: Temporal.Instant;
}

/**
 * The parsed inputs the model works from, before any zone conversion.
 *
 * `instant` is the one canonical moment the event happens, `eventZone` is the product rule for the venue, and `userZone` is the required display-and-comparison input.
 */
export interface EventTimeModel {

	/**
	 * The single canonical moment the event starts, parsed from the `startsAt` string.
	 */
	instant: Temporal.Instant;

	/**
	 * The IANA time zone that owns the event, a product rule rather than a machine read.
	 */
	eventZone: string;

	/**
	 * The IANA time zone the user reads the event in, a required input the product decides, never the host default.
	 */
	userZone: string;
}

/**
 * Everything the card needs to render, derived once from an `EventTimeModel` and a `Clock`.
 *
 * The two zoned values and the two plain dates are distinct fields so the template never re-derives a date, and each question reads off an explicit, named value.
 */
export interface EventTimeViewModel {

	/**
	 * The event instant expressed in the event's own zone, for the venue-local time on the card.
	 */
	eventZonedDateTime: Temporal.ZonedDateTime;

	/**
	 * The same event instant expressed in the user's zone, for the user-local time on the card.
	 */
	userZonedDateTime: Temporal.ZonedDateTime;

	/**
	 * The calendar date the event falls on in the venue's zone.
	 */
	eventDateInEventZone: Temporal.PlainDate;

	/**
	 * The calendar date the event falls on in the user's zone.
	 */
	eventDateInUserZone: Temporal.PlainDate;

	/**
	 * The answer to the same-day question: whether the event lands on one calendar date for both the host and the user.
	 * Computed as `eventDateInEventZone.equals( eventDateInUserZone )`, comparing dates after the conversion, never the raw instant.
	 */
	sameCalendarDay: boolean;

	/**
	 * The product-facing warning shown when the event's calendar date differs from the user's, or `null` when they match.
	 * Built from `sameCalendarDay`, naming the real calendar date in each zone, so the surface reads as a heads-up instead of a raw boolean.
	 */
	crossZoneWarning: string | null;

	/**
	 * The signed distance in whole calendar days, in the user's zone, from today to the event date.
	 * Calendar days, not elapsed time, so a DST day still counts as one day.
	 */
	daysUntilInUserCalendar: number;

	/**
	 * The answer to the today question, as the relative word the card shows.
	 * Derived from `daysUntilInUserCalendar` so the `'today'` case is exactly `daysUntilInUserCalendar === 0` and the label can never drift from the count.
	 */
	userCalendarLabel: UserCalendarLabel;
}

/**
 * The three states the component can be in, modeled the same way the component models time.
 *
 * Missing input and invalid input are kept apart on purpose: a blank attribute is a neutral "not ready yet", while an unparseable instant or a bad zone is a real error the card should say out loud.
 */
export type EventTimeState =
	| { status: 'missing-input' }
	| { status: 'invalid-input'; message: string }
	| { status: 'ready'; view: EventTimeViewModel };

/**
 * The detail payload carried by the `event-time-change` event of `event-time`.
 *
 * It reports the two answers the component computed, so a page can react to the resolved model without re-deriving it from the raw attributes.
 */
export interface EventTimeChangeDetail {

	/**
	 * Whether the event is the same calendar day for the venue and the user.
	 */
	sameCalendarDay: boolean;

	/**
	 * The relative word resolved for the user's calendar.
	 */
	userCalendarLabel: UserCalendarLabel;

	/**
	 * The signed calendar-day distance from the user's today to the event.
	 */
	daysUntilInUserCalendar: number;
}

/**
 * Event map for `event-time`, so a listener on the element is typed without a cast.
 */
export interface EventTimeEventMap extends HTMLElementEventMap {
	'event-time-change': CustomEvent<EventTimeChangeDetail>;
}

The model module, framework-free so it reads and tests on its own:

event-time-model.ts
// event-time-model.ts
import { Temporal } from 'temporal-polyfill';
import type {
	Clock,
	EventTimeModel,
	EventTimeState,
	EventTimeViewModel,
	UserCalendarLabel,
} from './types';

/**
 * Maps a signed day distance in the user's calendar to the relative word the card shows.
 *
 * Falls back to `'date'` so anything beyond the near window renders as a formatted date rather than a misleading relative word.
 * The `'today'` case is exactly `daysUntil === 0`, which keeps the rendered word locked to the count it came from.
 *
 * @param daysUntil - The signed whole-day distance from the user's today to the event date.
 * @returns The relative word, or `'date'` when the event is outside the yesterday-to-tomorrow window.
 */
export function userCalendarLabel( daysUntil: number ): UserCalendarLabel {
	if ( daysUntil === -1 ) {
		return 'yesterday';
	}

	if ( daysUntil === 0 ) {
		return 'today';
	}

	if ( daysUntil === 1 ) {
		return 'tomorrow';
	}

	return 'date';
}

/**
 * Reads the user's calendar date "now" from an injected clock.
 *
 * Going through the clock rather than `Temporal.Now` directly is what lets a demo freeze "now".
 *
 * @param clock - The clock to read the current instant from.
 * @param timeZone - The IANA zone whose calendar date is wanted.
 * @returns The plain calendar date in that zone at the clock's instant.
 */
export function getTodayInZone( clock: Clock, timeZone: string ): Temporal.PlainDate {
	return clock.now.toZonedDateTimeISO( timeZone ).toPlainDate();
}

/**
 * Builds the product-facing cross-zone warning from the same-day boolean, or `null` when the dates match.
 *
 * The product's whole job is that nobody marks the wrong day, so the sentence names the real calendar date in each zone, not just the weekday.
 *
 * @param sameCalendarDay - Whether the event lands on one calendar date for both zones.
 * @param eventDate - The event's calendar date in the host's zone.
 * @param userDate - The event's calendar date in the user's zone.
 * @param locale - The BCP 47 locale the dates are formatted in.
 * @returns The warning sentence naming both dates, or `null` when both zones share the date.
 */
export function buildCrossZoneWarning(
	sameCalendarDay: boolean,
	eventDate: Temporal.PlainDate,
	userDate: Temporal.PlainDate,
	locale: string,
): string | null {
	if ( sameCalendarDay ) {
		return null;
	}

	const dateFields = { weekday: 'long', month: 'short', day: 'numeric' } as const;
	const hostDate = eventDate.toLocaleString( locale, dateFields );
	const yourDate = userDate.toLocaleString( locale, dateFields );

	return `Heads up: this session is listed as ${ hostDate } in the host's time zone, but it falls on ${ yourDate } for you.`;
}

/**
 * Derives everything the card renders from the parsed model and a clock.
 *
 * The instant is converted into both zones first, then the two plain dates are compared for the same-day question, and the calendar-day distance powers the today label.
 * No millisecond math touches the calendar-day count, so a DST day still counts as a single day.
 *
 * @param model - The parsed event inputs.
 * @param clock - The clock supplying "now" for the today label and countdown.
 * @param locale - The BCP 47 locale the cross-zone warning's dates are formatted in.
 * @returns The resolved view model the template reads from.
 */
export function deriveViewModel( model: EventTimeModel, clock: Clock, locale = 'en-US' ): EventTimeViewModel {
	const eventZonedDateTime = model.instant.toZonedDateTimeISO( model.eventZone );
	const userZonedDateTime = model.instant.toZonedDateTimeISO( model.userZone );
	const eventDateInEventZone = eventZonedDateTime.toPlainDate();
	const eventDateInUserZone = userZonedDateTime.toPlainDate();

	// The same-day question: one instant, two zones, compared as calendar dates.
	const sameCalendarDay = eventDateInEventZone.equals( eventDateInUserZone );

	// The today question runs off the same plain dates, against today in the user's zone.
	const todayInUserZone = getTodayInZone( clock, model.userZone );
	const daysUntilInUserCalendar = todayInUserZone.until(
		eventDateInUserZone,
		{ largestUnit: 'days' },
	).days;

	return {
		eventZonedDateTime,
		userZonedDateTime,
		eventDateInEventZone,
		eventDateInUserZone,
		sameCalendarDay,
		crossZoneWarning: buildCrossZoneWarning( sameCalendarDay, eventDateInEventZone, eventDateInUserZone, locale ),
		daysUntilInUserCalendar,
		userCalendarLabel: userCalendarLabel( daysUntilInUserCalendar ),
	};
}

/**
 * Parses the raw attributes into an `EventTimeModel`.
 *
 * This is the only place a string becomes a `Temporal.Instant`, so the parsing boundary stays in one spot.
 *
 * @param startsAt - The canonical start as an ISO 8601 instant string.
 * @param eventZone - The IANA zone that owns the event.
 * @param userZone - The IANA zone the user reads it in.
 * @returns The parsed model.
 */
export function parseEventTimeModel(
	startsAt: string,
	eventZone: string,
	userZone: string,
): EventTimeModel {
	return {
		instant: Temporal.Instant.from( startsAt ),
		eventZone,
		userZone,
	};
}

/**
 * Resolves the raw attributes into one of the three explicit states the card renders.
 *
 * A blank attribute is `missing-input`. A value that fails to parse, a malformed instant or an unknown IANA zone, is `invalid-input` carrying the real `RangeError` message, so a wrong assumption fails loudly instead of swallowing into the same blank as missing input.
 *
 * @param startsAt - The canonical start as an ISO 8601 instant string.
 * @param eventZone - The IANA zone that owns the event.
 * @param userZone - The IANA zone the user reads it in.
 * @param clock - The clock supplying "now" for the today label and countdown.
 * @param locale - The BCP 47 locale the warning and times are formatted in.
 * @returns The resolved state: missing input, invalid input with a message, or a ready view model.
 */
export function buildEventTimeState(
	startsAt: string,
	eventZone: string,
	userZone: string,
	clock: Clock,
	locale = 'en-US',
): EventTimeState {
	if ( ! startsAt || ! eventZone || ! userZone ) {
		return { status: 'missing-input' };
	}

	try {
		const model = parseEventTimeModel( startsAt, eventZone, userZone );
		return { status: 'ready', view: deriveViewModel( model, clock, locale ) };
	} catch ( error ) {
		const message = error instanceof Error ? error.message : 'The event time could not be parsed.';
		return { status: 'invalid-input', message };
	}
}

/**
 * Formats a zoned date-time for display, with explicit fields plus the zone name.
 *
 * The fields are listed one by one because the shorthand `dateStyle` and `timeStyle` together with `timeZoneName` throws a `TypeError: Invalid option`.
 *
 * @param value - The zoned date-time to format.
 * @param locale - The BCP 47 locale tag to format in.
 * @returns The localized string, including the short zone name.
 */
export function formatZonedDateTime( value: Temporal.ZonedDateTime, locale: string ): string {
	return value.toLocaleString( locale, {
		year: 'numeric',
		month: 'short',
		day: 'numeric',
		hour: 'numeric',
		minute: '2-digit',
		timeZoneName: 'short',
	} );
}

/**
 * Formats a plain calendar date for display, with no time and no zone.
 *
 * Used for the `'date'` label, where a relative word would mislead, so the card shows the actual calendar date instead.
 *
 * @param value - The plain date to format.
 * @param locale - The BCP 47 locale tag to format in.
 * @returns The localized calendar date.
 */
export function formatPlainDate( value: Temporal.PlainDate, locale: string ): string {
	return value.toLocaleString( locale, {
		year: 'numeric',
		month: 'short',
		day: 'numeric',
	} );
}

The full event-time.ts, the three input properties and the clock above, the state field and its willUpdate wiring, the three-state render, the typed addEventListener overload, and the long static styles CSS, is folded in just below; expand it to read or copy every line, and the demo beside it still runs it editable. The Lit shell and its styling are plumbing; the model module and its types above are the part worth keeping.

The finished component runs as a live example. The clock is frozen at noon in Los Angeles on 2026-08-30, so the defaults show the divergence the card is built for. The Los Angeles user reads the event as “today” while it isn’t the same calendar day as the Berlin host. Edit starts-at, switch either zone, and flip on the type inspector to watch the intermediate Temporal values, the instant, the two ZonedDateTimes, and the two PlainDates, line up behind the answer.

event-time
import { LitElement, html, css } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { Temporal } from 'temporal-polyfill';
import {
	buildEventTimeState,
	formatPlainDate,
	formatZonedDateTime,
} from './event-time-model';
import type {
	Clock,
	EventTimeChangeDetail,
	EventTimeEventMap,
	EventTimeState,
	EventTimeViewModel,
} from './types';

/**
 * An event card that answers two questions about one instant across two time zones.
 *
 * It parses `starts-at` as an instant, converts it into the event zone and the user zone, and reports whether the event is the same calendar day for both, plus whether it reads as today, tomorrow, or yesterday on the user's calendar.
 * The user time zone is a required product input, not a machine read, and "now" comes from an injectable clock so a demo can freeze it.
 *
 * @element event-time
 * @fires event-time-change - Fired after each render with the two resolved answers, carrying an `EventTimeChangeDetail`.
 * @cssprop [--event-time-radius=12px] - The card's corner radius.
 */
@customElement( 'event-time' )
export class EventTime extends LitElement {

	/**
	 * The canonical start of the event as an ISO 8601 instant string.
	 * It's an instant on purpose, the one moment the event happens, not a calendar date, so every zone reads it consistently.
	 * It's a required input with no default; until a consumer sets it the card renders the neutral missing-input state.
	 */
	@property( { attribute: 'starts-at' } )
	startsAt!: string;

	/**
	 * The IANA time zone that owns the event, a product rule for the venue.
	 * It's a required input with no default, the same as the other two, so the venue zone is always a product decision.
	 */
	@property( { attribute: 'event-time-zone' } )
	eventTimeZone!: string;

	/**
	 * The IANA time zone the user reads the event in.
	 * This is a required input with no default, never the host zone, because letting the user's machine silently decide product behavior is the bug this component exists to avoid.
	 * A consumer that hasn't set it yet renders the neutral missing-input placeholder rather than leaking the rendering machine's zone.
	 */
	@property( { attribute: 'user-time-zone' } )
	userTimeZone!: string;

	/**
	 * The BCP 47 locale the times are formatted in.
	 */
	@property( { attribute: 'locale' } )
	locale = 'en-US';

	/**
	 * The clock the today label and the countdown read "now" from.
	 *
	 * Defaults to a clock backed by `Temporal.Now.instant()`, read once per component update. A fresh-reading getter doesn't re-render Lit on its own, so the label only recomputes when the component updates; if it must change while the card is open, schedule an update at the next local midnight or pass a clock driven from app state.
	 * A demo replaces it with a frozen instant so the rendered label and countdown are deterministic. It's a property, not an attribute, because a `Temporal.Instant` isn't a string.
	 */
	@property( { attribute: false } )
	clock: Clock = {
		get now(): Temporal.Instant {
			return Temporal.Now.instant();
		},
	};

	/**
	 * Styles for the component, scoped to its shadow root.
	 */
	static styles = css`
		:host {
			display: block;
			font-family: var( --font-sans, system-ui, sans-serif );
			color: var( --ink, #17171a );
		}

		.card {
			border: 1px solid var( --line, #e7e6e2 );
			border-radius: var( --event-time-radius, 12px );
			background: var( --surface, #fff );
			padding: 1.25rem 1.4rem;
			max-width: 30rem;
		}

		.title {
			margin: 0 0 0.9rem;
			font-size: 1.05rem;
			font-weight: 600;
			letter-spacing: -0.01em;
		}

		.rows {
			display: grid;
			gap: 0.55rem;
			margin: 0;
		}

		.row {
			display: grid;
			grid-template-columns: 7.5rem 1fr;
			gap: 0.5rem;
			align-items: baseline;
		}

		.row dt {
			font-family: var( --font-mono, monospace );
			font-size: 0.72rem;
			letter-spacing: 0.04em;
			text-transform: uppercase;
			color: var( --ink-faint, #71717b );
		}

		.row dd {
			margin: 0;
			font-size: 0.95rem;
		}

		.warning {
			margin: 1.1rem 0 0;
			border-radius: 10px;
			border: 1px solid color-mix( in srgb, #c8860a 38%, var( --surface, #fff ) );
			background: color-mix( in srgb, #c8860a 13%, var( --surface, #fff ) );
			padding: 0.6rem 0.8rem;
			font-size: 0.9rem;
			line-height: 1.5;
			color: color-mix( in srgb, #c8860a 60%, var( --ink, #17171a ) );
		}

		.label {
			margin: 1.1rem 0 0;
			font-size: 0.95rem;
		}

		.label .word {
			font-weight: 600;
		}

		.error {
			margin: 0;
			font-size: 0.9rem;
			color: color-mix( in srgb, #cc3b41 65%, var( --ink, #17171a ) );
		}

		.error code {
			font-family: var( --font-mono, monospace );
			font-size: 0.82rem;
		}
	`;

	/**
	 * Types `addEventListener( 'event-time-change', ... )` so the listener receives a `CustomEvent<EventTimeChangeDetail>` with no cast.
	 * Falls through to the standard signature for every other event.
	 */
	addEventListener<K extends keyof EventTimeEventMap>(
		type: K,
		listener: ( this: EventTime, ev: EventTimeEventMap[ K ] ) => void,
		options?: boolean | AddEventListenerOptions,
	): void;
	addEventListener(
		type: string,
		listener: EventListenerOrEventListenerObject,
		options?: boolean | AddEventListenerOptions,
	): void {
		super.addEventListener( type, listener, options );
	}

	/**
	 * The resolved state derived once per update in `willUpdate`: missing input, invalid input, or a ready view model.
	 *
	 * Both `render` and `updated` read this single value rather than re-deriving, so the "derive once" rule the card teaches also holds inside the card.
	 * Modeling the three states explicitly, rather than collapsing them to a nullable view, is the same discipline the model applies to time: a missing input and a bad input are different answers and the card shows different things for each.
	 */
	private state: EventTimeState = { status: 'missing-input' };

	/**
	 * Derives the state once before each render, reading the injected clock a single time.
	 *
	 * Doing the derivation here rather than in a getter means `render` and `updated` share one computed value, so the clock is read once per update and the today label can never disagree with the count the event reports.
	 *
	 */
	protected willUpdate(): void {
		this.state = buildEventTimeState( this.startsAt, this.eventTimeZone, this.userTimeZone, this.clock, this.locale );
	}

	/**
	 * Dispatches the typed `event-time-change` event after each successful render.
	 *
	 * It's `composed` so it crosses the shadow boundary and `bubbles` so a listener on the host receives it.
	 *
	 * @param view - The view model that was just rendered.
	 */
	private emitChange( view: EventTimeViewModel ): void {
		this.dispatchEvent(
			new CustomEvent<EventTimeChangeDetail>( 'event-time-change', {
				detail: {
					sameCalendarDay: view.sameCalendarDay,
					userCalendarLabel: view.userCalendarLabel,
					daysUntilInUserCalendar: view.daysUntilInUserCalendar,
				},
				bubbles: true,
				composed: true,
			} ),
		);
	}

	/**
	 * Lifecycle hook that emits the change event once the resolved render is in the DOM.
	 *
	 */
	protected updated(): void {
		if ( this.state.status === 'ready' ) {
			this.emitChange( this.state.view );
		}
	}

	/**
	 * Renders the card for whichever state the inputs resolved to.
	 *
	 * Missing input shows a neutral prompt, invalid input shows the real parse error, and a ready model shows the two times, the cross-zone warning when the dates differ, and the user-calendar label.
	 */
	render() {
		if ( this.state.status === 'missing-input' ) {
			return html`
				<div class="card">
					<p class="error">
						Set <code>starts-at</code>, <code>event-time-zone</code>, and <code>user-time-zone</code> to render the card.
					</p>
				</div>
			`;
		}

		if ( this.state.status === 'invalid-input' ) {
			return html`
				<div class="card">
					<p class="error">
						That event time could not be read: <code>${ this.state.message }</code>
					</p>
				</div>
			`;
		}

		const view = this.state.view;
		const userDay = view.userCalendarLabel === 'date'
			? formatPlainDate( view.eventDateInUserZone, this.locale )
			: view.userCalendarLabel;

		return html`
			<div class="card">
				<h2 class="title">Online workshop</h2>
				<dl class="rows">
					<div class="row">
						<dt>Venue time</dt>
						<dd>${ formatZonedDateTime( view.eventZonedDateTime, this.locale ) }</dd>
					</div>
					<div class="row">
						<dt>Your time</dt>
						<dd>${ formatZonedDateTime( view.userZonedDateTime, this.locale ) }</dd>
					</div>
				</dl>
				${ view.crossZoneWarning
					? html`<p class="warning">${ view.crossZoneWarning }</p>`
					: null }
				<p class="label">
					On your calendar this is <span class="word">${ userDay }</span>.
				</p>
			</div>
		`;
	}
}

declare global {
	interface HTMLElementTagNameMap {
		'event-time': EventTime;
	}
}
The panel shows the component source. Styles live in the component's Lit static styles, scoped to its shadow root.
event-time.ts
import { LitElement, html, css } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { Temporal } from 'temporal-polyfill';
import {
	buildEventTimeState,
	formatPlainDate,
	formatZonedDateTime,
} from './event-time-model';
import type {
	Clock,
	EventTimeChangeDetail,
	EventTimeEventMap,
	EventTimeState,
	EventTimeViewModel,
} from './types';

/**
 * An event card that answers two questions about one instant across two time zones.
 *
 * It parses `starts-at` as an instant, converts it into the event zone and the user zone, and reports whether the event is the same calendar day for both, plus whether it reads as today, tomorrow, or yesterday on the user's calendar.
 * The user time zone is a required product input, not a machine read, and "now" comes from an injectable clock so a demo can freeze it.
 *
 * @element event-time
 * @fires event-time-change - Fired after each render with the two resolved answers, carrying an `EventTimeChangeDetail`.
 * @cssprop [--event-time-radius=12px] - The card's corner radius.
 */
@customElement( 'event-time' )
export class EventTime extends LitElement {

	/**
	 * The canonical start of the event as an ISO 8601 instant string.
	 * It's an instant on purpose, the one moment the event happens, not a calendar date, so every zone reads it consistently.
	 * It's a required input with no default; until a consumer sets it the card renders the neutral missing-input state.
	 */
	@property( { attribute: 'starts-at' } )
	startsAt!: string;

	/**
	 * The IANA time zone that owns the event, a product rule for the venue.
	 * It's a required input with no default, the same as the other two, so the venue zone is always a product decision.
	 */
	@property( { attribute: 'event-time-zone' } )
	eventTimeZone!: string;

	/**
	 * The IANA time zone the user reads the event in.
	 * This is a required input with no default, never the host zone, because letting the user's machine silently decide product behavior is the bug this component exists to avoid.
	 * A consumer that hasn't set it yet renders the neutral missing-input placeholder rather than leaking the rendering machine's zone.
	 */
	@property( { attribute: 'user-time-zone' } )
	userTimeZone!: string;

	/**
	 * The BCP 47 locale the times are formatted in.
	 */
	@property( { attribute: 'locale' } )
	locale = 'en-US';

	/**
	 * The clock the today label and the countdown read "now" from.
	 *
	 * Defaults to a clock backed by `Temporal.Now.instant()`, read once per component update. A fresh-reading getter doesn't re-render Lit on its own, so the label only recomputes when the component updates; if it must change while the card is open, schedule an update at the next local midnight or pass a clock driven from app state.
	 * A demo replaces it with a frozen instant so the rendered label and countdown are deterministic. It's a property, not an attribute, because a `Temporal.Instant` isn't a string.
	 */
	@property( { attribute: false } )
	clock: Clock = {
		get now(): Temporal.Instant {
			return Temporal.Now.instant();
		},
	};

	/**
	 * Styles for the component, scoped to its shadow root.
	 */
	static styles = css`
		:host {
			display: block;
			font-family: var( --font-sans, system-ui, sans-serif );
			color: var( --ink, #17171a );
		}

		.card {
			border: 1px solid var( --line, #e7e6e2 );
			border-radius: var( --event-time-radius, 12px );
			background: var( --surface, #fff );
			padding: 1.25rem 1.4rem;
			max-width: 30rem;
		}

		.title {
			margin: 0 0 0.9rem;
			font-size: 1.05rem;
			font-weight: 600;
			letter-spacing: -0.01em;
		}

		.rows {
			display: grid;
			gap: 0.55rem;
			margin: 0;
		}

		.row {
			display: grid;
			grid-template-columns: 7.5rem 1fr;
			gap: 0.5rem;
			align-items: baseline;
		}

		.row dt {
			font-family: var( --font-mono, monospace );
			font-size: 0.72rem;
			letter-spacing: 0.04em;
			text-transform: uppercase;
			color: var( --ink-faint, #71717b );
		}

		.row dd {
			margin: 0;
			font-size: 0.95rem;
		}

		.warning {
			margin: 1.1rem 0 0;
			border-radius: 10px;
			border: 1px solid #f1d27a;
			background: #fdf6e3;
			padding: 0.6rem 0.8rem;
			font-size: 0.9rem;
			line-height: 1.5;
			color: #8a6d1f;
		}

		.label {
			margin: 1.1rem 0 0;
			font-size: 0.95rem;
		}

		.label .word {
			font-weight: 600;
		}

		.error {
			margin: 0;
			font-size: 0.9rem;
			color: #cc3b41;
		}

		.error code {
			font-family: var( --font-mono, monospace );
			font-size: 0.82rem;
		}
	`;

	/**
	 * Types `addEventListener( 'event-time-change', ... )` so the listener receives a `CustomEvent<EventTimeChangeDetail>` with no cast.
	 * Falls through to the standard signature for every other event.
	 */
	addEventListener<K extends keyof EventTimeEventMap>(
		type: K,
		listener: ( this: EventTime, ev: EventTimeEventMap[ K ] ) => void,
		options?: boolean | AddEventListenerOptions,
	): void;
	addEventListener(
		type: string,
		listener: EventListenerOrEventListenerObject,
		options?: boolean | AddEventListenerOptions,
	): void {
		super.addEventListener( type, listener, options );
	}

	/**
	 * The resolved state derived once per update in `willUpdate`: missing input, invalid input, or a ready view model.
	 *
	 * Both `render` and `updated` read this single value rather than re-deriving, so the "derive once" rule the card teaches also holds inside the card.
	 * Modeling the three states explicitly, rather than collapsing them to a nullable view, is the same discipline the model applies to time: a missing input and a bad input are different answers and the card shows different things for each.
	 */
	private state: EventTimeState = { status: 'missing-input' };

	/**
	 * Derives the state once before each render, reading the injected clock a single time.
	 *
	 * Doing the derivation here rather than in a getter means `render` and `updated` share one computed value, so the clock is read once per update and the today label can never disagree with the count the event reports.
	 *
	 */
	protected willUpdate(): void {
		this.state = buildEventTimeState( this.startsAt, this.eventTimeZone, this.userTimeZone, this.clock, this.locale );
	}

	/**
	 * Dispatches the typed `event-time-change` event after each successful render.
	 *
	 * It's `composed` so it crosses the shadow boundary and `bubbles` so a listener on the host receives it.
	 *
	 * @param view - The view model that was just rendered.
	 */
	private emitChange( view: EventTimeViewModel ): void {
		this.dispatchEvent(
			new CustomEvent<EventTimeChangeDetail>( 'event-time-change', {
				detail: {
					sameCalendarDay: view.sameCalendarDay,
					userCalendarLabel: view.userCalendarLabel,
					daysUntilInUserCalendar: view.daysUntilInUserCalendar,
				},
				bubbles: true,
				composed: true,
			} ),
		);
	}

	/**
	 * Lifecycle hook that emits the change event once the resolved render is in the DOM.
	 *
	 */
	protected updated(): void {
		if ( this.state.status === 'ready' ) {
			this.emitChange( this.state.view );
		}
	}

	/**
	 * Renders the card for whichever state the inputs resolved to.
	 *
	 * Missing input shows a neutral prompt, invalid input shows the real parse error, and a ready model shows the two times, the cross-zone warning when the dates differ, and the user-calendar label.
	 */
	render() {
		if ( this.state.status === 'missing-input' ) {
			return html`
				<div class="card">
					<p class="error">
						Set <code>starts-at</code>, <code>event-time-zone</code>, and <code>user-time-zone</code> to render the card.
					</p>
				</div>
			`;
		}

		if ( this.state.status === 'invalid-input' ) {
			return html`
				<div class="card">
					<p class="error">
						That event time could not be read: <code>${ this.state.message }</code>
					</p>
				</div>
			`;
		}

		const view = this.state.view;
		const userDay = view.userCalendarLabel === 'date'
			? formatPlainDate( view.eventDateInUserZone, this.locale )
			: view.userCalendarLabel;

		return html`
			<div class="card">
				<h2 class="title">Online workshop</h2>
				<dl class="rows">
					<div class="row">
						<dt>Venue time</dt>
						<dd>${ formatZonedDateTime( view.eventZonedDateTime, this.locale ) }</dd>
					</div>
					<div class="row">
						<dt>Your time</dt>
						<dd>${ formatZonedDateTime( view.userZonedDateTime, this.locale ) }</dd>
					</div>
				</dl>
				${ view.crossZoneWarning
					? html`<p class="warning">${ view.crossZoneWarning }</p>`
					: null }
				<p class="label">
					On your calendar this is <span class="word">${ userDay }</span>.
				</p>
			</div>
		`;
	}
}

declare global {
	interface HTMLElementTagNameMap {
		'event-time': EventTime;
	}
}

Lock the model with test vectors

The demo proves the model visually, and that’s the part you can poke at. The test vectors lock it, so the next change can’t quietly break the divergence the whole card is built on. Because the model is framework-free, the assertions read as plainly as the prose, and they live in event-time-model.test.ts right next to the model.

event-time-model.test.ts
// event-time-model.test.ts, the vectors that pin the model
const laClock = frozenClock( '2026-08-30T19:00:00Z' );
const tokyoClock = frozenClock( '2026-08-30T19:00:00Z' );

// Same instant, different user zone, different answer.
expect( deriveViewModel( berlinEvent( 'America/Los_Angeles' ), laClock ).sameCalendarDay ).toBe( false );
expect( deriveViewModel( berlinEvent( 'Asia/Tokyo' ), tokyoClock ).sameCalendarDay ).toBe( true );

// The warning names the real calendar date on each side, and stays empty when they match.
const warning = deriveViewModel( berlinEvent( 'America/Los_Angeles' ), laClock ).crossZoneWarning;
expect( warning ).toContain( 'Monday, Aug 31' );
expect( warning ).toContain( 'Sunday, Aug 30' );
expect( deriveViewModel( berlinEvent( 'Asia/Tokyo' ), tokyoClock ).crossZoneWarning ).toBeNull();

// The Los Angeles user reads it as today even though it isn't the host's calendar day.
expect( deriveViewModel( berlinEvent( 'America/Los_Angeles' ), laClock ).userCalendarLabel ).toBe( 'today' );

// Calendar days, not elapsed milliseconds, across the LA spring-forward.
const laSpringForwardClock = frozenClock( '2026-03-07T20:00:00Z' );
const laSpringForwardCase = parseEventTimeModel( '2026-03-10T19:00:00Z', 'America/Los_Angeles', 'America/Los_Angeles' );
expect( deriveViewModel( laSpringForwardCase, laSpringForwardClock ).daysUntilInUserCalendar ).toBe( 3 );

The same file also pins the failure states, that a blank input is missing-input and a bad zone is invalid-input, so the discriminated union can’t quietly regress to a single blank card either. That pairing is the senior habit worth taking away. The demo proves the model to a reader, and the test vectors prove it to the next commit.

A few ways this goes wrong

  • Comparing the instant instead of the calendar dates. Two events at the same instant are the same instant everywhere, but they aren’t the same calendar day everywhere. Convert into each zone first, then compare PlainDate values. The opener is this mistake wearing a coat.
  • Letting the machine zone stand in for the user zone. Defaulting userTimeZone to Temporal.Now.timeZoneId() reads fine until a user travels, sets their profile to a different zone, or simply lives somewhere your test machine doesn’t. The user zone is a product input. Make it required and let it be wrong loudly rather than wrong silently.
  • Conflating the same-day question with the today question. They share the same PlainDate machinery and read almost identically, so it’s tempting to compute one and reuse it for the other. They answer different things. Keep the same-day comparison and the today comparison as separate expressions, and derive the label from the day count so it can’t drift.
  • Counting days with milliseconds. Math.floor( diff / 86_400_000 ) is off by one across any daylight-saving boundary, and it fails silently, since the number it returns is perfectly plausible. Use PlainDate.until with a day unit and let the calendar count.
  • Mixing date math into the formatting. Once a toLocaleString call is also doing zone arithmetic, you can’t review the logic without re-reading the presentation. Do the temporal work in one place and format at the edge.

What separates a senior from a junior here

The clearest tell is a one-line review comment. A junior writes the user side like this, and it sails through the diff because it formats a real instant and produces a real-looking date:

ts
// Review smell:
const userDay = formatter.format( eventInstant );

The senior comment is short. This formatter has no timeZone, so it answers a device question when the product asked for the user’s zone. The fix makes the zone explicit before the date is compared.

ts
// Better:
const userZonedDateTime = instant.toZonedDateTimeISO( userTimeZone );
const eventDateInUserZone = userZonedDateTime.toPlainDate();

Now the zone is named before the date is compared, and the next reader can see the conversion happen instead of trusting a format string to carry it.

A few more contrasts that separate the levels on this exact problem:

  • Classifying the value before writing code. A junior asks which helper formats this. A senior asks which Temporal type the value is, an instant, a calendar date, a wall-clock time, or a duration, and lets that answer pick the API. The table earlier is that habit written down.
  • Telling the same-day question from the today question. A junior writes one date comparison and reuses it. A senior keeps the two as separate expressions and chooses the canonical case so the answers visibly disagree, because that disagreement is the proof the model is right.
  • Keeping formatting at the edge. A junior interleaves zone math with toLocaleString. A senior derives the model once, in framework-free code that tests on its own, and formats only at the boundary.

When not to use this pattern

Reach for the full Temporal model when the product genuinely spans zones, when “the same day” or “today” is a real product question, or when a countdown crosses daylight-saving boundaries. That’s when the types pay for themselves. If your app shows times in exactly one zone and never compares calendar days across zones, you don’t need any of this, and a single well-placed Intl.DateTimeFormat is the right amount of tool. You don’t need to retire your date library this afternoon; the habit worth building is recognizing which of the four contracts a value belongs to, so that when the same-day bug lands in a diff, you’re the person who names it in one comment.

Build your own

You’ve played with the finished card above. Now build one yourself, and test it the way it’ll actually break: pick an instant near a calendar boundary, read it in a zone east of the venue and a zone west of it, and confirm the same-day answer flips while the today label tracks “now” in the user’s zone. Then add the recurring case the table left out, an event that repeats at a wall-clock time in the user’s zone, and notice how quickly you reach for PlainTime plus a zone rule once you’re already thinking in contracts. That last part, the reaching for the right type without being told, is the eye this was meant to build.

Want more of these in your Google results?