From bd8489d3487c0462e1444a54a03d9f3da5682c89 Mon Sep 17 00:00:00 2001 From: Austen Stone Date: Thu, 20 Aug 2026 09:12:05 -0700 Subject: [PATCH 1/3] fix: correct sample data, keep CSVs out of the JS bundle, and pick a valid groupBy Three fixes to first-run behaviour: - enterprise-members.csv was actually loading the dormant-users export, so the Enterprise Members page rendered empty in the demo. Load the real members file and add the dormant-users sample alongside it. - Samples were imported with ?raw, which inlines 15 MB of CSV into a JS chunk the engine has to parse as source. Switch to ?url + fetch so Vite emits them as static assets. - addReport hardcoded groupByColumn to 'username'. Summarized metered usage reports have no username column, so the first chart rendered empty. resolveGroupByColumn samples the parsed rows and falls back to the schema default. Also fixes activeReportIndex, which used the unfiltered report count and pointed past the end of the array once samples were auto-removed on a real import. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/context/ReportContext.tsx | 5 ++-- src/lib/report-schema.test.ts | 34 ++++++++++++++++++++++ src/lib/report-schema.ts | 30 ++++++++++++++++++- src/lib/sample-data.ts | 54 +++++++++++++++++++---------------- 4 files changed, 96 insertions(+), 27 deletions(-) diff --git a/src/context/ReportContext.tsx b/src/context/ReportContext.tsx index 2d22899..ba1bc44 100644 --- a/src/context/ReportContext.tsx +++ b/src/context/ReportContext.tsx @@ -4,7 +4,7 @@ import { ReportContext } from './report-context'; import type { CombinedGroup, DateRange } from './report-context'; import { getCachedParsedReports, getCachedRawCSV, setCachedCSV, removeCachedCSV, clearCachedCSVs } from '../lib/local-storage'; import { readURLFilterState, writeURLFilterState } from '../lib/url-state'; -import { getReportSchema } from '../lib/report-schema'; +import { getReportSchema, resolveGroupByColumn } from '../lib/report-schema'; import { formatDateRangeCompact } from '../lib/formatters'; /** Fast FNV-1a hash for dedup — not crypto-grade, just collision-resistant enough for CSV content */ @@ -202,7 +202,8 @@ export function ReportProvider({ children }: { children: ReactNode }) { reports: nextReports, rawCsvs: nextRawCsvs, fileHashes: nextHashes, - activeReportIndex: prev.reports.length, + activeReportIndex: baseReports.length, + groupByColumn: resolveGroupByColumn(report, prev.groupByColumn), periodKey: 'all', dateRange: null, searchQuery: '', diff --git a/src/lib/report-schema.test.ts b/src/lib/report-schema.test.ts index a8fbcba..faec0e3 100644 --- a/src/lib/report-schema.test.ts +++ b/src/lib/report-schema.test.ts @@ -5,6 +5,7 @@ import { PAGE_TYPES, PAGE_REPORT_TYPES, PRODUCT_METRIC_OPTIONS, + resolveGroupByColumn, } from './report-schema'; import { REPORT_TYPES } from './types'; @@ -77,3 +78,36 @@ describe('product metric options', () => { expect(actions.some(o => o.label === 'Minutes')).toBe(true); }); }); + +describe('resolveGroupByColumn', () => { + const report = (type: string, rows: Array>) => + ({ type, rows, fileName: 'f.csv', rowCount: rows.length, dateRange: { start: '', end: '' } }) as never; + + it('keeps the current column when the report has data for it', () => { + const r = report(REPORT_TYPES.USAGE_REPORT, [{ username: 'ana-reyes', sku: 'actions_linux' }]); + expect(resolveGroupByColumn(r, 'username')).toBe('username'); + }); + + it('falls back to the schema default when the column is blank for every row', () => { + const rows = Array.from({ length: 50 }, () => ({ username: '', sku: 'actions_linux' })); + expect(resolveGroupByColumn(report(REPORT_TYPES.USAGE_REPORT, rows), 'username')).toBe('sku'); + }); + + it('falls back when the column is missing entirely, as in summarized exports', () => { + const rows = Array.from({ length: 50 }, () => ({ sku: 'actions_linux' })); + expect(resolveGroupByColumn(report(REPORT_TYPES.USAGE_REPORT, rows), 'username')).toBe('sku'); + }); + + it('keeps a column that is blank early but populated later', () => { + const rows = Array.from({ length: 500 }, (_, i) => ({ username: i < 400 ? '' : 'kai-nakamura' })); + expect(resolveGroupByColumn(report(REPORT_TYPES.USAGE_REPORT, rows), 'username')).toBe('username'); + }); + + it('uses the schema default when there is no current column', () => { + expect(resolveGroupByColumn(report(REPORT_TYPES.ENTERPRISE_MEMBERS, []), '')).toBe('licenseType'); + }); + + it('falls back for an empty report rather than keeping an unusable column', () => { + expect(resolveGroupByColumn(report(REPORT_TYPES.DORMANT_USERS, []), 'username')).toBe('role'); + }); +}); diff --git a/src/lib/report-schema.ts b/src/lib/report-schema.ts index 8d12dbc..a635f6e 100644 --- a/src/lib/report-schema.ts +++ b/src/lib/report-schema.ts @@ -1,5 +1,5 @@ import type { ComponentType } from 'react'; -import type { ReportType } from './types'; +import type { ParsedReport, ReportType } from './types'; import { REPORT_TYPES } from './types'; import { CopilotIcon, @@ -347,6 +347,34 @@ export function getAllSchemas(): ReportSchema[] { return Object.values(SCHEMA_REGISTRY); } +const GROUP_BY_SAMPLE_SIZE = 200; + +/** + * Pick a group-by column that the report actually has data for. + * + * Reports of the same type don't always carry the same columns — the + * summarized metered usage export drops `username` and `workflow_path`, and + * org-level seat activity has no `organization`. Keeping a column that is + * blank for every row renders an empty chart, so fall back to the schema + * default. Rows are sampled with a stride rather than from the head, since + * columns like `username` are legitimately blank for leading storage rows. + */ +export function resolveGroupByColumn( + report: ParsedReport, + currentColumn: string, +): string { + const schema = getReportSchema(report.type); + if (!currentColumn) return schema.defaultGroupBy; + + const rows = report.rows as Array>; + const stride = Math.max(1, Math.floor(rows.length / GROUP_BY_SAMPLE_SIZE)); + for (let i = 0; i < rows.length; i += stride) { + const value = rows[i]?.[currentColumn]; + if (value !== undefined && value !== null && value !== '') return currentColumn; + } + return schema.defaultGroupBy; +} + /** Sidebar nav page identifiers derived from report types */ export const PAGE_TYPES = { COPILOT: 'copilot', diff --git a/src/lib/sample-data.ts b/src/lib/sample-data.ts index c580c23..92b48f2 100644 --- a/src/lib/sample-data.ts +++ b/src/lib/sample-data.ts @@ -1,28 +1,34 @@ +import usageReportUrl from '../../examples/usageReport_1_7f2ed6006ee54fb8af73f5cbb7ac1f1d.csv?url'; +import premiumRequestUrl from '../../examples/premiumRequestUsageReport_1_c6fca30f0acd458098a95808eaf43399.csv?url'; +import tokenUsageUrl from '../../examples/Token.Usage.Report.csv?url'; +import seatActivityUrl from '../../examples/octodemo-seat-activity-1774680875.csv?url'; +import ghasCommittersUrl from '../../examples/ghas_active_committers_octodemo_2026-03-27T1521.csv?url'; +import dormantUsersUrl from '../../examples/export-octodemo-1774679438.csv?url'; +import enterpriseMembersUrl from '../../examples/export-octodemo-1774709193.csv?url'; + +const SAMPLES = [ + { name: 'usageReport.csv', url: usageReportUrl }, + { name: 'premiumRequestUsageReport.csv', url: premiumRequestUrl }, + { name: 'Token.Usage.Report.csv', url: tokenUsageUrl }, + { name: 'seat-activity.csv', url: seatActivityUrl }, + { name: 'ghas-active-committers.csv', url: ghasCommittersUrl }, + { name: 'dormant-users.csv', url: dormantUsersUrl }, + { name: 'enterprise-members.csv', url: enterpriseMembersUrl }, +] as const; + /** - * Lazily load example CSV files. These are code-split by Vite - * and only fetched when explicitly requested (e.g. from the tour prompt). - * Zero impact on normal users' bundle size. + * Load the example CSVs. These are emitted as static assets rather than + * imported with `?raw`, which would inline all 15 MB of them into a JS chunk + * the browser must download and parse as source before it can be a string. */ export async function loadSampleData(): Promise> { - const samples = await Promise.all([ - import('../../examples/usageReport_1_7f2ed6006ee54fb8af73f5cbb7ac1f1d.csv?raw').then( - (m) => ({ name: 'usageReport.csv', content: m.default }), - ), - import('../../examples/premiumRequestUsageReport_1_c6fca30f0acd458098a95808eaf43399.csv?raw').then( - (m) => ({ name: 'premiumRequestUsageReport.csv', content: m.default }), - ), - import('../../examples/Token.Usage.Report.csv?raw').then( - (m) => ({ name: 'Token.Usage.Report.csv', content: m.default }), - ), - import('../../examples/octodemo-seat-activity-1774680875.csv?raw').then( - (m) => ({ name: 'seat-activity.csv', content: m.default }), - ), - import('../../examples/ghas_active_committers_octodemo_2026-03-27T1521.csv?raw').then( - (m) => ({ name: 'ghas-active-committers.csv', content: m.default }), - ), - import('../../examples/export-octodemo-1774679438.csv?raw').then( - (m) => ({ name: 'enterprise-members.csv', content: m.default }), - ), - ]); - return samples; + return Promise.all( + SAMPLES.map(async ({ name, url }) => { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to load sample ${name}: ${response.status} ${response.statusText}`); + } + return { name, content: await response.text() }; + }), + ); } From 7050d9cb59e108f6bf0d8747cde1031c0aeb5ad3 Mon Sep 17 00:00:00 2001 From: Austen Stone Date: Thu, 20 Aug 2026 09:15:57 -0700 Subject: [PATCH 2/3] fix: make the typecheck gate actually check files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tsconfig.json is a solution-style config — "files": [] with only project references — so `tsc --noEmit` resolved zero files and always exited 0. CI has been running a typecheck gate that could never fail. `tsc -b --noEmit` walks the referenced projects instead. Verified by planting a deliberate type error: the old script reported nothing, the new one reports it. This immediately caught a real error in resolveGroupByColumn, which only surfaced in the build step: the row union has no index signature, so it can't be cast straight to Record. Read the column through an unknown-typed accessor instead. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- package.json | 2 +- src/lib/report-schema.ts | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 4d5d811..50c0faa 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", - "typecheck": "tsc --noEmit", + "typecheck": "tsc -b --noEmit", "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage", diff --git a/src/lib/report-schema.ts b/src/lib/report-schema.ts index a635f6e..024f5aa 100644 --- a/src/lib/report-schema.ts +++ b/src/lib/report-schema.ts @@ -366,10 +366,13 @@ export function resolveGroupByColumn( const schema = getReportSchema(report.type); if (!currentColumn) return schema.defaultGroupBy; - const rows = report.rows as Array>; + const readColumn = (row: unknown): unknown => + (row as Record | undefined)?.[currentColumn]; + + const rows: readonly unknown[] = report.rows; const stride = Math.max(1, Math.floor(rows.length / GROUP_BY_SAMPLE_SIZE)); for (let i = 0; i < rows.length; i += stride) { - const value = rows[i]?.[currentColumn]; + const value = readColumn(rows[i]); if (value !== undefined && value !== null && value !== '') return currentColumn; } return schema.defaultGroupBy; From edf270bde9800739eb658b69f55799488a90a7d6 Mon Sep 17 00:00:00 2001 From: Austen Stone Date: Thu, 20 Aug 2026 09:17:06 -0700 Subject: [PATCH 3/3] docs: correct the CSV export paths and privacy claim The README pointed everyone at "Settings > Billing > Usage report", which isn't where any of these reports live anymore. Replace it with a per-report table matching the in-app instructions in FileDropzone, and note that metered usage exports come in detailed and summarized shapes now that both parse. The privacy section claimed "100% client-side, no uploads, no telemetry". Accurate for CSV data, but formatters.ts does look up avatars from api.github.com for bot accounts. Narrow the claim to what the code does. Also drops the last /tbb/ scaffold paths from share-state.test.ts. The test mocks its own location so it passed either way, but the fixture should match the real base path. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 19 ++++++++++++++++--- src/lib/share-state.test.ts | 10 +++++----- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 27fa3a8..155551d 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Auto-detects the report type from CSV headers. Drop one file or a whole zip. | Report | What you get | |---|---| -| **Metered Usage** | Actions minutes, runner SKU breakdown, Copilot seats, Packages, LFS, storage costs | +| **Metered Usage** | Actions minutes, runner SKU breakdown, Copilot seats, Packages, LFS, storage costs. Detailed and summarized exports both supported | | **Copilot Premium Requests** | Per-user PRU consumption, model breakdown, quota tracking, AI credit costs | | **Copilot Token Usage** | Input/output tokens, cache creation/read, per-model cost with token-level granularity | | **GHAS Active Committers** | Advanced Security license consumption by user and repository | @@ -51,9 +51,10 @@ Auto-detects the report type from CSV headers. Drop one file or a whole zip. - Falls back to clipboard if URL exceeds 8,000 characters **Privacy** -- 100% client-side. No server, no uploads, no telemetry +- Your CSVs never leave the browser. No server, no uploads, no telemetry - Reports cached in IndexedDB across sessions - Sample data auto-removed when real data is imported +- The one outbound request is an avatar lookup to `api.github.com` for names matching a bot pattern (`*[bot]`). Human usernames are never sent anywhere ## Quick Start @@ -66,7 +67,19 @@ Drop a CSV onto the upload area, or append `?demo=auto` to load sample data inst ### Where to Get the CSVs -GitHub billing admins can export usage reports from **Settings → Billing → Usage report → Download CSV**. The app auto-detects the report type from headers. +Each report lives in a different part of GitHub's billing and admin UI. The app auto-detects the type from the headers, so you never have to say which one you're uploading. + +| Report | Where to export it | +|---|---| +| **Metered Usage** | Billing & Licensing → Usage → Metered Usage → **Get usage report** | +| **Copilot Premium Requests** | Billing & Licensing → Usage → Premium request analytics → **Get usage report** | +| **Copilot Token Usage** | Billing & Licensing → Usage → Premium request analytics → **Get usage report** | +| **GHAS Active Committers** | Licensing → GitHub Advanced Security → **Download CSV Report** | +| **Copilot Seat Activity** | AI Controls → Copilot → Access Management → **Download CSV report** | +| **Dormant Users** | Settings → Compliance → **Export** | +| **Enterprise Members** | Licensing → **Export** (next to GitHub Enterprise) | + +Metered usage exports come in two shapes and both work: **detailed** (up to 31 days, includes the user and workflow that incurred the cost) and **summarized** (up to a year, drops those columns). Pick summarized for long-range trends, detailed when you need to attribute spend to a person or workflow. ## Tech Stack diff --git a/src/lib/share-state.test.ts b/src/lib/share-state.test.ts index 36ad771..10d0fe6 100644 --- a/src/lib/share-state.test.ts +++ b/src/lib/share-state.test.ts @@ -8,7 +8,7 @@ describe('share URL round-trip', () => { it('buildShareURL → readShareData preserves filter state and CSV data', async () => { const { buildShareURL, readShareData } = await import('./share-state'); Object.defineProperty(window, 'location', { - value: { origin: 'http://localhost:5174', pathname: '/tbb/' }, + value: { origin: 'http://localhost:5174', pathname: '/github-actions-usage-report/' }, writable: true, }); @@ -37,7 +37,7 @@ describe('share URL round-trip', () => { it('returns null for oversized payloads', async () => { const { buildShareURL } = await import('./share-state'); Object.defineProperty(window, 'location', { - value: { origin: 'http://localhost:5174', pathname: '/tbb/' }, + value: { origin: 'http://localhost:5174', pathname: '/github-actions-usage-report/' }, writable: true, }); @@ -62,16 +62,16 @@ describe('clearShareHash', () => { it('strips share hash and preserves search params', () => { Object.defineProperty(window, 'location', { - value: { hash: '#data=xyz', pathname: '/tbb/', search: '?tab=table' }, + value: { hash: '#data=xyz', pathname: '/github-actions-usage-report/', search: '?tab=table' }, writable: true, }); clearShareHash(); - expect(replaceStateSpy).toHaveBeenCalledWith(null, '', '/tbb/?tab=table'); + expect(replaceStateSpy).toHaveBeenCalledWith(null, '', '/github-actions-usage-report/?tab=table'); }); it('ignores non-share hashes', () => { Object.defineProperty(window, 'location', { - value: { hash: '#section', pathname: '/tbb/', search: '' }, + value: { hash: '#section', pathname: '/github-actions-usage-report/', search: '' }, writable: true, }); clearShareHash();