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.tsx

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.tsxBlame313 lines · 1 contributor
9090df3Claude1/**
2 * Block J25 — Time-to-first-response insights page.
3 *
4 * GET /:owner/:repo/insights/response-time[?window=7|30|90|365|0]
5 *
6 * softAuth, read-only. Fetches issues for the repo + all their comments
7 * in two queries, runs the pure `buildResponseReport`, renders a
8 * KPI grid (p50/mean/p90/fastest/slowest), four latency buckets, and
9 * the oldest still-unreplied open issues.
10 */
11
12import { Hono } from "hono";
13import { eq, and, inArray } from "drizzle-orm";
14import { db } from "../db";
15import { repositories, users, issues, issueComments } from "../db/schema";
16import { Layout } from "../views/layout";
17import { RepoHeader } from "../views/components";
18import { softAuth } from "../middleware/auth";
19import type { AuthEnv } from "../middleware/auth";
20import {
21 buildResponseReport,
22 formatDuration,
23 parseWindow,
24 VALID_WINDOWS,
25 type ResponseIssueInput,
26} from "../lib/response-time";
27
28const responseTimeRoutes = new Hono<AuthEnv>();
29
30responseTimeRoutes.use("*", softAuth);
31
32async function resolveRepo(ownerName: string, repoName: string) {
33 try {
34 const [owner] = await db
35 .select()
36 .from(users)
37 .where(eq(users.username, ownerName))
38 .limit(1);
39 if (!owner) return null;
40 const [repo] = await db
41 .select()
42 .from(repositories)
43 .where(
44 and(
45 eq(repositories.ownerId, owner.id),
46 eq(repositories.name, repoName)
47 )
48 )
49 .limit(1);
50 if (!repo) return null;
51 return { owner, repo };
52 } catch {
53 return null;
54 }
55}
56
57responseTimeRoutes.get(
58 "/:owner/:repo/insights/response-time",
59 async (c) => {
60 const { owner: ownerName, repo: repoName } = c.req.param();
61 const user = c.get("user");
62 const windowDays = parseWindow(c.req.query("window"));
63
64 const resolved = await resolveRepo(ownerName, repoName);
65 if (!resolved) {
66 return c.html(
67 <Layout title="Not Found" user={user}>
68 <div class="empty-state">
69 <h2>Repository not found</h2>
70 </div>
71 </Layout>,
72 404
73 );
74 }
75
76 // Private-repo visibility: only the owner can see the metric.
77 if (resolved.repo.isPrivate && (!user || user.id !== resolved.owner.id)) {
78 return c.html(
79 <Layout title="Not Found" user={user}>
80 <div class="empty-state">
81 <h2>Repository not found</h2>
82 </div>
83 </Layout>,
84 404
85 );
86 }
87
88 let issueRows: {
89 id: string;
90 number: number;
91 title: string;
92 state: string;
93 authorId: string;
94 createdAt: Date;
95 }[] = [];
96 let commentRows: {
97 issueId: string;
98 authorId: string;
99 createdAt: Date;
100 }[] = [];
101 try {
102 issueRows = await db
103 .select({
104 id: issues.id,
105 number: issues.number,
106 title: issues.title,
107 state: issues.state,
108 authorId: issues.authorId,
109 createdAt: issues.createdAt,
110 })
111 .from(issues)
112 .where(eq(issues.repositoryId, resolved.repo.id))
113 .limit(2000);
114
115 if (issueRows.length > 0) {
116 commentRows = await db
117 .select({
118 issueId: issueComments.issueId,
119 authorId: issueComments.authorId,
120 createdAt: issueComments.createdAt,
121 })
122 .from(issueComments)
123 .where(
124 inArray(
125 issueComments.issueId,
126 issueRows.map((i) => i.id)
127 )
128 );
129 }
130 } catch {
131 // Empty arrays → empty report. Page still renders.
132 }
133
134 const commentsByIssue = new Map<
135 string,
136 { authorId: string; createdAt: Date }[]
137 >();
138 for (const c0 of commentRows) {
139 const arr = commentsByIssue.get(c0.issueId) ?? [];
140 arr.push({ authorId: c0.authorId, createdAt: c0.createdAt });
141 commentsByIssue.set(c0.issueId, arr);
142 }
143
144 const inputs: ResponseIssueInput[] = issueRows.map((i) => ({
145 id: i.id,
146 state: i.state,
147 authorId: i.authorId,
148 createdAt: i.createdAt,
149 comments: commentsByIssue.get(i.id) ?? [],
150 }));
151 const report = buildResponseReport({ issues: inputs, windowDays });
152
153 // Look up titles for unreplied issues so we can render a link.
154 const unrepliedMeta = report.unrepliedIssueIds
155 .map((id) => {
156 const i = issueRows.find((r) => r.id === id);
157 if (!i) return null;
158 return {
159 id,
160 number: i.number,
161 title: i.title,
162 createdAt: i.createdAt,
163 };
164 })
165 .filter((x): x is NonNullable<typeof x> => x !== null)
166 .slice(0, 25);
167
168 const windowLabel =
169 windowDays === 0 ? "All time" : `Last ${windowDays} days`;
170
171 const kpi = (label: string, value: string) => (
172 <div
173 style="border: 1px solid var(--border); border-radius: var(--radius); padding: 14px; background: var(--bg-secondary)"
174 >
175 <div style="font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-muted); margin-bottom: 6px">
176 {label}
177 </div>
178 <div style="font-size: 20px; font-weight: 600; font-family: var(--font-mono)">
179 {value}
180 </div>
181 </div>
182 );
183
184 return c.html(
185 <Layout
186 title={`Response time — ${ownerName}/${repoName}`}
187 user={user}
188 >
189 <RepoHeader owner={ownerName} repo={repoName} />
190 <div style="max-width: 920px">
191 <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px">
192 <h2 style="margin: 0">Time to first response</h2>
193 <form
194 method="GET"
195 action={`/${ownerName}/${repoName}/insights/response-time`}
196 style="display: flex; gap: 6px; align-items: center"
197 >
198 <label
199 for="window"
200 style="font-size: 12px; color: var(--text-muted)"
201 >
202 Window:
203 </label>
204 <select
205 id="window"
206 name="window"
207 onchange="this.form.submit()"
208 style="padding: 4px 8px; font-size: 12px"
209 >
210 {VALID_WINDOWS.map((w) => (
211 <option value={String(w)} selected={w === windowDays}>
212 {w === 0 ? "All time" : `Last ${w} days`}
213 </option>
214 ))}
215 </select>
216 </form>
217 </div>
218 <p style="color: var(--text-muted); font-size: 13px; margin-bottom: 20px">
219 <strong>{windowLabel}</strong>. Response time = time from issue
220 creation to the first comment by someone other than the author.
221 Comments authored by the issue author themselves don't count.
222 </p>
223
224 <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px; margin-bottom: 20px">
225 {kpi("Total issues", String(report.summary.total))}
226 {kpi("Responded", String(report.summary.responded))}
227 {kpi(
228 "Unreplied (open)",
229 String(report.summary.unresponded)
230 )}
231 {kpi("Median (p50)", formatDuration(report.summary.medianMs))}
232 {kpi("Mean", formatDuration(report.summary.meanMs))}
233 {kpi("p90", formatDuration(report.summary.p90Ms))}
234 {kpi("Fastest", formatDuration(report.summary.fastestMs))}
235 {kpi("Slowest", formatDuration(report.summary.slowestMs))}
236 </div>
237
238 <h3 style="margin-bottom: 10px">Distribution</h3>
239 <div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-bottom: 24px">
240 {[
241 ["≤ 1 hour", report.buckets.within1h],
242 ["1h – 1 day", report.buckets.within1d],
243 ["1d – 1 week", report.buckets.within1w],
244 ["> 1 week", report.buckets.over1w],
245 ].map(([label, count]) => (
246 <div
247 style="border: 1px solid var(--border); border-radius: var(--radius); padding: 12px; text-align: center"
248 >
249 <div style="font-size: 11px; color: var(--text-muted); margin-bottom: 4px">
250 {label}
251 </div>
252 <div style="font-size: 18px; font-weight: 600">{count}</div>
253 </div>
254 ))}
255 </div>
256
257 <h3 style="margin-bottom: 10px">
258 Oldest unreplied open issues ({unrepliedMeta.length}
259 {report.unrepliedIssueIds.length > unrepliedMeta.length
260 ? ` of ${report.unrepliedIssueIds.length}`
261 : ""}
262 )
263 </h3>
264 {unrepliedMeta.length === 0 ? (
265 <div class="empty-state">
266 <p>Nothing is waiting for a response. Nice.</p>
267 </div>
268 ) : (
269 <table style="width: 100%; border-collapse: collapse">
270 <thead>
271 <tr>
272 <th style="text-align: left; padding: 8px; border-bottom: 1px solid var(--border); font-size: 12px; color: var(--text-muted)">
273 Issue
274 </th>
275 <th style="text-align: right; padding: 8px; border-bottom: 1px solid var(--border); font-size: 12px; color: var(--text-muted); width: 140px">
276 Waiting
277 </th>
278 </tr>
279 </thead>
280 <tbody>
281 {unrepliedMeta.map((i) => {
282 const waitingMs = Math.max(
283 0,
284 report.now - new Date(i.createdAt).getTime()
285 );
286 return (
287 <tr>
288 <td style="padding: 8px; border-bottom: 1px solid var(--border)">
289 <a
290 href={`/${ownerName}/${repoName}/issues/${i.number}`}
291 >
292 <span style="color: var(--text-muted)">
293 #{i.number}
294 </span>{" "}
295 {i.title}
296 </a>
297 </td>
298 <td style="padding: 8px; border-bottom: 1px solid var(--border); text-align: right; font-family: var(--font-mono); font-size: 12px">
299 {formatDuration(waitingMs)}
300 </td>
301 </tr>
302 );
303 })}
304 </tbody>
305 </table>
306 )}
307 </div>
308 </Layout>
309 );
310 }
311);
312
313export default responseTimeRoutes;