| 1 | /** |
| 2 | * What the Files page's About, the Contributors, Releases and Activity |
| 3 | * pages say, worked out without the server: labels, the language bar, |
| 4 | * and an activity line for each event worth one. |
| 5 | */ |
| 6 | import type { G1tEvent, LanguageShare, License, Project, WeekCommits } from "@g1t/contracts"; |
| 7 | |
| 8 | /** |
| 9 | * The address the About links to: the project's homepage (its own, or its |
| 10 | * repository's website it follows), else production when it runs |
| 11 | * elsewhere. Null leaves the repository's website. |
| 12 | */ |
| 13 | export function projectHomepage(project: Pick<Project, "links" | "runs" | "productionUrl"> | null): string | null { |
| 14 | if (!project) return null; |
| 15 | return project.links?.homepage ?? (project.runs === "elsewhere" ? project.productionUrl : null) ?? null; |
| 16 | } |
| 17 | |
| 18 | /** "MIT license", as the About names it; "View license" for one not recognized. */ |
| 19 | export function licenseLabel(license: License): string { |
| 20 | return license.spdxId ? `${license.spdxId} license` : "View license"; |
| 21 | } |
| 22 | |
| 23 | /** "12", "1.2k", "15k". */ |
| 24 | export function compact(n: number): string { |
| 25 | return n >= 10_000 ? `${Math.round(n / 1000)}k` : n >= 1000 ? `${(n / 1000).toFixed(1).replace(/\.0$/, "")}k` : String(n); |
| 26 | } |
| 27 | |
| 28 | /** "1 star", "1.2k stars". */ |
| 29 | export function count(n: number, one: string, many = `${one}s`): string { |
| 30 | return `${compact(n)} ${n === 1 ? one : many}`; |
| 31 | } |
| 32 | |
| 33 | /** The language bar's pieces: the largest, then the rest under 1% as Other. */ |
| 34 | export function languageBar(languages: LanguageShare[], least = 0.1, most = 8): LanguageShare[] { |
| 35 | // Every language down to a tenth of a percent, the largest `most` of them; the rest as Other. |
| 36 | const sorted = [...languages].sort((a, b) => b.percent - a.percent); |
| 37 | const shown = sorted.filter((language, index) => language.percent >= least && index < most); |
| 38 | const rest = sorted.filter((language) => !shown.includes(language)); |
| 39 | if (rest.length === 0) return shown; |
| 40 | const bytes = rest.reduce((sum, language) => sum + language.bytes, 0); |
| 41 | const percent = Math.round(rest.reduce((sum, language) => sum + language.percent, 0) * 10) / 10; |
| 42 | if (percent === 0 && shown.length > 0) return shown; |
| 43 | return [...shown, { name: "Other", color: null, bytes, percent }]; |
| 44 | } |
| 45 | |
| 46 | |
| 47 | /** The tallest week, for a chart's scale: never zero. */ |
| 48 | export function peak(weeks: WeekCommits[]): number { |
| 49 | return Math.max(1, ...weeks.map((week) => week.commits)); |
| 50 | } |
| 51 | |
| 52 | /** A contributor's weeks laid over the repository's, missing weeks as none. */ |
| 53 | export function alignWeeks(all: WeekCommits[], own: WeekCommits[]): number[] { |
| 54 | const byWeek = new Map(own.map((week) => [week.week, week.commits])); |
| 55 | return all.map((week) => byWeek.get(week.week) ?? 0); |
| 56 | } |
| 57 | |
| 58 | /** An Activity line: what happened, and the branch, tag or pull request it was to. */ |
| 59 | export type ActivityLine = |
| 60 | | { kind: "push"; branch: string; commit: string; before: string | null; created: boolean; defaultBranch: boolean } |
| 61 | | { kind: "tag"; tag: string; commit: string } |
| 62 | | { kind: "merge"; number: number; commit: string } |
| 63 | | { kind: "renamed"; from: string; to: string } |
| 64 | | { kind: "default"; from: string; to: string }; |
| 65 | |
| 66 | /** The events the Activity page lists. */ |
| 67 | export const ACTIVITY_TYPES = ["git.push", "pull.merged", "branch.renamed", "repo.default_branch_changed"] as const; |
| 68 | |
| 69 | /** One event as an Activity line, or null for one it does not list. */ |
| 70 | export function activityLine(event: G1tEvent): ActivityLine | null { |
| 71 | switch (event.type) { |
| 72 | case "git.push": { |
| 73 | const data = event.data as { ref: string; before?: string | null; after: string; defaultBranch: boolean }; |
| 74 | if (data.ref.startsWith("refs/tags/")) return { kind: "tag", tag: data.ref.slice("refs/tags/".length), commit: data.after }; |
| 75 | if (!data.ref.startsWith("refs/heads/")) return null; |
| 76 | return { |
| 77 | kind: "push", |
| 78 | branch: data.ref.slice("refs/heads/".length), |
| 79 | commit: data.after, |
| 80 | before: data.before ?? null, |
| 81 | created: !data.before, |
| 82 | defaultBranch: data.defaultBranch, |
| 83 | }; |
| 84 | } |
| 85 | case "pull.merged": { |
| 86 | const data = event.data as { number: number; commit: string }; |
| 87 | return { kind: "merge", number: data.number, commit: data.commit }; |
| 88 | } |
| 89 | case "branch.renamed": { |
| 90 | const data = event.data as { from: string; to: string }; |
| 91 | return { kind: "renamed", from: data.from, to: data.to }; |
| 92 | } |
| 93 | case "repo.default_branch_changed": { |
| 94 | const data = event.data as { from: string; to: string; renamed: boolean }; |
| 95 | // A rename of the default branch says so once, as a rename. |
| 96 | return data.renamed ? null : { kind: "default", from: data.from, to: data.to }; |
| 97 | } |
| 98 | default: |
| 99 | return null; |
| 100 | } |
| 101 | } |