| 1 | // 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. */ |
| 6 | export type LabelLike = { name: string; color?: string | null }; |
| 7 | |
| 8 | /** What a list knows of a repository's labels: their colors, by name. */ |
| 9 | export type LabelColors = Record<string, string>; |
| 10 | |
| 11 | export 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. */ |
| 16 | export 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 | */ |
| 29 | export 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. */ |
| 40 | export 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. */ |
| 50 | export 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. */ |
| 55 | export 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 | |
| 60 | const MONTHS = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; |
| 61 | |
| 62 | /** `2026-10-14` as "October 14, 2026". */ |
| 63 | export 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 | */ |
| 73 | export 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. */ |
| 85 | export 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 | */ |
| 94 | export 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. */ |
| 110 | export 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 | } |