feat(analytics): timezone-aware date bucketing (ADR-0053 Phase 2) (#1982)#2001
Merged
Conversation
Day/week/month/quarter/year buckets resolve on a reference timezone's
calendar days, so a row near a tz day-boundary lands in the bucket a user
in that zone expects — identically on SQLite and Postgres.
Per decision D2, non-UTC bucketing runs in-memory uniformly rather than
emitting dialect-specific `date_trunc … AT TIME ZONE` (SQLite/MySQL lack
loaded tz data → cross-driver boundary skew). `engine.aggregate({ timezone })`
forces the in-memory path for a non-UTC zone; the date-range `where` still
goes to the driver. UTC / unset keeps the native fast path unchanged.
- New shared DST-safe `calendarPartsInTz`/`calendarPartsInTzOrUtc` in
@objectstack/core (Intl-based; falls back to UTC for unset/UTC/invalid).
- Thread the reference tz: EngineAggregateOptions → analytics executeAggregate
bridge / ObjectQLStrategy → applyInMemoryAggregation → bucketDateValue, plus
the draft-preview evaluator's bucketDate.
- formatDateBucket stays UTC by design (re-labels already-bucketed values).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Contributor
📓 Docs Drift CheckThis PR changes 4 package(s): 98 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
This was referenced Jul 18, 2026
os-zhuang
added a commit
that referenced
this pull request
Jul 18, 2026
Add acceptance tests for the timezone-aware today()/daysFromNow()/daysAgo() functions (compute-tz core of ADR-0053 Phase 2, decision D1). The implementation already shipped (#1998/#2001/#2006); these lock the issue's criteria and pin the DST-boundary + equality behavior: - AC1: today() at 2026-06-16T02:00Z in America/Los_Angeles == UTC-midnight of 2026-06-15. - AC3: reference tz unset vs 'UTC' is byte-for-byte the pre-Phase-2 behavior for all three functions. - AC2: calendar days are correct across both 2026 US DST transitions (spring-forward Mar 8, fall-back Nov 1); a Field.datetime instant compares equal to daysFromNow(n) across DST; a Field.date string matches via the hydration-safe idioms (ordering operators, date(), daysBetween()). - A characterization guard documents the known cel-js equality limitation: a bare `date-string == today()` silently returns false because cel-js's isEqual hard-codes `string == X` to false. This is timezone-independent and cross-cutting; the fix belongs in the data layer (hydrate date fields to Date where field types are known) and is tracked as a separate follow-up. Test-only; no changeset (no functional change). Claude-Session: https://claude.ai/code/session_01SuiM565BZ3TR1VD3prMguB Co-authored-by: Claude <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Part of ADR-0053 Phase 2 (Slice 5 of 6 — compute-tz). Closes #1982. Design: #1975 · Parent: #1928.
Makes analytics date bucketing timezone-aware: day/week/month/quarter/year buckets resolve on a reference timezone's calendar days, so a row near a tz day-boundary lands in the bucket a user in that zone would expect — identically on SQLite and Postgres.
Decision D2 — bucket in-memory, uniformly
Native driver date bucketing (
date_trunc) is UTC-only: SQLite has no tz database and MySQL needs tz tables loaded, so pushing tz-aware bucketing down splits boundaries per dialect. Soengine.aggregate({ timezone })forces the in-memory aggregation path when a non-UTC reference tz is set — the date-rangewherestill goes to the driver (only matching rows are fetched), but bucketing runs uniformly in JS. UTC / unset keeps the native driver fast path unchanged.Changes
@objectstack/core— new sharedcalendarPartsInTz/calendarPartsInTzOrUtcutil (the y/m/d an instant falls on in a zone). DST-safe viaIntl.DateTimeFormat().formatToParts()— never hand-rolled offset math. Falls back to the UTC calendar day for an unset /'UTC'/ invalid zone. Lives in core because both objectql and service-analytics need it and both already depend on core.@objectstack/objectql—bucketDateValue/applyInMemoryAggregationtake atimezone;engine.aggregateroutes non-UTC tz through the in-memory path and threads tz down.@objectstack/spec—EngineAggregateOptions.timezone+StrategyContext.executeAggregate({ timezone }).@objectstack/service-analytics—ObjectQLStrategyforwardsquery.timezone; the auto-bridge passes it toengine.aggregate; the draft-preview evaluator'sbucketDateis tz-aware.formatDateBucketstays UTC by design (it re-labels values already bucketed upstream; re-applying tz there would shift a correct day bucket).Acceptance criteria
'UTC'→ DB-side fast path, behavior unchanged.Tests
in-memory-aggregation.test.ts— tz day/month/quarter/week bucketing + grouping; UTC/invalid fallback.engine-aggregate-timezone.test.ts— routing: UTC/unset take native path, non-UTC forces in-memory with correct buckets.preview-evaluator.test.ts—bucketDatereference-zone resolution.Depends on
Slices 1 (timezone resolver #1978) and 3 (#1980) — both merged.
🤖 Generated with Claude Code