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

competitive-intel.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.

competitive-intel.tsxBlame738 lines · 1 contributor
9989d86Claude1/**
2 * Competitive Intelligence Engine — Admin UI.
3 *
4 * GET /admin/intelligence — dashboard (latest report per competitor)
5 * POST /admin/intelligence/scan — trigger a new scan (fire-and-forget)
6 * GET /admin/intelligence/:competitor — history for one competitor
7 *
8 * All routes gated by `isSiteAdmin`.
9 */
10
11import { Hono } from "hono";
12import { softAuth } from "../middleware/auth";
13import type { AuthEnv } from "../middleware/auth";
14import { isSiteAdmin } from "../lib/admin";
15import { Layout } from "../views/layout";
16import {
17 runIntelligenceScan,
18 getLatestReports,
19 getReportHistory,
20 getLastScanRun,
21 COMPETITORS,
22 type CompetitorReport,
23 type GapIdentified,
24 type FeatureShipped,
25} from "../lib/competitive-intel";
26
27const competitiveIntel = new Hono<AuthEnv>();
28competitiveIntel.use("*", softAuth);
29
30// ---------------------------------------------------------------------------
31// Auth gate helper (mirrors admin.tsx pattern)
32// ---------------------------------------------------------------------------
33
34async function gate(c: any): Promise<{ user: any } | Response> {
35 const user = c.get("user");
36 if (!user) return c.redirect("/login?next=/admin/intelligence");
37 if (!(await isSiteAdmin(user.id))) {
38 return c.html(
39 <Layout title="Forbidden" user={user}>
40 <div class="empty-state">
41 <h2>403 — Not a site admin</h2>
42 <p>You don't have permission to view this page.</p>
43 </div>
44 </Layout>,
45 403
46 );
47 }
48 return { user };
49}
50
51// ---------------------------------------------------------------------------
52// Priority badge helper
53// ---------------------------------------------------------------------------
54
55function PriorityBadge({ priority }: { priority: "high" | "medium" | "low" }) {
56 const styles: Record<string, string> = {
57 high: "background:#f85149;color:#fff",
58 medium: "background:#d29922;color:#0d1117",
59 low: "background:#30363d;color:#8b949e",
60 };
61 return (
62 <span
63 style={`display:inline-block;padding:2px 8px;border-radius:3px;font-size:11px;font-weight:600;text-transform:uppercase;${styles[priority] ?? styles.low}`}
64 >
65 {priority}
66 </span>
67 );
68}
69
70// ---------------------------------------------------------------------------
71// Competitor display name helper
72// ---------------------------------------------------------------------------
73
74function competitorName(id: string): string {
75 const c = COMPETITORS.find((c) => c.id === id);
76 return c ? c.name : id;
77}
78
79// ---------------------------------------------------------------------------
80// Format a date string nicely
81// ---------------------------------------------------------------------------
82
83function fmtDate(d: string | Date | null | undefined): string {
84 if (!d) return "—";
85 try {
86 return new Date(d as string).toLocaleDateString("en-US", {
87 year: "numeric",
88 month: "short",
89 day: "numeric",
90 });
91 } catch {
92 return String(d);
93 }
94}
95
96// ---------------------------------------------------------------------------
97// Compute gap priority counts across all reports
98// ---------------------------------------------------------------------------
99
100function countGapsByPriority(reports: CompetitorReport[]) {
101 let high = 0;
102 let medium = 0;
103 let low = 0;
104 for (const r of reports) {
105 const gaps = (r.gapsIdentified ?? []) as GapIdentified[];
106 for (const g of gaps) {
107 if (g.priority === "high") high++;
108 else if (g.priority === "medium") medium++;
109 else low++;
110 }
111 }
112 return { high, medium, low };
113}
114
115// ---------------------------------------------------------------------------
116// GET /admin/intelligence — dashboard
117// ---------------------------------------------------------------------------
118
119competitiveIntel.get("/admin/intelligence", async (c) => {
120 const g = await gate(c);
121 if (g instanceof Response) return g;
122 const { user } = g;
123
124 const [reports, lastRun] = await Promise.all([
125 getLatestReports(),
126 getLastScanRun(),
127 ]);
128
129 const scanStarted = c.req.query("scan") === "started";
130 const gapCounts = countGapsByPriority(reports);
131
132 // Map reports by competitor id for easy lookup
133 const reportMap = new Map<string, CompetitorReport>(
134 reports.map((r) => [r.competitor, r])
135 );
136
137 return c.html(
138 <Layout title="Competitive Intelligence — Admin" user={user}>
139 <div style="max-width:1100px;margin:0 auto;padding:24px 16px">
140 {/* Header */}
141 <div
142 style="display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:12px;margin-bottom:8px"
143 >
144 <div>
145 <h1 style="margin:0;font-size:22px;font-weight:700">
146 Competitive Intelligence
147 </h1>
148 <p style="color:var(--text-muted);font-size:13px;margin-top:4px">
149 Weekly gap analysis — what competitors are shipping vs. what
150 Gluecron has.
151 </p>
152 </div>
153 <div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
154 <a href="/admin" class="btn btn-sm">
155 Back to Admin
156 </a>
157 <form method="post" action="/admin/intelligence/scan">
158 <button
159 type="submit"
160 class="btn btn-primary btn-sm"
161 onclick="this.disabled=true;this.textContent='Scanning…';this.form.submit()"
162 >
163 Run scan now
164 </button>
165 </form>
166 </div>
167 </div>
168
169 {/* Scan started banner */}
170 {scanStarted && (
171 <div
172 class="auth-success"
173 style="margin-bottom:16px;font-size:13px"
174 >
175 Scan started in the background. Refresh in a few minutes to see
176 updated reports.
177 </div>
178 )}
179
180 {/* Last scan status */}
181 {lastRun && (
182 <div
183 style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);padding:10px 16px;margin-bottom:20px;font-size:13px;display:flex;gap:16px;flex-wrap:wrap;align-items:center"
184 >
185 <span>
186 <span style="color:var(--text-muted)">Last scan:</span>{" "}
187 {fmtDate(lastRun.startedAt as unknown as string)}
188 </span>
189 <span>
190 <span style="color:var(--text-muted)">Status:</span>{" "}
191 <span
192 style={
193 lastRun.status === "completed"
194 ? "color:var(--green);font-weight:600"
195 : lastRun.status === "failed"
196 ? "color:var(--red);font-weight:600"
197 : "color:var(--yellow);font-weight:600"
198 }
199 >
200 {lastRun.status}
201 </span>
202 </span>
203 <span>
204 <span style="color:var(--text-muted)">Reports created:</span>{" "}
205 {lastRun.competitorsScanned}
206 </span>
207 {lastRun.error && (
208 <span style="color:var(--red);font-size:12px">
209 {lastRun.error}
210 </span>
211 )}
212 </div>
213 )}
214
215 {/* Gap summary counts */}
216 <div
217 style="display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:12px;margin-bottom:24px"
218 >
219 <div
220 style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);padding:14px;text-align:center"
221 >
222 <div style="font-size:26px;font-weight:700;color:var(--red)">
223 {gapCounts.high}
224 </div>
225 <div
226 style="font-size:11px;text-transform:uppercase;color:var(--text-muted);margin-top:2px"
227 >
228 High-priority gaps
229 </div>
230 </div>
231 <div
232 style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);padding:14px;text-align:center"
233 >
234 <div style="font-size:26px;font-weight:700;color:var(--yellow)">
235 {gapCounts.medium}
236 </div>
237 <div
238 style="font-size:11px;text-transform:uppercase;color:var(--text-muted);margin-top:2px"
239 >
240 Medium-priority gaps
241 </div>
242 </div>
243 <div
244 style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);padding:14px;text-align:center"
245 >
246 <div style="font-size:26px;font-weight:700;color:var(--text-muted)">
247 {gapCounts.low}
248 </div>
249 <div
250 style="font-size:11px;text-transform:uppercase;color:var(--text-muted);margin-top:2px"
251 >
252 Low-priority gaps
253 </div>
254 </div>
255 <div
256 style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);padding:14px;text-align:center"
257 >
258 <div style="font-size:26px;font-weight:700">
259 {reports.length}
260 </div>
261 <div
262 style="font-size:11px;text-transform:uppercase;color:var(--text-muted);margin-top:2px"
263 >
264 Competitors tracked
265 </div>
266 </div>
267 </div>
268
269 {/* Competitor cards grid */}
270 {reports.length === 0 ? (
271 <div
272 style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);padding:48px;text-align:center;color:var(--text-muted)"
273 >
274 <p style="font-size:15px;margin-bottom:8px">No reports yet.</p>
275 <p style="font-size:13px">
276 Click "Run scan now" to fetch the latest competitor changelogs and
277 analyse gaps with Claude.
278 </p>
279 </div>
280 ) : (
281 <div
282 style="display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:16px"
283 >
284 {COMPETITORS.map((comp) => {
285 const report = reportMap.get(comp.id);
286 return (
287 <div
288 style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);overflow:hidden;display:flex;flex-direction:column"
289 >
290 {/* Card header */}
291 <div
292 style="padding:14px 16px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center"
293 >
294 <div>
295 <span style="font-weight:700;font-size:15px">
296 {comp.name}
297 </span>
298 {report && (
299 <span
300 style="margin-left:8px;font-size:12px;color:var(--text-muted)"
301 >
302 {fmtDate(report.reportDate as unknown as string)}
303 </span>
304 )}
305 </div>
306 <a
307 href={`/admin/intelligence/${comp.id}`}
308 style="font-size:12px;color:var(--text-link)"
309 >
310 History
311 </a>
312 </div>
313
314 {/* Card body */}
315 <div style="padding:14px 16px;flex:1;display:flex;flex-direction:column;gap:12px">
316 {!report ? (
317 <p style="font-size:13px;color:var(--text-muted)">
318 No report available. Run a scan to populate.
319 </p>
320 ) : (
321 <>
322 {/* Summary */}
323 {report.summary && (
324 <p style="font-size:13px;color:var(--text-muted);line-height:1.55">
325 {report.summary}
326 </p>
327 )}
328
329 {/* Gaps — high priority first */}
330 {(report.gapsIdentified as GapIdentified[]).length >
331 0 && (
332 <div>
333 <div
334 style="font-size:11px;font-weight:600;text-transform:uppercase;color:var(--text-muted);margin-bottom:6px;letter-spacing:0.05em"
335 >
336 Gaps identified (
337 {
338 (report.gapsIdentified as GapIdentified[])
339 .length
340 }
341 )
342 </div>
343 <ul
344 style="list-style:none;display:flex;flex-direction:column;gap:6px"
345 >
346 {(report.gapsIdentified as GapIdentified[])
347 .sort((a, b) => {
348 const order = {
349 high: 0,
350 medium: 1,
351 low: 2,
352 };
353 return (
354 (order[a.priority] ?? 3) -
355 (order[b.priority] ?? 3)
356 );
357 })
358 .slice(0, 5)
359 .map((gap) => (
360 <li
361 style="display:flex;align-items:flex-start;gap:8px;font-size:13px"
362 >
363 <PriorityBadge priority={gap.priority} />
364 <span style="color:var(--text)">
365 {gap.feature}
366 </span>
367 </li>
368 ))}
369 {(report.gapsIdentified as GapIdentified[])
370 .length > 5 && (
371 <li
372 style="font-size:12px;color:var(--text-muted)"
373 >
374 +
375 {(report.gapsIdentified as GapIdentified[])
376 .length - 5}{" "}
377 more —{" "}
378 <a
379 href={`/admin/intelligence/${comp.id}`}
380 style="color:var(--text-link)"
381 >
382 see all
383 </a>
384 </li>
385 )}
386 </ul>
387 </div>
388 )}
389
390 {/* Features shipped */}
391 {(report.featuresShipped as FeatureShipped[]).length >
392 0 && (
393 <div>
394 <div
395 style="font-size:11px;font-weight:600;text-transform:uppercase;color:var(--text-muted);margin-bottom:6px;letter-spacing:0.05em"
396 >
397 Features shipped (
398 {
399 (report.featuresShipped as FeatureShipped[])
400 .length
401 }
402 )
403 </div>
404 <ul
405 style="list-style:none;display:flex;flex-direction:column;gap:4px"
406 >
407 {(report.featuresShipped as FeatureShipped[])
408 .slice(0, 4)
409 .map((f) => (
410 <li style="font-size:13px;color:var(--text-muted)">
411 {f.url ? (
412 <a
413 href={f.url}
414 target="_blank"
415 rel="noopener noreferrer"
416 style="color:var(--text-link)"
417 >
418 {f.title}
419 </a>
420 ) : (
421 <span style="color:var(--text)">
422 {f.title}
423 </span>
424 )}
425 </li>
426 ))}
427 {(report.featuresShipped as FeatureShipped[])
428 .length > 4 && (
429 <li
430 style="font-size:12px;color:var(--text-muted)"
431 >
432 +
433 {(report.featuresShipped as FeatureShipped[])
434 .length - 4}{" "}
435 more
436 </li>
437 )}
438 </ul>
439 </div>
440 )}
441 </>
442 )}
443 </div>
444
445 {/* Card footer */}
446 <div
447 style="padding:10px 16px;border-top:1px solid var(--border);display:flex;justify-content:space-between;align-items:center"
448 >
449 <a
450 href={comp.changelogUrl}
451 target="_blank"
452 rel="noopener noreferrer"
453 style="font-size:12px;color:var(--text-muted)"
454 >
455 Changelog ↗
456 </a>
457 <a
458 href={`/admin/intelligence/${comp.id}`}
459 class="btn btn-sm"
460 style="font-size:12px"
461 >
462 View history
463 </a>
464 </div>
465 </div>
466 );
467 })}
468 </div>
469 )}
470 </div>
471 </Layout>
472 );
473});
474
475// ---------------------------------------------------------------------------
476// POST /admin/intelligence/scan — trigger scan (fire-and-forget)
477// ---------------------------------------------------------------------------
478
479competitiveIntel.post("/admin/intelligence/scan", async (c) => {
480 const g = await gate(c);
481 if (g instanceof Response) return g;
482
483 // Fire-and-forget — do NOT await
484 runIntelligenceScan().catch((err) => {
485 console.error("[competitive-intel] background scan error:", err);
486 });
487
488 return c.redirect("/admin/intelligence?scan=started");
489});
490
491// ---------------------------------------------------------------------------
492// GET /admin/intelligence/:competitor — history for one competitor
493// ---------------------------------------------------------------------------
494
495competitiveIntel.get("/admin/intelligence/:competitor", async (c) => {
496 const g = await gate(c);
497 if (g instanceof Response) return g;
498 const { user } = g;
499
500 const competitorId = c.req.param("competitor");
501 const knownCompetitor = COMPETITORS.find((x) => x.id === competitorId);
502
503 if (!knownCompetitor) {
504 return c.html(
505 <Layout title="Not Found" user={user}>
506 <div class="empty-state">
507 <h2>404</h2>
508 <p>Competitor not found: {competitorId}</p>
509 <a href="/admin/intelligence" style="margin-top:12px;display:inline-block">
510 Back to Intelligence
511 </a>
512 </div>
513 </Layout>,
514 404
515 );
516 }
517
518 const history = await getReportHistory(competitorId, 20);
519 const name = knownCompetitor.name;
520
521 return c.html(
522 <Layout title={`${name} — Intelligence History`} user={user}>
523 <div style="max-width:960px;margin:0 auto;padding:24px 16px">
524 {/* Header */}
525 <div
526 style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px;margin-bottom:20px"
527 >
528 <div>
529 <h1 style="font-size:22px;font-weight:700;margin:0">
530 {name} — Intelligence History
531 </h1>
532 <p style="color:var(--text-muted);font-size:13px;margin-top:4px">
533 {history.length} report{history.length === 1 ? "" : "s"} on
534 record.{" "}
535 <a
536 href={knownCompetitor.changelogUrl}
537 target="_blank"
538 rel="noopener noreferrer"
539 style="color:var(--text-link)"
540 >
541 View live changelog ↗
542 </a>
543 </p>
544 </div>
545 <a href="/admin/intelligence" class="btn btn-sm">
546 Back to Dashboard
547 </a>
548 </div>
549
550 {/* Timeline */}
551 {history.length === 0 ? (
552 <div
553 style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);padding:48px;text-align:center;color:var(--text-muted)"
554 >
555 <p>No reports for {name} yet. Run a scan to generate one.</p>
556 </div>
557 ) : (
558 <div style="display:flex;flex-direction:column;gap:20px">
559 {history.map((report, idx) => {
560 const gaps = (report.gapsIdentified ?? []) as GapIdentified[];
561 const features = (
562 report.featuresShipped ?? []
563 ) as FeatureShipped[];
564 const highGaps = gaps.filter((g) => g.priority === "high");
565 const medGaps = gaps.filter((g) => g.priority === "medium");
566 const lowGaps = gaps.filter((g) => g.priority === "low");
567
568 return (
569 <div
570 style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);overflow:hidden"
571 >
572 {/* Report header */}
573 <div
574 style="padding:14px 16px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px"
575 >
576 <div style="display:flex;align-items:center;gap:12px">
577 <span
578 style="background:var(--accent);color:#fff;font-size:11px;font-weight:600;padding:2px 8px;border-radius:3px"
579 >
580 {idx === 0 ? "LATEST" : `#${idx + 1}`}
581 </span>
582 <span style="font-weight:600;font-size:15px">
583 Week of{" "}
584 {fmtDate(report.reportDate as unknown as string)}
585 </span>
586 </div>
587 <div
588 style="display:flex;gap:8px;font-size:12px;color:var(--text-muted)"
589 >
590 <span>
591 <span style="color:var(--red);font-weight:600">
592 {highGaps.length}
593 </span>{" "}
594 high
595 </span>
596 <span>
597 <span style="color:var(--yellow);font-weight:600">
598 {medGaps.length}
599 </span>{" "}
600 medium
601 </span>
602 <span>
603 <span style="color:var(--text-muted);font-weight:600">
604 {lowGaps.length}
605 </span>{" "}
606 low
607 </span>
608 <span>{features.length} features shipped</span>
609 </div>
610 </div>
611
612 {/* Report body */}
613 <div
614 style="padding:16px;display:grid;grid-template-columns:1fr 1fr;gap:20px"
615 >
616 {/* Left: summary + gaps */}
617 <div>
618 {report.summary && (
619 <div style="margin-bottom:14px">
620 <div
621 style="font-size:11px;font-weight:600;text-transform:uppercase;color:var(--text-muted);margin-bottom:6px;letter-spacing:0.05em"
622 >
623 Summary
624 </div>
625 <p
626 style="font-size:13px;color:var(--text-muted);line-height:1.6"
627 >
628 {report.summary}
629 </p>
630 </div>
631 )}
632
633 {gaps.length > 0 && (
634 <div>
635 <div
636 style="font-size:11px;font-weight:600;text-transform:uppercase;color:var(--text-muted);margin-bottom:8px;letter-spacing:0.05em"
637 >
638 Gaps identified ({gaps.length})
639 </div>
640 <ul
641 style="list-style:none;display:flex;flex-direction:column;gap:8px"
642 >
643 {[...highGaps, ...medGaps, ...lowGaps].map(
644 (gap) => (
645 <li style="font-size:13px">
646 <div
647 style="display:flex;align-items:flex-start;gap:8px;margin-bottom:2px"
648 >
649 <PriorityBadge priority={gap.priority} />
650 <span
651 style="color:var(--text);font-weight:500"
652 >
653 {gap.feature}
654 </span>
655 </div>
656 {gap.notes && (
657 <p
658 style="font-size:12px;color:var(--text-muted);margin-top:3px;padding-left:4px;line-height:1.5"
659 >
660 {gap.notes}
661 </p>
662 )}
663 </li>
664 )
665 )}
666 </ul>
667 </div>
668 )}
669 </div>
670
671 {/* Right: features shipped */}
672 <div>
673 {features.length > 0 && (
674 <div>
675 <div
676 style="font-size:11px;font-weight:600;text-transform:uppercase;color:var(--text-muted);margin-bottom:8px;letter-spacing:0.05em"
677 >
678 Features shipped ({features.length})
679 </div>
680 <ul
681 style="list-style:none;display:flex;flex-direction:column;gap:10px"
682 >
683 {features.map((f) => (
684 <li style="font-size:13px">
685 <div style="font-weight:500;color:var(--text);margin-bottom:2px">
686 {f.url ? (
687 <a
688 href={f.url}
689 target="_blank"
690 rel="noopener noreferrer"
691 style="color:var(--text-link)"
692 >
693 {f.title}
694 </a>
695 ) : (
696 f.title
697 )}
698 </div>
699 {f.description && (
700 <p
701 style="font-size:12px;color:var(--text-muted);line-height:1.5"
702 >
703 {f.description}
704 </p>
705 )}
706 </li>
707 ))}
708 </ul>
709 </div>
710 )}
711
712 {features.length === 0 && gaps.length === 0 && (
713 <p style="font-size:13px;color:var(--text-muted)">
714 No structured data extracted for this report.
715 </p>
716 )}
717 </div>
718 </div>
719 </div>
720 );
721 })}
722 </div>
723 )}
724 </div>
725
726 {/* Mobile: single-column layout for the report body grid */}
727 <style>{`
728 @media (max-width: 640px) {
729 .intel-report-body {
730 grid-template-columns: 1fr !important;
731 }
732 }
733 `}</style>
734 </Layout>
735 );
736});
737
738export default competitiveIntel;