image cache on cloudflare r2

chore: biome format

dunkirk.sh 736f1576 298de486

verified
+24 -24
package.json
··· 1 1 { 2 - "name": "l4", 3 - "module": "src/index.ts", 4 - "type": "module", 5 - "private": true, 6 - "scripts": { 7 - "dev": "bun run --watch src/index.ts" 8 - }, 9 - "devDependencies": { 10 - "@types/bun": "latest", 11 - "@types/react": "^19.2.7", 12 - "@types/react-dom": "^19.2.3" 13 - }, 14 - "peerDependencies": { 15 - "typescript": "^5" 16 - }, 17 - "dependencies": { 18 - "bun-sqlite-migrations": "^1.0.2", 19 - "lit": "^3.3.1", 20 - "nanoid": "^5.1.6", 21 - "react": "^19.2.3", 22 - "react-dom": "^19.2.3", 23 - "sharp": "^0.34.5", 24 - "uplot": "^1.6.32" 25 - } 2 + "name": "l4", 3 + "module": "src/index.ts", 4 + "type": "module", 5 + "private": true, 6 + "scripts": { 7 + "dev": "bun run --watch src/index.ts" 8 + }, 9 + "devDependencies": { 10 + "@types/bun": "latest", 11 + "@types/react": "^19.2.7", 12 + "@types/react-dom": "^19.2.3" 13 + }, 14 + "peerDependencies": { 15 + "typescript": "^5" 16 + }, 17 + "dependencies": { 18 + "bun-sqlite-migrations": "^1.0.2", 19 + "lit": "^3.3.1", 20 + "nanoid": "^5.1.6", 21 + "react": "^19.2.3", 22 + "react-dom": "^19.2.3", 23 + "sharp": "^0.34.5", 24 + "uplot": "^1.6.32" 25 + } 26 26 }
+198 -165
scripts/seed-data.ts
··· 5 5 const db = new Database(DB_PATH, { create: true }); 6 6 7 7 // Generate realistic fake data 8 - const imageKeys: string[] = []; 8 + const _imageKeys: string[] = []; 9 9 const numImages = 500; // More images for variety 10 10 11 11 // Generate fake image keys with varying introduction dates 12 12 const now = Math.floor(Date.now() / 1000); 13 13 const oneYearAgo = now - 365 * 86400; 14 - const thirtyDaysAgo = now - 30 * 86400; 14 + const _thirtyDaysAgo = now - 30 * 86400; 15 15 const oneDayAgo = now - 86400; 16 16 17 17 interface Image { 18 - key: string; 19 - introducedAt: number; // When this image first appeared 20 - basePopularity: number; // Intrinsic popularity (0-1) 21 - trendFactor: number; // How much popularity changes over time (-0.5 to 0.5) 22 - viralPeak?: number; // Optional viral spike timestamp 18 + key: string; 19 + introducedAt: number; // When this image first appeared 20 + basePopularity: number; // Intrinsic popularity (0-1) 21 + trendFactor: number; // How much popularity changes over time (-0.5 to 0.5) 22 + viralPeak?: number; // Optional viral spike timestamp 23 23 } 24 24 25 25 const images: Image[] = []; 26 26 27 27 // Create images with staggered introduction dates 28 28 for (let i = 0; i < numImages; i++) { 29 - const introDate = oneYearAgo + Math.random() * (now - oneYearAgo); 30 - const basePopularity = Math.random() ** 1.5; // Skew toward lower popularity 31 - const trendFactor = (Math.random() - 0.5) * 0.8; // -0.4 to 0.4 32 - 33 - const image: Image = { 34 - key: `${nanoid(12)}.webp`, 35 - introducedAt: introDate, 36 - basePopularity, 37 - trendFactor, 38 - }; 39 - 40 - // 10% chance of having a viral spike 41 - if (Math.random() < 0.1) { 42 - image.viralPeak = introDate + Math.random() * (now - introDate); 43 - } 44 - 45 - images.push(image); 29 + const introDate = oneYearAgo + Math.random() * (now - oneYearAgo); 30 + const basePopularity = Math.random() ** 1.5; // Skew toward lower popularity 31 + const trendFactor = (Math.random() - 0.5) * 0.8; // -0.4 to 0.4 32 + 33 + const image: Image = { 34 + key: `${nanoid(12)}.webp`, 35 + introducedAt: introDate, 36 + basePopularity, 37 + trendFactor, 38 + }; 39 + 40 + // 10% chance of having a viral spike 41 + if (Math.random() < 0.1) { 42 + image.viralPeak = introDate + Math.random() * (now - introDate); 43 + } 44 + 45 + images.push(image); 46 46 } 47 47 48 48 console.log("Seeding database with fake data (1 year)..."); ··· 61 61 `); 62 62 63 63 console.log("Seeding hourly and daily data (1 year ago to 24 hours ago)..."); 64 - let totalHourlyHits = 0; 65 - let totalDailyHits = 0; 64 + let _totalHourlyHits = 0; 65 + let _totalDailyHits = 0; 66 66 67 67 for (let timestamp = oneYearAgo; timestamp < oneDayAgo; timestamp += 3600) { 68 - const bucketHour = timestamp - (timestamp % 3600); 69 - const bucketDay = timestamp - (timestamp % 86400); 70 - 71 - // Time-based factors 72 - const date = new Date(timestamp * 1000); 73 - const dayOfWeek = date.getUTCDay(); 74 - const hour = date.getUTCHours(); 75 - const month = date.getUTCMonth(); 76 - 77 - // Weekly pattern (weekdays busier) 78 - const isWeekend = dayOfWeek === 0 || dayOfWeek === 6; 79 - const weekdayMultiplier = isWeekend ? 0.6 : 1.0; 80 - 81 - // Daily pattern (business hours busier, with some randomness) 82 - const isBusinessHours = hour >= 9 && hour <= 17; 83 - const hourMultiplier = isBusinessHours 84 - ? 1.2 + Math.random() * 0.4 // 1.2-1.6 85 - : 0.4 + Math.random() * 0.3; // 0.4-0.7 86 - 87 - // Seasonal pattern (summer busier) 88 - const isSummer = month >= 5 && month <= 8; // June-Sept 89 - const seasonalMultiplier = isSummer ? 1.3 : 0.9; 90 - 91 - // Overall growth trend (traffic increases over time) 92 - const timeProgress = (timestamp - oneYearAgo) / (now - oneYearAgo); 93 - const growthMultiplier = 0.7 + (timeProgress * 0.6); // 0.7 to 1.3 94 - 95 - // Random noise to break perfect cycles 96 - const noiseMultiplier = 0.85 + Math.random() * 0.3; // 0.85-1.15 97 - 98 - const baseActivity = 0.25 * weekdayMultiplier * hourMultiplier * seasonalMultiplier * growthMultiplier * noiseMultiplier; 99 - 100 - for (const image of images) { 101 - // Skip if image doesn't exist yet 102 - if (timestamp < image.introducedAt) continue; 103 - 104 - // Calculate image-specific popularity at this time 105 - const timeSinceIntro = timestamp - image.introducedAt; 106 - const ageInDays = timeSinceIntro / 86400; 107 - 108 - // Popularity changes over time (trend factor) 109 - const trendProgress = Math.min(ageInDays / 180, 1); // Over 6 months 110 - const trendedPopularity = image.basePopularity + (image.trendFactor * trendProgress); 111 - 112 - // Viral spike (if any) 113 - let viralBoost = 1; 114 - if (image.viralPeak) { 115 - const distanceFromPeak = Math.abs(timestamp - image.viralPeak); 116 - const peakWindow = 7 * 86400; // 7 day spike 117 - if (distanceFromPeak < peakWindow) { 118 - viralBoost = 1 + (5 * (1 - distanceFromPeak / peakWindow)); // Up to 6x boost 119 - } 120 - } 121 - 122 - // New images get a temporary boost 123 - const newImageBoost = ageInDays < 3 ? (1 + (3 - ageInDays) * 0.5) : 1; 124 - 125 - const finalPopularity = trendedPopularity * viralBoost * newImageBoost; 126 - 127 - if (Math.random() < baseActivity * finalPopularity) { 128 - // Power law distribution for hit counts (most hits are small, some are large) 129 - const hits = Math.max(1, Math.floor((Math.random() ** 3) * 200)); 130 - hourlyStmt.run(image.key, bucketHour, hits); 131 - dailyStmt.run(image.key, bucketDay, hits); 132 - totalHourlyHits += hits; 133 - totalDailyHits += hits; 134 - } 135 - } 136 - 137 - // Progress indicator every 30 days 138 - if ((timestamp - oneYearAgo) % (30 * 86400) === 0) { 139 - const daysProcessed = Math.floor((timestamp - oneYearAgo) / 86400); 140 - console.log(` Processed ${daysProcessed} days...`); 141 - } 68 + const bucketHour = timestamp - (timestamp % 3600); 69 + const bucketDay = timestamp - (timestamp % 86400); 70 + 71 + // Time-based factors 72 + const date = new Date(timestamp * 1000); 73 + const dayOfWeek = date.getUTCDay(); 74 + const hour = date.getUTCHours(); 75 + const month = date.getUTCMonth(); 76 + 77 + // Weekly pattern (weekdays busier) 78 + const isWeekend = dayOfWeek === 0 || dayOfWeek === 6; 79 + const weekdayMultiplier = isWeekend ? 0.6 : 1.0; 80 + 81 + // Daily pattern (business hours busier, with some randomness) 82 + const isBusinessHours = hour >= 9 && hour <= 17; 83 + const hourMultiplier = isBusinessHours 84 + ? 1.2 + Math.random() * 0.4 // 1.2-1.6 85 + : 0.4 + Math.random() * 0.3; // 0.4-0.7 86 + 87 + // Seasonal pattern (summer busier) 88 + const isSummer = month >= 5 && month <= 8; // June-Sept 89 + const seasonalMultiplier = isSummer ? 1.3 : 0.9; 90 + 91 + // Overall growth trend (traffic increases over time) 92 + const timeProgress = (timestamp - oneYearAgo) / (now - oneYearAgo); 93 + const growthMultiplier = 0.7 + timeProgress * 0.6; // 0.7 to 1.3 94 + 95 + // Random noise to break perfect cycles 96 + const noiseMultiplier = 0.85 + Math.random() * 0.3; // 0.85-1.15 97 + 98 + const baseActivity = 99 + 0.25 * 100 + weekdayMultiplier * 101 + hourMultiplier * 102 + seasonalMultiplier * 103 + growthMultiplier * 104 + noiseMultiplier; 105 + 106 + for (const image of images) { 107 + // Skip if image doesn't exist yet 108 + if (timestamp < image.introducedAt) continue; 109 + 110 + // Calculate image-specific popularity at this time 111 + const timeSinceIntro = timestamp - image.introducedAt; 112 + const ageInDays = timeSinceIntro / 86400; 113 + 114 + // Popularity changes over time (trend factor) 115 + const trendProgress = Math.min(ageInDays / 180, 1); // Over 6 months 116 + const trendedPopularity = 117 + image.basePopularity + image.trendFactor * trendProgress; 118 + 119 + // Viral spike (if any) 120 + let viralBoost = 1; 121 + if (image.viralPeak) { 122 + const distanceFromPeak = Math.abs(timestamp - image.viralPeak); 123 + const peakWindow = 7 * 86400; // 7 day spike 124 + if (distanceFromPeak < peakWindow) { 125 + viralBoost = 1 + 5 * (1 - distanceFromPeak / peakWindow); // Up to 6x boost 126 + } 127 + } 128 + 129 + // New images get a temporary boost 130 + const newImageBoost = ageInDays < 3 ? 1 + (3 - ageInDays) * 0.5 : 1; 131 + 132 + const finalPopularity = trendedPopularity * viralBoost * newImageBoost; 133 + 134 + if (Math.random() < baseActivity * finalPopularity) { 135 + // Power law distribution for hit counts (most hits are small, some are large) 136 + const hits = Math.max(1, Math.floor(Math.random() ** 3 * 200)); 137 + hourlyStmt.run(image.key, bucketHour, hits); 138 + dailyStmt.run(image.key, bucketDay, hits); 139 + _totalHourlyHits += hits; 140 + _totalDailyHits += hits; 141 + } 142 + } 143 + 144 + // Progress indicator every 30 days 145 + if ((timestamp - oneYearAgo) % (30 * 86400) === 0) { 146 + const daysProcessed = Math.floor((timestamp - oneYearAgo) / 86400); 147 + console.log(` Processed ${daysProcessed} days...`); 148 + } 142 149 } 143 150 144 151 // Seed 10-minute data for last 24 hours ··· 149 156 `); 150 157 151 158 console.log("Seeding 10-minute, hourly, and daily data (last 24 hours)..."); 152 - let total10MinHits = 0; 159 + let _total10MinHits = 0; 153 160 154 161 for (let timestamp = oneDayAgo; timestamp <= now; timestamp += 600) { 155 - const bucket10Min = timestamp - (timestamp % 600); 156 - const bucketHour = timestamp - (timestamp % 3600); 157 - const bucketDay = timestamp - (timestamp % 86400); 158 - 159 - // Recent data gets slightly higher activity 160 - const recency = (timestamp - oneDayAgo) / (now - oneDayAgo); 161 - const date = new Date(timestamp * 1000); 162 - const dayOfWeek = date.getUTCDay(); 163 - const hour = date.getUTCHours(); 164 - 165 - const isWeekend = dayOfWeek === 0 || dayOfWeek === 6; 166 - const weekdayMultiplier = isWeekend ? 0.6 : 1.0; 167 - 168 - const isBusinessHours = hour >= 9 && hour <= 17; 169 - const hourMultiplier = isBusinessHours 170 - ? 1.3 + Math.random() * 0.4 171 - : 0.5 + Math.random() * 0.3; 172 - 173 - const noiseMultiplier = 0.85 + Math.random() * 0.3; 174 - 175 - const baseActivity = 0.35 * (1 + recency * 0.3) * weekdayMultiplier * hourMultiplier * noiseMultiplier; 176 - 177 - for (const image of images) { 178 - if (timestamp < image.introducedAt) continue; 179 - 180 - const timeSinceIntro = timestamp - image.introducedAt; 181 - const ageInDays = timeSinceIntro / 86400; 182 - const trendProgress = Math.min(ageInDays / 180, 1); 183 - const trendedPopularity = image.basePopularity + (image.trendFactor * trendProgress); 184 - 185 - let viralBoost = 1; 186 - if (image.viralPeak) { 187 - const distanceFromPeak = Math.abs(timestamp - image.viralPeak); 188 - const peakWindow = 7 * 86400; 189 - if (distanceFromPeak < peakWindow) { 190 - viralBoost = 1 + (5 * (1 - distanceFromPeak / peakWindow)); 191 - } 192 - } 193 - 194 - const newImageBoost = ageInDays < 3 ? (1 + (3 - ageInDays) * 0.5) : 1; 195 - const finalPopularity = trendedPopularity * viralBoost * newImageBoost; 196 - 197 - if (Math.random() < baseActivity * finalPopularity) { 198 - const hits = Math.max(1, Math.floor((Math.random() ** 3) * 100)); 199 - tenMinStmt.run(image.key, bucket10Min, hits); 200 - hourlyStmt.run(image.key, bucketHour, hits); 201 - dailyStmt.run(image.key, bucketDay, hits); 202 - total10MinHits += hits; 203 - } 204 - } 162 + const bucket10Min = timestamp - (timestamp % 600); 163 + const bucketHour = timestamp - (timestamp % 3600); 164 + const bucketDay = timestamp - (timestamp % 86400); 165 + 166 + // Recent data gets slightly higher activity 167 + const recency = (timestamp - oneDayAgo) / (now - oneDayAgo); 168 + const date = new Date(timestamp * 1000); 169 + const dayOfWeek = date.getUTCDay(); 170 + const hour = date.getUTCHours(); 171 + 172 + const isWeekend = dayOfWeek === 0 || dayOfWeek === 6; 173 + const weekdayMultiplier = isWeekend ? 0.6 : 1.0; 174 + 175 + const isBusinessHours = hour >= 9 && hour <= 17; 176 + const hourMultiplier = isBusinessHours 177 + ? 1.3 + Math.random() * 0.4 178 + : 0.5 + Math.random() * 0.3; 179 + 180 + const noiseMultiplier = 0.85 + Math.random() * 0.3; 181 + 182 + const baseActivity = 183 + 0.35 * 184 + (1 + recency * 0.3) * 185 + weekdayMultiplier * 186 + hourMultiplier * 187 + noiseMultiplier; 188 + 189 + for (const image of images) { 190 + if (timestamp < image.introducedAt) continue; 191 + 192 + const timeSinceIntro = timestamp - image.introducedAt; 193 + const ageInDays = timeSinceIntro / 86400; 194 + const trendProgress = Math.min(ageInDays / 180, 1); 195 + const trendedPopularity = 196 + image.basePopularity + image.trendFactor * trendProgress; 197 + 198 + let viralBoost = 1; 199 + if (image.viralPeak) { 200 + const distanceFromPeak = Math.abs(timestamp - image.viralPeak); 201 + const peakWindow = 7 * 86400; 202 + if (distanceFromPeak < peakWindow) { 203 + viralBoost = 1 + 5 * (1 - distanceFromPeak / peakWindow); 204 + } 205 + } 206 + 207 + const newImageBoost = ageInDays < 3 ? 1 + (3 - ageInDays) * 0.5 : 1; 208 + const finalPopularity = trendedPopularity * viralBoost * newImageBoost; 209 + 210 + if (Math.random() < baseActivity * finalPopularity) { 211 + const hits = Math.max(1, Math.floor(Math.random() ** 3 * 100)); 212 + tenMinStmt.run(image.key, bucket10Min, hits); 213 + hourlyStmt.run(image.key, bucketHour, hits); 214 + dailyStmt.run(image.key, bucketDay, hits); 215 + _total10MinHits += hits; 216 + } 217 + } 205 218 } 206 219 207 220 // Get summary stats 208 - const totalHitsHourly = db.prepare(`SELECT SUM(hits) as total FROM image_stats`).get() as { total: number }; 209 - const totalHitsDaily = db.prepare(`SELECT SUM(hits) as total FROM image_stats_daily`).get() as { total: number }; 210 - const totalHits10Min = db.prepare(`SELECT SUM(hits) as total FROM image_stats_10min`).get() as { total: number }; 211 - const uniqueImages = db.prepare(` 221 + const totalHitsHourly = db 222 + .prepare(`SELECT SUM(hits) as total FROM image_stats`) 223 + .get() as { total: number }; 224 + const totalHitsDaily = db 225 + .prepare(`SELECT SUM(hits) as total FROM image_stats_daily`) 226 + .get() as { total: number }; 227 + const totalHits10Min = db 228 + .prepare(`SELECT SUM(hits) as total FROM image_stats_10min`) 229 + .get() as { total: number }; 230 + const uniqueImages = db 231 + .prepare(` 212 232 SELECT COUNT(DISTINCT image_key) as count FROM ( 213 233 SELECT image_key FROM image_stats 214 234 UNION ··· 216 236 UNION 217 237 SELECT image_key FROM image_stats_daily 218 238 ) 219 - `).get() as { count: number }; 220 - const hourBuckets = db.prepare(`SELECT COUNT(*) as count FROM image_stats`).get() as { count: number }; 221 - const dayBuckets = db.prepare(`SELECT COUNT(*) as count FROM image_stats_daily`).get() as { count: number }; 222 - const tenMinBuckets = db.prepare(`SELECT COUNT(*) as count FROM image_stats_10min`).get() as { count: number }; 223 - const oldestHit = db.prepare(`SELECT MIN(bucket_hour) as min FROM image_stats`).get() as { min: number }; 224 - const newestHit = db.prepare(`SELECT MAX(bucket_hour) as max FROM image_stats`).get() as { max: number }; 239 + `) 240 + .get() as { count: number }; 241 + const hourBuckets = db 242 + .prepare(`SELECT COUNT(*) as count FROM image_stats`) 243 + .get() as { count: number }; 244 + const dayBuckets = db 245 + .prepare(`SELECT COUNT(*) as count FROM image_stats_daily`) 246 + .get() as { count: number }; 247 + const tenMinBuckets = db 248 + .prepare(`SELECT COUNT(*) as count FROM image_stats_10min`) 249 + .get() as { count: number }; 250 + const oldestHit = db 251 + .prepare(`SELECT MIN(bucket_hour) as min FROM image_stats`) 252 + .get() as { min: number }; 253 + const newestHit = db 254 + .prepare(`SELECT MAX(bucket_hour) as max FROM image_stats`) 255 + .get() as { max: number }; 225 256 226 257 console.log("\nSeeding complete!"); 227 258 console.log(`- Total hits (hourly): ${totalHitsHourly.total.toLocaleString()}`); ··· 231 262 console.log(`- Hourly buckets: ${hourBuckets.count.toLocaleString()}`); 232 263 console.log(`- Daily buckets: ${dayBuckets.count.toLocaleString()}`); 233 264 console.log(`- 10-minute buckets: ${tenMinBuckets.count.toLocaleString()}`); 234 - console.log(`- Time range: ${new Date(oldestHit.min * 1000).toISOString()} to ${new Date(newestHit.max * 1000).toISOString()}`); 235 - console.log(`- Days of data: ${Math.floor((newestHit.max - oldestHit.min) / 86400)}`); 236 - 237 - 265 + console.log( 266 + `- Time range: ${new Date(oldestHit.min * 1000).toISOString()} to ${new Date(newestHit.max * 1000).toISOString()}`, 267 + ); 268 + console.log( 269 + `- Days of data: ${Math.floor((newestHit.max - oldestHit.min) / 86400)}`, 270 + );
+140 -139
src/dashboard.css
··· 1 1 :root { 2 - --tropical-teal: #05a8aa; 3 - --celadon: #b8d5b8; 4 - --desert-sand: #d7b49e; 5 - --spicy-paprika: #dc602e; 6 - --tomato-jam: #bc412b; 7 - 8 - --bg-primary: #faf6f3; 9 - --bg-secondary: #fff; 10 - --text-primary: #2d2a26; 11 - --text-secondary: #6b635a; 12 - --border: #e8e0d8; 2 + --tropical-teal: #05a8aa; 3 + --celadon: #b8d5b8; 4 + --desert-sand: #d7b49e; 5 + --spicy-paprika: #dc602e; 6 + --tomato-jam: #bc412b; 7 + 8 + --bg-primary: #faf6f3; 9 + --bg-secondary: #fff; 10 + --text-primary: #2d2a26; 11 + --text-secondary: #6b635a; 12 + --border: #e8e0d8; 13 13 } 14 14 15 15 * { 16 - box-sizing: border-box; 17 - margin: 0; 18 - padding: 0; 16 + box-sizing: border-box; 17 + margin: 0; 18 + padding: 0; 19 19 } 20 20 21 21 body { 22 - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; 23 - background: var(--bg-primary); 24 - color: var(--text-primary); 25 - line-height: 1.5; 22 + font-family: 23 + -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; 24 + background: var(--bg-primary); 25 + color: var(--text-primary); 26 + line-height: 1.5; 26 27 } 27 28 28 29 .dashboard { 29 - max-width: 1200px; 30 - margin: 0 auto; 31 - padding: 24px; 30 + max-width: 1200px; 31 + margin: 0 auto; 32 + padding: 24px; 32 33 } 33 34 34 35 header { 35 - display: flex; 36 - justify-content: space-between; 37 - align-items: center; 38 - margin-bottom: 32px; 39 - padding-bottom: 16px; 40 - border-bottom: 2px solid var(--desert-sand); 36 + display: flex; 37 + justify-content: space-between; 38 + align-items: center; 39 + margin-bottom: 32px; 40 + padding-bottom: 16px; 41 + border-bottom: 2px solid var(--desert-sand); 41 42 } 42 43 43 44 header h1 { 44 - font-size: 1.75rem; 45 - font-weight: 600; 46 - color: var(--tomato-jam); 45 + font-size: 1.75rem; 46 + font-weight: 600; 47 + color: var(--tomato-jam); 47 48 } 48 49 49 50 .time-selector { 50 - display: flex; 51 - gap: 8px; 51 + display: flex; 52 + gap: 8px; 52 53 } 53 54 54 55 .time-selector button { 55 - padding: 8px 16px; 56 - border: 1px solid var(--border); 57 - background: var(--bg-secondary); 58 - border-radius: 6px; 59 - cursor: pointer; 60 - font-size: 0.875rem; 61 - color: var(--text-secondary); 62 - transition: all 0.15s ease; 56 + padding: 8px 16px; 57 + border: 1px solid var(--border); 58 + background: var(--bg-secondary); 59 + border-radius: 6px; 60 + cursor: pointer; 61 + font-size: 0.875rem; 62 + color: var(--text-secondary); 63 + transition: all 0.15s ease; 63 64 } 64 65 65 66 .time-selector button:hover { 66 - border-color: var(--spicy-paprika); 67 - color: var(--spicy-paprika); 67 + border-color: var(--spicy-paprika); 68 + color: var(--spicy-paprika); 68 69 } 69 70 70 71 .time-selector button.active { 71 - background: var(--spicy-paprika); 72 - border-color: var(--spicy-paprika); 73 - color: white; 72 + background: var(--spicy-paprika); 73 + border-color: var(--spicy-paprika); 74 + color: white; 74 75 } 75 76 76 77 .stats-grid { 77 - display: grid; 78 - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); 79 - gap: 16px; 80 - margin-bottom: 32px; 78 + display: grid; 79 + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); 80 + gap: 16px; 81 + margin-bottom: 32px; 81 82 } 82 83 83 84 .stat-card { 84 - background: var(--bg-secondary); 85 - border-radius: 12px; 86 - padding: 20px; 87 - border: 1px solid var(--border); 85 + background: var(--bg-secondary); 86 + border-radius: 12px; 87 + padding: 20px; 88 + border: 1px solid var(--border); 88 89 } 89 90 90 91 .stat-card .label { 91 - font-size: 0.875rem; 92 - color: var(--text-secondary); 93 - margin-bottom: 4px; 92 + font-size: 0.875rem; 93 + color: var(--text-secondary); 94 + margin-bottom: 4px; 94 95 } 95 96 96 97 .stat-card .value { 97 - font-size: 2rem; 98 - font-weight: 600; 99 - color: var(--spicy-paprika); 98 + font-size: 2rem; 99 + font-weight: 600; 100 + color: var(--spicy-paprika); 100 101 } 101 102 102 103 .chart-container { 103 - background: var(--bg-secondary); 104 - border-radius: 12px; 105 - padding: 24px; 106 - margin-bottom: 24px; 107 - border: 1px solid var(--border); 104 + background: var(--bg-secondary); 105 + border-radius: 12px; 106 + padding: 24px; 107 + margin-bottom: 24px; 108 + border: 1px solid var(--border); 108 109 } 109 110 110 111 .chart-container h2 { 111 - font-size: 1rem; 112 - font-weight: 600; 113 - margin-bottom: 16px; 114 - color: var(--text-primary); 112 + font-size: 1rem; 113 + font-weight: 600; 114 + margin-bottom: 16px; 115 + color: var(--text-primary); 115 116 } 116 117 117 118 .chart-wrapper { 118 - height: 300px; 119 - position: relative; 119 + height: 300px; 120 + position: relative; 120 121 } 121 122 122 123 .chart-hint { 123 - font-size: 0.75rem; 124 - color: var(--text-secondary); 125 - margin-top: 8px; 124 + font-size: 0.75rem; 125 + color: var(--text-secondary); 126 + margin-top: 8px; 126 127 } 127 128 128 129 .top-images { 129 - background: var(--bg-secondary); 130 - border-radius: 12px; 131 - padding: 24px; 132 - border: 1px solid var(--border); 130 + background: var(--bg-secondary); 131 + border-radius: 12px; 132 + padding: 24px; 133 + border: 1px solid var(--border); 133 134 } 134 135 135 136 .top-images h2 { 136 - font-size: 1rem; 137 - font-weight: 600; 138 - margin-bottom: 16px; 139 - color: var(--text-primary); 137 + font-size: 1rem; 138 + font-weight: 600; 139 + margin-bottom: 16px; 140 + color: var(--text-primary); 140 141 } 141 142 142 143 .image-list { 143 - display: flex; 144 - flex-direction: column; 145 - gap: 8px; 144 + display: flex; 145 + flex-direction: column; 146 + gap: 8px; 146 147 } 147 148 148 149 .image-row { 149 - display: flex; 150 - align-items: center; 151 - gap: 12px; 152 - padding: 12px; 153 - border-radius: 8px; 154 - background: var(--bg-primary); 155 - cursor: pointer; 156 - transition: background 0.15s ease; 150 + display: flex; 151 + align-items: center; 152 + gap: 12px; 153 + padding: 12px; 154 + border-radius: 8px; 155 + background: var(--bg-primary); 156 + cursor: pointer; 157 + transition: background 0.15s ease; 157 158 } 158 159 159 160 .image-row:hover { 160 - background: var(--desert-sand); 161 + background: var(--desert-sand); 161 162 } 162 163 163 164 .image-rank { 164 - width: 28px; 165 - height: 28px; 166 - border-radius: 50%; 167 - background: var(--celadon); 168 - display: flex; 169 - align-items: center; 170 - justify-content: center; 171 - font-size: 0.75rem; 172 - font-weight: 600; 173 - color: var(--text-primary); 174 - flex-shrink: 0; 165 + width: 28px; 166 + height: 28px; 167 + border-radius: 50%; 168 + background: var(--celadon); 169 + display: flex; 170 + align-items: center; 171 + justify-content: center; 172 + font-size: 0.75rem; 173 + font-weight: 600; 174 + color: var(--text-primary); 175 + flex-shrink: 0; 175 176 } 176 177 177 178 .image-row:nth-child(1) .image-rank { 178 - background: var(--spicy-paprika); 179 - color: white; 179 + background: var(--spicy-paprika); 180 + color: white; 180 181 } 181 182 182 183 .image-row:nth-child(2) .image-rank { 183 - background: var(--tomato-jam); 184 - color: white; 184 + background: var(--tomato-jam); 185 + color: white; 185 186 } 186 187 187 188 .image-row:nth-child(3) .image-rank { 188 - background: var(--tropical-teal); 189 - color: white; 189 + background: var(--tropical-teal); 190 + color: white; 190 191 } 191 192 192 193 .image-key { 193 - flex: 1; 194 - font-family: ui-monospace, monospace; 195 - font-size: 0.875rem; 196 - color: var(--text-primary); 197 - overflow: hidden; 198 - text-overflow: ellipsis; 194 + flex: 1; 195 + font-family: ui-monospace, monospace; 196 + font-size: 0.875rem; 197 + color: var(--text-primary); 198 + overflow: hidden; 199 + text-overflow: ellipsis; 199 200 } 200 201 201 202 .image-hits { 202 - font-weight: 600; 203 - color: var(--spicy-paprika); 204 - font-size: 0.875rem; 203 + font-weight: 600; 204 + color: var(--spicy-paprika); 205 + font-size: 0.875rem; 205 206 } 206 207 207 208 .loading { 208 - display: flex; 209 - align-items: center; 210 - justify-content: center; 211 - height: 200px; 212 - color: var(--text-secondary); 209 + display: flex; 210 + align-items: center; 211 + justify-content: center; 212 + height: 200px; 213 + color: var(--text-secondary); 213 214 } 214 215 215 216 .u-over { 216 - cursor: crosshair; 217 + cursor: crosshair; 217 218 } 218 219 219 220 footer { 220 - margin-top: 48px; 221 - padding-top: 24px; 222 - border-top: 1px solid var(--border); 223 - text-align: center; 224 - font-size: 0.875rem; 225 - color: var(--text-secondary); 221 + margin-top: 48px; 222 + padding-top: 24px; 223 + border-top: 1px solid var(--border); 224 + text-align: center; 225 + font-size: 0.875rem; 226 + color: var(--text-secondary); 226 227 } 227 228 228 229 footer a { 229 - color: var(--text-secondary); 230 - text-decoration: none; 231 - transition: color 0.15s ease; 230 + color: var(--text-secondary); 231 + text-decoration: none; 232 + transition: color 0.15s ease; 232 233 } 233 234 234 235 footer a:hover { 235 - color: var(--spicy-paprika); 236 + color: var(--spicy-paprika); 236 237 } 237 238 238 239 footer .heart { 239 - color: var(--spicy-paprika); 240 + color: var(--spicy-paprika); 240 241 } 241 242 242 243 footer .repo-link { 243 - display: block; 244 - margin-top: 8px; 245 - font-size: 0.75rem; 244 + display: block; 245 + margin-top: 8px; 246 + font-size: 0.75rem; 246 247 }
+205 -183
src/dashboard.ts
··· 3 3 import "./dashboard.css"; 4 4 5 5 interface TrafficData { 6 - granularity: string; 7 - data: Array<{ bucket?: number; bucket_hour?: number; bucket_day?: number; hits: number }>; 6 + granularity: string; 7 + data: Array<{ 8 + bucket?: number; 9 + bucket_hour?: number; 10 + bucket_day?: number; 11 + hits: number; 12 + }>; 8 13 } 9 14 10 15 interface OverviewData { 11 - totalHits: number; 12 - uniqueImages: number; 13 - topImages: Array<{ image_key: string; total: number }>; 16 + totalHits: number; 17 + uniqueImages: number; 18 + topImages: Array<{ image_key: string; total: number }>; 14 19 } 15 20 16 21 function formatNumber(n: number): string { 17 - if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M"; 18 - if (n >= 1_000) return (n / 1_000).toFixed(1) + "K"; 19 - return n.toString(); 22 + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; 23 + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`; 24 + return n.toString(); 20 25 } 21 26 22 27 class Dashboard { 23 - private days = 7; 24 - private chart: uPlot | null = null; 25 - private abortController: AbortController | null = null; 26 - private originalRange: { start: number; end: number } | null = null; 27 - private currentRange: { start: number; end: number } | null = null; 28 + private days = 7; 29 + private chart: uPlot | null = null; 30 + private abortController: AbortController | null = null; 31 + private originalRange: { start: number; end: number } | null = null; 32 + private currentRange: { start: number; end: number } | null = null; 28 33 29 - private readonly totalHitsEl = document.getElementById("total-hits")!; 30 - private readonly uniqueImagesEl = document.getElementById("unique-images")!; 31 - private readonly imageListEl = document.getElementById("image-list")!; 32 - private readonly chartEl = document.getElementById("chart")!; 33 - private readonly buttons = document.querySelectorAll<HTMLButtonElement>(".time-selector button"); 34 + private readonly totalHitsEl = document.getElementById( 35 + "total-hits", 36 + ) as HTMLElement; 37 + private readonly uniqueImagesEl = document.getElementById( 38 + "unique-images", 39 + ) as HTMLElement; 40 + private readonly imageListEl = document.getElementById( 41 + "image-list", 42 + ) as HTMLElement; 43 + private readonly chartEl = document.getElementById("chart") as HTMLElement; 44 + private readonly buttons = document.querySelectorAll<HTMLButtonElement>( 45 + ".time-selector button", 46 + ); 34 47 35 - constructor() { 36 - this.setupEventListeners(); 37 - this.fetchData(); 38 - window.addEventListener("resize", this.handleResize); 39 - } 48 + constructor() { 49 + this.setupEventListeners(); 50 + this.fetchData(); 51 + window.addEventListener("resize", this.handleResize); 52 + } 40 53 41 - private setupEventListeners() { 42 - this.buttons.forEach((btn) => { 43 - btn.addEventListener("click", () => { 44 - const newDays = parseInt(btn.dataset.days || "7", 10); 45 - if (newDays !== this.days) { 46 - this.days = newDays; 47 - this.currentRange = null; // Reset zoom 48 - this.originalRange = null; 49 - this.updateActiveButton(); 50 - this.fetchData(); 51 - } 52 - }); 53 - }); 54 - } 54 + private setupEventListeners() { 55 + this.buttons.forEach((btn) => { 56 + btn.addEventListener("click", () => { 57 + const newDays = parseInt(btn.dataset.days || "7", 10); 58 + if (newDays !== this.days) { 59 + this.days = newDays; 60 + this.currentRange = null; // Reset zoom 61 + this.originalRange = null; 62 + this.updateActiveButton(); 63 + this.fetchData(); 64 + } 65 + }); 66 + }); 67 + } 55 68 56 - private updateActiveButton() { 57 - this.buttons.forEach((btn) => { 58 - btn.classList.toggle("active", parseInt(btn.dataset.days || "0") === this.days); 59 - }); 60 - } 69 + private updateActiveButton() { 70 + this.buttons.forEach((btn) => { 71 + btn.classList.toggle( 72 + "active", 73 + parseInt(btn.dataset.days || "0", 10) === this.days, 74 + ); 75 + }); 76 + } 61 77 62 - private async fetchData() { 63 - this.abortController?.abort(); 64 - this.abortController = new AbortController(); 65 - const signal = this.abortController.signal; 78 + private async fetchData() { 79 + this.abortController?.abort(); 80 + this.abortController = new AbortController(); 81 + const signal = this.abortController.signal; 82 + 83 + try { 84 + let trafficUrl = `/api/stats/traffic?days=${this.days}`; 85 + 86 + // If we have a current range from zooming, use start/end instead 87 + if (this.currentRange) { 88 + trafficUrl = `/api/stats/traffic?start=${this.currentRange.start}&end=${this.currentRange.end}`; 89 + } 66 90 67 - try { 68 - let trafficUrl = `/api/stats/traffic?days=${this.days}`; 69 - 70 - // If we have a current range from zooming, use start/end instead 71 - if (this.currentRange) { 72 - trafficUrl = `/api/stats/traffic?start=${this.currentRange.start}&end=${this.currentRange.end}`; 73 - } 74 - 75 - const [overview, traffic] = await Promise.all([ 76 - fetch(`/api/stats/overview?days=${this.days}`, { signal }).then((r) => r.json() as Promise<OverviewData>), 77 - fetch(trafficUrl, { signal }).then((r) => r.json() as Promise<TrafficData>), 78 - ]); 91 + const [overview, traffic] = await Promise.all([ 92 + fetch(`/api/stats/overview?days=${this.days}`, { signal }).then( 93 + (r) => r.json() as Promise<OverviewData>, 94 + ), 95 + fetch(trafficUrl, { signal }).then( 96 + (r) => r.json() as Promise<TrafficData>, 97 + ), 98 + ]); 79 99 80 - if (signal.aborted) return; 100 + if (signal.aborted) return; 81 101 82 - this.renderOverview(overview); 83 - this.renderChart(traffic); 84 - } catch (e) { 85 - if ((e as Error).name !== "AbortError") { 86 - console.error("Failed to fetch data:", e); 87 - } 88 - } 89 - } 102 + this.renderOverview(overview); 103 + this.renderChart(traffic); 104 + } catch (e) { 105 + if ((e as Error).name !== "AbortError") { 106 + console.error("Failed to fetch data:", e); 107 + } 108 + } 109 + } 90 110 91 - private renderOverview(data: OverviewData) { 92 - this.totalHitsEl.textContent = formatNumber(data.totalHits); 93 - this.uniqueImagesEl.textContent = String(data.uniqueImages); 111 + private renderOverview(data: OverviewData) { 112 + this.totalHitsEl.textContent = formatNumber(data.totalHits); 113 + this.uniqueImagesEl.textContent = String(data.uniqueImages); 94 114 95 - if (data.topImages.length === 0) { 96 - this.imageListEl.innerHTML = '<div class="loading">No data yet</div>'; 97 - return; 98 - } 115 + if (data.topImages.length === 0) { 116 + this.imageListEl.innerHTML = '<div class="loading">No data yet</div>'; 117 + return; 118 + } 99 119 100 - this.imageListEl.innerHTML = data.topImages 101 - .map( 102 - (img, i) => ` 120 + this.imageListEl.innerHTML = data.topImages 121 + .map( 122 + (img, i) => ` 103 123 <div class="image-row" data-key="${img.image_key}"> 104 124 <div class="image-rank">${i + 1}</div> 105 125 <div class="image-key">${img.image_key}</div> 106 126 <div class="image-hits">${formatNumber(img.total)}</div> 107 127 </div> 108 - ` 109 - ) 110 - .join(""); 128 + `, 129 + ) 130 + .join(""); 111 131 112 - this.imageListEl.querySelectorAll(".image-row").forEach((row) => { 113 - row.addEventListener("click", () => { 114 - const key = (row as HTMLElement).dataset.key; 115 - if (key) window.open(`/i/${key}`, "_blank"); 116 - }); 117 - }); 118 - } 132 + this.imageListEl.querySelectorAll(".image-row").forEach((row) => { 133 + row.addEventListener("click", () => { 134 + const key = (row as HTMLElement).dataset.key; 135 + if (key) window.open(`/i/${key}`, "_blank"); 136 + }); 137 + }); 138 + } 119 139 120 - private renderChart(data: TrafficData) { 121 - const timestamps: number[] = []; 122 - const hits: number[] = []; 140 + private renderChart(data: TrafficData) { 141 + const timestamps: number[] = []; 142 + const hits: number[] = []; 123 143 124 - for (const point of data.data) { 125 - const ts = point.bucket ?? point.bucket_hour ?? point.bucket_day ?? 0; 126 - timestamps.push(ts); 127 - hits.push(point.hits); 128 - } 144 + for (const point of data.data) { 145 + const ts = point.bucket ?? point.bucket_hour ?? point.bucket_day ?? 0; 146 + timestamps.push(ts); 147 + hits.push(point.hits); 148 + } 129 149 130 - if (timestamps.length === 0) { 131 - return; 132 - } 133 - 134 - // Store original range if not set 135 - if (!this.originalRange) { 136 - this.originalRange = { 137 - start: timestamps[0], 138 - end: timestamps[timestamps.length - 1], 139 - }; 140 - } 150 + if (timestamps.length === 0) { 151 + return; 152 + } 141 153 142 - const chartData: uPlot.AlignedData = [timestamps, hits]; 154 + // Store original range if not set 155 + if (!this.originalRange) { 156 + this.originalRange = { 157 + start: timestamps[0], 158 + end: timestamps[timestamps.length - 1], 159 + }; 160 + } 143 161 144 - const opts: uPlot.Options = { 145 - width: this.chartEl.clientWidth, 146 - height: 280, 147 - cursor: { 148 - drag: { x: true, y: false }, 149 - }, 150 - select: { 151 - show: true, 152 - left: 0, 153 - top: 0, 154 - width: 0, 155 - height: 0, 156 - }, 157 - scales: { 158 - x: { time: true }, 159 - y: { auto: true }, 160 - }, 161 - axes: [ 162 - { 163 - stroke: "#6b635a", 164 - grid: { stroke: "#e8e0d8", width: 1 }, 165 - }, 166 - { 167 - stroke: "#6b635a", 168 - grid: { stroke: "#e8e0d8", width: 1 }, 169 - size: 60, 170 - values: (_, ticks) => ticks.map((v) => formatNumber(v)), 171 - }, 172 - ], 173 - series: [ 174 - {}, 175 - { 176 - label: "Hits", 177 - stroke: "#dc602e", 178 - fill: "rgba(220, 96, 46, 0.1)", 179 - width: 2, 180 - points: { show: false }, 181 - }, 182 - ], 183 - hooks: { 184 - setSelect: [ 185 - (u) => { 186 - if (u.select.width > 10) { 187 - const min = Math.floor(u.posToVal(u.select.left, "x")); 188 - const max = Math.floor(u.posToVal(u.select.left + u.select.width, "x")); 189 - 190 - // Store the zoomed range and fetch new data 191 - this.currentRange = { start: min, end: max }; 192 - this.fetchData(); 193 - 194 - u.setSelect({ left: 0, top: 0, width: 0, height: 0 }, false); 195 - } 196 - }, 197 - ], 198 - }, 199 - }; 162 + const chartData: uPlot.AlignedData = [timestamps, hits]; 200 163 201 - if (this.chart) { 202 - this.chart.destroy(); 203 - } 164 + const opts: uPlot.Options = { 165 + width: this.chartEl.clientWidth, 166 + height: 280, 167 + cursor: { 168 + drag: { x: true, y: false }, 169 + }, 170 + select: { 171 + show: true, 172 + left: 0, 173 + top: 0, 174 + width: 0, 175 + height: 0, 176 + }, 177 + scales: { 178 + x: { time: true }, 179 + y: { auto: true }, 180 + }, 181 + axes: [ 182 + { 183 + stroke: "#6b635a", 184 + grid: { stroke: "#e8e0d8", width: 1 }, 185 + }, 186 + { 187 + stroke: "#6b635a", 188 + grid: { stroke: "#e8e0d8", width: 1 }, 189 + size: 60, 190 + values: (_, ticks) => ticks.map((v) => formatNumber(v)), 191 + }, 192 + ], 193 + series: [ 194 + {}, 195 + { 196 + label: "Hits", 197 + stroke: "#dc602e", 198 + fill: "rgba(220, 96, 46, 0.1)", 199 + width: 2, 200 + points: { show: false }, 201 + }, 202 + ], 203 + hooks: { 204 + setSelect: [ 205 + (u) => { 206 + if (u.select.width > 10) { 207 + const min = Math.floor(u.posToVal(u.select.left, "x")); 208 + const max = Math.floor( 209 + u.posToVal(u.select.left + u.select.width, "x"), 210 + ); 211 + 212 + // Store the zoomed range and fetch new data 213 + this.currentRange = { start: min, end: max }; 214 + this.fetchData(); 204 215 205 - this.chartEl.innerHTML = ""; 206 - this.chart = new uPlot(opts, chartData, this.chartEl); 207 - 208 - // Add double-click to reset zoom 209 - this.chartEl.addEventListener("dblclick", () => { 210 - this.currentRange = null; 211 - this.fetchData(); 212 - }); 213 - } 216 + u.setSelect({ left: 0, top: 0, width: 0, height: 0 }, false); 217 + } 218 + }, 219 + ], 220 + }, 221 + }; 214 222 215 - private handleResize = () => { 216 - if (this.chart) { 217 - this.chart.setSize({ 218 - width: this.chartEl.clientWidth, 219 - height: 280, 220 - }); 221 - } 222 - }; 223 + if (this.chart) { 224 + this.chart.destroy(); 225 + } 226 + 227 + this.chartEl.innerHTML = ""; 228 + this.chart = new uPlot(opts, chartData, this.chartEl); 229 + 230 + // Add double-click to reset zoom 231 + this.chartEl.addEventListener("dblclick", () => { 232 + this.currentRange = null; 233 + this.fetchData(); 234 + }); 235 + } 236 + 237 + private handleResize = () => { 238 + if (this.chart) { 239 + this.chart.setSize({ 240 + width: this.chartEl.clientWidth, 241 + height: 280, 242 + }); 243 + } 244 + }; 223 245 } 224 246 225 247 new Dashboard();
+27 -14
src/index.ts
··· 11 11 } from "./stats"; 12 12 13 13 // Configuration from env 14 - const R2_PUBLIC_URL = process.env.R2_PUBLIC_URL!; 14 + const R2_PUBLIC_URL = process.env.R2_PUBLIC_URL || ""; 15 15 const PUBLIC_URL = process.env.PUBLIC_URL || "http://localhost:3000"; 16 16 const AUTH_TOKEN = process.env.AUTH_TOKEN; 17 17 18 18 // S3 configuration 19 19 const S3_ACCESS_KEY_ID = 20 - process.env.S3_ACCESS_KEY_ID || process.env.AWS_ACCESS_KEY_ID!; 20 + process.env.S3_ACCESS_KEY_ID || process.env.AWS_ACCESS_KEY_ID || ""; 21 21 const S3_SECRET_ACCESS_KEY = 22 - process.env.S3_SECRET_ACCESS_KEY || process.env.AWS_SECRET_ACCESS_KEY!; 22 + process.env.S3_SECRET_ACCESS_KEY || process.env.AWS_SECRET_ACCESS_KEY || ""; 23 23 const S3_BUCKET = 24 24 process.env.S3_BUCKET || process.env.AWS_BUCKET || "l4-images"; 25 - const S3_ENDPOINT = process.env.S3_ENDPOINT || process.env.AWS_ENDPOINT!; 25 + const S3_ENDPOINT = process.env.S3_ENDPOINT || process.env.AWS_ENDPOINT || ""; 26 26 const S3_REGION = process.env.S3_REGION || process.env.AWS_REGION || "auto"; 27 27 28 28 // Slack configuration 29 - const SLACK_BOT_TOKEN = process.env.SLACK_BOT_TOKEN!; 30 - const SLACK_SIGNING_SECRET = process.env.SLACK_SIGNING_SECRET!; 29 + const SLACK_BOT_TOKEN = process.env.SLACK_BOT_TOKEN || ""; 30 + const _SLACK_SIGNING_SECRET = process.env.SLACK_SIGNING_SECRET || ""; 31 31 const ALLOWED_CHANNELS = 32 32 process.env.ALLOWED_CHANNELS?.split(",").map((c) => c.trim()) || []; 33 33 ··· 126 126 }, 127 127 128 128 "/health": { 129 - async GET(request) { 129 + async GET(_request) { 130 130 return Response.json({ status: "ok" }); 131 131 }, 132 132 }, ··· 136 136 "/api/stats/overview": { 137 137 GET(request) { 138 138 const url = new URL(request.url); 139 - const days = parseInt(url.searchParams.get("days") || "7"); 139 + const days = parseInt(url.searchParams.get("days") || "7", 10); 140 140 const safeDays = Math.min(Math.max(days, 1), 365); 141 141 142 142 return Response.json({ ··· 175 175 GET(request) { 176 176 const imageKey = request.params.key; 177 177 const url = new URL(request.url); 178 - const days = parseInt(url.searchParams.get("days") || "30"); 178 + const days = parseInt(url.searchParams.get("days") || "30", 10); 179 179 const safeDays = Math.min(Math.max(days, 1), 365); 180 180 181 181 return Response.json(getStats(imageKey, safeDays)); ··· 201 201 }, 202 202 203 203 // Fallback for unmatched routes 204 - async fetch(request) { 204 + async fetch(_request) { 205 205 return new Response("Not found", { status: 404 }); 206 206 }, 207 207 }); ··· 295 295 } 296 296 } 297 297 298 - async function processSlackFiles(event: any) { 298 + interface SlackFile { 299 + url_private: string; 300 + name: string; 301 + mimetype: string; 302 + } 303 + 304 + interface SlackMessageEvent { 305 + text?: string; 306 + files?: SlackFile[]; 307 + channel: string; 308 + ts: string; 309 + } 310 + 311 + async function processSlackFiles(event: SlackMessageEvent) { 299 312 try { 300 313 // Check if message text contains "preserve" 301 314 const preserveFormat = ··· 309 322 }); 310 323 311 324 // Process all files in parallel 312 - const filePromises = event.files.map(async (file: any) => { 325 + const filePromises = (event.files || []).map(async (file) => { 313 326 try { 314 327 console.log(`Processing file: ${file.name}`); 315 328 ··· 366 379 await loadingReaction; 367 380 368 381 // Do all Slack API calls in parallel 369 - const apiCalls: Promise<any>[] = [ 382 + const apiCalls: Promise<unknown>[] = [ 370 383 // Remove loading reaction 371 384 callSlackAPI("reactions.remove", { 372 385 channel: event.channel, ··· 414 427 } 415 428 } 416 429 417 - async function callSlackAPI(method: string, params: any) { 430 + async function callSlackAPI(method: string, params: Record<string, unknown>) { 418 431 const response = await fetch(`https://slack.com/api/${method}`, { 419 432 method: "POST", 420 433 headers: {
+169 -165
src/stats.ts
··· 1 1 import { Database } from "bun:sqlite"; 2 - import { mkdirSync, existsSync } from "node:fs"; 2 + import { existsSync, mkdirSync } from "node:fs"; 3 3 import { dirname } from "node:path"; 4 - import { migrate, getMigrations } from "bun-sqlite-migrations"; 4 + import { getMigrations, migrate } from "bun-sqlite-migrations"; 5 5 6 6 const DB_PATH = process.env.STATS_DB_PATH || "./data/stats.db"; 7 7 8 8 const dbDir = dirname(DB_PATH); 9 9 if (!existsSync(dbDir)) { 10 - mkdirSync(dbDir, { recursive: true }); 10 + mkdirSync(dbDir, { recursive: true }); 11 11 } 12 12 13 13 const db = new Database(DB_PATH, { create: true }); ··· 46 46 let lastCleanup = 0; 47 47 48 48 export function recordHit(imageKey: string): void { 49 - const now = Math.floor(Date.now() / 1000); 50 - const bucket10Min = now - (now % 600); // 10 minutes = 600 seconds 51 - const bucketHour = now - (now % 3600); // 1 hour = 3600 seconds 52 - const bucketDay = now - (now % 86400); // 1 day = 86400 seconds 53 - 54 - // Write to all three tables 55 - increment10MinStmt.run(imageKey, bucket10Min); 56 - incrementHourStmt.run(imageKey, bucketHour); 57 - incrementDayStmt.run(imageKey, bucketDay); 58 - 59 - // Clean up old 10-minute data every 10 minutes 60 - if (now - lastCleanup >= 600) { 61 - const dayAgo = now - 86400; 62 - const cleanupBucket = dayAgo - (dayAgo % 600); 63 - 64 - // Delete 10-minute data older than 24 hours 65 - cleanup10MinStmt.run(cleanupBucket); 66 - 67 - lastCleanup = now; 68 - } 49 + const now = Math.floor(Date.now() / 1000); 50 + const bucket10Min = now - (now % 600); // 10 minutes = 600 seconds 51 + const bucketHour = now - (now % 3600); // 1 hour = 3600 seconds 52 + const bucketDay = now - (now % 86400); // 1 day = 86400 seconds 53 + 54 + // Write to all three tables 55 + increment10MinStmt.run(imageKey, bucket10Min); 56 + incrementHourStmt.run(imageKey, bucketHour); 57 + incrementDayStmt.run(imageKey, bucketDay); 58 + 59 + // Clean up old 10-minute data every 10 minutes 60 + if (now - lastCleanup >= 600) { 61 + const dayAgo = now - 86400; 62 + const cleanupBucket = dayAgo - (dayAgo % 600); 63 + 64 + // Delete 10-minute data older than 24 hours 65 + cleanup10MinStmt.run(cleanupBucket); 66 + 67 + lastCleanup = now; 68 + } 69 69 } 70 70 71 71 export function getStats(imageKey: string, sinceDays: number = 30) { 72 - const since = Math.floor(Date.now() / 1000) - sinceDays * 86400; 73 - return db 74 - .prepare( 75 - `SELECT bucket_hour, hits FROM image_stats 72 + const since = Math.floor(Date.now() / 1000) - sinceDays * 86400; 73 + return db 74 + .prepare( 75 + `SELECT bucket_hour, hits FROM image_stats 76 76 WHERE image_key = ? AND bucket_hour >= ? 77 - ORDER BY bucket_hour` 78 - ) 79 - .all(imageKey, since); 77 + ORDER BY bucket_hour`, 78 + ) 79 + .all(imageKey, since); 80 80 } 81 81 82 82 export function getTopImages(sinceDays: number = 7, limit: number = 10) { 83 - const since = Math.floor(Date.now() / 1000) - sinceDays * 86400; 84 - 85 - // Combine data from both hourly and 10-minute tables 86 - return db 87 - .prepare( 88 - `SELECT image_key, SUM(hits) as total FROM ( 83 + const since = Math.floor(Date.now() / 1000) - sinceDays * 86400; 84 + 85 + // Combine data from both hourly and 10-minute tables 86 + return db 87 + .prepare( 88 + `SELECT image_key, SUM(hits) as total FROM ( 89 89 SELECT image_key, hits FROM image_stats WHERE bucket_hour >= ? 90 90 UNION ALL 91 91 SELECT image_key, hits FROM image_stats_10min WHERE bucket_10min >= ? 92 92 ) 93 - GROUP BY image_key ORDER BY total DESC LIMIT ?` 94 - ) 95 - .all(since, since, limit); 93 + GROUP BY image_key ORDER BY total DESC LIMIT ?`, 94 + ) 95 + .all(since, since, limit); 96 96 } 97 97 98 98 export function getTotalHits(sinceDays: number = 30) { 99 - const since = Math.floor(Date.now() / 1000) - sinceDays * 86400; 100 - const result = db 101 - .prepare(`SELECT SUM(hits) as total FROM image_stats WHERE bucket_hour >= ?`) 102 - .get(since) as { total: number | null }; 103 - return result?.total ?? 0; 99 + const since = Math.floor(Date.now() / 1000) - sinceDays * 86400; 100 + const result = db 101 + .prepare( 102 + `SELECT SUM(hits) as total FROM image_stats WHERE bucket_hour >= ?`, 103 + ) 104 + .get(since) as { total: number | null }; 105 + return result?.total ?? 0; 104 106 } 105 107 106 108 export function getUniqueImages(sinceDays: number = 30) { 107 - const since = Math.floor(Date.now() / 1000) - sinceDays * 86400; 108 - const result = db 109 - .prepare(`SELECT COUNT(DISTINCT image_key) as count FROM image_stats WHERE bucket_hour >= ?`) 110 - .get(since) as { count: number | null }; 111 - return result?.count ?? 0; 109 + const since = Math.floor(Date.now() / 1000) - sinceDays * 86400; 110 + const result = db 111 + .prepare( 112 + `SELECT COUNT(DISTINCT image_key) as count FROM image_stats WHERE bucket_hour >= ?`, 113 + ) 114 + .get(since) as { count: number | null }; 115 + return result?.count ?? 0; 112 116 } 113 117 114 118 export function getHourlyTraffic(sinceDays: number = 7) { 115 - const since = Math.floor(Date.now() / 1000) - sinceDays * 86400; 116 - return db 117 - .prepare( 118 - `SELECT bucket_hour, SUM(hits) as hits 119 + const since = Math.floor(Date.now() / 1000) - sinceDays * 86400; 120 + return db 121 + .prepare( 122 + `SELECT bucket_hour, SUM(hits) as hits 119 123 FROM image_stats WHERE bucket_hour >= ? 120 - GROUP BY bucket_hour ORDER BY bucket_hour` 121 - ) 122 - .all(since) as { bucket_hour: number; hits: number }[]; 124 + GROUP BY bucket_hour ORDER BY bucket_hour`, 125 + ) 126 + .all(since) as { bucket_hour: number; hits: number }[]; 123 127 } 124 128 125 129 export function getDailyTraffic(sinceDays: number = 30) { 126 - const since = Math.floor(Date.now() / 1000) - sinceDays * 86400; 127 - return db 128 - .prepare( 129 - `SELECT (bucket_hour / 86400) * 86400 as bucket_day, SUM(hits) as hits 130 + const since = Math.floor(Date.now() / 1000) - sinceDays * 86400; 131 + return db 132 + .prepare( 133 + `SELECT (bucket_hour / 86400) * 86400 as bucket_day, SUM(hits) as hits 130 134 FROM image_stats WHERE bucket_hour >= ? 131 - GROUP BY bucket_day ORDER BY bucket_day` 132 - ) 133 - .all(since) as { bucket_day: number; hits: number }[]; 135 + GROUP BY bucket_day ORDER BY bucket_day`, 136 + ) 137 + .all(since) as { bucket_day: number; hits: number }[]; 134 138 } 135 139 136 140 export function getTraffic(sinceDays: number = 7, endTime?: number) { 137 - const now = Math.floor(Date.now() / 1000); 138 - const since = now - sinceDays * 86400; 139 - const end = endTime || now; 140 - 141 - // Calculate actual span (in case we're querying a specific range) 142 - const spanSeconds = end - since; 143 - const spanDays = spanSeconds / 86400; 144 - 145 - // For <= 1 day, use 10-minute data if available 146 - if (spanDays <= 1) { 147 - const data = db 148 - .prepare( 149 - `SELECT bucket_10min as bucket, SUM(hits) as hits 141 + const now = Math.floor(Date.now() / 1000); 142 + const since = now - sinceDays * 86400; 143 + const end = endTime || now; 144 + 145 + // Calculate actual span (in case we're querying a specific range) 146 + const spanSeconds = end - since; 147 + const spanDays = spanSeconds / 86400; 148 + 149 + // For <= 1 day, use 10-minute data if available 150 + if (spanDays <= 1) { 151 + const data = db 152 + .prepare( 153 + `SELECT bucket_10min as bucket, SUM(hits) as hits 150 154 FROM image_stats_10min WHERE bucket_10min >= ? AND bucket_10min <= ? 151 - GROUP BY bucket_10min ORDER BY bucket_10min` 152 - ) 153 - .all(since, end) as { bucket: number; hits: number }[]; 154 - 155 - if (data.length > 0) { 156 - return { granularity: "10min", data }; 157 - } 158 - } 159 - 160 - // For > 30 days, use daily data for better performance 161 - if (spanDays > 30) { 162 - const rangeResult = db 163 - .prepare( 164 - `SELECT MIN(bucket_day) as min_time, MAX(bucket_day) as max_time 165 - FROM image_stats_daily WHERE bucket_day >= ? AND bucket_day <= ?` 166 - ) 167 - .get(since, end) as { min_time: number | null; max_time: number | null }; 168 - 169 - if (!rangeResult.min_time || !rangeResult.max_time) { 170 - return { granularity: "daily", data: [] }; 171 - } 172 - 173 - const actualSpanSeconds = rangeResult.max_time - rangeResult.min_time; 174 - const actualSpanDays = actualSpanSeconds / 86400; 175 - 176 - let bucketSize: number; 177 - let bucketLabel: string; 178 - 179 - // For very long ranges, group days into larger buckets 180 - if (actualSpanDays <= 90) { 181 - bucketSize = 86400; // 1 day 182 - bucketLabel = "daily"; 183 - } else { 184 - // For 90+ days, use multi-day buckets to keep point count reasonable 185 - const dayMultiplier = Math.max(1, Math.floor(actualSpanDays / 90)); 186 - bucketSize = 86400 * dayMultiplier; 187 - bucketLabel = dayMultiplier === 1 ? "daily" : `${dayMultiplier}daily`; 188 - } 189 - 190 - const data = db 191 - .prepare( 192 - `SELECT (bucket_day / ?1) * ?1 as bucket, SUM(hits) as hits 155 + GROUP BY bucket_10min ORDER BY bucket_10min`, 156 + ) 157 + .all(since, end) as { bucket: number; hits: number }[]; 158 + 159 + if (data.length > 0) { 160 + return { granularity: "10min", data }; 161 + } 162 + } 163 + 164 + // For > 30 days, use daily data for better performance 165 + if (spanDays > 30) { 166 + const rangeResult = db 167 + .prepare( 168 + `SELECT MIN(bucket_day) as min_time, MAX(bucket_day) as max_time 169 + FROM image_stats_daily WHERE bucket_day >= ? AND bucket_day <= ?`, 170 + ) 171 + .get(since, end) as { min_time: number | null; max_time: number | null }; 172 + 173 + if (!rangeResult.min_time || !rangeResult.max_time) { 174 + return { granularity: "daily", data: [] }; 175 + } 176 + 177 + const actualSpanSeconds = rangeResult.max_time - rangeResult.min_time; 178 + const actualSpanDays = actualSpanSeconds / 86400; 179 + 180 + let bucketSize: number; 181 + let bucketLabel: string; 182 + 183 + // For very long ranges, group days into larger buckets 184 + if (actualSpanDays <= 90) { 185 + bucketSize = 86400; // 1 day 186 + bucketLabel = "daily"; 187 + } else { 188 + // For 90+ days, use multi-day buckets to keep point count reasonable 189 + const dayMultiplier = Math.max(1, Math.floor(actualSpanDays / 90)); 190 + bucketSize = 86400 * dayMultiplier; 191 + bucketLabel = dayMultiplier === 1 ? "daily" : `${dayMultiplier}daily`; 192 + } 193 + 194 + const data = db 195 + .prepare( 196 + `SELECT (bucket_day / ?1) * ?1 as bucket, SUM(hits) as hits 193 197 FROM image_stats_daily WHERE bucket_day >= ?2 AND bucket_day <= ?3 194 - GROUP BY bucket ORDER BY bucket` 195 - ) 196 - .all(bucketSize, since, end) as { bucket: number; hits: number }[]; 197 - 198 - return { granularity: bucketLabel, data }; 199 - } 200 - 201 - // For 1-30 days, use hourly data 202 - const rangeResult = db 203 - .prepare( 204 - `SELECT MIN(bucket_hour) as min_time, MAX(bucket_hour) as max_time 205 - FROM image_stats WHERE bucket_hour >= ? AND bucket_hour <= ?` 206 - ) 207 - .get(since, end) as { min_time: number | null; max_time: number | null }; 208 - 209 - if (!rangeResult.min_time || !rangeResult.max_time) { 210 - return { granularity: "hourly", data: [] }; 211 - } 212 - 213 - // Calculate actual data span in days 214 - const actualSpanSeconds = rangeResult.max_time - rangeResult.min_time; 215 - const actualSpanDays = actualSpanSeconds / 86400; 216 - 217 - // Scale granularity based on actual data span 218 - // <= 7 days: hourly 219 - // > 7 days: bucket size = floor(days / 7) hours 220 - 221 - let bucketSize: number; 222 - let bucketLabel: string; 223 - 224 - if (actualSpanDays <= 7) { 225 - bucketSize = 3600; // 1 hour 226 - bucketLabel = "hourly"; 227 - } else { 228 - const hourMultiplier = Math.floor(actualSpanDays / 7); 229 - bucketSize = 3600 * hourMultiplier; 230 - bucketLabel = `${hourMultiplier}hourly`; 231 - } 232 - 233 - const data = db 234 - .prepare( 235 - `SELECT (bucket_hour / ?1) * ?1 as bucket, SUM(hits) as hits 198 + GROUP BY bucket ORDER BY bucket`, 199 + ) 200 + .all(bucketSize, since, end) as { bucket: number; hits: number }[]; 201 + 202 + return { granularity: bucketLabel, data }; 203 + } 204 + 205 + // For 1-30 days, use hourly data 206 + const rangeResult = db 207 + .prepare( 208 + `SELECT MIN(bucket_hour) as min_time, MAX(bucket_hour) as max_time 209 + FROM image_stats WHERE bucket_hour >= ? AND bucket_hour <= ?`, 210 + ) 211 + .get(since, end) as { min_time: number | null; max_time: number | null }; 212 + 213 + if (!rangeResult.min_time || !rangeResult.max_time) { 214 + return { granularity: "hourly", data: [] }; 215 + } 216 + 217 + // Calculate actual data span in days 218 + const actualSpanSeconds = rangeResult.max_time - rangeResult.min_time; 219 + const actualSpanDays = actualSpanSeconds / 86400; 220 + 221 + // Scale granularity based on actual data span 222 + // <= 7 days: hourly 223 + // > 7 days: bucket size = floor(days / 7) hours 224 + 225 + let bucketSize: number; 226 + let bucketLabel: string; 227 + 228 + if (actualSpanDays <= 7) { 229 + bucketSize = 3600; // 1 hour 230 + bucketLabel = "hourly"; 231 + } else { 232 + const hourMultiplier = Math.floor(actualSpanDays / 7); 233 + bucketSize = 3600 * hourMultiplier; 234 + bucketLabel = `${hourMultiplier}hourly`; 235 + } 236 + 237 + const data = db 238 + .prepare( 239 + `SELECT (bucket_hour / ?1) * ?1 as bucket, SUM(hits) as hits 236 240 FROM image_stats WHERE bucket_hour >= ?2 AND bucket_hour <= ?3 237 - GROUP BY bucket ORDER BY bucket` 238 - ) 239 - .all(bucketSize, since, end) as { bucket: number; hits: number }[]; 240 - 241 - return { granularity: bucketLabel, data }; 241 + GROUP BY bucket ORDER BY bucket`, 242 + ) 243 + .all(bucketSize, since, end) as { bucket: number; hits: number }[]; 244 + 245 + return { granularity: bucketLabel, data }; 242 246 }