Repository navigation
fix(core,service-analytics): a date-bucket key spells its year with four digits, and the reader reads the week key the writer writes (#20760) - #20865
Conversation
…its (#20760) Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude <noreply@anthropic.com>
…alytics (#20760) Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude <noreply@anthropic.com>
…r spelling (#20760) Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): ⛔ 1 release-owned page(s) name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 31 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 797ddf6877b1f295674ad786897d24eafe4cfccb && git checkout 797ddf6877b1f295674ad786897d24eafe4cfccb
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 660a9b247e824f7747d63f3b778dccc9cb4751d6 c03977051a1869f6dd3e8c66e9f4dc49abbab0df && git checkout -B drift-repro 660a9b247e824f7747d63f3b778dccc9cb4751d6 && git merge --no-ff c03977051a1869f6dd3e8c66e9f4dc49abbab0df
node scripts/docs-audit/affected-docs.mjs --json 660a9b247e824f7747d63f3b778dccc9cb4751d6
|
Contract reviewServed-tier: PR #20865 for card #20760, branch Check-runs on the head, as read by this act (shortly before 2026-09-30T13:40Z): 39 check-runs; 28 completed ① Derived judgmentsTriage's direction (5903751640), judged against the diff:
Published spellings that move (the dev's six), each judged:
A narrowing a consumer can see: none. No input the reader accepted before is refused now; the unpadded spelling never matched its Pins against triage's list: for ② Semver levelChangeset The declaration line: the PR body's second line declares Review faces: the changeset text is accurate against the diff (SQLite pinned; PostgreSQL named as measured, which is the dev's uncommitted live harness, stated as such in the PR body). The PR body's "Gates" bullet (67 of 67 exit 0; ③ Boundary flags
No governed surface is touched (file list: core and service-analytics sources, test files in core, objectql, driver-memory, driver-sql and service-analytics, one changeset). Six check-runs were still running when read; a red among them is that gate's verdict, not this record's. Implemented-by: VERDICT: PASS Generated by Claude Code |
…writer for a year below 1000, and the draft preview keys a week by its ISO label (objectstack-ai#20867) (objectstack-ai#20971) Fixes objectstack-ai#20867 Clause-②: no ## What changes Triage direction 5912878801, as ruled: the two `service-analytics` readers of a date-bucket key match `@objectstack/core`'s one writer, `bucketDateKey`. Nothing in `packages/core`, `packages/spec` or `packages/objectql` moves. - **`dimension-labels.ts` `formatDateBucket`** (the dimension labels `queryDataset` resolves after grouping). A string that is a key the writer writes at the stated granularity is returned as written. What counts as one is decided by core's one reader of those keys, `bucketKeyToCalendarRange`, not by a second pattern here. A raw value (epoch ms, ISO text, `Date`) is relabelled by `bucketDateKey` itself, in UTC, so no label here spells a year of its own. A `week` or unstated granularity keeps labelling a raw value as its own `day` key, as before. The numeric-year branch (a driver answering `2026` as a number) is unchanged. - **`preview-evaluator.ts` `bucketDate`** (the draft preview, `queryDataset` with `previewDrafts`). It is now `bucketDateKey(value, granularity, timezone)`, typed with core's `BucketGranularity`. No second padding. Its caller passes the cube dimension's single granularity as typed, where it used to pass `String(...)` of it. `bucketDateKey`, `bucketKeyToCalendarRange`, `isBucketGranularity` and `BucketGranularity` are exports of `@objectstack/core` (`src/utils/datetime.ts`, re-exported by `src/index.ts`), a runtime dependency of this package that `dataset-executor.ts` and `analytics-service.ts` already import them from. ## Before and after Base `f6ccca4a44`, head `25ba591849`. Function level, `TZ=UTC`, read with a scratch `tsx` script over the package's own sources and core's built `dist`. The reference column is `bucketDateKey`. | input | driver | reference | before | after | |:--|:--|:--|:--|:--| | key `0050` | `formatDateBucket(key, 'year')` | `0050` | `1970` | `0050` | | key `0050-06` | `formatDateBucket(key, 'month')` | `0050-06` | `50-06` | `0050-06` | | key `0050-06-15` | `formatDateBucket(key, 'day')` | `0050-06-15` | `50-06-15` | `0050-06-15` | | key `0999` | `formatDateBucket(key, 'year')` | `0999` | `1970` | `0999` | | keys `0050-Q2`, `0050-W24`, `0049-W52` | `formatDateBucket` | as written | as written | as written | | raw `0050-06-15T10:00:00.000Z` | `formatDateBucket(raw, year / quarter / month / day)` | `0050` · `0050-Q2` · `0050-06` · `0050-06-15` | `50` · `50-Q2` · `50-06` · `50-06-15` | `0050` · `0050-Q2` · `0050-06` · `0050-06-15` | | `2026` keys and raw values (control) | `formatDateBucket` | | unchanged | unchanged | | `0050-06-15T10:00:00.000Z` | `bucketDate` year / quarter / month / week / day | `0050` · `0050-Q2` · `0050-06` · `0050-W24` · `0050-06-15` | `50` · `50-Q2` · `50-06` · `0050-06-13` · `50-06-15` | as the reference | | `0050-01-01T10:00:00.000Z` | `bucketDate` week | `0049-W52` | `0049-12-27` | `0049-W52` | | `0999-06-15T10:00:00.000Z` | `bucketDate` year / month | `0999` · `0999-06` | `999` · `999-06` | as the reference | | `2026-06-15T10:00:00.000Z` (control) | `bucketDate` week | `2026-W25` | `2026-06-15` | `2026-W25` | | `2026-06-15T10:00:00.000Z` (control) | `bucketDate` year / quarter / month / day | `2026` · `2026-Q2` · `2026-06` · `2026-06-15` | the same | the same | | epoch ms of `2026-06-15T10Z` / `0050-06-15T10Z` | `bucketDate`, every granularity | the key | `null` (the empty bucket) | the key | | `Date` of `0050-06-15T10Z` | `bucketDate` year / day | `0050` · `0050-06-15` | `1950` · `1950-06-15` | as the reference | And at the `queryDataset` level, over the same three rows (`0050-06-15`, `0050-06-15T10:00:00.000Z`, `2026-06-15`). The published leg is the ObjectQL strategy over the engine's in-memory grouping (`@objectstack/objectql` `applyInMemoryAggregation`) followed by the dimension labels. The preview leg is `previewDrafts` over the same rows as the seed draft. | granularity | published, before | preview, before | both, after | |:--|:--|:--|:--| | year | `1970`:2 · `2026`:1 | `50`:2 · `2026`:1 | `0050`:2 · `2026`:1 | | quarter | `0050-Q2`:2 · `2026-Q2`:1 | `50-Q2`:2 · `2026-Q2`:1 | `0050-Q2`:2 · `2026-Q2`:1 | | month | `50-06`:2 · `2026-06`:1 | `50-06`:2 · `2026-06`:1 | `0050-06`:2 · `2026-06`:1 | | week | `0050-W24`:2 · `2026-W25`:1 | `0050-06-13`:2 · `2026-06-15`:1 | `0050-W24`:2 · `2026-W25`:1 | | day | `50-06-15`:2 · `2026-06-15`:1 | `50-06-15`:2 · `2026-06-15`:1 | `0050-06-15`:2 · `2026-06-15`:1 | ## The preview's week key (triage's third bullet): measured, and adopted - **Measured at base:** the preview wrote the Monday's `YYYY-MM-DD` (`2026-06-15`) where the runtime writes the ISO week label (`2026-W25`), for every year, the 2026 control included. - **Adopted:** `bucketDate` delegating to `bucketDateKey` writes the ISO week label. The difference is not kept. - **Why it matters beyond the label:** the preview runs the same `DatasetExecutor`, whose `compareTo` alignment reads a key through `bucketKeyToCalendarRange`, and that reader answers `null` for a Monday date at `week`. Measured with one row on `2026-06-15` and one on `2025-06-16`, a `previousYear` comparison over the window `2026-06-15`..`2026-06-21`: - published, before and after: one row, `2026-W25`, current 1, compare 1; - preview, before: two rows, `2026-06-15` (1 / 0) and `2025-06-16` (0 / 1), the comparison appended as a foreign row; - preview, after: one row, `2026-W25` (1 / 1), as published. - The two existing preview week pins (`week-key-year-below-100.test.ts`, [objectstack-ai#20599]) pinned the Monday date. They now read the ISO label for the same instants, and the Monday each week starts on stays in a comment. ## Clause-② measured `no`, with no arm. The package's public entry (`src/index.ts`, `exports` `.` only) exports neither function, so no export is added, removed or retyped. What changes is published output: the labels `resolveDimensionLabels` / `queryDataset` give a key or a raw value in 0001..0999, and the preview's keys. Each now equals what the published path answers or the writer writes. No input that was answered before is refused now. The public door parses `timeDimensions[].granularity` and `dateGranularity` against `DatasetSelectionSchema` (read at schema level: `hour` refused, `week` accepted), so `bucketDate`'s narrowed parameter type admits every value that door lets through. The line parses as `no` under `scripts/pm/clause2-line.mjs` `readClause2Line`. ## Pins `src/__tests__/bucket-key-readers-four-digit-year.test.ts` (new), every expected key spelled literally, 0050 / 0050-01-01 / 0999 with a 2026 control: - `0050`, `0050-06` and `0050-06-15` label as written, and so does every key the writer gives each instant at each granularity; - a raw value in 0050 is relabelled with the year in four digits; - `bucketDate` equals the writer for ISO text, epoch ms and a `Date`, and in `Asia/Shanghai` (`0049-W52` in UTC is `0050-W01` there); - a draft preview keys `0050-06-15` as the published path does, at every granularity, with the 2026 control. Committed red first (`748c4bd5b9`): 38 failed / 28 passed of 66 across the new file and the two updated preview week pins. The fix is `25ba591849`. ## Ablation At `25ba591849`, one locked script. The subjects are imported from `src/` by relative path, so no build is involved. Every mutation went through `scripts/ablation-replace.mjs` (anchor 1 to 0, blob changed) and was restored by it: the blob equals HEAD's (`dimension-labels.ts` `50578e3e0edc`, `preview-evaluator.ts` `5eafd898fefc`) and `git diff HEAD` is empty. Pins run: the new file and `week-key-year-below-100.test.ts`, 66 tests. | leg | mutation | red | |:--|:--|:--| | R | both files written back to base `f6ccca4a44` (blobs `682193a14bc0` / `522ef30f91fb` on disk) | 38 / 66, as at the red commit | | A1 | `formatDateBucket`'s key recognition deleted | 5: the `0050` / `0999` year key reads `1970` (the key pin, three every-granularity pins, and the published leg at `year`); the 2026-only cases stay green | | A2 | its relabel spelled locally, year unpadded | 2: the two raw-value pins; keys still pass through the recogniser | | A3a | `bucketDate` strips the year's leading zeros | 25; the 2026 `bucketDate` control green | | A3b | `bucketDate` keys a week as a day | 26, the six 2026 week cases included | After all legs: both blobs equal HEAD's, `git status --porcelain` empty, 66 / 66 green. ## Verification (head `25ba591849`) - **Suites** (under the shared lock, `--maxWorkers=2`): `@objectstack/service-analytics` `vitest run`, 147 files / 3398 tests passed. `typecheck` (`tsc --noEmit`) exits 0, and `tsc --listFiles` lists both pin files, so the typecheck covers them. - **Build:** the tested dependency closure, `turbo run build --filter='@objectstack/service-analytics^...' --concurrency=1` (14 / 14), then the package itself (exit 0; `check-dts-emitted` 2 / 2). `dist/index.d.ts` names neither function. - **Gates:** `node scripts/pm/dispatch-gates.mjs --commands` re-derived at this head: 61 commands, identical to the dispatch-time list. All 61 ran in one locked sequential script, with the four roster families the derivation marks (`check-changeset-fixed`, `check:authz-resolver`, `check:error-code-casing`, `check:filter-alias-parity`). 63 exited 0 and 2 exited 3. `--ran`, with each exit code recorded: 61 derived, 59 run, 2 NOT MEASURED, 0 unrun. - NOT MEASURED: `check:dual-build-cjs-loads` and `check:type-check-debt`, both PREREQUISITE NOT MET (exit 3). They read every workspace package's built output, and this run built only the tested closure, as the dispatch's resource rule requires. CI's lint job builds the whole closure before them. - `check-changeset-no-major` exited 0 ("no `major` bump"). Its Clause-② level axis reads the PR payload, so it had no input locally. - **Lint (narrowed, proven):** `eslint --no-inline-config --format json` over the 4 changed `.ts` files. The JSON reports 4 files, 0 errors, 0 warnings, and none ignored. `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`, stated in the config itself), so this diff cannot move a verdict on an untouched file. The full `pnpm lint` is CI's. ## Acceptance notes - **Not measured:** an HTTP request through `POST /analytics/dataset/query`. The readings above are at function level and at `queryDataset` level, over the engine's in-memory grouping. No SQL driver ran; SQLite and PostgreSQL already pad their keys (measured in PR objectstack-ai#20865). The door's refusal of `hour` is read from `DatasetSelectionSchema.safeParse`, not from the route. - **Not measured locally:** the two gates above, and CI's Test Core, Dogfood, Build Core and Temporal Conformance jobs and the workspace type-check lanes. - **Unchanged, noted:** `formatDateBucket` still reads a NUMERIC year below 1000 (`50` under `year`) as epoch milliseconds (`1970`). No named producer answers a numeric year key: SQLite `strftime`, MySQL `date_format`, PostgreSQL `to_char` and MongoDB `$dateToString` all answer text, and the text key `0050` is now recognised. - **Unchanged, noted:** a raw instant under `week` still labels as its own day key (`YYYY-MM-DD`). A week bucket's key is the ISO label on every producer, and that key is recognised and kept. - **Changed at function level only:** `bucketDate` given a granularity outside the five now answers `bucketDateKey`'s echo of the raw value, where it answered the day key. Its type admits only the five, and `DatasetSelectionSchema` refuses `hour` (schema-level reading), so no public path reaches it. It is also the answer the engine's in-memory grouping gives, since `bucketDateValue` delegates to the same writer. - **Same family, other surface:** objectstack-ai#20602 (`/export`) remains its own card and is not addressed here. --- _Generated by [Claude Code](https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #20760
Clause-②: no
What changes
Triage direction 5903751640, as ruled: one writer, one reader, no local padding. Nothing in any driver's SQL moves.
@objectstack/corebucketDateKeyand its ISO week label. A privatebucketKeyYear(year)spells the year of a bucket key with four digits, and every arm goes through it:year,quarter,month,day(through a privatebucketDayKey) and the week label (isoWeekLabelFromCalendarDay). A year from 1000 to 9999 is spelled as before. No export is added or removed.bucketKeyToCalendarRange. Its patterns already required a four-digit year. The defect was the week arm: it checks a key against the label the writer gives the reconstructed Monday, and that label was unpadded, so0050-W01answerednull. Its bounds are now spelled by the writer's own day key (fmtisbucketDayKey), so the two share one spelling. It does not accept the unpadded spelling (50-06,49-W52): nothing writes it after this change.service-analyticsbucketKeyAtOrdinal(the declared cross-lane line). It spelled every granularity itself and carried a private copy of the ISO week rule (isoWeekKeyOfUtcMs, deleted). It now computes only the UTC instant the ordinal's bucket starts at, and core'sbucketDateKeyspells the key (a localbucketKeyAt, whichcalendarDayAtnow delegates to). No second padding.Before and after (function level)
Core at base
c90f9fb6e2, read from a temporary copy of the basedatetime.ts, against this branch:0050-06-15T10:00Z50·50-Q2·50-06·50-06-15·50-W240050·0050-Q2·0050-06·0050-06-15·0050-W240050-01-01T10:00Z50·50-Q1·50-01·50-01-01·49-W520050·0050-Q1·0050-01·0050-01-01·0049-W520999-06-15T10:00Z999·999-Q2·999-06·999-06-15·999-W240999·0999-Q2·0999-06·0999-06-15·0999-W242026-06-15T10:00Z(control)2026·2026-Q2·2026-06·2026-06-15·2026-W25bucketKeyToCalendarRange(key, 'week'):0050-W01,0049-W52and0999-W24answerednullat base. They now answer0050-01-03..0050-01-10,0049-12-27..0050-01-03and0999-06-10..0999-06-17.2026-W01answers2025-12-29..2026-01-05on both.The zone-2 hypotheses
H1, the callers: held, and the census found two more writers. Each named caller follows the helper with no edit:
objectqlin-memory-aggregation.tsbucketDateValueis a thin delegate (pinned);objectqlhaving-filter.tsnames the helper in a comment only; itsdaybucket'sdateclass now holds a realYYYY-MM-DD;driver-memorymemory-analytics.tsaggregateWithTimeBucketsdelegates (pinned);service-analyticsanalytics-service.ts, the drill ranges, callsbucketKeyToCalendarRange(pinned throughqueryDataset);service-analyticsdataset-executor.ts:calendarDayAtdelegates,alignedCompareBucketKeyreads through the reader (pinned), andbucketKeyAtOrdinalis H2;corecompensated-sum.tsnames the helper in a comment only.Two
service-analyticswriters call no helper and spell the year unpadded:preview-evaluator.tsbucketDateanddimension-labels.tsformatDateBucket. Both are outside the declared surface (Acceptance notes).H2: held.
bucketKeyAtOrdinalbuilt its own keys at every granularity. It now calls the helper (above).H3: SQLite and PostgreSQL pad, measured; MySQL is NOT MEASURED.
bucketDateKeyatyear,quarter,monthanddaythrough adatetimeand adatecolumn. SQLite bucketsweekin memory, not in SQL.SqlDriverraninitObjects,createandaggregateover adatetimeand adatecolumn holding 0050-06-15, 0050-01-01, 0999-06-15 and 2026-06-15. The keys equalbucketDateKeyat all five granularities, 0 mismatches in 10 cells,0049-W52fromIYYY"-W"IWincluded. This was a scratch harness, not committed (Acceptance notes).%Yand%xas four digits.H4: read at function level, not reached, and no refusal added.
0000, as SQLite'sstrftime('%Y')does.-1, never the padded fragment00-1. SQLite answers-001.10000. SQLite answers NULL.nullfor both the −1 and the 10000 key.dateand adatetimevalue. The shape is pinned in the core file below.H5: held. Early January 0050 keys
0049-W52, and0049-W52spans0049-12-27..0050-01-03. This is pinned in core, objectql, service-analytics and the PostgreSQL reading.Pins
One new file beside each face. Every expected key is spelled literally, and each file covers 0050, 0999 and the 2026 control:
datetime-bucket-key-four-digit-year.test.ts:Dateand as epoch ms too;0050-W01included);null;in-memory-aggregation-four-digit-year.test.ts: the engine's in-memorygroupByat every granularity.memory-analytics-four-digit-year.test.ts: the memory cube face at every granularity.sql-driver-bucket-key-four-digit-year.test.ts:SqlDriveron SQLite against core'sbucketDateKey, for 0050-06-15.bucket-key-four-digit-year.test.ts:bucketKeyAtOrdinalagainst the grouped key at every granularity;alignedCompareBucketKeyrestating0049-06/0049-W24as0050-06/0050-W24;queryDatasetdrill-down from0050-W01finding0050-01-03..0050-01-10.Two existing files had comments stating the unpadded spelling as current (
datetime-year-below-100.test.tsin core,week-key-year-below-100.test.tsin service-analytics). Their comments are corrected, and their lenient readers now require the four-digit year.Ablation: red/green on the padding
At
3716880ded. The padding line is byte-identical at the PR head.scripts/ablation-replace.mjsturnedreturn year >= 0 ? String(year).padStart(4, '0') : String(year);intoreturn String(year);(anchor 1 → 0, blobfe67aac16ed5→5a14574b2faf).dist/. Core was rebuilt, andablation-dist-preflight --absentpassed: the marker was absent from all 14 built files. objectql and service-analytics read core fromdist/.datetime*: 101 failed / 151 passed of 252;fe67aac16ed5andgit diff HEADis empty. Core was rebuilt, and preflight in default mode found the marker in 2 built files with the tree clean.Verification (head
c03977051a)mainat05a7547c9f(#20843) was merged in. It edits one comment indatetime.ts, and this branch's two range sentences now state itsdatetimefloor.vitest run, at076ba0c599, after the merge and a rebuild of the touched closure):local61 files / 1793 passed,repo3 / 48;local345 / 6779;c03977051a, which changes only the driver-sql pin: every face's pins plus driver-sql'sdate-bucketanddate-bucket-storagesuites pass, 36 / 252 / 6 / 5 / 57.typecheckexits 0 for all five packages.tsc --listFilesshows each new test file in a compiled program.node scripts/pm/dispatch-gates.mjs --commandsderived 67 families atc03977051a, and all 67 ran there. 67 of 67 exited 0.--ranreconciles: 67 derived, 67 run, 0 NOT MEASURED, 0 unrun.check:dual-build-cjs-loadsandcheck:type-check-debtfirst exited 3 (PREREQUISITE NOT MET: only the touched closure was built). Both exited 0 afterpnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*'(71/71 tasks). The seat corrected this bullet fromos-dev-report5912276902; the head is unchanged.as anyon the driver-sql pin's aggregate options (check:query-options-erasure, test surface 236 → 237). It was typed inc03977051a, and the ratchet holds at 236.eslint --no-inline-config --format jsonran over the 9 changed.tsfiles: 9 files, 0 errors, 0 warnings, none reported as ignored. The config covers all 9. It enables no type-aware linting (noparserOptions.projectanywhere, stated ineslint.config.mjsitself), so this diff cannot move a verdict on an untouched file. The fullpnpm lintis CI's.Clause-② measured
Six published outputs change spelling for a year below 1000:
bucketDateKey;groupBykeys;compareTomerge keys;bucketKeyToCalendarRange, which now answers for a padded week key;formatDateBucketgives an in-memory month or day key: it was1950-06, a wrong century, and is now50-06, what it gives the SQL key.Each is now what the SQL path answered already: SQLite pinned, PostgreSQL measured. The reader drops no key it read before, because the unpadded spelling never matched its four-digit patterns. So
Clause-②: nostands.Acceptance notes
dimension-labels.tsformatDateBucket(service-analytics). It relabels a date dimension's rows when labels resolve. For a padded key below 1000 it answersyear0050→1970, reading a pure-digit key as epoch seconds because its year check admits only 1000..9999. It answersmonth0050-06→50-06andday0050-06-15→50-06-15. Function level. It is the same unpadded-year family as [finding]/exportwrites adate/datetimecell with a year below 1000 unpadded (0500-01-01→500-01-01), so the export does not re-import #20602, it sits outside the declared surface, and it is reported for the family card.preview-evaluator.tsbucketDate(service-analytics, the draft preview). It spells the year unpadded atyear/quarter/month/day. Itsweekkey is the Monday'sYYYY-MM-DD(pinned so), not the ISO label. Outside the surface.driver-mongodbmongodb-aggregation.ts. A header comment says a year before 1000 "labels '0999' here and '999' in memory". This PR makes it false. The file is outside the declared surface, so it is left for its next editor.@objectstack/verifycheckDateBucketParity. It probes 2024 and 2025 instants only, so the parity device cannot see this family on any driver. No gate change here.live-dialect-matrix.testkit.ts). It is not extended to this pin. A MySQLdatetimebelow 1000 is driver-sql on MySQL reads a year 0..99 back a century late — REST create storesplaced_on: "0009-03-04"correctly, and…/queryreturns"1909-03-04"; adatetime0009-03-04T10:00Zreturns2004-09-03T10:00Z#20280's ground, and its reading is not measured here.Generated by Claude Code