Repository navigation
render: single-value formatters as a headless @malloydata/render/format entry - #3140
Merged
Merged
Conversation
…at entry
A value's display format (`# currency`, `# percent`, `# duration`,
`# number`) could only be applied by mounting the renderer: the
formatters took a data-tree Field, and the package's one entry loads
Solid, Vega and the DOM. Anything that wanted a value to read the way a
rendered table shows it (a server writing a summary, a dashboard
drawing its own KPI card) had to reimplement the formats and drift.
src/format/ now holds them with no Field in sight:
- resolveCellFormat(tag, {isTime}) -> CellFormatConfig, the plain-data
config resolveCellFormatTags already produced
- formatNumber(value, config): numbers, and digit strings for exact
bigints
- formatDateTime(value, config, {timeframe, isDate, timezone})
- formatDuration, formatScaledNumber, renderTimeString, NULL_SYMBOL
The renderer's cells call these same functions:
resolveCellFormatTags and the render*Field helpers are now thin
wrappers that add only the Field lookups (its config, its query
timezone), so rendered output is unchanged. util.ts re-exports what
moved.
`@malloydata/render/format` is built separately (vite.config.format.mts)
into dist/format/index.{mjs,cjs}: self-contained like the main build,
about 113 KB as CJS, and it loads in plain Node with no DOM stub, under
both ESM and require.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: lloyd tabb <1093458+lloydtabb@users.noreply.github.com>
formatDateTime handed a Date to ssf for a `# number="…"` date pattern, and ssf reads a Date's local fields, so the output depended on where it ran: Sep 1 00:00 UTC printed "Aug 2026" in any browser west of UTC, and a server and a browser could disagree. It now formats the value's wall clock as a spreadsheet serial date (which carries no zone): in the query's timezone for a timestamp, UTC otherwise, and the UTC calendar day for a date. The renderer passes the field's effective query zone, as it already did for the default path. This changes rendered output only for date patterns on hosts not in the value's zone, where it was wrong. Review fixes on the same PR: - typesVersions maps `format` to its .d.ts, so TypeScript on `moduleResolution: node` resolves @malloydata/render/format - resolveCellFormat returns a fresh default config, not one shared object stored on every untagged field - an invalid date pattern's warning names the field again - the legacy renderer's duration text reuses formatDuration (which takes the `# number` part pattern it applied), so one copy remains - the unused util.getText wrapper is gone Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: lloyd tabb <1093458+lloydtabb@users.noreply.github.com>
The comment claimed the suite also runs under other zones; jest.config.ts pins TZ=America/Los_Angeles, which is the west-of-UTC host the test needs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: lloyd tabb <1093458+lloydtabb@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
A value's display format (
# currency,# percent,# duration,# number) could only be applied by mounting the renderer:Field, andAnything else that wants a value to read the way a rendered table shows it has had to reimplement the formats and drift from them. That includes a server writing "$521k" into a summary, a dashboard drawing its own KPI card, and an LLM reporting a number. Concretely, Malloyyo now hands each result column's declared format to its MCP clients (malloydata/malloyyo#204) and wants a
format(path, value)for custom dashboards that matches the renderer exactly.What
src/format/: the formatters, with noField.resolveCellFormat(tag, {isTime})→CellFormatConfig. This is the same plain-data configresolveCellFormatTagsalready produced, and it survivesJSON.stringify.formatNumber(value, config)handles numbers, plus digit strings for exact bigints.formatDateTime(value, config, {timeframe, isDate, timezone}).formatDuration,formatScaledNumber,renderTimeString,NULL_SYMBOL.The renderer uses the same code.
resolveCellFormatTagsand therender*Fieldhelpers are now thin wrappers that add only theFieldlookups (its resolved config and its query timezone). Rendered output is unchanged.util.tsre-exports everything that moved, so existing imports keep working.A new export,
@malloydata/render/format. It's built separately byvite.config.format.mtsintodist/format/index.{mjs,cjs}, with types from the main build'sdist/module/format/.external: []). Leaving dependencies external breaks Node ESM:ssfis CommonJS-only, andlodash/startCaseis an extensionless subpath.importandrequire. Checked through the packageexports.CONTEXT.mddocuments the second entry and the rule that keeps it headless: nodata_tree, Solid, Vega or DOM imports insrc/format/.Tests
src/format/cell-format.spec.tscovers resolution (currency shorthand, percent, duration,# numberas a date pattern only on time fields, default, JSON round-trip) and formatting (currency, percent, number shorthands and ssf patterns, duration, bigint strings, null, date patterns, timeframes). Themalloy-renderjest project has no DOM setup, so this also shows the module doesn't need one.malloy-render: 228/228 passed (the repo's jest config pinsTZ=America/Los_Angeles).malloy-render-test(DOM renderer): 76 passed, 28 skipped. Consumer canary passes.tsc -p tsconfig.type-check.json, eslint and prettier are clean, and the full monoreponpm run buildsucceeds.Date patterns now read the query's zone, not the host's (behaviour fix)
Before this PR,
# number="mmm yyyy"handed aDatetossf, which reads the host's local fields. So 1 Sep 2026 00:00 UTC rendered "Aug 2026" in any browser west of UTC, and a server and a browser could disagree.formatDateTimenow formats the value's wall clock as a spreadsheet serial date, which carries no zone:query_timezone, as it already did for the default path), otherwise UTC;This is the one change to rendered output: date-pattern fields on hosts that aren't in the value's zone, where the output was wrong. The spec covers the UTC midnight-on-the-1st case, a query time zone and a date. Note that
jest.config.tspinsTZ=America/Los_Angeles, so the repo's suite only ever runs west of UTC. I also ran the spec with that line removed under UTC,America/Los_Angeles,Asia/TokyoandAsia/Kolkata, and it passes in all four.Differential check, old vs new. Using a throwaway harness (not committed), I rendered 7,824 cells through the renderer's own pipeline (
RenderFieldMetadata→getDataTree→renderCellValue) on bothmainand this branch, with the host in the same four zones:timezone:.The results:
main, 5,070 of the 7,824 cells changed with the host's zone.main:# currency=gbp…and# currency=poundthrowunrecognized character £in ssf. It's unchanged by this PR and worth its own fix.Review follow-ups (second commit)
typesVersionsmapsformatto its.d.ts. Checked:moduleResolution: node10fails with TS2307 without it and resolves with it;bundlerandnode16resolve either way.resolveCellFormatreturns a fresh default config instead of one shared object stored on every untagged field.html/duration.ts) reusesformatDuration, which now takes the per-part# numberpattern the legacy code applied, so only one copy of the duration logic remains.util.getTextwrapper is removed../formatis about 258 KB unminified, against 113 KB for CJS. Vite's library mode won't strip whitespace from ES output, even withbuild.minify.🤖 Generated with Claude Code