Skip to content

fix: emit all JSON timestamps as UTC RFC3339 via a Timestamp type - #6

Merged
baiirun merged 3 commits into
mainfrom
fix/json-utc-timestamps
Sep 23, 2026
Merged

baiirun merged 3 commits into
mainfrom
fix/json-utc-timestamps

Conversation

@baiirun

@baiirun baiirun commented Sep 23, 2026 •

Copy link
Copy Markdown
Owner

JSON output from prog couldn't be reliably processed with jq. Timestamps came out in two formats: logs as UTC (2026-08-26T02:38:39Z) and learnings with the local offset they were stored with (2026-01-09T15:12:45-06:00). jq's fromdateiso8601 rejects the second form. Items had no timestamps in JSON at all, so a staleness query over list --json wasn't possible.

The cause was formatting at each call site with .Format(time.RFC3339), which keeps whatever offset the value carries.

What changed

The formatting rule now lives on a type rather than at call sites. Timestamp is a defined type over time.Time whose MarshalJSON writes UTC with whole seconds — the only form jq accepts. JSON structs declare their time fields as Timestamp, so a new field copied from its neighbors gets the right format without anyone needing to know about a helper.

  • LogJSON and LearningJSON created_at are now Timestamp
  • ItemListJSON and ItemShowJSON gain created_at and updated_at
  • Timestamp also implements UnmarshalJSON, since the tests decode output back into these structs
  • docs/adr/0001-json-timestamp-format.md records the decision, the contract, rejected alternatives, and runnable verification, so the reasoning survives refactors of the type

A plain time.Time field is not a safe substitute: its built-in MarshalJSON emits fractional seconds (…45.123456789Z), which jq 1.6 also rejects. A new field declared as time.Time would compile and look fine in review — that's the remaining gap, deliberately left to review rather than an AST-based guard test.

Before / after

learning created_at:  2026-01-09T15:12:45-06:00  →  2026-01-09T21:12:45Z
item in list --json:  (no timestamps)            →  created_at, updated_at

This now works:

prog list --status in_progress --json |
  jq -r '.[] | select((now - (.updated_at|fromdateiso8601)) > 30*86400) | .id'

Validation: new test asserts a -06:00 time with nanoseconds marshals to exactly "2026-01-09T21:12:45Z" and round-trips to the same instant. Against the real database, all 1,354 timestamps in list --json parse with fromdateiso8601.

Scope

JSON output only; text output is unchanged. The database still stores Go's time.Time.String() format, so SQL date functions still return NULL — that needs the separate _time_format=sqlite DSN change plus a migration. ready --json stays minimal without timestamps, and status still has no JSON mode.

Most of the main.go diff is gofmt realigning struct fields around the longer type name.

🤖 Generated with Claude Code

JSON timestamps were inconsistent and partly unusable with jq. Logs came
out as UTC ("2026-08-26T02:38:39Z") but learnings kept the local offset
they were stored with ("2026-01-09T15:12:45-06:00"), and jq's
fromdateiso8601 rejects numeric offsets. Items had no timestamps in JSON
at all, so staleness queries over `list --json` were impossible.

The inconsistency came from formatting at each call site with
`.Format(time.RFC3339)`, which preserves whatever offset the value
carries. A shared helper would fix today's sites but relies on future
contributors knowing it exists, so the rule now lives on a type instead:
`Timestamp` (a defined type over time.Time) implements MarshalJSON to
write UTC with whole seconds, the only form jq accepts. JSON structs
declare their time fields as `Timestamp`, so a new field copied from its
neighbors gets the right format. A plain time.Time field would not work
as a substitute: its built-in MarshalJSON emits fractional seconds,
which jq also rejects.

- LogJSON and LearningJSON `created_at` are now Timestamp
- ItemListJSON and ItemShowJSON gain `created_at` and `updated_at`
- Timestamp also implements UnmarshalJSON, since tests decode output
  back into these structs

Scope: JSON output only. The database still stores Go's time.Time
String() format, so SQL date functions still return NULL; that needs
the separate DSN change and migration. `ready --json` stays minimal
without timestamps, and `status` still has no JSON mode.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
baiirun and others added 2 commits September 22, 2026 22:08
The reasoning for emitting JSON timestamps as UTC RFC 3339 with whole
seconds lived only in the Timestamp type's doc comment and this PR's
history. A comment goes away with the code it is attached to, and
history is only found by someone who already knows to look, so the
decision needs a record that survives refactors of the type.

Adds docs/adr/0001-json-timestamp-format.md: the normative contract
(UTC `Z`, no fractional seconds, no offsets, new fields use Timestamp),
why jq's fromdateiso8601 dictates that form, the rejected alternatives
(local offsets, plain time.Time, epoch integers, a helper, an AST lint
test), consequences including the remaining time.Time gap, and runnable
verification.

This is the first ADR in the repo; docs/adr/ is the proposed home for
future ones.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@baiirun
baiirun merged commit 4a7937d into main Sep 23, 2026
4 checks passed
@baiirun
baiirun deleted the fix/json-utc-timestamps branch September 23, 2026 03:13
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