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 (
+