@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 +16 -12
- package/src/adapters/EverystackAdapter.ts +10 -0
- package/src/adapters/types.ts +6 -0
- package/src/components/dashboard/Brush.tsx +4 -136
- package/src/components/dashboard/DimensionBars.tsx +4 -64
- package/src/components/dashboard/DimensionDonut.tsx +4 -95
- package/src/components/dashboard/StatDials.tsx +4 -48
- package/src/components/dashboard/TimeHistogram.tsx +19 -143
- package/src/index.ts +5 -0
- package/src/rpc/resource-dashboard-rpc.ts +56 -2
- package/src/screens/ResourceDashboard.tsx +14 -8
- package/src/screens/ResourceList.tsx +6 -0
- package/src/screens/ResourceShow.tsx +90 -2
- package/src/types.ts +34 -0
- package/src/utils/format-cell-value.ts +30 -0
- package/src/utils/row-actions.ts +21 -0
- package/LICENSE +0 -681
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/admin",
|
|
3
|
-
"version": "0.2.
|
|
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.
|
|
29
|
-
"@everystack/ui": ">=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
|
|
package/src/adapters/types.ts
CHANGED
|
@@ -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 —
|
|
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
|
-
|
|
13
|
-
|
|
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 —
|
|
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
|
-
|
|
9
|
-
|
|
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 —
|
|
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
|
-
|
|
9
|
-
|
|
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 —
|
|
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 {
|
|
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 —
|
|
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
|
|
12
|
-
import {
|
|
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({
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
<
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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,
|