@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/dist/charts.d.ts +52 -1
- package/dist/charts.d.ts.map +1 -1
- package/dist/charts.js +69 -0
- package/dist/layout.js +3 -3
- package/dist/template/report-template.html +18 -18
- package/package.json +3 -3
- package/src/ai-usage.tsx +108 -0
- package/src/charts-route.tsx +13 -0
- package/src/charts.tsx +192 -1
- package/src/layout.tsx +3 -3
- package/src/overview.tsx +3 -0
- package/src/scan-routes.tsx +77 -12
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@git-snitch/renderer",
|
|
3
|
-
"version": "0.0.
|
|
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.
|
|
39
|
-
"@git-snitch/ui": "0.0.
|
|
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",
|
package/src/ai-usage.tsx
ADDED
|
@@ -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
|
+
}
|
package/src/charts-route.tsx
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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} />
|
package/src/scan-routes.tsx
CHANGED
|
@@ -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
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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={
|
|
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
|
);
|