diff --git a/README.md b/README.md index 9de9bc2..a5d1166 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,11 @@ tools below without your data ever leaving your device. - **Spike Detection** - Flag days with anomalous usage above the trend. - **Cost Center Rollup** - Roll up AI Credit usage and budgets by cost center. +You can also add a **previous month's** CSV to compare against. The comparable summary +stat cards in each tool then gain a "vs last month" line showing the percentage change +(observed total, run rate, totals, user and model counts, and more). The comparison +report stays in memory in your browser, the same as the main report. + For what each tool shows, how to read it, and its key assumptions, see [docs/tools.md](docs/tools.md). diff --git a/docs/analytics.md b/docs/analytics.md index e692188..f0e0382 100644 --- a/docs/analytics.md +++ b/docs/analytics.md @@ -18,7 +18,7 @@ Only the aggregate, non-sensitive metadata listed below is captured. | Event | When | Properties | | --- | --- | --- | -| `csv_uploaded` | A usage report CSV is successfully parsed and loaded | `report_type` (`summarized` \| `detailed` \| `ai` \| `unknown`), `row_count` (number of rows in the report) | +| `csv_uploaded` | A usage report CSV is successfully parsed and loaded | `report_type` (`summarized` \| `detailed` \| `ai` \| `unknown`), `row_count` (number of rows in the report), `slot` (`primary` \| `comparison` — whether the file is the main report or the previous-month comparison report) | | `tool_viewed` | A tool is opened from the sidebar (or via the logo reset), or the active tool first appears when a report is loaded | `tool_id` (`usage-forecast` \| `team-insights` \| `model-breakdown` \| `spike-detection` \| `cost-center-rollup`) | | `resource_clicked` | A curated Resources link in the sidebar is opened | `resource_label` (the link's title), `resource_category` (`News` \| `Courses & guides` \| `Videos`) | | `github_link_clicked` | An outbound link to the project's GitHub repository is opened | `link_location` (`sidebar-source` \| `sidebar-build` \| `footer-source` \| `footer-issue`) | diff --git a/docs/tools.md b/docs/tools.md index ffacd96..dc964a5 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -9,6 +9,29 @@ monthly projections run to the end of the calendar month containing the report's latest day, because GitHub AI Credit entitlements reset monthly. Always refer to your GitHub billing statements as the source of truth. +## Compare to the previous month + +Alongside the main report you can add a **previous month's** CSV (the "Add previous +month CSV" control next to the loaded report). When present, the comparable summary +stat cards in each tool gain a **"vs last month"** line showing the percentage change, +with the previous figure on hover. An increase in spend or usage is shown in red and a +decrease in green; plain counts stay neutral. Covered cards include: + +- **Usage Forecast** — observed total, daily run rate, and projected month total. + The chart also overlays the previous month's cumulative usage as a dashed line. +- **Team Insights** — users, team total, and average per user. The "Average usage by + user group" and "Per-user insights & budget forecast" tables gain a **Last month** + column showing each group's / user's previous total with the change. +- **Model Breakdown** — models and total. The breakdown table gains a **vs last month** + column showing each model's increase or decrease. +- **Cost Center Rollup** — cost centers, total, and active users. The breakdown table + gains a **vs last month** column showing each cost center's increase or decrease. +- **Spike Detection** — the baseline run rate. + +Like the main report, the comparison CSV is parsed and held **in memory in your +browser only**; it is never persisted or sent anywhere. Clearing or replacing the main +report also clears the comparison. + - [Usage Forecast](#usage-forecast) — _Forecasting_ - [Team Insights](#team-insights) — _Breakdowns_ - [Model Breakdown](#model-breakdown) — _Breakdowns_ diff --git a/src/components/app-shell.tsx b/src/components/app-shell.tsx index 6356537..91c6705 100644 --- a/src/components/app-shell.tsx +++ b/src/components/app-shell.tsx @@ -121,7 +121,10 @@ function Shell() { {tool?.description} - +
+ + +
)} diff --git a/src/components/app.module.css b/src/components/app.module.css index 79c13f8..c490a88 100644 --- a/src/components/app.module.css +++ b/src/components/app.module.css @@ -991,6 +991,15 @@ flex-wrap: wrap; } +/* Stack the primary and comparison (previous month) uploaders in the content + band so they read as two distinct slots rather than one busy row. */ +.bandUploaders { + display: flex; + flex-direction: column; + gap: 8px; + align-items: flex-end; +} + .uploaderFileLabel { max-width: 100%; } @@ -1276,3 +1285,22 @@ max-width: none; } } + +/* "vs last month" delta line shown inside a stat card when a previous-period + report has been added. Inherits the muted statSub sizing; the tone modifiers + recolour it. Rising usage is "danger" (more spend) to match the forecast tool. */ +.comparisonDelta { + display: inline-flex; + align-items: center; + gap: 3px; + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +.deltaUp { + color: var(--fgColor-danger, #cf222e); +} + +.deltaDown { + color: var(--fgColor-success, #1a7f37); +} diff --git a/src/components/comparison-delta.tsx b/src/components/comparison-delta.tsx new file mode 100644 index 0000000..d1bc752 --- /dev/null +++ b/src/components/comparison-delta.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { ArrowUpRightIcon, ArrowDownRightIcon, DashIcon } from "@primer/octicons-react"; +import styles from "./app.module.css"; + +/** + * Compact "vs last month" delta shown inside a stat card or table cell once a + * previous-period report has been added. Renders nothing when there is no + * previous value. + * + * `tone="spend"` colours an increase red and a decrease green (more / less + * spend); `tone="neutral"` stays grey for counts where neither direction is + * inherently good or bad. With `compact`, only the arrow and percentage are + * shown (for table cells whose column header already says "vs last month"). + * `format` is used for the hover title that reveals the previous absolute value. + * + * Both inputs come from the two in-memory reports; nothing is persisted or sent. + */ +export function ComparisonDelta({ + current, + previous, + tone = "spend", + compact = false, + format, +}: { + current: number; + previous: number | null | undefined; + tone?: "spend" | "neutral"; + compact?: boolean; + format?: (n: number) => string; +}) { + if (previous == null) return null; + + const delta = current - previous; + const rising = delta > 1e-9; + const falling = delta < -1e-9; + const pct = previous === 0 ? null : delta / previous; + + const Icon = rising ? ArrowUpRightIcon : falling ? ArrowDownRightIcon : DashIcon; + const toneClass = + tone === "neutral" ? "" : rising ? styles.deltaUp : falling ? styles.deltaDown : ""; + const pctLabel = + pct === null + ? current > 0 + ? "new" + : "no change" + : `${pct > 0 ? "+" : ""}${Math.round(pct * 100)}%`; + const title = format ? `Last month: ${format(previous)}` : undefined; + + return ( + + + {compact ? pctLabel : `${pctLabel} vs last month`} + + ); +} diff --git a/src/components/csv-uploader.tsx b/src/components/csv-uploader.tsx index 42571fc..95635fd 100644 --- a/src/components/csv-uploader.tsx +++ b/src/components/csv-uploader.tsx @@ -16,8 +16,25 @@ const REPORT_TYPE_LABELS: Record = { unknown: "Usage report", }; -export function CsvUploader({ compact = false }: { compact?: boolean }) { - const { report, setReport, clearReport, locked } = useReport(); +/** + * Which report slot this uploader controls: the main report, or the optional + * previous-period report used for month-over-month comparison. + */ +type UploaderSlot = "primary" | "comparison"; + +export function CsvUploader({ + compact = false, + slot = "primary", +}: { + compact?: boolean; + slot?: UploaderSlot; +}) { + const ctx = useReport(); + const isComparison = slot === "comparison"; + const report = isComparison ? ctx.comparisonReport : ctx.report; + const setReport = isComparison ? ctx.setComparisonReport : ctx.setReport; + const clearReport = isComparison ? ctx.clearComparisonReport : ctx.clearReport; + const { locked } = ctx; const inputRef = useRef(null); const [busy, setBusy] = useState(false); const [dragging, setDragging] = useState(false); @@ -38,6 +55,7 @@ export function CsvUploader({ compact = false }: { compact?: boolean }) { posthog.capture("csv_uploaded", { report_type: parsed.reportType, row_count: parsed.rowCount, + slot, }); toast.success( `Loaded ${parsed.rowCount.toLocaleString()} rows - ${REPORT_TYPE_LABELS[parsed.reportType]}`, @@ -48,7 +66,7 @@ export function CsvUploader({ compact = false }: { compact?: boolean }) { setBusy(false); } }, - [setReport], + [setReport, slot], ); const onDrop = useCallback( @@ -70,9 +88,35 @@ export function CsvUploader({ compact = false }: { compact?: boolean }) { /> ); - if (compact && report) { + if (compact) { + // Comparison slot with nothing loaded yet: a slim affordance to add the + // previous period's CSV so usage can be compared month over month. + if (!report) { + if (locked) return null; + return ( +
+ + Compare to previous month + + + {hiddenInput} +
+ ); + } return (
+ {isComparison && ( + + Previous month + + )}
@@ -381,6 +423,7 @@ export function CostCenterRollup() { onSort={toggleSort} numeric /> + {previous && vs last month} @@ -414,12 +457,22 @@ export function CostCenterRollup() { {formatAic(c.projectedMonth)} {formatUsd(c.projectedMonth * USD_PER_AIC)} + {previous && ( + + + + )} ); })} {tableCenters.length === 0 && ( - + No cost centers match “{filter}”. @@ -487,11 +540,13 @@ function StatCard({ info, value, sub, + extra, }: { title: string; info?: string; value: string; sub?: string; + extra?: React.ReactNode; }) { return (
@@ -507,6 +562,7 @@ function StatCard({
{value}
{sub &&
{sub}
} + {extra &&
{extra}
} ); } diff --git a/src/components/tools/model-breakdown.tsx b/src/components/tools/model-breakdown.tsx index e8c0c5f..4376b95 100644 --- a/src/components/tools/model-breakdown.tsx +++ b/src/components/tools/model-breakdown.tsx @@ -28,6 +28,7 @@ import { ChevronUpIcon, } from "@primer/octicons-react"; import { useReport } from "@/components/report-provider"; +import { ComparisonDelta } from "@/components/comparison-delta"; import { usePrefersReducedMotion } from "@/components/use-prefers-reduced-motion"; import { ExportMenu } from "@/components/export-menu"; import { SortableTh, type SortDir } from "@/components/sortable-th"; @@ -80,7 +81,7 @@ interface DisplayRow { type SortKey = "model" | "totalQuantity" | "share" | "users"; export function ModelBreakdown() { - const { report } = useReport(); + const { report, comparisonReport } = useReport(); const prefersReducedMotion = usePrefersReducedMotion(); const chartRef = useRef(null); const [filter, setFilter] = useState(""); @@ -96,6 +97,27 @@ export function ModelBreakdown() { }); }, [report]); + // Previous-period equivalents for the comparable stat cards, derived from the + // optional comparison report. Null when no previous month has been added. + const previous = useMemo(() => { + if (!comparisonReport) return null; + const pModels = aggregateByModel(comparisonReport.rows); + // Per-model previous totals for the table. Auto selections are summed into a + // single bucket so they line up with the collapsed "Auto" row. + const byModel = new Map(); + let autoTotal = 0; + for (const m of pModels) { + byModel.set(m.model, m.totalQuantity); + if (isAutoModel(m.model)) autoTotal += m.totalQuantity; + } + return { + count: pModels.length, + total: pModels.reduce((a, m) => a + m.totalQuantity, 0), + byModel, + autoTotal, + }; + }, [comparisonReport]); + // Models with all Auto selections collapsed into a single "Auto" entry, re-ranked // by total. Shared by the chart, "Top model", and "Fastest growing" so they all // treat Auto as one model (matching the breakdown table). @@ -305,12 +327,27 @@ export function ModelBreakdown() { title="Models" info="Number of distinct models the report attributes AI Credit usage to." value={models.length.toLocaleString()} + extra={ + previous ? ( + n.toLocaleString()} + /> + ) : undefined + } /> + ) : undefined + } /> Trend + {previous && vs last month} @@ -531,6 +569,16 @@ export function ModelBreakdown() { {trend.label} + {previous && ( + + + + )} {isOpen && ( @@ -668,12 +716,14 @@ function StatCard({ value, sub, valueColor, + extra, }: { title: string; info?: string; value: string; sub?: string; valueColor?: string; + extra?: React.ReactNode; }) { return (
@@ -691,6 +741,7 @@ function StatCard({ {value}
{sub &&
{sub}
} + {extra &&
{extra}
} ); } diff --git a/src/components/tools/spike-detection.tsx b/src/components/tools/spike-detection.tsx index 79a1b50..f0e6d38 100644 --- a/src/components/tools/spike-detection.tsx +++ b/src/components/tools/spike-detection.tsx @@ -28,9 +28,10 @@ import { AlertIcon, } from "@primer/octicons-react"; import { useReport } from "@/components/report-provider"; +import { ComparisonDelta } from "@/components/comparison-delta"; import { usePrefersReducedMotion } from "@/components/use-prefers-reduced-motion"; import { ExportMenu } from "@/components/export-menu"; -import { aggregateDaily, dayContributions } from "@/lib/report"; +import { aggregateDaily, dayContributions, sumMetric, activeDays } from "@/lib/report"; import { detectSpikes } from "@/lib/forecast"; import styles from "../app.module.css"; @@ -45,7 +46,7 @@ const SENSITIVITY: Record = { const SENS_ORDER = ["high", "medium", "low"] as const; export function SpikeDetection() { - const { report } = useReport(); + const { report, comparisonReport } = useReport(); const prefersReducedMotion = usePrefersReducedMotion(); const [sensitivity, setSensitivity] = useState("medium"); const [expanded, setExpanded] = useState>(new Set()); @@ -73,6 +74,15 @@ export function SpikeDetection() { [report], ); + // Previous-period baseline run rate (avg AI Credits per active day), derived + // from the optional comparison report. Null when no previous month is added. + const previousBaseline = useMemo(() => { + if (!comparisonReport) return null; + const total = sumMetric(comparisonReport.rows, "quantity"); + const days = activeDays(comparisonReport.rows); + return days > 0 ? total / days : 0; + }, [comparisonReport]); + if (!report) return null; if (!analysis) { @@ -177,6 +187,15 @@ export function SpikeDetection() { info="Average AI Credits per day across the whole report - the expected daily level." value={`${formatAic(analysis.baselineRunRate)}/day`} sub={`${formatUsd(analysis.baselineRunRate * USD_PER_AIC)}/day`} + extra={ + previousBaseline != null ? ( + `${formatAic(n)}/day`} + /> + ) : undefined + } /> classifyUsageGroups(users), [users]); + // Previous-period equivalents for the comparable stat cards, derived from the + // optional comparison report. Null when no previous month has been added. + const previous = useMemo(() => { + if (!comparisonReport) return null; + const pUsers = aggregateByUser(comparisonReport.rows); + const total = pUsers.reduce((a, u) => a + u.totalQuantity, 0); + // Per-user previous totals, keyed by username, for the per-user table. + const userTotals = new Map(); + for (const u of pUsers) userTotals.set(u.username, u.totalQuantity); + // Previous usage-group totals, keyed by group, for the user-group table. + // `classifyUsageGroups` only reads `.totalQuantity`, so the run-rate and + // projection fields can be zero-filled. + const prevGroupTotals = new Map(); + for (const g of classifyUsageGroups( + pUsers.map((u) => ({ ...u, runRate: 0, projectedMonth: 0 })), + )) { + prevGroupTotals.set(g.key, g.totalQuantity); + } + return { + users: pUsers.length, + total, + avg: pUsers.length ? total / pUsers.length : 0, + userTotals, + groupTotals: prevGroupTotals, + }; + }, [comparisonReport]); + const sortedUsers = useMemo(() => { const dir = sortDir === "asc" ? 1 : -1; const arr = [...users]; @@ -270,6 +298,16 @@ export function TeamInsights() {
{totalUsers.toLocaleString()}
+ {previous && ( +
+ n.toLocaleString()} + /> +
+ )} @@ -278,12 +316,26 @@ export function TeamInsights() { info="Total AI Credits consumed across all users in the report." value={formatAic(totalAic)} sub={formatUsd(totalAic * USD_PER_AIC)} + extra={ + previous ? ( + + ) : undefined + } /> + ) : undefined + } /> {budget > 0 ? ( Avg usage Median usage Group total + {previous && Last month} @@ -385,6 +438,26 @@ export function TeamInsights() { "\u2014" )} + {previous && + (() => { + const prev = previous.groupTotals.get(g.key) ?? 0; + return ( + + {formatAic(prev)} + + {formatUsd(prev * USD_PER_AIC)} + +
+ +
+ + ); + })()} ))} @@ -499,6 +572,7 @@ export function TeamInsights() { onSort={toggleSort} /> )} + {previous && Last month} @@ -558,6 +632,26 @@ export function TeamInsights() { )} + {previous && + (() => { + const prev = previous.userTotals.get(u.username) ?? 0; + return ( + + {formatAic(prev)} + + {formatUsd(prev * USD_PER_AIC)} + +
+ +
+ + ); + })()} {isOpen && ( diff --git a/src/components/tools/usage-forecast.tsx b/src/components/tools/usage-forecast.tsx index 917fad4..7d0554d 100644 --- a/src/components/tools/usage-forecast.tsx +++ b/src/components/tools/usage-forecast.tsx @@ -21,10 +21,11 @@ import { InfoIcon, } from "@primer/octicons-react"; import { useReport } from "@/components/report-provider"; +import { ComparisonDelta } from "@/components/comparison-delta"; import { usePrefersReducedMotion } from "@/components/use-prefers-reduced-motion"; import { ExportMenu } from "@/components/export-menu"; import { usePersistentState } from "@/components/use-persistent-state"; -import { aggregateDaily, sumMetric } from "@/lib/report"; +import { aggregateDaily, sumMetric, activeDays } from "@/lib/report"; import { forecastDaily } from "@/lib/forecast"; import styles from "../app.module.css"; @@ -38,6 +39,7 @@ const SELECTION_COLOR = "#0969da"; * coloured line is the projection (the observed trend, or your scenario rate). */ const FORECAST_COLOR = "#8250df"; // projection / scenario run-rate line const BASELINE_COLOR = "#57606a"; // data-driven baseline shown for comparison +const PREV_MONTH_COLOR = "#9a6700"; // previous month's cumulative usage line /** * Days from the last observed day through the end of that calendar month. @@ -69,7 +71,7 @@ interface RangeSelection { } export function UsageForecast() { - const { report } = useReport(); + const { report, comparisonReport } = useReport(); const prefersReducedMotion = usePrefersReducedMotion(); const [entitlementInput, setEntitlementInput] = usePersistentState( "usage-forecast:entitlement", @@ -103,6 +105,38 @@ export function UsageForecast() { [report], ); + // Previous-period equivalents for the comparable stat cards, derived from the + // optional comparison report. Null when no previous month has been added. + const previous = useMemo(() => { + if (!comparisonReport) return null; + const total = sumMetric(comparisonReport.rows, "quantity"); + const days = activeDays(comparisonReport.rows); + return { total, runRate: days > 0 ? total / days : 0 }; + }, [comparisonReport]); + + // Previous month's cumulative AI Credits indexed by day-of-month (1-31), so it + // can be overlaid on the current month's chart aligned by calendar day. Days + // before the previous month's first active day read 0; later days carry the + // last known running total forward. Null when no previous month is added. + const prevMonthCumulative = useMemo(() => { + if (!comparisonReport) return null; + const pd = aggregateDaily(comparisonReport.rows, "quantity"); + if (pd.length === 0) return null; + const byDom = new Map(); + let run = 0; + for (const p of pd) { + run += p.value; + byDom.set(Number(p.date.slice(8, 10)), run); + } + const carry: number[] = []; + let last = 0; + for (let d = 1; d <= 31; d++) { + if (byDom.has(d)) last = byDom.get(d)!; + carry[d] = last; + } + return carry; + }, [comparisonReport]); + const forecast = useMemo(() => { if (!daily.length) return null; const horizon = daysToMonthEnd(daily[daily.length - 1].date); @@ -138,6 +172,9 @@ export function UsageForecast() { forecastLine: anchor ? round2(runA) : null, baselineLine: anchor ? round2(runA) : null, band: anchor ? ([round2(runA), round2(runA)] as [number, number]) : null, + prevMonth: prevMonthCumulative + ? round2(prevMonthCumulative[Number(p.date.slice(8, 10))] ?? 0) + : null, }; } if (i === obs) { @@ -164,9 +201,12 @@ export function UsageForecast() { forecastLine: round2(runF), baselineLine: round2(runBase), band: [round2(runL), round2(runU)] as [number, number], + prevMonth: prevMonthCumulative + ? round2(prevMonthCumulative[Number(p.date.slice(8, 10))] ?? 0) + : null, }; }); - }, [forecast, scenarioRate]); + }, [forecast, scenarioRate, prevMonthCumulative]); // Summary of the dragged range: the cumulative AIC consumed between the two // selected dates (the `forecast` series carries the running total for both @@ -279,7 +319,7 @@ export function UsageForecast() { // tallest plotted value, with a little headroom so its label isn't clipped. const dataMax = cumulativeData.reduce((max, d) => { const hi = Array.isArray(d.band) ? d.band[1] : 0; - return Math.max(max, d.actual ?? 0, d.forecast ?? 0, hi); + return Math.max(max, d.actual ?? 0, d.forecast ?? 0, hi, d.prevMonth ?? 0); }, 0); const yMax = Math.ceil(Math.max(dataMax, entitlement) * 1.05); @@ -413,6 +453,11 @@ export function UsageForecast() { info="Total AI Credits already consumed in the uploaded report, summed across all rows." value={formatAic(observed)} sub={`${forecast.observedDays} days · ${formatUsd(observed * USD_PER_AIC)}`} + extra={ + previous ? ( + + ) : undefined + } /> `${formatAic(n)}/day`} + /> + ) : undefined + } /> + {previous && ( + + )} + {prevMonthCumulative && ( + + )} {projectionLabel} + {prevMonthCumulative && ( + + + Last month + + )} @@ -695,6 +778,7 @@ function CapTooltip({ const forecast = find("forecastLine") as number | undefined; const baseline = find("baselineLine") as number | undefined; const band = find("band") as [number, number] | undefined; + const prevMonth = find("prevMonth") as number | null | undefined; const isProjected = actual == null; const value = (actual ?? forecast ?? 0) as number; @@ -732,6 +816,17 @@ function CapTooltip({ Range: {formatAic(band[0])} – {formatAic(band[1])} )} + {prevMonth != null && ( +
+ Last month: {formatAic(prevMonth)} + {(() => { + const diff = value - prevMonth; + const sign = diff >= 0 ? "+" : "−"; + const pct = prevMonth > 0 ? ` (${sign}${Math.round((Math.abs(diff) / prevMonth) * 100)}%)` : ""; + return ` · ${sign}${formatAic(Math.abs(diff))}${pct}`; + })()} +
+ )} {selDiff != null && (
(); + for (const row of rows) { + const day = normalizeDay(row.date); + if (day) days.add(day); + } + return days.size; +}