| 1 | import { Link } from "react-router"; |
| 2 | |
| 3 | import type { Contributor, WeekCommits } from "@g1t/contracts"; |
| 4 | |
| 5 | import type { Route } from "./+types/contributors"; |
| 6 | import { Avatar, EmptyState, TimeAgo } from "../../components/ui"; |
| 7 | import { Hint } from "../../components/ui/hint"; |
| 8 | import { requireRepo } from "../../lib/access.server"; |
| 9 | import { alignWeeks, contributorHref, count, peak } from "../../lib/about"; |
| 10 | import { page } from "../../lib/meta"; |
| 11 | import { repos } from "../../lib/services.server"; |
| 12 | import { unwrap } from "../../lib/session.server"; |
| 13 | |
| 14 | export function meta({ params, ...args }: Route.MetaArgs) { |
| 15 | return page(args, { title: `Contributors · ${params.owner}/${params.repo} · g1t` }); |
| 16 | } |
| 17 | |
| 18 | export 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. */ |
| 24 | const WEEKS = 52; |
| 25 | |
| 26 | function 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. */ |
| 31 | function 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 | |
| 50 | function 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 | |
| 83 | export 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 | } |