Skip to content

fix(ui): stop hand-slicing wire timestamps before the time helpers see them - #246

Merged
argszero merged 1 commit into
mainfrom
fix/render-wire-timestamps-through-the-time-helpers
Sep 14, 2026
Merged

argszero merged 1 commit into
mainfrom
fix/render-wire-timestamps-through-the-time-helpers

Conversation

@argszero

Copy link
Copy Markdown
Owner

Summary

src/dao.rs::utc_iso() serialises every timestamp as YYYY-MM-DDTHH:MM:SSZ, and ui/js/app.js has a family of helpers for turning that into something a user can read (fmtPrecise, timeCell, timeAgo, localMD). Three consumers sliced the raw string themselves instead — and each slice produced its own wrong face on screen:

consumer code (before) what the user saw
admin raise-requests, handled rows (r.created_at || "").slice(5, 16) 09-13T16:30 — the ISO T separator leaks into the UI, and the hour is UTC
settings → API keys, "created" String(k.created_at || "").slice(0, 10) the UTC day, so a UTC+8 user before 08:00 local is shown yesterday
transactions view row time: (t.time || "").replace("T", " ").slice(0, 16) seconds are destroyed before the renderer sees the value, while both renderers print HH:MM:SS — the seconds on screen are always a fabricated 00

None of the three is a trade-off. The helpers exist for exactly this, and the backend guarantees the format (app.js:3206 documents UTC for aggregate buckets only, which is a different thing: a month key is deliberately UTC because the SQL groups by UTC months — that is not this).

The third one is the interesting one: #229 (C2111) made the CSV export agree with the cell, by having both call fmtPrecise. Once both consumers read the same value, the truncation in the source became the only reading left — the column has said HH:MM:SS and the seconds have always been 00.

Also note the two "correct" neighbours that made this findable: the transactions time column and — in the same API-key row, same table — the "last used" column already went through timeCell.

Related Issue

None — no open issue covers this. Found by this task's own recon (the previous round's instrument confirmed the candidates before any code was written).

Changes

  • ui/js/app.js — the three consumers hand the raw value over: timeCell(r.created_at, true), fmtPrecise(k.created_at).slice(0, 10) (slicing a helper's output is fine), and time: t.time || ""
  • src/i18n_pack.rswire_timestamps_reach_the_renderer_unsliced pins the shape in CI: a wire timestamp field (created_at / last_used) may not be processed inline by .slice() / .replace(), and the transactions view row's time property must be a bare value. Both halves carry detector controls (below). The control flow itself has no JS runner in CI, so the runtime half lives in ui/README.md
  • ui/README.md — record the rule, the three faces and the smoke-test shape (including "the fixture must carry non-zero seconds" and "TZ is a precondition")
  • ui/index.html — cache-bust app.js
  • No config / schema / data-structure change, so no example file to sync

Tests

  • cargo test — 259 passed, 0 failed (258 before; +1 for the new gate)
  • cargo fmt --check — clean
  • cargo clippy --all-targets — only the pre-existing collapsible_match suggestion at src/protocol.rs:662 (unrelated; CI runs stable clippy and is green)
  • New unit test added

The gate has teeth (mutation legs, byte-exact restore verified by md5)

Restoring each pre-fix shape in the real ui/js/app.js and re-running the test:

  • both _at slices restored → ui/js/app.js 有 2 处原地加工线上时间戳:[("12px\">' + esc((r.created_at || \"\")", ".slice("), ("String(k.created_at || \"\")", ".slice(")]
  • the view-row slice restored → 交易视图行的 time 不再是裸值("(t.time || \"\").replace(\"T\"")
  • restored tree → green, md5 = c0f23c7c50397d2dbd0cbfa0ee7e58e8

The extractor is also proven on synthetic text inside the test itself, both ways: it must flag esc((r.created_at || "").slice(5, 16)) and created: String(k.created_at || "").slice(0, 10),, and it must not flag created: fmtPrecise(k.created_at).slice(0, 10), or the plain passthrough last: k.last_used || null,.

Runtime verification (jsdom, not in CI)

The instrument boots the real ui/index.html + the four real scripts, stubs only fetch (reproducing the backend's real wire format) and the Blob download, logs in through the real login form and drives the real nav. Fixture instant: 2026-09-13T16:30:45Z == 2026-09-14 00:30:45 Asia/Shanghai, and every expected string is derived independently from the fixture with node's own Date — never copied out of the helpers under test.

Variant Red assertions
origin/main (pre-fix, pinned 301ca4dc) A1 B1 B2 C1 C2 E1 (7/13)
F1 reverted only (raise cell) B1 B2
F2 reverted only (key "created") C1 C2
F3 reverted only (view row) A1 E1
half fix: hand-rolled but local (localMD09-14) B2
wrong fix: pre-format with fmtPrecise, re-parsed as UTC (8 h off) A1 E1
this branch — (13/13)

Where B1 = "no literal T in the handled-at cell", B2 = "the cell is the local instant, to the second", C1 = "the created cell is the local date", C2 = "and not the UTC date", A1 = "the transaction cell is the local instant including the real seconds", E1 = "the exported CSV 时间 field is the same string" (the second consumer of that same view-row value). Controls that were already green before and after, and stay green in every leg: D1 (the sibling "last used" cell renders through the helper), G1 (the relative title still renders), H1/H2 (both tables still render their row), A0 (booted, and the host timezone is Asia/Shanghai — the probe refuses to report a verdict otherwise, because under TZ=UTC local == UTC and this whole axis is invisible).

The three per-face red sets are pairwise disjoint and their union is exactly the pre-fix set. The two extra legs are the ones that pin the direction: the half fix shows that removing the T is not the whole rule, and the wrong fix shows that merely "calling a helper somewhere" is not either — the value has to arrive raw.

Checklist

  • Branch name follows the convention (fix/…)
  • Commit message uses Conventional Commits (fix(ui): …)
  • Single responsibility, minimal diff (4 files, +194/−4; 168 of those lines are the new gate and its comments, 6 are the actual fix)

…e them

`src/dao.rs::utc_iso()` serialises every timestamp as `YYYY-MM-DDTHH:MM:SSZ`, and
`ui/js/app.js` has a family of helpers for turning that into something a user can read
(`fmtPrecise`, `timeCell`, `timeAgo`, `localMD`). Three consumers sliced the raw string
themselves instead, and each slice produced its own wrong face on screen:

    admin raise-requests, handled rows:  (r.created_at || "").slice(5, 16)
      -> "09-13T16:30": the ISO `T` separator leaks into the UI, and the hour is UTC.
    settings -> API keys, "created":     String(k.created_at || "").slice(0, 10)
      -> the UTC day, so a UTC+8 user before 08:00 local is shown yesterday.
    transactions view row:               time: (t.time || "").replace("T"," ").slice(0,16)
      -> the seconds are destroyed *before* the renderer sees the value, while both
         renderers of that value (the column via `timeCell(t.time, true)` and the CSV
         export via `fmtPrecise`) print `HH:MM:SS`. The seconds on screen are a
         fabricated `00` -- always. #229 made the export agree with the cell; once both
         read the same value, the truncation in the source became the only reading left.

None of the three is a trade-off: the helpers already exist for exactly this, and the
backend guarantees the format (comments at `app.js:3206` document UTC for *aggregate*
buckets only, which is a different thing).

The fix hands each value over intact:

    timeCell(r.created_at, true)             -- local, to the second, relative in the title
    fmtPrecise(k.created_at).slice(0, 10)    -- slicing a helper's *output* is fine
    time: t.time || ""                       -- the row carries the wire value

`src/i18n_pack.rs::wire_timestamps_reach_the_renderer_unsliced` pins the shape in CI:
a wire timestamp field (`created_at` / `last_used`) may not be processed inline by
`.slice()` / `.replace()` (processing the *helper's* output is allowed, and a plain
passthrough like `last: k.last_used || null` is allowed), and the transactions view row's
`time` property must be a bare value. The extractor is proven on synthetic pre-fix and
post-fix text, and on the pre-fix tree itself: restoring either `_at` slice panics with
both offending expressions, restoring the view-row slice panics on the row shape.
The control flow has no JS runner in CI, so the runtime half is carried by
`ui/README.md` and its smoke-test notes.

Verified with a jsdom instrument over the real `ui/index.html` + four real scripts (only
`fetch` and the Blob download stubbed, with the backend's real wire format), seven legs
driven through the real login form and the real nav:

    live (this tree)  md5=c0f23c7c  passed=13/13  reds={}
    pre-fix tree      (301ca4d)    passed=7/13   reds={A1 B1 B2 C1 C2 E1}
    F1 reverted only                reds={B1 B2}
    F2 reverted only                reds={C1 C2}
    F3 reverted only                reds={A1 E1}
    half fix (localMD, loses time)  reds={B2}
    wrong fix (pre-format, re-parsed as UTC)  reds={A1 E1}

The three per-face red sets are pairwise disjoint and their union is exactly the pre-fix
set. The half fix shows the `T` alone is not the whole rule; the wrong fix shows
"call a helper somewhere" is not either -- the value has to arrive raw.

- ui/js/app.js    : the three consumers go through the helpers
- src/i18n_pack.rs: CI tripwire for the shape, with detector controls
- ui/README.md    : record the rule and the smoke-test shape
- ui/index.html   : cache-bust app.js

Co-authored-by: EMRG Evolution <emrg@argszero.dev>
@argszero
argszero merged commit 6e022e1 into main Sep 14, 2026
1 check passed
@argszero
argszero deleted the fix/render-wire-timestamps-through-the-time-helpers branch September 14, 2026 16:30
@argszero argszero mentioned this pull request Sep 15, 2026
10 tasks
argszero added a commit that referenced this pull request Sep 15, 2026
Ships the 18 PRs merged since v0.7.24 (#242-#259). Schema 14 -> 15 (two
covering indexes, applied at startup). No config change, so no deployment-side
config.toml edit is needed.

Two themes:

Perf on the NFS dev database
- #259: stop mapping the db (PRAGMA mmap_size 64MB -> 0) and stop a real write
  per request (dao::touch_api_key gains a 60s guard). Measured on the live dev
  db: mmap=64MB 1.7-3.1s per COUNT / 250 MiB read vs mmap=0 ~10.5ms / 80 KiB;
  mmap=0 alone still leaves ~1.2s behind any write, so the pair is required.
- #242: codify the two emergency indexes in a v15 migration and gate the
  conditional joins at the plan level.
- #243: read the sharing page's earn total from one batched aggregate.

Frontend: display must equal what it filters on, and one fact, one source
- #250 one writer for the transaction cache; #251 clear every session slot at
  the identity boundary and give the wallet view a loader; #253 one shared
  writer for the wallet/dashboard month-changes; #254 boot loads only the
  destination view; #255 a model row's identity is the model, not its index;
  #256 the marketplace source follows the session, not whether data arrived;
  #257 the sidebar advertises only digits that work; #258 the admin
  total-balance card sums the gift amount its caption names.

i18n
- #249 every backend error reaches the wordlist, and the comment stripper stops
  mangling UTF-8; #252 the backend stops inventing Chinese display labels in
  response data fields.

Forms and robustness
- #244 a non-auth boot failure no longer looks like being logged out; #245 a
  credential 401 is no longer read as a session expiry; #246 wire timestamps
  reach the renderer unsliced; #247 inline cards submit from every field; #248
  a market row's availability label comes from that row.

- Cargo.toml / Cargo.lock: 0.7.24 -> 0.7.25.
- CHANGELOG.md: v0.7.25 entry.
- ui/index.html: cache-bust left as-is; the UI PRs in this release already
  advanced it past the value deployed with v0.7.24 (app.js 20260915-13,
  i18n.js 20260915-3).

cargo test 288 passed; cargo fmt --check clean; clippy unchanged.
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