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