Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
2 changes: 1 addition & 1 deletion docs/analytics.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`) |
Expand Down
23 changes: 23 additions & 0 deletions docs/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_
Expand Down
5 changes: 4 additions & 1 deletion src/components/app-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,10 @@ function Shell() {
{tool?.description}
</Text>
</div>
<CsvUploader compact />
<div className={styles.bandUploaders}>
<CsvUploader compact />
<CsvUploader compact slot="comparison" />
</div>
</div>
)}

Expand Down
28 changes: 28 additions & 0 deletions src/components/app.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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%;
}
Expand Down Expand Up @@ -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);
}
56 changes: 56 additions & 0 deletions src/components/comparison-delta.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<span className={`${styles.comparisonDelta} ${toneClass}`} title={title}>
<Icon size={12} />
{compact ? pctLabel : `${pctLabel} vs last month`}
</span>
);
}
52 changes: 48 additions & 4 deletions src/components/csv-uploader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,25 @@ const REPORT_TYPE_LABELS: Record<ParsedReport["reportType"], string> = {
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<HTMLInputElement>(null);
const [busy, setBusy] = useState(false);
const [dragging, setDragging] = useState(false);
Expand All @@ -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]}`,
Expand All @@ -48,7 +66,7 @@ export function CsvUploader({ compact = false }: { compact?: boolean }) {
setBusy(false);
}
},
[setReport],
[setReport, slot],
);

const onDrop = useCallback(
Expand All @@ -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 (
<div className={styles.uploaderCompact}>
<Text className={styles.muted} style={{ fontSize: 12 }}>
Compare to previous month
</Text>
<Button
size="small"
leadingVisual={UploadIcon}
onClick={() => inputRef.current?.click()}
disabled={busy}
>
{busy ? "Parsing…" : "Add previous month CSV"}
</Button>
{hiddenInput}
</div>
);
}
return (
<div className={styles.uploaderCompact}>
{isComparison && (
<Text className={styles.muted} style={{ fontSize: 12 }}>
Previous month
</Text>
)}
<Label size="large" className={styles.uploaderFileLabel}>
<FileIcon />
<span style={{ marginLeft: 4 }}>{report.fileName}</span>
Expand Down
30 changes: 27 additions & 3 deletions src/components/report-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,14 @@ interface ReportContextValue {
report: ParsedReport | null;
setReport: (report: ParsedReport | null) => void;
clearReport: () => void;
/**
* Optional previous-period report used for month-over-month comparison.
* Like {@link report}, it is held in memory on the client only and never
* persisted or sent anywhere.
*/
comparisonReport: ParsedReport | null;
setComparisonReport: (report: ParsedReport | null) => void;
clearComparisonReport: () => void;
/**
* True when the report was preloaded with the build. In this mode the user
* cannot upload, replace, or clear the report.
Expand All @@ -36,6 +44,7 @@ const ReportContext = createContext<ReportContextValue | null>(null);
*/
export function ReportProvider({ children }: { children: React.ReactNode }) {
const [report, setReport] = useState<ParsedReport | null>(null);
const [comparisonReport, setComparisonReport] = useState<ParsedReport | null>(null);
const [locked, setLocked] = useState(false);
const [loading, setLoading] = useState(true);

Expand Down Expand Up @@ -65,12 +74,27 @@ export function ReportProvider({ children }: { children: React.ReactNode }) {
() => ({
report,
// In locked mode the report is fixed; ignore attempts to change it.
setReport: locked ? () => {} : setReport,
clearReport: locked ? () => {} : () => setReport(null),
setReport: locked
? () => {}
: (next) => {
setReport(next);
// Clearing or replacing the primary report drops the comparison so
// the two never get out of sync.
if (!next) setComparisonReport(null);
},
Comment on lines +77 to +84
clearReport: locked
? () => {}
: () => {
setReport(null);
setComparisonReport(null);
},
comparisonReport,
setComparisonReport: locked ? () => {} : setComparisonReport,
clearComparisonReport: locked ? () => {} : () => setComparisonReport(null),
locked,
loading,
}),
[report, locked, loading],
[report, comparisonReport, locked, loading],
);

return <ReportContext.Provider value={value}>{children}</ReportContext.Provider>;
Expand Down
Loading