diff --git a/src/components/claude/daily-by-user-chart.tsx b/src/components/claude/daily-by-user-chart.tsx index 852b776..849821a 100644 --- a/src/components/claude/daily-by-user-chart.tsx +++ b/src/components/claude/daily-by-user-chart.tsx @@ -1,11 +1,13 @@ "use client"; import { useMemo } from "react"; -import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Legend } from "recharts"; +import { BarChart, Bar, XAxis, YAxis, CartesianGrid } from "recharts"; import { ChartContainer, ChartTooltip, ChartTooltipContent, + ChartLegend, + ChartLegendContent, } from "@/components/ui/chart"; import type { ChartConfig } from "@/components/ui/chart"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; @@ -86,7 +88,7 @@ export function DailyByUserChart({ data }: { data: DailyByUserResult }) { } return row; }), - [data.days, stackedSeries] + [data.days, stackedSeries], ); const periodTotal = data.topUsers.reduce((s, u) => s + u.totalCents, 0); @@ -102,7 +104,9 @@ export function DailyByUserChart({ data }: { data: DailyByUserResult }) { {periodTotal > 0 && ( <> {" · "} - {formatCurrency(periodTotal)}{" "} + + {formatCurrency(periodTotal)} + {" "} this period )} @@ -147,7 +151,10 @@ export function DailyByUserChart({ data }: { data: DailyByUserResult }) { /> } /> - + } + /> {stackedSeries.map((s) => ( } /> - + } /> {stackedSeries.map((s) => ( 's fill and ignores per- overrides, so the red would otherwise +// never appear in the legend. Append an explicit "Over budget" swatch (only +// when a breach exists) so the red bars are accounted for. +function PlanVsActualLegend({ + showOverBudget, + ...props +}: ComponentProps & { showOverBudget: boolean }) { + return ( +
+ + {showOverBudget && ( +
+
+ Over budget +
+ )} +
+ ); +} + +export function PlanVsActualChart({ + periods, + forecast, +}: PlanVsActualChartProps) { const today = new Date(); const projection = buildProjectionLookup(forecast); @@ -58,6 +88,12 @@ export function PlanVsActualChart({ periods, forecast }: PlanVsActualChartProps) ? periods.reduce((s, p) => s + p.plannedAmountCents, 0) / periods.length : 0; + // Mirror the per-Cell red condition below so the legend only explains the + // alert color when at least one month actually breaches its plan. + const anyOverBudget = data.some( + (d) => d.planned > 0 && d.billed + d.running > d.planned, + ); + return ( @@ -103,8 +139,14 @@ export function PlanVsActualChart({ periods, forecast }: PlanVsActualChartProps) /> } /> - } /> - + } + /> + } - ) -} + ); +}; type ChartContextProps = { - config: ChartConfig -} + config: ChartConfig; +}; -const ChartContext = React.createContext(null) +const ChartContext = React.createContext(null); function useChart() { - const context = React.useContext(ChartContext) + const context = React.useContext(ChartContext); if (!context) { - throw new Error("useChart must be used within a ") + throw new Error("useChart must be used within a "); } - return context + return context; } function ChartContainer({ @@ -42,13 +42,13 @@ function ChartContainer({ config, ...props }: React.ComponentProps<"div"> & { - config: ChartConfig + config: ChartConfig; children: React.ComponentProps< typeof RechartsPrimitive.ResponsiveContainer - >["children"] + >["children"]; }) { - const uniqueId = React.useId() - const chartId = `chart-${id || uniqueId.replace(/:/g, "")}` + const uniqueId = React.useId(); + const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`; return ( @@ -57,7 +57,7 @@ function ChartContainer({ data-chart={chartId} className={cn( "flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-axis-tick_text]:font-mono [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden", - className + className, )} {...props} > @@ -67,16 +67,16 @@ function ChartContainer({
- ) + ); } const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { const colorConfig = Object.entries(config).filter( - ([, config]) => config.theme || config.color - ) + ([, config]) => config.theme || config.color, + ); if (!colorConfig.length) { - return null + return null; } return ( @@ -90,45 +90,45 @@ ${colorConfig .map(([key, itemConfig]) => { const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || - itemConfig.color - return color ? ` --color-${key}: ${color};` : null + itemConfig.color; + return color ? ` --color-${key}: ${color};` : null; }) .join("\n")} } -` +`, ) .join("\n"), }} /> - ) -} + ); +}; -const ChartTooltip = RechartsPrimitive.Tooltip +const ChartTooltip = RechartsPrimitive.Tooltip; type TooltipPayloadItem = { - value?: number | string - name?: string - dataKey?: string | number - color?: string - payload?: Record & { fill?: string } - type?: string -} + value?: number | string; + name?: string; + dataKey?: string | number; + color?: string; + payload?: Record & { fill?: string }; + type?: string; +}; type ValueFormatter = ( value: number | string, item: TooltipPayloadItem, -) => React.ReactNode +) => React.ReactNode; type SecondaryFormatter = ( value: number | string, item: TooltipPayloadItem, total: number, -) => React.ReactNode +) => React.ReactNode; type FooterRow = { - label: React.ReactNode - value: React.ReactNode -} + label: React.ReactNode; + value: React.ReactNode; +}; type LegacyFormatter = ( v: unknown, @@ -136,7 +136,7 @@ type LegacyFormatter = ( it: unknown, i: number, p: unknown, -) => React.ReactNode +) => React.ReactNode; function ChartTooltipContent({ active, @@ -161,54 +161,54 @@ function ChartTooltipContent({ footer, }: React.ComponentProps & React.ComponentProps<"div"> & { - hideLabel?: boolean - hideIndicator?: boolean - indicator?: "line" | "dot" | "dashed" - nameKey?: string - labelKey?: string + hideLabel?: boolean; + hideIndicator?: boolean; + indicator?: "line" | "dot" | "dashed"; + nameKey?: string; + labelKey?: string; /** Format a value while keeping the color swatch + name row layout. */ - valueFormatter?: ValueFormatter + valueFormatter?: ValueFormatter; /** Built-in numeric format preset. Used when valueFormatter isn't provided. */ - numberFormat?: ChartNumberFormat + numberFormat?: ChartNumberFormat; /** Show stack total as the first row. */ - showTotal?: boolean + showTotal?: boolean; /** Label for the total row. Default "Total". */ - totalLabel?: React.ReactNode + totalLabel?: React.ReactNode; /** Secondary line under each row (e.g. "28% of total"). */ - secondaryFormatter?: SecondaryFormatter + secondaryFormatter?: SecondaryFormatter; /** Sort rows in the tooltip. Default: keep payload order. */ - sort?: "desc" | "asc" | "none" + sort?: "desc" | "asc" | "none"; /** Footer row appended after series rows (e.g. "Budget cap $1,500"). */ - footer?: FooterRow | null + footer?: FooterRow | null; }) { - const { config } = useChart() + const { config } = useChart(); const tooltipLabel = React.useMemo(() => { if (hideLabel || !payload?.length) { - return null + return null; } - const [item] = payload - const key = `${labelKey || item?.dataKey || item?.name || "value"}` - const itemConfig = getPayloadConfigFromPayload(config, item, key) + const [item] = payload; + const key = `${labelKey || item?.dataKey || item?.name || "value"}`; + const itemConfig = getPayloadConfigFromPayload(config, item, key); const value = !labelKey && typeof label === "string" ? config[label as keyof typeof config]?.label || label - : itemConfig?.label + : itemConfig?.label; if (labelFormatter) { return (
{labelFormatter(value, payload)}
- ) + ); } if (!value) { - return null + return null; } - return
{value}
+ return
{value}
; }, [ label, labelFormatter, @@ -217,46 +217,46 @@ function ChartTooltipContent({ labelClassName, config, labelKey, - ]) + ]); if (!active || !payload?.length) { - return null + return null; } const visiblePayload = (payload as TooltipPayloadItem[]).filter( (item) => item.type !== "none", - ) + ); - const needsTotal = showTotal || Boolean(secondaryFormatter) + const needsTotal = showTotal || Boolean(secondaryFormatter); const numericTotal = needsTotal ? visiblePayload.reduce((acc, item) => { const n = - typeof item.value === "number" ? item.value : Number(item.value) - return Number.isFinite(n) ? acc + n : acc + typeof item.value === "number" ? item.value : Number(item.value); + return Number.isFinite(n) ? acc + n : acc; }, 0) - : 0 + : 0; const sortedPayload = sort && sort !== "none" ? [...visiblePayload].sort((a, b) => { - const av = Number(a.value) - const bv = Number(b.value) - const aFinite = Number.isFinite(av) - const bFinite = Number.isFinite(bv) - if (!aFinite && !bFinite) return 0 - if (!aFinite) return 1 - if (!bFinite) return -1 - return sort === "desc" ? bv - av : av - bv + const av = Number(a.value); + const bv = Number(b.value); + const aFinite = Number.isFinite(av); + const bFinite = Number.isFinite(bv); + if (!aFinite && !bFinite) return 0; + if (!aFinite) return 1; + if (!bFinite) return -1; + return sort === "desc" ? bv - av : av - bv; }) - : visiblePayload + : visiblePayload; const renderValue = (item: TooltipPayloadItem): React.ReactNode => { - const v = item.value - if (v === undefined || v === null) return "—" - if (valueFormatter) return valueFormatter(v, item) - if (numberFormat) return formatChartValue(v, numberFormat) - return typeof v === "number" ? v.toLocaleString() : String(v) - } + const v = item.value; + if (v === undefined || v === null) return "—"; + if (valueFormatter) return valueFormatter(v, item); + if (numberFormat) return formatChartValue(v, numberFormat); + return typeof v === "number" ? v.toLocaleString() : String(v); + }; const totalNode: React.ReactNode = valueFormatter ? valueFormatter(numericTotal, { @@ -266,18 +266,18 @@ function ChartTooltipContent({ }) : numberFormat ? formatChartValue(numericTotal, numberFormat) - : numericTotal.toLocaleString() + : numericTotal.toLocaleString(); - const nestLabel = sortedPayload.length === 1 && indicator !== "dot" + const nestLabel = sortedPayload.length === 1 && indicator !== "dot"; const usingNewRow = Boolean( valueFormatter || numberFormat || showTotal || secondaryFormatter || footer, - ) + ); return (
{!nestLabel ? tooltipLabel : null} @@ -293,20 +293,25 @@ function ChartTooltipContent({
{sortedPayload.map((item, index) => { - const key = `${nameKey || item.name || item.dataKey || "value"}` - const itemConfig = getPayloadConfigFromPayload(config, item, key) - const indicatorColor = color || item.payload?.fill || item.color - const seriesLabel = itemConfig?.label ?? item.name + const key = `${nameKey || item.name || item.dataKey || "value"}`; + const itemConfig = getPayloadConfigFromPayload(config, item, key); + const indicatorColor = color || item.payload?.fill || item.color; + const seriesLabel = itemConfig?.label ?? item.name; // Back-compat path: legacy `formatter` prop replaces the whole row. // Only honored when no new-shape props were passed. - if (formatter && !usingNewRow && item?.value !== undefined && item.name) { + if ( + formatter && + !usingNewRow && + item?.value !== undefined && + item.name + ) { return (
svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground", - indicator === "dot" && "items-center" + indicator === "dot" && "items-center", )} > {(formatter as unknown as LegacyFormatter)( @@ -317,7 +322,7 @@ function ChartTooltipContent({ item.payload, )}
- ) + ); } return ( @@ -325,7 +330,7 @@ function ChartTooltipContent({ key={item.dataKey} className={cn( "flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground", - indicator === "dot" && "items-center" + indicator === "dot" && "items-center", )} > {itemConfig?.icon ? ( @@ -341,7 +346,7 @@ function ChartTooltipContent({ "w-0 border-[1.5px] border-dashed bg-transparent": indicator === "dashed", "my-0.5": nestLabel && indicator === "dashed", - } + }, )} style={ { @@ -355,33 +360,35 @@ function ChartTooltipContent({
{nestLabel ? tooltipLabel : null} {seriesLabel}
- {item.value !== undefined && item.value !== null && (() => { - const secondary = secondaryFormatter - ? secondaryFormatter(item.value, item, numericTotal) - : null - return ( -
- - {renderValue(item)} - - {secondary != null && ( - - {secondary} + {item.value !== undefined && + item.value !== null && + (() => { + const secondary = secondaryFormatter + ? secondaryFormatter(item.value, item, numericTotal) + : null; + return ( +
+ + {renderValue(item)} - )} -
- ) - })()} + {secondary != null && ( + + {secondary} + + )} +
+ ); + })()}
- ) + ); })}
@@ -394,10 +401,10 @@ function ChartTooltipContent({ )} - ) + ); } -const ChartLegend = RechartsPrimitive.Legend +const ChartLegend = RechartsPrimitive.Legend; function ChartLegendContent({ className, @@ -407,13 +414,13 @@ function ChartLegendContent({ nameKey, }: React.ComponentProps<"div"> & Pick & { - hideIcon?: boolean - nameKey?: string + hideIcon?: boolean; + nameKey?: string; }) { - const { config } = useChart() + const { config } = useChart(); if (!payload?.length) { - return null + return null; } return ( @@ -421,20 +428,40 @@ function ChartLegendContent({ className={cn( "flex items-center justify-center gap-4", verticalAlign === "top" ? "pb-3" : "pt-3", - className + className, )} > {payload .filter((item) => item.type !== "none") .map((item) => { - const key = `${nameKey || item.dataKey || "value"}` - const itemConfig = getPayloadConfigFromPayload(config, item, key) + const key = `${nameKey || item.dataKey || "value"}`; + const itemConfig = getPayloadConfigFromPayload(config, item, key); + + // Swatch must match the series it labels. Prefer the rendered color + // Recharts hands us (the Bar fill / Line stroke), and fall back to the + // configured `--color-{key}` token so the indicator can never drift + // from the chart even if the payload color is missing. + const swatchColor = item.color ?? itemConfig?.color; + // Mirror the series' opacity (e.g. faint "forecast"/"estimate" bars) + // — on a greyscale palette an opacity gap reads as a different color. + const seriesProps = item.payload as + | Record + | undefined; + const readOpacity = (k: string) => + typeof seriesProps?.[k] === "number" + ? (seriesProps[k] as number) + : undefined; + const swatchOpacity = + readOpacity("fillOpacity") ?? + readOpacity("strokeOpacity") ?? + readOpacity("opacity") ?? + 1; return (
svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground" + "flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground", )} > {itemConfig?.icon && !hideIcon ? ( @@ -443,26 +470,27 @@ function ChartLegendContent({
)} {itemConfig?.label}
- ) + ); })}
- ) + ); } // Helper to extract item config from a payload. function getPayloadConfigFromPayload( config: ChartConfig, payload: unknown, - key: string + key: string, ) { if (typeof payload !== "object" || payload === null) { - return undefined + return undefined; } const payloadPayload = @@ -470,15 +498,15 @@ function getPayloadConfigFromPayload( typeof payload.payload === "object" && payload.payload !== null ? payload.payload - : undefined + : undefined; - let configLabelKey: string = key + let configLabelKey: string = key; if ( key in payload && typeof payload[key as keyof typeof payload] === "string" ) { - configLabelKey = payload[key as keyof typeof payload] as string + configLabelKey = payload[key as keyof typeof payload] as string; } else if ( payloadPayload && key in payloadPayload && @@ -486,12 +514,12 @@ function getPayloadConfigFromPayload( ) { configLabelKey = payloadPayload[ key as keyof typeof payloadPayload - ] as string + ] as string; } return configLabelKey in config ? config[configLabelKey] - : config[key as keyof typeof config] + : config[key as keyof typeof config]; } export { @@ -501,4 +529,4 @@ export { ChartLegend, ChartLegendContent, ChartStyle, -} +};