@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.
- package/dist/build.d.ts +7 -0
- package/dist/build.d.ts.map +1 -0
- package/dist/build.js +53 -0
- package/dist/charts.d.ts +106 -0
- package/dist/charts.d.ts.map +1 -0
- package/dist/charts.js +212 -0
- package/dist/custom-templates.d.ts +3 -0
- package/dist/custom-templates.d.ts.map +1 -0
- package/dist/custom-templates.js +1 -0
- package/dist/data.d.ts +24 -0
- package/dist/data.d.ts.map +1 -0
- package/dist/data.js +30 -0
- package/dist/empty-state.d.ts +13 -0
- package/dist/empty-state.d.ts.map +1 -0
- package/dist/empty-state.js +9 -0
- package/dist/export.d.ts +15 -0
- package/dist/export.d.ts.map +1 -0
- package/dist/export.js +53 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/inline-plugin.d.ts +13 -0
- package/dist/inline-plugin.d.ts.map +1 -0
- package/dist/inline-plugin.js +81 -0
- package/dist/layout.d.ts +43 -0
- package/dist/layout.d.ts.map +1 -0
- package/dist/layout.js +25 -0
- package/dist/remote-urls.d.ts +6 -0
- package/dist/remote-urls.d.ts.map +1 -0
- package/dist/remote-urls.js +82 -0
- package/dist/serialization.d.ts +5 -0
- package/dist/serialization.d.ts.map +1 -0
- package/dist/serialization.js +46 -0
- package/dist/tables.d.ts +50 -0
- package/dist/tables.d.ts.map +1 -0
- package/dist/tables.js +228 -0
- package/dist/template/report-template.html +135 -0
- package/dist/template.d.ts +21 -0
- package/dist/template.d.ts.map +1 -0
- package/dist/template.js +1 -0
- package/dist/theme-toggle.d.ts +2 -0
- package/dist/theme-toggle.d.ts.map +1 -0
- package/dist/theme-toggle.js +9 -0
- package/dist/theme.d.ts +16 -0
- package/dist/theme.d.ts.map +1 -0
- package/dist/theme.js +70 -0
- package/package.json +57 -0
- package/report-template.html +15 -0
- package/src/app.tsx +351 -0
- package/src/build.ts +68 -0
- package/src/charts-route.tsx +158 -0
- package/src/charts.tsx +482 -0
- package/src/custom-template-module.d.ts +5 -0
- package/src/custom-templates.ts +3 -0
- package/src/data.ts +52 -0
- package/src/empty-state.tsx +31 -0
- package/src/export.ts +77 -0
- package/src/index.ts +52 -0
- package/src/inline-plugin.ts +123 -0
- package/src/layout.tsx +152 -0
- package/src/main.tsx +17 -0
- package/src/overview.tsx +253 -0
- package/src/quality-hotspots-routes.tsx +340 -0
- package/src/remote-urls.ts +97 -0
- package/src/repo-routes.tsx +285 -0
- package/src/scan-routes.tsx +393 -0
- package/src/serialization.ts +58 -0
- package/src/styles.css +2 -0
- package/src/tables.tsx +467 -0
- package/src/template.ts +30 -0
- package/src/theme-toggle.tsx +24 -0
- package/src/theme.tsx +108 -0
- package/src/vite-env.d.ts +1 -0
- package/vite.config.ts +41 -0
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { Card, CardContent, CardHeader, CardTitle } from "@git-snitch/ui/components/card";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
import type { RepoReportData, ReportData } from "@git-snitch/core";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
ActivityHeatmap,
|
|
8
|
+
AdditionsVsDeletionsChart,
|
|
9
|
+
CodeOwnershipChart,
|
|
10
|
+
CommitActivityChart,
|
|
11
|
+
CommitSizeDistributionChart,
|
|
12
|
+
ContributionCalendar,
|
|
13
|
+
ContributorPieChart,
|
|
14
|
+
LanguageDistributionChart,
|
|
15
|
+
TimeOfDayChart,
|
|
16
|
+
VelocityChart,
|
|
17
|
+
WeeklyActivityChart,
|
|
18
|
+
deriveActivityHeatmapData,
|
|
19
|
+
deriveAdditionsVsDeletionsData,
|
|
20
|
+
deriveCodeOwnershipData,
|
|
21
|
+
deriveCommitActivityData,
|
|
22
|
+
deriveCommitSizeDistributionData,
|
|
23
|
+
deriveContributionCalendarData,
|
|
24
|
+
deriveContributorPieData,
|
|
25
|
+
deriveLanguageDistributionData,
|
|
26
|
+
deriveTimeOfDayData,
|
|
27
|
+
deriveVelocityData,
|
|
28
|
+
deriveWeeklyActivityData,
|
|
29
|
+
} from "./charts.js";
|
|
30
|
+
import { EmptyState } from "./empty-state.js";
|
|
31
|
+
|
|
32
|
+
type ChartsRouteProps = {
|
|
33
|
+
readonly report: ReportData;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
function repoDataMismatch() {
|
|
37
|
+
return (
|
|
38
|
+
<EmptyState
|
|
39
|
+
title="Charts are unavailable for scan reports"
|
|
40
|
+
description="This route expects a single-repository report. Open the scan overview for multi-repository aggregate evidence."
|
|
41
|
+
/>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function hasChartableActivity(report: RepoReportData) {
|
|
46
|
+
return (
|
|
47
|
+
report.commits.length > 0 ||
|
|
48
|
+
report.contributors.some((contributor) => contributor.commitCount > 0 || contributor.filesChanged > 0) ||
|
|
49
|
+
report.analysis.languages.some((language) => language.lines > 0 || language.files > 0) ||
|
|
50
|
+
report.analysis.cadence.some((point) => point.commits > 0)
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function ChartsHeader({ report }: { readonly report: RepoReportData }) {
|
|
55
|
+
const hasActivity = hasChartableActivity(report);
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<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">
|
|
59
|
+
<div className="max-w-4xl">
|
|
60
|
+
<h2 className="text-2xl font-semibold tracking-tight text-foreground sm:text-3xl">Charts</h2>
|
|
61
|
+
<p className="mt-3 text-sm leading-6 text-muted-foreground">
|
|
62
|
+
A compact visual read of cadence, churn, ownership, and timing. The layout keeps related evidence together instead of turning every metric into a competing dashboard tile.
|
|
63
|
+
</p>
|
|
64
|
+
</div>
|
|
65
|
+
<div className="rounded-2xl border border-border/70 bg-background/70 p-4">
|
|
66
|
+
<p className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">Visual scope</p>
|
|
67
|
+
<p className="mt-2 text-sm font-medium text-foreground">{hasActivity ? "Repository activity is chartable." : "No chartable activity yet."}</p>
|
|
68
|
+
<p className="mt-1 text-xs leading-5 text-muted-foreground">Charts use only the injected standalone report payload.</p>
|
|
69
|
+
</div>
|
|
70
|
+
</section>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function SectionFrame({ title, description, children }: { readonly title: string; readonly description: string; readonly children: ReactNode }) {
|
|
75
|
+
return (
|
|
76
|
+
<Card className="overflow-hidden shadow-none">
|
|
77
|
+
<CardHeader className="border-b border-border/60 bg-muted/25">
|
|
78
|
+
<CardTitle className="text-lg font-semibold tracking-tight text-foreground">{title}</CardTitle>
|
|
79
|
+
<p className="max-w-3xl text-sm leading-6 text-muted-foreground">{description}</p>
|
|
80
|
+
</CardHeader>
|
|
81
|
+
<CardContent className="p-4 sm:p-5">{children}</CardContent>
|
|
82
|
+
</Card>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function SparseRepositoryNotice({ report }: { readonly report: RepoReportData }) {
|
|
87
|
+
if (hasChartableActivity(report)) {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<EmptyState
|
|
93
|
+
title="This repository has no chartable activity yet"
|
|
94
|
+
description="Commit, contributor, language, and cadence data are all empty for this branch scope. Each chart below explains the specific data it needs."
|
|
95
|
+
/>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function ChartsRoute({ report }: ChartsRouteProps) {
|
|
100
|
+
if (report.kind !== "repo") {
|
|
101
|
+
return repoDataMismatch();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const activity = deriveCommitActivityData(report);
|
|
105
|
+
const churn = deriveAdditionsVsDeletionsData(report.commits);
|
|
106
|
+
const sizes = deriveCommitSizeDistributionData(report.commits);
|
|
107
|
+
const languages = deriveLanguageDistributionData(report);
|
|
108
|
+
const calendar = deriveContributionCalendarData(report.commits);
|
|
109
|
+
const velocity = deriveVelocityData(report);
|
|
110
|
+
const ownership = deriveCodeOwnershipData(report.contributors);
|
|
111
|
+
const heatmap = deriveActivityHeatmapData(report.commits);
|
|
112
|
+
const timeOfDay = deriveTimeOfDayData(report.commits);
|
|
113
|
+
const contributorShare = deriveContributorPieData(report.contributors);
|
|
114
|
+
const weeklyActivity = deriveWeeklyActivityData(report.commits);
|
|
115
|
+
const showContributorShare = contributorShare.length > 1;
|
|
116
|
+
|
|
117
|
+
return (
|
|
118
|
+
<div className="grid gap-6">
|
|
119
|
+
<ChartsHeader report={report} />
|
|
120
|
+
<SparseRepositoryNotice report={report} />
|
|
121
|
+
|
|
122
|
+
<SectionFrame title="Cadence and churn" description="Start with the signals that establish tempo before drilling into ownership or hourly behavior.">
|
|
123
|
+
<div className="grid grid-flow-dense gap-5 xl:grid-cols-12">
|
|
124
|
+
<div className="xl:col-span-7">
|
|
125
|
+
<CommitActivityChart data={activity} />
|
|
126
|
+
</div>
|
|
127
|
+
<div className="xl:col-span-5">
|
|
128
|
+
<VelocityChart data={velocity} />
|
|
129
|
+
</div>
|
|
130
|
+
<div className="xl:col-span-7">
|
|
131
|
+
<AdditionsVsDeletionsChart data={churn} />
|
|
132
|
+
</div>
|
|
133
|
+
<div className="xl:col-span-5">
|
|
134
|
+
<CommitSizeDistributionChart data={sizes} />
|
|
135
|
+
</div>
|
|
136
|
+
</div>
|
|
137
|
+
</SectionFrame>
|
|
138
|
+
|
|
139
|
+
<SectionFrame title="Activity rhythm" description="Temporal views reveal when work actually happens without mixing the timeline with unrelated repository totals.">
|
|
140
|
+
<div className="grid grid-flow-dense gap-5 lg:grid-cols-2">
|
|
141
|
+
<TimeOfDayChart data={timeOfDay} />
|
|
142
|
+
{showContributorShare ? <ContributorPieChart data={contributorShare} /> : <WeeklyActivityChart data={weeklyActivity} />}
|
|
143
|
+
<div className="lg:col-span-2">
|
|
144
|
+
<ActivityHeatmap data={heatmap} />
|
|
145
|
+
</div>
|
|
146
|
+
</div>
|
|
147
|
+
</SectionFrame>
|
|
148
|
+
|
|
149
|
+
<SectionFrame title="Repository shape" description="The final group stays focused on source mix, ownership footprint, and day-level density.">
|
|
150
|
+
<div className="grid grid-flow-dense gap-5 lg:grid-cols-3">
|
|
151
|
+
<LanguageDistributionChart data={languages} />
|
|
152
|
+
<CodeOwnershipChart data={ownership} />
|
|
153
|
+
<ContributionCalendar data={calendar} />
|
|
154
|
+
</div>
|
|
155
|
+
</SectionFrame>
|
|
156
|
+
</div>
|
|
157
|
+
);
|
|
158
|
+
}
|
package/src/charts.tsx
ADDED
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@git-snitch/ui/components/chart";
|
|
2
|
+
import { Card, CardContent, CardHeader, CardTitle } from "@git-snitch/ui/components/card";
|
|
3
|
+
import { cn } from "@git-snitch/ui/lib/utils";
|
|
4
|
+
import {
|
|
5
|
+
Area,
|
|
6
|
+
AreaChart,
|
|
7
|
+
Bar,
|
|
8
|
+
BarChart,
|
|
9
|
+
CartesianGrid,
|
|
10
|
+
Cell,
|
|
11
|
+
Line,
|
|
12
|
+
LineChart,
|
|
13
|
+
Pie,
|
|
14
|
+
PieChart,
|
|
15
|
+
PolarAngleAxis,
|
|
16
|
+
PolarGrid,
|
|
17
|
+
Radar,
|
|
18
|
+
RadarChart,
|
|
19
|
+
XAxis,
|
|
20
|
+
YAxis,
|
|
21
|
+
} from "recharts";
|
|
22
|
+
import type { ReactNode } from "react";
|
|
23
|
+
|
|
24
|
+
import type { CommitRecord, ContributorSummary, RepoReportData, ScanProjectReport, ScanReportData } from "@git-snitch/core";
|
|
25
|
+
|
|
26
|
+
import { EmptyState } from "./empty-state.js";
|
|
27
|
+
|
|
28
|
+
const chartPalette = ["var(--chart-1)", "var(--chart-2)", "var(--chart-3)", "var(--chart-4)", "var(--chart-5)"] as const;
|
|
29
|
+
const shortWeekdays = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] as const;
|
|
30
|
+
const staticChartProps = { isAnimationActive: false } as const;
|
|
31
|
+
type Weekday = (typeof shortWeekdays)[number];
|
|
32
|
+
|
|
33
|
+
export type CommitActivityPoint = { readonly period: string; readonly commits: number };
|
|
34
|
+
export type ContributorPieSlice = { readonly name: string; readonly commits: number };
|
|
35
|
+
export type LanguageDistributionSlice = { readonly language: string; readonly lines: number; readonly files: number };
|
|
36
|
+
export type AdditionsVsDeletionsPoint = { readonly period: string; readonly additions: number; readonly deletions: number };
|
|
37
|
+
export type CommitSizeBucket = { readonly label: string; readonly commits: number };
|
|
38
|
+
export type WeeklyActivityPoint = { readonly day: string; readonly commits: number };
|
|
39
|
+
export type TimeOfDayPoint = { readonly hour: string; readonly commits: number };
|
|
40
|
+
export type ContributionCalendarDay = { readonly date: string; readonly commits: number };
|
|
41
|
+
export type VelocityPoint = { readonly period: string; readonly commits: number; readonly average: number };
|
|
42
|
+
export type CodeOwnershipPoint = { readonly owner: string; readonly additions: number; readonly deletions: number; readonly filesChanged: number };
|
|
43
|
+
export type ProjectComparisonPoint = { readonly project: string; readonly commits: number; readonly contributors: number; readonly filesChanged: number };
|
|
44
|
+
export type ActivityHeatmapCell = { readonly day: string; readonly hour: string; readonly commits: number };
|
|
45
|
+
|
|
46
|
+
type ChartPanelProps = {
|
|
47
|
+
readonly title: string;
|
|
48
|
+
readonly description: string;
|
|
49
|
+
readonly children: ReactNode;
|
|
50
|
+
readonly isEmpty: boolean;
|
|
51
|
+
readonly emptyTitle: string;
|
|
52
|
+
readonly emptyDescription: string;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
function ChartPanel({ title, description, children, isEmpty, emptyTitle, emptyDescription }: ChartPanelProps) {
|
|
56
|
+
if (isEmpty) {
|
|
57
|
+
return <EmptyState title={emptyTitle} description={emptyDescription} />;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<Card className="shadow-none">
|
|
62
|
+
<CardHeader className="space-y-2">
|
|
63
|
+
<CardTitle className="text-base font-semibold tracking-tight text-foreground">{title}</CardTitle>
|
|
64
|
+
<p className="text-sm leading-6 text-muted-foreground">{description}</p>
|
|
65
|
+
</CardHeader>
|
|
66
|
+
<CardContent>{children}</CardContent>
|
|
67
|
+
</Card>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function hasPositiveValue<T>(data: readonly T[], select: (item: T) => readonly number[]) {
|
|
72
|
+
return data.some((item) => select(item).some((value) => value > 0));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function parseIsoDate(value: string) {
|
|
76
|
+
const date = new Date(value);
|
|
77
|
+
return Number.isNaN(date.getTime()) ? undefined : date;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function monthKey(value: string) {
|
|
81
|
+
return value.slice(0, 7);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function weekdayForDate(date: Date): Weekday {
|
|
85
|
+
return shortWeekdays[date.getUTCDay()] ?? "Sun";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function deriveCommitActivityData(report: Pick<RepoReportData, "analysis">): readonly CommitActivityPoint[] {
|
|
89
|
+
return report.analysis.cadence.map((point) => ({ period: point.period, commits: point.commits }));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function deriveContributorPieData(contributors: readonly ContributorSummary[]): readonly ContributorPieSlice[] {
|
|
93
|
+
return contributors
|
|
94
|
+
.filter((contributor) => contributor.commitCount > 0)
|
|
95
|
+
.map((contributor) => ({ name: contributor.name, commits: contributor.commitCount }))
|
|
96
|
+
.sort((left, right) => right.commits - left.commits || left.name.localeCompare(right.name));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function deriveLanguageDistributionData(report: Pick<RepoReportData | ScanReportData, "analysis">): readonly LanguageDistributionSlice[] {
|
|
100
|
+
return report.analysis.languages
|
|
101
|
+
.filter((language) => language.lines > 0 || language.files > 0)
|
|
102
|
+
.map((language) => ({ language: language.language, lines: language.lines, files: language.files }))
|
|
103
|
+
.sort((left, right) => right.lines - left.lines || left.language.localeCompare(right.language));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function deriveAdditionsVsDeletionsData(commits: readonly CommitRecord[]): readonly AdditionsVsDeletionsPoint[] {
|
|
107
|
+
const periods = new Map<string, { additions: number; deletions: number }>();
|
|
108
|
+
for (const commit of commits) {
|
|
109
|
+
const period = monthKey(commit.authoredAt);
|
|
110
|
+
const current = periods.get(period) ?? { additions: 0, deletions: 0 };
|
|
111
|
+
current.additions += commit.files.reduce((sum, file) => sum + file.additions, 0);
|
|
112
|
+
current.deletions += commit.files.reduce((sum, file) => sum + file.deletions, 0);
|
|
113
|
+
periods.set(period, current);
|
|
114
|
+
}
|
|
115
|
+
return [...periods.entries()].sort(([left], [right]) => left.localeCompare(right)).map(([period, totals]) => ({ period, ...totals }));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function deriveCommitSizeDistributionData(commits: readonly CommitRecord[]): readonly CommitSizeBucket[] {
|
|
119
|
+
const buckets = [
|
|
120
|
+
{ label: "0 (empty)", min: 0, max: 0, commits: 0 },
|
|
121
|
+
{ label: "1-10", min: 1, max: 10, commits: 0 },
|
|
122
|
+
{ label: "11-50", min: 11, max: 50, commits: 0 },
|
|
123
|
+
{ label: "51-200", min: 51, max: 200, commits: 0 },
|
|
124
|
+
{ label: "201+", min: 201, max: Number.POSITIVE_INFINITY, commits: 0 },
|
|
125
|
+
];
|
|
126
|
+
for (const commit of commits) {
|
|
127
|
+
const changedLines = commit.files.reduce((sum, file) => sum + file.additions + file.deletions, 0);
|
|
128
|
+
const bucket = buckets.find((candidate) => changedLines >= candidate.min && changedLines <= candidate.max);
|
|
129
|
+
if (bucket) {
|
|
130
|
+
bucket.commits += 1;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return buckets.map(({ label, commits }) => ({ label, commits }));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function deriveWeeklyActivityData(commits: readonly CommitRecord[]): readonly WeeklyActivityPoint[] {
|
|
137
|
+
const counts = new Map<Weekday, number>(shortWeekdays.map((day) => [day, 0]));
|
|
138
|
+
for (const commit of commits) {
|
|
139
|
+
const date = parseIsoDate(commit.authoredAt);
|
|
140
|
+
if (date) {
|
|
141
|
+
const day = weekdayForDate(date);
|
|
142
|
+
counts.set(day, (counts.get(day) ?? 0) + 1);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return shortWeekdays.map((day) => ({ day, commits: counts.get(day) ?? 0 }));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function deriveTimeOfDayData(commits: readonly CommitRecord[]): readonly TimeOfDayPoint[] {
|
|
149
|
+
const counts = Array.from({ length: 24 }, () => 0);
|
|
150
|
+
for (const commit of commits) {
|
|
151
|
+
const date = parseIsoDate(commit.authoredAt);
|
|
152
|
+
if (date) {
|
|
153
|
+
const hour = date.getUTCHours();
|
|
154
|
+
counts[hour] = (counts[hour] ?? 0) + 1;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return counts.map((commits, hour) => ({ hour: `${hour.toString().padStart(2, "0")}:00`, commits }));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function deriveContributionCalendarData(commits: readonly CommitRecord[]): readonly ContributionCalendarDay[] {
|
|
161
|
+
if (commits.length === 0) return [];
|
|
162
|
+
|
|
163
|
+
const counts = new Map<string, number>();
|
|
164
|
+
let minDate = "";
|
|
165
|
+
let maxDate = "";
|
|
166
|
+
for (const commit of commits) {
|
|
167
|
+
const date = commit.authoredAt.slice(0, 10);
|
|
168
|
+
counts.set(date, (counts.get(date) ?? 0) + 1);
|
|
169
|
+
if (minDate === "" || date < minDate) minDate = date;
|
|
170
|
+
if (maxDate === "" || date > maxDate) maxDate = date;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const result: ContributionCalendarDay[] = [];
|
|
174
|
+
const current = new Date(`${minDate}T00:00:00Z`);
|
|
175
|
+
const end = new Date(`${maxDate}T00:00:00Z`);
|
|
176
|
+
while (current <= end) {
|
|
177
|
+
const dateStr = current.toISOString().slice(0, 10);
|
|
178
|
+
result.push({ date: dateStr, commits: counts.get(dateStr) ?? 0 });
|
|
179
|
+
current.setUTCDate(current.getUTCDate() + 1);
|
|
180
|
+
}
|
|
181
|
+
return result;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function deriveVelocityData(report: Pick<RepoReportData, "analysis">): readonly VelocityPoint[] {
|
|
185
|
+
const activity = deriveCommitActivityData(report);
|
|
186
|
+
return activity.map((point, index) => {
|
|
187
|
+
const window = activity.slice(Math.max(0, index - 2), index + 1);
|
|
188
|
+
const average = window.reduce((sum, item) => sum + item.commits, 0) / window.length;
|
|
189
|
+
return { ...point, average: Math.round(average * 10) / 10 };
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function deriveCodeOwnershipData(contributors: readonly ContributorSummary[]): readonly CodeOwnershipPoint[] {
|
|
194
|
+
return contributors
|
|
195
|
+
.filter((contributor) => contributor.additions > 0 || contributor.deletions > 0 || contributor.filesChanged > 0)
|
|
196
|
+
.map((contributor) => ({
|
|
197
|
+
owner: contributor.name,
|
|
198
|
+
additions: contributor.additions,
|
|
199
|
+
deletions: contributor.deletions,
|
|
200
|
+
filesChanged: contributor.filesChanged,
|
|
201
|
+
}))
|
|
202
|
+
.sort((left, right) => right.filesChanged - left.filesChanged || left.owner.localeCompare(right.owner));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function deriveProjectsComparisonData(projects: readonly ScanProjectReport[]): readonly ProjectComparisonPoint[] {
|
|
206
|
+
return projects
|
|
207
|
+
.map((project) => ({
|
|
208
|
+
project: project.repository.relativePath,
|
|
209
|
+
commits: project.report.repository.totalCommits,
|
|
210
|
+
contributors: project.report.repository.totalContributors,
|
|
211
|
+
filesChanged: new Set(project.report.commits.flatMap((commit) => commit.files.map((file) => file.path))).size,
|
|
212
|
+
}))
|
|
213
|
+
.sort((left, right) => right.commits - left.commits || left.project.localeCompare(right.project));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function deriveActivityHeatmapData(commits: readonly CommitRecord[]): readonly ActivityHeatmapCell[] {
|
|
217
|
+
const counts = new Map<string, number>();
|
|
218
|
+
for (const commit of commits) {
|
|
219
|
+
const date = parseIsoDate(commit.authoredAt);
|
|
220
|
+
if (date) {
|
|
221
|
+
const key = `${weekdayForDate(date)}-${date.getUTCHours().toString().padStart(2, "0")}:00`;
|
|
222
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return shortWeekdays.flatMap((day) =>
|
|
226
|
+
Array.from({ length: 24 }, (_, hour) => {
|
|
227
|
+
const label = `${hour.toString().padStart(2, "0")}:00`;
|
|
228
|
+
return { day, hour: label, commits: counts.get(`${day}-${label}`) ?? 0 };
|
|
229
|
+
}),
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function CommitActivityChart({ data }: { readonly data: readonly CommitActivityPoint[] }) {
|
|
234
|
+
return (
|
|
235
|
+
<ChartPanel
|
|
236
|
+
title="Commit activity"
|
|
237
|
+
description="Monthly commit cadence without decorative chrome."
|
|
238
|
+
isEmpty={!hasPositiveValue(data, (item) => [item.commits])}
|
|
239
|
+
emptyTitle="No commit activity to chart"
|
|
240
|
+
emptyDescription="This report has no dated commits, so there is no cadence data yet."
|
|
241
|
+
>
|
|
242
|
+
<ChartContainer config={{ commits: { label: "Commits", color: chartPalette[1] } }} className="h-64 w-full">
|
|
243
|
+
<AreaChart data={data} margin={{ left: 0, right: 12, top: 8, bottom: 0 }}>
|
|
244
|
+
<CartesianGrid vertical={false} />
|
|
245
|
+
<XAxis dataKey="period" tickLine={false} axisLine={false} />
|
|
246
|
+
<YAxis width={32} tickLine={false} axisLine={false} allowDecimals={false} />
|
|
247
|
+
<ChartTooltip content={<ChartTooltipContent />} />
|
|
248
|
+
<Area type="monotone" dataKey="commits" stroke="var(--color-commits)" fill="var(--color-commits)" fillOpacity={0.18} {...staticChartProps} />
|
|
249
|
+
</AreaChart>
|
|
250
|
+
</ChartContainer>
|
|
251
|
+
</ChartPanel>
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export function ContributorPieChart({ data }: { readonly data: readonly ContributorPieSlice[] }) {
|
|
256
|
+
return (
|
|
257
|
+
<ChartPanel
|
|
258
|
+
title="Contributor share"
|
|
259
|
+
description="Commit share by contributor."
|
|
260
|
+
isEmpty={!hasPositiveValue(data, (item) => [item.commits])}
|
|
261
|
+
emptyTitle="No contributor share to chart"
|
|
262
|
+
emptyDescription="Contributor share appears after at least one contributor has commits."
|
|
263
|
+
>
|
|
264
|
+
<ChartContainer config={{ commits: { label: "Commits" } }} className="h-64 w-full">
|
|
265
|
+
<PieChart>
|
|
266
|
+
<ChartTooltip content={<ChartTooltipContent nameKey="name" />} />
|
|
267
|
+
<Pie data={data} dataKey="commits" nameKey="name" innerRadius={56} outerRadius={88} paddingAngle={2} {...staticChartProps}>
|
|
268
|
+
{data.map((slice, index) => (
|
|
269
|
+
<Cell key={slice.name} fill={chartPalette[index % chartPalette.length]} />
|
|
270
|
+
))}
|
|
271
|
+
</Pie>
|
|
272
|
+
</PieChart>
|
|
273
|
+
</ChartContainer>
|
|
274
|
+
</ChartPanel>
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export function LanguageDistributionChart({ data }: { readonly data: readonly LanguageDistributionSlice[] }) {
|
|
279
|
+
return (
|
|
280
|
+
<ChartPanel
|
|
281
|
+
title="Language distribution"
|
|
282
|
+
description="Lines by detected language."
|
|
283
|
+
isEmpty={!hasPositiveValue(data, (item) => [item.lines, item.files])}
|
|
284
|
+
emptyTitle="No language distribution to chart"
|
|
285
|
+
emptyDescription="Language data is unavailable when no source files were counted."
|
|
286
|
+
>
|
|
287
|
+
<ChartContainer config={{ lines: { label: "Lines", color: chartPalette[0] } }} className="h-64 w-full">
|
|
288
|
+
<BarChart data={data} layout="vertical" margin={{ left: 8, right: 16, top: 8, bottom: 0 }}>
|
|
289
|
+
<CartesianGrid horizontal={false} />
|
|
290
|
+
<XAxis type="number" hide />
|
|
291
|
+
<YAxis dataKey="language" type="category" width={96} tickLine={false} axisLine={false} />
|
|
292
|
+
<ChartTooltip content={<ChartTooltipContent />} />
|
|
293
|
+
<Bar dataKey="lines" fill="var(--color-lines)" radius={[0, 3, 3, 0]} {...staticChartProps} />
|
|
294
|
+
</BarChart>
|
|
295
|
+
</ChartContainer>
|
|
296
|
+
</ChartPanel>
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export function AdditionsVsDeletionsChart({ data }: { readonly data: readonly AdditionsVsDeletionsPoint[] }) {
|
|
301
|
+
return (
|
|
302
|
+
<ChartPanel
|
|
303
|
+
title="Additions vs deletions"
|
|
304
|
+
description="Monthly churn split into added and removed lines."
|
|
305
|
+
isEmpty={!hasPositiveValue(data, (item) => [item.additions, item.deletions])}
|
|
306
|
+
emptyTitle="No line churn to chart"
|
|
307
|
+
emptyDescription="This report has no file-level additions or deletions."
|
|
308
|
+
>
|
|
309
|
+
<ChartContainer config={{ additions: { label: "Additions", color: chartPalette[0] }, deletions: { label: "Deletions", color: chartPalette[4] } }} className="h-64 w-full">
|
|
310
|
+
<BarChart data={data} margin={{ left: 0, right: 12, top: 8, bottom: 0 }}>
|
|
311
|
+
<CartesianGrid vertical={false} />
|
|
312
|
+
<XAxis dataKey="period" tickLine={false} axisLine={false} />
|
|
313
|
+
<YAxis width={40} tickLine={false} axisLine={false} allowDecimals={false} />
|
|
314
|
+
<ChartTooltip content={<ChartTooltipContent />} />
|
|
315
|
+
<Bar dataKey="additions" fill="var(--color-additions)" radius={[3, 3, 0, 0]} {...staticChartProps} />
|
|
316
|
+
<Bar dataKey="deletions" fill="var(--color-deletions)" radius={[3, 3, 0, 0]} {...staticChartProps} />
|
|
317
|
+
</BarChart>
|
|
318
|
+
</ChartContainer>
|
|
319
|
+
</ChartPanel>
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export function CommitSizeDistributionChart({ data }: { readonly data: readonly CommitSizeBucket[] }) {
|
|
324
|
+
return (
|
|
325
|
+
<ChartPanel
|
|
326
|
+
title="Commit size distribution"
|
|
327
|
+
description="Commits grouped by touched lines."
|
|
328
|
+
isEmpty={!hasPositiveValue(data, (item) => [item.commits])}
|
|
329
|
+
emptyTitle="No commit sizes to chart"
|
|
330
|
+
emptyDescription="Commit size distribution needs commits with file-level line changes."
|
|
331
|
+
>
|
|
332
|
+
<ChartContainer config={{ commits: { label: "Commits", color: chartPalette[2] } }} className="h-64 w-full">
|
|
333
|
+
<BarChart data={data} margin={{ left: 0, right: 12, top: 8, bottom: 0 }}>
|
|
334
|
+
<CartesianGrid vertical={false} />
|
|
335
|
+
<XAxis dataKey="label" tickLine={false} axisLine={false} />
|
|
336
|
+
<YAxis width={32} tickLine={false} axisLine={false} allowDecimals={false} />
|
|
337
|
+
<ChartTooltip content={<ChartTooltipContent />} />
|
|
338
|
+
<Bar dataKey="commits" fill="var(--color-commits)" radius={[3, 3, 0, 0]} {...staticChartProps} />
|
|
339
|
+
</BarChart>
|
|
340
|
+
</ChartContainer>
|
|
341
|
+
</ChartPanel>
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export function WeeklyActivityChart({ data }: { readonly data: readonly WeeklyActivityPoint[] }) {
|
|
346
|
+
return (
|
|
347
|
+
<ChartPanel title="Weekly activity" description="Commits by UTC weekday." isEmpty={!hasPositiveValue(data, (item) => [item.commits])} emptyTitle="No weekly activity to chart" emptyDescription="This activity view needs at least one dated commit.">
|
|
348
|
+
<ChartContainer config={{ commits: { label: "Commits", color: chartPalette[1] } }} className="h-64 w-full">
|
|
349
|
+
<BarChart data={data} margin={{ left: 0, right: 12, top: 8, bottom: 0 }}>
|
|
350
|
+
<CartesianGrid vertical={false} />
|
|
351
|
+
<XAxis dataKey="day" tickLine={false} axisLine={false} interval="preserveStartEnd" />
|
|
352
|
+
<YAxis width={32} tickLine={false} axisLine={false} allowDecimals={false} />
|
|
353
|
+
<ChartTooltip content={<ChartTooltipContent />} />
|
|
354
|
+
<Bar dataKey="commits" fill="var(--color-commits)" radius={[3, 3, 0, 0]} {...staticChartProps} />
|
|
355
|
+
</BarChart>
|
|
356
|
+
</ChartContainer>
|
|
357
|
+
</ChartPanel>
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export function TimeOfDayChart({ data }: { readonly data: readonly TimeOfDayPoint[] }) {
|
|
362
|
+
return (
|
|
363
|
+
<ChartPanel title="Time of day" description="Commits by UTC hour." isEmpty={!hasPositiveValue(data, (item) => [item.commits])} emptyTitle="No hourly activity to chart" emptyDescription="This activity view needs at least one dated commit.">
|
|
364
|
+
<ChartContainer config={{ commits: { label: "Commits", color: chartPalette[1] } }} className="h-64 w-full">
|
|
365
|
+
<BarChart data={data} margin={{ left: 0, right: 12, top: 8, bottom: 0 }}>
|
|
366
|
+
<CartesianGrid vertical={false} />
|
|
367
|
+
<XAxis dataKey="hour" tickLine={false} axisLine={false} interval="preserveStartEnd" />
|
|
368
|
+
<YAxis width={32} tickLine={false} axisLine={false} allowDecimals={false} />
|
|
369
|
+
<ChartTooltip content={<ChartTooltipContent />} />
|
|
370
|
+
<Bar dataKey="commits" fill="var(--color-commits)" radius={[3, 3, 0, 0]} {...staticChartProps} />
|
|
371
|
+
</BarChart>
|
|
372
|
+
</ChartContainer>
|
|
373
|
+
</ChartPanel>
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
export function ContributionCalendar({ data }: { readonly data: readonly ContributionCalendarDay[] }) {
|
|
378
|
+
const firstDate = data.length > 0 ? data[0] : undefined;
|
|
379
|
+
const firstDayOffset = firstDate !== undefined
|
|
380
|
+
? new Date(`${firstDate.date}T00:00:00Z`).getUTCDay()
|
|
381
|
+
: 0;
|
|
382
|
+
|
|
383
|
+
return (
|
|
384
|
+
<ChartPanel
|
|
385
|
+
title="Contribution calendar"
|
|
386
|
+
description="Daily commit density."
|
|
387
|
+
isEmpty={!hasPositiveValue(data, (item) => [item.commits])}
|
|
388
|
+
emptyTitle="No contribution calendar to show"
|
|
389
|
+
emptyDescription="The calendar needs at least one dated commit."
|
|
390
|
+
>
|
|
391
|
+
<div className="grid grid-cols-7 gap-1" aria-label="Daily contribution calendar">
|
|
392
|
+
{shortWeekdays.map((day) => (
|
|
393
|
+
<div key={day} className="text-center text-xs text-muted-foreground">{day}</div>
|
|
394
|
+
))}
|
|
395
|
+
{Array.from({ length: firstDayOffset }, (_, i) => (
|
|
396
|
+
<div key={`offset-${i}`} />
|
|
397
|
+
))}
|
|
398
|
+
{data.map((day) => (
|
|
399
|
+
<div key={day.date} title={`${day.date}: ${day.commits} commits`} className={cn("h-7 rounded-sm border", heatClass(day.commits))}>
|
|
400
|
+
<span className="sr-only">{`${day.date}: ${day.commits} commits`}</span>
|
|
401
|
+
</div>
|
|
402
|
+
))}
|
|
403
|
+
</div>
|
|
404
|
+
</ChartPanel>
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
export function VelocityChart({ data }: { readonly data: readonly VelocityPoint[] }) {
|
|
409
|
+
return (
|
|
410
|
+
<ChartPanel title="Velocity" description="Commit cadence with a three-period rolling average." isEmpty={!hasPositiveValue(data, (item) => [item.commits, item.average])} emptyTitle="No velocity to chart" emptyDescription="Velocity appears after commits exist across dated periods.">
|
|
411
|
+
<ChartContainer config={{ commits: { label: "Commits", color: chartPalette[1] }, average: { label: "Average", color: chartPalette[4] } }} className="h-64 w-full">
|
|
412
|
+
<LineChart data={data} margin={{ left: 0, right: 12, top: 8, bottom: 0 }}>
|
|
413
|
+
<CartesianGrid vertical={false} />
|
|
414
|
+
<XAxis dataKey="period" tickLine={false} axisLine={false} />
|
|
415
|
+
<YAxis width={32} tickLine={false} axisLine={false} allowDecimals={false} />
|
|
416
|
+
<ChartTooltip content={<ChartTooltipContent />} />
|
|
417
|
+
<Line type="monotone" dataKey="commits" stroke="var(--color-commits)" strokeWidth={2} dot={false} {...staticChartProps} />
|
|
418
|
+
<Line type="monotone" dataKey="average" stroke="var(--color-average)" strokeWidth={2} dot={false} strokeDasharray="4 4" {...staticChartProps} />
|
|
419
|
+
</LineChart>
|
|
420
|
+
</ChartContainer>
|
|
421
|
+
</ChartPanel>
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
export function CodeOwnershipChart({ data }: { readonly data: readonly CodeOwnershipPoint[] }) {
|
|
426
|
+
return (
|
|
427
|
+
<ChartPanel title="Code ownership" description="Contributor footprint by changed files and churn." isEmpty={!hasPositiveValue(data, (item) => [item.filesChanged, item.additions, item.deletions])} emptyTitle="No ownership data to chart" emptyDescription="Ownership needs contributor file or churn statistics.">
|
|
428
|
+
<ChartContainer config={{ additions: { label: "Additions", color: chartPalette[0] }, deletions: { label: "Deletions", color: chartPalette[3] }, filesChanged: { label: "Files changed", color: chartPalette[2] } }} className="h-72 w-full">
|
|
429
|
+
<RadarChart data={data} margin={{ left: 16, right: 16, top: 8, bottom: 8 }}>
|
|
430
|
+
<PolarGrid />
|
|
431
|
+
<PolarAngleAxis dataKey="owner" />
|
|
432
|
+
<ChartTooltip content={<ChartTooltipContent />} />
|
|
433
|
+
<Radar dataKey="filesChanged" stroke="var(--color-filesChanged)" fill="var(--color-filesChanged)" fillOpacity={0.18} {...staticChartProps} />
|
|
434
|
+
</RadarChart>
|
|
435
|
+
</ChartContainer>
|
|
436
|
+
</ChartPanel>
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
export function ProjectsComparisonChart({ data }: { readonly data: readonly ProjectComparisonPoint[] }) {
|
|
441
|
+
return (
|
|
442
|
+
<ChartPanel title="Projects comparison" description="Repository activity across a scan." isEmpty={!hasPositiveValue(data, (item) => [item.commits, item.contributors, item.filesChanged])} emptyTitle="No projects to compare" emptyDescription="Project comparison needs at least one scanned repository with activity.">
|
|
443
|
+
<ChartContainer config={{ commits: { label: "Commits", color: chartPalette[1] }, contributors: { label: "Contributors", color: chartPalette[3] } }} className="h-72 w-full">
|
|
444
|
+
<BarChart data={data} layout="vertical" margin={{ left: 8, right: 16, top: 8, bottom: 0 }}>
|
|
445
|
+
<CartesianGrid horizontal={false} />
|
|
446
|
+
<XAxis type="number" hide />
|
|
447
|
+
<YAxis dataKey="project" type="category" width={112} tickLine={false} axisLine={false} />
|
|
448
|
+
<ChartTooltip content={<ChartTooltipContent />} />
|
|
449
|
+
<Bar dataKey="commits" fill="var(--color-commits)" radius={[0, 3, 3, 0]} {...staticChartProps} />
|
|
450
|
+
<Bar dataKey="contributors" fill="var(--color-contributors)" radius={[0, 3, 3, 0]} {...staticChartProps} />
|
|
451
|
+
</BarChart>
|
|
452
|
+
</ChartContainer>
|
|
453
|
+
</ChartPanel>
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
export function ActivityHeatmap({ data }: { readonly data: readonly ActivityHeatmapCell[] }) {
|
|
458
|
+
return (
|
|
459
|
+
<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.">
|
|
460
|
+
<div className="grid grid-flow-dense grid-cols-[repeat(24,minmax(0,1fr))] gap-1" aria-label="Activity heatmap by day and hour">
|
|
461
|
+
{data.map((cell) => (
|
|
462
|
+
<div key={`${cell.day}-${cell.hour}`} title={`${cell.day} ${cell.hour}: ${cell.commits} commits`} className={cn("h-4 rounded-[2px] border", heatClass(cell.commits))}>
|
|
463
|
+
<span className="sr-only">{`${cell.day} ${cell.hour}: ${cell.commits} commits`}</span>
|
|
464
|
+
</div>
|
|
465
|
+
))}
|
|
466
|
+
</div>
|
|
467
|
+
</ChartPanel>
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function heatClass(commits: number) {
|
|
472
|
+
if (commits >= 8) {
|
|
473
|
+
return "border-chart-4/40 bg-chart-4";
|
|
474
|
+
}
|
|
475
|
+
if (commits >= 4) {
|
|
476
|
+
return "border-chart-3/40 bg-chart-3/80";
|
|
477
|
+
}
|
|
478
|
+
if (commits >= 1) {
|
|
479
|
+
return "border-chart-2/30 bg-chart-2/45";
|
|
480
|
+
}
|
|
481
|
+
return "border-border bg-muted/45";
|
|
482
|
+
}
|