@agent-native/toolkit 0.8.3 → 0.9.1
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 +42 -3
- package/agent-native.eject.json +33 -0
- package/dist/chat-history/ChatHistoryRail.d.ts +19 -0
- package/dist/chat-history/ChatHistoryRail.d.ts.map +1 -0
- package/dist/chat-history/ChatHistoryRail.js +22 -0
- package/dist/chat-history/ChatHistoryRail.js.map +1 -0
- package/dist/chat-history/ChatHistoryRail.spec.d.ts +2 -0
- package/dist/chat-history/ChatHistoryRail.spec.d.ts.map +1 -0
- package/dist/chat-history/ChatHistoryRail.spec.js +81 -0
- package/dist/chat-history/ChatHistoryRail.spec.js.map +1 -0
- package/dist/chat-history/index.d.ts +1 -0
- package/dist/chat-history/index.d.ts.map +1 -1
- package/dist/chat-history/index.js +1 -0
- package/dist/chat-history/index.js.map +1 -1
- package/dist/chat-history.css +59 -0
- package/dist/composer/RealtimeVoiceMode.d.ts +5 -1
- package/dist/composer/RealtimeVoiceMode.d.ts.map +1 -1
- package/dist/composer/RealtimeVoiceMode.js +27 -8
- package/dist/composer/RealtimeVoiceMode.js.map +1 -1
- package/dist/composer/RealtimeVoiceMode.spec.js +51 -11
- package/dist/composer/RealtimeVoiceMode.spec.js.map +1 -1
- package/dist/composer/VoiceButton.d.ts +2 -0
- package/dist/composer/VoiceButton.d.ts.map +1 -1
- package/dist/composer/VoiceButton.js +33 -1
- package/dist/composer/VoiceButton.js.map +1 -1
- package/dist/composer/VoiceButton.spec.js +9 -1
- package/dist/composer/VoiceButton.spec.js.map +1 -1
- package/dist/composer/realtime-voice-audio-level.d.ts.map +1 -1
- package/dist/composer/realtime-voice-audio-level.js +2 -1
- package/dist/composer/realtime-voice-audio-level.js.map +1 -1
- package/dist/composer/realtime-voice-audio-level.spec.js +7 -0
- package/dist/composer/realtime-voice-audio-level.spec.js.map +1 -1
- package/dist/composer/useRealtimeVoiceMode.d.ts.map +1 -1
- package/dist/composer/useRealtimeVoiceMode.js +4 -4
- package/dist/composer/useRealtimeVoiceMode.js.map +1 -1
- package/dist/dashboard/DataTable.d.ts +20 -0
- package/dist/dashboard/DataTable.d.ts.map +1 -0
- package/dist/dashboard/DataTable.js +87 -0
- package/dist/dashboard/DataTable.js.map +1 -0
- package/dist/dashboard/DateRangePicker.d.ts +15 -0
- package/dist/dashboard/DateRangePicker.d.ts.map +1 -0
- package/dist/dashboard/DateRangePicker.js +15 -0
- package/dist/dashboard/DateRangePicker.js.map +1 -0
- package/dist/dashboard/GenericChartPanel.d.ts +57 -0
- package/dist/dashboard/GenericChartPanel.d.ts.map +1 -0
- package/dist/dashboard/GenericChartPanel.js +119 -0
- package/dist/dashboard/GenericChartPanel.js.map +1 -0
- package/dist/dashboard/GenericChartPanel.spec.d.ts +2 -0
- package/dist/dashboard/GenericChartPanel.spec.d.ts.map +1 -0
- package/dist/dashboard/GenericChartPanel.spec.js +41 -0
- package/dist/dashboard/GenericChartPanel.spec.js.map +1 -0
- package/dist/dashboard/MetricCard.d.ts +14 -0
- package/dist/dashboard/MetricCard.d.ts.map +1 -0
- package/dist/dashboard/MetricCard.js +10 -0
- package/dist/dashboard/MetricCard.js.map +1 -0
- package/dist/dashboard/StatsCard.d.ts +16 -0
- package/dist/dashboard/StatsCard.d.ts.map +1 -0
- package/dist/dashboard/StatsCard.js +8 -0
- package/dist/dashboard/StatsCard.js.map +1 -0
- package/dist/dashboard/dashboard-layout.d.ts +49 -0
- package/dist/dashboard/dashboard-layout.d.ts.map +1 -0
- package/dist/dashboard/dashboard-layout.js +231 -0
- package/dist/dashboard/dashboard-layout.js.map +1 -0
- package/dist/dashboard/dashboard-layout.spec.d.ts +2 -0
- package/dist/dashboard/dashboard-layout.spec.d.ts.map +1 -0
- package/dist/dashboard/dashboard-layout.spec.js +52 -0
- package/dist/dashboard/dashboard-layout.spec.js.map +1 -0
- package/dist/dashboard/index.d.ts +7 -0
- package/dist/dashboard/index.d.ts.map +1 -0
- package/dist/dashboard/index.js +7 -0
- package/dist/dashboard/index.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/styles.css +35 -0
- package/package.json +15 -1
- package/src/chat-history/ChatHistoryRail.spec.tsx +138 -0
- package/src/chat-history/ChatHistoryRail.tsx +89 -0
- package/src/chat-history/index.ts +5 -0
- package/src/chat-history.css +59 -0
- package/src/composer/RealtimeVoiceMode.spec.tsx +116 -11
- package/src/composer/RealtimeVoiceMode.tsx +106 -36
- package/src/composer/VoiceButton.spec.ts +17 -1
- package/src/composer/VoiceButton.tsx +49 -0
- package/src/composer/realtime-voice-audio-level.spec.ts +15 -0
- package/src/composer/realtime-voice-audio-level.ts +2 -1
- package/src/composer/useRealtimeVoiceMode.tsx +5 -3
- package/src/dashboard/DataTable.tsx +273 -0
- package/src/dashboard/DateRangePicker.tsx +57 -0
- package/src/dashboard/GenericChartPanel.spec.tsx +53 -0
- package/src/dashboard/GenericChartPanel.tsx +461 -0
- package/src/dashboard/MetricCard.tsx +54 -0
- package/src/dashboard/StatsCard.tsx +49 -0
- package/src/dashboard/dashboard-layout.spec.ts +79 -0
- package/src/dashboard/dashboard-layout.ts +329 -0
- package/src/dashboard/index.ts +6 -0
- package/src/index.ts +1 -0
- package/src/styles.css +35 -0
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import {
|
|
3
|
+
Area,
|
|
4
|
+
AreaChart,
|
|
5
|
+
Bar,
|
|
6
|
+
BarChart,
|
|
7
|
+
CartesianGrid,
|
|
8
|
+
Cell,
|
|
9
|
+
Legend,
|
|
10
|
+
Line,
|
|
11
|
+
LineChart,
|
|
12
|
+
Pie,
|
|
13
|
+
PieChart,
|
|
14
|
+
ResponsiveContainer,
|
|
15
|
+
Tooltip,
|
|
16
|
+
XAxis,
|
|
17
|
+
YAxis,
|
|
18
|
+
} from "recharts";
|
|
19
|
+
|
|
20
|
+
import { cn } from "../utils.js";
|
|
21
|
+
import { DataTable } from "./DataTable.js";
|
|
22
|
+
|
|
23
|
+
const DEFAULT_COLORS = [
|
|
24
|
+
"hsl(var(--chart-1, 0 0% 20%))",
|
|
25
|
+
"hsl(var(--chart-2, 155 36% 42%))",
|
|
26
|
+
"hsl(var(--chart-3, 32 55% 50%))",
|
|
27
|
+
"hsl(var(--chart-4, 270 25% 55%))",
|
|
28
|
+
"hsl(var(--chart-5, 350 38% 55%))",
|
|
29
|
+
"hsl(var(--chart-6, 190 25% 48%))",
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
export type GenericChartDatum = Record<string, unknown>;
|
|
33
|
+
export type GenericChartType =
|
|
34
|
+
| "metric"
|
|
35
|
+
| "line"
|
|
36
|
+
| "area"
|
|
37
|
+
| "bar"
|
|
38
|
+
| "stacked-bar"
|
|
39
|
+
| "stacked-area"
|
|
40
|
+
| "pie"
|
|
41
|
+
| "donut"
|
|
42
|
+
| "table";
|
|
43
|
+
|
|
44
|
+
export interface GenericChartConfig {
|
|
45
|
+
chartType: GenericChartType;
|
|
46
|
+
xKey?: string;
|
|
47
|
+
yKey?: string;
|
|
48
|
+
yKeys?: string[];
|
|
49
|
+
colors?: string[];
|
|
50
|
+
stacked?: boolean;
|
|
51
|
+
legend?: boolean;
|
|
52
|
+
description?: string;
|
|
53
|
+
columns?: string[];
|
|
54
|
+
limit?: number;
|
|
55
|
+
formatValue?: (value: number) => string;
|
|
56
|
+
formatXAxis?: (value: string) => string;
|
|
57
|
+
formatSeriesName?: (value: string) => string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface GenericChartRenderContext<TData, TConfig> {
|
|
61
|
+
data: TData;
|
|
62
|
+
config: TConfig;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface GenericChartPanelProps<TData, TConfig> {
|
|
66
|
+
data: TData | null | undefined;
|
|
67
|
+
config: TConfig;
|
|
68
|
+
loading?: boolean;
|
|
69
|
+
error?: ReactNode;
|
|
70
|
+
isEmpty?: (data: TData) => boolean;
|
|
71
|
+
/** App-specific rendering takes priority over the portable Recharts renderer. */
|
|
72
|
+
render?: (context: GenericChartRenderContext<TData, TConfig>) => ReactNode;
|
|
73
|
+
/** Opt into Toolkit's source-agnostic Recharts renderer for row data. */
|
|
74
|
+
chart?: GenericChartConfig;
|
|
75
|
+
renderLoading?: (config: TConfig) => ReactNode;
|
|
76
|
+
renderError?: (error: ReactNode, config: TConfig) => ReactNode;
|
|
77
|
+
renderEmpty?: (config: TConfig) => ReactNode;
|
|
78
|
+
emptyMessage?: ReactNode;
|
|
79
|
+
className?: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* A render-only chart state boundary. Data acquisition, query serialization,
|
|
84
|
+
* demos, pivots, and app-specific chart renderers stay with the consuming app.
|
|
85
|
+
*/
|
|
86
|
+
export function GenericChartPanel<TData, TConfig>({
|
|
87
|
+
data,
|
|
88
|
+
config,
|
|
89
|
+
loading = false,
|
|
90
|
+
error,
|
|
91
|
+
isEmpty,
|
|
92
|
+
render,
|
|
93
|
+
chart,
|
|
94
|
+
renderLoading,
|
|
95
|
+
renderError,
|
|
96
|
+
renderEmpty,
|
|
97
|
+
emptyMessage = "No data available.",
|
|
98
|
+
className,
|
|
99
|
+
}: GenericChartPanelProps<TData, TConfig>) {
|
|
100
|
+
if (loading) {
|
|
101
|
+
return (
|
|
102
|
+
<>
|
|
103
|
+
{renderLoading?.(config) ?? (
|
|
104
|
+
<ChartPanelPlaceholder className={className} />
|
|
105
|
+
)}
|
|
106
|
+
</>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (error) {
|
|
111
|
+
return (
|
|
112
|
+
<>
|
|
113
|
+
{renderError?.(error, config) ?? (
|
|
114
|
+
<ChartPanelMessage className={className} tone="error">
|
|
115
|
+
{error}
|
|
116
|
+
</ChartPanelMessage>
|
|
117
|
+
)}
|
|
118
|
+
</>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (data == null || isEmpty?.(data)) {
|
|
123
|
+
return (
|
|
124
|
+
<>
|
|
125
|
+
{renderEmpty?.(config) ?? (
|
|
126
|
+
<ChartPanelMessage className={className}>
|
|
127
|
+
{emptyMessage}
|
|
128
|
+
</ChartPanelMessage>
|
|
129
|
+
)}
|
|
130
|
+
</>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (render) return <>{render({ data, config })}</>;
|
|
135
|
+
if (chart && Array.isArray(data)) {
|
|
136
|
+
return (
|
|
137
|
+
<GenericChartRenderer rows={data as GenericChartDatum[]} config={chart} />
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Derives chart axes from provider-neutral row data when keys are not configured. */
|
|
145
|
+
export function resolveGenericChartKeys(
|
|
146
|
+
rows: GenericChartDatum[],
|
|
147
|
+
config: Pick<GenericChartConfig, "xKey" | "yKey" | "yKeys">,
|
|
148
|
+
): { xKey: string; yKeys: string[] } {
|
|
149
|
+
const columns = Object.keys(rows[0] ?? {});
|
|
150
|
+
const xKey =
|
|
151
|
+
config.xKey && columns.includes(config.xKey)
|
|
152
|
+
? config.xKey
|
|
153
|
+
: (columns[0] ?? "");
|
|
154
|
+
const configured = (
|
|
155
|
+
config.yKeys?.length ? config.yKeys : config.yKey ? [config.yKey] : []
|
|
156
|
+
).filter((key) => columns.includes(key));
|
|
157
|
+
const yKeys = configured.length
|
|
158
|
+
? configured
|
|
159
|
+
: columns.filter((key) => key !== xKey && isNumericLike(rows[0]?.[key]));
|
|
160
|
+
return {
|
|
161
|
+
xKey,
|
|
162
|
+
yKeys: yKeys.length
|
|
163
|
+
? yKeys
|
|
164
|
+
: columns.filter((key) => key !== xKey).slice(0, 1),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function formatGenericChartValue(value: unknown): string {
|
|
169
|
+
if (typeof value === "number") return value.toLocaleString();
|
|
170
|
+
if (
|
|
171
|
+
typeof value === "string" &&
|
|
172
|
+
value.trim() !== "" &&
|
|
173
|
+
Number.isFinite(Number(value))
|
|
174
|
+
) {
|
|
175
|
+
return Number(value).toLocaleString();
|
|
176
|
+
}
|
|
177
|
+
return String(value ?? "-");
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function isNumericLike(value: unknown): boolean {
|
|
181
|
+
return (
|
|
182
|
+
typeof value === "number" ||
|
|
183
|
+
(typeof value === "string" &&
|
|
184
|
+
value.trim() !== "" &&
|
|
185
|
+
Number.isFinite(Number(value)))
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function GenericChartRenderer({
|
|
190
|
+
rows,
|
|
191
|
+
config,
|
|
192
|
+
}: {
|
|
193
|
+
rows: GenericChartDatum[];
|
|
194
|
+
config: GenericChartConfig;
|
|
195
|
+
}) {
|
|
196
|
+
const { xKey, yKeys } = resolveGenericChartKeys(rows, config);
|
|
197
|
+
const colors = config.colors?.length ? config.colors : DEFAULT_COLORS;
|
|
198
|
+
const valueFormatter = config.formatValue ?? formatGenericChartValue;
|
|
199
|
+
const xFormatter = config.formatXAxis ?? ((value: string) => value);
|
|
200
|
+
const seriesFormatter = config.formatSeriesName ?? ((value: string) => value);
|
|
201
|
+
|
|
202
|
+
if (config.chartType === "metric") {
|
|
203
|
+
const valueKey =
|
|
204
|
+
config.yKey && xKey !== config.yKey ? config.yKey : (yKeys[0] ?? xKey);
|
|
205
|
+
const first = rows[0]?.[valueKey];
|
|
206
|
+
const raw =
|
|
207
|
+
rows.length > 1 && rows.every((row) => isNumericLike(row[valueKey]))
|
|
208
|
+
? rows.reduce((sum, row) => sum + Number(row[valueKey]), 0)
|
|
209
|
+
: first;
|
|
210
|
+
const value = isNumericLike(raw)
|
|
211
|
+
? valueFormatter(Number(raw))
|
|
212
|
+
: formatGenericChartValue(raw);
|
|
213
|
+
return (
|
|
214
|
+
<div className="flex min-h-12 flex-1 flex-col items-center justify-center py-2 text-center">
|
|
215
|
+
<div className="text-3xl font-bold">{value}</div>
|
|
216
|
+
{config.description && (
|
|
217
|
+
<p className="mt-1 text-xs text-muted-foreground">
|
|
218
|
+
{config.description}
|
|
219
|
+
</p>
|
|
220
|
+
)}
|
|
221
|
+
</div>
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (config.chartType === "table") {
|
|
226
|
+
return (
|
|
227
|
+
<DataTable data={rows} columns={config.columns} maxRows={config.limit} />
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (!xKey || yKeys.length === 0) {
|
|
232
|
+
return (
|
|
233
|
+
<ChartPanelMessage>
|
|
234
|
+
Chart data needs an x-axis and at least one value column.
|
|
235
|
+
</ChartPanelMessage>
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (config.chartType === "pie" || config.chartType === "donut") {
|
|
240
|
+
const valueKey = yKeys[0];
|
|
241
|
+
return (
|
|
242
|
+
<ChartCanvas>
|
|
243
|
+
<PieChart>
|
|
244
|
+
<Pie
|
|
245
|
+
data={rows}
|
|
246
|
+
dataKey={valueKey}
|
|
247
|
+
nameKey={xKey}
|
|
248
|
+
cx="50%"
|
|
249
|
+
cy="50%"
|
|
250
|
+
innerRadius={config.chartType === "donut" ? 52 : 0}
|
|
251
|
+
outerRadius={80}
|
|
252
|
+
label={({ name, percent }) =>
|
|
253
|
+
`${seriesFormatter(String(name))} ${((percent ?? 0) * 100).toFixed(0)}%`
|
|
254
|
+
}
|
|
255
|
+
labelLine={false}
|
|
256
|
+
>
|
|
257
|
+
{rows.map((_, index) => (
|
|
258
|
+
<Cell key={index} fill={colors[index % colors.length]} />
|
|
259
|
+
))}
|
|
260
|
+
</Pie>
|
|
261
|
+
<Tooltip
|
|
262
|
+
formatter={(value: unknown) =>
|
|
263
|
+
formatTooltipValue(value, valueFormatter)
|
|
264
|
+
}
|
|
265
|
+
/>
|
|
266
|
+
{config.legend !== false && (
|
|
267
|
+
<Legend formatter={(value) => seriesFormatter(String(value))} />
|
|
268
|
+
)}
|
|
269
|
+
</PieChart>
|
|
270
|
+
</ChartCanvas>
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const stacked =
|
|
275
|
+
config.stacked ||
|
|
276
|
+
config.chartType === "stacked-bar" ||
|
|
277
|
+
config.chartType === "stacked-area";
|
|
278
|
+
if (config.chartType === "bar" || config.chartType === "stacked-bar") {
|
|
279
|
+
return (
|
|
280
|
+
<ChartCanvas>
|
|
281
|
+
<BarChart data={rows}>
|
|
282
|
+
<ChartAxes
|
|
283
|
+
xKey={xKey}
|
|
284
|
+
yFormatter={valueFormatter}
|
|
285
|
+
xFormatter={xFormatter}
|
|
286
|
+
/>
|
|
287
|
+
<Tooltip
|
|
288
|
+
formatter={(value: unknown) =>
|
|
289
|
+
formatTooltipValue(value, valueFormatter)
|
|
290
|
+
}
|
|
291
|
+
labelFormatter={(value) => xFormatter(String(value))}
|
|
292
|
+
/>
|
|
293
|
+
{config.legend !== false && (
|
|
294
|
+
<Legend formatter={(value) => seriesFormatter(String(value))} />
|
|
295
|
+
)}
|
|
296
|
+
{yKeys.map((key, index) => (
|
|
297
|
+
<Bar
|
|
298
|
+
key={key}
|
|
299
|
+
dataKey={key}
|
|
300
|
+
name={seriesFormatter(key)}
|
|
301
|
+
fill={colors[index % colors.length]}
|
|
302
|
+
stackId={stacked ? "stack" : undefined}
|
|
303
|
+
radius={stacked && index < yKeys.length - 1 ? 0 : 4}
|
|
304
|
+
/>
|
|
305
|
+
))}
|
|
306
|
+
</BarChart>
|
|
307
|
+
</ChartCanvas>
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const showFill =
|
|
312
|
+
config.chartType === "area" || config.chartType === "stacked-area";
|
|
313
|
+
return (
|
|
314
|
+
<ChartCanvas>
|
|
315
|
+
{showFill ? (
|
|
316
|
+
<AreaChart data={rows}>
|
|
317
|
+
<ChartAxes
|
|
318
|
+
xKey={xKey}
|
|
319
|
+
yFormatter={valueFormatter}
|
|
320
|
+
xFormatter={xFormatter}
|
|
321
|
+
/>
|
|
322
|
+
<Tooltip
|
|
323
|
+
formatter={(value: unknown) =>
|
|
324
|
+
formatTooltipValue(value, valueFormatter)
|
|
325
|
+
}
|
|
326
|
+
labelFormatter={(value) => xFormatter(String(value))}
|
|
327
|
+
/>
|
|
328
|
+
{config.legend !== false && (
|
|
329
|
+
<Legend formatter={(value) => seriesFormatter(String(value))} />
|
|
330
|
+
)}
|
|
331
|
+
{yKeys.map((key, index) => (
|
|
332
|
+
<Area
|
|
333
|
+
key={key}
|
|
334
|
+
type="monotone"
|
|
335
|
+
dataKey={key}
|
|
336
|
+
name={seriesFormatter(key)}
|
|
337
|
+
stroke={colors[index % colors.length]}
|
|
338
|
+
fill={colors[index % colors.length]}
|
|
339
|
+
fillOpacity={stacked || yKeys.length === 1 ? 0.25 : 0}
|
|
340
|
+
stackId={stacked ? "stack" : undefined}
|
|
341
|
+
/>
|
|
342
|
+
))}
|
|
343
|
+
</AreaChart>
|
|
344
|
+
) : (
|
|
345
|
+
<LineChart data={rows}>
|
|
346
|
+
<ChartAxes
|
|
347
|
+
xKey={xKey}
|
|
348
|
+
yFormatter={valueFormatter}
|
|
349
|
+
xFormatter={xFormatter}
|
|
350
|
+
/>
|
|
351
|
+
<Tooltip
|
|
352
|
+
formatter={(value: unknown) =>
|
|
353
|
+
formatTooltipValue(value, valueFormatter)
|
|
354
|
+
}
|
|
355
|
+
labelFormatter={(value) => xFormatter(String(value))}
|
|
356
|
+
/>
|
|
357
|
+
{config.legend !== false && (
|
|
358
|
+
<Legend formatter={(value) => seriesFormatter(String(value))} />
|
|
359
|
+
)}
|
|
360
|
+
{yKeys.map((key, index) => (
|
|
361
|
+
<Line
|
|
362
|
+
key={key}
|
|
363
|
+
type="monotone"
|
|
364
|
+
dataKey={key}
|
|
365
|
+
name={seriesFormatter(key)}
|
|
366
|
+
stroke={colors[index % colors.length]}
|
|
367
|
+
strokeWidth={2}
|
|
368
|
+
dot={false}
|
|
369
|
+
/>
|
|
370
|
+
))}
|
|
371
|
+
</LineChart>
|
|
372
|
+
)}
|
|
373
|
+
</ChartCanvas>
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function ChartCanvas({ children }: { children: ReactNode }) {
|
|
378
|
+
return (
|
|
379
|
+
<div className="h-[250px] w-full">
|
|
380
|
+
<ResponsiveContainer width="100%" height="100%">
|
|
381
|
+
{children}
|
|
382
|
+
</ResponsiveContainer>
|
|
383
|
+
</div>
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function ChartAxes({
|
|
388
|
+
xKey,
|
|
389
|
+
yFormatter,
|
|
390
|
+
xFormatter,
|
|
391
|
+
}: {
|
|
392
|
+
xKey: string;
|
|
393
|
+
yFormatter: (value: number) => string;
|
|
394
|
+
xFormatter: (value: string) => string;
|
|
395
|
+
}) {
|
|
396
|
+
return (
|
|
397
|
+
<>
|
|
398
|
+
<XAxis
|
|
399
|
+
dataKey={xKey}
|
|
400
|
+
stroke="hsl(var(--muted-foreground))"
|
|
401
|
+
fontSize={12}
|
|
402
|
+
tickLine={false}
|
|
403
|
+
axisLine={false}
|
|
404
|
+
tickFormatter={(value) => xFormatter(String(value))}
|
|
405
|
+
/>
|
|
406
|
+
<YAxis
|
|
407
|
+
stroke="hsl(var(--muted-foreground))"
|
|
408
|
+
fontSize={12}
|
|
409
|
+
tickLine={false}
|
|
410
|
+
axisLine={false}
|
|
411
|
+
tickFormatter={(value) => yFormatter(Number(value))}
|
|
412
|
+
/>
|
|
413
|
+
<CartesianGrid
|
|
414
|
+
strokeDasharray="3 3"
|
|
415
|
+
stroke="hsl(var(--border))"
|
|
416
|
+
vertical={false}
|
|
417
|
+
/>
|
|
418
|
+
</>
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function formatTooltipValue(
|
|
423
|
+
value: unknown,
|
|
424
|
+
formatter: (value: number) => string,
|
|
425
|
+
): string {
|
|
426
|
+
const numeric = Number(value);
|
|
427
|
+
return Number.isFinite(numeric) ? formatter(numeric) : String(value ?? "-");
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
export function ChartPanelPlaceholder({ className }: { className?: string }) {
|
|
431
|
+
return (
|
|
432
|
+
<div
|
|
433
|
+
className={cn(
|
|
434
|
+
"min-h-[250px] w-full animate-pulse rounded-md bg-muted",
|
|
435
|
+
className,
|
|
436
|
+
)}
|
|
437
|
+
/>
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function ChartPanelMessage({
|
|
442
|
+
children,
|
|
443
|
+
className,
|
|
444
|
+
tone = "muted",
|
|
445
|
+
}: {
|
|
446
|
+
children: ReactNode;
|
|
447
|
+
className?: string;
|
|
448
|
+
tone?: "error" | "muted";
|
|
449
|
+
}) {
|
|
450
|
+
return (
|
|
451
|
+
<div
|
|
452
|
+
className={cn(
|
|
453
|
+
"flex min-h-[250px] items-center justify-center px-4 py-8 text-center text-sm",
|
|
454
|
+
tone === "error" ? "text-destructive" : "text-muted-foreground",
|
|
455
|
+
className,
|
|
456
|
+
)}
|
|
457
|
+
>
|
|
458
|
+
{children}
|
|
459
|
+
</div>
|
|
460
|
+
);
|
|
461
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { ComponentType } from "react";
|
|
2
|
+
|
|
3
|
+
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card.js";
|
|
4
|
+
import { Skeleton } from "../ui/skeleton.js";
|
|
5
|
+
|
|
6
|
+
export interface MetricCardProps {
|
|
7
|
+
title: string;
|
|
8
|
+
value: string | number | null;
|
|
9
|
+
icon?: ComponentType<{ className?: string }>;
|
|
10
|
+
description?: string;
|
|
11
|
+
loading?: boolean;
|
|
12
|
+
error?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** A compact, data-source-agnostic dashboard metric. */
|
|
16
|
+
export function MetricCard({
|
|
17
|
+
title,
|
|
18
|
+
value,
|
|
19
|
+
icon: Icon,
|
|
20
|
+
description,
|
|
21
|
+
loading,
|
|
22
|
+
error,
|
|
23
|
+
}: MetricCardProps) {
|
|
24
|
+
return (
|
|
25
|
+
<Card className="border-border/50 bg-card">
|
|
26
|
+
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
27
|
+
<CardTitle className="text-sm font-medium text-muted-foreground">
|
|
28
|
+
{title}
|
|
29
|
+
</CardTitle>
|
|
30
|
+
{Icon && <Icon className="h-4 w-4 text-muted-foreground" />}
|
|
31
|
+
</CardHeader>
|
|
32
|
+
<CardContent>
|
|
33
|
+
{loading ? (
|
|
34
|
+
<Skeleton className="h-8 w-24" />
|
|
35
|
+
) : error ? (
|
|
36
|
+
<p className="text-sm text-destructive">{error}</p>
|
|
37
|
+
) : (
|
|
38
|
+
<>
|
|
39
|
+
<div className="text-2xl font-bold">
|
|
40
|
+
{typeof value === "number"
|
|
41
|
+
? value.toLocaleString()
|
|
42
|
+
: (value ?? "-")}
|
|
43
|
+
</div>
|
|
44
|
+
{description && (
|
|
45
|
+
<p className="mt-1 text-xs text-muted-foreground">
|
|
46
|
+
{description}
|
|
47
|
+
</p>
|
|
48
|
+
)}
|
|
49
|
+
</>
|
|
50
|
+
)}
|
|
51
|
+
</CardContent>
|
|
52
|
+
</Card>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { ComponentType } from "react";
|
|
2
|
+
|
|
3
|
+
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card.js";
|
|
4
|
+
import { cn } from "../utils.js";
|
|
5
|
+
|
|
6
|
+
export interface StatsCardProps {
|
|
7
|
+
title: string;
|
|
8
|
+
value: string;
|
|
9
|
+
icon: ComponentType<{ className?: string }>;
|
|
10
|
+
description: string;
|
|
11
|
+
trend?: { value: number; label?: string };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** A metric card with an optional positive/negative percentage trend. */
|
|
15
|
+
export function StatsCard({
|
|
16
|
+
title,
|
|
17
|
+
value,
|
|
18
|
+
icon: Icon,
|
|
19
|
+
description,
|
|
20
|
+
trend,
|
|
21
|
+
}: StatsCardProps) {
|
|
22
|
+
return (
|
|
23
|
+
<Card className="border-border/50 bg-card shadow-sm">
|
|
24
|
+
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
25
|
+
<CardTitle className="text-sm font-medium text-muted-foreground">
|
|
26
|
+
{title}
|
|
27
|
+
</CardTitle>
|
|
28
|
+
<Icon className="h-4 w-4 text-muted-foreground" />
|
|
29
|
+
</CardHeader>
|
|
30
|
+
<CardContent>
|
|
31
|
+
<div className="text-2xl font-bold">{value}</div>
|
|
32
|
+
<p className="mt-1 text-xs text-muted-foreground">
|
|
33
|
+
{trend && (
|
|
34
|
+
<span
|
|
35
|
+
className={cn(
|
|
36
|
+
"mr-1 font-medium",
|
|
37
|
+
trend.value > 0 ? "text-foreground" : "text-destructive",
|
|
38
|
+
)}
|
|
39
|
+
>
|
|
40
|
+
{trend.value > 0 ? "+" : ""}
|
|
41
|
+
{trend.value}%{trend.label ? ` ${trend.label}` : ""}
|
|
42
|
+
</span>
|
|
43
|
+
)}
|
|
44
|
+
{description}
|
|
45
|
+
</p>
|
|
46
|
+
</CardContent>
|
|
47
|
+
</Card>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
buildDashboardPanelGroups,
|
|
5
|
+
clampDashboardColumns,
|
|
6
|
+
columnExpansionForDropSlot,
|
|
7
|
+
movePanelToDropSlot,
|
|
8
|
+
removePanelFromLayout,
|
|
9
|
+
type DashboardLayoutPanel,
|
|
10
|
+
type DashboardDropSlot,
|
|
11
|
+
} from "./dashboard-layout.js";
|
|
12
|
+
|
|
13
|
+
type Panel = DashboardLayoutPanel & { kind?: "section" | "chart" };
|
|
14
|
+
|
|
15
|
+
function panel(id: string, width = 1): Panel {
|
|
16
|
+
return { id, width, kind: "chart" };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const options = { isSection: (item: Panel) => item.kind === "section" };
|
|
20
|
+
|
|
21
|
+
describe("dashboard layout", () => {
|
|
22
|
+
it("groups a section and balances persisted widths per visible row", () => {
|
|
23
|
+
const panels: Panel[] = [
|
|
24
|
+
panel("a"),
|
|
25
|
+
{ id: "section", kind: "section", columns: 3 },
|
|
26
|
+
panel("b"),
|
|
27
|
+
panel("c"),
|
|
28
|
+
panel("d"),
|
|
29
|
+
];
|
|
30
|
+
const groups = buildDashboardPanelGroups(panels, 2, options);
|
|
31
|
+
|
|
32
|
+
expect(
|
|
33
|
+
groups.map((group) =>
|
|
34
|
+
group.rows.map((row) => row.panels.map((item) => item.id)),
|
|
35
|
+
),
|
|
36
|
+
).toEqual([[["a"]], [["b", "c", "d"]]]);
|
|
37
|
+
expect(groups[1].columns).toBe(3);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("removes without backfilling a later row", () => {
|
|
41
|
+
const next = removePanelFromLayout(
|
|
42
|
+
["a", "b", "c", "d", "e"].map((id) => panel(id)),
|
|
43
|
+
"b",
|
|
44
|
+
3,
|
|
45
|
+
options,
|
|
46
|
+
);
|
|
47
|
+
expect(next.map((item) => [item.id, item.width])).toEqual([
|
|
48
|
+
["a", 2],
|
|
49
|
+
["c", 1],
|
|
50
|
+
["d", 2],
|
|
51
|
+
["e", 1],
|
|
52
|
+
]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("moves into a column slot and grows the target group as needed", () => {
|
|
56
|
+
const panels = ["a", "b", "c"].map((id) => panel(id));
|
|
57
|
+
const slot: DashboardDropSlot = {
|
|
58
|
+
type: "column",
|
|
59
|
+
groupKey: "intro",
|
|
60
|
+
rowIndex: 0,
|
|
61
|
+
columnIndex: 1,
|
|
62
|
+
};
|
|
63
|
+
const groups = buildDashboardPanelGroups(panels, 2, options);
|
|
64
|
+
expect(columnExpansionForDropSlot(groups, "c", slot)).toEqual({
|
|
65
|
+
columns: 3,
|
|
66
|
+
sectionPanelId: null,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const next = movePanelToDropSlot(panels, "c", slot, 2, options);
|
|
70
|
+
expect(next.map((item) => item.id)).toEqual(["a", "c", "b"]);
|
|
71
|
+
expect(next.map((item) => item.width)).toEqual([1, 1, 1]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("clamps malformed and out-of-range column counts", () => {
|
|
75
|
+
expect(clampDashboardColumns("4")).toBe(2);
|
|
76
|
+
expect(clampDashboardColumns(-3)).toBe(1);
|
|
77
|
+
expect(clampDashboardColumns(99)).toBe(6);
|
|
78
|
+
});
|
|
79
|
+
});
|