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
}
null,undefined,''and whitespace-only strings →kind: 'null'.- Successfully parsed values →
kind: 'value'. - Everything else →
kind: 'invalid'.
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:
- 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 .NETDateTime"O" round-trip timestamps (1960-08-07T14:10:08.2353749Z) parse — ECMA-262 specifies exactly three digits, but real services emit seven. Dateinstances (an Invalid Date iskind: 'invalid').- Bare finite numbers, as epoch milliseconds. Hosts that store temporal
cells as numbers rather than
Dateobjects — a meaningful memory saving on large datasets — depend on this. - 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 | 0000–9999 |
MM |
month | 2 | 01–12 |
DD |
day | 2 | 01–31, validated against the month |
HH |
hour (24h) | 2 | 00–23 |
mm |
minute | 2 | 00–59 |
ss |
second | 2 | 00–59 |
SSS |
millisecond | 3 | 000–999 |
- The match is anchored; the value is trimmed first. No match →
invalid. - Range validation rejects rather than rolls over.
31/02/2026matchesDD/MM/YYYYbut isinvalid—new Date(2026, 1, 31)would silently become March 3rd, which is the same class of corruption as guessing field order. - Absent time tokens default to zero.
- A pattern containing an unrecognized letter (
M/D/YYYY,hh:mm A, month names) is a pattern error: it warns once and the column falls back to'auto'. Treating a stray letter as a literal would silently mark every row invalid, so a typo must not blank a column. - No AM/PM, named months, or timezone-offset tokens. Normalize upstream or use the epoch modes.
- The compiled regex is cached on the column and recompiled when
inputPatternchanges.
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:
kind: 'null'→opts.nullText(default'')kind: 'invalid'→String(value)— the raw source text, so bad data stays visible rather than turning into a misleading datekind: 'value'→Intl.DateTimeFormat
Defaults when the column declares no formatOptions:
date—{ year:'numeric', month:'2-digit', day:'2-digit' }datetime— the above plus{ hour:'2-digit', minute:'2-digit', second:'2-digit' }time—{ hour:'2-digit', minute:'2-digit', second:'2-digit' }
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 —
- filter operands come from native
<input type="date">/"datetime-local"/"time", whose.valueis ISO by HTML spec regardless of page language (only the widget's presentation is localized); - persisted filter models store that ISO operand verbatim, so a filter set in one language re-applies correctly in another;
- Excel export writes formatted text and is never re-imported.
Known limitations
sourceFormat/inputPatterndo not reach the static filter predicates. The filter model carries onlytype, not the column definition, soStaticDataManager's predicates always parse in'auto'mode. Every default column is unaffected ('auto'and'epochMs'agree for numbers); an'epochSec'or'strict-pattern'column will sort and render by its declared format but filter as'auto'. Threading column definitions into the predicate engine would be needed to close this.
Recommendations
- Standardize backend payloads on ISO 8601 for
dateanddatetime. - Standardize
timeas zero-padded 24-hourHH:mm:ss. - Where a feed is locale-ordered, declare
sourceFormat: 'strict-pattern'rather than relying on any parser's guess. - Treat locale formatting strictly as presentation.