Skip to content

fix(ui): export the transaction time column in the same 口径 as the cell - #229

Merged
argszero merged 1 commit into
mainfrom
fix/tx-csv-time-local
Sep 14, 2026
Merged

argszero merged 1 commit into
mainfrom
fix/tx-csv-time-local

Conversation

@argszero

Copy link
Copy Markdown
Owner

Summary

The transactions CSV export printed the time column in a different 口径 than the table cell it sits next to: exportTxCsv wrote the view row's raw t.time (the UTC string the API returns, which txsToView does not convert), while the cell renders the very same field through fmtPrecise() — i.e. local time. In a UTC+8 session one and the same row reads 23:04 in the table and 15:04 in the downloaded file (west of Greenwich the sign flips). A CSV whose header is localized (时间) and whose values disagree with the screen is a trap for anyone reconciling the file against the UI.

This is the same class of divergence the project already closed for the 点数 column in #196 (C2054/C2055), whose own comment states the rule: "the export covers the currently visible rows, so the column must use the same 口径 as the cell — otherwise the screen says -3.7 and the file says 3.7". Time is simply the column that was never brought along.

Provenance: the cell moved to precise local time in #139 (rant 2026-08-24T12:38:44); that commit updated the cell but not the other consumer of the same field (exportTxCsv, added earlier in #68).

Related Issue

N/A — no open issue. Background: #139 (cell 口径 changed to local), #196 (the same divergence fixed for the 点数 column).

Changes

  • ui/js/app.js — the CSV 时间 column now uses fmtPrecise(t.time), the same helper that renders the cell, instead of a second copy of "timestamp to string" (one line + a comment stating why the two must stay同源).
  • ui/README.md — the CSV convention section now records that every column shares the cell's 口径 (with time as the second instance after 点数).
  • ui/index.html — cache-bust for the changed app.js (?v=20260914-6).
  • No config / data-structure change, no new i18n key, no Rust change.

Tests

  • cargo test235 passed (unchanged; the change is JS-only)
  • cargo fmt --check — exit 0
  • cargo clippy --all-targets -- -D warnings — clean
  • node --check on all four ui/js/*.js
  • jsdom probe (real ui/index.html + api.js/data.js/i18n.js/app.js, only fetch stubbed): boots three transactions, reads the rendered cell text and the bytes of the exported Blob (captured from URL.createObjectURL), and asserts that each CSV column equals its cell — time (the axis), plus type / pts / tokens as controls. Pre-change tree: 5/6, red on the time column only. Fixed tree: 6/6, exit 0, under both TZ=Asia/Shanghai and TZ=America/New_York.
    • The probe asserts a non-UTC process TZ first: under TZ=UTC the two 口径 coincide and the value divergence is invisible (it exists only in the format then), so a UTC run would be meaningless.
    • A/B legs: pre-change {time} · mirror-image repair (make the cell print UTC so the two "agree") {cell-local} — disjoint from the pre-change set, i.e. the instrument rejects consistency-by-breaking-the-cell · half fix (append ":00" and stay UTC) {time} (same assertion, different input — not mutually exclusive with the pre-change leg, recorded as such) · control-teeth leg (CSV type field printed raw) {type-control} · fixed tree {}.

Checklist

  • Branch naming follows the convention (fix/…)
  • Commit message uses Conventional Commits (fix(ui): …)
  • Single responsibility, minimal change (3 files, +8/−3)

`exportTxCsv` wrote the view row's raw `t.time` — the UTC string the API returns,
which `txsToView` does not convert — while the table cell renders that same field
through `fmtPrecise()` (local time, #139 / rant 2026-08-24T12:38:44). In a UTC+8
session one and the same row therefore reads `23:04` in the table and `15:04` in
the exported file (west of Greenwich the sign flips).

This is the class of divergence C2054/C2055 already closed for the 点数 column:
"the export covers the currently visible rows, so the column must use the same
口径 as the cell — otherwise the screen says -3.7 and the file says 3.7".

Fix: print the time column with the very helper that renders the cell
(`fmtPrecise(t.time)`) instead of keeping a second copy of "time to string".

Verified with a jsdom probe (real ui/index.html + the four scripts, only `fetch`
stubbed) that boots three transactions, reads the rendered cell and the bytes of
the exported Blob: pre-change red on the time-column assertion only; fixed tree
6/6, in Asia/Shanghai and America/New_York. The probe also asserts the process TZ
is not UTC, since under TZ=UTC the two 口径 coincide and the defect is invisible.
@argszero
argszero merged commit d1faab6 into main Sep 14, 2026
1 check passed
@argszero
argszero deleted the fix/tx-csv-time-local branch September 14, 2026 00:02
argszero added a commit that referenced this pull request Sep 14, 2026
…e them (#246)

`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>
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