Blame · Line-by-line history
response-time.ts
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| 9090df3 | 1 | /** |
| 2 | * Block J25 — Time-to-first-response metric. | |
| 3 | * | |
| 4 | * For each issue, the response time is the elapsed time between the | |
| 5 | * issue's creation and the first comment made by someone OTHER than the | |
| 6 | * issue author. "Responses" from the issue author themselves don't | |
| 7 | * count — that rule matches GitHub's own "time to first response" | |
| 8 | * metric used in Insights and Community Health. | |
| 9 | * | |
| 10 | * This module is pure. Inputs are plain shapes; outputs are plain | |
| 11 | * summaries. The route feeds in Drizzle rows + does the display. | |
| 12 | */ | |
| 13 | ||
| 14 | export interface ResponseIssueInput { | |
| 15 | id: string; | |
| 16 | createdAt: Date | string; | |
| 17 | authorId: string; | |
| 18 | state: "open" | "closed" | string; | |
| 19 | /** Only comments with `authorId !== issue.authorId` count. */ | |
| 20 | comments: Array<{ authorId: string; createdAt: Date | string }>; | |
| 21 | } | |
| 22 | ||
| 23 | export interface IssueResponseStat { | |
| 24 | id: string; | |
| 25 | state: string; | |
| 26 | createdAt: number; // epoch ms | |
| 27 | responseMs: number | null; // null when no non-author response yet | |
| 28 | } | |
| 29 | ||
| 30 | export interface ResponseTimeSummary { | |
| 31 | total: number; | |
| 32 | responded: number; | |
| 33 | /** Open issues with zero non-author comments. */ | |
| 34 | unresponded: number; | |
| 35 | medianMs: number | null; | |
| 36 | meanMs: number | null; | |
| 37 | p90Ms: number | null; | |
| 38 | fastestMs: number | null; | |
| 39 | slowestMs: number | null; | |
| 40 | } | |
| 41 | ||
| 42 | export interface ResponseTimeBuckets { | |
| 43 | /** ≤ 1 hour. */ | |
| 44 | within1h: number; | |
| 45 | /** > 1h and ≤ 24h. */ | |
| 46 | within1d: number; | |
| 47 | /** > 24h and ≤ 7d. */ | |
| 48 | within1w: number; | |
| 49 | /** > 7d. */ | |
| 50 | over1w: number; | |
| 51 | } | |
| 52 | ||
| 53 | export interface ResponseReport { | |
| 54 | /** Window in whole days, 0 means "all time". */ | |
| 55 | windowDays: number; | |
| 56 | now: number; | |
| 57 | perIssue: IssueResponseStat[]; | |
| 58 | summary: ResponseTimeSummary; | |
| 59 | buckets: ResponseTimeBuckets; | |
| 60 | /** Open issues with no non-author response yet, oldest first. */ | |
| 61 | unrepliedIssueIds: string[]; | |
| 62 | } | |
| 63 | ||
| 64 | const HOUR = 60 * 60 * 1000; | |
| 65 | const DAY = 24 * HOUR; | |
| 66 | const WEEK = 7 * DAY; | |
| 67 | ||
| 68 | export const DEFAULT_WINDOW_DAYS = 30; | |
| 69 | export const VALID_WINDOWS = [0, 7, 30, 90, 365] as const; | |
| 70 | export type ResponseWindow = (typeof VALID_WINDOWS)[number]; | |
| 71 | ||
| 72 | export function parseWindow(raw: unknown): ResponseWindow { | |
| 73 | if (typeof raw === "string" && raw.trim()) { | |
| 74 | const n = parseInt(raw, 10); | |
| 75 | if (VALID_WINDOWS.includes(n as ResponseWindow)) { | |
| 76 | return n as ResponseWindow; | |
| 77 | } | |
| 78 | } | |
| 79 | return DEFAULT_WINDOW_DAYS; | |
| 80 | } | |
| 81 | ||
| 82 | function toMs(v: Date | string): number { | |
| 83 | if (v instanceof Date) return v.getTime(); | |
| 84 | const t = Date.parse(v); | |
| 85 | return Number.isFinite(t) ? t : NaN; | |
| 86 | } | |
| 87 | ||
| 88 | /** | |
| 89 | * Returns elapsed ms from `issueCreatedAt` to the earliest comment NOT | |
| 90 | * authored by the issue author, or `null` when no such comment exists | |
| 91 | * or the inputs are unparseable. Negative differences (comment dated | |
| 92 | * before issue) are clamped to 0. | |
| 93 | */ | |
| 94 | export function computeTimeToFirstResponse(input: { | |
| 95 | issueCreatedAt: Date | string; | |
| 96 | issueAuthorId: string; | |
| 97 | comments: Array<{ authorId: string; createdAt: Date | string }>; | |
| 98 | }): number | null { | |
| 99 | const base = toMs(input.issueCreatedAt); | |
| 100 | if (!Number.isFinite(base)) return null; | |
| 101 | ||
| 102 | let earliest: number | null = null; | |
| 103 | for (const c of input.comments) { | |
| 104 | if (c.authorId === input.issueAuthorId) continue; | |
| 105 | const t = toMs(c.createdAt); | |
| 106 | if (!Number.isFinite(t)) continue; | |
| 107 | if (earliest === null || t < earliest) earliest = t; | |
| 108 | } | |
| 109 | if (earliest === null) return null; | |
| 110 | return Math.max(0, earliest - base); | |
| 111 | } | |
| 112 | ||
| 113 | /** | |
| 114 | * Reduce a list of issues to per-issue stats. Filters by window (only | |
| 115 | * issues created within the last `windowDays` days) when `windowDays > 0`. | |
| 116 | */ | |
| 117 | export function computeIssueStats( | |
| 118 | issues: ResponseIssueInput[], | |
| 119 | windowDays: number, | |
| 120 | now: Date | number = Date.now() | |
| 121 | ): IssueResponseStat[] { | |
| 122 | const nowMs = typeof now === "number" ? now : now.getTime(); | |
| 123 | const cutoff = windowDays > 0 ? nowMs - windowDays * DAY : -Infinity; | |
| 124 | ||
| 125 | const out: IssueResponseStat[] = []; | |
| 126 | for (const i of issues) { | |
| 127 | const created = toMs(i.createdAt); | |
| 128 | if (!Number.isFinite(created)) continue; | |
| 129 | if (created < cutoff) continue; | |
| 130 | ||
| 131 | const responseMs = computeTimeToFirstResponse({ | |
| 132 | issueCreatedAt: i.createdAt, | |
| 133 | issueAuthorId: i.authorId, | |
| 134 | comments: i.comments, | |
| 135 | }); | |
| 136 | out.push({ | |
| 137 | id: i.id, | |
| 138 | state: i.state, | |
| 139 | createdAt: created, | |
| 140 | responseMs, | |
| 141 | }); | |
| 142 | } | |
| 143 | return out; | |
| 144 | } | |
| 145 | ||
| 146 | function percentile(sorted: number[], p: number): number | null { | |
| 147 | if (sorted.length === 0) return null; | |
| 148 | if (sorted.length === 1) return sorted[0]; | |
| 149 | // Linear interpolation (inclusive method). | |
| 150 | const rank = (p / 100) * (sorted.length - 1); | |
| 151 | const lo = Math.floor(rank); | |
| 152 | const hi = Math.ceil(rank); | |
| 153 | if (lo === hi) return sorted[lo]; | |
| 154 | const frac = rank - lo; | |
| 155 | return Math.round(sorted[lo] + (sorted[hi] - sorted[lo]) * frac); | |
| 156 | } | |
| 157 | ||
| 158 | export function summariseResponseTimes( | |
| 159 | stats: IssueResponseStat[] | |
| 160 | ): ResponseTimeSummary { | |
| 161 | const responded = stats | |
| 162 | .filter((s) => s.responseMs !== null) | |
| 163 | .map((s) => s.responseMs as number); | |
| 164 | const unresponded = stats.filter( | |
| 165 | (s) => s.responseMs === null && s.state === "open" | |
| 166 | ).length; | |
| 167 | responded.sort((a, b) => a - b); | |
| 168 | const total = stats.length; | |
| 169 | if (responded.length === 0) { | |
| 170 | return { | |
| 171 | total, | |
| 172 | responded: 0, | |
| 173 | unresponded, | |
| 174 | medianMs: null, | |
| 175 | meanMs: null, | |
| 176 | p90Ms: null, | |
| 177 | fastestMs: null, | |
| 178 | slowestMs: null, | |
| 179 | }; | |
| 180 | } | |
| 181 | const sum = responded.reduce((a, b) => a + b, 0); | |
| 182 | return { | |
| 183 | total, | |
| 184 | responded: responded.length, | |
| 185 | unresponded, | |
| 186 | medianMs: percentile(responded, 50), | |
| 187 | meanMs: Math.round(sum / responded.length), | |
| 188 | p90Ms: percentile(responded, 90), | |
| 189 | fastestMs: responded[0], | |
| 190 | slowestMs: responded[responded.length - 1], | |
| 191 | }; | |
| 192 | } | |
| 193 | ||
| 194 | export function bucketResponseTimes( | |
| 195 | stats: IssueResponseStat[] | |
| 196 | ): ResponseTimeBuckets { | |
| 197 | const buckets: ResponseTimeBuckets = { | |
| 198 | within1h: 0, | |
| 199 | within1d: 0, | |
| 200 | within1w: 0, | |
| 201 | over1w: 0, | |
| 202 | }; | |
| 203 | for (const s of stats) { | |
| 204 | if (s.responseMs === null) continue; | |
| 205 | if (s.responseMs <= HOUR) buckets.within1h++; | |
| 206 | else if (s.responseMs <= DAY) buckets.within1d++; | |
| 207 | else if (s.responseMs <= WEEK) buckets.within1w++; | |
| 208 | else buckets.over1w++; | |
| 209 | } | |
| 210 | return buckets; | |
| 211 | } | |
| 212 | ||
| 213 | /** | |
| 214 | * Format a ms duration as a compact human string: "3h", "1d 4h", | |
| 215 | * "12m", "45s", "—" for null. Non-negative; rounds to the nearest | |
| 216 | * sensible unit. | |
| 217 | */ | |
| 218 | export function formatDuration(ms: number | null): string { | |
| 219 | if (ms === null) return "\u2014"; | |
| 220 | if (ms < 0) return "0s"; | |
| 221 | if (ms < 1000) return `${ms}ms`; | |
| 222 | if (ms < 60 * 1000) return `${Math.round(ms / 1000)}s`; | |
| 223 | if (ms < HOUR) return `${Math.round(ms / (60 * 1000))}m`; | |
| 224 | if (ms < DAY) { | |
| 225 | const h = Math.floor(ms / HOUR); | |
| 226 | const m = Math.round((ms - h * HOUR) / (60 * 1000)); | |
| 227 | return m > 0 ? `${h}h ${m}m` : `${h}h`; | |
| 228 | } | |
| 229 | const d = Math.floor(ms / DAY); | |
| 230 | const h = Math.round((ms - d * DAY) / HOUR); | |
| 231 | return h > 0 ? `${d}d ${h}h` : `${d}d`; | |
| 232 | } | |
| 233 | ||
| 234 | export function buildResponseReport(opts: { | |
| 235 | issues: ResponseIssueInput[]; | |
| 236 | windowDays: number; | |
| 237 | now?: Date | number; | |
| 238 | }): ResponseReport { | |
| 239 | const nowMs = | |
| 240 | opts.now === undefined | |
| 241 | ? Date.now() | |
| 242 | : typeof opts.now === "number" | |
| 243 | ? opts.now | |
| 244 | : opts.now.getTime(); | |
| 245 | const perIssue = computeIssueStats(opts.issues, opts.windowDays, nowMs); | |
| 246 | const summary = summariseResponseTimes(perIssue); | |
| 247 | const buckets = bucketResponseTimes(perIssue); | |
| 248 | const unrepliedIssueIds = perIssue | |
| 249 | .filter((s) => s.responseMs === null && s.state === "open") | |
| 250 | .sort((a, b) => a.createdAt - b.createdAt) | |
| 251 | .map((s) => s.id); | |
| 252 | return { | |
| 253 | windowDays: opts.windowDays, | |
| 254 | now: nowMs, | |
| 255 | perIssue, | |
| 256 | summary, | |
| 257 | buckets, | |
| 258 | unrepliedIssueIds, | |
| 259 | }; | |
| 260 | } | |
| 261 | ||
| 262 | export const __internal = { | |
| 263 | HOUR, | |
| 264 | DAY, | |
| 265 | WEEK, | |
| 266 | DEFAULT_WINDOW_DAYS, | |
| 267 | VALID_WINDOWS, | |
| 268 | parseWindow, | |
| 269 | computeTimeToFirstResponse, | |
| 270 | computeIssueStats, | |
| 271 | summariseResponseTimes, | |
| 272 | bucketResponseTimes, | |
| 273 | buildResponseReport, | |
| 274 | formatDuration, | |
| 275 | percentile, | |
| 276 | }; |