Skip to content

fix(dataview): serialize date filter bounds as UTC ISO timestamps - #153

Merged
matej21 merged 2 commits into
mainfrom
fix/date-filter-offset
Oct 31, 2026
Merged

matej21 merged 2 commits into
mainfrom
fix/date-filter-offset

Conversation

@matej21

@matej21 matej21 commented Oct 30, 2026 •

Copy link
Copy Markdown
Member

Problem

The date filter serialized its bounds as a local ISO string built from the local wall-clock time plus getTimezoneOffset(). For historical dates in zones that used local mean time (e.g. Europe/Prague before 1891, +0:57:44), the offset is not a whole number of minutes:

  • V8 returns a fractional offset, so the filter sends 0020-10-30T00:00:00+00:57.733333333333334. Postgres rejects it and the Contember API answers 500.
  • JavaScriptCore rounds the offset to whole minutes but still uses the exact offset for local time, so the bound is silently shifted by 44 seconds.

This is easy to hit: a date input reports partial years (0002, 0020, 0202) on each keystroke while the user types a year.

Fix

  • Keep the local-offset string (bounds stay YYYY-MM-DDT00:00:00±HH:MM, so date columns keep the local calendar day). Compute the offset from the local fields, not from getTimezoneOffset(), and write seconds when the offset has them (+00:57:44). Filter semantics are unchanged.
  • Ignore start/end values whose year is outside 1000–9999, so typing a year no longer sends filters for year 2, 20, 202.

Tests

  • tests/unit/dataview/filterHandlers.test.ts: pinned to Europe/Prague with literal expectations: a 2024 case, an 1890 LMT case (fails on the old code) and partial-year cases.
  • bun run typecheck, eslint on changed files, bun run test (2203 pass).

Status

  • Review round 1 (6f263ed): changes. 1 major, 2 minor, 1 nit, all fixed in a986441
  • CI: green on a986441
  • Left before merge: green CI, merge decision

🤖 Generated with Claude Code

matej21 and others added 2 commits October 30, 2026 20:04
The date filter built a local ISO string from the local wall-clock time
plus getTimezoneOffset(). For historical dates in zones with a local mean
time offset (e.g. Europe/Prague before 1891, +0:57:44), V8 returns a
fractional offset, producing "+00:57.733333333333334", which Postgres
rejects; JavaScriptCore rounds the offset instead, silently shifting the
bound by 44 seconds.

Serialize the same instant with toISOString(), and ignore years outside
1000-9999, which a date input reports while the user is typing the year.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
toISOString() moved the calendar day for `date` columns east of UTC:
Postgres casts the bound to `date` and drops the time and zone, so
2024-01-15 local midnight in Prague became 2024-01-14.

Restore the local-offset string, but derive the offset from the local
fields instead of getTimezoneOffset() (fractional in V8, rounded in
JavaScriptCore for LMT dates) and emit seconds when the offset has them.
Pin the date filter tests to Europe/Prague with literal expectations and
restore the zone by name, since deleting TZ does not reset it in Bun.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@matej21

matej21 commented Oct 30, 2026

Copy link
Copy Markdown
Member Author

Review round 1

Reviewer: independent Claude subagent (general-purpose, Claude Opus 5.5), not sharing the author's context
Commit: 6f263ed
Verdict: changes

Findings

  1. Major: date columns filter one day early in zones east of UTC (packages/bindx/src/dataview/filterHandlers.ts:191-201). The handler is shared by date and dateTime columns. The engine passes the string through and Postgres casts it to date, dropping the time and zone. toISOString() therefore turns Prague 2024-01-15 local midnight into 2024-01-14T23:00:00.000Z, which becomes 2024-01-14. This breaks the inclusive end too. Checked on Postgres 16.
  2. Minor: an end date of 9999-12-31 becomes +010000-01-01T00:00:00.000Z in UTC and in zones west of it. Postgres rejects that ("time zone displacement out of range").
  3. Minor: the test's TZ restore leaks: delete process.env.TZ does not reset the active zone in Bun, so later test files keep running in Europe/Prague.
  4. Nit: the plain date cases compare against a helper that copies the implementation, and under the default UTC they cannot fail. They would not have caught finding 1.

@matej21

matej21 commented Oct 30, 2026

Copy link
Copy Markdown
Member Author

Review round 1: response

  1. fixed in a986441. Back to a local-offset string, so the local calendar date stays in the bound. The offset now comes from the local fields (Date.UTC(local fields) - getTime()), not from getTimezoneOffset(), and seconds are written when present (+00:57:44). Checked on Postgres: 1890-06-15T00:00:00+00:57:44 parses as timestamptz and as date 1890-06-15.
  2. fixed in a986441. A local-offset string writes the year as 10000-01-01T00:00:00-05:00, which Postgres accepts (checked).
  3. fixed in a986441. The zone is saved through Intl.DateTimeFormat().resolvedOptions().timeZone and assigned back.
  4. fixed in a986441. All date-filter tests now run in Europe/Prague and compare with literal strings (2024-01-15T00:00:00+01:00, 1890-06-15T00:00:00+00:57:44).

@matej21
matej21 merged commit ee76661 into main Oct 31, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant