this repo has no description
3
fork

Configure Feed

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

feat: add graphs / dashboard

+544 -15
+1
package.json
··· 7 7 "private": true, 8 8 "scripts": { 9 9 "dev": "bun run --watch src/index.ts", 10 + "start": "bun run src/index.ts", 10 11 "ngrok": "ngrok http 3000 --domain=casual-renewing-reptile.ngrok-free.app", 11 12 "db:generate": "drizzle-kit generate", 12 13 "db:migrate": "drizzle-kit migrate",
+260
public/app.js
··· 1 + document.addEventListener("DOMContentLoaded", () => { 2 + // Elements 3 + const storyList = document.getElementById("story-list"); 4 + const totalAlertsEl = document.getElementById("total-alerts"); 5 + const uniqueStoriesEl = document.getElementById("unique-stories"); 6 + const highestRankEl = document.getElementById("highest-rank"); 7 + const refreshButton = document.getElementById("refresh-data"); 8 + const graphContainer = document.getElementById("graph-container"); 9 + const noGraph = document.getElementById("no-graph"); 10 + const rankChart = document.getElementById("rank-chart"); 11 + 12 + // Chart instance 13 + let chart = null; 14 + let activeStoryId = null; 15 + 16 + // Fetch stories data 17 + function fetchStories() { 18 + storyList.innerHTML = '<div class="loading">Loading stories...</div>'; 19 + 20 + fetch("/api/alerts") 21 + .then((response) => { 22 + if (!response.ok) { 23 + throw new Error("Network response was not ok"); 24 + } 25 + return response.json(); 26 + }) 27 + .then((data) => { 28 + // Remove duplicate stories (keep the one with best rank) 29 + const uniqueStories = removeDuplicateStories(data); 30 + displayStories(uniqueStories); 31 + updateStats(uniqueStories); 32 + }) 33 + .catch((error) => { 34 + storyList.innerHTML = `<div class="loading">Error loading data: ${error.message}</div>`; 35 + console.error("Error fetching stories:", error); 36 + }); 37 + } 38 + 39 + // Remove duplicate stories based on URL 40 + function removeDuplicateStories(stories) { 41 + const urlMap = new Map(); 42 + 43 + stories.forEach(story => { 44 + const existingStory = urlMap.get(story.url); 45 + 46 + if (!existingStory || story.rank < existingStory.rank) { 47 + urlMap.set(story.url, story); 48 + } 49 + }); 50 + 51 + return Array.from(urlMap.values()); 52 + } 53 + 54 + // Display stories in the UI 55 + function displayStories(stories) { 56 + if (!stories || stories.length === 0) { 57 + storyList.innerHTML = '<div class="loading">No stories found.</div>'; 58 + return; 59 + } 60 + 61 + // Sort stories by rank (lowest/best rank first) 62 + stories.sort((a, b) => a.rank - b.rank); 63 + 64 + let html = ""; 65 + for (const story of stories) { 66 + const date = new Date(story.timestamp).toLocaleString(); 67 + html += ` 68 + <div class="story-item" data-id="${story.id}" data-url="${story.url}"> 69 + <h3>${story.title}</h3> 70 + <p>Best Rank: #${story.rank}</p> 71 + <div class="story-meta"> 72 + <span>Points: ${story.points}</span> 73 + <span>Comments: ${story.comments}</span> 74 + <span>By: ${story.by}</span> 75 + </div> 76 + <div class="story-meta"> 77 + <span>Detected: ${date}</span> 78 + <span><a href="${story.url}" target="_blank" class="external-link">View Story ↗</a></span> 79 + </div> 80 + </div> 81 + `; 82 + } 83 + 84 + storyList.innerHTML = html; 85 + 86 + // Add event listeners to story items 87 + document.querySelectorAll('.story-item').forEach(item => { 88 + item.addEventListener('click', (e) => { 89 + // Prevent triggering when clicking links 90 + if (e.target.classList.contains('external-link') || e.target.closest('.external-link')) { 91 + return; 92 + } 93 + 94 + const storyId = item.dataset.id; 95 + loadStoryGraph(storyId); 96 + 97 + // Mark as active 98 + document.querySelectorAll('.story-item').forEach(i => i.classList.remove('active')); 99 + item.classList.add('active'); 100 + }); 101 + }); 102 + } 103 + 104 + // Load story snapshots and display graph 105 + function loadStoryGraph(storyId) { 106 + if (activeStoryId === storyId) return; 107 + activeStoryId = storyId; 108 + 109 + noGraph.style.display = 'flex'; 110 + rankChart.style.display = 'none'; 111 + noGraph.innerHTML = '<div class="loading">Loading graph data...</div>'; 112 + 113 + fetch(`/api/story/${storyId}/snapshots`) 114 + .then(response => { 115 + if (!response.ok) { 116 + throw new Error("Failed to fetch snapshot data"); 117 + } 118 + return response.json(); 119 + }) 120 + .then(snapshots => { 121 + if (!snapshots || snapshots.length === 0) { 122 + noGraph.innerHTML = '<p>No historical data available for this story.</p>'; 123 + return; 124 + } 125 + 126 + displayGraph(snapshots); 127 + }) 128 + .catch(error => { 129 + console.error("Error fetching snapshots:", error); 130 + noGraph.innerHTML = `<p>Error loading graph: ${error.message}</p>`; 131 + }); 132 + } 133 + 134 + // Display the rank history graph 135 + function displayGraph(snapshots) { 136 + noGraph.style.display = 'none'; 137 + rankChart.style.display = 'block'; 138 + 139 + // Prepare data for Chart.js 140 + const timestamps = snapshots.map(snap => snap.date); 141 + const positionData = snapshots.map(snap => snap.position); 142 + const scoreData = snapshots.map(snap => snap.score); 143 + 144 + // Create data points that Chart.js can use without time adapter 145 + const positionDataPoints = timestamps.map((t, i) => ({ 146 + x: new Date(t).getTime(), 147 + y: positionData[i] 148 + })); 149 + 150 + const scoreDataPoints = timestamps.map((t, i) => ({ 151 + x: new Date(t).getTime(), 152 + y: scoreData[i] 153 + })); 154 + 155 + // Destroy existing chart if it exists 156 + if (chart) { 157 + chart.destroy(); 158 + } 159 + 160 + // Create new chart 161 + chart = new Chart(rankChart, { 162 + type: 'line', 163 + data: { 164 + datasets: [ 165 + { 166 + label: 'Rank (Position)', 167 + data: positionDataPoints, 168 + borderColor: '#ff6600', 169 + backgroundColor: 'rgba(255, 102, 0, 0.1)', 170 + tension: 0.1, 171 + yAxisID: 'y' 172 + }, 173 + { 174 + label: 'Score (Points)', 175 + data: scoreDataPoints, 176 + borderColor: '#3b82f6', 177 + backgroundColor: 'rgba(59, 130, 246, 0.1)', 178 + tension: 0.1, 179 + yAxisID: 'y1' 180 + } 181 + ] 182 + }, 183 + options: { 184 + responsive: true, 185 + maintainAspectRatio: false, 186 + scales: { 187 + x: { 188 + type: 'linear', 189 + position: 'bottom', 190 + title: { 191 + display: true, 192 + text: 'Time' 193 + }, 194 + ticks: { 195 + callback: function(value) { 196 + return new Date(value).toLocaleString(); 197 + } 198 + } 199 + }, 200 + y: { 201 + position: 'left', 202 + reverse: true, // Lower rank numbers (better) should be at the top 203 + title: { 204 + display: true, 205 + text: 'Rank' 206 + }, 207 + grid: { 208 + display: true 209 + } 210 + }, 211 + y1: { 212 + position: 'right', 213 + title: { 214 + display: true, 215 + text: 'Points' 216 + }, 217 + grid: { 218 + display: false 219 + } 220 + } 221 + }, 222 + plugins: { 223 + tooltip: { 224 + callbacks: { 225 + title: function(context) { 226 + return new Date(context[0].parsed.x).toLocaleString(); 227 + } 228 + } 229 + } 230 + } 231 + } 232 + }); 233 + } 234 + 235 + // Update statistics 236 + function updateStats(stories) { 237 + if (!stories || stories.length === 0) { 238 + totalAlertsEl.textContent = "0"; 239 + uniqueStoriesEl.textContent = "0"; 240 + highestRankEl.textContent = "N/A"; 241 + return; 242 + } 243 + 244 + // Total stories 245 + totalAlertsEl.textContent = stories.length; 246 + 247 + // Unique stories (should be same as total since we've already de-duped) 248 + uniqueStoriesEl.textContent = stories.length; 249 + 250 + // Best (lowest) rank 251 + const bestRank = Math.min(...stories.map((s) => s.rank)); 252 + highestRankEl.textContent = bestRank; 253 + } 254 + 255 + // Event listeners 256 + refreshButton.addEventListener("click", fetchStories); 257 + 258 + // Initial data fetch 259 + fetchStories(); 260 + });
+194
public/index.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 + <title>HN Alerts Dashboard</title> 7 + <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/water.css@2/out/water.css"> 8 + <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.3/dist/chart.umd.min.js"></script> 9 + <style> 10 + :root { 11 + --bg-color: #ffffff; 12 + --bg-secondary: #f5f5f5; 13 + --text-color: #212121; 14 + --text-secondary: #555; 15 + --hn-orange: #ff6600; 16 + --hn-orange-hover: #e05d00; 17 + --border-color: #ddd; 18 + } 19 + 20 + @media (prefers-color-scheme: dark) { 21 + :root { 22 + --bg-color: #1a1a1a; 23 + --bg-secondary: #2a2a2a; 24 + --text-color: #f0f0f0; 25 + --text-secondary: #aaa; 26 + --border-color: #444; 27 + } 28 + } 29 + 30 + body { 31 + max-width: 1200px; 32 + margin: 0 auto; 33 + padding: 2rem; 34 + background-color: var(--bg-color); 35 + color: var(--text-color); 36 + } 37 + 38 + .header { 39 + display: flex; 40 + align-items: center; 41 + justify-content: space-between; 42 + margin-bottom: 2rem; 43 + } 44 + 45 + .header img { 46 + height: 60px; 47 + } 48 + 49 + .main-container { 50 + display: flex; 51 + gap: 2rem; 52 + } 53 + 54 + .story-list { 55 + flex: 1; 56 + max-width: 600px; 57 + margin-top: 1rem; 58 + } 59 + 60 + .graph-container { 61 + flex: 1; 62 + background-color: var(--bg-secondary); 63 + border-radius: 5px; 64 + padding: 1rem; 65 + height: 500px; 66 + position: sticky; 67 + top: 1rem; 68 + } 69 + 70 + .story-item { 71 + background-color: var(--bg-secondary); 72 + border-radius: 5px; 73 + padding: 1rem; 74 + margin-bottom: 1rem; 75 + border-left: 4px solid var(--hn-orange); 76 + cursor: pointer; 77 + transition: transform 0.1s ease; 78 + } 79 + 80 + .story-item:hover { 81 + transform: translateX(4px); 82 + } 83 + 84 + .story-item.active { 85 + border-left-width: 8px; 86 + font-weight: bold; 87 + } 88 + 89 + .story-item h3 { 90 + margin-top: 0; 91 + } 92 + 93 + .story-meta { 94 + font-size: 0.9rem; 95 + color: var(--text-secondary); 96 + display: flex; 97 + justify-content: space-between; 98 + } 99 + 100 + .loading { 101 + text-align: center; 102 + padding: 2rem; 103 + color: var(--text-secondary); 104 + } 105 + 106 + .stats { 107 + display: flex; 108 + flex-wrap: wrap; 109 + gap: 1rem; 110 + margin-bottom: 2rem; 111 + } 112 + 113 + .stat-card { 114 + flex: 1; 115 + min-width: 200px; 116 + padding: 1rem; 117 + background-color: var(--bg-secondary); 118 + border-radius: 5px; 119 + text-align: center; 120 + } 121 + 122 + .stat-number { 123 + font-size: 2rem; 124 + font-weight: bold; 125 + color: var(--hn-orange); 126 + } 127 + 128 + .refresh-button { 129 + background-color: var(--hn-orange); 130 + color: white; 131 + border: none; 132 + padding: 0.5rem 1rem; 133 + border-radius: 5px; 134 + cursor: pointer; 135 + } 136 + 137 + .refresh-button:hover { 138 + background-color: var(--hn-orange-hover); 139 + } 140 + 141 + .no-graph { 142 + display: flex; 143 + height: 100%; 144 + align-items: center; 145 + justify-content: center; 146 + color: var(--text-secondary); 147 + text-align: center; 148 + } 149 + </style> 150 + </head> 151 + <body> 152 + <div class="header"> 153 + <div> 154 + <h1>Hacker News Alerts Dashboard</h1> 155 + <p>Monitor your HN front page appearances</p> 156 + </div> 157 + <img src="https://cachet.dunkirk.sh/emojis/ycombinator/r" alt="HN Logo"> 158 + </div> 159 + 160 + <div class="stats"> 161 + <div class="stat-card"> 162 + <div class="stat-number" id="total-alerts">-</div> 163 + <div>Total Alerts</div> 164 + </div> 165 + <div class="stat-card"> 166 + <div class="stat-number" id="unique-stories">-</div> 167 + <div>Unique Stories</div> 168 + </div> 169 + <div class="stat-card"> 170 + <div class="stat-number" id="highest-rank">-</div> 171 + <div>Best Rank</div> 172 + </div> 173 + </div> 174 + 175 + <div> 176 + <button class="refresh-button" id="refresh-data">Refresh Data</button> 177 + </div> 178 + 179 + <div class="main-container"> 180 + <div class="story-list" id="story-list"> 181 + <div class="loading">Loading stories...</div> 182 + </div> 183 + 184 + <div class="graph-container" id="graph-container"> 185 + <div class="no-graph" id="no-graph"> 186 + <p>Click on a story to see its rank history on the Hacker News front page.</p> 187 + </div> 188 + <canvas id="rank-chart" style="display: none;"></canvas> 189 + </div> 190 + </div> 191 + 192 + <script src="app.js"></script> 193 + </body> 194 + </html>
+89 -15
src/index.ts
··· 3 3 import setup from "./features"; 4 4 import { db } from "./libs/db"; 5 5 import { version, name } from "../package.json"; 6 + import root from "../public/index.html" 7 + 6 8 const environment = process.env.NODE_ENV; 7 9 const commit = (() => { 8 10 try { ··· 57 59 58 60 await setup(); 59 61 60 - export default { 62 + const server = Bun.serve({ 61 63 port: process.env.PORT || 3000, 62 - async fetch(request: Request) { 63 - const url = new URL(request.url); 64 - const path = url.pathname; 64 + reusePort: true, 65 + routes: { 66 + "/": root, 67 + "/api/alerts": async () => { 68 + try { 69 + // Get stories that reached the front page (leaderboard) 70 + const storyAlerts = await db.query.stories.findMany({ 71 + where: (stories, { eq }) => eq(stories.isOnLeaderboard, true), 72 + orderBy: (stories, { desc }) => [desc(stories.enteredLeaderboardAt)], 73 + limit: 100, 74 + }); 65 75 66 - switch (path) { 67 - case "/": 68 - return new Response(`Hello World from ${name}@${version}@${commit}`); 69 - case "/health": 70 - return new Response("OK"); 71 - case "/slack": 72 - return slackApp.run(request); 73 - default: 74 - return new Response("404 Not Found", { status: 404 }); 75 - } 76 + // Transform story data to match the format expected by the frontend 77 + const alerts = storyAlerts.map((story) => ({ 78 + id: story.id, 79 + title: story.title, 80 + url: story.url || `https://news.ycombinator.com/item?id=${story.id}`, 81 + rank: story.peakPosition || 30, // Default to 30 if no position recorded 82 + points: story.peakScore || story.score, 83 + comments: story.descendants, 84 + timestamp: story.enteredLeaderboardAt 85 + ? new Date(story.enteredLeaderboardAt * 1000).toISOString() 86 + : new Date(story.firstSeenAt * 1000).toISOString(), 87 + by: story.by, 88 + })); 89 + 90 + return new Response(JSON.stringify(alerts), { 91 + headers: { "Content-Type": "application/json" }, 92 + }); 93 + } catch (error) { 94 + console.error("Failed to fetch alerts:", error); 95 + return new Response( 96 + JSON.stringify({ error: "Failed to fetch alerts" }), 97 + { 98 + status: 500, 99 + headers: { "Content-Type": "application/json" }, 100 + }, 101 + ); 102 + } 103 + }, 104 + "/api/story/:id/snapshots": async (req) => { 105 + try { 106 + const storyId = parseInt(req.params.id as string); 107 + if (isNaN(storyId)) { 108 + return new Response( 109 + JSON.stringify({ error: "Invalid story ID" }), 110 + { 111 + status: 400, 112 + headers: { "Content-Type": "application/json" }, 113 + } 114 + ); 115 + } 116 + 117 + // Get snapshots for the story 118 + const snapshots = await db.query.leaderboardSnapshots.findMany({ 119 + where: (snapshots, { eq }) => eq(snapshots.storyId, storyId), 120 + orderBy: (snapshots, { asc }) => [asc(snapshots.timestamp)], 121 + }); 122 + 123 + // Transform snapshot data for frontend 124 + const graphData = snapshots.map(snapshot => ({ 125 + timestamp: snapshot.timestamp, 126 + position: snapshot.position, 127 + score: snapshot.score, 128 + date: new Date(snapshot.timestamp * 1000).toISOString(), 129 + })); 130 + 131 + return new Response(JSON.stringify(graphData), { 132 + headers: { "Content-Type": "application/json" }, 133 + }); 134 + } catch (error) { 135 + console.error(`Failed to fetch snapshots for story:`, error); 136 + return new Response( 137 + JSON.stringify({ error: "Failed to fetch snapshots" }), 138 + { 139 + status: 500, 140 + headers: { "Content-Type": "application/json" }, 141 + } 142 + ); 143 + } 144 + }, 145 + "/health": () => { 146 + return new Response(JSON.stringify({ status: "ok" }), { 147 + headers: { "Content-Type": "application/json" }, 148 + }); 149 + }, 76 150 }, 77 - }; 151 + }); 78 152 79 153 console.log( 80 154 `🚀 Server Started in ${