@nanobpm/urban 0.58.0 → 0.59.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/urban",
3
- "version": "0.58.0",
3
+ "version": "0.59.0",
4
4
  "description": "Urban: build and run code-first apps on Nano — runtime, derivation toolkit, and CLI in one. Author durable processes with @nanobpm/workflow (defineFlow), a typed datasource, forms, triggers and surfaces from a nano.app.json manifest, on Node or Deno.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -321,6 +321,36 @@ function interpTemplate(tpl, row) {
321
321
  })
322
322
  : tpl;
323
323
  }
324
+
325
+ // Format a single-field cell value per a column's opt-in `col.format` (issue
326
+ // #327). This runs in the browser, so it is the ONLY place that can render a
327
+ // stored UTC ISO timestamp in the VIEWER'S local timezone — the server never
328
+ // knows the viewer's zone, so a server-formatted string would always be wrong
329
+ // for someone. Currently the sole format is "datetime": a parseable ISO/date
330
+ // value → "11:42am Aug 19" (h:mm + lowercase am/pm, then short-month day), both
331
+ // parts in local time. Defensive by construction: an empty value, an unparseable
332
+ // date, or any unknown format returns the raw text verbatim — a mis-set format
333
+ // never blanks a cell or throws, matching how an unrecognised link.kind degrades
334
+ // to plain text. Display-only: the untransformed rawText still drives the badge
335
+ // gate/tooltip and any sort/groupBy (those key off the real field).
336
+ /** @param {any} format
337
+ * @param {string} raw
338
+ * @returns {string} */
339
+ function fmtCellValue(format, raw) {
340
+ if (format !== "datetime" || raw === "") return raw;
341
+ const d = new Date(raw);
342
+ if (Number.isNaN(d.getTime())) return raw;
343
+ // Present in a stable en-US format ("11:42am Aug 19") regardless of the
344
+ // viewer's locale ordering (e.g. en-GB would emit "19 Aug"), while the ABSENCE
345
+ // of a `timeZone` option keeps both parts in the viewer's LOCAL zone. hour12
346
+ // forces am/pm; the replace lowercases "AM"/"a.m."/… to "am"/"pm" and drops the
347
+ // separating space so the output is exactly "11:42am".
348
+ const time = d
349
+ .toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", hour12: true })
350
+ .replace(/\s*([AaPp])\.?\s*[Mm]\.?$/, (_m, p) => p.toLowerCase() + "m");
351
+ const date = d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
352
+ return time + " " + date;
353
+ }
324
354
  /** @param {any} node */
325
355
  function renderText(node) {
326
356
  const v = node.props.variant;
@@ -641,9 +671,15 @@ function gridCell(col, row, role) {
641
671
  // field-derived defaults. Kept separate from the (possibly templated) display
642
672
  // text so a template never re-lights or blanks a badge.
643
673
  const rawText = row[col.field] == null ? "" : String(row[col.field]);
674
+ // Per-column display format (issue #327): an opt-in `col.format` transforms the
675
+ // single-field value for display only (e.g. "datetime" → the viewer's local
676
+ // time). rawText above stays untransformed so the badge gate/tooltip and any
677
+ // sort/groupBy still key off the real field. A col.template composes its own
678
+ // {{field}} tokens and therefore wins over the formatted field text below.
679
+ const fieldText = col.format ? fmtCellValue(col.format, rawText) : rawText;
644
680
  // Visible text: a col.template (interpolated from the row) wins over the
645
- // raw field value, else the field value is shown verbatim.
646
- const text = typeof col.template === "string" ? interpTemplate(col.template, row) : rawText;
681
+ // formatted/raw field value, else the field value is shown verbatim.
682
+ const text = typeof col.template === "string" ? interpTemplate(col.template, row) : fieldText;
647
683
  // 1. badge — a compact status indicator. When the row's field value is
648
684
  // non-empty (truthy after trimming) render a small circular badge (e.g. a
649
685
  // red "1" dot flagging an incident) whose tooltip is the full field text;
@@ -2176,4 +2212,4 @@ function boot() {
2176
2212
  // renderer functions / RENDERERS registry can be imported directly.
2177
2213
  if (typeof document !== "undefined" && document.getElementById("page")) boot();
2178
2214
 
2179
- export { RENDERERS, renderText, renderButton, renderProse, renderNav };
2215
+ export { RENDERERS, renderText, renderButton, renderProse, renderNav, fmtCellValue, gridCell };
@@ -7,7 +7,7 @@
7
7
  import { test } from "node:test";
8
8
  import assert from "node:assert/strict";
9
9
  import { PAGE_NODE_TYPES } from "@nanobpm/nano-app-schema";
10
- import { RENDERERS, renderText } from "./runtime.browser.js";
10
+ import { RENDERERS, renderText, fmtCellValue, gridCell } from "./runtime.browser.js";
11
11
 
12
12
  // ── Minimal fake DOM ────────────────────────────────────────────────────────
13
13
  // Just enough of the Element/Document surface that el()/renderText touch:
@@ -116,3 +116,45 @@ test("#291: renderText degrades to empty text (never throws) on a missing text p
116
116
  assert.equal(node.tagName, "P");
117
117
  assert.equal(node.textContent, "");
118
118
  });
119
+
120
+ test('#327: fmtCellValue "datetime" renders an ISO timestamp in the viewer\'s local time as "h:mmam Mon D"', () => {
121
+ const iso = "2026-08-18T23:42:47.788Z";
122
+ const out = fmtCellValue("datetime", iso);
123
+ // Shape: h:mm + lowercase am/pm (no separating space), then short-month day.
124
+ assert.match(out, /^\d{1,2}:\d{2}(am|pm) [A-Z][a-z]{2} \d{1,2}$/, `unexpected shape: ${out}`);
125
+ // Local, not UTC: the hour/minute/am-pm/day track the runner's local zone, so
126
+ // the assertion is timezone-independent (derives the expectation from the same
127
+ // Date in local time) yet still proves the value isn't rendered as raw UTC.
128
+ const d = new Date(iso);
129
+ let h = d.getHours() % 12;
130
+ if (h === 0) h = 12;
131
+ const mm = String(d.getMinutes()).padStart(2, "0");
132
+ const ampm = d.getHours() < 12 ? "am" : "pm";
133
+ assert.ok(out.startsWith(`${h}:${mm}${ampm} `), `local time prefix wrong: ${out}`);
134
+ assert.ok(out.endsWith(` ${d.getDate()}`), `local day suffix wrong: ${out}`);
135
+ });
136
+
137
+ test("#327: fmtCellValue passes through empty, unparseable, and unknown-format values verbatim (never blank/throw)", () => {
138
+ assert.equal(fmtCellValue("datetime", ""), "");
139
+ assert.equal(fmtCellValue("datetime", "not-a-date"), "not-a-date");
140
+ // An unknown format is a no-op — a mis-set column shows its raw value, not blank.
141
+ assert.equal(fmtCellValue("nope", "2026-08-18T23:42:47.788Z"), "2026-08-18T23:42:47.788Z");
142
+ });
143
+
144
+ test("#327: gridCell applies col.format to the cell text, leaving an unformatted column verbatim", (t) => {
145
+ t.after(installFakeDom());
146
+ const iso = "2026-08-18T23:42:47.788Z";
147
+ const formatted = gridCell(
148
+ { field: "updated_at", header: "Updated", format: "datetime" },
149
+ { updated_at: iso },
150
+ "secondary",
151
+ );
152
+ assert.match(
153
+ formatted.textContent,
154
+ /^\d{1,2}:\d{2}(am|pm) [A-Z][a-z]{2} \d{1,2}$/,
155
+ `cell not local-formatted: ${formatted.textContent}`,
156
+ );
157
+ // No `format` → the field value renders exactly as stored (backward compatible).
158
+ const plain = gridCell({ field: "updated_at", header: "Updated" }, { updated_at: iso }, "secondary");
159
+ assert.equal(plain.textContent, iso);
160
+ });
@@ -378,10 +378,12 @@ test("renderer interpolates a column's per-cell template into DOM text (#214)",
378
378
  // resolving to prototype cruft — guard the own-property gate explicitly.
379
379
  assert.match(js, /Object\.prototype\.hasOwnProperty\.call\(row, key\) \? row\[key\] : null/);
380
380
  assert.match(js, /return v == null \? "" : String\(v\);/);
381
- // gridCell keeps the raw single-field value separate (drives the badge gate)
382
- // and lets a string template win for the rendered text.
381
+ // gridCell keeps the raw single-field value separate (drives the badge gate),
382
+ // applies an opt-in col.format to the field text (#327), and lets a string
383
+ // template win for the rendered text over that formatted/raw field value.
383
384
  assert.match(js, /const rawText = row\[col\.field\] == null \? "" : String\(row\[col\.field\]\);/);
384
- assert.match(js, /const text = typeof col\.template === "string" \? interpTemplate\(col\.template, row\) : rawText;/);
385
+ assert.match(js, /const fieldText = col\.format \? fmtCellValue\(col\.format, rawText\) : rawText;/);
386
+ assert.match(js, /const text = typeof col\.template === "string" \? interpTemplate\(col\.template, row\) : fieldText;/);
385
387
  });
386
388
 
387
389
  test("renderer renders a column's subtitle as a muted second line, never an href (#257)", async () => {