# Languages, civil dates and time zones

> Localize headers and labels with any Intl locale, format SuperScheduler.Date values, and keep time-zone conversion and recurrence where they belong: in your app.

Source: https://superscheduler.org/en/docs/locales-dates-timezones/
Reviewed: 2026-10-07

Set `locale` to any Intl locale id such as `fr-fr`: month and day names, the 12- or 24-hour clock and the first day of the week follow it, and `timeFormat` and `weekStarts` override the last two. Dates are civil wall-clock values: the scheduler never converts time zones and never expands recurring events. Convert instants to the business time zone before you pass events, convert back when you save, and expand series into occurrences in your application.

A scheduler shows dates to people, so two separate concerns meet here. Localization decides how a date is written: names, order, clock and first day of the week. Time semantics decide which date it is: the library works with civil wall-clock values and leaves time zones and recurrence to your application. This guide covers both, for Pro and, where noted, Lite.

## Set the locale
`locale` accepts any locale id that `Intl` understands, written SuperScheduler-style in lower case: `en-us` (the default), `en-gb`, `fr-fr`, `de-de`, `es-es`, `pt-br`, `nl-nl`, `ja-jp` and so on. There is no list to register. `en_US` is normalized to `en-us`, and an id that `Intl` cannot resolve falls back to `en-us`.

In Pro, the locale drives:

- month and day names in the default time headers and in every header `format` pattern;
- the default clock when `timeFormat` is `'Auto'` (12-hour for `en-us`, 24-hour for most European locales);
- the first day of the week when `weekStarts` is `'Auto'` (Sunday for `en-us` and `pt-br`, Monday for most European locales);
- the default date patterns of day headers and the dates on the drag card;
- the language of keyboard announcements and of some built-in labels (see [Built-in strings](#built-in-strings)).

Lite accepts the same `locale` ids for its day headers.

```tsx
// src/FrenchPlanning.tsx
import { useCallback, useState } from 'react'
import { SuperScheduler, SuperSchedulerComponent } from 'super-scheduler'
import type { SchedulerEventClickArgs } from 'super-scheduler'

// One constant for the scheduler and for every date you format yourself.
const LOCALE = 'fr-fr'
const WEEK_START = SuperScheduler.Date.today().firstDayOfWeek(1)

const TIME_HEADERS: SuperScheduler.TimeHeaderData[] = [
  { groupBy: 'Day', format: 'dddd d MMMM' }, // "lundi 5 octobre"
  { groupBy: 'Hour' }, // default labels follow timeFormat: "0" to "23" here, "2 PM" in 12-hour mode
]

interface Props {
  readonly rooms: SuperScheduler.ResourceData[]
  readonly events: SuperScheduler.EventData[]
}

export function FrenchPlanning({ rooms, events }: Props) {
  const [summary, setSummary] = useState('')

  // SuperScheduler.Date#toString does not read the scheduler's locale: pass it explicitly.
  const onEventClick = useCallback((args: SchedulerEventClickArgs) => {
    const start = args.e.start().toString('dddd d MMMM, HH:mm', LOCALE)
    const end = args.e.end().toString('HH:mm', LOCALE)
    setSummary(`${args.e.text()} : ${start} – ${end}`)
  }, [])

  return (
    <>
      <p aria-live="polite">{summary}</p>
      <SuperSchedulerComponent
        locale={LOCALE}
        // 'Auto' already gives Monday and a 24-hour clock for fr-fr; explicit values ignore the locale.
        weekStarts={1}
        timeFormat="Clock24Hours"
        startDate={WEEK_START}
        days={7}
        scale="Hour"
        cellWidth={48}
        timeHeaders={TIME_HEADERS}
        resources={rooms}
        events={events}
        onEventClick={onEventClick}
      />
    </>
  )
}
```
You should see headers such as "lundi 5 octobre" over hour columns labeled `0` to `23`, a week that starts on Monday, and a French summary when you click an event.

## Clock format and first day of the week
`timeFormat` takes `'Auto'`, `'Clock12Hours'` or `'Clock24Hours'`. It changes the default hour labels only; an explicit `format` in `timeHeaders` always wins (`'HH:mm'` for 24 hours, `'h:mm tt'` for 12 hours). It is a display setting, not a time conversion: switching it never moves an event.

`weekStarts` takes `'Auto'` or a day number from `0` (Sunday) to `6` (Saturday). It affects `Week` cells and header groups, the week lines drawn when zoomed out, and the default week numbers: ISO numbers when weeks start on Monday, US numbers otherwise. In your own code, `date.firstDayOfWeek()` defaults to Sunday, so pass the same value (`firstDayOfWeek(1)`) or the locale id (`firstDayOfWeek('fr-fr')`).

What `'Auto'` resolves to for a few ids:

| Locale | `ddd d MMM` | Clock | Week starts |
|---|---|---|---|
| `en-us` | Mo 5 Oct | 12-hour | Sunday |
| `en-gb` | Mo 5 Oct | 24-hour | Monday |
| `es-es` | L 5 oct | 24-hour | Monday |
| `de-de` | Mo 5 Okt | 24-hour | Monday |
| `fr-fr` | lu 5 oct. | 24-hour | Monday |
| `pt-br` | se 5 out. | 24-hour | Sunday |

## Format dates in your own UI
`SuperScheduler.Date` formats with SuperScheduler patterns: `yyyy`, `yy`, `MMMM`, `MMM`, `MM`, `M`, `dddd`, `ddd`, `dd`, `d`, `HH`, `H`, `hh`, `h`, `mm`, `m`, `ss`, `s` and `tt` (AM/PM). Everything else is literal text.

> **Behavior:**
> `date.toString(pattern, locale)` does not read the scheduler's `locale`. Without the second argument it formats in `en-us`. Keep the locale id in one constant and pass it to the component and to every `toString` call, as in the example above.

`ddd` is the short weekday name from `Intl`, which is one or two letters in several locales: "Mo" in English, "L" in Spanish, "lu" in French, "dl" in Catalan. Use `dddd` for the full name, register your own short names (below), or format with `Intl` directly. `date.toDate()` returns a native `Date` with the same ticks, meant to be read in UTC, so format it with `timeZone: 'UTC'` to show exactly the civil value on any device:

```ts
// src/format.ts
import type { SuperScheduler } from 'super-scheduler'

// toDate() returns a native Date with the same ticks, meant to be read in UTC.
// Formatting it with timeZone 'UTC' shows exactly the civil value, on any device.
const dayFormat = new Intl.DateTimeFormat('fr-FR', {
  weekday: 'short',
  day: 'numeric',
  month: 'short',
  timeZone: 'UTC',
})
const stayFormat = new Intl.DateTimeFormat('en-GB', {
  day: 'numeric',
  month: 'short',
  hour: '2-digit',
  minute: '2-digit',
  timeZone: 'UTC',
})

export function formatDay(date: SuperScheduler.Date): string {
  return dayFormat.format(date.toDate()) // "lun. 5 oct."
}

export function formatStay(start: SuperScheduler.Date, end: SuperScheduler.Date): string {
  return stayFormat.formatRange(start.toDate(), end.toDate())
}
```
## Custom names and patterns
`SuperScheduler.Locale.register()` replaces the names and patterns of an id for every scheduler and every `toString` call that uses it. A `SuperScheduler.Locale` passed directly as the `locale` option is registered automatically.

```ts
// src/locale.ts
import { SuperScheduler } from 'super-scheduler'

// Start from the Intl data for the id: fields you leave out fall back to US English, not to French.
const base = SuperScheduler.Locale.find('fr-fr')

// Run once at startup, before the first scheduler mounts.
SuperScheduler.Locale.register(
  new SuperScheduler.Locale('fr-fr', {
    ...base,
    // `ddd` gives two-letter Intl abbreviations ("lu"); these read "lun.".
    dayNamesShort: ['dim.', 'lun.', 'mar.', 'mer.', 'jeu.', 'ven.', 'sam.'],
  }),
)

// Every scheduler with locale="fr-fr" and every toString(pattern, 'fr-fr') now uses these names.
export const sample = new SuperScheduler.Date('2026-10-05').toString('ddd d MMM', 'fr-fr') // "lun. 5 oct."
```
> **Behavior:**
> Fields you omit from `new SuperScheduler.Locale(id, fields)` take the US English defaults (English names, `M/d/yyyy`, 12-hour clock, Sunday), not the `Intl` data for that id. Start from `SuperScheduler.Locale.find(id)` as shown, then override.

## Built-in strings and their languages
The library writes a few strings of its own. The language is the first part of the locale id (`ca-es` gives `ca`).

| Strings | Languages | Override |
|---|---|---|
| Keyboard announcements and focus labels | English, Spanish, Catalan, Basque, Galician, German, French, Italian, Portuguese | Not configurable |
| Drag card durations and refusals ("2 nights", "Overlaps", "Not allowed") | Same nine languages | `dragCard={{ labels: { ... } }}` |
| Grid name, loading, empty and error texts | English, or Spanish for `es` locales | `emptyState`, `errorState` and `loadingLabelText`; the grid's accessible name is fixed |
| History entry labels ("Move", "Resize") | English, or Spanish for `es` locales | `createHistory({ labels })` |
| Minimap labels | English, or Spanish for `es` locales | `createMinimap(control, element, { labels })` or the `labels` prop of `SchedulerMinimap` |
| Level-of-detail badge | English, or Spanish for `es` locales | third argument of `createLodBadge` |
| Lite: grid label and empty text | English | `ariaLabel`, `emptyState` |

Any other language reads English, so a French or German application should pass its own strings for the history, minimap, badge and status texts. Event text, resource names and any HTML you render are yours to translate.

## Civil dates
Every date in SuperScheduler is a civil wall-clock value without a time zone. `'2026-10-01T10:00:00'` means ten o'clock on the planning board, wherever the page is opened. The consequences:

- **Strings need seconds.** `'2026-10-01'` and `'2026-10-01T10:00:00'` are valid; `'2026-10-01T10:00'` throws "is not an ISO 8601 date". Native `Date` objects do not type-check as `start` or `end`.
- **Zones are converted to UTC.** A string with `Z` or an offset is converted to the UTC wall clock: `'2026-10-01T10:00:00+02:00'` becomes `08:00:00`. Strip zones only after converting to the business time zone yourself.
- **No daylight-saving surprises.** `2026-03-29T02:30:00` exists, and adding an hour to it gives `03:30`, regardless of the browser's zone. Durations are plain wall-clock differences.
- **Intervals are half-open.** An event from `14:00` to `16:00` ends before a booking that starts at `16:00`.
- **Native dates.** `new SuperScheduler.Date(date)` reads a native `Date`'s UTC fields; `new SuperScheduler.Date(date, true)` reads its local fields. `toDate()` returns a `Date` to read in UTC; `toDateLocal()` returns one whose local fields show the wall clock.
- **"Today" is the viewer's device.** `SuperScheduler.Date.today()`, the default `startDate`, the today highlight and the now line use the device's clock. A planner in New York looking at a hotel in Madrid sees New York's today. If that matters, compute the business "today" yourself and pass it as `startDate` or to `scrollTo`.

## Time zones are your application's job
If your backend stores instants (UTC timestamps), choose the time zone each scheduler represents, usually the site's or the resource owner's, and convert at the edges. `Intl.DateTimeFormat` with a `timeZone` gives you the wall clock of any instant, with no extra dependency:

```ts
// src/time-zones.ts
import { SuperScheduler } from 'super-scheduler'

const formatters = new Map<string, Intl.DateTimeFormat>()

function partsFormatter(timeZone: string): Intl.DateTimeFormat {
  let formatter = formatters.get(timeZone)
  if (formatter === undefined) {
    formatter = new Intl.DateTimeFormat('en-US', {
      timeZone,
      hourCycle: 'h23',
      year: 'numeric',
      month: '2-digit',
      day: '2-digit',
      hour: '2-digit',
      minute: '2-digit',
      second: '2-digit',
    })
    formatters.set(timeZone, formatter)
  }
  return formatter
}

/** What a wall clock in `timeZone` shows at `instant`, as a civil ISO string: "2026-10-01T10:00:00". */
export function toWallClock(instant: Date, timeZone: string): string {
  const part: Record<string, string> = {}
  for (const { type, value } of partsFormatter(timeZone).formatToParts(instant)) part[type] = value
  return `${part.year}-${part.month}-${part.day}T${part.hour}:${part.minute}:${part.second}`
}

function offsetAt(ms: number, timeZone: string): number {
  return Date.parse(`${toWallClock(new Date(ms), timeZone)}Z`) - ms
}

/**
 * The instant at which a wall clock in `timeZone` shows `wall`. Times skipped or repeated by a
 * daylight-saving change have no single answer: this picks a neighbouring instant, so validate
 * them in your application if they matter.
 */
export function fromWallClock(wall: SuperScheduler.DateInput, timeZone: string): Date {
  const asUtc = Date.parse(`${new SuperScheduler.Date(wall).value}Z`)
  const guess = asUtc - offsetAt(asUtc, timeZone)
  return new Date(asUtc - offsetAt(guess, timeZone))
}

/** API instants to scheduler events on the property's wall clock. */
export async function loadBookings(fromUtc: string, toUtc: string, timeZone: string) {
  const rows = await fetchBookingInstants(fromUtc, toUtc)
  return rows.map((row): SuperScheduler.EventData => ({
    id: row.id,
    resource: row.roomId,
    text: row.guest,
    start: toWallClock(new Date(row.startUtc), timeZone),
    end: toWallClock(new Date(row.endUtc), timeZone),
  }))
}

// Saving goes the other way: fromWallClock(event.start, 'Europe/Madrid').toISOString()
```
Convert on the way in (`toWallClock` when you map API rows to events) and on the way out (`fromWallClock` when you save `start` and `end`, which are `SuperScheduler.Date` objects after a drag). For range loading, convert the chunk's civil `start` and `end` the same way before querying a UTC API.

> **Limitation:**
> One scheduler has one time axis. Rows that live in different time zones can still share it, but then you decide what the axis means: convert every row to one display zone, or show each row in its own local time and accept that the same column means different instants.

Times skipped or repeated when clocks change are a business rule, not a formatting detail. A booking at 02:30 on the night the clocks go forward does not exist in Madrid; decide whether to reject it, move it, or store it differently.

## Recurring events
The scheduler has no recurrence engine. The `recurrent` and `recurrentMasterId` fields are typed, but `control.events.findRecurrent()` is reserved and returns `null`. Store series in your application and expand them into ordinary events for the dates on screen. Give each occurrence an id that is stable across requests, such as the series id plus its date, so range loading can merge it:

```ts
// src/recurrence.ts
import { SuperScheduler } from 'super-scheduler'

/** A weekly series as your application stores it. */
export interface WeeklySeries {
  readonly id: string
  readonly resource: string
  readonly text: string
  /** First occurrence, civil date-time with seconds. */
  readonly start: string
  readonly durationMinutes: number
  /** 0 = Sunday ... 6 = Saturday. */
  readonly weekdays: readonly number[]
  /** Last day of the series, inclusive, as yyyy-MM-dd. */
  readonly until: string
  /** Days removed from the series, as yyyy-MM-dd. */
  readonly exceptions: readonly string[]
}

/** The occurrences that overlap [from, to), each with an id that is stable across requests. */
export function expandWeekly(
  series: WeeklySeries,
  from: SuperScheduler.Date,
  to: SuperScheduler.Date,
) {
  const first = new SuperScheduler.Date(series.start)
  const firstDay = first.getDatePart().ticks
  const afterLastDay = new SuperScheduler.Date(series.until).addDays(1).ticks
  // Look back far enough to catch an occurrence that started earlier and is still running.
  const lookBack = Math.ceil(series.durationMinutes / 1440)
  const events: SuperScheduler.EventData[] = []
  for (
    let day = from.getDatePart().addDays(-lookBack);
    day.ticks < to.ticks;
    day = day.addDays(1)
  ) {
    if (day.ticks < firstDay || day.ticks >= afterLastDay) continue
    if (!series.weekdays.includes(day.getDayOfWeek())) continue
    const key = day.toString('yyyy-MM-dd')
    if (series.exceptions.includes(key)) continue
    const start = day.addTime(first.getTimePart())
    const end = start.addMinutes(series.durationMinutes)
    if (end.ticks <= from.ticks) continue
    events.push({
      id: `${series.id}:${key}`,
      resource: series.resource,
      text: series.text,
      start,
      end,
    })
  }
  return events
}
```
Editing is also yours. "Only this occurrence" usually means adding the date to the series' exceptions and creating a standalone event; "this and following" splits the series; "all occurrences" changes the series and expands it again. Map `onEventsChange` changes back to these operations by the occurrence id. Expanding in a range loader's `load` function keeps long series cheap: only the visible chunks are expanded.

## Checklist
- One `LOCALE` constant, passed to the component and to every `toString` call.
- `weekStarts` and `firstDayOfWeek()` agree.
- Your own strings for history, minimap, badge and status texts outside English and Spanish.
- Event dates as civil strings with seconds, converted to the business time zone before they reach the scheduler.
- Saving converts back to instants if your backend stores them.
- Series expanded per visible range with stable occurrence ids.

→ https://superscheduler.org/en/examples/video-production/
Related guides: [Resources, events and intervals](https://superscheduler.org/en/docs/resources-events-intervals/), [Time scales and zoom](https://superscheduler.org/en/docs/time-scales-zoom/), [Keyboard, accessibility and touch](https://superscheduler.org/en/docs/keyboard-accessibility-touch/) and [Loading data by date range](https://superscheduler.org/en/docs/range-loading/).
