Files
k-mood/docs/adr/0015-one-canonical-instant-in-the-column.md
Gabriel Kaszewski bf148902ab spa hardening, offline logging, rate limit fixes
server:
- backup exporter, auth extractors, error shapes, CONTEXT (prior work)
- spa assets served outside the rate limit via route_layer
- requests_per_second went to per_second(), which takes an interval not a
  rate: 50 meant one request per 50s once burst was spent. now converted
  properly. 15/s, burst 60

spa fixes:
- account delete cleared snake_case token keys that were never written
- refresh interceptor could retry forever
- date ranges used local day boundaries stamped +00:00
- "all" period trend plotted one page; calendar days fabricated mood 3
- chart grid invisible: hsl(var(--border)) against rgba tokens
- blob url leak, orphaned media on failed save, devtools in prod bundle
- pt-safe/safe-area-pb classes never existed

spa features:
- offline outbox: entries queue to IndexedDB, replay with backoff, only
  server refusals count against an entry
- drafts persist, quick-log sheet, diary infinite scroll + filters
- route error boundary, stale-chunk recovery, no service worker in dev

a11y + perf:
- mood picker is a radiogroup, activity picker keyboard-operable,
  text alternatives for colour/emoji, locale week start
- dark glass over the bright photo: worst case 1.4:1 -> 4.9-9.6:1
- initial payload 1095->769kB raw, 306->230kB gzip; 38 unused components
  and 5 deps dropped; fonts 218->133kB

53 tests added (43 spa, 10 server)
2026-08-28 15:00:30 +02:00

2.9 KiB

A MoodEntry's instant is stored in one canonical form

logged_at is a DateTime<FixedOffset> written to a TEXT column with to_rfc3339(), so the offset it happened to arrive in went into the column. Every range query compares that column as a string:

WHERE user_id = ? AND logged_at >= ? AND logged_at <= ? ORDER BY logged_at DESC

Text ordering only agrees with instant ordering while every row carries the same offset. 2026-08-25T00:30:00+02:00 sorts after 2026-08-25T00:00:00+00:00 and precedes it in time. Mixed offsets are reachable: update_profile lets an account change timezone, and the importer stamps each row with the offset in force on that row's own date, so a year of imported history already spans two.

The in-memory fake could not catch this. It compares DateTime<FixedOffset> values, and chrono compares instants — the fake was right and the database was wrong, which is the one direction the fake rules do not warn about.

Considered Options

  • A second sortable column beside logged_at — keeps the offset a row arrived in. Two representations of one fact, both writable, and nothing forces them to agree.
  • An integer epoch column — sorts and compares correctly and reads as nothing at all in a sqlite3 session, on a table that is otherwise legible text.

Consequences

sortable_instant converts to UTC and formats to second precision, and is the single place any instant becomes column text. Writes, range predicates and the cascade's range delete all go through it, so a query cannot be written against one convention and stored data another.

The offset a client sent is not preserved. Per ADR 0001 nothing derives a day boundary from it — the User's timezone does that — so the offset was a rendering, not data. MoodEntry carries the instant it was logged, and one instant now has one spelling. The SPA reads loggedAt through new Date(...) in every place it touches it, so it renders in the viewer's own zone either way.

Second precision is the granularity the importer works in, and it makes two spellings of one moment compare equal.

Migration 015 rewrites existing rows with SQLite's own time parser, which reads the offset suffix and normalizes to UTC, and adds an index on (user_id, logged_at) now that the column's order means something.

Import dedup had to move with it

import_entries skipped a row when (logged_at.to_rfc3339(), mood) matched a stored entry — a comparison of renderings. Once stored rows read back as +00:00 and the importer derives +02:00 for the same instant, that key stops matching and a re-import duplicates the whole file.

AlreadyHere keys on the instant and the mood instead, so the same moment is the same entry whichever offset either side is spelled in. It is also updated as rows are accepted, which the old set was not, so a file that repeats a row internally no longer imports it twice.