| 1 | /** |
| 2 | * The Incidents pages' wording and arithmetic: tabs and filters read from |
| 3 | * the address, chips and their tones, durations, and reading the forms. |
| 4 | * No Workers or React imports, so it is tested under Node. The status |
| 5 | * worker checks everything again. |
| 6 | */ |
| 7 | import { |
| 8 | type AdminIncident, |
| 9 | type AdminMaintenance, |
| 10 | COMPONENT_IMPACTS, |
| 11 | type ComponentImpact, |
| 12 | INCIDENT_SEVERITIES, |
| 13 | INCIDENT_STATUSES, |
| 14 | type ImpactInput, |
| 15 | type IncidentSeverity, |
| 16 | type IncidentStatus, |
| 17 | type MaintenanceState, |
| 18 | } from "@g1t/contracts/status"; |
| 19 | |
| 20 | export { COMPONENT_IMPACTS, INCIDENT_SEVERITIES, INCIDENT_STATUSES }; |
| 21 | |
| 22 | export type Tone = "plain" | "lavender" | "mint" | "warn" | "danger" | "info"; |
| 23 | |
| 24 | export const SEVERITY_TONE: Record<IncidentSeverity, Tone> = { sev1: "danger", sev2: "warn", sev3: "info", sev4: "plain" }; |
| 25 | export const STATUS_TONE: Record<IncidentStatus, Tone> = { investigating: "danger", identified: "warn", monitoring: "info", resolved: "mint" }; |
| 26 | export const IMPACT_TONE: Record<ComponentImpact, Tone> = { operational: "mint", degraded: "warn", partial_outage: "warn", major_outage: "danger" }; |
| 27 | export const MAINTENANCE_TONE: Record<MaintenanceState, Tone> = { scheduled: "info", in_progress: "lavender", completed: "mint", cancelled: "plain" }; |
| 28 | export const MAINTENANCE_WORD: Record<MaintenanceState, string> = { |
| 29 | scheduled: "Scheduled", |
| 30 | in_progress: "In progress", |
| 31 | completed: "Completed", |
| 32 | cancelled: "Cancelled", |
| 33 | }; |
| 34 | |
| 35 | export function severityLabel(severity: IncidentSeverity): string { |
| 36 | return INCIDENT_SEVERITIES.find((s) => s.value === severity)?.label ?? severity.toUpperCase(); |
| 37 | } |
| 38 | |
| 39 | export function statusLabel(status: IncidentStatus): string { |
| 40 | return INCIDENT_STATUSES.find((s) => s.value === status)?.label ?? status; |
| 41 | } |
| 42 | |
| 43 | export function impactLabel(impact: ComponentImpact): string { |
| 44 | return COMPONENT_IMPACTS.find((i) => i.value === impact)?.label ?? impact; |
| 45 | } |
| 46 | |
| 47 | export function notifyByDefault(severity: IncidentSeverity): boolean { |
| 48 | return INCIDENT_SEVERITIES.find((s) => s.value === severity)?.notify ?? false; |
| 49 | } |
| 50 | |
| 51 | /** A notify choice: `yes`, `no`, or `auto` (the severity's default). */ |
| 52 | export function wantsNotify(choice: string, severity: IncidentSeverity): boolean { |
| 53 | return choice === "yes" || choice === "on" || (choice === "auto" && notifyByDefault(severity)); |
| 54 | } |
| 55 | |
| 56 | /** The status a next update most likely moves to. */ |
| 57 | export function nextStatus(status: IncidentStatus): IncidentStatus { |
| 58 | return status === "investigating" ? "identified" : status === "identified" ? "monitoring" : "resolved"; |
| 59 | } |
| 60 | |
| 61 | // --- Tabs and filters ------------------------------------------------------------------ |
| 62 | |
| 63 | export const TABS = [ |
| 64 | { value: "open", label: "Open" }, |
| 65 | { value: "maintenance", label: "Scheduled maintenance" }, |
| 66 | { value: "resolved", label: "Resolved" }, |
| 67 | { value: "drafts", label: "Drafts" }, |
| 68 | ] as const; |
| 69 | export type Tab = (typeof TABS)[number]["value"]; |
| 70 | |
| 71 | export type Filters = { tab: Tab; severity: IncidentSeverity | null; component: string | null; q: string }; |
| 72 | |
| 73 | export function parseFilters(params: URLSearchParams): Filters { |
| 74 | const tab = TABS.find((t) => t.value === params.get("tab"))?.value ?? "open"; |
| 75 | const severity = INCIDENT_SEVERITIES.find((s) => s.value === params.get("severity"))?.value ?? null; |
| 76 | const component = /^[a-z][a-z0-9-]{0,40}$/.test(params.get("component") ?? "") ? params.get("component") : null; |
| 77 | const q = (params.get("q") ?? "").trim().slice(0, 100); |
| 78 | return { tab, severity, component, q }; |
| 79 | } |
| 80 | |
| 81 | export function incidentsHref(filters: Partial<Filters>): string { |
| 82 | const params = new URLSearchParams(); |
| 83 | if (filters.tab && filters.tab !== "open") params.set("tab", filters.tab); |
| 84 | if (filters.severity) params.set("severity", filters.severity); |
| 85 | if (filters.component) params.set("component", filters.component); |
| 86 | if (filters.q) params.set("q", filters.q); |
| 87 | const query = params.toString(); |
| 88 | return query ? `/incidents?${query}` : "/incidents"; |
| 89 | } |
| 90 | |
| 91 | /** Which tab an incident is under. Maintenance has its own. */ |
| 92 | export function tabOf(incident: Pick<AdminIncident, "visibility" | "resolved_at">): Exclude<Tab, "maintenance"> { |
| 93 | if (incident.visibility === "draft") return "drafts"; |
| 94 | if (incident.visibility === "dismissed" || incident.resolved_at) return "resolved"; |
| 95 | return "open"; |
| 96 | } |
| 97 | |
| 98 | export function filterIncidents<T extends Pick<AdminIncident, "visibility" | "resolved_at" | "severity" | "components" | "title">>( |
| 99 | list: T[], |
| 100 | filters: Filters, |
| 101 | ): T[] { |
| 102 | const q = filters.q.toLowerCase(); |
| 103 | return list.filter( |
| 104 | (i) => |
| 105 | tabOf(i) === filters.tab && |
| 106 | (!filters.severity || i.severity === filters.severity) && |
| 107 | (!filters.component || i.components.some((c) => c.key === filters.component && c.impact !== "operational")) && |
| 108 | (!q || i.title.toLowerCase().includes(q)), |
| 109 | ); |
| 110 | } |
| 111 | |
| 112 | /** Maintenance under the tab: upcoming and under way first, soonest first; then finished, newest first. */ |
| 113 | export function maintenanceList(list: AdminMaintenance[], component: string | null): AdminMaintenance[] { |
| 114 | const live = (m: AdminMaintenance) => m.state === "scheduled" || m.state === "in_progress"; |
| 115 | return list |
| 116 | .filter((m) => !component || m.components.includes(component)) |
| 117 | .sort((a, b) => (live(a) !== live(b) ? (live(a) ? -1 : 1) : live(a) ? a.starts_at.localeCompare(b.starts_at) : b.starts_at.localeCompare(a.starts_at))); |
| 118 | } |
| 119 | |
| 120 | export function tabCounts(incidents: Pick<AdminIncident, "visibility" | "resolved_at">[], maintenance: Pick<AdminMaintenance, "state">[]): Record<Tab, number> { |
| 121 | const counts: Record<Tab, number> = { open: 0, maintenance: 0, resolved: 0, drafts: 0 }; |
| 122 | for (const i of incidents) counts[tabOf(i)] += 1; |
| 123 | counts.maintenance = maintenance.filter((m) => m.state === "scheduled" || m.state === "in_progress").length; |
| 124 | return counts; |
| 125 | } |
| 126 | |
| 127 | // --- Time ---------------------------------------------------------------------------- |
| 128 | |
| 129 | /** "45s", "12m", "2h 05m", "3d 4h"; a dash for none. */ |
| 130 | export function duration(seconds: number | null | undefined): string { |
| 131 | if (seconds == null || Number.isNaN(seconds)) return "—"; |
| 132 | const s = Math.max(0, Math.round(seconds)); |
| 133 | if (s < 60) return `${s}s`; |
| 134 | const minutes = Math.floor(s / 60); |
| 135 | if (minutes < 60) return `${minutes}m`; |
| 136 | const hours = Math.floor(minutes / 60); |
| 137 | if (hours < 48) return `${hours}h ${String(minutes % 60).padStart(2, "0")}m`; |
| 138 | return `${Math.floor(hours / 24)}d ${hours % 24}h`; |
| 139 | } |
| 140 | |
| 141 | export function secondsBetween(from: string, to: Date | string): number { |
| 142 | return (new Date(to).getTime() - Date.parse(from)) / 1000; |
| 143 | } |
| 144 | |
| 145 | /** The median of the known values; null with none. */ |
| 146 | export function median(values: (number | null)[]): number | null { |
| 147 | const known = values.filter((v): v is number => v != null).sort((a, b) => a - b); |
| 148 | if (!known.length) return null; |
| 149 | const mid = Math.floor(known.length / 2); |
| 150 | return known.length % 2 ? known[mid]! : (known[mid - 1]! + known[mid]!) / 2; |
| 151 | } |
| 152 | |
| 153 | /** A `datetime-local` value, read as UTC: sudo ships no script to know the browser's zone. */ |
| 154 | export function utc(raw: string): string | null { |
| 155 | const value = raw.trim(); |
| 156 | if (!value) return null; |
| 157 | const iso = /Z$|[+-]\d\d:\d\d$/.test(value) ? value : `${value.length === 16 ? `${value}:00` : value}Z`; |
| 158 | return Number.isNaN(Date.parse(iso)) ? null : new Date(iso).toISOString(); |
| 159 | } |
| 160 | |
| 161 | /** A time as a `datetime-local` value in UTC: `2026-10-05T14:00`. */ |
| 162 | export function localValue(at: Date | string): string { |
| 163 | return new Date(at).toISOString().slice(0, 16); |
| 164 | } |
| 165 | |
| 166 | // --- Forms ------------------------------------------------------------------------------ |
| 167 | |
| 168 | /** Each part's impact from `impact.<key>` selects; an empty choice leaves the part as it is. */ |
| 169 | export function readImpacts(form: FormData, keys: string[]): ImpactInput[] { |
| 170 | const out: ImpactInput[] = []; |
| 171 | for (const key of keys) { |
| 172 | const value = form.get(`impact.${key}`); |
| 173 | const impact = COMPONENT_IMPACTS.find((i) => i.value === value)?.value; |
| 174 | if (impact) out.push({ key, impact }); |
| 175 | } |
| 176 | return out; |
| 177 | } |
| 178 | |
| 179 | /** Staff addresses for the roles' suggestions: exact addresses in STAFF_EMAILS, and any seen. */ |
| 180 | export function staffSuggestions(staffEmails: string, seen: (string | null | undefined)[]): string[] { |
| 181 | const listed = staffEmails |
| 182 | .split(",") |
| 183 | .map((e) => e.trim().toLowerCase()) |
| 184 | .filter((e) => /^[^@\s]+@[^@\s]+$/.test(e)); |
| 185 | return [...new Set([...listed, ...seen.filter((e): e is string => !!e && e.includes("@")).map((e) => e.toLowerCase())])].sort(); |
| 186 | } |
| 187 | |
| 188 | /** The chip an incident's phase shows: draft, dismissed, postmortem published, or its status. */ |
| 189 | export function phase(i: Pick<AdminIncident, "visibility" | "status" | "postmortem_published_at">): { label: string; tone: Tone } { |
| 190 | if (i.visibility === "draft") return { label: "Draft", tone: "lavender" }; |
| 191 | if (i.visibility === "dismissed") return { label: "Dismissed", tone: "plain" }; |
| 192 | if (i.postmortem_published_at) return { label: "Postmortem published", tone: "mint" }; |
| 193 | return { label: statusLabel(i.status), tone: STATUS_TONE[i.status] }; |
| 194 | } |