guide · computers

ABS pubDate: Timezone UTC-Only, the Decision

ABS coerces pubDate to a UTC Date via z.coerce.date() and sorts by epoch ms. Local-clock pubDates are an anti-pattern; DST swaps a post's rank twice a year. The schema, the comparator, the verify.

July 14, 2026 · By Alastair Fraser

A retro robot standing in front of two clocks: left labeled LOCAL with a DST lever mid-rotation, right labeled UTC and perfectly still. The robot points at the UTC clock while the LOCAL clock's hands slip by one hour.

The decision in one sentence

ABS coerces every frontmatter pubDate to a UTC Date via z.coerce.date() and sorts by that Date’s underlying epoch milliseconds — there is no second timezone, no local-clock fallback, no per-author offset field. The schema line is the contract; the sort comparator reads it; the DST calendar is what happens when the contract is ignored.

Two files carry the rule:

  1. src/content.config.tspubDate: z.coerce.date(). Zod parses a string into a Date; that Date is the canonical value downstream.
  2. src/pages/index.astro and src/pages/rss.xml.js(a, b) => b.data.pubDate - a.data.pubDate. Subtracting Date objects coerces them via valueOf() (epoch ms). The order is wall-clock-independent: a date in Tokyo at 09:00 JST and the same instant at 00:00 UTC compare as identical.

That’s the entire decision. The rest is why anything else is wrong.

What z.coerce.date() actually does

z.coerce.date() runs new Date(input) on whatever you pass it. The output is a JavaScript Date, stored as UTC epoch milliseconds (integer ms since 1970-01-01T00:00:00Z). The wall-clock portion — year, month, day, hour — is derived from that integer using the system local timezone for display.

Two consequences:

  1. The sort is in UTC. b.data.pubDate - a.data.pubDate subtracts epoch ms. No timezone offset enters the comparison. A Date for 2026-03-08T13:00:00Z sorts before 2026-03-08T15:00:00Z the same way everywhere — VPS, Mac, the feed reader’s render server.
  2. Display is whatever the consumer’s locale says. Browsers format via toLocaleString(); the RSS feed via RFC-822. ABS never writes a pubDate string into HTML by hand; it always hands the Date to a formatter.

That separation — UTC internally, locale externally — is the whole game.

The DST trap, walked through

US daylight saving time changes twice a year:

  • 2026-03-08 — spring forward at 02:00 local → 03:00 local. Local UTC offset goes -05:00 (EST) → -04:00 (EDT).
  • 2026-11-01 — fall back at 02:00 local → 01:00 local. Local UTC offset goes -04:00 (EDT) → -05:00 (EST).

Suppose an editor publishes a post on the evening of 2026-03-07 with the local-time string "2026-03-07 23:30". The string alone carries no timezone. z.coerce.date() parses it with new Date("2026-03-07 23:30") — and JavaScript’s Date.parse rules treat an offset-less datetime as local time of the host running the parser.

So:

  • If the VPS parses it, the post’s epoch ms is what the VPS’s clock says “2026-03-07 23:30” means.
  • If the editor’s Mac parses it (e.g. via a local preview), it’s the Mac’s clock.

Worse: the VPS is on UTC year-round, so its “2026-03-07 23:30” parses to 2026-03-07T23:30:00Z. The editor’s Mac in America/New_York parses the same string to 2026-03-08T04:30:00Z (EST is -05:00). Same markdown file, two UTC timestamps five hours apart, depending on who reads it first.

Why ordering flips on a DST day

The failure shape isn’t subtle. Take three posts published around a DST boundary:

postfrontmatter stringeditor-laptop intent (PDT)UTC epochrank
A2026-03-07 22:00local evening2026-03-08T05:00:00Z1st
B2026-03-08 06:30local morning2026-03-08T13:30:00Z2nd
C2026-03-08 23:00local night2026-03-09T06:00:00Z3rd

That order looks right — A is the latest in real-world time, so it sorts first. Add a missing timezone and the strings become lexicographic nightmares: 22:00 < 06:30 < 23:00 only because the date precedes the time in the literal.

The fall-back weekend makes this provably irrecoverable. On 2026-11-01 the local hour “01:30” occurs twice. The same string "2026-11-01 01:30" parses to two different Date objects — the first occurrence (EDT, -04:00, → 2026-11-01T05:30:00Z) or the second (EST, -05:00, → 2026-11-01T06:30:00Z). The exact same string yields two valid Dates one hour apart, both UTC, both real. No string-formatting rule disambiguates. The only escape is to refuse ambiguous strings in the first place.

Why we don’t add a timezone field

Some CMSes store a wall-clock string plus timezone: "America/Los_Angeles" and resolve at render. ABS doesn’t, for three reasons:

  1. The sort is already UTC. An offset adds no information to the comparator — 2026-07-14T11:30:00-07:00 and 2026-07-14T18:30:00Z produce the same epoch ms. The offset field is a rendering hint, not a sort key. ABS doesn’t render local times anywhere.
  2. Two-coupled-fields-wrong > one-unambiguous-field-right. A timezone field invites drift: an editor changes the time, forgets the offset, ships a Date with the wrong wall-clock label.
  3. Astro doesn’t need it. z.coerce.date() already handles ISO-8601 strings with offsets. A sibling timezone field would re-implement what the standard library already does correctly.

The same logic kills “store the wall-clock string, format at render.” Format-at-render is locale-dependent and DST-dependent; store-at-write in UTC is invariant.

Edge cases the schema already handles

Three shapes show up in frontmatter; the schema decides each:

  1. pubDate: 2026-07-14 (date-only). z.coerce.date() parses to 2026-07-14T00:00:00Z. Build accepts; editorial preference is the full T00:00:00Z so the file is grep-uniform with the corpus.
  2. pubDate: 2026-07-14T11:30:00-07:00 (offset). Parses to 2026-07-14T18:30:00Z. Correct. Use when the editor wants to capture local-clock intent (“11:30 my time”) with an unambiguous UTC moment.
  3. pubDate: 1718607600000 (epoch ms). Parses to that instant. Rejected by convention — unreadable in code review. Schema accepts; author shouldn’t write it.

The schema’s tolerance is wide on purpose: a build that fails on 2026-07-14 (vs. 2026-07-14T00:00:00Z) blocks valid posts. Better to accept the date and flag it in review.

The verify

Three checks confirm the contract holds on the live site:

# 1. RSS feed pubDates must read descending.
curl -sS https://agenticbotsitter.com/rss.xml \
  | grep -oE '<pubDate>[^<]+' | head -20
# Expected: descending RFC-822 timestamps.

# 2. Repo frontmatter must all parse to valid Dates.
find /srv/abs-site/src/content/posts -name '*.md' -print0 \
  | xargs -0 grep -hoE '^pubDate: .*$' | head -20
# Expected: strings with 'T'+'Z' or an offset. Date-only is allowed but is the smell.

# 3. DST-trap test: change -04:00 → -05:00 on the same wall-clock time.
#    Different epoch ms confirms the offset survives parsing.
node -e 'console.log(new Date("2026-11-01T01:30:00-04:00").valueOf() -
               new Date("2026-11-01T01:30:00-05:00").valueOf())'
# Expected: 3600000 (one hour).

The “publish a test post at local 11:30pm” experiment in the brief: set pubDate: 2026-07-14T23:30:00-07:00 (West-Coast late night) and confirm it ranks after any post with pubDate: 2026-07-15T06:00:00Z (UTC morning). The first is “really” 06:30Z; the second is 06:00Z. The second should sort first by 30 minutes. If it sorts the other way, something in the chain is string-comparing, not Date-subtracting.

What we don’t do

  • Store pubDate as a string. Strings are locale-dependent and DST-blind. Always coerce.
  • Add a timezone field. One UTC field is unambiguous; two coupled fields invite drift.
  • Compare pubDate strings lexicographically. Use Date arithmetic; let JS coerce to epoch ms.
  • Accept “yesterday”, “now”, or relative strings. ISO-8601 only.
  • Render pubDate in any user’s local timezone on the site. The feed and post pages emit absolute timestamps; readers translate to their locale. We don’t ship a “your time” widget.
  • Audit authors for offset errors at write time. The schema accepts the strings; the editorial convention is the gate. A build that fails on offset errors would block valid posts.

Sources

Last verified: 2026-07-14 against src/content.config.ts (pubDate: z.coerce.date()) and the live /rss.xml (sorted by b.data.pubDate - a.data.pubDate, no string-comparison visible in the rendered feed).

Sources

#pubdate#timezone#utc#dst#sort#convention

Submit a take

Have a different read on this? Drop a comment below — your email isn't published, and I read every one. Nothing leaves the site until I approve it.

Your email address will not be published. Required fields are marked.