The codebase follows Clean Architecture. Domain logic is pure JavaScript with no DOM dependencies. The presentation layer is React components that read from a central hook and pass data down as props. Interactive filter controls use Mantine v8 as a UI component library.
graph TD
subgraph Presentation["app/presentation/"]
CTX[context/AppContext.jsx]
COMP[components/**/*.jsx]
CHARTS[charts/*.jsx]
end
subgraph State["app/state/"]
HOOK[useAppState.js]
end
subgraph Domain["app/domain/"]
D1[config/constants.js]
D2[data/parser.js]
D3[data/merger.js]
D4[data/aggregator.js]
D5[filtering/engine.js]
D6[insights/engine.js]
D7[export/csv.js]
D8[export/ndjson.js]
D9[data/detect.js]
A1[aiusage/parser.js]
A2[aiusage/aggregator.js]
A3[aiusage/filtering.js]
A4[aiusage/insights.js]
A5[aiusage/budget.js]
A6[aiusage/export.js]
end
subgraph Common["common/"]
C1[utils/format.js]
C2[utils/download.js]
C3[types/index.js]
end
COMP --> CTX
CHARTS --> CTX
CTX --> HOOK
HOOK --> Domain
Domain --> Common
Domain modules have no imports from app/state/ or app/presentation/. They receive data as arguments and return values — this is what makes them testable in Node without a browser.
flowchart LR
A[User drops .ndjson file] --> B["parseNDJSON\nchunked, 10k lines/batch"]
B --> C["normalizeRecord\ncoerce types, drop invalids"]
C --> D["mergeRecords\nMath.max on overlapping exports"]
D --> E["useAppState.js\nrawData state"]
E --> F["filterRecords\ndate · user · IDE · language"]
F --> G["aggregateData\nbyUser · byDay · byIDE · byLanguage · byFeature · byModel"]
G --> H1["KpiSection"]
G --> H2["14 chart components"]
G --> H3["generateInsights → InsightsPanel"]
G --> H4["DataTable"]
Files are parsed once and stored in rawData. Every filter change re-runs filterRecords → aggregateData → render. The derived values are wrapped in useMemo, so they recompute only when their inputs change.
detectFileType(name, text) routes each uploaded file. A file whose first non-blank character is { (or has the .ndjson/.json extension) goes to the activity pipeline above. A CSV whose header carries the AI-usage column signature (username + quantity/aic_quantity + a cost/quota column) goes to the AI-usage pipeline:
flowchart LR
CSV[AI Usage Report CSV] --> P["parseAIUsageCSV\nRFC-4180, BOM-aware, header-driven"]
P --> AG["aggregateAIUsage\nbyUser · byDay · byModel · byOrg · byCostCenter · bySku"]
AG --> BUD["computeAIUsageBudget(records, licenseConfig)\nrun-rate projection · pooled overage"]
AG --> INS["generateAIUsageInsights + generateBudgetInsights"]
AG --> CH["AI Usage charts + budget cards + tables"]
The two datasets are never merged. useAppState holds both (rawData and aiUsageRaw) plus activeView; App.jsx renders Dashboard or AIUsageDashboard accordingly, and ViewTabs switches between them when both are loaded.
The budget model is the one piece that needs care: included credits pool at the billing entity level (per GitHub's usage-based billing), so computeAIUsageBudget projects month-end consumption from the observed run rate and computes overage as max(0, projected − total allowance) at the pooled level — overage accrues only once the shared pool is exhausted, not when an individual exceeds their seat share. Org and enterprise budgets are the sum of configured (or active-user) seat quotas; the per-user quota from the file is each seat's contribution to the pool, surfaced as a "heavy user" signal rather than a charge. See data.md for the full model.
graph TD
main["app/main.jsx\n(MantineProvider)"] --> App["App.jsx"]
App --> CTX["AppContext.Provider\n(useAppState)"]
App --> Footer["Footer (fixed, always visible)"]
CTX --> Upload["UploadZone"]
CTX --> Progress["ProgressBar"]
CTX --> Dashboard["Dashboard (activity)"]
CTX --> AIDash["AIUsageDashboard (cost)"]
Dashboard --> Tabs1[ViewTabs]
Dashboard --> Header
Dashboard --> FilterBar
Dashboard --> KpiSection
Dashboard --> Charts["14 chart components"]
Dashboard --> InsightsPanel
Dashboard --> DataTable
AIDash --> Tabs2[ViewTabs]
AIDash --> BudgetSummary
AIDash --> LicenseConfig
AIDash --> AICharts["budget + cost charts"]
AIDash --> Breakdown["BudgetBreakdownTable (org/user)"]
AIDash --> AITable["AIUsageTable"]
App picks the top-level view from state: UploadZone (no data), ProgressBar (loading), or — once data is loaded — Dashboard or AIUsageDashboard depending on activeView (and which dataset is present). ViewTabs renders only when both datasets are loaded, letting the user switch. Footer is always rendered, outside the conditional logic in App.jsx.
Interactive filter controls use Mantine v8:
FilterBarusesDatePickerInput type="range"(a single calendar popover where the user clicks start date then end date) andSelect(searchable, clearable dropdowns) for User, IDE, and Language filtersMantineProvideris configured inapp/main.jsxwithdefaultColorScheme="dark"and a custom green primary color- CSS variable overrides in
app/presentation/styles/global.cssunder[data-mantine-color-scheme="dark"]and[data-mantine-color-scheme="light"]blocks map design tokens for both color schemes
The header and upload screen both render a Sun/Moon toggle button using useMantineColorScheme() + useComputedColorScheme('dark'). The user's preference persists in localStorage automatically. Chart colors react to scheme changes via getChartColors() in chartOptions.js, which reads data-mantine-color-scheme from the document root. A MutationObserver in useChart.js watches for attribute changes and triggers chart rebuilds.
classDiagram
class useAppState {
CopilotRecord[] rawData
LoadedFile[] loadedFiles
FilterCriteria filters
ValueConfig valueConfig
boolean loading
Progress progress
CopilotRecord[] filteredData
AggregatedData aggregatedData
Insight[] insights
FilterOptions filterOptions
}
class CopilotRecord {
string user_login
string day
number code_generation_activity_count
number code_acceptance_activity_count
number loc_added_sum
number loc_deleted_sum
number active_time_minutes
IdeEntry[] totals_by_ide
FeatureEntry[] totals_by_feature
LanguageFeatureEntry[] totals_by_language_feature
LanguageModelEntry[] totals_by_language_model
ModelFeatureEntry[] totals_by_model_feature
}
class AggregatedData {
Object byUser
Object byDay
Object byIDE
Object byLanguage
Object byFeature
Object byModel
}
class FilterCriteria {
string|null dateFrom
string|null dateTo
string|null user
string|null ide
string|null language
}
useAppState --> CopilotRecord
useAppState --> AggregatedData
useAppState --> FilterCriteria
filteredData, aggregatedData, insights, and filterOptions are derived from rawData + filters via useMemo. The AI-usage side mirrors this with aiUsageRaw + aiUsageFilters → aiUsageFiltered, aiUsageAggregated, aiUsageBudget, and aiUsageInsights, plus activeView and the in-memory licenseConfig. No separate dispatch step.
The real GitHub Copilot Enterprise export format (as of late 2025):
{
"report_start_day": "2025-11-19",
"report_end_day": "2025-12-16",
"day": "2025-12-07",
"enterprise_id": "5429",
"user_id": 12345678,
"user_login": "octocat",
"user_initiated_interaction_count": 40,
"code_generation_activity_count": 101,
"code_acceptance_activity_count": 4,
"loc_suggested_to_add_sum": 617,
"loc_suggested_to_delete_sum": 0,
"loc_added_sum": 2452,
"loc_deleted_sum": 93,
"used_agent": true,
"used_chat": true,
"totals_by_ide": [...],
"totals_by_feature": [...],
"totals_by_language_feature": [...],
"totals_by_language_model": [...],
"totals_by_model_feature": [...]
}Field notes:
loc_suggested_to_add_sum— lines Copilot suggested (ghost text shown)loc_added_sum— lines actually accepted/applied (what landed in the file)active_time_minutes— not present in current API exports; parser defaults to 0model— not a root-level field; model info is intotals_by_language_modelandtotals_by_model_featureuser_initiated_interaction_count— user-triggered chat/agent interactions (not passive completions)
Data passes through two distinct aggregation stages. Understanding where each happens is important when adding new features.
Runs once per filter change. Produces AggregatedData used by all charts and KPI cards.
filteredData (CopilotRecord[])
└─ byUser { [login]: { generations, acceptances, linesAdded, linesDeleted, activeTime, days, features } }
└─ byDay { [YYYY-MM-DD]: { generations, chatCount, linesAdded, linesDeleted, activeUsers } }
└─ byIDE { [ide]: { generations, acceptances } }
└─ byLanguage { [lang]: { generations } }
└─ byFeature { [feature]: { generations, acceptances } }
└─ byModel { [model]: { generations } }
This is the single source of truth for all charts. Components read from useApp().aggregatedData, never re-aggregate themselves.
DataTable re-aggregates filteredData (not aggregatedData) independently so it can track days as a Set for the Days Active column and compute value columns on the fly using the current valueConfig. This is intentional — aggregatedData.byUser doesn't carry valueConfig-dependent values.
| View | Aggregation | Granularity |
|---|---|---|
| KPI cards | aggregateData() → byUser / byDay |
Period totals |
| All charts | aggregateData() → relevant slice |
Period totals |
| Insights | aggregateData() + filteredRecords |
Period totals + per-day checks |
| Data Explorer table | Component-level useMemo |
Per user, selected period |
| Header "Export CSV" | buildRawRecordsCSV — no aggregation |
Per user per day |
| Table "CSV" button | buildDataCSV — aggregated |
Per user, selected period |
| "Export NDJSON" | buildNDJSON(rawData) — all data, no filter |
Raw records |
GitHub Copilot Enterprise exports use 28-day rolling windows. Uploading two overlapping exports creates duplicate user_login + day records. mergeRecords() deduplicates by taking Math.max for all numeric fields — same day = same source data, so max is always correct. Nested arrays (totals_by_ide, etc.) keep the first-seen copy.
| Principle | Where it shows up |
|---|---|
| Single Responsibility | Each module has one job: parser.js parses, merger.js merges, aggregator.js aggregates. None of them render or touch the DOM. |
| Open/Closed | New insight: add a block to insights/engine.js, no existing code changes. New chart: add a JSX component under presentation/charts/. |
| Liskov | Not directly applicable (no inheritance). Composition used throughout. |
| Interface Segregation | Domain functions take only the data slice they need — filterRecords(records, criteria) doesn't receive the whole state. |
| Dependency Inversion | Domain modules depend on common/utils/format.js, not on browser APIs. The React layer wires the browser into the domain by passing callbacks (onProgress). |