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.
| 9989d86 | 1 | /** |
| 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 | ||
| 11 | import { Hono } from "hono"; | |
| 12 | import { softAuth } from "../middleware/auth"; | |
| 13 | import type { AuthEnv } from "../middleware/auth"; | |
| 14 | import { isSiteAdmin } from "../lib/admin"; | |
| 15 | import { Layout } from "../views/layout"; | |
| 16 | import { | |
| 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 | ||
| 27 | const competitiveIntel = new Hono<AuthEnv>(); | |
| 28 | competitiveIntel.use("*", softAuth); | |
| 29 | ||
| 30 | // --------------------------------------------------------------------------- | |
| 31 | // Auth gate helper (mirrors admin.tsx pattern) | |
| 32 | // --------------------------------------------------------------------------- | |
| 33 | ||
| 34 | async 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 | ||
| 55 | function 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 | ||
| 74 | function 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 | ||
| 83 | function 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 | ||
| 100 | function 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 | ||
| 119 | competitiveIntel.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 | ||
| 479 | competitiveIntel.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 | ||
| 495 | competitiveIntel.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 | ||
| 738 | export default competitiveIntel; |