Skip to content

g1t/apps/web/app/routes/repo/contributors.tsx

134 lines6,250 bytesCodeBlame
1import { Link } from "react-router";
2
3import type { Contributor, WeekCommits } from "@g1t/contracts";
4
5import type { Route } from "./+types/contributors";
6import { Avatar, EmptyState, TimeAgo } from "../../components/ui";
7import { Hint } from "../../components/ui/hint";
8import { requireRepo } from "../../lib/access.server";
9import { alignWeeks, contributorHref, count, peak } from "../../lib/about";
10import { page } from "../../lib/meta";
11import { repos } from "../../lib/services.server";
12import { unwrap } from "../../lib/session.server";
13
14export function meta({ params, ...args }: Route.MetaArgs) {
15 return page(args, { title: `Contributors · ${params.owner}/${params.repo} · g1t` });
16}
17
18export async function loader({ params, context }: Route.LoaderArgs) {
19 const { viewer } = await requireRepo(context, params, "read");
20 return { contributors: unwrap(await repos.contributors({ namespace: params.owner, name: params.repo }, viewer)) };
21}
22
23/** The most weeks a chart shows: the latest. */
24const WEEKS = 52;
25
26function weekLabel(week: string): string {
27 return new Date(`${week}T00:00:00Z`).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" });
28}
29
30/** Commits per week as bars, newest on the right, each with its week on hover. */
31function WeekBars({ weeks, values, height, label, scale }: { weeks: WeekCommits[]; values: number[]; height: number; label: string; scale?: number }) {
32 // One scale for every contributor, so their bars compare.
33 const top = scale ?? Math.max(1, ...values);
34 return (
35 <div role="img" aria-label={label} className="flex items-end gap-[2px]" style={{ height }}>
36 {values.map((value, at) => (
37 <Hint key={weeks[at].week} label={`${count(value, "commit")} · week of ${weekLabel(weeks[at].week)}`}>
38 <span className="flex h-full min-w-0 flex-1 items-end">
39 <span
40 className={`w-full rounded-t-[2px] ${value > 0 ? "bg-accent" : "bg-line"}`}
41 style={{ height: value > 0 ? `${Math.max(6, (value / top) * 100)}%` : 2 }}
42 />
43 </span>
44 </Hint>
45 ))}
46 </div>
47 );
48}
49
50function Person({ contributor, rank, weeks, scale }: { contributor: Contributor; rank: number; weeks: WeekCommits[]; scale: number }) {
51 const href = contributorHref(contributor);
52 const name = href ? (
53 <Link to={href} className="font-medium hover:text-accent">
54 {contributor.name}
55 </Link>
56 ) : (
57 <span className="font-medium">{contributor.name}</span>
58 );
59 return (
60 <li className="rounded-xl border border-line bg-surface p-4">
61 <div className="flex items-center gap-3">
62 <Avatar name={contributor.name} image={contributor.avatar} size={36} system={contributor.kind === "g1t"} />
63 <div className="min-w-0 grow">
64 <p className="flex items-center gap-2 truncate text-sm">
65 {name}
66 {contributor.kind === "g1t" && <span className="text-xs text-faint">agent</span>}
67 </p>
68 <p className="text-xs text-muted">
69 {count(contributor.commits, "commit")} · last <TimeAgo at={contributor.lastAt} />
70 </p>
71 </div>
72 <span className="text-xs text-faint tabular-nums">#{rank}</span>
73 </div>
74 {contributor.weeks.length > 0 && weeks.length > 0 && (
75 <div className="mt-3">
76 <WeekBars weeks={weeks} values={alignWeeks(weeks, contributor.weeks)} height={40} label={`${contributor.name}'s commits by week`} scale={scale} />
77 </div>
78 )}
79 </li>
80 );
81}
82
83export default function Contributors({ loaderData }: Route.ComponentProps) {
84 const { contributors } = loaderData;
85 const weeks = contributors.weeks.slice(-WEEKS);
86 const all = weeks.map((week) => week.commits);
87 const scale = Math.max(1, ...contributors.contributors.flatMap((contributor) => alignWeeks(weeks, contributor.weeks)));
88 return (
89 <div className="space-y-6">
90 <div>
91 <h2 className="text-lg font-semibold tracking-tight">
92 Contributors <span className="font-normal text-faint">{contributors.total}</span>
93 </h2>
94 <p className="mt-1 text-sm text-muted">
95 Commits on the default branch by who made them: people by the addresses they confirmed, g1t as itself, anyone else by
96 the name on their commits.
97 {contributors.partial && ` The newest ${contributors.commits.toLocaleString("en-US")} commits are counted.`}
98 </p>
99 </div>
100 {contributors.pending ? (
101 <EmptyState title="Reading the history">The default branch is being read for the first time. This page has it in a few seconds.</EmptyState>
102 ) : contributors.contributors.length === 0 ? (
103 <EmptyState title="No commits yet">Push a first commit and who made it shows here.</EmptyState>
104 ) : (
105 <>
106 <section className="rounded-xl border border-line bg-surface p-5">
107 <div className="mb-4 flex flex-wrap items-baseline justify-between gap-2">
108 <h3 className="text-sm font-semibold">Commits per week</h3>
109 <span className="text-xs text-muted">
110 {count(contributors.commits, "commit")} · peak {peak(weeks)} a week
111 </span>
112 </div>
113 <WeekBars weeks={weeks} values={all} height={120} label="Commits per week" />
114 <div className="mt-2 flex justify-between text-xs text-faint">
115 <span>{weeks[0] && weekLabel(weeks[0].week)}</span>
116 <span>{weeks.at(-1) && weekLabel(weeks.at(-1)!.week)}</span>
117 </div>
118 </section>
119 <ul className="grid gap-3 md:grid-cols-2">
120 {contributors.contributors.map((contributor, at) => (
121 <Person key={`${contributor.kind}:${contributor.name}`} contributor={contributor} rank={at + 1} weeks={weeks} scale={scale} />
122 ))}
123 </ul>
124 {contributors.computedAt && (
125 <p className="text-xs text-faint">
126 Counted <TimeAgo at={contributors.computedAt} />
127 {contributors.commit && contributors.head && contributors.commit !== contributors.head && ", and being counted again for the newest commits"}.
128 </p>
129 )}
130 </>
131 )}
132 </div>
133 );
134}