+24
-24
package.json
+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
+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
+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
+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
+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
+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
}