@git-snitch/renderer 0.0.11 → 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.11",
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.11",
39
- "@git-snitch/ui": "0.0.11"
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 CHANGED
@@ -1,9 +1,11 @@
1
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
2
 
3
+ import type { ColumnDef } from "@tanstack/react-table";
4
+ import { useMemo } from "react";
4
5
  import type { AiUsageBreakdownItem, AiUsageSummary, ReportAiUsageProjectSummary } from "@git-snitch/core";
5
6
 
6
7
  import { EmptyState } from "./empty-state.js";
8
+ import { DataTable } from "./tables.js";
7
9
 
8
10
  type AiUsagePanelProps = {
9
11
  readonly title?: string;
@@ -41,41 +43,28 @@ function SummaryMetric({ label, value, description }: { readonly label: string;
41
43
  );
42
44
  }
43
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
+
44
53
  function BreakdownTable({ title, rows, emptyDescription }: { readonly title: string; readonly rows: readonly AiUsageBreakdownItem[]; readonly emptyDescription: string }) {
45
- const visibleRows = rows.slice(0, 8);
54
+ const columns = useMemo(() => breakdownColumns, []);
46
55
 
47
56
  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>
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
+ />
79
68
  );
80
69
  }
81
70
 
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 { AiUsageBreakdownItem, 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
 
@@ -43,6 +43,10 @@ export type CodeOwnershipPoint = { readonly owner: string; readonly additions: n
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
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 };
46
50
 
47
51
  type ChartPanelProps = {
48
52
  readonly title: string;
@@ -494,6 +498,167 @@ export function ActivityHeatmap({ data }: { readonly data: readonly ActivityHeat
494
498
  );
495
499
  }
496
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
+
497
662
  function heatClass(commits: number) {
498
663
  if (commits >= 8) {
499
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
  }
@@ -5,6 +5,19 @@ import { cn } from "@git-snitch/ui/lib/utils";
5
5
 
6
6
  import { AiUsagePanel, formatAiUsageCost, formatAiUsageTokens } from "./ai-usage.js";
7
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";
8
21
  import { EmptyState } from "./empty-state.js";
9
22
  import { StatsGrid } from "./layout.js";
10
23
  import { RepoOverview } from "./overview.js";
@@ -318,6 +331,28 @@ function CrossProjectContributors({ report }: { readonly report: ScanReportData
318
331
  );
319
332
  }
320
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
+
321
356
  export function ScanOverview({ report }: ScanRouteProps) {
322
357
  if (report.kind !== "scan") {
323
358
  return scanDataMismatch("Scan overview is unavailable for repository reports");
@@ -335,6 +370,7 @@ export function ScanOverview({ report }: ScanRouteProps) {
335
370
  />
336
371
  ) : null}
337
372
  <ProjectComparison report={report} />
373
+ <ScanCharts report={report} />
338
374
  <CrossProjectContributors report={report} />
339
375
  </div>
340
376
  );