@everystack/admin 0.2.0 → 0.2.2

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": "@everystack/admin",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Declarative admin dashboard framework for PostgREST APIs",
5
5
  "license": "AGPL-3.0-only",
6
6
  "publishConfig": {
@@ -15,22 +15,33 @@
15
15
  "types": "./src/index.ts",
16
16
  "default": "./src/index.ts"
17
17
  },
18
+ "./rpc": {
19
+ "types": "./src/rpc/resource-dashboard-rpc.ts",
20
+ "default": "./src/rpc/resource-dashboard-rpc.ts"
21
+ },
18
22
  "./presets/twitter": {
19
23
  "types": "./src/presets/twitter.ts",
20
24
  "default": "./src/presets/twitter.ts"
21
25
  }
22
26
  },
27
+ "scripts": {
28
+ "test": "jest",
29
+ "build": "tsc --build",
30
+ "lint": "tsc --noEmit"
31
+ },
23
32
  "dependencies": {
24
33
  "d3-scale": "4.0.2",
25
34
  "d3-time-format": "4.1.0"
26
35
  },
27
36
  "peerDependencies": {
28
- "@everystack/api": ">=0.1.0",
29
- "@everystack/ui": ">=0.1.0",
37
+ "@everystack/api": ">=0.2.4",
38
+ "@everystack/ui": ">=0.2.2",
30
39
  "react": "19.2.6",
31
40
  "react-native": "0.83.6"
32
41
  },
33
42
  "devDependencies": {
43
+ "@everystack/api": "workspace:*",
44
+ "@everystack/ui": "workspace:*",
34
45
  "@types/d3-scale": "4.0.9",
35
46
  "@types/d3-time-format": "4.0.3",
36
47
  "@types/jest": "29.5.14",
@@ -40,13 +51,6 @@
40
51
  "react": "19.2.6",
41
52
  "react-native": "0.83.6",
42
53
  "ts-jest": "29.4.9",
43
- "typescript": "5.9.3",
44
- "@everystack/api": "0.2.0",
45
- "@everystack/ui": "0.2.0"
46
- },
47
- "scripts": {
48
- "test": "jest",
49
- "build": "tsc --build",
50
- "lint": "tsc --noEmit"
54
+ "typescript": "5.9.3"
51
55
  }
52
- }
56
+ }
@@ -120,6 +120,16 @@ export class EverystackAdapter implements DataAdapter {
120
120
  }
121
121
  }
122
122
 
123
+ async rpc<T = unknown>(fn: string, body?: unknown): Promise<T> {
124
+ const result = await this.client.rpc<T>(fn, body);
125
+
126
+ if (result.error) {
127
+ throw new Error(result.error.message);
128
+ }
129
+
130
+ return result.data as T;
131
+ }
132
+
123
133
  async aggregate(resource: string, params: AggregateParams): Promise<AggregateResult> {
124
134
  const selectParts: string[] = [];
125
135
 
@@ -55,6 +55,12 @@ export interface DataAdapter {
55
55
  */
56
56
  aggregate?(resource: string, params: AggregateParams): Promise<AggregateResult>;
57
57
 
58
+ /**
59
+ * Call a named RPC on the backend (row actions, dashboards).
60
+ * Throws on error, like the other adapter methods.
61
+ */
62
+ rpc?<T = unknown>(fn: string, body?: unknown): Promise<T>;
63
+
58
64
  /**
59
65
  * Subscribe to real-time updates (optional)
60
66
  */
@@ -1,139 +1,7 @@
1
1
  /**
2
- * Brush — Gesture-based selection overlay for chart brushing.
3
- *
4
- * Uses react-native PanResponder for cross-platform gesture tracking
5
- * (no dependency on react-native-gesture-handler). Maps pixel
6
- * coordinates to data domain values via a d3 scale's `.invert()` method.
7
- *
8
- * Render as a sibling overlaid on top of chart content inside a
9
- * position: relative container.
2
+ * Brush — re-exported from @everystack/ui, the single chart vocabulary
3
+ * (docs/plans/admin-observability.md, decision 8).
10
4
  */
11
5
 
12
- import React, { useRef, useState } from 'react';
13
- import { View, PanResponder } from 'react-native';
14
-
15
- export interface BrushProps {
16
- /** d3 scale with `.invert()` for mapping pixels → data domain. */
17
- xScale: { invert: (px: number) => unknown };
18
- /** Chart width in pixels. */
19
- width: number;
20
- /** Chart height in pixels. */
21
- height: number;
22
- /** Called when brush selection changes. null = cleared. */
23
- onBrush: (extent: [unknown, unknown] | null) => void;
24
- /** Called when brush is cleared (double-tap). */
25
- onClear?: () => void;
26
- /** Overlay color for the selection region. */
27
- brushColor?: string;
28
- }
29
-
30
- /**
31
- * Clamp a value between min and max.
32
- */
33
- export function clamp(value: number, min: number, max: number): number {
34
- return Math.max(min, Math.min(max, value));
35
- }
36
-
37
- /**
38
- * Compute brush extent from two pixel positions and a scale.
39
- *
40
- * Returns domain values in [min, max] order regardless of drag direction.
41
- */
42
- export function computeBrushExtent(
43
- startPx: number,
44
- endPx: number,
45
- width: number,
46
- invert: (px: number) => unknown,
47
- ): [unknown, unknown] {
48
- const minPx = clamp(Math.min(startPx, endPx), 0, width);
49
- const maxPx = clamp(Math.max(startPx, endPx), 0, width);
50
- return [invert(minPx), invert(maxPx)];
51
- }
52
-
53
- export function Brush({
54
- xScale,
55
- width,
56
- height,
57
- onBrush,
58
- onClear,
59
- brushColor = 'rgba(99, 102, 241, 0.2)',
60
- }: BrushProps): React.ReactElement {
61
- const [selection, setSelection] = useState<{ left: number; width: number } | null>(null);
62
- const startXRef = useRef(0);
63
- const lastTapRef = useRef(0);
64
-
65
- const panResponder = useRef(
66
- PanResponder.create({
67
- onStartShouldSetPanResponder: () => true,
68
- onMoveShouldSetPanResponder: () => true,
69
-
70
- onPanResponderGrant: (evt) => {
71
- const now = Date.now();
72
- const x = evt.nativeEvent.locationX;
73
-
74
- // Double-tap detection (< 300ms)
75
- if (now - lastTapRef.current < 300) {
76
- setSelection(null);
77
- onBrush(null);
78
- onClear?.();
79
- lastTapRef.current = 0;
80
- return;
81
- }
82
- lastTapRef.current = now;
83
- startXRef.current = x;
84
- setSelection({ left: x, width: 0 });
85
- },
86
-
87
- onPanResponderMove: (evt) => {
88
- const currentX = evt.nativeEvent.locationX;
89
- const startX = startXRef.current;
90
- const left = clamp(Math.min(startX, currentX), 0, width);
91
- const right = clamp(Math.max(startX, currentX), 0, width);
92
- setSelection({ left, width: right - left });
93
- },
94
-
95
- onPanResponderRelease: (evt) => {
96
- const endX = evt.nativeEvent.locationX;
97
- const startX = startXRef.current;
98
-
99
- // Minimum 4px drag to count as a brush (not just a tap)
100
- if (Math.abs(endX - startX) < 4) {
101
- setSelection(null);
102
- return;
103
- }
104
-
105
- const extent = computeBrushExtent(startX, endX, width, xScale.invert.bind(xScale));
106
- onBrush(extent);
107
- },
108
- }),
109
- ).current;
110
-
111
- return (
112
- <View
113
- style={{
114
- position: 'absolute',
115
- top: 0,
116
- left: 0,
117
- width,
118
- height,
119
- }}
120
- {...panResponder.panHandlers}
121
- >
122
- {selection && selection.width > 0 && (
123
- <View
124
- style={{
125
- position: 'absolute',
126
- left: selection.left,
127
- top: 0,
128
- width: selection.width,
129
- height,
130
- backgroundColor: brushColor,
131
- borderLeftWidth: 1,
132
- borderRightWidth: 1,
133
- borderColor: 'rgba(99, 102, 241, 0.5)',
134
- }}
135
- />
136
- )}
137
- </View>
138
- );
139
- }
6
+ export { Brush, clamp, computeBrushExtent } from '@everystack/ui/dashboard';
7
+ export type { BrushProps } from '@everystack/ui/dashboard';
@@ -1,67 +1,7 @@
1
1
  /**
2
- * DimensionBars — Horizontal bar chart for any categorical dimension.
3
- *
4
- * Generalized from PlatformBars. Accepts title, data, colors, and filter state.
5
- * Tapping a bar toggles that value in the parent's filter state.
2
+ * DimensionBars — re-exported from @everystack/ui, the single chart
3
+ * vocabulary (docs/plans/admin-observability.md, decision 8).
6
4
  */
7
5
 
8
- import React from 'react';
9
- import { View, Text, Pressable } from 'react-native';
10
-
11
- export interface DimensionBarsProps {
12
- title: string;
13
- data: { value: string; count: number }[];
14
- colors?: Record<string, string>;
15
- activeFilters?: string[];
16
- onFilter: (value: string) => void;
17
- }
18
-
19
- const DEFAULT_COLOR = '#6366f1';
20
-
21
- export function DimensionBars({ title, data, colors, activeFilters, onFilter }: DimensionBarsProps): React.ReactElement {
22
- const sorted = [...data].sort((a, b) => b.count - a.count);
23
- const maxValue = Math.max(...sorted.map(e => e.count), 1);
24
-
25
- if (sorted.length === 0) {
26
- return (
27
- <View style={{ flex: 1 }}>
28
- <Text style={{ fontSize: 12, fontWeight: '600', color: '#6b7280', marginBottom: 8 }}>
29
- {title}
30
- </Text>
31
- <Text style={{ color: '#9ca3af', fontSize: 12 }}>No data</Text>
32
- </View>
33
- );
34
- }
35
-
36
- return (
37
- <View style={{ flex: 1 }}>
38
- <Text style={{ fontSize: 12, fontWeight: '600', color: '#6b7280', marginBottom: 8 }}>
39
- {title}
40
- </Text>
41
- {sorted.map((entry) => {
42
- const color = colors?.[entry.value] ?? DEFAULT_COLOR;
43
- const widthPercent = (entry.count / maxValue) * 100;
44
- const isActive = activeFilters?.includes(entry.value);
45
-
46
- return (
47
- <Pressable
48
- key={entry.value}
49
- onPress={() => onFilter(entry.value)}
50
- style={{ marginBottom: 6, opacity: activeFilters && !isActive ? 0.4 : 1 }}
51
- >
52
- <View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 2 }}>
53
- <Text style={{ fontSize: 11, color: '#374151', flex: 1 }}>{entry.value}</Text>
54
- <Text style={{ fontSize: 11, color: '#6b7280', fontWeight: '500' }}>{entry.count}</Text>
55
- </View>
56
- <View style={{ height: 12, backgroundColor: '#f1f5f9', borderRadius: 6 }}>
57
- <View style={{
58
- height: 12, width: `${widthPercent}%`, backgroundColor: color,
59
- borderRadius: 6, minWidth: 4,
60
- }} />
61
- </View>
62
- </Pressable>
63
- );
64
- })}
65
- </View>
66
- );
67
- }
6
+ export { DimensionBars } from '@everystack/ui/dashboard';
7
+ export type { DimensionBarsProps } from '@everystack/ui/dashboard';
@@ -1,98 +1,7 @@
1
1
  /**
2
- * DimensionDonut — Stacked bar showing distribution for any categorical dimension.
3
- *
4
- * Generalized from LevelDonut. Accepts title, data, colors, order, and filter state.
5
- * Tapping a segment toggles that value in the parent's filter state.
2
+ * DimensionDonut — re-exported from @everystack/ui, the single chart
3
+ * vocabulary (docs/plans/admin-observability.md, decision 8).
6
4
  */
7
5
 
8
- import React from 'react';
9
- import { View, Text, Pressable } from 'react-native';
10
-
11
- export interface DimensionDonutProps {
12
- title: string;
13
- data: { value: string; count: number }[];
14
- colors?: Record<string, string>;
15
- order?: string[];
16
- activeFilters?: string[];
17
- onFilter: (value: string) => void;
18
- }
19
-
20
- const DEFAULT_COLOR = '#6b7280';
21
-
22
- export function DimensionDonut({ title, data, colors, order, activeFilters, onFilter }: DimensionDonutProps): React.ReactElement {
23
- // Sort by order if provided, else by count
24
- const sorted = order
25
- ? order
26
- .map(val => {
27
- const entry = data.find(e => e.value === val);
28
- return { value: val, count: entry?.count ?? 0 };
29
- })
30
- .filter(e => e.count > 0)
31
- : [...data].sort((a, b) => b.count - a.count);
32
-
33
- const total = sorted.reduce((sum, e) => sum + e.count, 0);
34
-
35
- if (sorted.length === 0) {
36
- return (
37
- <View style={{ flex: 1 }}>
38
- <Text style={{ fontSize: 12, fontWeight: '600', color: '#6b7280', marginBottom: 8 }}>
39
- {title}
40
- </Text>
41
- <Text style={{ color: '#9ca3af', fontSize: 12 }}>No data</Text>
42
- </View>
43
- );
44
- }
45
-
46
- return (
47
- <View style={{ flex: 1 }}>
48
- <Text style={{ fontSize: 12, fontWeight: '600', color: '#6b7280', marginBottom: 8 }}>
49
- {title}
50
- </Text>
51
-
52
- {/* Stacked bar */}
53
- <View style={{
54
- flexDirection: 'row', height: 24, borderRadius: 12, overflow: 'hidden',
55
- marginBottom: 12,
56
- }}>
57
- {sorted.map((entry) => {
58
- const color = colors?.[entry.value] ?? DEFAULT_COLOR;
59
- const widthPercent = (entry.count / total) * 100;
60
- const isActive = activeFilters?.includes(entry.value);
61
- return (
62
- <Pressable
63
- key={entry.value}
64
- onPress={() => onFilter(entry.value)}
65
- style={{
66
- width: `${widthPercent}%`,
67
- backgroundColor: color,
68
- minWidth: 4,
69
- opacity: activeFilters && !isActive ? 0.4 : 1,
70
- }}
71
- />
72
- );
73
- })}
74
- </View>
75
-
76
- {/* Legend */}
77
- <View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 12 }}>
78
- {sorted.map((entry) => {
79
- const color = colors?.[entry.value] ?? DEFAULT_COLOR;
80
- const percent = total > 0 ? ((entry.count / total) * 100).toFixed(1) : '0';
81
- const isActive = activeFilters?.includes(entry.value);
82
- return (
83
- <Pressable
84
- key={entry.value}
85
- onPress={() => onFilter(entry.value)}
86
- style={{ flexDirection: 'row', alignItems: 'center', opacity: activeFilters && !isActive ? 0.4 : 1 }}
87
- >
88
- <View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: color, marginRight: 4 }} />
89
- <Text style={{ fontSize: 11, color: '#374151' }}>
90
- {entry.value} ({percent}%)
91
- </Text>
92
- </Pressable>
93
- );
94
- })}
95
- </View>
96
- </View>
97
- );
98
- }
6
+ export { DimensionDonut } from '@everystack/ui/dashboard';
7
+ export type { DimensionDonutProps } from '@everystack/ui/dashboard';
@@ -1,12 +1,10 @@
1
1
  /**
2
- * StatDials — Config-driven circular dials for dashboard KPIs.
3
- *
4
- * Renders N dials from an array of { label, value, color, ratio }.
5
- * Each dial shows a colored ring proportional to ratio (0-1).
2
+ * StatDials — config-driven KPI dials, rendered by the shared
3
+ * @everystack/ui StatDials (decision 8). Public props unchanged.
6
4
  */
7
5
 
8
6
  import React from 'react';
9
- import { View, Text } from 'react-native';
7
+ import { StatDials as UiStatDials } from '@everystack/ui/dashboard';
10
8
 
11
9
  export interface DialData {
12
10
  label: string;
@@ -20,48 +18,6 @@ export interface StatDialsProps {
20
18
  dials: DialData[];
21
19
  }
22
20
 
23
- function Dial({ label, value, color, ratio }: DialData): React.ReactElement {
24
- const size = 64;
25
- const borderWidth = 4;
26
- const fillPercent = Math.min(1, Math.max(0, ratio ?? 1));
27
-
28
- return (
29
- <View style={{ alignItems: 'center', flex: 1 }}>
30
- <View style={{
31
- width: size, height: size, borderRadius: size / 2,
32
- borderWidth,
33
- borderColor: `${color}20`,
34
- backgroundColor: '#ffffff',
35
- alignItems: 'center', justifyContent: 'center',
36
- }}>
37
- {/* Overlay ring */}
38
- <View style={{
39
- position: 'absolute', top: -borderWidth, left: -borderWidth,
40
- width: size, height: size, borderRadius: size / 2,
41
- borderWidth,
42
- borderLeftColor: fillPercent > 0 ? color : 'transparent',
43
- borderBottomColor: fillPercent >= 0.25 ? color : 'transparent',
44
- borderRightColor: fillPercent >= 0.5 ? color : 'transparent',
45
- borderTopColor: fillPercent >= 0.75 ? color : 'transparent',
46
- transform: [{ rotate: '-90deg' }],
47
- }} />
48
- <Text style={{ color, fontSize: value > 999 ? 14 : 18, fontWeight: '700' }}>
49
- {value.toLocaleString()}
50
- </Text>
51
- </View>
52
- <Text style={{ marginTop: 6, fontSize: 11, color: '#6b7280', fontWeight: '500' }}>
53
- {label}
54
- </Text>
55
- </View>
56
- );
57
- }
58
-
59
21
  export function StatDials({ dials }: StatDialsProps): React.ReactElement {
60
- return (
61
- <View style={{ flexDirection: 'row', gap: 8, paddingVertical: 12 }}>
62
- {dials.map((dial) => (
63
- <Dial key={dial.label} {...dial} />
64
- ))}
65
- </View>
66
- );
22
+ return <UiStatDials dials={dials} />;
67
23
  }
@@ -1,16 +1,10 @@
1
1
  /**
2
- * TimeHistogram — Brushable hourly event histogram.
3
- *
4
- * Receives pre-aggregated time buckets from the server. Brushing
5
- * calls onBrush with [startISO, endISO] to update the parent's
6
- * filter state, which triggers a new server query.
7
- *
8
- * Renders bars as react-native Views (no SVG dependency).
2
+ * TimeHistogram — the resource dashboard's brushable histogram, rendered by
3
+ * the shared @everystack/ui primitive (decision 8). Public props unchanged.
9
4
  */
10
5
 
11
- import React, { useMemo } from 'react';
12
- import { View, Text } from 'react-native';
13
- import { Brush } from './Brush';
6
+ import React from 'react';
7
+ import { TimeHistogram as UiTimeHistogram } from '@everystack/ui/dashboard';
14
8
 
15
9
  export interface TimeHistogramProps {
16
10
  data: { bucket: string; count: number }[];
@@ -20,139 +14,21 @@ export interface TimeHistogramProps {
20
14
  onBrush: (extent: [string, string] | null) => void;
21
15
  }
22
16
 
23
- export function TimeHistogram({ data, width, height = 120, title, onBrush }: TimeHistogramProps): React.ReactElement {
24
- const { bins, maxCount, xScale, barWidth } = useMemo(() => {
25
- if (!data || data.length === 0) {
26
- return { bins: [] as { bucket: string; ts: number; count: number }[], maxCount: 0, xScale: null, barWidth: 0 };
27
- }
28
-
29
- const sorted = data
30
- .map(d => ({ bucket: d.bucket, ts: new Date(d.bucket).getTime(), count: d.count }))
31
- .filter(d => d.count > 0 && !isNaN(d.ts))
32
- .sort((a, b) => a.ts - b.ts);
33
-
34
- if (sorted.length === 0) {
35
- return { bins: [] as { bucket: string; ts: number; count: number }[], maxCount: 0, xScale: null, barWidth: 0 };
36
- }
37
-
38
- const minTime = sorted[0].ts;
39
- const maxTime = sorted[sorted.length - 1].ts;
40
- const mc = Math.max(...sorted.map(e => e.count), 1);
41
-
42
- try {
43
- const { scaleLinear } = require('d3-scale');
44
- const padding = 16;
45
- const chartWidth = width - padding * 2;
46
- const scale = scaleLinear()
47
- .domain([minTime, maxTime + 3_600_000])
48
- .range([padding, padding + chartWidth]);
49
- const bw = Math.max(2, chartWidth / Math.max(sorted.length, 1) - 1);
50
- return { bins: sorted, maxCount: mc, xScale: scale, barWidth: bw };
51
- } catch {
52
- return { bins: sorted, maxCount: mc, xScale: null, barWidth: 0 };
53
- }
54
- }, [data, width]);
55
-
56
- if (bins.length === 0) {
57
- return (
58
- <View style={{ height, justifyContent: 'center', alignItems: 'center' }}>
59
- <Text style={{ color: '#9ca3af', fontSize: 12 }}>No event data</Text>
60
- </View>
61
- );
62
- }
63
-
64
- const barHeight = height - 32;
65
-
66
- const handleBrush = (extent: [unknown, unknown] | null) => {
67
- if (!extent) {
68
- onBrush(null);
69
- return;
70
- }
71
- // Convert epoch ms back to ISO strings for the server
72
- const start = new Date(extent[0] as number).toISOString();
73
- const end = new Date(extent[1] as number).toISOString();
74
- onBrush([start, end]);
75
- };
76
-
77
- // Format time labels
78
- const firstTime = new Date(bins[0].ts);
79
- const lastTime = new Date(bins[bins.length - 1].ts);
80
- const formatTime = (d: Date) => {
81
- try {
82
- const { timeFormat } = require('d3-time-format');
83
- return timeFormat('%b %d %H:%M')(d);
84
- } catch {
85
- return d.toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
86
- }
87
- };
88
-
17
+ export function TimeHistogram({
18
+ data,
19
+ width,
20
+ height = 120,
21
+ title,
22
+ onBrush,
23
+ }: TimeHistogramProps): React.ReactElement {
89
24
  return (
90
- <View style={{ marginTop: 16 }}>
91
- <Text style={{ fontSize: 12, fontWeight: '600', color: '#6b7280', marginBottom: 8 }}>
92
- {title ?? 'Events Over Time'}
93
- </Text>
94
- <View style={{ position: 'relative', height, width }}>
95
- {/* Bars */}
96
- {xScale ? (
97
- <View style={{ position: 'absolute', top: 0, left: 0, width, height: barHeight }}>
98
- {bins.map((bin) => {
99
- const x = xScale(bin.ts);
100
- const h = Math.max(2, (bin.count / maxCount) * barHeight);
101
- return (
102
- <View
103
- key={bin.bucket}
104
- style={{
105
- position: 'absolute',
106
- left: x,
107
- bottom: 0,
108
- width: barWidth,
109
- height: h,
110
- backgroundColor: bin.count > maxCount * 0.7 ? '#ef4444' : '#6366f1',
111
- borderRadius: 2,
112
- }}
113
- />
114
- );
115
- })}
116
- </View>
117
- ) : (
118
- <View style={{
119
- flexDirection: 'row', alignItems: 'flex-end', height: barHeight, gap: 1,
120
- paddingHorizontal: 16,
121
- }}>
122
- {bins.map((bin) => (
123
- <View
124
- key={bin.bucket}
125
- style={{
126
- flex: 1,
127
- height: Math.max(2, (bin.count / maxCount) * barHeight),
128
- backgroundColor: bin.count > maxCount * 0.7 ? '#ef4444' : '#6366f1',
129
- borderRadius: 2,
130
- }}
131
- />
132
- ))}
133
- </View>
134
- )}
135
-
136
- {/* Brush overlay */}
137
- {xScale && (
138
- <Brush
139
- xScale={xScale}
140
- width={width}
141
- height={barHeight}
142
- onBrush={handleBrush}
143
- />
144
- )}
145
-
146
- {/* Time labels */}
147
- <View style={{
148
- flexDirection: 'row', justifyContent: 'space-between',
149
- paddingHorizontal: 16, marginTop: 4,
150
- position: 'absolute', bottom: 0, left: 0, right: 0,
151
- }}>
152
- <Text style={{ fontSize: 10, color: '#9ca3af' }}>{formatTime(firstTime)}</Text>
153
- <Text style={{ fontSize: 10, color: '#9ca3af' }}>{formatTime(lastTime)}</Text>
154
- </View>
155
- </View>
156
- </View>
25
+ <UiTimeHistogram
26
+ data={(data ?? []).map((d) => ({ bucket: d.bucket, counts: { count: d.count } }))}
27
+ width={width}
28
+ height={height}
29
+ title={title ?? 'Events Over Time'}
30
+ emptyText="No event data"
31
+ onBrush={onBrush}
32
+ />
157
33
  );
158
34
  }
package/src/index.ts CHANGED
@@ -30,6 +30,8 @@ export type {
30
30
  StatDialConfig,
31
31
  StatDialValue,
32
32
  DimensionConfig,
33
+ // Row actions
34
+ RowActionConfig,
33
35
  } from './types';
34
36
 
35
37
  // Adapter types
@@ -126,6 +128,9 @@ export { ImageDisplay } from './components/ImageDisplay';
126
128
  export { resolveImageUrl, type ResolveImageUrlOptions } from './utils/resolve-image-url';
127
129
  export { uploadFile, type UploadFileOptions, type UploadFileResult } from './utils/upload-file';
128
130
 
131
+ // Row actions
132
+ export { visibleRowActions } from './utils/row-actions';
133
+
129
134
  // Utilities
130
135
  export {
131
136
  toCamelCase,