Skip to content

Commit 6b7aa86

Browse files
committed
feat(appkit): metric-view hook, formatters, generator const + payload metadata (PR5 1-4)
Implement the four parallel PR5 slices against the frozen phase-0 contracts: - Generator (type-generator): emit metric-views.ts (was .d.ts) carrying both the erasable declare-module MetricRegistry augmentation and a runtime `export const metricViewsMetadata = {...} as const`. Header is a type-only import (no runtime side-effect import on the Node server). Rename propagated through METRIC_TYPES_FILE, mvOutFile, vite-plugin, CLI announce, and tests; generated .ts added to Biome ignore. - Server (analytics plugin): accept an injected `metricViewsMetadata` config and stamp the responding metric's per-column slice (scoped to the requested measures/dimensions) into the SSE result payload. Metadata is response decoration — it never enters composeMetricCacheKey and never alters SQL. - Hook (appkit-ui): `useMetricView(key, opts)` mirroring useAnalyticsQuery (SSE, abort-on-arg-change, autoStart), returning { data, loading, error, errorCode, metadata }. - Formatters (appkit-ui js): pure, React-free, tree-shakeable formatValue / formatLabel / toD3Format taking the format spec / column metadata as args. Also fix a pre-existing latent port collision: analytics.integration.test.ts and server.integration.test.ts both hardcoded port 9879; under the added metric-test weight they could bind concurrently in the shared vitest worker pool, so an analytics request hit the server-plugin app and 404'd. Switch the analytics integration test to an OS-assigned ephemeral port (port: 0), matching the files plugin integration test. Co-authored-by: Isaac Signed-off-by: Atila Fassina <atila@fassina.eu>
1 parent 10e86a5 commit 6b7aa86

24 files changed

Lines changed: 1548 additions & 39 deletions

biome.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@
2020
"!**/*.gen.css",
2121
"!**/*.gen.ts",
2222
"!**/typedoc-sidebar.ts",
23-
"!**/template"
23+
"!**/template",
24+
"!**/metric-views.ts",
25+
"!**/metric-views.d.ts"
2426
]
2527
},
2628
"formatter": {
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { describe, expect, test } from "vitest";
2+
import { formatLabel, formatValue, toD3Format } from "./format";
3+
4+
describe("js/format formatValue", () => {
5+
test("currency spec formats with prefix, grouping and 2 decimals", () => {
6+
expect(formatValue(1234.5, "$#,##0.00")).toBe("$1,234.50");
7+
});
8+
9+
test("currency spec handles negatives with sign before the symbol", () => {
10+
expect(formatValue(-1234.5, "$#,##0.00")).toBe("-$1,234.50");
11+
});
12+
13+
test("integer spec groups thousands with no decimals", () => {
14+
expect(formatValue(1234567, "#,##0")).toBe("1,234,567");
15+
});
16+
17+
test("decimal grouping spec keeps N decimals", () => {
18+
expect(formatValue(1234.5, "#,##0.00")).toBe("1,234.50");
19+
});
20+
21+
test("percent spec multiplies by 100 and appends %", () => {
22+
expect(formatValue(0.1234, "0.0%")).toBe("12.3%");
23+
});
24+
25+
test("integer percent spec has no decimals", () => {
26+
expect(formatValue(0.5, "0%")).toBe("50%");
27+
});
28+
29+
test("accepts numeric strings", () => {
30+
expect(formatValue("1234.5", "$#,##0.00")).toBe("$1,234.50");
31+
});
32+
33+
test("accepts bigint values", () => {
34+
expect(formatValue(1234567n, "#,##0")).toBe("1,234,567");
35+
});
36+
37+
test("no format falls back to toLocaleString for numbers", () => {
38+
expect(formatValue(1234.5)).toBe((1234.5).toLocaleString());
39+
});
40+
41+
test("no format passes through strings", () => {
42+
expect(formatValue("hello")).toBe("hello");
43+
});
44+
45+
test("null and undefined become empty string", () => {
46+
expect(formatValue(null)).toBe("");
47+
expect(formatValue(undefined)).toBe("");
48+
expect(formatValue(null, "$#,##0.00")).toBe("");
49+
});
50+
51+
test("non-numeric value with numeric spec falls back to String()", () => {
52+
expect(formatValue("N/A", "#,##0")).toBe("N/A");
53+
});
54+
});
55+
56+
describe("js/format formatLabel", () => {
57+
test("display_name wins over the raw name", () => {
58+
const meta = { type: "double", display_name: "Avg LTV" };
59+
expect(formatLabel("avg_ltv", meta)).toBe("Avg LTV");
60+
});
61+
62+
test("humanizes snake_case when no display_name", () => {
63+
expect(formatLabel("avg_ltv")).toBe("Avg Ltv");
64+
});
65+
66+
test("humanizes camelCase", () => {
67+
expect(formatLabel("totalSpend")).toBe("Total Spend");
68+
});
69+
70+
test("humanizes ALL_CAPS", () => {
71+
expect(formatLabel("TOTAL_SPEND")).toBe("Total Spend");
72+
});
73+
74+
test("columnMeta without display_name falls back to humanize", () => {
75+
expect(formatLabel("user_name", { type: "string" })).toBe("User Name");
76+
});
77+
});
78+
79+
describe("js/format toD3Format", () => {
80+
test("maps the common numeric specs", () => {
81+
expect(toD3Format("$#,##0.00")).toBe("$,.2f");
82+
expect(toD3Format("#,##0")).toBe(",.0f");
83+
expect(toD3Format("#,##0.00")).toBe(",.2f");
84+
expect(toD3Format("0.0%")).toBe(".1%");
85+
});
86+
87+
test("no spec returns undefined", () => {
88+
expect(toD3Format()).toBeUndefined();
89+
expect(toD3Format("")).toBeUndefined();
90+
});
91+
92+
test("unrecognized specs return undefined", () => {
93+
expect(toD3Format("yyyy-MM-dd")).toBeUndefined();
94+
expect(toD3Format("abc")).toBeUndefined();
95+
});
96+
});
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import type { MetricColumnMeta } from "shared";
2+
3+
// ============================================================================
4+
// Pure Format Utilities
5+
// ============================================================================
6+
// Library-agnostic, tree-shakeable helpers for turning raw metric values and
7+
// column metadata into display strings. These take the UC/YAML format spec (or
8+
// MetricColumnMeta) as ARGUMENTS — no React, no chart-lib coupling, no bundled
9+
// artifact — so they can be used from any surface (tables, tooltips, charts).
10+
11+
/**
12+
* Counts the number of fractional digits declared by a numeric format spec.
13+
* E.g. "#,##0.00" -> 2, "#,##0" -> 0, "0.0%" -> 1.
14+
*/
15+
function countDecimals(format: string): number {
16+
const dotIndex = format.indexOf(".");
17+
if (dotIndex === -1) return 0;
18+
const frac = format.slice(dotIndex + 1);
19+
const match = frac.match(/^[0#]+/);
20+
return match ? match[0].length : 0;
21+
}
22+
23+
/**
24+
* Best-effort coercion of an arbitrary value to a finite number. Handles the
25+
* common wire shapes (number, bigint, numeric string). Returns null when the
26+
* value cannot be meaningfully treated as a number.
27+
*/
28+
function coerceNumber(value: unknown): number | null {
29+
if (typeof value === "number") return Number.isFinite(value) ? value : null;
30+
if (typeof value === "bigint") return Number(value);
31+
if (typeof value === "string") {
32+
if (value.trim() === "") return null;
33+
const n = Number(value);
34+
return Number.isFinite(n) ? n : null;
35+
}
36+
return null;
37+
}
38+
39+
/** Format a number with fixed decimals + optional thousands grouping. */
40+
function formatNumber(
41+
value: number,
42+
decimals: number,
43+
grouping: boolean,
44+
): string {
45+
return value.toLocaleString("en-US", {
46+
minimumFractionDigits: decimals,
47+
maximumFractionDigits: decimals,
48+
useGrouping: grouping,
49+
});
50+
}
51+
52+
/**
53+
* Format a raw value using a UC/YAML printf-style format spec.
54+
*
55+
* Recognizes the common spreadsheet-style specs:
56+
* - currency prefix, e.g. `"$#,##0.00"` (1234.5 -> "$1,234.50")
57+
* - thousands grouping + N decimals, e.g. `"#,##0"` (1234567 -> "1,234,567")
58+
* or `"#,##0.00"` (1234.5 -> "1,234.50")
59+
* - percent, e.g. `"0.0%"` (0.1234 -> "12.3%") — the value is multiplied by 100
60+
*
61+
* No format spec -> sensible default: numbers via `toLocaleString`, everything
62+
* else via `String()`. `null`/`undefined` -> `""`. Unrecognized specs fall back
63+
* to a best-effort result (the number grouped, or `String(value)`).
64+
*/
65+
export function formatValue(value: unknown, format?: string): string {
66+
if (value === null || value === undefined) return "";
67+
68+
if (!format) {
69+
if (typeof value === "number") {
70+
return Number.isFinite(value) ? value.toLocaleString() : String(value);
71+
}
72+
if (typeof value === "bigint") return value.toLocaleString();
73+
return String(value);
74+
}
75+
76+
const num = coerceNumber(value);
77+
// Non-numeric value with a numeric-ish spec: nothing sensible to format.
78+
if (num === null) return String(value);
79+
80+
const isPercent = format.includes("%");
81+
const isCurrency = format.includes("$");
82+
const grouping = format.includes(",");
83+
const decimals = countDecimals(format);
84+
85+
if (isPercent) {
86+
return `${formatNumber(num * 100, decimals, grouping)}%`;
87+
}
88+
89+
if (isCurrency) {
90+
const sign = num < 0 ? "-" : "";
91+
return `${sign}$${formatNumber(Math.abs(num), decimals, grouping)}`;
92+
}
93+
94+
return formatNumber(num, decimals, grouping);
95+
}
96+
97+
/**
98+
* Turns a raw column name into a human-readable label.
99+
* Handles camelCase, snake_case, acronyms, and ALL_CAPS.
100+
* E.g., "totalSpend" -> "Total Spend", "avg_ltv" -> "Avg Ltv".
101+
*/
102+
function humanize(name: string): string {
103+
return (
104+
name
105+
// Handle consecutive uppercase followed by lowercase (e.g., HTTPUrl -> HTTP Url)
106+
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
107+
// Handle lowercase followed by uppercase (e.g., totalSpend -> total Spend)
108+
.replace(/([a-z])([A-Z])/g, "$1 $2")
109+
// Replace underscores with spaces
110+
.replace(/_/g, " ")
111+
// Collapse multiple spaces into one
112+
.replace(/\s+/g, " ")
113+
// Normalize to title case
114+
.toLowerCase()
115+
.replace(/\b\w/g, (l) => l.toUpperCase())
116+
.trim()
117+
);
118+
}
119+
120+
/**
121+
* Human label for a column: prefers `columnMeta.display_name`, else humanizes
122+
* the raw column name (camelCase / snake_case / CAPS -> Title Case).
123+
*/
124+
export function formatLabel(
125+
name: string,
126+
columnMeta?: MetricColumnMeta,
127+
): string {
128+
if (columnMeta?.display_name) return columnMeta.display_name;
129+
return humanize(name);
130+
}
131+
132+
/**
133+
* Maps a UC/spreadsheet-style format spec to a
134+
* [d3-format](https://d3js.org/d3-format) specifier string, for charts that
135+
* consume d3 format strings.
136+
*
137+
* Best-effort mapping for the common specs:
138+
* - `"$#,##0.00"` -> `"$,.2f"`
139+
* - `"#,##0"` -> `",.0f"`
140+
* - `"#,##0.00"` -> `",.2f"`
141+
* - `"0.0%"` -> `".1%"`
142+
*
143+
* No spec, or a spec that is not a recognizable numeric pattern -> `undefined`.
144+
*/
145+
export function toD3Format(format?: string): string | undefined {
146+
if (!format) return undefined;
147+
148+
// Only map specs built purely from numeric-format characters; anything else
149+
// (date patterns, free text, ...) is left unrecognized.
150+
if (format.replace(/[#0,.$%\s]/g, "") !== "") return undefined;
151+
if (!/[0#]/.test(format)) return undefined;
152+
153+
const group = format.includes(",") ? "," : "";
154+
const decimals = countDecimals(format);
155+
156+
if (format.includes("%")) {
157+
return `${group}.${decimals}%`;
158+
}
159+
160+
const prefix = format.includes("$") ? "$" : "";
161+
return `${prefix}${group}.${decimals}f`;
162+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from "./format";

packages/appkit-ui/src/js/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,5 @@ export {
1212
export * from "./arrow";
1313
export * from "./config";
1414
export * from "./constants";
15+
export * from "./format";
1516
export * from "./sse";

0 commit comments

Comments
 (0)