feat(admin): dashboard home, telemetry section, enrollment ops screen
- mission-control dashboard: now strip, tournament quick actions, usage sparklines, error feed, live activity, tools list - /admin/telemetry with functions/events/errors/players/usage child routes, hand-rolled svg chart primitives, decomposed activities table - error browser with grouping and mark-resolved - consolidated per-tournament enrollment screen; hub simplified with run tournament promoted - /admin/activities redirects to /admin/telemetry/functions
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
import { ReactNode } from "react";
|
||||
import { Box, Group, Stack, Text, UnstyledButton } from "@mantine/core";
|
||||
|
||||
export interface BarListItem {
|
||||
label: ReactNode;
|
||||
value: number;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
interface BarListProps {
|
||||
items: BarListItem[];
|
||||
formatValue?: (value: number) => string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
const BarRow = ({
|
||||
item,
|
||||
max,
|
||||
formatValue,
|
||||
color,
|
||||
}: {
|
||||
item: BarListItem;
|
||||
max: number;
|
||||
formatValue: (value: number) => string;
|
||||
color: string;
|
||||
}) => {
|
||||
const content = (
|
||||
<Stack gap={4} w="100%">
|
||||
<Group justify="space-between" wrap="nowrap" gap="md">
|
||||
<Text size="sm" truncate style={{ minWidth: 0 }}>
|
||||
{item.label}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" style={{ fontVariantNumeric: "tabular-nums" }}>
|
||||
{formatValue(item.value)}
|
||||
</Text>
|
||||
</Group>
|
||||
<Box
|
||||
h={8}
|
||||
w={`${max > 0 ? Math.max((item.value / max) * 100, 1) : 0}%`}
|
||||
bg={color}
|
||||
style={{ borderRadius: "0 4px 4px 0" }}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
if (!item.onClick) {
|
||||
return <Box py={6}>{content}</Box>;
|
||||
}
|
||||
|
||||
return (
|
||||
<UnstyledButton w="100%" py={6} onClick={item.onClick}>
|
||||
{content}
|
||||
</UnstyledButton>
|
||||
);
|
||||
};
|
||||
|
||||
export const BarList = ({
|
||||
items,
|
||||
formatValue = (value) => value.toLocaleString(),
|
||||
color = "var(--mantine-color-anchor)",
|
||||
}: BarListProps) => {
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<Text c="dimmed" size="sm">
|
||||
—
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const max = Math.max(...items.map((item) => item.value));
|
||||
|
||||
return (
|
||||
<Stack gap={2}>
|
||||
{items.map((item, index) => (
|
||||
<BarRow
|
||||
key={index}
|
||||
item={item}
|
||||
max={max}
|
||||
formatValue={formatValue}
|
||||
color={color}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default BarList;
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { niceExtent, pointsToPath } from "./chart-path";
|
||||
|
||||
describe("pointsToPath", () => {
|
||||
it("returns an empty string for an empty series", () => {
|
||||
expect(pointsToPath([], 100, 40)).toBe("");
|
||||
});
|
||||
|
||||
it("renders a single point as a horizontal segment without NaN", () => {
|
||||
const path = pointsToPath([5], 100, 40, 2);
|
||||
expect(path).not.toContain("NaN");
|
||||
expect(path).toBe("M2,20 L98,20");
|
||||
});
|
||||
|
||||
it("centers a flat series vertically without NaN", () => {
|
||||
const path = pointsToPath([3, 3, 3], 100, 40);
|
||||
expect(path).not.toContain("NaN");
|
||||
expect(path).toBe("M0,20 L50,20 L100,20");
|
||||
});
|
||||
|
||||
it("maps min to the bottom and max to the top", () => {
|
||||
const path = pointsToPath([0, 10], 100, 40);
|
||||
expect(path).toBe("M0,40 L100,0");
|
||||
});
|
||||
|
||||
it("respects padding", () => {
|
||||
const path = pointsToPath([0, 10], 100, 40, 4);
|
||||
expect(path).toBe("M4,36 L96,4");
|
||||
});
|
||||
|
||||
it("uses a shared extent when provided", () => {
|
||||
const path = pointsToPath([0, 5], 100, 40, 0, [0, 10]);
|
||||
expect(path).toBe("M0,40 L100,20");
|
||||
});
|
||||
});
|
||||
|
||||
describe("niceExtent", () => {
|
||||
it("handles an empty series", () => {
|
||||
expect(niceExtent([])).toEqual([0, 1]);
|
||||
});
|
||||
|
||||
it("handles an all-zero series", () => {
|
||||
expect(niceExtent([0, 0, 0])).toEqual([0, 1]);
|
||||
});
|
||||
|
||||
it("spreads a flat non-zero series", () => {
|
||||
const [lo, hi] = niceExtent([10, 10]);
|
||||
expect(lo).toBeLessThan(10);
|
||||
expect(hi).toBeGreaterThan(10);
|
||||
expect(Number.isNaN(lo)).toBe(false);
|
||||
expect(Number.isNaN(hi)).toBe(false);
|
||||
});
|
||||
|
||||
it("covers the data with rounded bounds", () => {
|
||||
const [lo, hi] = niceExtent([3, 47, 12]);
|
||||
expect(lo).toBeLessThanOrEqual(3);
|
||||
expect(hi).toBeGreaterThanOrEqual(47);
|
||||
});
|
||||
|
||||
it("never dips below zero for non-negative data", () => {
|
||||
const [lo] = niceExtent([1, 2, 3]);
|
||||
expect(lo).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
export function pointsToPath(
|
||||
values: number[],
|
||||
width: number,
|
||||
height: number,
|
||||
pad = 0,
|
||||
extent?: [number, number]
|
||||
): string {
|
||||
if (values.length === 0) return "";
|
||||
|
||||
const [lo, hi] = extent ?? [Math.min(...values), Math.max(...values)];
|
||||
const span = hi - lo;
|
||||
const innerWidth = Math.max(width - pad * 2, 0);
|
||||
const innerHeight = Math.max(height - pad * 2, 0);
|
||||
|
||||
const yFor = (value: number) =>
|
||||
span === 0
|
||||
? pad + innerHeight / 2
|
||||
: pad + innerHeight - ((value - lo) / span) * innerHeight;
|
||||
|
||||
if (values.length === 1) {
|
||||
const y = yFor(values[0]);
|
||||
return `M${pad},${y} L${pad + innerWidth},${y}`;
|
||||
}
|
||||
|
||||
const step = innerWidth / (values.length - 1);
|
||||
return values
|
||||
.map((value, index) => {
|
||||
const command = index === 0 ? "M" : "L";
|
||||
return `${command}${pad + step * index},${yFor(value)}`;
|
||||
})
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
export function niceExtent(values: number[]): [number, number] {
|
||||
if (values.length === 0) return [0, 1];
|
||||
|
||||
let min = Math.min(...values);
|
||||
let max = Math.max(...values);
|
||||
|
||||
if (min === max) {
|
||||
if (min === 0) return [0, 1];
|
||||
const spread = Math.abs(min) / 2;
|
||||
min -= spread;
|
||||
max += spread;
|
||||
}
|
||||
|
||||
const step = niceStep((max - min) / 4);
|
||||
let lo = Math.floor(min / step) * step;
|
||||
const hi = Math.ceil(max / step) * step;
|
||||
if (min >= 0 && lo < 0) lo = 0;
|
||||
return [lo, hi];
|
||||
}
|
||||
|
||||
function niceStep(rough: number): number {
|
||||
const power = 10 ** Math.floor(Math.log10(rough));
|
||||
const fraction = rough / power;
|
||||
if (fraction <= 1) return power;
|
||||
if (fraction <= 2) return 2 * power;
|
||||
if (fraction <= 5) return 5 * power;
|
||||
return 10 * power;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export { pointsToPath, niceExtent } from "./chart-path";
|
||||
export { Sparkline } from "./sparkline";
|
||||
export { BarList } from "./bar-list";
|
||||
export type { BarListItem } from "./bar-list";
|
||||
export { StatTile } from "./stat-tile";
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Text } from "@mantine/core";
|
||||
import { niceExtent, pointsToPath } from "./chart-path";
|
||||
|
||||
const VIEW_WIDTH = 240;
|
||||
const PAD = 3;
|
||||
|
||||
interface SparklineProps {
|
||||
data: number[];
|
||||
series2?: number[];
|
||||
height?: number;
|
||||
showArea?: boolean;
|
||||
label?: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export const Sparkline = ({
|
||||
data,
|
||||
series2,
|
||||
height = 48,
|
||||
showArea = false,
|
||||
label,
|
||||
color = "var(--mantine-color-anchor)",
|
||||
}: SparklineProps) => {
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<Text c="dimmed" size="sm" aria-label={label}>
|
||||
—
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const extent = niceExtent(series2?.length ? [...data, ...series2] : data);
|
||||
const path = pointsToPath(data, VIEW_WIDTH, height, PAD, extent);
|
||||
const secondPath = series2?.length
|
||||
? pointsToPath(series2, VIEW_WIDTH, height, PAD, extent)
|
||||
: null;
|
||||
const areaPath = showArea
|
||||
? `${path} L${VIEW_WIDTH - PAD},${height - PAD} L${PAD},${height - PAD} Z`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${VIEW_WIDTH} ${height}`}
|
||||
preserveAspectRatio="none"
|
||||
width="100%"
|
||||
height={height}
|
||||
role="img"
|
||||
aria-label={label}
|
||||
style={{ display: "block" }}
|
||||
>
|
||||
{areaPath && <path d={areaPath} fill={color} fillOpacity={0.1} stroke="none" />}
|
||||
<path
|
||||
d={path}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
{secondPath && (
|
||||
<path
|
||||
d={secondPath}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
strokeDasharray="4 3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sparkline;
|
||||
@@ -0,0 +1,53 @@
|
||||
import { ReactNode } from "react";
|
||||
import { Box, Card, Group, Stack, Text, UnstyledButton } from "@mantine/core";
|
||||
import Sparkline from "./sparkline";
|
||||
|
||||
interface StatTileProps {
|
||||
label: ReactNode;
|
||||
value: ReactNode;
|
||||
delta?: number;
|
||||
spark?: number[];
|
||||
caption?: ReactNode;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export const StatTile = ({ label, value, delta, spark, caption, onClick }: StatTileProps) => {
|
||||
const content = (
|
||||
<Stack gap={4}>
|
||||
<Text size="xs" c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
<Group gap="xs" align="baseline" wrap="nowrap">
|
||||
<Text fz={24} fw={600} lh={1.2}>
|
||||
{value}
|
||||
</Text>
|
||||
{delta !== undefined && delta !== 0 && (
|
||||
<Text size="xs" c={delta > 0 ? "green" : "red"}>
|
||||
{delta > 0 ? "+" : ""}
|
||||
{delta.toLocaleString()}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
{caption && <Box>{caption}</Box>}
|
||||
{spark && spark.length > 0 && <Sparkline data={spark} height={28} />}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
if (onClick) {
|
||||
return (
|
||||
<UnstyledButton onClick={onClick} w="100%">
|
||||
<Card withBorder radius="md" p="md">
|
||||
{content}
|
||||
</Card>
|
||||
</UnstyledButton>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card withBorder radius="md" p="md">
|
||||
{content}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default StatTile;
|
||||
Reference in New Issue
Block a user