Skip to content

g1t/apps/web/app/lib/labels.ts

117 lines5,272 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1// Labels and milestones as pages show them: a label's chip colors from its
2// own color, a milestone's progress and due date in words, and the filters
3// lists take. Pure, so it is tested without a browser.
4
5/** A label as a chip needs it. */
6export type LabelLike = { name: string; color?: string | null };
7
8/** What a list knows of a repository's labels: their colors, by name. */
9export type LabelColors = Record<string, string>;
10
11export function colorsOf(labels: { name: string; color: string }[]): LabelColors {
12 return Object.fromEntries(labels.map((label) => [label.name, label.color]));
13}
14
15/** Six lowercase hex digits from `#A1B2C3`, `a1b2c3` or `abc`; null otherwise. */
16export function tidyColor(color: string | null | undefined): string | null {
17 const hex = (color ?? "").trim().replace(/^#/, "").toLowerCase();
18 if (!/^[0-9a-f]+$/.test(hex)) return null;
19 if (hex.length === 6) return hex;
20 if (hex.length === 3) return [...hex].map((c) => c + c).join("");
21 return null;
22}
23
24/**
25 * A chip's colors from a label's own: a tinted fill and border, and text
26 * mixed toward the page's text color so that it reads on a dark or a light
27 * page alike. A label without a usable color is drawn in neutral tones.
28 */
29export function chipStyle(color: string | null | undefined): Record<string, string> {
30 const hex = tidyColor(color);
31 if (!hex) return {};
32 return {
33 color: `color-mix(in oklab, #${hex} 55%, var(--color-fg))`,
34 borderColor: `color-mix(in oklab, #${hex} 55%, transparent)`,
35 backgroundColor: `color-mix(in oklab, #${hex} 18%, transparent)`,
36 };
37}
38
39/** Labels that match what someone typed: by name or description. */
40export function matchLabels<T extends { name: string; description?: string }>(labels: T[], query: string): T[] {
41 const words = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
42 if (words.length === 0) return labels;
43 return labels.filter((label) => {
44 const text = `${label.name} ${label.description ?? ""}`.toLowerCase();
45 return words.every((word) => text.includes(word));
46 });
47}
48
49/** A label name as g1t keeps them: lowercase, single spaces. */
50export function tidyLabelName(name: string): string {
51 return name.trim().split(/\s+/).join(" ").toLowerCase();
52}
53
54/** A milestone's progress: closed items over all of them, 0 to 100. */
55export function percentDone(milestone: { openItems: number; closedItems: number }): number {
56 const total = milestone.openItems + milestone.closedItems;
57 return total === 0 ? 0 : Math.floor((milestone.closedItems * 100) / total);
58}
59
60const MONTHS = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
61
62/** `2026-10-14` as "October 14, 2026". */
63export function dayInWords(day: string): string {
64 const [year, month, date] = day.split("-").map(Number);
65 if (!year || !month || !date) return day;
66 return `${MONTHS[month - 1]} ${date}, ${year}`;
67}
68
69/**
70 * When a milestone is due, said against today: "Due by October 14, 2026",
71 * "Past due by 3 days", or for a closed one simply when it was due.
72 */
73export function dueInWords(milestone: { dueOn: string | null; state: string }, today: Date): string | null {
74 if (!milestone.dueOn) return null;
75 const due = Date.parse(`${milestone.dueOn}T23:59:59Z`);
76 const late = Math.floor((today.getTime() - due) / 86_400_000);
77 if (milestone.state === "open" && late >= 0) {
78 const days = late + 1;
79 return `Past due by ${days} ${days === 1 ? "day" : "days"}`;
80 }
81 return `Due by ${dayInWords(milestone.dueOn)}`;
82}
83
84/** Whether an open milestone's day has passed. */
85export function isOverdue(milestone: { dueOn: string | null; state: string }, today: Date): boolean {
86 return milestone.state === "open" && milestone.dueOn != null && Date.parse(`${milestone.dueOn}T23:59:59Z`) < today.getTime();
87}
88
89/**
90 * The filters an issue or pull request list reads from its address:
91 * `?label=bug&milestone=3`, and `label:bug milestone:3 is:closed` written
92 * in `q`, which win over the plain parameters.
93 */
94export function listFilters(query: URLSearchParams): { label: string; milestone: number | null; state: "open" | "closed"; base: string } {
95 let label = query.get("label") ?? "";
96 let milestone = Number(query.get("milestone")) || null;
97 let state: "open" | "closed" = query.get("state") === "closed" ? "closed" : "open";
98 let base = query.get("base") ?? "";
99 for (const [, key, quoted, plain] of (query.get("q") ?? "").matchAll(/(\w+):(?:"([^"]*)"|(\S+))/g)) {
100 const value = quoted ?? plain ?? "";
101 if (key === "label") label = value;
102 else if (key === "milestone") milestone = Number(value) || null;
103 else if (key === "is" && (value === "open" || value === "closed")) state = value;
104 else if (key === "base") base = value;
105 }
106 return { label: label ? tidyLabelName(label) : "", milestone, state, base };
107}
108
109/** A list's address with one filter changed, keeping the others. */
110export function withFilter(path: string, current: URLSearchParams, key: string, value: string | null): string {
111 const next = new URLSearchParams(current);
112 next.delete("q");
113 if (value == null || value === "") next.delete(key);
114 else next.set(key, value);
115 const text = next.toString();
116 return text ? `${path}?${text}` : path;
117}