Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
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.

response-time.tsBlame276 lines · 1 contributor
9090df3Claude1/**
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
14export 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
23export 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
30export 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
42export 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
53export 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
64const HOUR = 60 * 60 * 1000;
65const DAY = 24 * HOUR;
66const WEEK = 7 * DAY;
67
68export const DEFAULT_WINDOW_DAYS = 30;
69export const VALID_WINDOWS = [0, 7, 30, 90, 365] as const;
70export type ResponseWindow = (typeof VALID_WINDOWS)[number];
71
72export 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
82function 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 */
94export 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 */
117export 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
146function 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
158export 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
194export 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 */
218export 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
234export 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
262export 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};