| 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 { Contributor, 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 = 1): LanguageShare[] { |
| 35 | const shown = languages.filter((language) => language.percent >= least); |
| 36 | const rest = languages.filter((language) => language.percent < least); |
| 37 | if (rest.length === 0) return shown; |
| 38 | const bytes = rest.reduce((sum, language) => sum + language.bytes, 0); |
| 39 | const percent = Math.round(rest.reduce((sum, language) => sum + language.percent, 0) * 10) / 10; |
| 40 | if (percent === 0 && shown.length > 0) return shown; |
| 41 | return [...shown, { name: "Other", color: null, bytes, percent }]; |
| 42 | } |
| 43 | |
| 44 | /** Where a contributor's name leads: their profile, for an account. */ |
| 45 | export function contributorHref(contributor: Contributor): string | null { |
| 46 | return contributor.kind === "user" && contributor.username ? `/u/${contributor.username}` : null; |
| 47 | } |
| 48 | |
| 49 | /** The tallest week, for a chart's scale: never zero. */ |
| 50 | export function peak(weeks: WeekCommits[]): number { |
| 51 | return Math.max(1, ...weeks.map((week) => week.commits)); |
| 52 | } |
| 53 | |
| 54 | /** A contributor's weeks laid over the repository's, missing weeks as none. */ |
| 55 | export function alignWeeks(all: WeekCommits[], own: WeekCommits[]): number[] { |
| 56 | const byWeek = new Map(own.map((week) => [week.week, week.commits])); |
| 57 | return all.map((week) => byWeek.get(week.week) ?? 0); |
| 58 | } |
| 59 | |
| 60 | /** An Activity line: what happened, and the branch, tag or pull request it was to. */ |
| 61 | export type ActivityLine = |
| 62 | | { kind: "push"; branch: string; commit: string; before: string | null; created: boolean; defaultBranch: boolean } |
| 63 | | { kind: "tag"; tag: string; commit: string } |
| 64 | | { kind: "merge"; number: number; commit: string } |
| 65 | | { kind: "renamed"; from: string; to: string } |
| 66 | | { kind: "default"; from: string; to: string }; |
| 67 | |
| 68 | /** The events the Activity page lists. */ |
| 69 | export const ACTIVITY_TYPES = ["git.push", "pull.merged", "branch.renamed", "repo.default_branch_changed"] as const; |
| 70 | |
| 71 | /** One event as an Activity line, or null for one it does not list. */ |
| 72 | export function activityLine(event: G1tEvent): ActivityLine | null { |
| 73 | switch (event.type) { |
| 74 | case "git.push": { |
| 75 | const data = event.data as { ref: string; before?: string | null; after: string; defaultBranch: boolean }; |
| 76 | if (data.ref.startsWith("refs/tags/")) return { kind: "tag", tag: data.ref.slice("refs/tags/".length), commit: data.after }; |
| 77 | if (!data.ref.startsWith("refs/heads/")) return null; |
| 78 | return { |
| 79 | kind: "push", |
| 80 | branch: data.ref.slice("refs/heads/".length), |
| 81 | commit: data.after, |
| 82 | before: data.before ?? null, |
| 83 | created: !data.before, |
| 84 | defaultBranch: data.defaultBranch, |
| 85 | }; |
| 86 | } |
| 87 | case "pull.merged": { |
| 88 | const data = event.data as { number: number; commit: string }; |
| 89 | return { kind: "merge", number: data.number, commit: data.commit }; |
| 90 | } |
| 91 | case "branch.renamed": { |
| 92 | const data = event.data as { from: string; to: string }; |
| 93 | return { kind: "renamed", from: data.from, to: data.to }; |
| 94 | } |
| 95 | case "repo.default_branch_changed": { |
| 96 | const data = event.data as { from: string; to: string; renamed: boolean }; |
| 97 | // A rename of the default branch says so once, as a rename. |
| 98 | return data.renamed ? null : { kind: "default", from: data.from, to: data.to }; |
| 99 | } |
| 100 | default: |
| 101 | return null; |
| 102 | } |
| 103 | } |