@git-snitch/renderer 0.0.10 → 0.0.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@git-snitch/renderer",
3
- "version": "0.0.10",
3
+ "version": "0.0.12",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -35,8 +35,8 @@
35
35
  "react-dom": "^19.2.5",
36
36
  "recharts": "3.8.0",
37
37
  "vite": "^8.0.8",
38
- "@git-snitch/core": "0.0.10",
39
- "@git-snitch/ui": "0.0.10"
38
+ "@git-snitch/core": "0.0.12",
39
+ "@git-snitch/ui": "0.0.12"
40
40
  },
41
41
  "devDependencies": {
42
42
  "@testing-library/dom": "^10.4.1",
@@ -0,0 +1,108 @@
1
+ import { Card, CardContent, CardHeader, CardTitle } from "@git-snitch/ui/components/card";
2
+
3
+ import type { ColumnDef } from "@tanstack/react-table";
4
+ import { useMemo } from "react";
5
+ import type { AiUsageBreakdownItem, AiUsageSummary, ReportAiUsageProjectSummary } from "@git-snitch/core";
6
+
7
+ import { EmptyState } from "./empty-state.js";
8
+ import { DataTable } from "./tables.js";
9
+
10
+ type AiUsagePanelProps = {
11
+ readonly title?: string;
12
+ readonly description?: string;
13
+ readonly usage: AiUsageSummary | ReportAiUsageProjectSummary;
14
+ };
15
+
16
+ function formatNumber(value: number) {
17
+ return new Intl.NumberFormat("en").format(value);
18
+ }
19
+
20
+ export function formatAiUsageCost(value: number) {
21
+ return new Intl.NumberFormat("en", { style: "currency", currency: "USD", maximumFractionDigits: 4 }).format(value);
22
+ }
23
+
24
+ export function formatAiUsageTokens(value: number) {
25
+ return new Intl.NumberFormat("en", { notation: "compact", maximumFractionDigits: 1 }).format(value);
26
+ }
27
+
28
+ function hasBreakdowns(usage: AiUsageSummary | ReportAiUsageProjectSummary): usage is ReportAiUsageProjectSummary {
29
+ return "breakdowns" in usage;
30
+ }
31
+
32
+ function usageHasTotals(usage: AiUsageSummary) {
33
+ return usage.records > 0 || usage.tokens.total > 0 || usage.cost > 0;
34
+ }
35
+
36
+ function SummaryMetric({ label, value, description }: { readonly label: string; readonly value: string; readonly description: string }) {
37
+ return (
38
+ <div className="rounded-xl border border-border/70 bg-background/70 p-4">
39
+ <p className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">{label}</p>
40
+ <p className="mt-2 text-2xl font-semibold tracking-tight text-foreground">{value}</p>
41
+ <p className="mt-1 text-xs leading-5 text-muted-foreground">{description}</p>
42
+ </div>
43
+ );
44
+ }
45
+
46
+ const breakdownColumns: ColumnDef<AiUsageBreakdownItem>[] = [
47
+ { accessorKey: "key", header: "Name", cell: ({ row }) => <span className="font-medium text-foreground">{row.original.key}</span> },
48
+ { accessorKey: "records", header: "Messages", cell: ({ row }) => formatNumber(row.original.records) },
49
+ { accessorKey: "tokens.total", header: "Tokens", cell: ({ row }) => formatAiUsageTokens(row.original.tokens.total) },
50
+ { accessorKey: "cost", header: "Cost", cell: ({ row }) => formatAiUsageCost(row.original.cost) },
51
+ ];
52
+
53
+ function BreakdownTable({ title, rows, emptyDescription }: { readonly title: string; readonly rows: readonly AiUsageBreakdownItem[]; readonly emptyDescription: string }) {
54
+ const columns = useMemo(() => breakdownColumns, []);
55
+
56
+ return (
57
+ <DataTable
58
+ ariaLabel={title}
59
+ data={rows}
60
+ columns={columns}
61
+ search={{ placeholder: "Search", toText: (row) => row.key }}
62
+ exportConfig={{
63
+ filename: `${title.toLowerCase().replace(/\s+/g, "-")}.csv`,
64
+ mapRow: (row) => ({ name: row.key, messages: row.records, tokens: row.tokens.total, cost: row.cost }),
65
+ }}
66
+ emptyState={{ title: `${title} unavailable`, description: emptyDescription }}
67
+ />
68
+ );
69
+ }
70
+
71
+ export function AiUsagePanel({
72
+ title = "AI usage",
73
+ description = "Local assistant usage matched to this report. Workspace paths are not rendered in the HTML report.",
74
+ usage,
75
+ }: AiUsagePanelProps) {
76
+ const hasTotals = usageHasTotals(usage);
77
+
78
+ return (
79
+ <Card className="overflow-hidden shadow-none transition-transform duration-500 ease-out hover:-translate-y-0.5" aria-label={title}>
80
+ <CardHeader className="space-y-2">
81
+ <CardTitle className="text-base font-semibold tracking-tight text-foreground">{title}</CardTitle>
82
+ <p className="text-sm leading-6 text-muted-foreground">{description}</p>
83
+ </CardHeader>
84
+ <CardContent className="grid gap-4">
85
+ <div className="grid grid-flow-dense gap-3 sm:grid-cols-3">
86
+ <SummaryMetric label="Total tokens" value={formatNumber(usage.tokens.total)} description="Input, output, cache, and reasoning tokens." />
87
+ <SummaryMetric label="Estimated cost" value={formatAiUsageCost(usage.cost)} description="USD estimate reported or derived from local logs." />
88
+ <SummaryMetric label="Messages" value={formatNumber(usage.records)} description="Matched assistant usage records." />
89
+ </div>
90
+
91
+ {!hasTotals ? (
92
+ <EmptyState
93
+ title="No AI usage matched this report"
94
+ description="AI usage collection was enabled, but the matched local logs contain zero messages, tokens, and estimated cost for this report scope."
95
+ />
96
+ ) : null}
97
+
98
+ {hasBreakdowns(usage) ? (
99
+ <div className="grid grid-flow-dense gap-4 xl:grid-cols-3">
100
+ <BreakdownTable title="Client breakdown" rows={usage.breakdowns.byClient} emptyDescription="No client-level AI usage rows were available." />
101
+ <BreakdownTable title="Model breakdown" rows={usage.breakdowns.byModel} emptyDescription="No model-level AI usage rows were available." />
102
+ <BreakdownTable title="Recent days" rows={usage.breakdowns.byDay} emptyDescription="No dated AI usage rows were available." />
103
+ </div>
104
+ ) : null}
105
+ </CardContent>
106
+ </Card>
107
+ );
108
+ }
@@ -6,6 +6,7 @@ import type { RepoReportData, ReportData } from "@git-snitch/core";
6
6
  import {
7
7
  ActivityHeatmap,
8
8
  AdditionsVsDeletionsChart,
9
+ AiUsageBreakdownChart,
9
10
  CodeOwnershipChart,
10
11
  CommitActivityChart,
11
12
  CommitSizeDistributionChart,
@@ -17,6 +18,7 @@ import {
17
18
  WeeklyActivityChart,
18
19
  deriveActivityHeatmapData,
19
20
  deriveAdditionsVsDeletionsData,
21
+ deriveAiUsageBreakdownData,
20
22
  deriveCodeOwnershipData,
21
23
  deriveCommitActivityData,
22
24
  deriveCommitSizeDistributionData,
@@ -112,6 +114,8 @@ export function ChartsRoute({ report }: ChartsRouteProps) {
112
114
  const timeOfDay = deriveTimeOfDayData(report.commits);
113
115
  const contributorShare = deriveContributorPieData(report.contributors);
114
116
  const weeklyActivity = deriveWeeklyActivityData(report.commits);
117
+ const aiUsageByModel = deriveAiUsageBreakdownData(report.aiUsage?.breakdowns.byModel ?? []);
118
+ const aiUsageByHarness = deriveAiUsageBreakdownData(report.aiUsage?.breakdowns.byClient ?? []);
115
119
  const showContributorShare = contributorShare.length > 1;
116
120
 
117
121
  return (
@@ -153,6 +157,15 @@ export function ChartsRoute({ report }: ChartsRouteProps) {
153
157
  <ContributionCalendar data={calendar} />
154
158
  </div>
155
159
  </SectionFrame>
160
+
161
+ {report.aiUsage !== undefined ? (
162
+ <SectionFrame title="AI usage" description="Model and harness usage from matched local assistant logs. These charts stay scoped to this repository report payload.">
163
+ <div className="grid grid-flow-dense gap-5 lg:grid-cols-2">
164
+ <AiUsageBreakdownChart title="AI usage by model" description="Matched assistant tokens and messages grouped by model." data={aiUsageByModel} />
165
+ <AiUsageBreakdownChart title="AI usage by harness" description="Matched assistant tokens and messages grouped by local harness or client." data={aiUsageByHarness} />
166
+ </div>
167
+ </SectionFrame>
168
+ ) : null}
156
169
  </div>
157
170
  );
158
171
  }
package/src/charts.tsx CHANGED
@@ -21,7 +21,7 @@ import {
21
21
  } from "recharts";
22
22
  import type { ReactNode } from "react";
23
23
 
24
- import type { CommitRecord, ContributorSummary, RepoReportData, ScanProjectReport, ScanReportData } from "@git-snitch/core";
24
+ import type { AiUsageBreakdownItem, CommitRecord, ContributorSummary, RepoReportData, ReportAiUsageProjectSummary, ScanProjectReport, ScanReportData } from "@git-snitch/core";
25
25
 
26
26
  import { EmptyState } from "./empty-state.js";
27
27
 
@@ -42,6 +42,11 @@ export type VelocityPoint = { readonly period: string; readonly commits: number;
42
42
  export type CodeOwnershipPoint = { readonly owner: string; readonly additions: number; readonly deletions: number; readonly filesChanged: number };
43
43
  export type ProjectComparisonPoint = { readonly project: string; readonly commits: number; readonly contributors: number; readonly filesChanged: number };
44
44
  export type ActivityHeatmapCell = { readonly day: string; readonly hour: string; readonly commits: number };
45
+ export type AiUsageBreakdownPoint = { readonly name: string; readonly messages: number; readonly tokens: number; readonly cost: number };
46
+ export type ScanCommitSlice = { readonly name: string; readonly commits: number };
47
+ export type ScanChurnSlice = { readonly name: string; readonly additions: number; readonly deletions: number; readonly churn: number };
48
+ export type ScanAiProjectSlice = { readonly name: string; readonly messages: number; readonly tokens: number };
49
+ export type ScanAiModelSlice = { readonly name: string; readonly messages: number; readonly tokens: number };
45
50
 
46
51
  type ChartPanelProps = {
47
52
  readonly title: string;
@@ -213,6 +218,14 @@ export function deriveProjectsComparisonData(projects: readonly ScanProjectRepor
213
218
  .sort((left, right) => right.commits - left.commits || left.project.localeCompare(right.project));
214
219
  }
215
220
 
221
+ export function deriveAiUsageBreakdownData(rows: readonly AiUsageBreakdownItem[]): readonly AiUsageBreakdownPoint[] {
222
+ return rows
223
+ .filter((row) => row.records > 0 || row.tokens.total > 0 || row.cost > 0)
224
+ .map((row) => ({ name: row.key, messages: row.records, tokens: row.tokens.total, cost: row.cost }))
225
+ .sort((left, right) => right.tokens - left.tokens || right.messages - left.messages || left.name.localeCompare(right.name))
226
+ .slice(0, 8);
227
+ }
228
+
216
229
  export function deriveActivityHeatmapData(commits: readonly CommitRecord[]): readonly ActivityHeatmapCell[] {
217
230
  const counts = new Map<string, number>();
218
231
  for (const commit of commits) {
@@ -454,6 +467,23 @@ export function ProjectsComparisonChart({ data }: { readonly data: readonly Proj
454
467
  );
455
468
  }
456
469
 
470
+ export function AiUsageBreakdownChart({ title, description, data }: { readonly title: string; readonly description: string; readonly data: readonly AiUsageBreakdownPoint[] }) {
471
+ return (
472
+ <ChartPanel title={title} description={description} isEmpty={!hasPositiveValue(data, (item) => [item.messages, item.tokens])} emptyTitle={`No ${title.toLowerCase()} to chart`} emptyDescription="AI usage charts need matched local assistant records with model or harness metadata.">
473
+ <ChartContainer config={{ tokens: { label: "Tokens", color: chartPalette[2] }, messages: { label: "Messages", color: chartPalette[4] } }} className="h-72 w-full">
474
+ <BarChart data={data} layout="vertical" margin={{ left: 8, right: 16, top: 8, bottom: 0 }}>
475
+ <CartesianGrid horizontal={false} />
476
+ <XAxis type="number" hide />
477
+ <YAxis dataKey="name" type="category" width={124} tickLine={false} axisLine={false} />
478
+ <ChartTooltip content={<ChartTooltipContent />} />
479
+ <Bar dataKey="tokens" fill="var(--color-tokens)" radius={[0, 3, 3, 0]} {...staticChartProps} />
480
+ <Bar dataKey="messages" fill="var(--color-messages)" radius={[0, 3, 3, 0]} {...staticChartProps} />
481
+ </BarChart>
482
+ </ChartContainer>
483
+ </ChartPanel>
484
+ );
485
+ }
486
+
457
487
  export function ActivityHeatmap({ data }: { readonly data: readonly ActivityHeatmapCell[] }) {
458
488
  return (
459
489
  <ChartPanel title="Activity heatmap" description="UTC weekday and hour density." isEmpty={!hasPositiveValue(data, (item) => [item.commits])} emptyTitle="No activity heatmap to show" emptyDescription="The heatmap needs at least one dated commit.">
@@ -468,6 +498,167 @@ export function ActivityHeatmap({ data }: { readonly data: readonly ActivityHeat
468
498
  );
469
499
  }
470
500
 
501
+ export function ScanCommitBarChart({ data }: { readonly data: readonly ScanCommitSlice[] }) {
502
+ return (
503
+ <ChartPanel
504
+ title="Commits per project"
505
+ description="Commit count across scanned repositories."
506
+ isEmpty={!hasPositiveValue(data, (item) => [item.commits])}
507
+ emptyTitle="No commits to chart"
508
+ emptyDescription="Commit comparison needs at least one scanned project with commits."
509
+ >
510
+ <ChartContainer config={{ commits: { label: "Commits", color: chartPalette[1] } }} className="h-64 w-full">
511
+ <BarChart data={data} layout="vertical" margin={{ left: 8, right: 16, top: 8, bottom: 0 }}>
512
+ <CartesianGrid horizontal={false} />
513
+ <XAxis type="number" hide />
514
+ <YAxis dataKey="name" type="category" width={112} tickLine={false} axisLine={false} />
515
+ <ChartTooltip content={<ChartTooltipContent />} />
516
+ <Bar dataKey="commits" fill="var(--color-commits)" radius={[0, 3, 3, 0]} {...staticChartProps} />
517
+ </BarChart>
518
+ </ChartContainer>
519
+ </ChartPanel>
520
+ );
521
+ }
522
+
523
+ export function ScanChurnPieChart({ data }: { readonly data: readonly ScanChurnSlice[] }) {
524
+ return (
525
+ <ChartPanel
526
+ title="Churn per project"
527
+ description="Lines changed (additions + deletions) by project."
528
+ isEmpty={!hasPositiveValue(data, (item) => [item.churn])}
529
+ emptyTitle="No churn to chart"
530
+ emptyDescription="Churn data appears after file-level additions or deletions exist."
531
+ >
532
+ <ChartContainer config={{ churn: { label: "Churn" } }} className="h-64 w-full">
533
+ <PieChart>
534
+ <ChartTooltip content={<ChartTooltipContent nameKey="name" />} />
535
+ <Pie data={data} dataKey="churn" nameKey="name" innerRadius={56} outerRadius={88} paddingAngle={2} {...staticChartProps}>
536
+ {data.map((slice, index) => (
537
+ <Cell key={slice.name} fill={chartPalette[index % chartPalette.length]} />
538
+ ))}
539
+ </Pie>
540
+ </PieChart>
541
+ </ChartContainer>
542
+ </ChartPanel>
543
+ );
544
+ }
545
+
546
+ export function ScanAiMessagesPieChart({ data }: { readonly data: readonly ScanAiProjectSlice[] }) {
547
+ return (
548
+ <ChartPanel
549
+ title="AI messages per project"
550
+ description="Message share across scanned repositories."
551
+ isEmpty={!hasPositiveValue(data, (item) => [item.messages])}
552
+ emptyTitle="No AI messages to chart"
553
+ emptyDescription="AI message data appears after local assistant records are matched to projects."
554
+ >
555
+ <ChartContainer config={{ messages: { label: "Messages" } }} className="h-64 w-full">
556
+ <PieChart>
557
+ <ChartTooltip content={<ChartTooltipContent nameKey="name" />} />
558
+ <Pie data={data} dataKey="messages" nameKey="name" innerRadius={56} outerRadius={88} paddingAngle={2} {...staticChartProps}>
559
+ {data.map((slice, index) => (
560
+ <Cell key={slice.name} fill={chartPalette[index % chartPalette.length]} />
561
+ ))}
562
+ </Pie>
563
+ </PieChart>
564
+ </ChartContainer>
565
+ </ChartPanel>
566
+ );
567
+ }
568
+
569
+ export function ScanAiTokensBarChart({ data }: { readonly data: readonly ScanAiProjectSlice[] }) {
570
+ return (
571
+ <ChartPanel
572
+ title="AI tokens per project"
573
+ description="Token usage across scanned repositories."
574
+ isEmpty={!hasPositiveValue(data, (item) => [item.tokens])}
575
+ emptyTitle="No AI tokens to chart"
576
+ emptyDescription="AI token data appears after local assistant records are matched to projects."
577
+ >
578
+ <ChartContainer config={{ tokens: { label: "Tokens", color: chartPalette[2] } }} className="h-64 w-full">
579
+ <BarChart data={data} layout="vertical" margin={{ left: 8, right: 16, top: 8, bottom: 0 }}>
580
+ <CartesianGrid horizontal={false} />
581
+ <XAxis type="number" hide />
582
+ <YAxis dataKey="name" type="category" width={112} tickLine={false} axisLine={false} />
583
+ <ChartTooltip content={<ChartTooltipContent />} />
584
+ <Bar dataKey="tokens" fill="var(--color-tokens)" radius={[0, 3, 3, 0]} {...staticChartProps} />
585
+ </BarChart>
586
+ </ChartContainer>
587
+ </ChartPanel>
588
+ );
589
+ }
590
+
591
+ export function ScanAiModelsPieChart({ data }: { readonly data: readonly ScanAiModelSlice[] }) {
592
+ return (
593
+ <ChartPanel
594
+ title="AI usage by model"
595
+ description="Message share across all models used in the scan."
596
+ isEmpty={!hasPositiveValue(data, (item) => [item.messages])}
597
+ emptyTitle="No AI model data to chart"
598
+ emptyDescription="AI model data appears after local assistant records with model metadata are matched."
599
+ >
600
+ <ChartContainer config={{ messages: { label: "Messages" } }} className="h-64 w-full">
601
+ <PieChart>
602
+ <ChartTooltip content={<ChartTooltipContent nameKey="name" />} />
603
+ <Pie data={data} dataKey="messages" nameKey="name" innerRadius={56} outerRadius={88} paddingAngle={2} {...staticChartProps}>
604
+ {data.map((slice, index) => (
605
+ <Cell key={slice.name} fill={chartPalette[index % chartPalette.length]} />
606
+ ))}
607
+ </Pie>
608
+ </PieChart>
609
+ </ChartContainer>
610
+ </ChartPanel>
611
+ );
612
+ }
613
+
614
+ export function deriveScanCommitData(projects: readonly ScanProjectReport[]): readonly ScanCommitSlice[] {
615
+ return projects
616
+ .map((project) => ({
617
+ name: project.repository.name,
618
+ commits: project.report.commits.length,
619
+ }))
620
+ .sort((left, right) => right.commits - left.commits || left.name.localeCompare(right.name));
621
+ }
622
+
623
+ export function deriveScanChurnData(projects: readonly ScanProjectReport[]): readonly ScanChurnSlice[] {
624
+ return projects
625
+ .map((project) => {
626
+ let additions = 0;
627
+ let deletions = 0;
628
+ for (const commit of project.report.commits) {
629
+ for (const file of commit.files) {
630
+ additions += file.additions;
631
+ deletions += file.deletions;
632
+ }
633
+ }
634
+ return { name: project.repository.name, additions, deletions, churn: additions + deletions };
635
+ })
636
+ .filter((item) => item.churn > 0)
637
+ .sort((left, right) => right.churn - left.churn || left.name.localeCompare(right.name));
638
+ }
639
+
640
+ export function deriveScanAiPerProjectData(projects: readonly ScanProjectReport[]): readonly ScanAiProjectSlice[] {
641
+ return projects
642
+ .filter((project) => project.report.aiUsage !== undefined && project.report.aiUsage.records > 0)
643
+ .map((project) => ({
644
+ name: project.repository.name,
645
+ messages: project.report.aiUsage!.records,
646
+ tokens: project.report.aiUsage!.tokens.total,
647
+ }))
648
+ .sort((left, right) => right.messages - left.messages || left.name.localeCompare(right.name));
649
+ }
650
+
651
+ export function deriveScanAiModelsData(aiUsage: ReportAiUsageProjectSummary): readonly ScanAiModelSlice[] {
652
+ return aiUsage.breakdowns.byModel
653
+ .filter((item) => item.records > 0 || item.tokens.total > 0)
654
+ .map((item) => ({
655
+ name: item.key,
656
+ messages: item.records,
657
+ tokens: item.tokens.total,
658
+ }))
659
+ .sort((left, right) => right.messages - left.messages || left.name.localeCompare(right.name));
660
+ }
661
+
471
662
  function heatClass(commits: number) {
472
663
  if (commits >= 8) {
473
664
  return "border-chart-4/40 bg-chart-4";
package/src/layout.tsx CHANGED
@@ -53,7 +53,7 @@ export function Header({ title, titleHref, eyebrow, description, actions }: Head
53
53
 
54
54
  return (
55
55
  <header className="border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/75">
56
- <div className="mx-auto flex w-full max-w-7xl flex-col gap-6 px-5 py-8 sm:px-8 lg:flex-row lg:items-end lg:justify-between">
56
+ <div className="mx-auto flex w-full flex-col gap-6 px-6 py-8 sm:px-10 lg:flex-row lg:items-end lg:justify-between">
57
57
  <div className="max-w-4xl">
58
58
  {eyebrow ? <p className="text-xs font-medium uppercase tracking-[0.24em] text-muted-foreground">{eyebrow}</p> : null}
59
59
  {titleHref ? (
@@ -81,7 +81,7 @@ export function Navigation({ items, label = "Report sections" }: NavigationProps
81
81
 
82
82
  return (
83
83
  <nav aria-label={label} className="border-b bg-background/80">
84
- <div className="mx-auto flex w-full max-w-7xl gap-2 overflow-x-auto px-5 py-3 sm:px-8">
84
+ <div className="mx-auto flex w-full gap-2 overflow-x-auto px-6 py-3 sm:px-10">
85
85
  {items.map((item) =>
86
86
  item.disabled ? (
87
87
  <span
@@ -146,7 +146,7 @@ export function AppShell({
146
146
  <main className="min-h-screen w-full max-w-full overflow-x-hidden bg-background text-foreground transition-colors">
147
147
  <Header title={title} titleHref={titleHref} eyebrow={eyebrow} description={description} actions={headerActions} />
148
148
  <Navigation items={navigationItems} />
149
- <div className={cn("mx-auto flex w-full max-w-7xl flex-col gap-8 px-5 py-8 sm:px-8 sm:py-10")}>{children}</div>
149
+ <div className={cn("mx-auto flex w-full flex-col gap-8 px-6 py-8 sm:px-10 sm:py-10")}>{children}</div>
150
150
  </main>
151
151
  );
152
152
  }
package/src/overview.tsx CHANGED
@@ -2,6 +2,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@git-snitch/ui/compone
2
2
 
3
3
  import type { CommitRecord, GitHubRepoMeta, RepoReportData, ReportData } from "@git-snitch/core";
4
4
 
5
+ import { AiUsagePanel } from "./ai-usage.js";
5
6
  import { CommitActivityChart, deriveCommitActivityData } from "./charts.js";
6
7
  import { EmptyState } from "./empty-state.js";
7
8
  import { StatsGrid } from "./layout.js";
@@ -232,6 +233,7 @@ export function RepoOverview({ report }: { readonly report: ReportData }) {
232
233
  <div className="grid gap-6">
233
234
  <StatsGrid stats={buildOverviewStats(report)} />
234
235
  {githubMeta !== undefined ? <GitHubMetaBar meta={githubMeta} /> : null}
236
+ {report.aiUsage !== undefined ? <AiUsagePanel usage={report.aiUsage} /> : null}
235
237
  <EmptyState
236
238
  title="This repository has no commit activity yet"
237
239
  description="git-snitch found a repository report, but there are no commits or contributors to summarize. Charts and streaks will appear after activity exists."
@@ -244,6 +246,7 @@ export function RepoOverview({ report }: { readonly report: ReportData }) {
244
246
  <div className="grid gap-6">
245
247
  <StatsGrid stats={buildOverviewStats(report)} />
246
248
  {githubMeta !== undefined ? <GitHubMetaBar meta={githubMeta} /> : null}
249
+ {report.aiUsage !== undefined ? <AiUsagePanel usage={report.aiUsage} /> : null}
247
250
  <section aria-label="Repository overview previews" className="grid grid-flow-dense gap-6 lg:grid-cols-2">
248
251
  <StreakCard streak={deriveStreakSummary(report.commits)} />
249
252
  <ChartPreview report={report} />
@@ -3,7 +3,21 @@ import { useMemo, useState } from "react";
3
3
  import type { ContributorSummary, RepoReportData, ReportData, ScanProjectReport, ScanReportData } from "@git-snitch/core";
4
4
  import { cn } from "@git-snitch/ui/lib/utils";
5
5
 
6
+ import { AiUsagePanel, formatAiUsageCost, formatAiUsageTokens } from "./ai-usage.js";
6
7
  import { ChartsRoute } from "./charts-route.js";
8
+ import {
9
+ LanguageDistributionChart,
10
+ ScanAiMessagesPieChart,
11
+ ScanAiModelsPieChart,
12
+ ScanAiTokensBarChart,
13
+ ScanChurnPieChart,
14
+ ScanCommitBarChart,
15
+ deriveLanguageDistributionData,
16
+ deriveScanAiModelsData,
17
+ deriveScanAiPerProjectData,
18
+ deriveScanChurnData,
19
+ deriveScanCommitData,
20
+ } from "./charts.js";
7
21
  import { EmptyState } from "./empty-state.js";
8
22
  import { StatsGrid } from "./layout.js";
9
23
  import { RepoOverview } from "./overview.js";
@@ -43,6 +57,9 @@ type ProjectComparisonRow = {
43
57
  readonly contributors: number;
44
58
  readonly churn: number;
45
59
  readonly lastCommitAt: string | undefined;
60
+ readonly aiMessages: number | undefined;
61
+ readonly aiTokens: number | undefined;
62
+ readonly aiCost: number | undefined;
46
63
  };
47
64
 
48
65
  export type ScanProjectRouteEntry = {
@@ -180,7 +197,7 @@ export function deriveCrossProjectContributors(report: ScanReportData): readonly
180
197
  .sort((left, right) => right.commitCount - left.commitCount || right.projectCount - left.projectCount || left.name.localeCompare(right.name));
181
198
  }
182
199
 
183
- const projectComparisonColumns: ColumnDef<ProjectComparisonRow>[] = [
200
+ const projectComparisonBaseColumns: ColumnDef<ProjectComparisonRow>[] = [
184
201
  {
185
202
  accessorKey: "label",
186
203
  header: "Project",
@@ -208,6 +225,12 @@ const projectComparisonColumns: ColumnDef<ProjectComparisonRow>[] = [
208
225
  { accessorKey: "lastCommitAt", header: "Last commit", cell: ({ row }) => formatDate(row.original.lastCommitAt) },
209
226
  ];
210
227
 
228
+ const projectComparisonAiUsageColumns: ColumnDef<ProjectComparisonRow>[] = [
229
+ { accessorKey: "aiMessages", header: "AI messages", cell: ({ row }) => row.original.aiMessages === undefined ? "—" : formatNumber(row.original.aiMessages) },
230
+ { accessorKey: "aiTokens", header: "AI tokens", cell: ({ row }) => row.original.aiTokens === undefined ? "—" : formatAiUsageTokens(row.original.aiTokens) },
231
+ { accessorKey: "aiCost", header: "AI cost", cell: ({ row }) => row.original.aiCost === undefined ? "—" : formatAiUsageCost(row.original.aiCost) },
232
+ ];
233
+
211
234
  const crossProjectContributorColumns: ColumnDef<ContributorAggregate>[] = [
212
235
  {
213
236
  accessorKey: "name",
@@ -245,19 +268,31 @@ function ScanIntro({ report }: { readonly report: ScanReportData }) {
245
268
 
246
269
  function ProjectComparison({ report }: { readonly report: ScanReportData }) {
247
270
  const entries = deriveScanProjectRouteEntries(report);
271
+ const hasProjectAiUsage = report.projects.some((project) => project.report.aiUsage !== undefined);
272
+ const columns = useMemo(
273
+ () => hasProjectAiUsage ? [...projectComparisonBaseColumns, ...projectComparisonAiUsageColumns] : projectComparisonBaseColumns,
274
+ [hasProjectAiUsage],
275
+ );
248
276
 
249
277
  const rows: readonly ProjectComparisonRow[] = useMemo(
250
278
  () =>
251
- entries.map((entry) => ({
252
- slug: entry.slug,
253
- href: entry.href,
254
- label: entry.label,
255
- remoteUrl: entry.project.repository.remoteUrl,
256
- commits: entry.project.report.commits.length,
257
- contributors: entry.project.report.contributors.length,
258
- churn: totalChurn(entry.project.report),
259
- lastCommitAt: entry.project.repository.lastCommitAt,
260
- })),
279
+ entries.map((entry) => {
280
+ const usage = entry.project.report.aiUsage;
281
+
282
+ return {
283
+ slug: entry.slug,
284
+ href: entry.href,
285
+ label: entry.label,
286
+ remoteUrl: entry.project.repository.remoteUrl,
287
+ commits: entry.project.report.commits.length,
288
+ contributors: entry.project.report.contributors.length,
289
+ churn: totalChurn(entry.project.report),
290
+ lastCommitAt: entry.project.repository.lastCommitAt,
291
+ aiMessages: usage?.records,
292
+ aiTokens: usage?.tokens.total,
293
+ aiCost: usage?.cost,
294
+ };
295
+ }),
261
296
  [entries],
262
297
  );
263
298
 
@@ -265,7 +300,7 @@ function ProjectComparison({ report }: { readonly report: ScanReportData }) {
265
300
  <DataTable
266
301
  ariaLabel="Project comparison"
267
302
  data={rows}
268
- columns={projectComparisonColumns}
303
+ columns={columns}
269
304
  search={{ placeholder: "Search projects", toText: (row) => `${row.label}` }}
270
305
  emptyState={{
271
306
  title: "No repositories matched this scan",
@@ -296,6 +331,28 @@ function CrossProjectContributors({ report }: { readonly report: ScanReportData
296
331
  );
297
332
  }
298
333
 
334
+ function ScanCharts({ report }: { readonly report: ScanReportData }) {
335
+ const commitData = deriveScanCommitData(report.projects);
336
+ const churnData = deriveScanChurnData(report.projects);
337
+ const languageData = deriveLanguageDistributionData(report);
338
+ const aiPerProject = deriveScanAiPerProjectData(report.projects);
339
+ const aiModels = report.analysis.aiUsage !== undefined ? deriveScanAiModelsData(report.analysis.aiUsage) : [];
340
+
341
+ return (
342
+ <section aria-label="Visual comparison">
343
+ <h2 className="mb-4 text-lg font-semibold tracking-tight text-foreground">Visual comparison</h2>
344
+ <div className="grid gap-6 lg:grid-cols-3">
345
+ <ScanCommitBarChart data={commitData} />
346
+ <ScanChurnPieChart data={churnData} />
347
+ <LanguageDistributionChart data={languageData} />
348
+ {aiPerProject.length > 0 ? <ScanAiMessagesPieChart data={aiPerProject} /> : null}
349
+ {aiPerProject.length > 0 ? <ScanAiTokensBarChart data={aiPerProject} /> : null}
350
+ {aiModels.length > 0 ? <ScanAiModelsPieChart data={aiModels} /> : null}
351
+ </div>
352
+ </section>
353
+ );
354
+ }
355
+
299
356
  export function ScanOverview({ report }: ScanRouteProps) {
300
357
  if (report.kind !== "scan") {
301
358
  return scanDataMismatch("Scan overview is unavailable for repository reports");
@@ -305,7 +362,15 @@ export function ScanOverview({ report }: ScanRouteProps) {
305
362
  <div className="grid gap-6">
306
363
  <ScanIntro report={report} />
307
364
  <StatsGrid stats={buildScanStats(report)} />
365
+ {report.analysis.aiUsage !== undefined ? (
366
+ <AiUsagePanel
367
+ title="Scan AI usage"
368
+ description="Total matched local assistant usage across repositories in this scan. Workspace paths are not rendered in the HTML report."
369
+ usage={report.analysis.aiUsage}
370
+ />
371
+ ) : null}
308
372
  <ProjectComparison report={report} />
373
+ <ScanCharts report={report} />
309
374
  <CrossProjectContributors report={report} />
310
375
  </div>
311
376
  );