Hapio Calendar

Time zones and daylight saving time

Every calendar has its own zone; changing it re-renders without remounting, and DST days are drawn and edited to the minute.

A calendar shows one time zone, timeZone: an IANA name ('Europe/Stockholm'), 'UTC', or 'local', the browser's zone (the default). Events from anywhere are drawn at their wall time in that zone, two calendars on a page can show two zones, and nothing reads or changes a global zone.

Choosing the zone

  • An explicit zone renders the same on the server and in every browser. Use it when your data belongs to a place: a salon in Stockholm shows Stockholm time to a visitor in New York. The server renders the whole calendar when it also has a date (defaultDate or date) or now; see SSR and RSC.
  • 'local' is known only in the browser, so the server renders the block's skeleton (at the root's size) and the calendar appears after hydration. See SSR and RSC.

Changing timeZone is a plain prop update. Nothing remounts and there is no key to change:

components/location-calendar.tsx
'use client'

import type { CalendarEventInput } from '@hapio/calendar-react'
import { useId, useState } from 'react'

import { HapioCalendar } from '@/components/hapio-calendar/hapio-calendar'

const LOCATIONS = [
  { id: 'stockholm', name: 'Stockholm', timeZone: 'Europe/Stockholm' },
  { id: 'new-york', name: 'New York', timeZone: 'America/New_York' },
] as const

type LocationId = (typeof LOCATIONS)[number]['id']

export function LocationCalendar({ events }: { events: readonly CalendarEventInput[] }) {
  const [locationId, setLocationId] = useState<LocationId>('stockholm')
  const location = LOCATIONS.find((item) => item.id === locationId) ?? LOCATIONS[0]
  const id = useId()
  return (
    <>
      <label htmlFor={id}>Location</label>
      <select
        id={id}
        value={location.id}
        onChange={(event) => {
          const next = LOCATIONS.find((item) => item.id === event.target.value)
          if (next) setLocationId(next.id)
        }}
      >
        {LOCATIONS.map((item) => (
          <option key={item.id} value={item.id}>
            {item.name}
          </option>
        ))}
      </select>
      {/* A new zone is a plain prop update: no `key`, nothing remounts. */}
      <HapioCalendar events={events} timeZone={location.timeZone} className="h-[36rem]" />
    </>
  )
}

On a zone change the calendar reports the new range (onRangeChange, with the new timeZone), event sources fetch it, and what belonged to the old zone ends: the selection is cleared, an open "+N more" popover or actions menu closes, and a drag or keyboard move in progress is cancelled.

Zone names are normalized: legacy aliases map to current names (Asia/Calcutta is Asia/Kolkata, Europe/Kiev is Europe/Kyiv) and case is fixed. Compare zones with sameTimeZone(a, b), never ===; normalizeTimeZone(id) returns the current name (and throws a RangeError for an unknown zone).

How dates are read

InputMeaning
Date, epoch ms, Temporal.Instant, ISO with Z or an offsetan exact instant: drawn at its wall time in the calendar's zone
Temporal.ZonedDateTime, ISO with [Zone]an exact instant (its zone is kept for recurring series)
Temporal.PlainDate, 'YYYY-MM-DD'an all-day date: the same date in every zone, never converted
Temporal.PlainDateTime, ISO without an offsetfloating: that wall time in the calendar's zone, whatever the zone is

A floating wall time that falls in a gap (it does not exist that day) or in a repeated hour (it happens twice) is resolved as Temporal does by default, 'compatible': a skipped time moves forward by the gap's length, a repeated one takes its first occurrence.

What the calendar gives back is always exact: callback spans and changes carry start and end as Temporal.ZonedDateTime in the calendar's zone and startIso and endIso with each end's own offset ('2026-10-25T01:30:00+02:00' to '2026-10-25T03:00:00+01:00'), and all-day ranges as dates with an exclusive end.

Daylight saving time

The time grids are a wall-clock axis: 00:00 at the top, 24:00 at the bottom, every day. On the days the clocks change, the axis stays and the lane marks what is special:

  • Spring forward (a 23-hour day): the hour that does not exist is a band (data-gap) in that day's lane. A time inside it resolves to the instant after it, and the slot cursor and keyboard moves step over it.
  • Fall back (a 25-hour day): the hour that happens twice is a band (data-repeated). Events of both passes are drawn in it as overlapping events (02:30 summer time and 02:30 winter time are an hour apart, at the same wall time). An event that ends at an earlier wall time than it starts (02:45 summer time to 02:15 winter time) is drawn one snap high and marked data-dst-overlap.
  • Names say which pass: in the hour that happens twice, the slot cursor and the "Change time…" form name times with their offset, "02:30 (UTC+2)" and "02:30 (UTC+1)", so a screen reader user can tell them apart.
  • Pointer and keyboard differ on purpose. A drop with the pointer lands on a wall time, which in the repeated hour means its first pass ('compatible'). Keyboard steps are elapsed time: ↓ from 02:30 summer time reaches 02:00 winter time, the second pass, so every instant can be reached from the keyboard. EventChange.delta reports both: ms (elapsed) and days (wall days).
  • The reschedule form asks. "Change time…" offers a choice of offset for a wall time that happens twice, and says when a wall time does not exist that day and where the event will be instead.
  • Floating events save wall times. apply() writes a floating event's new times without an offset, as its input had them, and a wall time in the repeated hour reads as its first pass: a floating one-off event moved to the second 02:30 comes back from your data at the first. Write events whose place in that hour matters with an offset or a zone. (A floating recurring series writes such a time with its offset; see Recurrence.)
  • Days do not always start at 00:00. Where midnight is skipped (Santiago, Havana, Beirut, Cairo on their DST days) a day starts at its first instant, and a day a zone skipped entirely (Samoa's 30 December 2011) is a lane that is all gap.

See the week Europe leaves daylight saving time, with bookings in both 02:30s, in the daylight saving time example.

Recurring events and zones

A recurring series has its own zone (09:00 Stockholm stays 09:00 Stockholm when the calendar shows New York), so its occurrences can move against the calendar's hours around DST changes. See Recurrence.