this repo has no description
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add item page

+1039 -1
+1
README.md
··· 12 12 - **#1 Post Alerts**: Special notifications when your post reaches the coveted #1 position 13 13 - **Leaderboard History**: Track how your posts perform over time with rank and point history 14 14 - **Web Dashboard**: View all currently tracked stories and their stats 15 + - **Individual Story Pages**: View detailed information about any story using HN-compatible URLs 15 16 - **User Verification**: Securely link your HN account with Slack using verification phrases 16 17 17 18 ## 🚧 Development Setup
+4 -1
public/app.js
··· 349 349 <span>Detected: ${date}</span> 350 350 <span class="duration ${durationClass}" title="Time since first detection" data-timestamp="${timestampMs}" data-story-id="${story.id}">${durationEmoji} ${durationText}</span> 351 351 <span><a href="${story.url}" target="_blank" class="external-link">View Story ↗</a></span> 352 + <span><a href="/item?id=${story.id}" class="item-link">View Stats</a></span> 352 353 </div> 353 354 </div> 354 355 `; ··· 374 375 // Prevent triggering when clicking links 375 376 if ( 376 377 e.target.classList.contains("external-link") || 377 - e.target.closest(".external-link") 378 + e.target.closest(".external-link") || 379 + e.target.classList.contains("item-link") || 380 + e.target.closest(".item-link") 378 381 ) { 379 382 return; 380 383 }
+929
public/item.html
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8" /> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6 + <link 7 + rel="icon" 8 + type="image/png" 9 + href="/public/favicon-96x96.png" 10 + sizes="96x96" 11 + /> 12 + <link rel="icon" type="image/svg+xml" href="/public/favicon.svg" /> 13 + <link rel="shortcut icon" href="/public/favicon.ico" /> 14 + <link 15 + rel="apple-touch-icon" 16 + sizes="180x180" 17 + href="/public/apple-touch-icon.png" 18 + /> 19 + <meta name="apple-mobile-web-app-title" content="HN Alerts" /> 20 + <link rel="manifest" href="/public/site.webmanifest" /> 21 + <meta 22 + name="theme-color" 23 + content="#ff6600" 24 + media="(prefers-color-scheme: light)" 25 + /> 26 + <meta 27 + name="theme-color" 28 + content="#1a1a1a" 29 + media="(prefers-color-scheme: dark)" 30 + /> 31 + <title>HN Story | Hacker News Alerts</title> 32 + <link 33 + rel="stylesheet" 34 + href="https://cdn.jsdelivr.net/npm/water.css@2/out/water.css" 35 + /> 36 + <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.3/dist/chart.umd.min.js"></script> 37 + <style> 38 + :root { 39 + --bg-color: #ffffff; 40 + --bg-secondary: #f5f5f5; 41 + --text-color: #212121; 42 + --text-secondary: #555; 43 + --hn-orange: #ff6600; 44 + --hn-orange-hover: #e05d00; 45 + --border-color: #ddd; 46 + } 47 + 48 + @media (prefers-color-scheme: dark) { 49 + :root { 50 + --bg-color: #1a1a1a; 51 + --bg-secondary: #2a2a2a; 52 + --text-color: #f0f0f0; 53 + --text-secondary: #aaa; 54 + --border-color: #444; 55 + } 56 + } 57 + 58 + body { 59 + max-width: 1200px; 60 + margin: 0 auto; 61 + padding: 2rem; 62 + background-color: var(--bg-color); 63 + color: var(--text-color); 64 + } 65 + 66 + .header { 67 + display: flex; 68 + align-items: center; 69 + justify-content: space-between; 70 + } 71 + 72 + .header img { 73 + height: 60px; 74 + } 75 + 76 + .main-container { 77 + display: flex; 78 + gap: 2rem; 79 + } 80 + 81 + .story-details { 82 + flex: 1; 83 + margin-top: 1rem; 84 + } 85 + 86 + .graph-container { 87 + background-color: var(--bg-secondary); 88 + border-radius: 16px; 89 + padding: 1.5rem; 90 + height: 500px; 91 + margin: 1.5rem 0; 92 + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.08); 93 + border: 1px solid rgba(59, 130, 246, 0.1); 94 + transition: all 0.3s ease; 95 + } 96 + 97 + .graph-container:hover { 98 + box-shadow: 0 15px 30px rgba(0, 0, 0, 0.12); 99 + border-color: rgba(59, 130, 246, 0.2); 100 + } 101 + 102 + .story-item { 103 + background-color: var(--bg-secondary); 104 + border-radius: 12px; 105 + padding: 1.2rem; 106 + margin-bottom: 1.2rem; 107 + border-left: 4px solid var(--hn-orange); 108 + position: relative; 109 + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.05); 110 + } 111 + 112 + .story-item h3 { 113 + margin-top: 0; 114 + margin-bottom: 0.8rem; 115 + font-size: 1.3rem; 116 + line-height: 1.3; 117 + color: var(--text-color); 118 + max-width: 95%; 119 + } 120 + 121 + .story-meta { 122 + font-size: 0.9rem; 123 + color: var(--text-secondary); 124 + display: flex; 125 + justify-content: space-between; 126 + margin-top: 0.8rem; 127 + padding-top: 0.8rem; 128 + border-top: 1px solid rgba(255, 102, 0, 0.1); 129 + } 130 + 131 + .loading { 132 + text-align: center; 133 + padding: 2.5rem; 134 + color: var(--text-secondary); 135 + font-style: italic; 136 + background-color: var(--bg-secondary); 137 + border-radius: 12px; 138 + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.05); 139 + animation: pulse 1.5s infinite ease-in-out; 140 + } 141 + 142 + @keyframes pulse { 143 + 0% { 144 + opacity: 0.6; 145 + } 146 + 50% { 147 + opacity: 1; 148 + } 149 + 100% { 150 + opacity: 0.6; 151 + } 152 + } 153 + 154 + .stats { 155 + display: flex; 156 + flex-wrap: wrap; 157 + gap: 1.5rem; 158 + margin: 2rem 0; 159 + padding: 1.5rem; 160 + background: linear-gradient( 161 + 135deg, 162 + rgba(255, 102, 0, 0.08) 0%, 163 + rgba(255, 102, 0, 0.02) 50%, 164 + rgba(255, 102, 0, 0.08) 100% 165 + ); 166 + border-radius: 16px; 167 + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.03); 168 + backdrop-filter: blur(10px); 169 + -webkit-backdrop-filter: blur(10px); 170 + border: 1px solid rgba(255, 102, 0, 0.1); 171 + justify-content: center; 172 + align-items: center; 173 + width: fit-content; 174 + max-width: 100%; 175 + margin-left: auto; 176 + margin-right: auto; 177 + } 178 + 179 + .back-link { 180 + display: inline-flex; 181 + align-items: center; 182 + gap: 8px; 183 + margin-bottom: 16px; 184 + color: var(--hn-orange); 185 + text-decoration: none; 186 + font-weight: bold; 187 + transition: all 0.2s ease; 188 + } 189 + 190 + .back-link:hover { 191 + color: var(--hn-orange-hover); 192 + } 193 + 194 + .performance-summary { 195 + margin-top: 2rem; 196 + } 197 + 198 + .section-icon { 199 + font-size: 2rem; 200 + color: var(--hn-orange); 201 + display: inline-block; 202 + margin-bottom: 1rem; 203 + } 204 + 205 + .performance-metrics-container { 206 + display: grid; 207 + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); 208 + gap: 1rem; 209 + margin-bottom: 2rem; 210 + } 211 + 212 + .performance-metric { 213 + background-color: var(--bg-secondary); 214 + border-radius: 12px; 215 + padding: 1rem; 216 + display: flex; 217 + flex-direction: column; 218 + align-items: center; 219 + text-align: center; 220 + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.05); 221 + position: relative; 222 + overflow: hidden; 223 + transition: all 0.3s ease; 224 + } 225 + 226 + .performance-metric:hover { 227 + transform: translateY(-5px); 228 + box-shadow: 0 8px 15px rgba(0, 0, 0, 0.1); 229 + } 230 + 231 + .performance-metric::after { 232 + content: ""; 233 + position: absolute; 234 + bottom: 0; 235 + left: 0; 236 + width: 100%; 237 + height: 3px; 238 + background: linear-gradient( 239 + to right, 240 + var(--hn-orange), 241 + var(--hn-orange-hover) 242 + ); 243 + transform: scaleX(0); 244 + transform-origin: bottom right; 245 + transition: transform 0.3s ease-out; 246 + } 247 + 248 + @media (prefers-color-scheme: dark) { 249 + .performance-metric { 250 + box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2); 251 + } 252 + 253 + .performance-metric:hover { 254 + box-shadow: 0 8px 20px rgba(0, 0, 0, 0.25); 255 + } 256 + } 257 + 258 + .metric-label { 259 + color: var(--text-secondary); 260 + font-size: 0.9rem; 261 + margin-bottom: 0.5rem; 262 + } 263 + 264 + .metric-value { 265 + font-size: 1.8rem; 266 + font-weight: bold; 267 + color: var(--hn-orange); 268 + margin-bottom: 0.5rem; 269 + } 270 + 271 + .metric-description { 272 + font-size: 0.8rem; 273 + color: var(--text-secondary); 274 + } 275 + 276 + .verified-badge { 277 + background-color: var(--hn-orange); 278 + color: white; 279 + padding: 0.2em 0.5em; 280 + border-radius: 4px; 281 + font-size: 0.8rem; 282 + font-weight: bold; 283 + display: inline-flex; 284 + align-items: center; 285 + gap: 4px; 286 + margin-left: 0.5rem; 287 + vertical-align: middle; 288 + } 289 + 290 + .no-graph { 291 + display: flex; 292 + flex-direction: column; 293 + justify-content: center; 294 + align-items: center; 295 + height: 100%; 296 + text-align: center; 297 + color: var(--text-secondary); 298 + } 299 + 300 + .error-message { 301 + color: var(--hn-orange); 302 + font-weight: bold; 303 + margin-top: 1rem; 304 + } 305 + 306 + .link-group { 307 + display: flex; 308 + gap: 1rem; 309 + margin-top: 1rem; 310 + flex-wrap: wrap; 311 + } 312 + 313 + .external-link { 314 + display: inline-flex; 315 + align-items: center; 316 + gap: 6px; 317 + background-color: var(--bg-secondary); 318 + color: var(--hn-orange); 319 + border: 1px solid var(--hn-orange); 320 + border-radius: 8px; 321 + padding: 0.5rem 1rem; 322 + text-decoration: none; 323 + font-weight: bold; 324 + transition: all 0.2s ease; 325 + } 326 + 327 + .external-link:hover { 328 + background-color: var(--hn-orange); 329 + color: white; 330 + } 331 + 332 + .meta-bar { 333 + display: flex; 334 + flex-wrap: wrap; 335 + gap: 1rem; 336 + margin-bottom: 1rem; 337 + } 338 + 339 + .meta-item { 340 + display: flex; 341 + align-items: center; 342 + gap: 0.5rem; 343 + color: var(--text-secondary); 344 + font-size: 0.9rem; 345 + } 346 + 347 + @media (max-width: 1240px) { 348 + .graph-container { 349 + height: 400px; 350 + } 351 + } 352 + 353 + @media (max-width: 1015px) { 354 + .graph-container { 355 + height: 350px; 356 + } 357 + } 358 + 359 + @media (max-width: 765px) { 360 + .main-container { 361 + flex-direction: column; 362 + } 363 + 364 + .graph-container { 365 + max-width: none; 366 + } 367 + 368 + .story-details { 369 + max-width: none; 370 + } 371 + } 372 + 373 + @media (max-width: 540px) { 374 + .graph-container { 375 + height: 300px; 376 + } 377 + 378 + body { 379 + padding: 1rem; 380 + } 381 + 382 + .story-item h3 { 383 + font-size: 1.1rem; 384 + } 385 + 386 + .metric-value { 387 + font-size: 1.5rem; 388 + } 389 + 390 + .performance-metrics-container { 391 + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); 392 + } 393 + } 394 + 395 + @media (max-width: 420px) { 396 + .graph-container { 397 + height: 240px; 398 + } 399 + 400 + .no-graph { 401 + font-size: 1rem; 402 + } 403 + 404 + body { 405 + font-size: 0.8rem; 406 + padding: 0.7rem; 407 + } 408 + 409 + .story-item { 410 + font-size: 0.9rem; 411 + } 412 + 413 + .story-item h3 { 414 + font-size: 1.1rem; 415 + } 416 + 417 + .story-meta { 418 + font-size: 0.8rem; 419 + } 420 + 421 + .metric-value { 422 + font-size: 1.2rem; 423 + } 424 + 425 + .header h1 { 426 + font-size: 1.3rem; 427 + } 428 + 429 + .header img { 430 + height: 50px; 431 + } 432 + 433 + .performance-metric { 434 + padding: 0.8rem 0.7rem; 435 + } 436 + } 437 + </style> 438 + </head> 439 + <body> 440 + <div class="header"> 441 + <div> 442 + <h1>Hacker News Story</h1> 443 + <p>Performance tracking and statistics</p> 444 + </div> 445 + <img 446 + src="https://cachet.dunkirk.sh/emojis/ycombinator/r" 447 + alt="HN Logo" 448 + /> 449 + </div> 450 + 451 + <a href="/" class="back-link"> 452 + <svg 453 + xmlns="http://www.w3.org/2000/svg" 454 + width="16" 455 + height="16" 456 + viewBox="0 0 24 24" 457 + fill="none" 458 + stroke="currentColor" 459 + stroke-width="2" 460 + stroke-linecap="round" 461 + stroke-linejoin="round" 462 + > 463 + <line x1="19" y1="12" x2="5" y2="12"></line> 464 + <polyline points="12 19 5 12 12 5"></polyline> 465 + </svg> 466 + Back to Dashboard 467 + </a> 468 + 469 + <div class="performance-summary"> 470 + <div id="performance-metrics" class="performance-metrics-container"> 471 + <!-- Performance metrics will be populated here --> 472 + <div class="performance-metric"> 473 + <div class="metric-label">Loading...</div> 474 + <div class="metric-value">-</div> 475 + </div> 476 + <div class="performance-metric"> 477 + <div class="metric-label">Loading...</div> 478 + <div class="metric-value">-</div> 479 + </div> 480 + <div class="performance-metric"> 481 + <div class="metric-label">Loading...</div> 482 + <div class="metric-value">-</div> 483 + </div> 484 + </div> 485 + </div> 486 + 487 + <div id="story-container" class="story-item"> 488 + <div class="loading">Loading story data...</div> 489 + </div> 490 + 491 + <div class="graph-container" style="max-width: 100%"> 492 + <div class="no-graph" id="no-graph"> 493 + <span class="section-icon">📈</span> 494 + <p>Loading rank & score history...</p> 495 + </div> 496 + <canvas id="rank-chart" style="display: none"></canvas> 497 + </div> 498 + 499 + <div class="main-container"> 500 + <div class="story-details" style="max-width: 100%"></div> 501 + </div> 502 + 503 + <script> 504 + // Get the story ID from the URL 505 + const urlParams = new URLSearchParams(window.location.search); 506 + const storyId = urlParams.get("id"); 507 + 508 + // Cache for snapshot data and ETags 509 + const snapshotDataCache = {}; 510 + const snapshotEtagCache = {}; 511 + 512 + // Check if we have a valid story ID 513 + if (!storyId) { 514 + document.getElementById("no-graph").innerHTML = 515 + '<div class="error-message">No story ID provided. Please use ?id=XXXXX in the URL.</div>'; 516 + document.getElementById("story-container").innerHTML = 517 + '<div class="error-message">No story ID provided. Please use ?id=XXXXX in the URL.</div>'; 518 + } else { 519 + // Fetch story data 520 + fetchStoryData(storyId); 521 + 522 + // Fetch story snapshots for the chart 523 + loadStoryGraph(storyId); 524 + } 525 + 526 + function fetchStoryData(storyId) { 527 + fetch(`/api/story/${storyId}`) 528 + .then((response) => { 529 + if (!response.ok) { 530 + throw new Error(`HTTP error ${response.status}`); 531 + } 532 + return response.json(); 533 + }) 534 + .then((story) => { 535 + displayStoryData(story); 536 + updatePerformanceMetrics(story); 537 + }) 538 + .catch((error) => { 539 + console.error("Error fetching story data:", error); 540 + document.getElementById("story-container").innerHTML = 541 + `<div class="error-message">Failed to load story: ${error.message}</div>`; 542 + }); 543 + } 544 + 545 + function displayStoryData(story) { 546 + const container = document.getElementById("story-container"); 547 + 548 + // Add monitored user badge if applicable 549 + const monitoredBadge = story.isFromMonitoredUser 550 + ? `<span class="verified-badge">TRACKED USER</span>` 551 + : ""; 552 + 553 + const html = ` 554 + <h3> 555 + <a href="${story.url || `https://news.ycombinator.com/item?id=${story.id}`}" target="_blank" rel="noopener"> 556 + ${escapeHTML(story.title)} 557 + </a> 558 + </h3> 559 + 560 + <div class="meta-bar"> 561 + <div class="meta-item"> 562 + <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--hn-orange)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 563 + <path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path> 564 + <circle cx="12" cy="7" r="4"></circle> 565 + </svg> 566 + <span>by <strong>${escapeHTML(story.by)}</strong>${monitoredBadge}</span> 567 + </div> 568 + 569 + ${ 570 + story.rank 571 + ? ` 572 + <div class="meta-item"> 573 + <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--hn-orange)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 574 + <polyline points="22 12 18 12 15 21 9 3 6 12 2 12"></polyline> 575 + </svg> 576 + <span>Current Rank: <strong>#${story.rank}</strong></span> 577 + </div> 578 + ` 579 + : "" 580 + } 581 + 582 + <div class="meta-item"> 583 + <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--hn-orange)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 584 + <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon> 585 + </svg> 586 + <span>Current Score: <strong>${story.points}</strong> points</span> 587 + </div> 588 + 589 + 590 + </div> 591 + 592 + <div class="story-meta"> 593 + <span>First seen: ${formatDate(story.timestamp || Date.now())}</span> 594 + <span>ID: ${story.id}</span> 595 + </div> 596 + 597 + <div class="link-group"> 598 + <a href="${story.url || `https://news.ycombinator.com/item?id=${story.id}`}" target="_blank" class="external-link"> 599 + <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 600 + <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path> 601 + <polyline points="15 3 21 3 21 9"></polyline> 602 + <line x1="10" y1="14" x2="21" y2="3"></line> 603 + </svg> 604 + View Original Link 605 + </a> 606 + <a href="https://news.ycombinator.com/item?id=${story.id}" target="_blank" class="external-link"> 607 + <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 608 + <path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"></path> 609 + </svg> 610 + View on HN 611 + </a> 612 + </div> 613 + `; 614 + 615 + container.innerHTML = html; 616 + 617 + // Update document title 618 + document.title = `${escapeHTML(story.title)} | HN Alerts`; 619 + } 620 + 621 + function updatePerformanceMetrics(story) { 622 + const container = document.getElementById( 623 + "performance-metrics", 624 + ); 625 + 626 + const metrics = [ 627 + { 628 + label: "Peak Position", 629 + value: story.peakRank ? `#${story.peakRank}` : "N/A", 630 + description: "Best position achieved", 631 + icon: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--hn-orange)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 632 + <polyline points="22 12 18 12 15 21 9 3 6 12 2 12"></polyline> 633 + </svg>`, 634 + }, 635 + { 636 + label: "Peak Points", 637 + value: story.peakPoints || story.points || "N/A", 638 + description: "Maximum points received", 639 + icon: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--hn-orange)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 640 + <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon> 641 + </svg>`, 642 + }, 643 + { 644 + label: "Time on Front Page", 645 + value: story.timeOnFrontPage 646 + ? formatDuration(story.timeOnFrontPage) 647 + : "N/A", 648 + description: "Total time tracked", 649 + icon: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--hn-orange)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 650 + <circle cx="12" cy="12" r="10"></circle> 651 + <polyline points="12 6 12 12 16 14"></polyline> 652 + </svg>`, 653 + }, 654 + { 655 + label: "Comments", 656 + value: story.comments || "N/A", 657 + description: "Discussion count", 658 + icon: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--hn-orange)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 659 + <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path> 660 + </svg>`, 661 + }, 662 + ]; 663 + 664 + let html = ""; 665 + metrics.forEach((metric) => { 666 + html += ` 667 + <div class="performance-metric"> 668 + ${metric.icon} 669 + <div class="metric-label">${metric.label}</div> 670 + <div class="metric-value">${metric.value}</div> 671 + <div class="metric-description">${metric.description}</div> 672 + </div> 673 + `; 674 + }); 675 + 676 + container.innerHTML = html; 677 + } 678 + 679 + // Comment link function removed as we now show comments in metrics 680 + 681 + function loadStoryGraph(storyId) { 682 + const noGraph = document.getElementById("no-graph"); 683 + const rankChart = document.getElementById("rank-chart"); 684 + 685 + noGraph.style.display = "flex"; 686 + rankChart.style.display = "none"; 687 + 688 + const options = { 689 + headers: {}, 690 + }; 691 + 692 + // Add If-None-Match header if we have an ETag for this story 693 + if (snapshotEtagCache[storyId]) { 694 + options.headers["If-None-Match"] = 695 + snapshotEtagCache[storyId]; 696 + } 697 + 698 + fetch(`/api/story/${storyId}/snapshots`, options) 699 + .then((response) => { 700 + // Store the new ETag if available 701 + const etag = response.headers.get("ETag"); 702 + if (etag) snapshotEtagCache[storyId] = etag; 703 + 704 + if (!response.ok) { 705 + // Allow 304 Not Modified 706 + if (response.status === 304) { 707 + console.log( 708 + `Story ${storyId} snapshots not modified, using cached data`, 709 + ); 710 + // Use the cached data for this story ID 711 + if (snapshotDataCache[storyId]) { 712 + return snapshotDataCache[storyId]; 713 + } 714 + // If we don't have cached data, re-fetch 715 + throw new Error( 716 + "Cache miss on 304, re-fetching", 717 + ); 718 + } 719 + throw new Error("Failed to fetch snapshot data"); 720 + } 721 + return response.json(); 722 + }) 723 + .then((snapshots) => { 724 + if (!snapshots || snapshots.length === 0) { 725 + noGraph.innerHTML = ` 726 + <span class="section-icon">📊</span> 727 + <p>No historical data available for this story yet.</p> 728 + `; 729 + return; 730 + } 731 + 732 + // Cache the snapshots data for future use 733 + snapshotDataCache[storyId] = snapshots; 734 + 735 + // Show the chart 736 + noGraph.style.display = "none"; 737 + rankChart.style.display = "block"; 738 + 739 + displayGraph(snapshots); 740 + }) 741 + .catch((error) => { 742 + console.error("Error fetching snapshots:", error); 743 + noGraph.innerHTML = ` 744 + <span class="section-icon">⚠️</span> 745 + <p class="error-message">Error loading graph: ${error.message}</p> 746 + `; 747 + }); 748 + } 749 + 750 + function displayGraph(snapshots) { 751 + // Extract data points 752 + const positions = snapshots.map((s) => s.position); 753 + const scores = snapshots.map((s) => s.score); 754 + const timestamps = snapshots.map( 755 + (s) => new Date(s.timestamp * 1000), 756 + ); 757 + 758 + // Format labels as times 759 + const timeLabels = timestamps.map((ts) => { 760 + const hours = ts.getHours().toString().padStart(2, "0"); 761 + const minutes = ts.getMinutes().toString().padStart(2, "0"); 762 + return `${hours}:${minutes}`; 763 + }); 764 + 765 + // Calculate y-axis scaling for position 766 + // Lower position numbers (like #1) are better, so we need to invert the scale 767 + const maxPosition = Math.max(...positions); 768 + 769 + // Create the chart 770 + const ctx = document 771 + .getElementById("rank-chart") 772 + .getContext("2d"); 773 + 774 + const chart = new Chart(ctx, { 775 + type: "line", 776 + data: { 777 + labels: timeLabels, 778 + datasets: [ 779 + { 780 + label: "Position", 781 + data: positions, 782 + borderColor: "rgb(255, 102, 0)", 783 + backgroundColor: "rgba(255, 102, 0, 0.1)", 784 + fill: false, 785 + tension: 0.1, 786 + pointRadius: 3, 787 + pointHoverRadius: 5, 788 + yAxisID: "y-position", 789 + }, 790 + { 791 + label: "Score", 792 + data: scores, 793 + borderColor: "rgb(102, 187, 106)", 794 + backgroundColor: "rgba(102, 187, 106, 0.1)", 795 + fill: false, 796 + tension: 0.1, 797 + pointRadius: 3, 798 + pointHoverRadius: 5, 799 + yAxisID: "y-score", 800 + }, 801 + ], 802 + }, 803 + options: { 804 + responsive: true, 805 + maintainAspectRatio: false, 806 + interaction: { 807 + mode: "index", 808 + intersect: false, 809 + }, 810 + scales: { 811 + x: { 812 + title: { 813 + display: true, 814 + text: "Time", 815 + }, 816 + }, 817 + "y-position": { 818 + type: "linear", 819 + display: true, 820 + position: "left", 821 + title: { 822 + display: true, 823 + text: "Position (#1 is Best)", 824 + color: "rgb(255, 102, 0)", 825 + }, 826 + reverse: true, // Lower values (#1) at top 827 + min: 1, 828 + max: Math.min(Math.max(...positions) + 2, 30), 829 + ticks: { 830 + callback: function (value) { 831 + return "#" + value; 832 + }, 833 + color: "rgb(255, 102, 0)", 834 + }, 835 + grid: { 836 + color: "rgba(255, 102, 0, 0.1)", 837 + }, 838 + }, 839 + "y-score": { 840 + type: "linear", 841 + display: true, 842 + position: "right", 843 + title: { 844 + display: true, 845 + text: "Points", 846 + color: "rgb(102, 187, 106)", 847 + }, 848 + min: 0, 849 + suggestedMax: Math.max(...scores) * 1.1, 850 + ticks: { 851 + color: "rgb(102, 187, 106)", 852 + }, 853 + grid: { 854 + drawOnChartArea: false, 855 + color: "rgba(102, 187, 106, 0.1)", 856 + }, 857 + }, 858 + }, 859 + plugins: { 860 + tooltip: { 861 + callbacks: { 862 + title: function (tooltipItems) { 863 + const index = tooltipItems[0].dataIndex; 864 + return timestamps[ 865 + index 866 + ].toLocaleString(); 867 + }, 868 + label: function (context) { 869 + let label = context.dataset.label || ""; 870 + if (label) { 871 + label += ": "; 872 + } 873 + if (label === "Position: ") { 874 + return label + "#" + context.raw; 875 + } 876 + if (label === "Score: ") { 877 + return ( 878 + label + context.raw + " points" 879 + ); 880 + } 881 + return label + context.raw; 882 + }, 883 + }, 884 + }, 885 + legend: { 886 + position: "top", 887 + labels: { 888 + usePointStyle: true, 889 + boxWidth: 10, 890 + padding: 20, 891 + }, 892 + }, 893 + }, 894 + }, 895 + }); 896 + } 897 + 898 + function formatDuration(seconds) { 899 + if (!seconds) return "N/A"; 900 + 901 + const hours = Math.floor(seconds / 3600); 902 + const minutes = Math.floor((seconds % 3600) / 60); 903 + 904 + if (hours > 0) { 905 + return `${hours}h ${minutes}m`; 906 + } else { 907 + return `${minutes}m`; 908 + } 909 + } 910 + 911 + function formatDate(timestamp) { 912 + const date = new Date(timestamp); 913 + return ( 914 + date.toLocaleDateString() + " " + date.toLocaleTimeString() 915 + ); 916 + } 917 + 918 + function escapeHTML(str) { 919 + if (!str) return ""; 920 + return str 921 + .replace(/&/g, "&amp;") 922 + .replace(/</g, "&lt;") 923 + .replace(/>/g, "&gt;") 924 + .replace(/"/g, "&quot;") 925 + .replace(/'/g, "&#039;"); 926 + } 927 + </script> 928 + </body> 929 + </html>
+105
src/index.ts
··· 12 12 } from "./libs/cache"; 13 13 import { handleCORS } from "./libs/cors"; 14 14 import root from "../public/index.html"; 15 + import item from "../public/item.html"; 15 16 import { count } from "drizzle-orm"; 16 17 import { stories } from "./libs/schema"; 17 18 ··· 95 96 maxRequestBodySize: 1024 * 1024, 96 97 routes: { 97 98 "/": root, 99 + "/item": item, 98 100 // Apply CORS to all API routes 101 + "/api/story/:id": handleCORS(async (req) => { 102 + try { 103 + // Extract the story ID from the URL path 104 + const url = new URL(req.url); 105 + const pathParts = url.pathname.split("/"); 106 + const storyIdStr = pathParts[3]; // Get ID from path parts 107 + const storyId = storyIdStr 108 + ? Number.parseInt(storyIdStr, 10) 109 + : Number.NaN; 110 + 111 + if (Number.isNaN(storyId) || storyId <= 0) { 112 + return new Response(JSON.stringify({ error: "Invalid story ID" }), { 113 + status: 400, 114 + headers: { "Content-Type": "application/json" }, 115 + }); 116 + } 117 + 118 + // Create a cache key for the story 119 + const cacheKey = `story_${storyId}`; 120 + 121 + // Function to fetch the story data 122 + const queryFn = async () => { 123 + const story = await db.query.stories.findFirst({ 124 + columns: { 125 + id: true, 126 + title: true, 127 + url: true, 128 + position: true, 129 + peakPosition: true, 130 + score: true, 131 + peakScore: true, 132 + descendants: true, 133 + by: true, 134 + enteredLeaderboardAt: true, 135 + firstSeenAt: true, 136 + lastSeenOnLeaderboardAt: true, 137 + isFromMonitoredUser: true, 138 + }, 139 + where: (stories, { eq }) => eq(stories.id, storyId), 140 + }); 141 + 142 + if (!story) { 143 + return null; 144 + } 145 + 146 + // Calculate time on front page if available 147 + let timeOnFrontPage = null; 148 + if (story.enteredLeaderboardAt && story.lastSeenOnLeaderboardAt) { 149 + timeOnFrontPage = 150 + story.lastSeenOnLeaderboardAt - story.enteredLeaderboardAt; 151 + } 152 + 153 + // Format the response 154 + return { 155 + id: story.id, 156 + title: story.title, 157 + url: 158 + story.url || `https://news.ycombinator.com/item?id=${story.id}`, 159 + rank: story.position, 160 + peakRank: story.peakPosition, 161 + points: story.score, 162 + peakPoints: story.peakScore, 163 + comments: story.descendants, 164 + timestamp: (story.enteredLeaderboardAt || story.firstSeenAt) * 1000, 165 + by: story.by, 166 + isFromMonitoredUser: story.isFromMonitoredUser, 167 + timeOnFrontPage: timeOnFrontPage, 168 + }; 169 + }; 170 + 171 + // Register this dynamic query for potential cache warming 172 + queryCache.register(cacheKey, queryFn, 600); 173 + 174 + // Execute the query with caching 175 + const data = await queryCache.get(cacheKey, queryFn, 600); 176 + 177 + if (!data) { 178 + return new Response(JSON.stringify({ error: "Story not found" }), { 179 + status: 404, 180 + headers: { "Content-Type": "application/json" }, 181 + }); 182 + } 183 + 184 + // Create response with cached headers 185 + const headers = createCacheHeaders(cacheKey, 600); 186 + const response = new Response(JSON.stringify(data), { headers }); 187 + 188 + return compressResponse(req, response); 189 + } catch (error) { 190 + if (!isProduction) { 191 + console.error("Failed to fetch story:", error); 192 + } 193 + Sentry.captureException(error); 194 + 195 + return new Response( 196 + JSON.stringify({ error: "Failed to fetch story" }), 197 + { 198 + status: 500, 199 + headers: { "Content-Type": "application/json" }, 200 + }, 201 + ); 202 + } 203 + }), 99 204 "/api/stories": handleCORS( 100 205 createCachedEndpoint( 101 206 "leaderboard_stories",