@git-snitch/renderer 0.0.3

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.
Files changed (74) hide show
  1. package/dist/build.d.ts +7 -0
  2. package/dist/build.d.ts.map +1 -0
  3. package/dist/build.js +53 -0
  4. package/dist/charts.d.ts +106 -0
  5. package/dist/charts.d.ts.map +1 -0
  6. package/dist/charts.js +212 -0
  7. package/dist/custom-templates.d.ts +3 -0
  8. package/dist/custom-templates.d.ts.map +1 -0
  9. package/dist/custom-templates.js +1 -0
  10. package/dist/data.d.ts +24 -0
  11. package/dist/data.d.ts.map +1 -0
  12. package/dist/data.js +30 -0
  13. package/dist/empty-state.d.ts +13 -0
  14. package/dist/empty-state.d.ts.map +1 -0
  15. package/dist/empty-state.js +9 -0
  16. package/dist/export.d.ts +15 -0
  17. package/dist/export.d.ts.map +1 -0
  18. package/dist/export.js +53 -0
  19. package/dist/index.d.ts +15 -0
  20. package/dist/index.d.ts.map +1 -0
  21. package/dist/index.js +9 -0
  22. package/dist/inline-plugin.d.ts +13 -0
  23. package/dist/inline-plugin.d.ts.map +1 -0
  24. package/dist/inline-plugin.js +81 -0
  25. package/dist/layout.d.ts +43 -0
  26. package/dist/layout.d.ts.map +1 -0
  27. package/dist/layout.js +25 -0
  28. package/dist/remote-urls.d.ts +6 -0
  29. package/dist/remote-urls.d.ts.map +1 -0
  30. package/dist/remote-urls.js +82 -0
  31. package/dist/serialization.d.ts +5 -0
  32. package/dist/serialization.d.ts.map +1 -0
  33. package/dist/serialization.js +46 -0
  34. package/dist/tables.d.ts +50 -0
  35. package/dist/tables.d.ts.map +1 -0
  36. package/dist/tables.js +228 -0
  37. package/dist/template/report-template.html +135 -0
  38. package/dist/template.d.ts +21 -0
  39. package/dist/template.d.ts.map +1 -0
  40. package/dist/template.js +1 -0
  41. package/dist/theme-toggle.d.ts +2 -0
  42. package/dist/theme-toggle.d.ts.map +1 -0
  43. package/dist/theme-toggle.js +9 -0
  44. package/dist/theme.d.ts +16 -0
  45. package/dist/theme.d.ts.map +1 -0
  46. package/dist/theme.js +70 -0
  47. package/package.json +57 -0
  48. package/report-template.html +15 -0
  49. package/src/app.tsx +351 -0
  50. package/src/build.ts +68 -0
  51. package/src/charts-route.tsx +158 -0
  52. package/src/charts.tsx +482 -0
  53. package/src/custom-template-module.d.ts +5 -0
  54. package/src/custom-templates.ts +3 -0
  55. package/src/data.ts +52 -0
  56. package/src/empty-state.tsx +31 -0
  57. package/src/export.ts +77 -0
  58. package/src/index.ts +52 -0
  59. package/src/inline-plugin.ts +123 -0
  60. package/src/layout.tsx +152 -0
  61. package/src/main.tsx +17 -0
  62. package/src/overview.tsx +253 -0
  63. package/src/quality-hotspots-routes.tsx +340 -0
  64. package/src/remote-urls.ts +97 -0
  65. package/src/repo-routes.tsx +285 -0
  66. package/src/scan-routes.tsx +393 -0
  67. package/src/serialization.ts +58 -0
  68. package/src/styles.css +2 -0
  69. package/src/tables.tsx +467 -0
  70. package/src/template.ts +30 -0
  71. package/src/theme-toggle.tsx +24 -0
  72. package/src/theme.tsx +108 -0
  73. package/src/vite-env.d.ts +1 -0
  74. package/vite.config.ts +41 -0
@@ -0,0 +1,285 @@
1
+ import { Button } from "@git-snitch/ui/components/button";
2
+ import { Card, CardContent, CardHeader, CardTitle } from "@git-snitch/ui/components/card";
3
+ import { useMemo, useState } from "react";
4
+ import type { ReactNode } from "react";
5
+
6
+ import type { CommitRecord, ContributorSummary, JsonObject, RepoReportData, ReportData } from "@git-snitch/core";
7
+
8
+ import {
9
+ CodeOwnershipChart,
10
+ ContributorPieChart,
11
+ deriveCodeOwnershipData,
12
+ deriveContributorPieData,
13
+ } from "./charts.js";
14
+ import { EmptyState } from "./empty-state.js";
15
+ import { downloadJson } from "./export.js";
16
+ import { CommitsTable, ContributorsTable } from "./tables.js";
17
+ import type { DownloadResult } from "./export.js";
18
+
19
+ export type JsonDownloadResult = DownloadResult;
20
+ export type JsonDownloader = (filename: string, rows: readonly JsonObject[]) => JsonDownloadResult;
21
+
22
+ type RepoRouteProps = {
23
+ readonly report: ReportData;
24
+ readonly jsonDownloader?: JsonDownloader;
25
+ };
26
+
27
+ type ContributorTimelineSummary =
28
+ | {
29
+ readonly status: "ready";
30
+ readonly firstDate: string;
31
+ readonly lastDate: string;
32
+ readonly activeDays: number;
33
+ readonly latestContributor: string;
34
+ }
35
+ | { readonly status: "empty" }
36
+ | { readonly status: "insufficient-dates" };
37
+
38
+ function formatNumber(value: number) {
39
+ return new Intl.NumberFormat("en").format(value);
40
+ }
41
+
42
+ function dayKey(value: string | undefined) {
43
+ if (value === undefined) {
44
+ return undefined;
45
+ }
46
+
47
+ const timestamp = Date.parse(value);
48
+ return Number.isNaN(timestamp) ? undefined : new Date(timestamp).toISOString().slice(0, 10);
49
+ }
50
+
51
+ function daysBetweenInclusive(firstDate: string, lastDate: string) {
52
+ const first = Date.parse(`${firstDate}T00:00:00.000Z`);
53
+ const last = Date.parse(`${lastDate}T00:00:00.000Z`);
54
+ return Math.floor((last - first) / 86_400_000) + 1;
55
+ }
56
+
57
+ function repoDataMismatch(title: string) {
58
+ return (
59
+ <EmptyState
60
+ title={title}
61
+ description="This route expects a single-repository report. Open the scan overview for multi-repository aggregate evidence."
62
+ />
63
+ );
64
+ }
65
+
66
+ function repoFilename(report: RepoReportData, suffix: string) {
67
+ const safeName = report.repository.name.trim().replaceAll(/[^a-zA-Z0-9._-]+/g, "-").replaceAll(/^-|-$/g, "");
68
+ return `${safeName.length > 0 ? safeName : "repo"}-${suffix}`;
69
+ }
70
+
71
+ function commitAdditions(commit: CommitRecord) {
72
+ return commit.files.reduce((sum, file) => sum + file.additions, 0);
73
+ }
74
+
75
+ function commitDeletions(commit: CommitRecord) {
76
+ return commit.files.reduce((sum, file) => sum + file.deletions, 0);
77
+ }
78
+
79
+ function commitToJsonRow(commit: CommitRecord): JsonObject {
80
+ return {
81
+ hash: commit.hash,
82
+ shortHash: commit.shortHash,
83
+ message: commit.message,
84
+ authorName: commit.author.name,
85
+ authorEmail: commit.author.email,
86
+ authoredAt: commit.authoredAt,
87
+ committedAt: commit.committedAt,
88
+ classification: commit.classification,
89
+ additions: commitAdditions(commit),
90
+ deletions: commitDeletions(commit),
91
+ files: commit.files.map((file) => file.path),
92
+ refs: commit.refs,
93
+ };
94
+ }
95
+
96
+ function contributorToJsonRow(contributor: ContributorSummary): JsonObject {
97
+ return {
98
+ name: contributor.name,
99
+ email: contributor.email,
100
+ commitCount: contributor.commitCount,
101
+ additions: contributor.additions,
102
+ deletions: contributor.deletions,
103
+ filesChanged: contributor.filesChanged,
104
+ firstCommitAt: contributor.firstCommitAt ?? null,
105
+ lastCommitAt: contributor.lastCommitAt ?? null,
106
+ };
107
+ }
108
+
109
+ function defaultJsonDownloader(filename: string, rows: readonly JsonObject[]) {
110
+ return downloadJson(filename, rows);
111
+ }
112
+
113
+ function JsonExportButton({ filename, rows, downloader }: { readonly filename: string; readonly rows: readonly JsonObject[]; readonly downloader?: JsonDownloader }) {
114
+ const [status, setStatus] = useState<string | undefined>();
115
+ const canExport = rows.length > 0;
116
+
117
+ function handleExport() {
118
+ if (!canExport) {
119
+ return;
120
+ }
121
+
122
+ const result = (downloader ?? defaultJsonDownloader)(filename, rows);
123
+ setStatus(result.status === "downloaded" ? "JSON export started." : result.reason);
124
+ }
125
+
126
+ return (
127
+ <div className="flex flex-col items-start gap-2 sm:items-end">
128
+ <Button type="button" variant="outline" size="sm" onClick={handleExport} disabled={!canExport}>
129
+ Export JSON
130
+ </Button>
131
+ {status ? <p className="text-xs text-muted-foreground" aria-live="polite">{status}</p> : null}
132
+ </div>
133
+ );
134
+ }
135
+
136
+ function RouteHeader({ title, description, action }: { readonly title: string; readonly description: string; readonly action?: ReactNode }) {
137
+ return (
138
+ <section className="grid grid-flow-dense gap-4 rounded-2xl border border-border/70 bg-card/80 p-5 shadow-sm sm:grid-cols-[1fr_auto] sm:items-start">
139
+ <div className="max-w-3xl">
140
+ <h2 className="text-2xl font-semibold tracking-tight text-foreground sm:text-3xl">{title}</h2>
141
+ <p className="mt-2 text-sm leading-6 text-muted-foreground">{description}</p>
142
+ </div>
143
+ {action}
144
+ </section>
145
+ );
146
+ }
147
+
148
+ function CommitsSummary({ commits }: { readonly commits: readonly CommitRecord[] }) {
149
+ const touchedFiles = new Set(commits.flatMap((commit) => commit.files.map((file) => file.path))).size;
150
+ const additions = commits.reduce((sum, commit) => sum + commitAdditions(commit), 0);
151
+ const deletions = commits.reduce((sum, commit) => sum + commitDeletions(commit), 0);
152
+
153
+ return (
154
+ <section aria-label="Commit ledger summary" className="grid grid-flow-dense gap-3 sm:grid-cols-3">
155
+ <SummaryCard title="Touched files" value={formatNumber(touchedFiles)} description="Unique paths changed by visible commits." />
156
+ <SummaryCard title="Additions" value={formatNumber(additions)} description="Lines added across commit file stats." />
157
+ <SummaryCard title="Deletions" value={formatNumber(deletions)} description="Lines removed across commit file stats." />
158
+ </section>
159
+ );
160
+ }
161
+
162
+ function SummaryCard({ title, value, description }: { readonly title: string; readonly value: string; readonly description: string }) {
163
+ return (
164
+ <Card className="overflow-hidden shadow-none transition-transform duration-500 ease-out hover:-translate-y-0.5">
165
+ <CardHeader>
166
+ <CardTitle className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">{title}</CardTitle>
167
+ </CardHeader>
168
+ <CardContent>
169
+ <p className="text-3xl font-semibold tracking-tight text-foreground">{value}</p>
170
+ <p className="mt-2 text-xs leading-5 text-muted-foreground">{description}</p>
171
+ </CardContent>
172
+ </Card>
173
+ );
174
+ }
175
+
176
+ function ContributorsComparison({ contributors }: { readonly contributors: readonly ContributorSummary[] }) {
177
+ const contributorShare = deriveContributorPieData(contributors);
178
+ const ownership = deriveCodeOwnershipData(contributors);
179
+
180
+ if (contributorShare.length === 0 && ownership.length === 0) {
181
+ return <EmptyState title="No contributor comparison yet" description="Comparison visuals need contributor commit, file, or churn activity." />;
182
+ }
183
+
184
+ return (
185
+ <section aria-label="Contributor comparison visuals" className="grid grid-flow-dense gap-6 lg:grid-cols-2">
186
+ <ContributorPieChart data={contributorShare} />
187
+ <CodeOwnershipChart data={ownership} />
188
+ </section>
189
+ );
190
+ }
191
+
192
+ export function deriveContributorTimelineSummary(contributors: readonly ContributorSummary[]): ContributorTimelineSummary {
193
+ if (contributors.length === 0) {
194
+ return { status: "empty" };
195
+ }
196
+
197
+ const datedContributors = contributors
198
+ .map((contributor) => ({ contributor, firstDate: dayKey(contributor.firstCommitAt), lastDate: dayKey(contributor.lastCommitAt) }))
199
+ .filter((entry): entry is { readonly contributor: ContributorSummary; readonly firstDate: string; readonly lastDate: string } => entry.firstDate !== undefined && entry.lastDate !== undefined)
200
+ .sort((left, right) => left.lastDate.localeCompare(right.lastDate));
201
+
202
+ if (datedContributors.length === 0) {
203
+ return { status: "insufficient-dates" };
204
+ }
205
+
206
+ const firstDate = datedContributors.reduce((earliest, entry) => entry.firstDate < earliest ? entry.firstDate : earliest, datedContributors[0]?.firstDate ?? "");
207
+ const latest = datedContributors[datedContributors.length - 1];
208
+
209
+ if (latest === undefined) {
210
+ return { status: "insufficient-dates" };
211
+ }
212
+
213
+ return {
214
+ status: "ready",
215
+ firstDate,
216
+ lastDate: latest.lastDate,
217
+ activeDays: daysBetweenInclusive(firstDate, latest.lastDate),
218
+ latestContributor: latest.contributor.name,
219
+ };
220
+ }
221
+
222
+ function ContributorTimeline({ contributors }: { readonly contributors: readonly ContributorSummary[] }) {
223
+ const summary = deriveContributorTimelineSummary(contributors);
224
+
225
+ if (summary.status === "empty") {
226
+ return <EmptyState title="No contributor timeline yet" description="Timeline summary appears once at least one contributor exists." />;
227
+ }
228
+
229
+ if (summary.status === "insufficient-dates") {
230
+ return <EmptyState title="Contributor timeline is unavailable" description="Contributor rows do not include valid first and last commit dates." />;
231
+ }
232
+
233
+ return (
234
+ <section aria-label="Contributor activity timeline" className="grid grid-flow-dense gap-3 sm:grid-cols-3">
235
+ <SummaryCard title="Activity span" value={`${formatNumber(summary.activeDays)} days`} description={`${summary.firstDate} through ${summary.lastDate}.`} />
236
+ <SummaryCard title="Latest contributor" value={summary.latestContributor} description="Contributor with the most recent recorded commit." />
237
+ <SummaryCard title="Tracked people" value={formatNumber(contributors.length)} description="Contributor identities in this repository report." />
238
+ </section>
239
+ );
240
+ }
241
+
242
+ export function CommitsRoute({ report, jsonDownloader }: RepoRouteProps) {
243
+ const jsonRows = useMemo(() => report.kind === "repo" ? report.commits.map(commitToJsonRow) : [], [report]);
244
+
245
+ if (report.kind !== "repo") {
246
+ return repoDataMismatch("Commits are unavailable for scan reports");
247
+ }
248
+
249
+ const hasCommits = report.commits.length > 0;
250
+
251
+ return (
252
+ <div className="grid gap-6">
253
+ <RouteHeader
254
+ title="Commits ledger"
255
+ description="Searchable, sortable commit evidence with line churn and file context preserved for standalone reports."
256
+ action={hasCommits ? <JsonExportButton filename={repoFilename(report, "commits.json")} rows={jsonRows} downloader={jsonDownloader} /> : undefined}
257
+ />
258
+ {hasCommits ? <CommitsSummary commits={report.commits} /> : null}
259
+ <CommitsTable commits={report.commits} exportFilename={repoFilename(report, "commits.csv")} remoteUrl={report.repository.remoteUrl} />
260
+ </div>
261
+ );
262
+ }
263
+
264
+ export function ContributorsRoute({ report, jsonDownloader }: RepoRouteProps) {
265
+ const jsonRows = useMemo(() => report.kind === "repo" ? report.contributors.map(contributorToJsonRow) : [], [report]);
266
+
267
+ if (report.kind !== "repo") {
268
+ return repoDataMismatch("Contributors are unavailable for scan reports");
269
+ }
270
+
271
+ const hasContributors = report.contributors.length > 0;
272
+
273
+ return (
274
+ <div className="grid gap-6">
275
+ <RouteHeader
276
+ title="Contributors"
277
+ description="A contributor-first view of ownership, recent activity, and exportable identity-level report data."
278
+ action={hasContributors ? <JsonExportButton filename={repoFilename(report, "contributors.json")} rows={jsonRows} downloader={jsonDownloader} /> : undefined}
279
+ />
280
+ <ContributorsComparison contributors={report.contributors} />
281
+ <ContributorTimeline contributors={report.contributors} />
282
+ <ContributorsTable contributors={report.contributors} exportFilename={repoFilename(report, "contributors.csv")} />
283
+ </div>
284
+ );
285
+ }
@@ -0,0 +1,393 @@
1
+ import type { ColumnDef } from "@tanstack/react-table";
2
+ import { useMemo, useState } from "react";
3
+ import type { ContributorSummary, RepoReportData, ReportData, ScanProjectReport, ScanReportData } from "@git-snitch/core";
4
+ import { cn } from "@git-snitch/ui/lib/utils";
5
+
6
+ import { ChartsRoute } from "./charts-route.js";
7
+ import { EmptyState } from "./empty-state.js";
8
+ import { StatsGrid } from "./layout.js";
9
+ import { RepoOverview } from "./overview.js";
10
+ import { HotspotsRoute, QualityRoute } from "./quality-hotspots-routes.js";
11
+ import { CommitsRoute, ContributorsRoute } from "./repo-routes.js";
12
+ import { DataTable } from "./tables.js";
13
+
14
+ type ScanRouteProps = {
15
+ readonly report: ReportData;
16
+ };
17
+
18
+ type ScanProjectRouteProps = ScanRouteProps & {
19
+ readonly projectSlug: string;
20
+ };
21
+
22
+ type ContributorAggregate = {
23
+ readonly key: string;
24
+ readonly name: string;
25
+ readonly email: string;
26
+ readonly commitCount: number;
27
+ readonly additions: number;
28
+ readonly deletions: number;
29
+ readonly filesChanged: number;
30
+ readonly projectCount: number;
31
+ };
32
+
33
+ type MutableContributorAggregate = Omit<ContributorAggregate, "projectCount"> & {
34
+ readonly projectKeys: Set<string>;
35
+ };
36
+
37
+ type ProjectComparisonRow = {
38
+ readonly slug: string;
39
+ readonly href: string;
40
+ readonly label: string;
41
+ readonly remoteUrl: string | undefined;
42
+ readonly commits: number;
43
+ readonly contributors: number;
44
+ readonly churn: number;
45
+ readonly lastCommitAt: string | undefined;
46
+ };
47
+
48
+ export type ScanProjectRouteEntry = {
49
+ readonly project: ScanProjectReport;
50
+ readonly slug: string;
51
+ readonly href: string;
52
+ readonly label: string;
53
+ };
54
+
55
+ function formatNumber(value: number) {
56
+ return new Intl.NumberFormat("en").format(value);
57
+ }
58
+
59
+ function totalChurn(report: RepoReportData) {
60
+ return report.commits.reduce(
61
+ (sum, commit) => sum + commit.files.reduce((fileSum, file) => fileSum + file.additions + file.deletions, 0),
62
+ 0,
63
+ );
64
+ }
65
+
66
+ function stableHash(value: string) {
67
+ let hash = 2_166_136_261;
68
+
69
+ for (const character of value) {
70
+ hash ^= character.charCodeAt(0);
71
+ hash = Math.imul(hash, 16_777_619);
72
+ }
73
+
74
+ return (hash >>> 0).toString(36).slice(0, 6);
75
+ }
76
+
77
+ function slugBase(value: string) {
78
+ const slug = value
79
+ .trim()
80
+ .toLowerCase()
81
+ .replaceAll(/[^a-z0-9._-]+/g, "-")
82
+ .replaceAll(/-+/g, "-")
83
+ .replaceAll(/^-|-$/g, "");
84
+
85
+ return slug.length > 0 ? slug : "project";
86
+ }
87
+
88
+ function formatDate(isoDate: string | undefined): string {
89
+ if (isoDate === undefined) {
90
+ return "Not available";
91
+ }
92
+ return new Intl.DateTimeFormat("en", { dateStyle: "medium", timeStyle: "short", timeZone: "UTC" }).format(new Date(isoDate));
93
+ }
94
+
95
+ export function deriveScanProjectSlug(project: ScanProjectReport) {
96
+ const source = `${project.repository.id}|${project.repository.relativePath}|${project.repository.name}`;
97
+ return `${slugBase(project.repository.id || project.repository.relativePath || project.repository.name)}-${stableHash(source)}`;
98
+ }
99
+
100
+ export function deriveScanProjectRouteEntries(report: ScanReportData): readonly ScanProjectRouteEntry[] {
101
+ return report.projects.map((project) => {
102
+ const slug = deriveScanProjectSlug(project);
103
+
104
+ return {
105
+ project,
106
+ slug,
107
+ href: `#/scan/projects/${slug}`,
108
+ label: project.repository.name,
109
+ };
110
+ });
111
+ }
112
+
113
+ function scanDataMismatch(title: string) {
114
+ return (
115
+ <EmptyState
116
+ title={title}
117
+ description="This route expects a scan report. Open the repository overview, commits, contributors, charts, quality, or hotspots routes for single-repository data."
118
+ />
119
+ );
120
+ }
121
+
122
+ function buildScanStats(report: ScanReportData) {
123
+ return [
124
+ { label: "Repositories", value: formatNumber(report.analysis.totalRepositories), description: "Projects included in this scan report" },
125
+ { label: "Commits", value: formatNumber(report.analysis.totalCommits), description: "Commits aggregated across scanned projects" },
126
+ { label: "Contributors", value: formatNumber(report.analysis.totalContributors), description: "Contributor identities counted by the scan analysis" },
127
+ { label: "Languages", value: formatNumber(report.analysis.languages.length), description: "Detected language groups across projects" },
128
+ ];
129
+ }
130
+
131
+ function contributorKey(contributor: ContributorSummary) {
132
+ const email = contributor.email.trim().toLowerCase();
133
+ return email.length > 0 ? email : contributor.name.trim().toLowerCase();
134
+ }
135
+
136
+ export function deriveCrossProjectContributors(report: ScanReportData): readonly ContributorAggregate[] {
137
+ const aggregates = new Map<string, MutableContributorAggregate>();
138
+
139
+ for (const project of report.projects) {
140
+ for (const contributor of project.report.contributors) {
141
+ const key = contributorKey(contributor);
142
+ const existing = aggregates.get(key);
143
+
144
+ if (existing) {
145
+ aggregates.set(key, {
146
+ ...existing,
147
+ commitCount: existing.commitCount + contributor.commitCount,
148
+ additions: existing.additions + contributor.additions,
149
+ deletions: existing.deletions + contributor.deletions,
150
+ filesChanged: existing.filesChanged + contributor.filesChanged,
151
+ projectKeys: new Set([...existing.projectKeys, project.repository.id]),
152
+ });
153
+ } else {
154
+ aggregates.set(key, {
155
+ key,
156
+ name: contributor.name,
157
+ email: contributor.email,
158
+ commitCount: contributor.commitCount,
159
+ additions: contributor.additions,
160
+ deletions: contributor.deletions,
161
+ filesChanged: contributor.filesChanged,
162
+ projectKeys: new Set([project.repository.id]),
163
+ });
164
+ }
165
+ }
166
+ }
167
+
168
+ return [...aggregates.values()]
169
+ .map((aggregate) => ({
170
+ key: aggregate.key,
171
+ name: aggregate.name,
172
+ email: aggregate.email,
173
+ commitCount: aggregate.commitCount,
174
+ additions: aggregate.additions,
175
+ deletions: aggregate.deletions,
176
+ filesChanged: aggregate.filesChanged,
177
+ projectCount: aggregate.projectKeys.size,
178
+ }))
179
+ .filter((aggregate) => aggregate.projectCount > 1)
180
+ .sort((left, right) => right.commitCount - left.commitCount || right.projectCount - left.projectCount || left.name.localeCompare(right.name));
181
+ }
182
+
183
+ const projectComparisonColumns: ColumnDef<ProjectComparisonRow>[] = [
184
+ {
185
+ accessorKey: "label",
186
+ header: "Project",
187
+ cell: ({ row }) => (
188
+ <div>
189
+ <a className="font-medium text-foreground underline-offset-4 hover:underline" href={row.original.href}>
190
+ {row.original.label}
191
+ </a>
192
+ {row.original.remoteUrl ? (
193
+ <a
194
+ className="mt-1 block text-xs text-muted-foreground hover:text-foreground"
195
+ href={row.original.remoteUrl}
196
+ target="_blank"
197
+ rel="noopener noreferrer"
198
+ >
199
+ View remote
200
+ </a>
201
+ ) : null}
202
+ </div>
203
+ ),
204
+ },
205
+ { accessorKey: "commits", header: "Commits", cell: ({ row }) => formatNumber(row.original.commits) },
206
+ { accessorKey: "contributors", header: "Contributors", cell: ({ row }) => formatNumber(row.original.contributors) },
207
+ { accessorKey: "churn", header: "Churn", cell: ({ row }) => formatNumber(row.original.churn) },
208
+ { accessorKey: "lastCommitAt", header: "Last commit", cell: ({ row }) => formatDate(row.original.lastCommitAt) },
209
+ ];
210
+
211
+ const crossProjectContributorColumns: ColumnDef<ContributorAggregate>[] = [
212
+ {
213
+ accessorKey: "name",
214
+ header: "Contributor",
215
+ cell: ({ row }) => (
216
+ <div>
217
+ <span className="font-medium text-foreground">{row.original.name}</span>
218
+ <p className="mt-1 text-xs text-muted-foreground">{row.original.email}</p>
219
+ </div>
220
+ ),
221
+ },
222
+ { accessorKey: "projectCount", header: "Projects", cell: ({ row }) => formatNumber(row.original.projectCount) },
223
+ { accessorKey: "commitCount", header: "Commits", cell: ({ row }) => formatNumber(row.original.commitCount) },
224
+ { accessorKey: "additions", header: "Additions", cell: ({ row }) => formatNumber(row.original.additions) },
225
+ { accessorKey: "deletions", header: "Deletions", cell: ({ row }) => formatNumber(row.original.deletions) },
226
+ ];
227
+
228
+ function ScanIntro({ report }: { readonly report: ScanReportData }) {
229
+ return (
230
+ <section className="grid grid-flow-dense gap-5 rounded-3xl border border-border/70 bg-card/80 p-6 shadow-sm md:grid-cols-[minmax(0,1fr)_18rem] md:items-end">
231
+ <div className="max-w-4xl">
232
+ <h2 className="text-2xl font-semibold tracking-tight text-foreground sm:text-3xl">Scan overview</h2>
233
+ <p className="mt-3 text-sm leading-6 text-muted-foreground">
234
+ Evidence across {report.projects.length} repositories: repository totals, comparable project rows, and contributors whose work spans more than one codebase.
235
+ </p>
236
+ </div>
237
+ <div className="rounded-2xl border border-border/70 bg-background/70 p-4">
238
+ <p className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">Scan scope</p>
239
+ <p className="mt-2 text-sm font-medium text-foreground">Max depth {report.options.scan.maxDepth}</p>
240
+ <p className="mt-1 text-xs leading-5 text-muted-foreground">Generated {report.generatedAt}</p>
241
+ </div>
242
+ </section>
243
+ );
244
+ }
245
+
246
+ function ProjectComparison({ report }: { readonly report: ScanReportData }) {
247
+ const entries = deriveScanProjectRouteEntries(report);
248
+
249
+ const rows: readonly ProjectComparisonRow[] = useMemo(
250
+ () =>
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
+ })),
261
+ [entries],
262
+ );
263
+
264
+ return (
265
+ <DataTable
266
+ ariaLabel="Project comparison"
267
+ data={rows}
268
+ columns={projectComparisonColumns}
269
+ search={{ placeholder: "Search projects", toText: (row) => `${row.label}` }}
270
+ emptyState={{
271
+ title: "No repositories matched this scan",
272
+ description: "git-snitch did not find repositories within the configured directory, max depth, include patterns, and exclude patterns. Widen the scan scope or check that the target directory contains Git repositories.",
273
+ }}
274
+ />
275
+ );
276
+ }
277
+
278
+ function CrossProjectContributors({ report }: { readonly report: ScanReportData }) {
279
+ const contributors = deriveCrossProjectContributors(report);
280
+
281
+ return (
282
+ <DataTable
283
+ ariaLabel="Cross-project contributors"
284
+ data={contributors}
285
+ columns={crossProjectContributorColumns}
286
+ search={{ placeholder: "Search contributors", toText: (row) => `${row.name} ${row.email}` }}
287
+ exportConfig={{
288
+ filename: "cross-project-contributors.csv",
289
+ mapRow: (row) => ({ name: row.name, email: row.email, projects: row.projectCount, commits: row.commitCount, additions: row.additions, deletions: row.deletions }),
290
+ }}
291
+ emptyState={{
292
+ title: "No shared contributors across projects",
293
+ description: "Each contributor identity currently appears in only one scanned repository. Shared contributor evidence will appear here once the same author email or name is present in more than one project.",
294
+ }}
295
+ />
296
+ );
297
+ }
298
+
299
+ export function ScanOverview({ report }: ScanRouteProps) {
300
+ if (report.kind !== "scan") {
301
+ return scanDataMismatch("Scan overview is unavailable for repository reports");
302
+ }
303
+
304
+ return (
305
+ <div className="grid gap-6">
306
+ <ScanIntro report={report} />
307
+ <StatsGrid stats={buildScanStats(report)} />
308
+ <ProjectComparison report={report} />
309
+ <CrossProjectContributors report={report} />
310
+ </div>
311
+ );
312
+ }
313
+
314
+ const scanProjectTabs = ["Overview", "Commits", "Contributors", "Charts", "Quality", "Hotspots"] as const;
315
+ type ScanProjectTab = (typeof scanProjectTabs)[number];
316
+
317
+ function ScanProjectTabs({ report }: { readonly report: RepoReportData }) {
318
+ const [activeTab, setActiveTab] = useState<ScanProjectTab>("Overview");
319
+
320
+ return (
321
+ <div className="grid gap-6">
322
+ <nav aria-label="Project sections" className="flex gap-1 rounded-xl border border-border/70 bg-muted/25 p-1">
323
+ {scanProjectTabs.map((tab) => (
324
+ <button
325
+ key={tab}
326
+ type="button"
327
+ onClick={() => setActiveTab(tab)}
328
+ className={cn(
329
+ "rounded-lg px-4 py-2 text-sm font-medium transition-colors",
330
+ activeTab === tab
331
+ ? "bg-background text-foreground shadow-sm"
332
+ : "text-muted-foreground hover:text-foreground",
333
+ )}
334
+ >
335
+ {tab}
336
+ </button>
337
+ ))}
338
+ </nav>
339
+ {activeTab === "Overview" ? <RepoOverview report={report} /> : null}
340
+ {activeTab === "Commits" ? <CommitsRoute report={report} /> : null}
341
+ {activeTab === "Contributors" ? <ContributorsRoute report={report} /> : null}
342
+ {activeTab === "Charts" ? <ChartsRoute report={report} /> : null}
343
+ {activeTab === "Quality" ? <QualityRoute report={report} /> : null}
344
+ {activeTab === "Hotspots" ? <HotspotsRoute report={report} /> : null}
345
+ </div>
346
+ );
347
+ }
348
+
349
+ export function ScanProjectRoute({ report, projectSlug }: ScanProjectRouteProps) {
350
+ if (report.kind !== "scan") {
351
+ return scanDataMismatch("Scan project drill-down is unavailable for repository reports");
352
+ }
353
+
354
+ const entry = deriveScanProjectRouteEntries(report).find((candidate) => candidate.slug === projectSlug);
355
+
356
+ if (!entry) {
357
+ return (
358
+ <EmptyState
359
+ title="Scan project was not found"
360
+ description="The project link does not match any repository in this scan report. Return to the scan overview and choose one of the generated project links."
361
+ />
362
+ );
363
+ }
364
+
365
+ const repoReport = entry.project.report;
366
+
367
+ return (
368
+ <div className="grid gap-6">
369
+ <section className="grid grid-flow-dense gap-4 rounded-2xl border border-border/70 bg-card/80 p-5 shadow-sm sm:grid-cols-[1fr_auto] sm:items-start">
370
+ <div className="max-w-4xl">
371
+ <h2 className="text-2xl font-semibold tracking-tight text-foreground sm:text-3xl">{entry.project.repository.name}</h2>
372
+ <p className="mt-2 text-sm leading-6 text-muted-foreground">
373
+ Drill-down for {entry.project.repository.name}. Overview, commits, contributors, charts, hotspots, and quality signals from the scanned project report.
374
+ </p>
375
+ {entry.project.repository.remoteUrl ? (
376
+ <a
377
+ className="mt-2 inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
378
+ href={entry.project.repository.remoteUrl}
379
+ target="_blank"
380
+ rel="noopener noreferrer"
381
+ >
382
+ View remote
383
+ </a>
384
+ ) : null}
385
+ </div>
386
+ <a className="text-sm font-medium text-foreground underline-offset-4 hover:underline" href="#/scan">
387
+ Back to scan overview
388
+ </a>
389
+ </section>
390
+ <ScanProjectTabs report={repoReport} />
391
+ </div>
392
+ );
393
+ }