From b0339abf7d635590e630dc9a29a6c89b8e1a858b Mon Sep 17 00:00:00 2001 From: Eugene Aseev Date: Tue, 22 Sep 2026 14:01:26 +0800 Subject: [PATCH] viewer: align amount columns and their headers, level grid cards, name the product consistently; query: run project SQL in UTC A rounded amount keeps its trailing zeros, so a column of amounts lines up on the decimal point instead of printing "9,000,000", "1,499,999.5" and "101,570,558.71" side by side. `scaleAmount` was doing double duty: it is the lossless rendering, where dropping trailing zeros is right, and it was also rendering figures `displayAmount` had already rounded to a fixed width, where dropping them is wrong. It now takes a flag, and only the exact rendering trims. Alignment is decided once per column rather than per cell. Deciding it per value left a null, which has no digits, out of line with the figures above it, and left-aligned a count column's header over its right-aligned numbers. A declared amount and an anchored scalar numeric type answer for themselves; only VARCHAR is settled by its values, so a uint256 that was never declared an amount still reads as one while a list of numbers, a struct, a bit string and an enum label do not. 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 dashboard footer credits Chainplot by name and links to this repository. Both footer links carry the same underline and differ only in weight, so a release published `results_only`, which has no fork link, does not present its one remaining link as plain text. Capitalized is how the docs and the page title already spell the product, so the templates and the usdc-supply example now match; lowercase stays for the command, the config file, the image tag and the repository path. Dated plan and spec documents are left as written. 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 Opus 5 --- docs/capabilities.md | 9 +++ examples/usdc-supply/.env.example | 2 +- examples/usdc-supply/README.md | 2 +- examples/usdc-supply/compose.yaml | 6 +- src/query/workerMain.ts | 18 +++++ templates/ingest-transfers/.env.example | 2 +- templates/ingest-transfers/README.md | 4 +- templates/ingest-transfers/compose.yaml | 6 +- tests/query/timezone.test.ts | 67 +++++++++++++++++++ tests/viewer/format.test.ts | 88 +++++++++++++++++++++++-- viewer/src/App.tsx | 34 ++++++++-- viewer/src/format.ts | 64 ++++++++++++++++-- viewer/src/styles.css | 16 ++++- 13 files changed, 289 insertions(+), 29 deletions(-) create mode 100644 tests/query/timezone.test.ts diff --git a/docs/capabilities.md b/docs/capabilities.md index 885140e..3d68929 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -44,6 +44,15 @@ INSERT, COPY, ATTACH, PRAGMA and friends outright, so the rule is the parser's, not a keyword denylist. Filesystem and network access are disabled before any project SQL executes, models included: a forked recipe is a stranger's code. +The session runs in UTC. rindexer exports `block_timestamp` as `TIMESTAMP WITH +TIME ZONE`, and DuckDB renders, casts and buckets that type in the session +`TimeZone`, which otherwise follows the machine. The worker pins it, so +`strftime`, `date_trunc`, `hour()` and casts to `TIMESTAMP` give the same +answer on the producer, in a fork in another zone, and in `query` on a laptop. +This is a worker-wide contract, not a per-query option; it needs DuckDB's ICU +extension, which the bundled binaries link statically, and the worker refuses +to run project SQL on a build without it. + `cp_sortkey(v)` is available in every query. It maps a decimal-string amount to a fixed-width key whose lexicographic order is signed-numeric order, so `ORDER BY cp_sortkey(value)` sorts uint256 correctly without projecting a diff --git a/examples/usdc-supply/.env.example b/examples/usdc-supply/.env.example index d9d4714..2194818 100644 --- a/examples/usdc-supply/.env.example +++ b/examples/usdc-supply/.env.example @@ -7,7 +7,7 @@ RPC_URL= # nothing to install or point at yourself. DATABASE_URL=postgresql://chainplot:chainplot@postgres:5432/chainplot -# Producer image tag. Build it once from a chainplot checkout: +# Producer image tag. Build it once from a Chainplot checkout: # docker build --platform linux/amd64 -t chainplot:local \ # -f docker/producer.Dockerfile . # CHAINPLOT_IMAGE=chainplot:local diff --git a/examples/usdc-supply/README.md b/examples/usdc-supply/README.md index 0686c16..48f9e71 100644 --- a/examples/usdc-supply/README.md +++ b/examples/usdc-supply/README.md @@ -27,7 +27,7 @@ indexed filters, a model, and time-series charts. ## Run it -Build the producer image once from a chainplot checkout, then: +Build the producer image once from a Chainplot checkout, then: ```bash cp .env.example .env # fill in RPC_URL diff --git a/examples/usdc-supply/compose.yaml b/examples/usdc-supply/compose.yaml index e8a2fe8..58a9257 100644 --- a/examples/usdc-supply/compose.yaml +++ b/examples/usdc-supply/compose.yaml @@ -16,9 +16,9 @@ services: retries: 30 producer: - # The producer image is the chainplot CLI plus the pinned rindexer binary. - # It is built from the chainplot repo, not from this project — a scaffolded - # project has no CLI sources to build from. Once, from a chainplot checkout: + # The producer image is the Chainplot CLI plus the pinned rindexer binary. + # It is built from the Chainplot repo, not from this project — a scaffolded + # project has no CLI sources to build from. Once, from a Chainplot checkout: # # docker build --platform linux/amd64 \ # -t chainplot:local -f docker/producer.Dockerfile . diff --git a/src/query/workerMain.ts b/src/query/workerMain.ts index ea2ba4c..5999ac5 100644 --- a/src/query/workerMain.ts +++ b/src/query/workerMain.ts @@ -160,6 +160,24 @@ async function execute(req: WorkerRequest): Promise<{ // trusted than the query itself, so they must land on this side of it. // DuckDB does not allow re-enabling external access in a session. await conn.run("SET enable_external_access=false"); + // rindexer exports block_timestamp as TIMESTAMP WITH TIME ZONE, and + // DuckDB renders, casts and buckets that type in the session's + // TimeZone, which defaults to the machine's. Pinning UTC is what makes + // an hourly or daily figure the same on the producer, in a fork on a + // laptop in another zone, and in the published results. The setting + // belongs to DuckDB's ICU extension, which the bundled binaries link + // statically; a build without it must fail here, before any project + // SQL, rather than quietly compute in local time. + try { + await conn.run("SET TimeZone='UTC'"); + } catch (err) { + throw issueError({ + code: "internal", + message: + "DuckDB build lacks ICU time-zone support; the query worker requires it " + + `so timestamps compute in UTC on every machine (${err instanceof Error ? err.message : String(err)})`, + }); + } await conn.run(SORT_KEY_MACRO); for (const model of req.models ?? []) { diff --git a/templates/ingest-transfers/.env.example b/templates/ingest-transfers/.env.example index d9d4714..2194818 100644 --- a/templates/ingest-transfers/.env.example +++ b/templates/ingest-transfers/.env.example @@ -7,7 +7,7 @@ RPC_URL= # nothing to install or point at yourself. DATABASE_URL=postgresql://chainplot:chainplot@postgres:5432/chainplot -# Producer image tag. Build it once from a chainplot checkout: +# Producer image tag. Build it once from a Chainplot checkout: # docker build --platform linux/amd64 -t chainplot:local \ # -f docker/producer.Dockerfile . # CHAINPLOT_IMAGE=chainplot:local diff --git a/templates/ingest-transfers/README.md b/templates/ingest-transfers/README.md index 02c8982..6302e48 100644 --- a/templates/ingest-transfers/README.md +++ b/templates/ingest-transfers/README.md @@ -7,8 +7,8 @@ thing you supply is an archive-capable RPC endpoint. ## One-time: build the producer image -The producer image is the chainplot CLI plus rindexer. It is built from a -chainplot checkout, not from this project — a scaffolded project has no CLI +The producer image is the Chainplot CLI plus rindexer. It is built from a +Chainplot checkout, not from this project — a scaffolded project has no CLI sources. rindexer ships linux/amd64 only, so build for that platform: ```bash diff --git a/templates/ingest-transfers/compose.yaml b/templates/ingest-transfers/compose.yaml index e8a2fe8..58a9257 100644 --- a/templates/ingest-transfers/compose.yaml +++ b/templates/ingest-transfers/compose.yaml @@ -16,9 +16,9 @@ services: retries: 30 producer: - # The producer image is the chainplot CLI plus the pinned rindexer binary. - # It is built from the chainplot repo, not from this project — a scaffolded - # project has no CLI sources to build from. Once, from a chainplot checkout: + # The producer image is the Chainplot CLI plus the pinned rindexer binary. + # It is built from the Chainplot repo, not from this project — a scaffolded + # project has no CLI sources to build from. Once, from a Chainplot checkout: # # docker build --platform linux/amd64 \ # -t chainplot:local -f docker/producer.Dockerfile . diff --git a/tests/query/timezone.test.ts b/tests/query/timezone.test.ts new file mode 100644 index 0000000..519342e --- /dev/null +++ b/tests/query/timezone.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { DuckDBInstance } from "@duckdb/node-api"; +import { runQuery } from "../../src/query/runQuery.js"; + +// A snapshot shaped like rindexer's export: block_timestamp is TIMESTAMP WITH +// TIME ZONE. Written by this test process, not the worker, which has external +// access switched off before any project SQL runs. +async function timestampFixture(): Promise { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "chainplot-tz-")); + const parquet = path.join(dir, "events.parquet"); + const instance = await DuckDBInstance.create(":memory:"); + const conn = await instance.connect(); + await conn.run( + `COPY ( + SELECT * FROM (VALUES + (1, TIMESTAMPTZ '2026-09-16 00:26:20+00'), + (2, TIMESTAMPTZ '2026-09-16 23:59:59+00') + ) AS t(rindexer_id, block_timestamp) + ) TO '${parquet.replaceAll("'", "''")}' (FORMAT PARQUET)`, + ); + return parquet; +} + +// DuckDB formats, casts and buckets TIMESTAMPTZ in the session TimeZone, which +// follows the machine unless pinned: the same query would publish "08:00" from +// a laptop in Singapore and "00:00" from the producer container. The worker +// pins UTC so a fork reproduces the published figures wherever it runs. +describe("query session timezone", () => { + // TimeZone is a setting DuckDB's ICU extension registers, so reading it + // back also proves the bundled build links ICU: without it the worker's + // SET would have failed before this query ran. (duckdb_extensions() is not + // usable here: it scans the extension directory, which the worker's + // external-access lockdown forbids.) + it("is pinned to UTC by the ICU-backed setting", async () => { + const parquet = await timestampFixture(); + const result = await runQuery({ + sql: "SELECT current_setting('TimeZone') AS tz FROM (SELECT 1)", + tables: { events: parquet }, + rawAmountColumns: [], + rowLimit: 10, + }); + expect(result.rows).toEqual([["UTC"]]); + }); + + it("renders, buckets and casts a TIMESTAMPTZ column in UTC", async () => { + const parquet = await timestampFixture(); + const result = await runQuery({ + sql: `SELECT typeof(block_timestamp) AS t, + strftime(block_timestamp, '%Y-%m-%d %H:%M:%S') AS rendered, + strftime(date_trunc('day', block_timestamp), '%Y-%m-%d') AS day, + hour(block_timestamp) AS h, + strftime(block_timestamp::TIMESTAMP, '%H:%M') AS cast_naive + FROM events ORDER BY rindexer_id`, + tables: { events: parquet }, + rawAmountColumns: [], + rowLimit: 10, + }); + expect(result.rows).toEqual([ + // hour() is BIGINT, which the worker serializes as a decimal string. + ["TIMESTAMP WITH TIME ZONE", "2026-09-16 00:26:20", "2026-09-16", "0", "00:26"], + ["TIMESTAMP WITH TIME ZONE", "2026-09-16 23:59:59", "2026-09-16", "23", "23:59"], + ]); + }); +}); diff --git a/tests/viewer/format.test.ts b/tests/viewer/format.test.ts index fe6c929..a9b3933 100644 --- a/tests/viewer/format.test.ts +++ b/tests/viewer/format.test.ts @@ -4,6 +4,7 @@ import { columnLabel, compareValues, displayAmount, + isNumericColumn, formatCell, groupDigits, relativeTime, @@ -214,20 +215,35 @@ describe("display rounding", () => { }); it("rounds half away from zero rather than truncating", () => { - expect(displayAmount(1_999_999n, 6)).toBe("2"); - expect(displayAmount(-1_999_999n, 6)).toBe("-2"); + expect(displayAmount(1_999_999n, 6)).toBe("2.00"); + expect(displayAmount(-1_999_999n, 6)).toBe("-2.00"); expect(displayAmount(1_005_000n, 6)).toBe("1.01"); }); + // A column of amounts is read down the decimal point. Trimming the zeros + // here would print "9,000,000", "1,499,999.5" and "101,570,558.71" in one + // column, which is what the exact rendering is for. + it("pads every rounded figure to the same width", () => { + expect(displayAmount(9_000_000_000_000n, 6)).toBe("9,000,000.00"); + expect(displayAmount(1_499_999_500_000n, 6)).toBe("1,499,999.50"); + expect(displayAmount(101_570_558_713_200n, 6)).toBe("101,570,558.71"); + }); + + it("still trims trailing zeros when rendering an exact value", () => { + expect(scaleAmount(9_000_000_000_000n, 6)).toBe("9,000,000"); + expect(scaleAmount(1_499_999_500_000n, 6)).toBe("1,499,999.5"); + }); + it("keeps full precision below one unit, where the fraction is the value", () => { expect(displayAmount(1n, 18)).toBe("0.000000000000000001"); expect(displayAmount(-1n, 18)).toBe("-0.000000000000000001"); }); - it("leaves a value that needs no rounding untouched", () => { + it("pads a value that needs no rounding, and reveals it exactly on hover", () => { const cell = formatCell("1500000", usdc); - expect(cell.text).toBe("1.5 USDC"); - expect(cell.exact).toBe("1500000"); + expect(cell.text).toBe("1.50 USDC"); + expect(cell.exact).toContain("1.5 USDC"); + expect(cell.exact).toContain("1500000"); }); it("does not round integer columns that are not amounts", () => { @@ -281,3 +297,65 @@ describe("rowWindow", () => { expect(rowWindow(5000, 0, 400, 0, opts).virtual).toBe(false); }); }); + +// Alignment follows the column, not the value. Deciding per cell put a null, +// which has no digits, out of line with the figures above it, and left-aligned +// a header over right-aligned cells. +describe("isNumericColumn", () => { + it("treats a declared amount as numeric however it is typed", () => { + expect( + isNumericColumn({ name: "v", logical_type: "VARCHAR", raw_amount: true }, [null]), + ).toBe(true); + }); + + it("treats a numeric logical type as numeric even when every value is null", () => { + expect(isNumericColumn({ name: "n", logical_type: "BIGINT" }, [null, null])).toBe(true); + expect(isNumericColumn({ name: "d", logical_type: "DECIMAL(18,3)" }, [])).toBe(true); + expect(isNumericColumn({ name: "h", logical_type: "HUGEINT" }, [])).toBe(true); + expect(isNumericColumn({ name: "u", logical_type: "UBIGINT" }, [])).toBe(true); + }); + + it("settles a VARCHAR by its values, so an undeclared uint256 still reads as one", () => { + const column = { name: "v", logical_type: "VARCHAR" }; + expect(isNumericColumn(column, ["1", "115792089237316195423570985008687907853269984665640564039457584007913129639935"])).toBe(true); + expect(isNumericColumn(column, ["1", null, "2"])).toBe(true); + }); + + // A list of numbers is not a number: it renders as "[1, 2, 3]" and belongs + // on the left with the text. An unanchored type match accepts "INTEGER[]". + it("does not treat a list of numbers as a number column", () => { + for (const logical_type of ["INTEGER[]", "UBIGINT[]", "DECIMAL(18,3)[]", "DOUBLE[]"]) { + expect(isNumericColumn({ name: "l", logical_type }, [])).toBe(false); + expect(isNumericColumn({ name: "l", logical_type }, [[1, 2]])).toBe(false); + } + expect(isNumericColumn({ name: "s", logical_type: "STRUCT(a INTEGER)" }, [])).toBe(false); + }); + + it("accepts the scalar numeric types as DuckDB spells them", () => { + for (const logical_type of [ + "TINYINT", "SMALLINT", "INTEGER", "BIGINT", "HUGEINT", + "UTINYINT", "USMALLINT", "UINTEGER", "UBIGINT", "UHUGEINT", + "FLOAT", "REAL", "DOUBLE", "DECIMAL(18,3)", "DECIMAL", + ]) { + expect(isNumericColumn({ name: "n", logical_type }, [])).toBe(true); + } + }); + + // Only text is settled by its values. A BIT string is "0101" and an enum + // label can be any text at all; neither is a number because it reads like + // digits. + it("does not infer from the values of a type that is not text", () => { + expect(isNumericColumn({ name: "b", logical_type: "BIT" }, ["0101", "1"])).toBe(false); + expect(isNumericColumn({ name: "e", logical_type: "ENUM('1','2')" }, ["1", "2"])).toBe(false); + expect(isNumericColumn({ name: "d", logical_type: "DATE" }, ["20260916"])).toBe(false); + expect(isNumericColumn({ name: "u", logical_type: "UUID" }, ["12345"])).toBe(false); + }); + + it("does not call a VARCHAR numeric on the strength of some of its values", () => { + const column = { name: "v", logical_type: "VARCHAR" }; + expect(isNumericColumn(column, ["1", "Ethereum"])).toBe(false); + expect(isNumericColumn(column, ["00:26:20"])).toBe(false); + expect(isNumericColumn(column, [])).toBe(false); + expect(isNumericColumn(column, [null, null])).toBe(false); + }); +}); diff --git a/viewer/src/App.tsx b/viewer/src/App.tsx index a236b4f..8df9ffa 100644 --- a/viewer/src/App.tsx +++ b/viewer/src/App.tsx @@ -12,13 +12,15 @@ import { columnLabel, compareValues, formatCell, + isNumericColumn, relativeTime, rowWindow, toChartNumber, } from "./format.js"; /** Where a reader learns how to rebuild a release from its dataset. */ -const FORK_HOWTO_URL = "https://github.com/chainstacklabs/chainplot#fork-a-published-release"; +const CHAINPLOT_REPO_URL = "https://github.com/chainstacklabs/chainplot"; +const FORK_HOWTO_URL = `${CHAINPLOT_REPO_URL}#fork-a-published-release`; /** Columns the panel asked to compute but not show, e.g. an explicit sort key. */ function visibleColumns( @@ -160,6 +162,20 @@ function DataTable({ const [rowHeight, setRowHeight] = useState(ASSUMED_ROW_HEIGHT); const bodyRef = useRef(null); + // Alignment is decided once per column, so a header, its figures and a null + // among them all sit on the same edge. + const numericColumns = useMemo(() => { + const numeric = new Set(); + for (const index of columns) { + const column = result.columns[index]; + if (!column) continue; + if (isNumericColumn(column, result.rows.map((row) => row[index]))) { + numeric.add(index); + } + } + return numeric; + }, [columns, result]); + const sorted = useMemo(() => { if (sortCol === null) return result.rows; const rows = [...result.rows]; @@ -220,7 +236,13 @@ function DataTable({