mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
fix(panel): charts get real axes, humanized ticks, and dark-theme tooltips (#611)
Every chart hand-rolled its own k-only formatter (22596k-style ticks, left- clipped y labels), used recharts' default white tooltip (invisible header on the dark theme), and two charts pinned a numeric XAxis interval that collapses short series to a single tick. The agent Token Activity chart had no axes at all and blanked its tooltip date on purpose. One shared formatTokens/formatBucket (lib/format.ts) and one shared themed tooltip style (components/charts/chart-tooltip.tsx) now feed all 10 charts; axis widths/margins sized to the labels; preserveStartEnd tick intervals. Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
@@ -18,6 +18,7 @@ import {
|
||||
useUsageSessions,
|
||||
} from "@/hooks/use-usage";
|
||||
import type { UsagePeriod } from "@/lib/api/usage";
|
||||
import { formatTokens } from "@/lib/format";
|
||||
import { TaskStatus, Team } from "@/types";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
@@ -78,13 +79,6 @@ function humanizeCount(n: number): string {
|
||||
return String(n);
|
||||
}
|
||||
|
||||
/** Format token counts (same as humanizeCount but used for token display). */
|
||||
function fmtTokens(n: number): string {
|
||||
if (n >= 1_000_000) return (n / 1_000_000).toFixed(2) + "M";
|
||||
if (n >= 1_000) return (n / 1_000).toFixed(1) + "K";
|
||||
return String(n);
|
||||
}
|
||||
|
||||
// ─── Shared sub-components ────────────────────────────────────────────────────
|
||||
|
||||
interface MetricCardProps {
|
||||
@@ -493,14 +487,14 @@ function TokenUsageCostsSection() {
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 2xl:grid-cols-6">
|
||||
<SummaryCard
|
||||
title="Tokens Input"
|
||||
value={summary ? fmtTokens(summary.tokens_input) : undefined}
|
||||
value={summary ? formatTokens(summary.tokens_input) : undefined}
|
||||
icon={<Zap className="h-4 w-4 text-yellow-500" />}
|
||||
isLoading={loadingSnap}
|
||||
tip="Prompt/context tokens sent to the model in the selected window"
|
||||
/>
|
||||
<SummaryCard
|
||||
title="Tokens Output"
|
||||
value={summary ? fmtTokens(summary.tokens_output) : undefined}
|
||||
value={summary ? formatTokens(summary.tokens_output) : undefined}
|
||||
icon={<Zap className="h-4 w-4 text-blue-500" />}
|
||||
isLoading={loadingSnap}
|
||||
tip="Tokens generated by the model in response, in the selected window"
|
||||
@@ -531,7 +525,7 @@ function TokenUsageCostsSection() {
|
||||
/>
|
||||
<SummaryCard
|
||||
title="Total Tokens"
|
||||
value={summary ? fmtTokens(summary.total_tokens) : undefined}
|
||||
value={summary ? formatTokens(summary.total_tokens) : undefined}
|
||||
icon={<Activity className="h-4 w-4 text-blue-500" />}
|
||||
isLoading={loadingSnap}
|
||||
tip="Input + output tokens combined in the selected window"
|
||||
@@ -697,8 +691,8 @@ function CacheEfficiencyCard({
|
||||
<div>
|
||||
<div className="text-3xl font-bold">{pct.toFixed(1)}%</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{cacheStats ? fmtTokens(cacheStats.tokens_cache_read) : "—"} cache
|
||||
reads · saved $
|
||||
{cacheStats ? formatTokens(cacheStats.tokens_cache_read) : "—"}{" "}
|
||||
cache reads · saved $
|
||||
{cacheStats?.cost_saved_by_cache_usd.toFixed(4) ?? "—"}
|
||||
</p>
|
||||
<Progress value={pct} className="mt-2" />
|
||||
@@ -892,8 +886,10 @@ const VALID_METRICS_TABS: MetricsTab[] = [
|
||||
|
||||
const METRICS_TAB_HINTS: Record<MetricsTab, string> = {
|
||||
performance: "Task velocity, status counts, agent load, and team health",
|
||||
"token-usage": "Token spend, cost projections, cache efficiency, and per-session detail",
|
||||
delivery: "Cycle time, bottlenecks, and rework rate reconstructed from the audit log",
|
||||
"token-usage":
|
||||
"Token spend, cost projections, cache efficiency, and per-session detail",
|
||||
delivery:
|
||||
"Cycle time, bottlenecks, and rework rate reconstructed from the audit log",
|
||||
scorecards: "Per-agent and per-team delivery scorecards",
|
||||
};
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
AreaChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip as RTooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import {
|
||||
Card,
|
||||
@@ -21,6 +23,8 @@ import { GitBranch, BookOpen } from "lucide-react";
|
||||
import { useUsageTimeSeries } from "@/hooks/use-usage";
|
||||
import { useWorkSessions } from "@/hooks/use-work-sessions";
|
||||
import { useAgentJournalEntries } from "@/hooks/use-journals";
|
||||
import { formatTokens, formatBucket } from "@/lib/format";
|
||||
import { chartTooltipStyle } from "@/components/charts/chart-tooltip";
|
||||
import { WorkSessionStatus, type UsageTimePoint } from "@/types";
|
||||
|
||||
interface AgentActivityPanelProps {
|
||||
@@ -36,11 +40,6 @@ const SESSION_STATUS_LABEL: Record<WorkSessionStatus, string> = {
|
||||
[WorkSessionStatus.ABANDONED]: "Abandoned",
|
||||
};
|
||||
|
||||
function fmtK(n: number): string {
|
||||
if (n >= 1_000) return (n / 1_000).toFixed(0) + "k";
|
||||
return String(n);
|
||||
}
|
||||
|
||||
type TimelineItem = {
|
||||
kind: "session" | "journal";
|
||||
title: string;
|
||||
@@ -49,8 +48,18 @@ type TimelineItem = {
|
||||
};
|
||||
|
||||
function mergeTimeline(
|
||||
sessions: { task_id: string; branch_name: string; status: WorkSessionStatus; started_at: string }[],
|
||||
journals: { title: string; type: string; timestamp: string; task_id: string | null }[],
|
||||
sessions: {
|
||||
task_id: string;
|
||||
branch_name: string;
|
||||
status: WorkSessionStatus;
|
||||
started_at: string;
|
||||
}[],
|
||||
journals: {
|
||||
title: string;
|
||||
type: string;
|
||||
timestamp: string;
|
||||
task_id: string | null;
|
||||
}[],
|
||||
): TimelineItem[] {
|
||||
const items: TimelineItem[] = [
|
||||
...sessions.map((s) => ({
|
||||
@@ -62,12 +71,17 @@ function mergeTimeline(
|
||||
...journals.map((j) => ({
|
||||
kind: "journal" as const,
|
||||
title: j.title,
|
||||
subtitle: j.task_id ? `Task ${j.task_id.slice(0, 8)} · ${j.type}` : j.type,
|
||||
subtitle: j.task_id
|
||||
? `Task ${j.task_id.slice(0, 8)} · ${j.type}`
|
||||
: j.type,
|
||||
timestamp: j.timestamp,
|
||||
})),
|
||||
];
|
||||
return items
|
||||
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(),
|
||||
)
|
||||
.slice(0, 8);
|
||||
}
|
||||
|
||||
@@ -92,7 +106,9 @@ export function AgentActivityPanel({
|
||||
[sessions, journals],
|
||||
);
|
||||
const timelineLoading = sessionsLoading || journalsLoading;
|
||||
const hasTokens = (series ?? []).some((p: UsageTimePoint) => p.total_tokens > 0);
|
||||
const hasTokens = (series ?? []).some(
|
||||
(p: UsageTimePoint) => p.total_tokens > 0,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
@@ -127,13 +143,27 @@ export function AgentActivityPanel({
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<XAxis
|
||||
dataKey="bucket"
|
||||
tickFormatter={formatBucket}
|
||||
tick={{ fontSize: 9 }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tickFormatter={formatTokens}
|
||||
tick={{ fontSize: 9 }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={36}
|
||||
/>
|
||||
<RTooltip
|
||||
{...chartTooltipStyle}
|
||||
formatter={(value) => [
|
||||
fmtK(typeof value === "number" ? value : 0),
|
||||
formatTokens(typeof value === "number" ? value : 0),
|
||||
"Tokens",
|
||||
]}
|
||||
contentStyle={{ fontSize: 12 }}
|
||||
labelFormatter={() => ""}
|
||||
labelFormatter={(label) => formatBucket(String(label))}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
@@ -212,4 +242,4 @@ export function AgentActivityPanel({
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { HelpTip } from "@/components/ui/help-tip";
|
||||
import { formatBucket } from "@/lib/format";
|
||||
import { chartTooltipStyle } from "@/components/charts/chart-tooltip";
|
||||
import type { UsageTimePoint } from "@/types";
|
||||
|
||||
interface SpendTrendChartProps {
|
||||
@@ -19,11 +21,6 @@ interface SpendTrendChartProps {
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
function formatBucket(bucket: string): string {
|
||||
const d = new Date(bucket);
|
||||
return d.getMonth() + 1 + "/" + d.getDate();
|
||||
}
|
||||
|
||||
function fmtCost(n: number): string {
|
||||
return "$" + n.toFixed(2);
|
||||
}
|
||||
@@ -63,7 +60,7 @@ export function SpendTrendChart({ data, isLoading }: SpendTrendChartProps) {
|
||||
<XAxis
|
||||
dataKey="day"
|
||||
tick={{ fontSize: 9 }}
|
||||
interval={4}
|
||||
interval="preserveStartEnd"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
@@ -75,13 +72,17 @@ export function SpendTrendChart({ data, isLoading }: SpendTrendChartProps) {
|
||||
width={44}
|
||||
/>
|
||||
<Tooltip
|
||||
{...chartTooltipStyle}
|
||||
formatter={(value) => [
|
||||
fmtCost(typeof value === "number" ? value : 0),
|
||||
"Spend",
|
||||
]}
|
||||
contentStyle={{ fontSize: 12 }}
|
||||
/>
|
||||
<Bar dataKey="Spend" fill="var(--chart-3)" radius={[3, 3, 0, 0]} />
|
||||
<Bar
|
||||
dataKey="Spend"
|
||||
fill="var(--chart-3)"
|
||||
radius={[3, 3, 0, 0]}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
// Shared recharts <Tooltip> styling — a bare `contentStyle={{fontSize:12}}`
|
||||
// renders recharts' default white content box with the default (also-white)
|
||||
// label text, so on the dark theme the tooltip is a white box with an
|
||||
// invisible date header. Pull both colors from the popover CSS vars instead.
|
||||
export const chartTooltipStyle: {
|
||||
contentStyle: CSSProperties;
|
||||
labelStyle: CSSProperties;
|
||||
} = {
|
||||
contentStyle: {
|
||||
backgroundColor: "var(--popover)",
|
||||
color: "var(--popover-foreground)",
|
||||
border: "1px solid var(--border)",
|
||||
fontSize: 12,
|
||||
},
|
||||
labelStyle: { color: "var(--popover-foreground)" },
|
||||
};
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { HelpTip } from "@/components/ui/help-tip";
|
||||
import { formatBucket } from "@/lib/format";
|
||||
import { chartTooltipStyle } from "@/components/charts/chart-tooltip";
|
||||
import type { UsageTimePoint } from "@/types";
|
||||
|
||||
interface CostTrendChartProps {
|
||||
@@ -19,11 +21,6 @@ interface CostTrendChartProps {
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
function formatBucket(bucket: string): string {
|
||||
const d = new Date(bucket);
|
||||
return d.getMonth() + 1 + "/" + d.getDate();
|
||||
}
|
||||
|
||||
function fmtCost(n: number): string {
|
||||
return "$" + n.toFixed(2);
|
||||
}
|
||||
@@ -88,11 +85,11 @@ export function CostTrendChart({ data, isLoading }: CostTrendChartProps) {
|
||||
width={44}
|
||||
/>
|
||||
<Tooltip
|
||||
{...chartTooltipStyle}
|
||||
formatter={(value) => [
|
||||
fmtCost(typeof value === "number" ? value : 0),
|
||||
"Cost",
|
||||
]}
|
||||
contentStyle={{ fontSize: 12 }}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
|
||||
@@ -15,6 +15,8 @@ import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { SegmentedControl } from "@/components/ui/segmented-control";
|
||||
import { HelpTip } from "@/components/ui/help-tip";
|
||||
import { useIsMobile } from "@/hooks/use-is-mobile";
|
||||
import { formatTokens } from "@/lib/format";
|
||||
import { chartTooltipStyle } from "@/components/charts/chart-tooltip";
|
||||
import type { AgentUsageRow } from "@/types";
|
||||
|
||||
interface AgentUsageChartProps {
|
||||
@@ -22,11 +24,6 @@ interface AgentUsageChartProps {
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
function fmtK(n: number): string {
|
||||
if (n >= 1_000) return (n / 1_000).toFixed(0) + "k";
|
||||
return String(n);
|
||||
}
|
||||
|
||||
const VIEW_OPTIONS = [
|
||||
{ value: "chart", label: "Chart" },
|
||||
{ value: "table", label: "Table" },
|
||||
@@ -101,7 +98,7 @@ export function AgentUsageChart({ data, isLoading }: AgentUsageChartProps) {
|
||||
<ResponsiveContainer width="100%" height={208}>
|
||||
<BarChart
|
||||
data={chartData}
|
||||
margin={{ top: 4, right: 8, left: 0, bottom: 24 }}
|
||||
margin={{ top: 4, right: 8, left: 0, bottom: isMobile ? 40 : 32 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" className="opacity-20" />
|
||||
<XAxis
|
||||
@@ -114,18 +111,18 @@ export function AgentUsageChart({ data, isLoading }: AgentUsageChartProps) {
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tickFormatter={fmtK}
|
||||
tickFormatter={formatTokens}
|
||||
tick={{ fontSize: 10 }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={36}
|
||||
width={46}
|
||||
/>
|
||||
<Tooltip
|
||||
{...chartTooltipStyle}
|
||||
formatter={(value) => [
|
||||
fmtK(typeof value === "number" ? value : 0),
|
||||
formatTokens(typeof value === "number" ? value : 0),
|
||||
"Tokens",
|
||||
]}
|
||||
contentStyle={{ fontSize: 12 }}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="Tokens"
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
useRework,
|
||||
useTeamScorecard,
|
||||
} from "@/hooks/use-observability";
|
||||
import { chartTooltipStyle } from "@/components/charts/chart-tooltip";
|
||||
import type { Scorecard } from "@/types";
|
||||
|
||||
const CELLS = ["backend", "frontend", "ux_ui"] as const;
|
||||
@@ -91,8 +92,8 @@ function CycleTimeCard() {
|
||||
tickFormatter={(v) => v + "h"}
|
||||
/>
|
||||
<Tooltip
|
||||
{...chartTooltipStyle}
|
||||
formatter={(value) => [value + "h", "Avg"]}
|
||||
contentStyle={{ fontSize: 12 }}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="Hours"
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { HelpTip } from "@/components/ui/help-tip";
|
||||
import { useIsMobile } from "@/hooks/use-is-mobile";
|
||||
import { chartTooltipStyle } from "@/components/charts/chart-tooltip";
|
||||
import type { ModelUsageSlice } from "@/types";
|
||||
|
||||
// Design-system chart tokens — resolves to theme-aware palette
|
||||
@@ -71,12 +72,12 @@ export function ModelUsageDonut({ data, isLoading }: ModelUsageDonutProps) {
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
{...chartTooltipStyle}
|
||||
formatter={(value, name) => [
|
||||
(typeof value === "number" ? value : 0).toLocaleString() +
|
||||
" tokens",
|
||||
name,
|
||||
]}
|
||||
contentStyle={{ fontSize: 12 }}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: isMobile ? 9 : 11 }} />
|
||||
</PieChart>
|
||||
|
||||
@@ -21,6 +21,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { HelpTip } from "@/components/ui/help-tip";
|
||||
import { ChevronUp, ChevronDown } from "lucide-react";
|
||||
import { formatTokens } from "@/lib/format";
|
||||
import type { UsageSession } from "@/types";
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
@@ -46,8 +47,16 @@ interface Column {
|
||||
}
|
||||
|
||||
const COLUMNS: Column[] = [
|
||||
{ key: "agent_slug", label: "Agent", tip: "The agent slug that ran this session — click to sort" },
|
||||
{ key: "model", label: "Model", tip: "Claude/Grok model used for this session — click to sort" },
|
||||
{
|
||||
key: "agent_slug",
|
||||
label: "Agent",
|
||||
tip: "The agent slug that ran this session — click to sort",
|
||||
},
|
||||
{
|
||||
key: "model",
|
||||
label: "Model",
|
||||
tip: "Claude/Grok model used for this session — click to sort",
|
||||
},
|
||||
{
|
||||
key: "started_at",
|
||||
label: "Started",
|
||||
@@ -87,11 +96,6 @@ function formatTime(ts: string): string {
|
||||
});
|
||||
}
|
||||
|
||||
function fmtK(n: number): string {
|
||||
if (n >= 1_000) return (n / 1_000).toFixed(1) + "k";
|
||||
return String(n);
|
||||
}
|
||||
|
||||
interface SessionsTableProps {
|
||||
data: UsageSession[] | undefined;
|
||||
isLoading: boolean;
|
||||
@@ -198,16 +202,16 @@ export function SessionsTable({ data, isLoading }: SessionsTableProps) {
|
||||
{formatTime(s.started_at)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{fmtK(s.total_tokens)}
|
||||
{formatTokens(s.total_tokens)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{fmtK(s.tokens_input)}
|
||||
{formatTokens(s.tokens_input)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{fmtK(s.tokens_output)}
|
||||
{formatTokens(s.tokens_output)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{fmtK(s.tokens_cache)}
|
||||
{formatTokens(s.tokens_cache)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
${s.cost.toFixed(4)}
|
||||
@@ -241,16 +245,16 @@ export function SessionsTable({ data, isLoading }: SessionsTableProps) {
|
||||
{formatTime(s.started_at)}
|
||||
</ResponsiveTableCardRow>
|
||||
<ResponsiveTableCardRow label="Total">
|
||||
{fmtK(s.total_tokens)}
|
||||
{formatTokens(s.total_tokens)}
|
||||
</ResponsiveTableCardRow>
|
||||
<ResponsiveTableCardRow label="Input">
|
||||
{fmtK(s.tokens_input)}
|
||||
{formatTokens(s.tokens_input)}
|
||||
</ResponsiveTableCardRow>
|
||||
<ResponsiveTableCardRow label="Output">
|
||||
{fmtK(s.tokens_output)}
|
||||
{formatTokens(s.tokens_output)}
|
||||
</ResponsiveTableCardRow>
|
||||
<ResponsiveTableCardRow label="Cache">
|
||||
{fmtK(s.tokens_cache)}
|
||||
{formatTokens(s.tokens_cache)}
|
||||
</ResponsiveTableCardRow>
|
||||
<ResponsiveTableCardRow label="Cost">
|
||||
${s.cost.toFixed(4)}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { HelpTip } from "@/components/ui/help-tip";
|
||||
import { chartTooltipStyle } from "@/components/charts/chart-tooltip";
|
||||
|
||||
interface TaskStatusSlice {
|
||||
name: string;
|
||||
@@ -73,11 +74,11 @@ export function TaskStatusChart({ slices, isLoading }: TaskStatusChartProps) {
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
{...chartTooltipStyle}
|
||||
formatter={(value, name) => [
|
||||
`${typeof value === "number" ? value : 0} tasks`,
|
||||
name,
|
||||
]}
|
||||
contentStyle={{ fontSize: 12 }}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||
</PieChart>
|
||||
@@ -86,4 +87,4 @@ export function TaskStatusChart({ slices, isLoading }: TaskStatusChartProps) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { SegmentedControl } from "@/components/ui/segmented-control";
|
||||
import { HelpTip } from "@/components/ui/help-tip";
|
||||
import { useIsMobile } from "@/hooks/use-is-mobile";
|
||||
import { formatTokens } from "@/lib/format";
|
||||
import { chartTooltipStyle } from "@/components/charts/chart-tooltip";
|
||||
import type { TeamUsageRow } from "@/types";
|
||||
|
||||
interface TeamUsageChartProps {
|
||||
@@ -22,11 +24,6 @@ interface TeamUsageChartProps {
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
function fmtK(n: number): string {
|
||||
if (n >= 1_000) return (n / 1_000).toFixed(0) + "k";
|
||||
return String(n);
|
||||
}
|
||||
|
||||
const VIEW_OPTIONS = [
|
||||
{ value: "chart", label: "Chart" },
|
||||
{ value: "table", label: "Table" },
|
||||
@@ -104,7 +101,7 @@ export function TeamUsageChart({ data, isLoading }: TeamUsageChartProps) {
|
||||
top: 4,
|
||||
right: 8,
|
||||
left: 0,
|
||||
bottom: isMobile ? 24 : 8,
|
||||
bottom: isMobile ? 40 : 8,
|
||||
}}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" className="opacity-20" />
|
||||
@@ -118,18 +115,18 @@ export function TeamUsageChart({ data, isLoading }: TeamUsageChartProps) {
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tickFormatter={fmtK}
|
||||
tickFormatter={formatTokens}
|
||||
tick={{ fontSize: 10 }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={36}
|
||||
width={46}
|
||||
/>
|
||||
<Tooltip
|
||||
{...chartTooltipStyle}
|
||||
formatter={(value) => [
|
||||
fmtK(typeof value === "number" ? value : 0),
|
||||
formatTokens(typeof value === "number" ? value : 0),
|
||||
"Tokens",
|
||||
]}
|
||||
contentStyle={{ fontSize: 12 }}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="Tokens"
|
||||
|
||||
@@ -14,6 +14,8 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { HelpTip } from "@/components/ui/help-tip";
|
||||
import { useIsMobile } from "@/hooks/use-is-mobile";
|
||||
import { formatTokens, formatBucket } from "@/lib/format";
|
||||
import { chartTooltipStyle } from "@/components/charts/chart-tooltip";
|
||||
import type { UsageTimePoint } from "@/types";
|
||||
|
||||
interface UsageTimeSeriesChartProps {
|
||||
@@ -21,23 +23,6 @@ interface UsageTimeSeriesChartProps {
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
function formatBucket(bucket: string): string {
|
||||
const d = new Date(bucket);
|
||||
// If the bucket has a non-zero time component it is an hourly bucket → show HH:00.
|
||||
// Otherwise it is a daily bucket → show MM/DD.
|
||||
const isHourly =
|
||||
d.getMinutes() === 0 && (d.getHours() !== 0 || bucket.includes("T"));
|
||||
if (isHourly && d.getSeconds() === 0 && !bucket.endsWith("T00:00:00.000Z")) {
|
||||
return d.getHours().toString().padStart(2, "0") + ":00";
|
||||
}
|
||||
return d.getMonth() + 1 + "/" + d.getDate();
|
||||
}
|
||||
|
||||
function fmtK(n: number): string {
|
||||
if (n >= 1_000) return (n / 1_000).toFixed(0) + "k";
|
||||
return String(n);
|
||||
}
|
||||
|
||||
export function UsageTimeSeriesChart({
|
||||
data,
|
||||
isLoading,
|
||||
@@ -99,23 +84,23 @@ export function UsageTimeSeriesChart({
|
||||
<XAxis
|
||||
dataKey="hour"
|
||||
tick={{ fontSize: isMobile ? 9 : 10 }}
|
||||
interval={isMobile ? 5 : 3}
|
||||
interval="preserveStartEnd"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tickFormatter={fmtK}
|
||||
tickFormatter={formatTokens}
|
||||
tick={{ fontSize: 10 }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={36}
|
||||
width={46}
|
||||
/>
|
||||
<Tooltip
|
||||
{...chartTooltipStyle}
|
||||
formatter={(value, name) => [
|
||||
fmtK(typeof value === "number" ? value : 0),
|
||||
formatTokens(typeof value === "number" ? value : 0),
|
||||
name,
|
||||
]}
|
||||
contentStyle={{ fontSize: 12 }}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: isMobile ? 10 : 12 }} />
|
||||
<Area
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { HelpTip } from "@/components/ui/help-tip";
|
||||
import { chartTooltipStyle } from "@/components/charts/chart-tooltip";
|
||||
import type { WorkSessionSummary } from "@/types";
|
||||
|
||||
interface SessionTrendChartProps {
|
||||
@@ -112,13 +113,17 @@ export function SessionTrendChart({
|
||||
width={28}
|
||||
/>
|
||||
<Tooltip
|
||||
{...chartTooltipStyle}
|
||||
formatter={(value) => [
|
||||
typeof value === "number" ? value : 0,
|
||||
"Sessions started",
|
||||
]}
|
||||
contentStyle={{ fontSize: 12 }}
|
||||
/>
|
||||
<Bar dataKey="count" fill="var(--chart-1)" radius={[3, 3, 0, 0]} />
|
||||
<Bar
|
||||
dataKey="count"
|
||||
fill="var(--chart-1)"
|
||||
radius={[3, 3, 0, 0]}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { formatTokens, formatBucket } from "@/lib/format";
|
||||
|
||||
describe("formatTokens", () => {
|
||||
it("renders raw digits below 1000", () => {
|
||||
expect(formatTokens(0)).toBe("0");
|
||||
expect(formatTokens(999)).toBe("999");
|
||||
});
|
||||
|
||||
it("renders K with one decimal at the 1000 boundary", () => {
|
||||
expect(formatTokens(1_000)).toBe("1.0K");
|
||||
expect(formatTokens(22_596)).toBe("22.6K");
|
||||
expect(formatTokens(999_999)).toBe("1000.0K");
|
||||
});
|
||||
|
||||
it("renders M with two decimals at the 1,000,000 boundary", () => {
|
||||
expect(formatTokens(1_000_000)).toBe("1.00M");
|
||||
expect(formatTokens(22_596_000)).toBe("22.60M");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatBucket", () => {
|
||||
it("formats a midnight-UTC daily bucket as MM/DD", () => {
|
||||
// Regression guard: a plain "T00:00:00.000Z" bucket must never be
|
||||
// mistaken for an hourly bucket just because minutes/seconds are 0.
|
||||
expect(formatBucket("2026-07-15T00:00:00.000Z")).toBe(
|
||||
new Date("2026-07-15T00:00:00.000Z").getMonth() +
|
||||
1 +
|
||||
"/" +
|
||||
new Date("2026-07-15T00:00:00.000Z").getDate(),
|
||||
);
|
||||
});
|
||||
|
||||
it("formats a non-midnight hourly bucket as HH:00", () => {
|
||||
const bucket = "2026-07-15T14:00:00.000Z";
|
||||
const expectedHour = new Date(bucket).getHours().toString().padStart(2, "0");
|
||||
expect(formatBucket(bucket)).toBe(`${expectedHour}:00`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
// Shared number/time formatters for usage & metrics charts — was
|
||||
// reimplemented per-chart (inconsistent K/M rounding caused "22596k"-style
|
||||
// ticks); one copy so every chart humanizes the same way.
|
||||
|
||||
/** Format token counts with a K/M suffix — M keeps 2 decimals, K keeps 1. */
|
||||
export function formatTokens(n: number): string {
|
||||
if (n >= 1_000_000) return (n / 1_000_000).toFixed(2) + "M";
|
||||
if (n >= 1_000) return (n / 1_000).toFixed(1) + "K";
|
||||
return String(n);
|
||||
}
|
||||
|
||||
/** Format a usage time-bucket ISO string as "HH:00" (hourly) or "MM/DD" (daily). */
|
||||
export function formatBucket(bucket: string): string {
|
||||
const d = new Date(bucket);
|
||||
// If the bucket has a non-zero time component it is an hourly bucket → show HH:00.
|
||||
// Otherwise it is a daily bucket → show MM/DD.
|
||||
const isHourly =
|
||||
d.getMinutes() === 0 && (d.getHours() !== 0 || bucket.includes("T"));
|
||||
if (isHourly && d.getSeconds() === 0 && !bucket.endsWith("T00:00:00.000Z")) {
|
||||
return d.getHours().toString().padStart(2, "0") + ":00";
|
||||
}
|
||||
return d.getMonth() + 1 + "/" + d.getDate();
|
||||
}
|
||||
Reference in New Issue
Block a user