Skip to content

Commit 52c6b5e

Browse files
easeevclaude
andcommitted
viewer: level grid cards and link the footer to the repo; query: run project SQL in UTC
Panels in a grid row stretch to equal height and KPI values sit at the bottom of the card, so a KPI with a description no longer breaks the row. The footer brand links to the chainplot repository. The query worker sets TimeZone='UTC' before any project SQL. rindexer exports block_timestamp as TIMESTAMP WITH TIME ZONE and DuckDB renders, casts and buckets that type in the session zone, so a fork built outside UTC published different hourly and daily figures than the producer. The setting comes from DuckDB's ICU extension, which the bundled binaries link statically; a build without it fails before project SQL runs. The contract is documented in docs/capabilities.md and covered by a test against a parquet TIMESTAMPTZ column. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 126a207 commit 52c6b5e

5 files changed

Lines changed: 116 additions & 4 deletions

File tree

‎docs/capabilities.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,15 @@ INSERT, COPY, ATTACH, PRAGMA and friends outright, so the rule is the parser's,
4444
not a keyword denylist. Filesystem and network access are disabled before any
4545
project SQL executes, models included: a forked recipe is a stranger's code.
4646

47+
The session runs in UTC. rindexer exports `block_timestamp` as `TIMESTAMP WITH
48+
TIME ZONE`, and DuckDB renders, casts and buckets that type in the session
49+
`TimeZone`, which otherwise follows the machine. The worker pins it, so
50+
`strftime`, `date_trunc`, `hour()` and casts to `TIMESTAMP` give the same
51+
answer on the producer, in a fork in another zone, and in `query` on a laptop.
52+
This is a worker-wide contract, not a per-query option; it needs DuckDB's ICU
53+
extension, which the bundled binaries link statically, and the worker refuses
54+
to run project SQL on a build without it.
55+
4756
`cp_sortkey(v)` is available in every query. It maps a decimal-string amount to
4857
a fixed-width key whose lexicographic order is signed-numeric order, so
4958
`ORDER BY cp_sortkey(value)` sorts uint256 correctly without projecting a

‎src/query/workerMain.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,24 @@ async function execute(req: WorkerRequest): Promise<{
160160
// trusted than the query itself, so they must land on this side of it.
161161
// DuckDB does not allow re-enabling external access in a session.
162162
await conn.run("SET enable_external_access=false");
163+
// rindexer exports block_timestamp as TIMESTAMP WITH TIME ZONE, and
164+
// DuckDB renders, casts and buckets that type in the session's
165+
// TimeZone, which defaults to the machine's. Pinning UTC is what makes
166+
// an hourly or daily figure the same on the producer, in a fork on a
167+
// laptop in another zone, and in the published results. The setting
168+
// belongs to DuckDB's ICU extension, which the bundled binaries link
169+
// statically; a build without it must fail here, before any project
170+
// SQL, rather than quietly compute in local time.
171+
try {
172+
await conn.run("SET TimeZone='UTC'");
173+
} catch (err) {
174+
throw issueError({
175+
code: "internal",
176+
message:
177+
"DuckDB build lacks ICU time-zone support; the query worker requires it " +
178+
`so timestamps compute in UTC on every machine (${err instanceof Error ? err.message : String(err)})`,
179+
});
180+
}
163181
await conn.run(SORT_KEY_MACRO);
164182

165183
for (const model of req.models ?? []) {

‎tests/query/timezone.test.ts‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { describe, expect, it } from "vitest";
2+
import fs from "node:fs";
3+
import os from "node:os";
4+
import path from "node:path";
5+
import { DuckDBInstance } from "@duckdb/node-api";
6+
import { runQuery } from "../../src/query/runQuery.js";
7+
8+
// A snapshot shaped like rindexer's export: block_timestamp is TIMESTAMP WITH
9+
// TIME ZONE. Written by this test process, not the worker, which has external
10+
// access switched off before any project SQL runs.
11+
async function timestampFixture(): Promise<string> {
12+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "chainplot-tz-"));
13+
const parquet = path.join(dir, "events.parquet");
14+
const instance = await DuckDBInstance.create(":memory:");
15+
const conn = await instance.connect();
16+
await conn.run(
17+
`COPY (
18+
SELECT * FROM (VALUES
19+
(1, TIMESTAMPTZ '2026-09-16 00:26:20+00'),
20+
(2, TIMESTAMPTZ '2026-09-16 23:59:59+00')
21+
) AS t(rindexer_id, block_timestamp)
22+
) TO '${parquet.replaceAll("'", "''")}' (FORMAT PARQUET)`,
23+
);
24+
return parquet;
25+
}
26+
27+
// DuckDB formats, casts and buckets TIMESTAMPTZ in the session TimeZone, which
28+
// follows the machine unless pinned: the same query would publish "08:00" from
29+
// a laptop in Singapore and "00:00" from the producer container. The worker
30+
// pins UTC so a fork reproduces the published figures wherever it runs.
31+
describe("query session timezone", () => {
32+
// TimeZone is a setting DuckDB's ICU extension registers, so reading it
33+
// back also proves the bundled build links ICU: without it the worker's
34+
// SET would have failed before this query ran. (duckdb_extensions() is not
35+
// usable here: it scans the extension directory, which the worker's
36+
// external-access lockdown forbids.)
37+
it("is pinned to UTC by the ICU-backed setting", async () => {
38+
const parquet = await timestampFixture();
39+
const result = await runQuery({
40+
sql: "SELECT current_setting('TimeZone') AS tz FROM (SELECT 1)",
41+
tables: { events: parquet },
42+
rawAmountColumns: [],
43+
rowLimit: 10,
44+
});
45+
expect(result.rows).toEqual([["UTC"]]);
46+
});
47+
48+
it("renders, buckets and casts a TIMESTAMPTZ column in UTC", async () => {
49+
const parquet = await timestampFixture();
50+
const result = await runQuery({
51+
sql: `SELECT typeof(block_timestamp) AS t,
52+
strftime(block_timestamp, '%Y-%m-%d %H:%M:%S') AS rendered,
53+
strftime(date_trunc('day', block_timestamp), '%Y-%m-%d') AS day,
54+
hour(block_timestamp) AS h,
55+
strftime(block_timestamp::TIMESTAMP, '%H:%M') AS cast_naive
56+
FROM events ORDER BY rindexer_id`,
57+
tables: { events: parquet },
58+
rawAmountColumns: [],
59+
rowLimit: 10,
60+
});
61+
expect(result.rows).toEqual([
62+
// hour() is BIGINT, which the worker serializes as a decimal string.
63+
["TIMESTAMP WITH TIME ZONE", "2026-09-16 00:26:20", "2026-09-16", "0", "00:26"],
64+
["TIMESTAMP WITH TIME ZONE", "2026-09-16 23:59:59", "2026-09-16", "23", "23:59"],
65+
]);
66+
});
67+
});

‎viewer/src/App.tsx‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ import {
1818
} from "./format.js";
1919

2020
/** Where a reader learns how to rebuild a release from its dataset. */
21-
const FORK_HOWTO_URL = "https://github.com/chainstacklabs/chainplot#fork-a-published-release";
21+
const CHAINPLOT_REPO_URL = "https://github.com/chainstacklabs/chainplot";
22+
const FORK_HOWTO_URL = `${CHAINPLOT_REPO_URL}#fork-a-published-release`;
2223

2324
/** Columns the panel asked to compute but not show, e.g. an explicit sort key. */
2425
function visibleColumns(
@@ -528,7 +529,11 @@ export function App() {
528529
))}
529530

530531
<footer className="colophon">
531-
Built by <span>chainplot</span> · {release.mode.replace(/_/g, " ")} ·{" "}
532+
Built by{" "}
533+
<a className="brand" href={CHAINPLOT_REPO_URL} rel="noopener">
534+
chainplot
535+
</a>{" "}
536+
· {release.mode.replace(/_/g, " ")} ·{" "}
532537
<time dateTime={release.generated_at}>{release.generated_at}</time>
533538
{" · "}
534539
{release.mode === "results_only" ? (

‎viewer/src/styles.css‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -186,13 +186,17 @@ body {
186186
display: grid;
187187
grid-template-columns: repeat(2, minmax(0, 1fr));
188188
gap: 1rem;
189-
align-items: start;
189+
/* Cards in a row stretch to the same height, so a KPI with a description
190+
and one without still end up level. */
191+
align-items: stretch;
190192
}
191193

192194
.panel {
193195
/* min-width:0 lets a grid child shrink, which is what keeps a wide table
194196
inside its card instead of stretching the whole row. */
195197
min-width: 0;
198+
display: flex;
199+
flex-direction: column;
196200
padding: 1rem 1.25rem 1.25rem;
197201
border: 1px solid var(--border-static);
198202
border-radius: var(--radius-l);
@@ -228,6 +232,9 @@ body {
228232
align-items: baseline;
229233
gap: 0.55rem;
230234
flex-wrap: wrap;
235+
/* Sit at the bottom of the card, so values line up across a row whatever
236+
the header above them took. */
237+
margin-top: auto;
231238
}
232239

233240
.kpi-value {
@@ -416,9 +423,15 @@ table.data-table th.raw button::after {
416423
font-size: 0.75rem;
417424
}
418425

419-
.colophon span {
426+
.colophon .brand {
420427
font-weight: 600;
421428
color: var(--text-secondary);
429+
text-decoration: none;
430+
}
431+
432+
.colophon .brand:hover {
433+
text-decoration: underline;
434+
text-underline-offset: 2px;
422435
}
423436

424437
.colophon a {

0 commit comments

Comments
 (0)