@git-snitch/renderer 0.0.9 → 0.0.11

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.9",
3
+ "version": "0.0.11",
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.9",
39
- "@git-snitch/ui": "0.0.9"
38
+ "@git-snitch/core": "0.0.11",
39
+ "@git-snitch/ui": "0.0.11"
40
40
  },
41
41
  "devDependencies": {
42
42
  "@testing-library/dom": "^10.4.1",
@@ -0,0 +1,119 @@
1
+ import { Card, CardContent, CardHeader, CardTitle } from "@git-snitch/ui/components/card";
2
+ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@git-snitch/ui/components/table";
3
+
4
+ import type { AiUsageBreakdownItem, AiUsageSummary, ReportAiUsageProjectSummary } from "@git-snitch/core";
5
+
6
+ import { EmptyState } from "./empty-state.js";
7
+
8
+ type AiUsagePanelProps = {
9
+ readonly title?: string;
10
+ readonly description?: string;
11
+ readonly usage: AiUsageSummary | ReportAiUsageProjectSummary;
12
+ };
13
+
14
+ function formatNumber(value: number) {
15
+ return new Intl.NumberFormat("en").format(value);
16
+ }
17
+
18
+ export function formatAiUsageCost(value: number) {
19
+ return new Intl.NumberFormat("en", { style: "currency", currency: "USD", maximumFractionDigits: 4 }).format(value);
20
+ }
21
+
22
+ export function formatAiUsageTokens(value: number) {
23
+ return new Intl.NumberFormat("en", { notation: "compact", maximumFractionDigits: 1 }).format(value);
24
+ }
25
+
26
+ function hasBreakdowns(usage: AiUsageSummary | ReportAiUsageProjectSummary): usage is ReportAiUsageProjectSummary {
27
+ return "breakdowns" in usage;
28
+ }
29
+
30
+ function usageHasTotals(usage: AiUsageSummary) {
31
+ return usage.records > 0 || usage.tokens.total > 0 || usage.cost > 0;
32
+ }
33
+
34
+ function SummaryMetric({ label, value, description }: { readonly label: string; readonly value: string; readonly description: string }) {
35
+ return (
36
+ <div className="rounded-xl border border-border/70 bg-background/70 p-4">
37
+ <p className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">{label}</p>
38
+ <p className="mt-2 text-2xl font-semibold tracking-tight text-foreground">{value}</p>
39
+ <p className="mt-1 text-xs leading-5 text-muted-foreground">{description}</p>
40
+ </div>
41
+ );
42
+ }
43
+
44
+ function BreakdownTable({ title, rows, emptyDescription }: { readonly title: string; readonly rows: readonly AiUsageBreakdownItem[]; readonly emptyDescription: string }) {
45
+ const visibleRows = rows.slice(0, 8);
46
+
47
+ return (
48
+ <div className="rounded-xl border border-border/70 bg-background/70">
49
+ <div className="border-b border-border/70 px-4 py-3">
50
+ <h4 className="text-sm font-semibold text-foreground">{title}</h4>
51
+ </div>
52
+ {visibleRows.length === 0 ? (
53
+ <div className="p-4">
54
+ <EmptyState title={`${title} unavailable`} description={emptyDescription} />
55
+ </div>
56
+ ) : (
57
+ <Table aria-label={title}>
58
+ <TableHeader>
59
+ <TableRow className="hover:bg-transparent">
60
+ <TableHead className="bg-muted/30 px-3 py-2 text-[0.7rem] uppercase tracking-[0.18em] text-muted-foreground">Name</TableHead>
61
+ <TableHead className="bg-muted/30 px-3 py-2 text-[0.7rem] uppercase tracking-[0.18em] text-muted-foreground">Messages</TableHead>
62
+ <TableHead className="bg-muted/30 px-3 py-2 text-[0.7rem] uppercase tracking-[0.18em] text-muted-foreground">Tokens</TableHead>
63
+ <TableHead className="bg-muted/30 px-3 py-2 text-[0.7rem] uppercase tracking-[0.18em] text-muted-foreground">Cost</TableHead>
64
+ </TableRow>
65
+ </TableHeader>
66
+ <TableBody>
67
+ {visibleRows.map((row) => (
68
+ <TableRow key={row.key}>
69
+ <TableCell className="px-3 py-3 text-sm font-medium text-foreground">{row.key}</TableCell>
70
+ <TableCell className="px-3 py-3 text-sm text-muted-foreground">{formatNumber(row.records)}</TableCell>
71
+ <TableCell className="px-3 py-3 text-sm text-muted-foreground">{formatNumber(row.tokens.total)}</TableCell>
72
+ <TableCell className="px-3 py-3 text-sm text-muted-foreground">{formatAiUsageCost(row.cost)}</TableCell>
73
+ </TableRow>
74
+ ))}
75
+ </TableBody>
76
+ </Table>
77
+ )}
78
+ </div>
79
+ );
80
+ }
81
+
82
+ export function AiUsagePanel({
83
+ title = "AI usage",
84
+ description = "Local assistant usage matched to this report. Workspace paths are not rendered in the HTML report.",
85
+ usage,
86
+ }: AiUsagePanelProps) {
87
+ const hasTotals = usageHasTotals(usage);
88
+
89
+ return (
90
+ <Card className="overflow-hidden shadow-none transition-transform duration-500 ease-out hover:-translate-y-0.5" aria-label={title}>
91
+ <CardHeader className="space-y-2">
92
+ <CardTitle className="text-base font-semibold tracking-tight text-foreground">{title}</CardTitle>
93
+ <p className="text-sm leading-6 text-muted-foreground">{description}</p>
94
+ </CardHeader>
95
+ <CardContent className="grid gap-4">
96
+ <div className="grid grid-flow-dense gap-3 sm:grid-cols-3">
97
+ <SummaryMetric label="Total tokens" value={formatNumber(usage.tokens.total)} description="Input, output, cache, and reasoning tokens." />
98
+ <SummaryMetric label="Estimated cost" value={formatAiUsageCost(usage.cost)} description="USD estimate reported or derived from local logs." />
99
+ <SummaryMetric label="Messages" value={formatNumber(usage.records)} description="Matched assistant usage records." />
100
+ </div>
101
+
102
+ {!hasTotals ? (
103
+ <EmptyState
104
+ title="No AI usage matched this report"
105
+ description="AI usage collection was enabled, but the matched local logs contain zero messages, tokens, and estimated cost for this report scope."
106
+ />
107
+ ) : null}
108
+
109
+ {hasBreakdowns(usage) ? (
110
+ <div className="grid grid-flow-dense gap-4 xl:grid-cols-3">
111
+ <BreakdownTable title="Client breakdown" rows={usage.breakdowns.byClient} emptyDescription="No client-level AI usage rows were available." />
112
+ <BreakdownTable title="Model breakdown" rows={usage.breakdowns.byModel} emptyDescription="No model-level AI usage rows were available." />
113
+ <BreakdownTable title="Recent days" rows={usage.breakdowns.byDay} emptyDescription="No dated AI usage rows were available." />
114
+ </div>
115
+ ) : null}
116
+ </CardContent>
117
+ </Card>
118
+ );
119
+ }
@@ -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, ScanProjectReport, ScanReportData } from "@git-snitch/core";
25
25
 
26
26
  import { EmptyState } from "./empty-state.js";
27
27
 
@@ -42,6 +42,7 @@ 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 };
45
46
 
46
47
  type ChartPanelProps = {
47
48
  readonly title: string;
@@ -213,6 +214,14 @@ export function deriveProjectsComparisonData(projects: readonly ScanProjectRepor
213
214
  .sort((left, right) => right.commits - left.commits || left.project.localeCompare(right.project));
214
215
  }
215
216
 
217
+ export function deriveAiUsageBreakdownData(rows: readonly AiUsageBreakdownItem[]): readonly AiUsageBreakdownPoint[] {
218
+ return rows
219
+ .filter((row) => row.records > 0 || row.tokens.total > 0 || row.cost > 0)
220
+ .map((row) => ({ name: row.key, messages: row.records, tokens: row.tokens.total, cost: row.cost }))
221
+ .sort((left, right) => right.tokens - left.tokens || right.messages - left.messages || left.name.localeCompare(right.name))
222
+ .slice(0, 8);
223
+ }
224
+
216
225
  export function deriveActivityHeatmapData(commits: readonly CommitRecord[]): readonly ActivityHeatmapCell[] {
217
226
  const counts = new Map<string, number>();
218
227
  for (const commit of commits) {
@@ -454,6 +463,23 @@ export function ProjectsComparisonChart({ data }: { readonly data: readonly Proj
454
463
  );
455
464
  }
456
465
 
466
+ export function AiUsageBreakdownChart({ title, description, data }: { readonly title: string; readonly description: string; readonly data: readonly AiUsageBreakdownPoint[] }) {
467
+ return (
468
+ <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.">
469
+ <ChartContainer config={{ tokens: { label: "Tokens", color: chartPalette[2] }, messages: { label: "Messages", color: chartPalette[4] } }} className="h-72 w-full">
470
+ <BarChart data={data} layout="vertical" margin={{ left: 8, right: 16, top: 8, bottom: 0 }}>
471
+ <CartesianGrid horizontal={false} />
472
+ <XAxis type="number" hide />
473
+ <YAxis dataKey="name" type="category" width={124} tickLine={false} axisLine={false} />
474
+ <ChartTooltip content={<ChartTooltipContent />} />
475
+ <Bar dataKey="tokens" fill="var(--color-tokens)" radius={[0, 3, 3, 0]} {...staticChartProps} />
476
+ <Bar dataKey="messages" fill="var(--color-messages)" radius={[0, 3, 3, 0]} {...staticChartProps} />
477
+ </BarChart>
478
+ </ChartContainer>
479
+ </ChartPanel>
480
+ );
481
+ }
482
+
457
483
  export function ActivityHeatmap({ data }: { readonly data: readonly ActivityHeatmapCell[] }) {
458
484
  return (
459
485
  <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.">
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,6 +3,7 @@ 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";
7
8
  import { EmptyState } from "./empty-state.js";
8
9
  import { StatsGrid } from "./layout.js";
@@ -43,6 +44,9 @@ type ProjectComparisonRow = {
43
44
  readonly contributors: number;
44
45
  readonly churn: number;
45
46
  readonly lastCommitAt: string | undefined;
47
+ readonly aiMessages: number | undefined;
48
+ readonly aiTokens: number | undefined;
49
+ readonly aiCost: number | undefined;
46
50
  };
47
51
 
48
52
  export type ScanProjectRouteEntry = {
@@ -180,7 +184,7 @@ export function deriveCrossProjectContributors(report: ScanReportData): readonly
180
184
  .sort((left, right) => right.commitCount - left.commitCount || right.projectCount - left.projectCount || left.name.localeCompare(right.name));
181
185
  }
182
186
 
183
- const projectComparisonColumns: ColumnDef<ProjectComparisonRow>[] = [
187
+ const projectComparisonBaseColumns: ColumnDef<ProjectComparisonRow>[] = [
184
188
  {
185
189
  accessorKey: "label",
186
190
  header: "Project",
@@ -208,6 +212,12 @@ const projectComparisonColumns: ColumnDef<ProjectComparisonRow>[] = [
208
212
  { accessorKey: "lastCommitAt", header: "Last commit", cell: ({ row }) => formatDate(row.original.lastCommitAt) },
209
213
  ];
210
214
 
215
+ const projectComparisonAiUsageColumns: ColumnDef<ProjectComparisonRow>[] = [
216
+ { accessorKey: "aiMessages", header: "AI messages", cell: ({ row }) => row.original.aiMessages === undefined ? "—" : formatNumber(row.original.aiMessages) },
217
+ { accessorKey: "aiTokens", header: "AI tokens", cell: ({ row }) => row.original.aiTokens === undefined ? "—" : formatAiUsageTokens(row.original.aiTokens) },
218
+ { accessorKey: "aiCost", header: "AI cost", cell: ({ row }) => row.original.aiCost === undefined ? "—" : formatAiUsageCost(row.original.aiCost) },
219
+ ];
220
+
211
221
  const crossProjectContributorColumns: ColumnDef<ContributorAggregate>[] = [
212
222
  {
213
223
  accessorKey: "name",
@@ -245,19 +255,31 @@ function ScanIntro({ report }: { readonly report: ScanReportData }) {
245
255
 
246
256
  function ProjectComparison({ report }: { readonly report: ScanReportData }) {
247
257
  const entries = deriveScanProjectRouteEntries(report);
258
+ const hasProjectAiUsage = report.projects.some((project) => project.report.aiUsage !== undefined);
259
+ const columns = useMemo(
260
+ () => hasProjectAiUsage ? [...projectComparisonBaseColumns, ...projectComparisonAiUsageColumns] : projectComparisonBaseColumns,
261
+ [hasProjectAiUsage],
262
+ );
248
263
 
249
264
  const rows: readonly ProjectComparisonRow[] = useMemo(
250
265
  () =>
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
- })),
266
+ entries.map((entry) => {
267
+ const usage = entry.project.report.aiUsage;
268
+
269
+ return {
270
+ slug: entry.slug,
271
+ href: entry.href,
272
+ label: entry.label,
273
+ remoteUrl: entry.project.repository.remoteUrl,
274
+ commits: entry.project.report.commits.length,
275
+ contributors: entry.project.report.contributors.length,
276
+ churn: totalChurn(entry.project.report),
277
+ lastCommitAt: entry.project.repository.lastCommitAt,
278
+ aiMessages: usage?.records,
279
+ aiTokens: usage?.tokens.total,
280
+ aiCost: usage?.cost,
281
+ };
282
+ }),
261
283
  [entries],
262
284
  );
263
285
 
@@ -265,7 +287,7 @@ function ProjectComparison({ report }: { readonly report: ScanReportData }) {
265
287
  <DataTable
266
288
  ariaLabel="Project comparison"
267
289
  data={rows}
268
- columns={projectComparisonColumns}
290
+ columns={columns}
269
291
  search={{ placeholder: "Search projects", toText: (row) => `${row.label}` }}
270
292
  emptyState={{
271
293
  title: "No repositories matched this scan",
@@ -305,6 +327,13 @@ export function ScanOverview({ report }: ScanRouteProps) {
305
327
  <div className="grid gap-6">
306
328
  <ScanIntro report={report} />
307
329
  <StatsGrid stats={buildScanStats(report)} />
330
+ {report.analysis.aiUsage !== undefined ? (
331
+ <AiUsagePanel
332
+ title="Scan AI usage"
333
+ description="Total matched local assistant usage across repositories in this scan. Workspace paths are not rendered in the HTML report."
334
+ usage={report.analysis.aiUsage}
335
+ />
336
+ ) : null}
308
337
  <ProjectComparison report={report} />
309
338
  <CrossProjectContributors report={report} />
310
339
  </div>
package/vite.config.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import tailwindcss from "@tailwindcss/vite";
2
2
  import viteReact from "@vitejs/plugin-react";
3
+ import { createRequire } from "node:module";
4
+ import { basename, dirname, resolve } from "node:path";
3
5
  import { fileURLToPath } from "node:url";
4
6
  import { defineConfig } from "vite";
5
7
 
@@ -7,9 +9,27 @@ import { createInlineHtmlPlugin } from "./src/inline-plugin";
7
9
 
8
10
  const packageDirectory = fileURLToPath(new URL(".", import.meta.url));
9
11
  const defaultTemplateModule = fileURLToPath(new URL("./src/custom-templates.ts", import.meta.url));
10
- const packageNodeModules = fileURLToPath(new URL("./node_modules/", import.meta.url));
11
12
  const customTemplateModule = process.env.GIT_SNITCH_TEMPLATE_MODULE;
12
13
 
14
+ const require = createRequire(import.meta.url);
15
+
16
+ function resolvePackageModule(specifier: string): string {
17
+ const entry = require.resolve(specifier);
18
+ return entry;
19
+ }
20
+
21
+ function resolvePackageRoot(pkgName: string): string {
22
+ const entry = require.resolve(pkgName);
23
+ let dir = dirname(entry);
24
+ while (basename(dir) !== pkgName && dirname(dir) !== dir) {
25
+ dir = dirname(dir);
26
+ }
27
+ return dir;
28
+ }
29
+
30
+ const reactRoot = resolvePackageRoot("react");
31
+ const reactDomRoot = resolvePackageRoot("react-dom");
32
+
13
33
  export default defineConfig({
14
34
  base: "./",
15
35
  build: {
@@ -30,9 +50,10 @@ export default defineConfig({
30
50
  resolve: {
31
51
  alias: {
32
52
  "@git-snitch/renderer": fileURLToPath(new URL("./src", import.meta.url)),
33
- react: `${packageNodeModules}react`,
34
- "react/jsx-dev-runtime": `${packageNodeModules}react/jsx-dev-runtime.js`,
35
- "react/jsx-runtime": `${packageNodeModules}react/jsx-runtime.js`,
53
+ react: resolve(reactRoot),
54
+ "react/jsx-dev-runtime": resolvePackageModule("react/jsx-dev-runtime"),
55
+ "react/jsx-runtime": resolvePackageModule("react/jsx-runtime"),
56
+ "react-dom": resolve(reactDomRoot),
36
57
  "virtual:git-snitch-custom-templates": customTemplateModule ?? defaultTemplateModule,
37
58
  },
38
59
  tsconfigPaths: true,