Date, DateTime, and Time Types Implementation

How Vanilla Grid parses, sorts, filters and renders the three temporal column types — date, datetime and time.

Purpose

Temporal handling is deterministic for sorting, locale-aware for display, and explicit about malformed input. One parser backs every path, so a value that sorts as invalid also filters and renders as invalid.

Before features/datetime.feature.js existed there were four independent new Date(value) call sites — cell formatting, the pairwise comparator, the precomputed sort keys, and the data managers' filter predicates. They could disagree about the same malformed cell. They now share one implementation.

Architecture

features/datetime.feature.js is a pure, dependency-free module exposing window.VanillaGridDateTimeFeature. It is auto-loaded before every consumer.

Consumer Entry point What it uses
vanilla-grid.js _formatByType() formatTemporalValue
features/sorting-comparator.feature.js defaultCompareValues() compareTemporalValues
features/sorting-comparator.feature.js _buildSortKeyColumns() parseTemporalValue + inline bucket ranks
data-managers/static-data-manager.js _epoch() parseDateTimeValue
data-managers/odata-data-manager.js _toIso() parseDateTimeValue

Each consumer keeps an inline new Date() fallback for partial bundles that omit the file; the shipped bundle always includes it.

The filter Worker never parses

StaticDataManager._workerSource is a template string evaluated inside a Web Worker — a separate realm with no window and no loaded modules — so it cannot delegate to the feature. Keeping a hand-maintained parser copy there would be a standing drift hazard inside a general-purpose component, and would let filter results depend on whether the worker offload happened to kick in.

Instead, both string-parsing sites were moved to the main thread, where the one canonical parser runs:

What Where it is resolved Shipped to the Worker as
Row values (ordinary temporal column) _packColumnValuesChunked() { kind: 'numeric', numeric: Float64Array }
Row values (boxed temporal column) _packColumnValuesChunked() { kind: 'boxed', values, numeric } — raw values and an epoch sidecar
Filter operands _packCondition() condition.valueEpoch / valueToEpoch

A column goes boxed when any of its conditions uses a type-agnostic unary operator (isEmpty / isNotEmpty / …), because those test the raw value's emptiness identity and would break if encoded to NaN. A temporal column can still carry an ordering operator alongside one — isNotEmpty AND before X — so the boxed payload ships the epoch sidecar too, and the Worker chooses the source per condition, never per column.

What reaches the Worker is therefore always a number. Its epochOf() is a guard, not a parser: a non-numeric value is reported unparseable rather than guessed at. tests/node/datetime-feature.test.js asserts the worker source contains no date-parsing code, so a parser cannot creep back in.

The parse envelope

Every parse* function returns the same shape and never throws:

{
  ok: boolean,                          // true only for kind === 'value'
  kind: 'value' | 'null' | 'invalid',
  numeric: number | null,               // epoch ms; seconds since midnight for `time`
  raw: unknown                          // the original value
}

The null/invalid split is the point: an empty cell and a corrupt one are different problems, and collapsing them hides data-quality issues.

API

const F = window.VanillaGridDateTimeFeature;

F.isDateType(column); F.isDateTimeType(column); F.isTimeType(column);
F.isTemporalColumn(column);

F.parseDateValue(value, column);        // → envelope (epoch ms)
F.parseDateTimeValue(value, column);    // → envelope (epoch ms)
F.parseTimeValue(value, column);        // → envelope (seconds since midnight)
F.parseTemporalValue(value, column);    // dispatches on column.type

F.compareTemporalValues(a, b, column, collator);        // → number
F.formatTemporalValue(value, column, { locale, nullText });  // → string

parseDateValue and parseDateTimeValue are the same function. A date column is never truncated to the start of its day — several hosts declare type="date" while feeding a full ISO instant, and truncating would shift those columns by up to a day depending on the viewer's offset.

formatTemporalValue's nullText defaults to '' so grid cells render blank; callers that want a placeholder (detail panels use '-') pass their own.

Column options

{
  key: 'createdAt',
  type: 'datetime',
  sourceFormat: 'auto',   // 'auto' | 'epochMs' | 'epochSec' | 'strict-pattern'
  inputPattern: null,     // required for 'strict-pattern', e.g. 'DD/MM/YYYY HH:mm:ss'
  nulls: 'first',         // 'first' (default) | 'last'
  timeZone: 'UTC',        // forwarded to Intl.DateTimeFormat
  formatOptions: {}       // forwarded to Intl.DateTimeFormat ({} = locale default)
}

Accepted input

'auto' (default)

The name is deliberately not 'iso' — the mode accepts more than ISO strings:

  1. ISO 8601, with or without a time part, with or without an offset. A space may replace T. 1 to 9+ fractional-second digits are accepted and truncated toward zero to milliseconds, so .NET DateTime "O" round-trip timestamps (1960-08-07T14:10:08.2353749Z) parse — ECMA-262 specifies exactly three digits, but real services emit seven.
  2. Date instances (an Invalid Date is kind: 'invalid').
  3. Bare finite numbers, as epoch milliseconds. Hosts that store temporal cells as numbers rather than Date objects — a meaningful memory saving on large datasets — depend on this.
  4. Any other string the engine can parse, except the ambiguous forms below.

Out-of-range ISO components are rejected, never rolled over: 2026-04-31, 2023-02-29 and 2026-13-01 are all invalid.

'epochMs' / 'epochSec'

Numbers (or numeric strings) are epoch milliseconds or seconds. Declaring this removes all guessing for a controlled feed.

'strict-pattern'

Fixed-width, zero-padded numeric tokens; every other character is a literal:

Token Meaning Digits Range
YYYY year 4 00009999
MM month 2 0112
DD day 2 0131, validated against the month
HH hour (24h) 2 0023
mm minute 2 0059
ss second 2 0059
SSS millisecond 3 000999

Ambiguity policy

03/04/2026 is 3 April in most of the world and March 4th in the US. The engine's own behaviour is worse than either: it silently picks one for day numbers ≤ 12 and returns Invalid Date above that, so the same column behaves differently row by row.

Slash- and dot-separated day-month-year forms are therefore invalid in 'auto' mode rather than guessed. A four-digit leading year (2011/01/26) is unambiguous and still parses.

To read such a feed, declare it:

{ key: 'birthDate', type: 'date', sourceFormat: 'strict-pattern', inputPattern: 'DD/MM/YYYY' }

Time zones

This module changes which strings are accepted, never what instant an accepted string means:

Input shape Resolves to
ISO with Z or an explicit offset that instant
ISO date-only (2026-04-03) UTC midnight (the ECMA-262 rule)
ISO datetime without an offset host local time (the ECMA-262 rule)
epochMs / epochSec that instant
strict-pattern extraction host local time, matching offsetless ISO
time values seconds since midnight, no zone

Known caveat: an ISO date-only value lands on UTC midnight, so a type: 'date' column rendered in a negative-offset locale shows the previous calendar day. Set column.timeZone: 'UTC' to read it back in the zone it was parsed in.

Known caveat: the on / notOn filter operators compare local calendar components against an operand that a date-only string put at UTC midnight, so they disagree by a day in negative-offset zones. Both caveats predate this module and are preserved rather than silently changed.

Sorting

compareTemporalValues orders by bucket first, then within the bucket:

column.nulls Bucket order
'first' (default) null → value (chronological) → invalid
'last' value (chronological) → invalid → null

Two nulls tie; two invalid values are ordered by the locale collator so the result stays deterministic instead of collapsing into one tie.

The default reproduces the grid's historical hardcoded nulls-first ordering exactly, so no existing grid changes order unless it opts in.

Both sort paths implement this identically — defaultCompareValues delegates, and _buildSortKeyColumns precomputes the same bucket ranks alongside its numeric keys so the Schwartzian path stays O(n) in parsing.

Temporal columns are dispatched before the comparator's generic nulls-first guard, because they own their own null placement. Routing them after it would pin every temporal column to nulls-first regardless of column.nulls.

Formatting

formatTemporalValue resolves the envelope, then formats:

Defaults when the column declares no formatOptions:

formatOptions: {} is not the same as omitting it: an empty bag is specified to be equivalent to passing no options to Intl.DateTimeFormat, i.e. the locale default (1/26/2011), whereas the default bag above is 2-digit (01/26/2011).

time values are anchored on the epoch day and read back in UTC, so the host's offset cannot shift the displayed clock.

The formatter cache

Intl.DateTimeFormat construction is expensive, so one formatter is cached per column and invalidated on four fields: the formatter's presence, the formatOptions reference, timeZone, and locale.

The locale check is what makes a module-level column constant safe for a caller that re-renders after a live language switch. Without it the first locale seen would be pinned forever — an Italian user would keep seeing English dates. Any caller reusing a stable column object depends on this.

Localization

The parser is locale-independent by construction: localized output never becomes parser input. The three round-trip paths were checked —

Known limitations

Recommendations

  1. Standardize backend payloads on ISO 8601 for date and datetime.
  2. Standardize time as zero-padded 24-hour HH:mm:ss.
  3. Where a feed is locale-ordered, declare sourceFormat: 'strict-pattern' rather than relying on any parser's guess.
  4. Treat locale formatting strictly as presentation.