Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

debt-map.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.

debt-map.tsxBlame1241 lines · 1 contributor
bcc4020Claude1/**
2 * AI Technical Debt Map.
3 *
4 * Routes:
5 * GET /:owner/:repo/debt-map — render the interactive graph page
6 * POST /:owner/:repo/debt-map/analyze — trigger analysis (owner only), returns JSON
7 * GET /:owner/:repo/debt-map/data — return DebtReport JSON
8 *
9 * The page renders a Canvas-based force-directed graph where:
10 * - Node radius = sqrt(lines), capped 6–30 px
11 * - Node colour = debt score (green → yellow → red)
12 * - Edges = import relationships
13 * - Click a node = side panel with Claude's debt analysis
14 *
15 * Analysis is asynchronous: the POST kicks off a background Bun promise and
16 * returns {status:"running"}; the GET /data endpoint returns the cached
17 * report (or 202 if still running).
18 */
19
20import { Hono } from "hono";
21import { and, eq } from "drizzle-orm";
22import { db } from "../db";
23import { repositories, users } from "../db/schema";
24import { Layout } from "../views/layout";
25import { RepoHeader, RepoNav } from "../views/components";
26import { softAuth } from "../middleware/auth";
27import type { AuthEnv } from "../middleware/auth";
28import { requireRepoAccess } from "../middleware/repo-access";
29import { isAiAvailable } from "../lib/ai-client";
30import { analyzeRepo } from "../lib/debt-analyzer";
31import {
32 getDebtReport,
33 setDebtReport,
34 getJobStatus,
35 setJobStatus,
36} from "../lib/debt-cache";
37import { getUnreadCount } from "../lib/unread";
38
39const debtMapRoutes = new Hono<AuthEnv>();
40
41debtMapRoutes.use("/:owner/:repo/debt-map*", softAuth);
42
43// ─── CSS ──────────────────────────────────────────────────────────────────────
44
45const styles = `
46 .dm-wrap {
47 max-width: 1200px;
48 margin: 0 auto;
49 padding: var(--space-5) var(--space-4);
50 }
51
52 /* ── Hero ───────────────────────────────────────────────────────────────── */
53 .dm-hero {
54 position: relative;
55 margin-bottom: var(--space-5);
56 padding: var(--space-5) var(--space-6);
57 background: var(--bg-elevated);
58 border: 1px solid var(--border);
59 border-radius: 16px;
60 overflow: hidden;
61 }
62 .dm-hero::before {
63 content: '';
64 position: absolute;
65 top: 0; left: 0; right: 0;
66 height: 2px;
67 background: linear-gradient(90deg, transparent 0%, #f87171 30%, #fbbf24 60%, #34d399 100%);
68 opacity: 0.8;
69 pointer-events: none;
70 }
71 .dm-hero-orb {
72 position: absolute;
73 inset: -30% -10% auto auto;
74 width: 440px; height: 440px;
75 background: radial-gradient(circle, rgba(248,113,113,0.15), rgba(251,191,36,0.08) 45%, transparent 70%);
76 filter: blur(80px);
77 opacity: 0.7;
78 pointer-events: none;
79 z-index: 0;
80 }
81 .dm-hero-inner { position: relative; z-index: 1; max-width: 720px; }
82
83 .dm-eyebrow {
84 display: inline-flex;
85 align-items: center;
86 gap: 8px;
87 text-transform: uppercase;
88 font-family: var(--font-mono);
89 font-size: 11px;
90 letter-spacing: 0.18em;
91 color: var(--text-muted);
92 font-weight: 600;
93 margin-bottom: 14px;
94 }
95 .dm-eyebrow-dot {
96 width: 8px; height: 8px;
97 border-radius: 9999px;
98 background: linear-gradient(135deg, #f87171, #fbbf24);
99 box-shadow: 0 0 0 3px rgba(248,113,113,0.18);
100 }
101 .dm-title {
102 font-family: var(--font-display, system-ui, sans-serif);
103 font-size: clamp(24px, 4vw, 36px);
104 font-weight: 800;
105 letter-spacing: -0.025em;
106 line-height: 1.08;
107 margin: 0 0 var(--space-2);
108 color: var(--text-strong, var(--text));
109 }
110 .dm-title-grad {
111 background: linear-gradient(135deg, #f87171 0%, #fbbf24 50%, #34d399 100%);
112 -webkit-background-clip: text;
113 background-clip: text;
114 -webkit-text-fill-color: transparent;
115 color: transparent;
116 }
117 .dm-sub {
118 font-size: 14px;
119 color: var(--text-muted);
120 margin: 0 0 var(--space-4);
121 line-height: 1.6;
122 }
123
124 /* ── Stats callout ───────────────────────────────────────────────────────── */
125 .dm-stats {
126 display: flex;
127 gap: var(--space-4);
128 flex-wrap: wrap;
129 margin-bottom: var(--space-5);
130 }
131 .dm-stat-card {
132 flex: 1;
133 min-width: 130px;
134 background: var(--bg-elevated);
135 border: 1px solid var(--border);
136 border-radius: 12px;
137 padding: var(--space-3) var(--space-4);
138 }
139 .dm-stat-label {
140 font-size: 11px;
141 text-transform: uppercase;
142 letter-spacing: 0.08em;
143 color: var(--text-muted);
144 margin-bottom: 4px;
145 }
146 .dm-stat-value {
147 font-size: 22px;
148 font-weight: 700;
149 font-variant-numeric: tabular-nums;
150 color: var(--text-strong, var(--text));
151 }
152 .dm-stat-sub {
153 font-size: 11px;
154 color: var(--text-muted);
155 margin-top: 2px;
156 }
157 .dm-debt-hours {
158 font-size: 18px;
159 font-weight: 800;
160 background: linear-gradient(135deg, #f87171, #fbbf24);
161 -webkit-background-clip: text;
162 background-clip: text;
163 -webkit-text-fill-color: transparent;
164 color: transparent;
165 }
166
167 /* ── Analyze button ──────────────────────────────────────────────────────── */
168 .dm-analyze-btn {
169 display: inline-flex;
170 align-items: center;
171 gap: 8px;
172 padding: 10px 20px;
173 background: linear-gradient(135deg, #f87171 0%, #fbbf24 100%);
174 color: #ffffff;
175 border: none;
176 border-radius: 10px;
177 font-size: 14px;
178 font-weight: 600;
179 cursor: pointer;
180 font-family: inherit;
181 box-shadow: 0 6px 16px -4px rgba(248,113,113,0.45);
182 transition: transform 120ms ease, box-shadow 120ms ease;
183 }
184 .dm-analyze-btn:hover {
185 transform: translateY(-1px);
186 box-shadow: 0 10px 22px -6px rgba(248,113,113,0.55);
187 }
188 .dm-analyze-btn:disabled {
189 opacity: 0.6;
190 cursor: not-allowed;
191 transform: none;
192 }
193
194 /* ── Canvas container ────────────────────────────────────────────────────── */
195 .dm-graph-wrap {
196 position: relative;
197 background: var(--bg-elevated);
198 border: 1px solid var(--border);
199 border-radius: 14px;
200 overflow: hidden;
201 margin-bottom: var(--space-5);
202 display: flex;
203 gap: 0;
204 }
205 #dm-canvas {
206 display: block;
207 flex: 1;
208 min-width: 0;
209 cursor: grab;
210 background: transparent;
211 }
212 #dm-canvas:active { cursor: grabbing; }
213
214 /* ── Side panel ──────────────────────────────────────────────────────────── */
215 .dm-panel {
216 width: 300px;
217 flex-shrink: 0;
218 border-left: 1px solid var(--border);
219 padding: var(--space-4);
220 display: none;
221 overflow-y: auto;
222 max-height: 500px;
223 background: var(--bg);
224 }
225 .dm-panel.is-open { display: block; }
226 .dm-panel-path {
227 font-family: var(--font-mono, monospace);
228 font-size: 11px;
229 color: var(--text-muted);
230 word-break: break-all;
231 margin-bottom: var(--space-2);
232 }
233 .dm-panel-score {
234 font-size: 32px;
235 font-weight: 800;
236 font-variant-numeric: tabular-nums;
237 margin-bottom: var(--space-1);
238 }
239 .dm-panel-score-label {
240 font-size: 11px;
241 text-transform: uppercase;
242 letter-spacing: 0.08em;
243 color: var(--text-muted);
244 margin-bottom: var(--space-3);
245 }
246 .dm-panel-section { margin-bottom: var(--space-3); }
247 .dm-panel-section h4 {
248 font-size: 11px;
249 text-transform: uppercase;
250 letter-spacing: 0.08em;
251 color: var(--text-muted);
252 margin: 0 0 8px;
253 }
254 .dm-panel-issue {
255 display: flex;
256 align-items: flex-start;
257 gap: 6px;
258 font-size: 13px;
259 color: var(--text);
260 margin-bottom: 6px;
261 line-height: 1.4;
262 }
263 .dm-panel-issue::before {
264 content: '▸';
265 color: var(--text-muted);
266 flex-shrink: 0;
267 margin-top: 1px;
268 }
269 .dm-panel-hours {
270 font-size: 22px;
271 font-weight: 700;
272 color: var(--text-strong, var(--text));
273 }
274 .dm-panel-link {
275 display: inline-flex;
276 align-items: center;
277 gap: 6px;
278 padding: 6px 14px;
279 background: var(--bg-elevated);
280 border: 1px solid var(--border);
281 border-radius: 8px;
282 font-size: 12px;
283 font-weight: 500;
284 color: var(--text);
285 text-decoration: none;
286 margin-top: var(--space-3);
287 transition: border-color 120ms ease;
288 }
289 .dm-panel-link:hover { border-color: var(--border-strong, var(--border)); color: var(--text); }
290
291 /* ── Legend ──────────────────────────────────────────────────────────────── */
292 .dm-legend {
293 display: flex;
294 gap: var(--space-3);
295 align-items: center;
296 flex-wrap: wrap;
297 margin-bottom: var(--space-3);
298 font-size: 12px;
299 color: var(--text-muted);
300 }
301 .dm-legend-dot {
302 width: 10px; height: 10px;
303 border-radius: 9999px;
304 flex-shrink: 0;
305 }
306 .dm-legend-item {
307 display: flex;
308 align-items: center;
309 gap: 6px;
310 }
311
312 /* ── Table ───────────────────────────────────────────────────────────────── */
313 .dm-table-wrap {
314 background: var(--bg-elevated);
315 border: 1px solid var(--border);
316 border-radius: 12px;
317 overflow: hidden;
318 margin-bottom: var(--space-5);
319 }
320 .dm-table-head {
321 display: flex;
322 align-items: center;
323 justify-content: space-between;
324 padding: 12px 16px;
325 border-bottom: 1px solid var(--border);
326 }
327 .dm-table-head h3 {
328 margin: 0;
329 font-size: 14px;
330 font-weight: 700;
331 color: var(--text-strong, var(--text));
332 }
333 .dm-sort-bar {
334 display: flex;
335 gap: 4px;
336 font-size: 12px;
337 }
338 .dm-sort-btn {
339 padding: 3px 10px;
340 border-radius: 6px;
341 border: 1px solid var(--border);
342 background: var(--bg);
343 color: var(--text-muted);
344 cursor: pointer;
345 font-size: 12px;
346 font-family: inherit;
347 transition: border-color 120ms, color 120ms;
348 }
349 .dm-sort-btn:hover { color: var(--text); }
350 .dm-sort-btn.active {
351 background: var(--bg-elevated);
352 border-color: var(--accent, #5865f2);
353 color: var(--accent, #5865f2);
354 }
355 table.dm-table {
356 width: 100%;
357 border-collapse: collapse;
358 font-size: 13px;
359 }
360 .dm-table th {
361 text-align: left;
362 padding: 8px 16px;
363 font-size: 11px;
364 text-transform: uppercase;
365 letter-spacing: 0.07em;
366 color: var(--text-muted);
367 border-bottom: 1px solid var(--border);
368 white-space: nowrap;
369 }
370 .dm-table td {
371 padding: 10px 16px;
372 border-bottom: 1px solid var(--border);
373 color: var(--text);
374 vertical-align: middle;
375 }
376 .dm-table tr:last-child td { border-bottom: none; }
377 .dm-table tr:hover td { background: rgba(255,255,255,0.03); }
378 .dm-path {
379 font-family: var(--font-mono, monospace);
380 font-size: 11.5px;
381 word-break: break-all;
382 }
383 .dm-score-bar-wrap {
384 display: flex;
385 align-items: center;
386 gap: 8px;
387 min-width: 120px;
388 }
389 .dm-score-bar-bg {
390 flex: 1;
391 height: 6px;
392 background: rgba(255,255,255,0.08);
393 border-radius: 9999px;
394 overflow: hidden;
395 }
396 .dm-score-bar-fill {
397 height: 100%;
398 border-radius: 9999px;
399 transition: width 400ms ease;
400 }
401 .dm-score-num {
402 font-variant-numeric: tabular-nums;
403 font-size: 12px;
404 min-width: 28px;
405 text-align: right;
406 }
407
408 /* ── Empty state ─────────────────────────────────────────────────────────── */
409 .dm-empty {
410 text-align: center;
411 padding: var(--space-6) var(--space-4);
412 border: 1px dashed var(--border);
413 border-radius: 14px;
414 color: var(--text-muted);
415 margin-bottom: var(--space-5);
416 }
417 .dm-empty-icon {
418 font-size: 40px;
419 margin-bottom: var(--space-3);
420 opacity: 0.5;
421 }
422 .dm-empty strong {
423 display: block;
424 font-size: 16px;
425 color: var(--text);
426 margin-bottom: 8px;
427 }
428 .dm-empty p {
429 font-size: 13px;
430 margin: 0 0 var(--space-4);
431 max-width: 400px;
432 margin-left: auto;
433 margin-right: auto;
434 }
435
436 /* ── No-AI gate ──────────────────────────────────────────────────────────── */
437 .dm-no-ai {
438 background: rgba(251,191,36,0.08);
439 border: 1px solid rgba(251,191,36,0.25);
440 border-radius: 10px;
441 padding: var(--space-3) var(--space-4);
442 font-size: 13px;
443 color: var(--text-muted);
444 margin-bottom: var(--space-4);
445 }
446 .dm-no-ai strong { color: #fbbf24; }
447
448 /* ── Running state ────────────────────────────────────────────────────────── */
449 .dm-running {
450 display: flex;
451 align-items: center;
452 gap: 12px;
453 padding: var(--space-3) var(--space-4);
454 background: rgba(140,109,255,0.08);
455 border: 1px solid rgba(140,109,255,0.25);
456 border-radius: 10px;
457 margin-bottom: var(--space-4);
458 font-size: 13px;
459 color: var(--text-muted);
460 }
461 .dm-spinner {
462 width: 16px; height: 16px;
463 border: 2px solid rgba(140,109,255,0.3);
464 border-top-color: #8c6dff;
465 border-radius: 9999px;
466 animation: dm-spin 0.8s linear infinite;
467 flex-shrink: 0;
468 }
469 @keyframes dm-spin { to { transform: rotate(360deg); } }
470
471 /* ── Claude attribution ───────────────────────────────────────────────────── */
472 .dm-attribution {
473 font-size: 11px;
474 color: var(--text-muted);
475 text-align: center;
476 margin-top: var(--space-4);
477 opacity: 0.7;
478 }
479`;
480
481// ─── Helpers ──────────────────────────────────────────────────────────────────
482
483function debtColor(score: number): string {
484 if (score <= 33) return "#34d399"; // green
485 if (score <= 66) return "#fbbf24"; // yellow
486 return "#f87171"; // red
487}
488
489// ─── Route: GET /:owner/:repo/debt-map ────────────────────────────────────────
490
491debtMapRoutes.get(
492 "/:owner/:repo/debt-map",
493 requireRepoAccess("read"),
494 async (c) => {
495 const { owner, repo } = c.req.param();
496 const user = c.get("user") ?? null;
497
498 // Look up repo from DB (requireRepoAccess already stashed it)
499 const repository = (
500 c.get("repository" as never) as
501 | { id: string; name: string; isPrivate: boolean; ownerId: string }
502 | undefined
503 ) ?? null;
504
505 let repoId = repository?.id ?? "";
506 if (!repoId) {
507 const ownerRow = await db
508 .select({ id: users.id })
509 .from(users)
510 .where(eq(users.username, owner))
511 .limit(1)
512 .then((r) => r[0] ?? null);
513 if (!ownerRow) return c.notFound();
514 const repoRow = await db
515 .select({ id: repositories.id, ownerId: repositories.ownerId })
516 .from(repositories)
517 .where(and(eq(repositories.ownerId, ownerRow.id), eq(repositories.name, repo)))
518 .limit(1)
519 .then((r) => r[0] ?? null);
520 if (!repoRow) return c.notFound();
521 repoId = repoRow.id;
522 }
523
524 const isOwner =
525 user !== null &&
526 repository !== null &&
527 (repository as { ownerId: string }).ownerId === user.id;
528
529 const report = getDebtReport(repoId);
530 const jobStatus = getJobStatus(repoId);
531 const unreadCount = user ? await getUnreadCount(user.id) : 0;
532 const aiAvailable = isAiAvailable();
533
534 return c.html(
535 <Layout
536 title={`Debt Map — ${owner}/${repo}`}
537 user={user}
538 notificationCount={unreadCount}
539 >
540 <style dangerouslySetInnerHTML={{ __html: styles }} />
541 <div class="dm-wrap">
542 <RepoHeader owner={owner} repo={repo} />
543 <RepoNav owner={owner} repo={repo} active="debt-map" />
544
545 {/* Hero */}
546 <div class="dm-hero">
547 <div class="dm-hero-orb" />
548 <div class="dm-hero-inner">
549 <div class="dm-eyebrow">
550 <span class="dm-eyebrow-dot" />
551 AI Technical Debt Map
552 </div>
553 <h1 class="dm-title">
554 Visualise your{" "}
555 <span class="dm-title-grad">technical debt</span>
556 </h1>
557 <p class="dm-sub">
558 An interactive graph of every source file — sized by lines,
559 coloured by Claude&rsquo;s debt score. Click a node to see
560 detailed findings and estimated cleanup hours.
561 </p>
562
563 {!aiAvailable && (
564 <div class="dm-no-ai">
565 <strong>AI features require ANTHROPIC_API_KEY.</strong>{" "}
566 Set the environment variable to enable Claude analysis. Basic
567 heuristic scores will still be computed.
568 </div>
569 )}
570
571 {isOwner && (
572 <button
573 class="dm-analyze-btn"
574 id="dm-trigger-btn"
575 data-repo-id={repoId}
576 data-owner={owner}
577 data-repo={repo}
578 >
579 {report ? "Re-analyze" : "Analyze now"}
580 </button>
581 )}
582 </div>
583 </div>
584
585 {/* Running indicator */}
586 {jobStatus?.status === "running" && !report && (
587 <div class="dm-running" id="dm-running-bar">
588 <div class="dm-spinner" />
589 <span>
590 Analysis running &mdash; this can take 20&ndash;30 seconds.
591 Page will refresh when complete.
592 </span>
593 </div>
594 )}
595
596 {/* No analysis yet */}
597 {!report && jobStatus?.status !== "running" && (
598 <div class="dm-empty">
599 <div class="dm-empty-icon">&#9638;</div>
600 <strong>No analysis yet</strong>
601 <p>
602 {isOwner
603 ? 'Click "Analyze now" to scan the codebase with Claude and generate the debt graph.'
604 : "The repository owner has not run a debt analysis yet."}
605 </p>
606 </div>
607 )}
608
609 {/* Graph + table — shown when report exists */}
610 {report && (
611 <>
612 {/* Stat cards */}
613 <div class="dm-stats">
614 <div class="dm-stat-card">
615 <div class="dm-stat-label">Files analyzed</div>
616 <div class="dm-stat-value">{report.nodes.length}</div>
617 </div>
618 <div class="dm-stat-card">
619 <div class="dm-stat-label">Total debt</div>
620 <div class="dm-stat-value dm-debt-hours">
621 ~{report.totalDebtHours}h
622 </div>
623 <div class="dm-stat-sub">estimated cleanup time</div>
624 </div>
625 <div class="dm-stat-card">
626 <div class="dm-stat-label">High-debt files</div>
627 <div class="dm-stat-value">
628 {report.nodes.filter((n) => n.debtScore >= 67).length}
629 </div>
630 <div class="dm-stat-sub">score &ge; 67</div>
631 </div>
632 <div class="dm-stat-card">
633 <div class="dm-stat-label">Last analyzed</div>
634 <div class="dm-stat-value" style="font-size:14px;">
635 {new Date(report.analyzedAt).toLocaleDateString()}
636 </div>
637 </div>
638 </div>
639
640 {/* Legend */}
641 <div class="dm-legend">
642 <span style="color:var(--text-muted);font-size:12px;">
643 Node size = lines of code &nbsp;&bull;&nbsp; Colour = debt score
644 </span>
645 <span class="dm-legend-item">
646 <span class="dm-legend-dot" style="background:#34d399;" />
647 Low (0-33)
648 </span>
649 <span class="dm-legend-item">
650 <span class="dm-legend-dot" style="background:#fbbf24;" />
651 Medium (34-66)
652 </span>
653 <span class="dm-legend-item">
654 <span class="dm-legend-dot" style="background:#f87171;" />
655 High (67-100)
656 </span>
657 </div>
658
659 {/* Canvas graph */}
660 <div class="dm-graph-wrap">
661 <canvas id="dm-canvas" width="900" height="500" />
662 <div class="dm-panel" id="dm-panel">
663 <div class="dm-panel-path" id="dm-panel-path"></div>
664 <div class="dm-panel-score" id="dm-panel-score"></div>
665 <div class="dm-panel-score-label">debt score / 100</div>
666
667 <div class="dm-panel-section">
668 <h4>Issues</h4>
669 <div id="dm-panel-issues"></div>
670 </div>
671
672 <div class="dm-panel-section">
673 <h4>Est. cleanup</h4>
674 <div class="dm-panel-hours" id="dm-panel-hours"></div>
675 </div>
676
677 <div id="dm-panel-link-wrap"></div>
678 </div>
679 </div>
680
681 {/* Table */}
682 <div class="dm-table-wrap">
683 <div class="dm-table-head">
684 <h3>All files</h3>
685 <div class="dm-sort-bar">
686 <button class="dm-sort-btn active" data-sort="debt">
687 By debt
688 </button>
689 <button class="dm-sort-btn" data-sort="lines">
690 By size
691 </button>
692 <button class="dm-sort-btn" data-sort="hours">
693 By hours
694 </button>
695 </div>
696 </div>
697 <table class="dm-table" id="dm-table">
698 <thead>
699 <tr>
700 <th>File</th>
701 <th>Lines</th>
702 <th style="min-width:160px;">Debt score</th>
703 <th>Est. hours</th>
704 <th>Issues</th>
705 </tr>
706 </thead>
707 <tbody id="dm-table-body">
708 {[...report.nodes]
709 .sort((a, b) => b.debtScore - a.debtScore)
710 .map((node) => {
711 const color = debtColor(node.debtScore);
712 return (
713 <tr key={node.path}>
714 <td>
715 <a
716 href={`/${owner}/${repo}/blob/HEAD/${node.path}`}
717 class="dm-path"
718 >
719 {node.path}
720 </a>
721 </td>
722 <td style="font-variant-numeric:tabular-nums;font-size:12px;">
723 {node.lines.toLocaleString()}
724 </td>
725 <td>
726 <div class="dm-score-bar-wrap">
727 <div class="dm-score-bar-bg">
728 <div
729 class="dm-score-bar-fill"
730 style={`width:${node.debtScore}%;background:${color};`}
731 />
732 </div>
733 <span class="dm-score-num" style={`color:${color};`}>
734 {node.debtScore}
735 </span>
736 </div>
737 </td>
738 <td style="font-variant-numeric:tabular-nums;">
739 {node.estimatedHours}h
740 </td>
741 <td style="font-size:12px;color:var(--text-muted);max-width:280px;">
742 {node.issues.slice(0, 2).join(", ")}
743 {node.issues.length > 2 && ` +${node.issues.length - 2} more`}
744 </td>
745 </tr>
746 );
747 })}
748 </tbody>
749 </table>
750 </div>
751
752 {/* Inline canvas script */}
753 <script
754 dangerouslySetInnerHTML={{
755 __html: buildGraphScript(owner, repo, report.nodes),
756 }}
757 />
758 </>
759 )}
760
761 <p class="dm-attribution">Powered by Claude &mdash; Anthropic</p>
762 </div>
763
764 {/* Trigger analysis + polling script */}
765 <script
766 dangerouslySetInnerHTML={{
767 __html: buildControlScript(owner, repo, repoId, isOwner),
768 }}
769 />
770 </Layout>
771 );
772 }
773);
774
775// ─── Route: POST /:owner/:repo/debt-map/analyze ───────────────────────────────
776
777debtMapRoutes.post(
778 "/:owner/:repo/debt-map/analyze",
779 requireRepoAccess("read"),
780 async (c) => {
781 const { owner, repo } = c.req.param();
782 const user = c.get("user") ?? null;
783
784 if (!user) {
785 return c.json({ error: "Authentication required" }, 401);
786 }
787
788 // Verify ownership
789 const ownerRow = await db
790 .select({ id: users.id })
791 .from(users)
792 .where(eq(users.username, owner))
793 .limit(1)
794 .then((r) => r[0] ?? null);
795
796 if (!ownerRow || ownerRow.id !== user.id) {
797 return c.json({ error: "Only the repository owner can trigger analysis" }, 403);
798 }
799
800 const repoRow = await db
801 .select({ id: repositories.id })
802 .from(repositories)
803 .where(and(eq(repositories.ownerId, ownerRow.id), eq(repositories.name, repo)))
804 .limit(1)
805 .then((r) => r[0] ?? null);
806
807 if (!repoRow) return c.json({ error: "Repository not found" }, 404);
808
809 const repoId = repoRow.id;
810 const current = getJobStatus(repoId);
811
812 if (current?.status === "running") {
813 return c.json({ status: "running" });
814 }
815
816 // Kick off background analysis
817 setJobStatus(repoId, "running");
818
819 // Fire-and-forget
820 analyzeRepo(repoId, owner, repo)
821 .then((report) => {
822 setDebtReport(repoId, report);
823 setJobStatus(repoId, "done");
824 })
825 .catch((err) => {
826 console.error("[debt-map] analysis failed:", err);
827 setJobStatus(repoId, "error", String(err?.message ?? err));
828 });
829
830 return c.json({ status: "running" });
831 }
832);
833
834// ─── Route: GET /:owner/:repo/debt-map/data ───────────────────────────────────
835
836debtMapRoutes.get(
837 "/:owner/:repo/debt-map/data",
838 requireRepoAccess("read"),
839 async (c) => {
840 const { owner, repo } = c.req.param();
841
842 const ownerRow = await db
843 .select({ id: users.id })
844 .from(users)
845 .where(eq(users.username, owner))
846 .limit(1)
847 .then((r) => r[0] ?? null);
848
849 if (!ownerRow) return c.json({ error: "Not found" }, 404);
850
851 const repoRow = await db
852 .select({ id: repositories.id })
853 .from(repositories)
854 .where(and(eq(repositories.ownerId, ownerRow.id), eq(repositories.name, repo)))
855 .limit(1)
856 .then((r) => r[0] ?? null);
857
858 if (!repoRow) return c.json({ error: "Not found" }, 404);
859
860 const repoId = repoRow.id;
861 const report = getDebtReport(repoId);
862 const job = getJobStatus(repoId);
863
864 if (!report) {
865 return c.json(
866 { status: job?.status ?? "idle", error: job?.error },
867 report ? 200 : 202
868 );
869 }
870
871 return c.json({ status: "done", report });
872 }
873);
874
875// ─── Client-side scripts ──────────────────────────────────────────────────────
876
877/**
878 * Build the inline canvas script for the force-directed graph.
879 * Runs at 60fps for 200 iterations then stops (spring simulation).
880 */
881function buildGraphScript(
882 owner: string,
883 repo: string,
884 nodes: Array<{
885 path: string;
886 lines: number;
887 debtScore: number;
888 issues: string[];
889 estimatedHours: number;
890 imports: string[];
891 }>
892): string {
893 // Serialise only what the client needs
894 const clientNodes = nodes.map((n) => ({
895 path: n.path,
896 lines: n.lines,
897 debtScore: n.debtScore,
898 issues: n.issues,
899 estimatedHours: n.estimatedHours,
900 imports: n.imports,
901 }));
902
903 return `(function(){
904 var OWNER = ${JSON.stringify(owner)};
905 var REPO = ${JSON.stringify(repo)};
906 var nodesData = ${JSON.stringify(clientNodes)};
907
908 // Build a path→index lookup
909 var pathIndex = {};
910 nodesData.forEach(function(n, i){ pathIndex[n.path] = i; });
911
912 // Build edge list (indices)
913 var edges = [];
914 nodesData.forEach(function(n, i){
915 (n.imports || []).forEach(function(imp){
916 // Try with and without extensions
917 var j = pathIndex[imp];
918 if(j === undefined){
919 // Try common extensions
920 var exts = ['.ts','.tsx','.js','.jsx','.py','.go','.rs'];
921 for(var e=0;e<exts.length;e++){
922 j = pathIndex[imp+exts[e]];
923 if(j !== undefined) break;
924 }
925 }
926 if(j !== undefined && j !== i){
927 edges.push([i, j]);
928 }
929 });
930 });
931
932 var canvas = document.getElementById('dm-canvas');
933 if(!canvas) return;
934 var ctx = canvas.getContext('2d');
935
936 // Responsive sizing
937 function resize(){
938 var wrap = canvas.parentElement;
939 var w = wrap ? Math.max(300, wrap.clientWidth - (panelOpen ? 300 : 0)) : 900;
940 canvas.width = w;
941 canvas.height = 500;
942 }
943 var panelOpen = false;
944 resize();
945 window.addEventListener('resize', function(){ resize(); draw(); });
946
947 // Initialise positions randomly within canvas
948 var W = canvas.width, H = canvas.height;
949 var sim = nodesData.map(function(n, i){
950 return {
951 x: 60 + Math.random() * (W - 120),
952 y: 60 + Math.random() * (H - 120),
953 vx: 0, vy: 0,
954 radius: Math.min(30, Math.max(6, Math.sqrt(n.lines) * 0.9)),
955 color: debtColor(n.debtScore),
956 idx: i
957 };
958 });
959
960 function debtColor(score){
961 if(score <= 33) return '#34d399';
962 if(score <= 66) return '#fbbf24';
963 return '#f87171';
964 }
965
966 // Force simulation
967 var ITERATIONS = 200;
968 var iter = 0;
969 var running = true;
970
971 function tick(){
972 if(!running) return;
973 W = canvas.width; H = canvas.height;
974 var REPEL = 1800;
975 var ATTRACT = 0.005;
976 var DAMPING = 0.85;
977 var CENTER_PULL = 0.002;
978
979 // Reset forces
980 sim.forEach(function(s){ s.fx = 0; s.fy = 0; });
981
982 // Repulsion between nodes
983 for(var i=0;i<sim.length;i++){
984 for(var j=i+1;j<sim.length;j++){
985 var dx = sim[j].x - sim[i].x;
986 var dy = sim[j].y - sim[i].y;
987 var dist2 = dx*dx + dy*dy + 1;
988 var force = REPEL / dist2;
989 var nx = dx / Math.sqrt(dist2);
990 var ny = dy / Math.sqrt(dist2);
991 sim[i].fx -= force * nx;
992 sim[i].fy -= force * ny;
993 sim[j].fx += force * nx;
994 sim[j].fy += force * ny;
995 }
996 }
997
998 // Spring attraction along edges
999 edges.forEach(function(e){
1000 var a = sim[e[0]], b = sim[e[1]];
1001 if(!a || !b) return;
1002 var dx = b.x - a.x;
1003 var dy = b.y - a.y;
1004 var dist = Math.sqrt(dx*dx + dy*dy) + 1;
1005 var restLen = 120;
1006 var stretch = dist - restLen;
1007 var force = ATTRACT * stretch;
1008 var nx = dx / dist, ny = dy / dist;
1009 a.fx += force * nx; a.fy += force * ny;
1010 b.fx -= force * nx; b.fy -= force * ny;
1011 });
1012
1013 // Gentle center pull
1014 sim.forEach(function(s){
1015 s.fx += (W/2 - s.x) * CENTER_PULL;
1016 s.fy += (H/2 - s.y) * CENTER_PULL;
1017 });
1018
1019 // Integrate
1020 sim.forEach(function(s){
1021 s.vx = (s.vx + s.fx) * DAMPING;
1022 s.vy = (s.vy + s.fy) * DAMPING;
1023 s.x += s.vx;
1024 s.y += s.vy;
1025 // Clamp to canvas
1026 var r = s.radius;
1027 s.x = Math.max(r, Math.min(W - r, s.x));
1028 s.y = Math.max(r, Math.min(H - r, s.y));
1029 });
1030
1031 draw();
1032 iter++;
1033 if(iter < ITERATIONS){
1034 requestAnimationFrame(tick);
1035 } else {
1036 running = false;
1037 draw();
1038 }
1039 }
1040
1041 var selectedIdx = -1;
1042
1043 function draw(){
1044 ctx.clearRect(0, 0, canvas.width, canvas.height);
1045
1046 // Edges
1047 ctx.save();
1048 ctx.strokeStyle = 'rgba(255,255,255,0.06)';
1049 ctx.lineWidth = 1;
1050 edges.forEach(function(e){
1051 var a = sim[e[0]], b = sim[e[1]];
1052 if(!a || !b) return;
1053 ctx.beginPath();
1054 ctx.moveTo(a.x, a.y);
1055 ctx.lineTo(b.x, b.y);
1056 ctx.stroke();
1057 });
1058 ctx.restore();
1059
1060 // Nodes
1061 sim.forEach(function(s){
1062 var n = nodesData[s.idx];
1063 ctx.save();
1064 // Glow for selected
1065 if(s.idx === selectedIdx){
1066 ctx.shadowColor = s.color;
1067 ctx.shadowBlur = 16;
1068 }
1069 ctx.beginPath();
1070 ctx.arc(s.x, s.y, s.radius, 0, Math.PI * 2);
1071 ctx.fillStyle = s.color;
1072 ctx.globalAlpha = s.idx === selectedIdx ? 1.0 : 0.82;
1073 ctx.fill();
1074 ctx.restore();
1075 });
1076 }
1077
1078 // Click handler
1079 canvas.addEventListener('click', function(e){
1080 var rect = canvas.getBoundingClientRect();
1081 var mx = e.clientX - rect.left;
1082 var my = e.clientY - rect.top;
1083 var hit = -1;
1084 for(var i=0;i<sim.length;i++){
1085 var s = sim[i];
1086 var dx = s.x - mx, dy = s.y - my;
1087 if(dx*dx + dy*dy <= s.radius*s.radius){
1088 hit = i;
1089 break;
1090 }
1091 }
1092 if(hit === -1){
1093 selectedIdx = -1;
1094 closePanel();
1095 } else {
1096 selectedIdx = hit;
1097 openPanel(hit);
1098 }
1099 draw();
1100 });
1101
1102 function openPanel(idx){
1103 var n = nodesData[idx];
1104 var panel = document.getElementById('dm-panel');
1105 if(!panel) return;
1106 panelOpen = true;
1107 panel.classList.add('is-open');
1108
1109 document.getElementById('dm-panel-path').textContent = n.path;
1110
1111 var scoreEl = document.getElementById('dm-panel-score');
1112 scoreEl.textContent = n.debtScore;
1113 scoreEl.style.color = debtColor(n.debtScore);
1114
1115 var issuesEl = document.getElementById('dm-panel-issues');
1116 issuesEl.innerHTML = (n.issues && n.issues.length)
1117 ? n.issues.map(function(iss){ return '<div class="dm-panel-issue">'+escHtml(iss)+'</div>'; }).join('')
1118 : '<span style="color:var(--text-muted);font-size:13px;">No issues found</span>';
1119
1120 document.getElementById('dm-panel-hours').textContent = n.estimatedHours + 'h';
1121
1122 var linkWrap = document.getElementById('dm-panel-link-wrap');
1123 linkWrap.innerHTML = '<a href="/'+escHtml(OWNER)+'/'+escHtml(REPO)+'/blob/HEAD/'+escHtml(n.path)+'" class="dm-panel-link">View file &rarr;</a>';
1124
1125 resize();
1126 if(!running) draw();
1127 }
1128
1129 function closePanel(){
1130 panelOpen = false;
1131 var panel = document.getElementById('dm-panel');
1132 if(panel) panel.classList.remove('is-open');
1133 resize();
1134 if(!running) draw();
1135 }
1136
1137 function escHtml(s){
1138 return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
1139 }
1140
1141 // Sort table buttons
1142 document.querySelectorAll('.dm-sort-btn').forEach(function(btn){
1143 btn.addEventListener('click', function(){
1144 document.querySelectorAll('.dm-sort-btn').forEach(function(b){ b.classList.remove('active'); });
1145 btn.classList.add('active');
1146 var sort = btn.getAttribute('data-sort');
1147 var tbody = document.getElementById('dm-table-body');
1148 if(!tbody) return;
1149 var rows = Array.from(tbody.querySelectorAll('tr'));
1150 rows.sort(function(a, b){
1151 var aPath = a.querySelector('.dm-path') ? a.querySelector('.dm-path').textContent.trim() : '';
1152 var bPath = b.querySelector('.dm-path') ? b.querySelector('.dm-path').textContent.trim() : '';
1153 var aN = nodesData.find(function(n){ return n.path === aPath; });
1154 var bN = nodesData.find(function(n){ return n.path === bPath; });
1155 if(!aN || !bN) return 0;
1156 if(sort === 'debt') return bN.debtScore - aN.debtScore;
1157 if(sort === 'lines') return bN.lines - aN.lines;
1158 if(sort === 'hours') return bN.estimatedHours - aN.estimatedHours;
1159 return 0;
1160 });
1161 rows.forEach(function(r){ tbody.appendChild(r); });
1162 });
1163 });
1164
1165 requestAnimationFrame(tick);
1166})();`;
1167}
1168
1169/**
1170 * Control script: handles the "Analyze now" button and polls for job
1171 * completion when a job is running.
1172 */
1173function buildControlScript(
1174 owner: string,
1175 repo: string,
1176 repoId: string,
1177 isOwner: boolean
1178): string {
1179 return `(function(){
1180 var OWNER = ${JSON.stringify(owner)};
1181 var REPO = ${JSON.stringify(repo)};
1182 var IS_OWNER = ${JSON.stringify(isOwner)};
1183
1184 // Analyze button
1185 var btn = document.getElementById('dm-trigger-btn');
1186 if(btn){
1187 btn.addEventListener('click', function(){
1188 btn.disabled = true;
1189 btn.textContent = 'Analyzing…';
1190 fetch('/'+OWNER+'/'+REPO+'/debt-map/analyze', {
1191 method: 'POST',
1192 headers: {'x-csrf-token': document.querySelector('meta[name=csrf-token]') ? document.querySelector('meta[name=csrf-token]').content : ''},
1193 credentials: 'same-origin'
1194 })
1195 .then(function(r){ return r.json(); })
1196 .then(function(data){
1197 if(data.status === 'running'){
1198 // Show running bar + start polling
1199 var runBar = document.getElementById('dm-running-bar');
1200 if(!runBar){
1201 runBar = document.createElement('div');
1202 runBar.id = 'dm-running-bar';
1203 runBar.className = 'dm-running';
1204 runBar.innerHTML = '<div class="dm-spinner"></div><span>Analysis running &mdash; this can take 20&ndash;30 seconds. Reloading when done…</span>';
1205 btn.parentElement && btn.parentElement.after ? btn.parentElement.after(runBar) : document.querySelector('.dm-wrap').insertBefore(runBar, document.querySelector('.dm-empty') || document.querySelector('.dm-graph-wrap'));
1206 }
1207 pollUntilDone();
1208 }
1209 })
1210 .catch(function(err){ btn.disabled = false; btn.textContent = 'Retry'; });
1211 });
1212 }
1213
1214 // Poll /debt-map/data until done, then reload page
1215 function pollUntilDone(){
1216 setTimeout(function(){
1217 fetch('/'+OWNER+'/'+REPO+'/debt-map/data', {credentials:'same-origin'})
1218 .then(function(r){ return r.json(); })
1219 .then(function(data){
1220 if(data.status === 'done'){
1221 window.location.reload();
1222 } else if(data.status === 'error'){
1223 var runBar = document.getElementById('dm-running-bar');
1224 if(runBar) runBar.innerHTML = '<span style="color:#f87171;">Analysis failed: '+(data.error||'unknown error')+'</span>';
1225 var btn2 = document.getElementById('dm-trigger-btn');
1226 if(btn2){ btn2.disabled = false; btn2.textContent = 'Retry'; }
1227 } else {
1228 pollUntilDone();
1229 }
1230 })
1231 .catch(function(){ pollUntilDone(); });
1232 }, 3000);
1233 }
1234
1235 // If job is running on page load, start polling
1236 var runBar = document.getElementById('dm-running-bar');
1237 if(runBar){ pollUntilDone(); }
1238})();`;
1239}
1240
1241export default debtMapRoutes;