@databob/sdk 0.0.2 → 0.0.3

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/README.md CHANGED
@@ -107,6 +107,33 @@ function TopPages() {
107
107
 
108
108
  `live: true` keeps the result current over server-sent events. Pass `fetchToken` instead of `token` and the provider refreshes expired tokens for you.
109
109
 
110
+ ## Charts
111
+
112
+ Drop-in components for the numbers you show your users. Plain SVG and HTML, no chart library, no extra dependency. They inherit your font and text color, so they fit light and dark themes as they are.
113
+
114
+ ```tsx
115
+ import { Chart, Funnel, Stat, TopList } from "@databob/sdk/react";
116
+ import { api } from "@/databob/_generated/api";
117
+
118
+ <Stat metric={api.revenue} value="revenue" format="currency:USD" compare label="Revenue" />
119
+ <Chart metric={api.pageviews} args={{ range: "30d" }} x="$day" y="views" compare live title="Pageviews" />
120
+ <TopList metric={api.topPages} label="path" value="views" limit={5} title="Top pages" />
121
+ <Funnel metric={api.activation} labels={{ signup: "Signed up", purchase: "Bought" }} />
122
+ ```
123
+
124
+ | Component | Shows | Key props |
125
+ | --- | --- | --- |
126
+ | `Chart` | A series over time | `x`, `y` (one column or several), `type` `"area"` \| `"line"` \| `"bar"`, `compare` draws the previous period dashed |
127
+ | `TopList` | Ranked rows with proportional bars | `label`, `value`, `limit`, `renderLabel` (for links) |
128
+ | `Funnel` | Users at each step, conversion, drop-off | `labels` |
129
+ | `Stat` | One number, with the change from the previous period | `value` (rows are summed), `compare`, `lowerIsBetter` |
130
+
131
+ Props are typed from the metric: `y` and `value` must be number columns the metric returns, and a typo fails to compile. Every component takes `live`, `timeZone`, `format` (`"number"`, `"compact"`, `"percent"`, `"duration"`, `"currency:EUR"`, or a function), `title`, `className`, and `style`.
132
+
133
+ Theme with CSS variables on any ancestor: `--databob-accent`, `--databob-series-2` to `-5`, `--databob-muted`, `--databob-grid`, `--databob-track`, `--databob-positive`, `--databob-negative`, `--databob-tooltip-bg`, `--databob-tooltip-fg`.
134
+
135
+ Each component has a `View` twin (`ChartView`, `TopListView`, `FunnelView`, `StatView`) that takes data instead of a metric, for server rendering or data you fetched yourself with `databob.metric()`.
136
+
110
137
  ## Privacy rules
111
138
 
112
139
  databob refuses personal data at the door instead of cleaning it up later.
@@ -0,0 +1,76 @@
1
+ import { type CSSProperties, type ReactNode } from "react";
2
+ import type { AggregateResult, Cell, FunnelStep } from "@databob/engine";
3
+ import type { MetricRef, RefArgs, RefResult } from "./api.js";
4
+ export type Format = "number" | "compact" | "percent" | "duration" | `currency:${string}` | ((value: number) => string);
5
+ export declare function formatValue(value: number | null | undefined, format?: Format): string;
6
+ export declare function formatBucket(label: Cell): string;
7
+ export declare function niceMax(value: number): number;
8
+ type Row = Record<string, Cell>;
9
+ type RowOf<R> = RefResult<R> extends AggregateResult<infer T> ? T : never;
10
+ type NumericKey<T> = {
11
+ [K in keyof T]: T[K] extends number ? K : never;
12
+ }[keyof T] & string;
13
+ type AnyKey<T> = keyof T & string;
14
+ type Shared = {
15
+ title?: ReactNode;
16
+ className?: string;
17
+ style?: CSSProperties;
18
+ format?: Format;
19
+ };
20
+ export type ChartViewProps = Shared & {
21
+ rows: Row[];
22
+ previous?: Row[] | undefined;
23
+ x: string;
24
+ y: string | string[];
25
+ type?: "line" | "area" | "bar";
26
+ height?: number;
27
+ width?: number;
28
+ labels?: Record<string, string>;
29
+ };
30
+ export declare function ChartView({ rows, previous, x, y, type, height, width, labels, format, title, className, style, }: ChartViewProps): import("react").JSX.Element;
31
+ export type TopListViewProps = Shared & {
32
+ rows: Row[];
33
+ label: string;
34
+ value: string;
35
+ limit?: number;
36
+ renderLabel?: (label: Cell, row: Row) => ReactNode;
37
+ };
38
+ export declare function TopListView({ rows, label, value, limit, format, renderLabel, title, className, style }: TopListViewProps): import("react").JSX.Element;
39
+ export type FunnelViewProps = Omit<Shared, "format"> & {
40
+ steps: FunnelStep[];
41
+ labels?: Record<string, string>;
42
+ };
43
+ export declare function FunnelView({ steps, labels, title, className, style }: FunnelViewProps): import("react").JSX.Element;
44
+ export type StatViewProps = Shared & {
45
+ value: number | null;
46
+ previous?: number | null | undefined;
47
+ label?: ReactNode;
48
+ lowerIsBetter?: boolean;
49
+ };
50
+ export declare function StatView({ value, previous, label, lowerIsBetter, format, title, className, style }: StatViewProps): import("react").JSX.Element;
51
+ type Bound<R extends MetricRef> = {
52
+ metric: R;
53
+ args?: RefArgs<R>;
54
+ live?: boolean;
55
+ timeZone?: string;
56
+ };
57
+ export type ChartProps<R extends MetricRef> = Bound<R> & Omit<ChartViewProps, "rows" | "previous" | "x" | "y"> & {
58
+ x: AnyKey<RowOf<R>>;
59
+ y: NumericKey<RowOf<R>> | NumericKey<RowOf<R>>[];
60
+ compare?: boolean;
61
+ };
62
+ export declare function Chart<R extends MetricRef>({ metric, args, live, timeZone, compare, ...view }: ChartProps<R>): import("react").JSX.Element;
63
+ export type TopListProps<R extends MetricRef> = Bound<R> & Omit<TopListViewProps, "rows" | "label" | "value" | "renderLabel"> & {
64
+ label: AnyKey<RowOf<R>>;
65
+ value: NumericKey<RowOf<R>>;
66
+ renderLabel?: (label: Cell, row: RowOf<R>) => ReactNode;
67
+ };
68
+ export declare function TopList<R extends MetricRef>({ metric, args, live, timeZone, renderLabel, ...view }: TopListProps<R>): import("react").JSX.Element;
69
+ export type FunnelProps<R extends MetricRef> = Bound<R> & Omit<FunnelViewProps, "steps">;
70
+ export declare function Funnel<R extends MetricRef>({ metric, args, live, timeZone, ...view }: FunnelProps<R>): import("react").JSX.Element;
71
+ export type StatProps<R extends MetricRef> = Bound<R> & Omit<StatViewProps, "value" | "previous"> & {
72
+ value: NumericKey<RowOf<R>>;
73
+ compare?: boolean;
74
+ };
75
+ export declare function Stat<R extends MetricRef>({ metric, args, live, timeZone, compare, value, ...view }: StatProps<R>): import("react").JSX.Element;
76
+ export {};
package/dist/charts.js ADDED
@@ -0,0 +1,286 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ // Charts for the analytics you hand to your users. No chart library: plain SVG
4
+ // and HTML, themed with CSS variables, inheriting the page's font and colors.
5
+ //
6
+ // <Chart metric={api.pageviews} args={{ range: "30d" }} x="$day" y="views" compare live />
7
+ // <TopList metric={api.topPages} label="path" value="views" />
8
+ // <Funnel metric={api.activation} />
9
+ // <Stat metric={api.revenue} value="revenue" format="currency:USD" compare />
10
+ //
11
+ // Theme with --databob-accent, --databob-series-2 … -5, --databob-muted,
12
+ // --databob-grid, --databob-track, --databob-positive, --databob-negative.
13
+ // Each component also has a View twin (ChartView, TopListView, FunnelView,
14
+ // StatView) that takes data you already have, for server rendering.
15
+ import { useEffect, useMemo, useRef, useState } from "react";
16
+ import { useMetric } from "./react.js";
17
+ const formatters = new Map();
18
+ function numberFormat(key, options) {
19
+ let format = formatters.get(key);
20
+ if (!format) {
21
+ format = new Intl.NumberFormat(undefined, options);
22
+ formatters.set(key, format);
23
+ }
24
+ return format;
25
+ }
26
+ export function formatValue(value, format = "number") {
27
+ if (value === null || value === undefined || Number.isNaN(value))
28
+ return "–";
29
+ if (typeof format === "function")
30
+ return format(value);
31
+ if (format === "compact")
32
+ return numberFormat("compact", { notation: "compact", maximumFractionDigits: 1 }).format(value);
33
+ if (format === "percent")
34
+ return numberFormat("percent", { style: "percent", maximumFractionDigits: 1 }).format(value);
35
+ if (format === "duration") {
36
+ const seconds = Math.round(value / 1000);
37
+ if (seconds < 60)
38
+ return `${seconds}s`;
39
+ const minutes = Math.floor(seconds / 60);
40
+ if (minutes < 60)
41
+ return `${minutes}m ${seconds % 60}s`;
42
+ return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
43
+ }
44
+ if (format.startsWith("currency:")) {
45
+ const currency = format.slice("currency:".length) || "USD";
46
+ return numberFormat(`currency:${currency}`, { style: "currency", currency, maximumFractionDigits: 2 }).format(value);
47
+ }
48
+ return numberFormat("number", { maximumFractionDigits: 2 }).format(value);
49
+ }
50
+ const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
51
+ // Time bucket labels from the engine ($hour, $day, $week, $month) read better short.
52
+ export function formatBucket(label) {
53
+ if (typeof label !== "string")
54
+ return label === null ? "–" : String(label);
55
+ let match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):00$/.exec(label);
56
+ if (match) {
57
+ const hour = Number(match[4]);
58
+ return `${MONTHS[Number(match[2]) - 1]} ${Number(match[3])}, ${hour % 12 === 0 ? 12 : hour % 12}${hour < 12 ? "am" : "pm"}`;
59
+ }
60
+ match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(label);
61
+ if (match)
62
+ return `${MONTHS[Number(match[2]) - 1]} ${Number(match[3])}`;
63
+ match = /^(\d{4})-(\d{2})$/.exec(label);
64
+ if (match)
65
+ return `${MONTHS[Number(match[2]) - 1]} ${match[1]}`;
66
+ return label;
67
+ }
68
+ // One decimal is plenty for SVG coordinates and keeps the markup small.
69
+ function r1(value) {
70
+ return Math.round(value * 10) / 10;
71
+ }
72
+ export function niceMax(value) {
73
+ if (value <= 0)
74
+ return 1;
75
+ const power = 10 ** Math.floor(Math.log10(value));
76
+ for (const step of [1, 1.2, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10]) {
77
+ if (step * power >= value)
78
+ return step * power;
79
+ }
80
+ return 10 * power;
81
+ }
82
+ // ---------- theme ----------
83
+ const SERIES = [
84
+ "var(--databob-accent, #2f80ed)",
85
+ "var(--databob-series-2, #f2994a)",
86
+ "var(--databob-series-3, #27ae60)",
87
+ "var(--databob-series-4, #9b51e0)",
88
+ "var(--databob-series-5, #eb5757)",
89
+ ];
90
+ const MUTED = "var(--databob-muted, color-mix(in srgb, currentColor 55%, transparent))";
91
+ const GRID = "var(--databob-grid, color-mix(in srgb, currentColor 12%, transparent))";
92
+ const TRACK = "var(--databob-track, color-mix(in srgb, currentColor 7%, transparent))";
93
+ const POSITIVE = "var(--databob-positive, #1e9e5a)";
94
+ const NEGATIVE = "var(--databob-negative, #d64545)";
95
+ const frame = { fontFamily: "inherit", color: "inherit", fontSize: 13, lineHeight: 1.4, minWidth: 0 };
96
+ const titleStyle = { fontWeight: 600, marginBottom: 8 };
97
+ const mutedText = { color: MUTED, fontSize: 12 };
98
+ function Frame({ title, className, style, children, kind, }) {
99
+ return (_jsxs("div", { className: className, style: { ...frame, ...style }, "data-databob": kind, children: [title ? _jsx("div", { style: titleStyle, children: title }) : null, children] }));
100
+ }
101
+ function Placeholder({ height, text }) {
102
+ return (_jsx("div", { style: {
103
+ height,
104
+ borderRadius: 6,
105
+ background: TRACK,
106
+ display: "flex",
107
+ alignItems: "center",
108
+ justifyContent: "center",
109
+ ...mutedText,
110
+ }, role: text ? "status" : undefined, children: text }));
111
+ }
112
+ function useWidth(fixed) {
113
+ const ref = useRef(null);
114
+ const [measured, setMeasured] = useState(0);
115
+ useEffect(() => {
116
+ if (fixed)
117
+ return;
118
+ const element = ref.current;
119
+ if (!element)
120
+ return;
121
+ const update = () => setMeasured(element.clientWidth);
122
+ update();
123
+ if (typeof ResizeObserver === "undefined")
124
+ return;
125
+ const observer = new ResizeObserver(update);
126
+ observer.observe(element);
127
+ return () => observer.disconnect();
128
+ }, [fixed]);
129
+ return [ref, fixed ?? (measured || 600)];
130
+ }
131
+ function num(value) {
132
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
133
+ }
134
+ export function ChartView({ rows, previous, x, y, type = "area", height = 220, width, labels = {}, format = "number", title, className, style, }) {
135
+ const [ref, measured] = useWidth(width);
136
+ const [hover, setHover] = useState(null);
137
+ const series = Array.isArray(y) ? y : [y];
138
+ const compareKey = series[0];
139
+ const showPrevious = previous !== undefined && previous.length > 0;
140
+ const padding = { top: 8, right: 8, bottom: 22, left: 44 };
141
+ const innerWidth = Math.max(10, measured - padding.left - padding.right);
142
+ const innerHeight = Math.max(10, height - padding.top - padding.bottom);
143
+ const count = rows.length;
144
+ const values = rows.flatMap((row) => series.map((key) => num(row[key])));
145
+ const previousValues = showPrevious ? previous.map((row) => num(row[compareKey])) : [];
146
+ const top = niceMax(Math.max(0, ...values, ...previousValues));
147
+ const xAt = (index) => type === "bar"
148
+ ? padding.left + (innerWidth / Math.max(1, count)) * (index + 0.5)
149
+ : padding.left + (count <= 1 ? innerWidth / 2 : (innerWidth / (count - 1)) * index);
150
+ const yAt = (value) => padding.top + innerHeight - (value / top) * innerHeight;
151
+ const path = (points) => points.map((value, index) => `${index === 0 ? "M" : "L"}${xAt(index).toFixed(1)},${yAt(value).toFixed(1)}`).join("");
152
+ const last = rows[count - 1];
153
+ const summary = count === 0
154
+ ? "No data"
155
+ : `${series.map((key) => labels[key] ?? key).join(", ")} by ${x}, ${count} points. Latest ${formatBucket(last?.[x] ?? null)}: ${series
156
+ .map((key) => formatValue(num(last?.[key]), format))
157
+ .join(", ")}.`;
158
+ const ticks = [0, top / 2, top];
159
+ const xTicks = count <= 1 ? [0] : [0, Math.floor((count - 1) / 2), count - 1];
160
+ const barWidth = type === "bar" ? Math.max(1, (innerWidth / Math.max(1, count)) * 0.7 / series.length) : 0;
161
+ function onMove(event) {
162
+ if (count === 0)
163
+ return;
164
+ const box = event.currentTarget.getBoundingClientRect();
165
+ const scale = measured / Math.max(1, box.width);
166
+ const px = (event.clientX - box.left) * scale;
167
+ let best = 0;
168
+ for (let index = 1; index < count; index += 1) {
169
+ if (Math.abs(xAt(index) - px) < Math.abs(xAt(best) - px))
170
+ best = index;
171
+ }
172
+ setHover(best);
173
+ }
174
+ return (_jsxs(Frame, { title: title, className: className, style: style, kind: "chart", children: [_jsxs("div", { ref: ref, style: { position: "relative", width: width ?? "100%" }, children: [count === 0 ? (_jsx(Placeholder, { height: height, text: "No data yet" })) : (_jsxs("svg", { width: "100%", height: height, viewBox: `0 0 ${measured} ${height}`, role: "img", "aria-label": summary, onPointerMove: onMove, onPointerLeave: () => setHover(null), style: { display: "block", overflow: "visible", touchAction: "pan-y" }, children: [ticks.map((tick) => (_jsxs("g", { children: [_jsx("line", { x1: padding.left, x2: r1(padding.left + innerWidth), y1: r1(yAt(tick)), y2: r1(yAt(tick)), stroke: GRID }), _jsx("text", { x: padding.left - 6, y: r1(yAt(tick)), textAnchor: "end", dominantBaseline: "middle", fontSize: 11, fill: MUTED, children: formatValue(tick, format === "number" ? "compact" : format) })] }, tick))), xTicks.map((index) => (_jsx("text", { x: r1(xAt(index)), y: height - 6, textAnchor: count > 1 && index === 0 ? "start" : count > 1 && index === count - 1 ? "end" : "middle", fontSize: 11, fill: MUTED, children: formatBucket(rows[index]?.[x] ?? null) }, index))), showPrevious && type !== "bar" ? (_jsx("path", { d: path(previousValues.slice(0, Math.max(count, 1))), fill: "none", stroke: MUTED, strokeWidth: 1.5, strokeDasharray: "4 4" })) : null, series.map((key, seriesIndex) => {
175
+ const color = SERIES[seriesIndex % SERIES.length];
176
+ const points = rows.map((row) => num(row[key]));
177
+ if (type === "bar") {
178
+ return (_jsx("g", { fill: color, children: points.map((value, index) => (_jsx("rect", { x: r1(xAt(index) - (barWidth * series.length) / 2 + barWidth * seriesIndex), y: r1(yAt(value)), width: r1(barWidth), height: r1(Math.max(0, padding.top + innerHeight - yAt(value))), rx: r1(Math.min(3, barWidth / 3)), opacity: hover === null || hover === index ? 1 : 0.55 }, index))) }, key));
179
+ }
180
+ const line = path(points);
181
+ return (_jsxs("g", { children: [type === "area" ? (_jsx("path", { d: `${line}L${xAt(count - 1).toFixed(1)},${yAt(0)}L${xAt(0).toFixed(1)},${yAt(0)}Z`, fill: color, opacity: 0.12 })) : null, _jsx("path", { d: line, fill: "none", stroke: color, strokeWidth: 2, strokeLinejoin: "round", strokeLinecap: "round" })] }, key));
182
+ }), hover !== null && type !== "bar" ? (_jsxs("g", { children: [_jsx("line", { x1: r1(xAt(hover)), x2: r1(xAt(hover)), y1: padding.top, y2: r1(padding.top + innerHeight), stroke: GRID }), series.map((key, seriesIndex) => (_jsx("circle", { cx: r1(xAt(hover)), cy: r1(yAt(num(rows[hover]?.[key]))), r: 3.5, fill: SERIES[seriesIndex % SERIES.length] }, key)))] })) : null] })), hover !== null && rows[hover] ? (_jsxs("div", { role: "tooltip", style: {
183
+ position: "absolute",
184
+ top: 0,
185
+ left: `${Math.min(Math.max(xAt(hover) / measured, 0.12), 0.88) * 100}%`,
186
+ transform: "translateX(-50%)",
187
+ pointerEvents: "none",
188
+ background: "var(--databob-tooltip-bg, Canvas)",
189
+ color: "var(--databob-tooltip-fg, CanvasText)",
190
+ border: `1px solid ${GRID}`,
191
+ borderRadius: 6,
192
+ padding: "4px 8px",
193
+ fontSize: 12,
194
+ whiteSpace: "nowrap",
195
+ boxShadow: "0 2px 8px rgb(0 0 0 / 0.12)",
196
+ }, children: [_jsx("div", { style: mutedText, children: formatBucket(rows[hover][x] ?? null) }), series.map((key, seriesIndex) => (_jsxs("div", { style: { display: "flex", gap: 6, alignItems: "center" }, children: [_jsx("span", { style: { width: 8, height: 8, borderRadius: 2, background: SERIES[seriesIndex % SERIES.length] } }), _jsx("span", { children: labels[key] ?? key }), _jsx("strong", { style: { marginLeft: "auto", paddingLeft: 8 }, children: formatValue(num(rows[hover][key]), format) })] }, key))), showPrevious && previous[hover] ? (_jsxs("div", { style: mutedText, children: ["Previous: ", formatValue(num(previous[hover][compareKey]), format)] })) : null] })) : null] }), series.length > 1 || showPrevious ? (_jsxs("div", { style: { display: "flex", gap: 12, flexWrap: "wrap", marginTop: 6, ...mutedText }, children: [series.map((key, seriesIndex) => (_jsxs("span", { style: { display: "inline-flex", gap: 6, alignItems: "center" }, children: [_jsx("span", { style: { width: 10, height: 3, borderRadius: 2, background: SERIES[seriesIndex % SERIES.length] } }), labels[key] ?? key] }, key))), showPrevious ? (_jsxs("span", { style: { display: "inline-flex", gap: 6, alignItems: "center" }, children: [_jsx("span", { style: { width: 10, borderTop: `2px dashed ${MUTED}` } }), "Previous period"] })) : null] })) : null] }));
197
+ }
198
+ export function TopListView({ rows, label, value, limit = 10, format = "number", renderLabel, title, className, style }) {
199
+ const shown = rows.slice(0, limit);
200
+ const max = Math.max(0, ...shown.map((row) => num(row[value])));
201
+ return (_jsx(Frame, { title: title, className: className, style: style, kind: "top-list", children: shown.length === 0 ? (_jsx(Placeholder, { height: 80, text: "No data yet" })) : (_jsx("ol", { style: { listStyle: "none", margin: 0, padding: 0, display: "grid", gap: 4 }, children: shown.map((row, index) => {
202
+ const amount = num(row[value]);
203
+ return (_jsxs("li", { style: { position: "relative", display: "flex", alignItems: "center", gap: 12, padding: "5px 8px" }, children: [_jsx("span", { "aria-hidden": "true", style: {
204
+ position: "absolute",
205
+ inset: 0,
206
+ width: `${max === 0 ? 0 : r1((amount / max) * 100)}%`,
207
+ background: "color-mix(in srgb, var(--databob-accent, #2f80ed) 16%, transparent)",
208
+ borderRadius: 4,
209
+ } }), _jsx("span", { style: { position: "relative", minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: renderLabel ? renderLabel(row[label] ?? null, row) : String(row[label] ?? "–") }), _jsx("span", { style: { position: "relative", marginLeft: "auto", fontVariantNumeric: "tabular-nums", fontWeight: 600 }, children: formatValue(amount, format) })] }, index));
210
+ }) })) }));
211
+ }
212
+ export function FunnelView({ steps, labels = {}, title, className, style }) {
213
+ const first = steps[0]?.users ?? 0;
214
+ return (_jsx(Frame, { title: title, className: className, style: style, kind: "funnel", children: steps.length === 0 || first === 0 ? (_jsx(Placeholder, { height: 80, text: "No one has started this funnel yet" })) : (_jsx("ol", { style: { listStyle: "none", margin: 0, padding: 0, display: "grid", gap: 10 }, children: steps.map((step, index) => (_jsxs("li", { children: [_jsxs("div", { style: { display: "flex", gap: 8, alignItems: "baseline" }, children: [_jsx("span", { style: { fontWeight: 600 }, children: labels[step.event] ?? step.event }), _jsx("span", { style: { marginLeft: "auto", fontVariantNumeric: "tabular-nums" }, children: formatValue(step.users, "number") }), _jsx("span", { style: { ...mutedText, width: 52, textAlign: "right" }, children: formatValue(step.conversion, "percent") })] }), _jsx("div", { style: { height: 10, borderRadius: 5, background: TRACK, marginTop: 4, overflow: "hidden" }, children: _jsx("div", { style: {
215
+ height: "100%",
216
+ width: `${r1(step.conversion * 100)}%`,
217
+ background: "var(--databob-accent, #2f80ed)",
218
+ borderRadius: 5,
219
+ } }) }), index > 0 && step.dropoff > 0 ? (_jsxs("div", { style: { ...mutedText, marginTop: 2 }, children: [formatValue(step.dropoff, "number"), " dropped off"] })) : null] }, `${step.event}-${index}`))) })) }));
220
+ }
221
+ export function StatView({ value, previous, label, lowerIsBetter = false, format = "number", title, className, style }) {
222
+ let delta = null;
223
+ if (previous !== undefined && previous !== null && value !== null) {
224
+ const change = previous === 0 ? (value === 0 ? 0 : null) : (value - previous) / Math.abs(previous);
225
+ if (change === null) {
226
+ delta = _jsx("span", { style: mutedText, children: "new" });
227
+ }
228
+ else {
229
+ const good = change === 0 ? null : (change > 0) !== lowerIsBetter;
230
+ delta = (_jsxs("span", { style: {
231
+ fontSize: 12,
232
+ fontWeight: 600,
233
+ color: good === null ? MUTED : good ? POSITIVE : NEGATIVE,
234
+ fontVariantNumeric: "tabular-nums",
235
+ }, "aria-label": `${change >= 0 ? "up" : "down"} ${formatValue(Math.abs(change), "percent")} from the previous period`, children: [change > 0 ? "▲" : change < 0 ? "▼" : "•", " ", formatValue(Math.abs(change), "percent")] }));
236
+ }
237
+ }
238
+ return (_jsxs(Frame, { title: title, className: className, style: style, kind: "stat", children: [label ? _jsx("div", { style: mutedText, children: label }) : null, _jsxs("div", { style: { display: "flex", alignItems: "baseline", gap: 8, flexWrap: "wrap" }, children: [_jsx("span", { style: { fontSize: 28, fontWeight: 650, letterSpacing: "-0.01em", fontVariantNumeric: "tabular-nums" }, children: formatValue(value, format) }), delta] })] }));
239
+ }
240
+ function metricOptions(props, compare) {
241
+ const options = {};
242
+ if (props.live)
243
+ options.live = true;
244
+ if (props.timeZone)
245
+ options.timeZone = props.timeZone;
246
+ if (compare)
247
+ options.compare = "previous";
248
+ return options;
249
+ }
250
+ function State({ error, height }) {
251
+ return _jsx(Placeholder, { height: height, text: error ? `Could not load: ${error.message}` : undefined });
252
+ }
253
+ export function Chart({ metric, args, live, timeZone, compare = false, ...view }) {
254
+ const { data, error } = useMetric(metric, args, metricOptions({ live, timeZone }, compare));
255
+ const result = data;
256
+ if (!result)
257
+ return _jsx(Frame, { title: view.title, className: view.className, style: view.style, kind: "chart", children: _jsx(State, { error: error, height: view.height ?? 220 }) });
258
+ return _jsx(ChartView, { ...view, rows: result.rows, previous: result.previous?.rows });
259
+ }
260
+ export function TopList({ metric, args, live, timeZone, renderLabel, ...view }) {
261
+ const { data, error } = useMetric(metric, args, metricOptions({ live, timeZone }, false));
262
+ const result = data;
263
+ if (!result)
264
+ return _jsx(Frame, { title: view.title, className: view.className, style: view.style, kind: "top-list", children: _jsx(State, { error: error, height: 120 }) });
265
+ return (_jsx(TopListView, { ...view, rows: result.rows, ...(renderLabel ? { renderLabel: renderLabel } : {}) }));
266
+ }
267
+ export function Funnel({ metric, args, live, timeZone, ...view }) {
268
+ const { data, error } = useMetric(metric, args, metricOptions({ live, timeZone }, false));
269
+ const result = data;
270
+ if (!result)
271
+ return _jsx(Frame, { title: view.title, className: view.className, style: view.style, kind: "funnel", children: _jsx(State, { error: error, height: 120 }) });
272
+ return _jsx(FunnelView, { ...view, steps: result.steps });
273
+ }
274
+ export function Stat({ metric, args, live, timeZone, compare = false, value, ...view }) {
275
+ const { data, error } = useMetric(metric, args, metricOptions({ live, timeZone }, compare));
276
+ const result = data;
277
+ const totals = useMemo(() => {
278
+ if (!result)
279
+ return null;
280
+ const sum = (rows) => rows.reduce((total, row) => total + num(row[value]), 0);
281
+ return { current: sum(result.rows), previous: result.previous ? sum(result.previous.rows) : undefined };
282
+ }, [result, value]);
283
+ if (!totals)
284
+ return _jsx(Frame, { title: view.title, className: view.className, style: view.style, kind: "stat", children: _jsx(State, { error: error, height: 56 }) });
285
+ return _jsx(StatView, { ...view, value: totals.current, previous: totals.previous });
286
+ }
package/dist/react.d.ts CHANGED
@@ -24,4 +24,4 @@ export declare class MetricError extends Error {
24
24
  constructor(code: string, message: string);
25
25
  }
26
26
  export declare function useMetric<R extends MetricRef>(ref: R, args?: RefArgs<R>, options?: MetricOptions): MetricState<RefResult<R>>;
27
- export {};
27
+ export { Chart, ChartView, Funnel, FunnelView, Stat, StatView, TopList, TopListView, formatBucket, formatValue, type ChartProps, type ChartViewProps, type Format, type FunnelProps, type FunnelViewProps, type StatProps, type StatViewProps, type TopListProps, type TopListViewProps, } from "./charts.js";
package/dist/react.js CHANGED
@@ -131,3 +131,4 @@ export function useMetric(ref, args, options = {}) {
131
131
  }, [key, context, options.skip]);
132
132
  return state;
133
133
  }
134
+ export { Chart, ChartView, Funnel, FunnelView, Stat, StatView, TopList, TopListView, formatBucket, formatValue, } from "./charts.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@databob/sdk",
3
- "version": "0.0.2",
3
+ "version": "0.0.3",
4
4
  "description": "Privacy-first product analytics you define in code. Typed events, named metrics, per-tenant data, and live React hooks.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",