+26
-2
src/dashboard.css
+26
-2
src/dashboard.css
···
115
115
color: var(--text-primary);
116
116
}
117
117
118
+
.chart-outer {
119
+
position: relative;
120
+
height: 300px;
121
+
}
122
+
118
123
.chart-wrapper {
119
-
height: 300px;
120
-
position: relative;
124
+
height: 100%;
125
+
}
126
+
127
+
.chart-loading {
128
+
position: absolute;
129
+
inset: 0;
130
+
display: flex;
131
+
align-items: center;
132
+
justify-content: center;
133
+
background: rgba(250, 246, 243, 0.5);
134
+
backdrop-filter: blur(1px);
135
+
font-size: 0.75rem;
136
+
color: var(--text-secondary);
137
+
opacity: 0;
138
+
pointer-events: none;
139
+
transition: opacity 0.15s ease-out;
140
+
z-index: 10;
141
+
}
142
+
143
+
.chart-loading.visible {
144
+
opacity: 1;
121
145
}
122
146
123
147
.chart-hint {
+4
-1
src/dashboard.html
+4
-1
src/dashboard.html
···
52
52
53
53
<section class="chart-container">
54
54
<h2>Traffic Overview</h2>
55
-
<div class="chart-wrapper" id="chart" role="img" aria-label="Traffic over time chart"></div>
55
+
<div class="chart-outer">
56
+
<div class="chart-wrapper" id="chart" role="img" aria-label="Traffic over time chart"></div>
57
+
<div id="chart-loading" class="chart-loading">Refining...</div>
58
+
</div>
56
59
<p class="chart-hint">Drag to zoom, double-click to reset</p>
57
60
</section>
58
61
+260
-39
src/dashboard.ts
+260
-39
src/dashboard.ts
···
18
18
topImages: Array<{ image_key: string; total: number }>;
19
19
}
20
20
21
+
type Granularity = "10min" | "hourly" | "daily";
22
+
23
+
interface LodCacheEntry {
24
+
granularity: Granularity;
25
+
range: { start: number; end: number };
26
+
timestamps: number[];
27
+
hits: number[];
28
+
}
29
+
21
30
function formatNumber(n: number): string {
22
31
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
23
32
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
24
33
return n.toString();
25
34
}
26
35
36
+
function downsample(
37
+
timestamps: number[],
38
+
hits: number[],
39
+
minX: number,
40
+
maxX: number,
41
+
maxPoints: number,
42
+
): { timestamps: number[]; hits: number[] } {
43
+
const startIdx = timestamps.findIndex((t) => t >= minX);
44
+
if (startIdx === -1) return { timestamps: [], hits: [] };
45
+
46
+
let endIdx = timestamps.length - 1;
47
+
for (let i = timestamps.length - 1; i >= 0; i--) {
48
+
if (timestamps[i]! <= maxX) {
49
+
endIdx = i;
50
+
break;
51
+
}
52
+
}
53
+
54
+
const sliceLen = endIdx - startIdx + 1;
55
+
if (sliceLen <= 0) return { timestamps: [], hits: [] };
56
+
57
+
const tsSlice = timestamps.slice(startIdx, endIdx + 1);
58
+
const hSlice = hits.slice(startIdx, endIdx + 1);
59
+
60
+
if (sliceLen <= maxPoints) {
61
+
return { timestamps: tsSlice, hits: hSlice };
62
+
}
63
+
64
+
const bucketSize = Math.ceil(sliceLen / maxPoints);
65
+
const dsTs: number[] = [];
66
+
const dsHits: number[] = [];
67
+
68
+
for (let i = 0; i < sliceLen; i += bucketSize) {
69
+
const jEnd = Math.min(i + bucketSize, sliceLen);
70
+
let sumHits = 0;
71
+
for (let j = i; j < jEnd; j++) sumHits += hSlice[j]!;
72
+
const avgHits = sumHits / (jEnd - i);
73
+
dsTs.push(tsSlice[i]!);
74
+
dsHits.push(avgHits);
75
+
}
76
+
77
+
return { timestamps: dsTs, hits: dsHits };
78
+
}
79
+
27
80
class Dashboard {
28
81
private days = 7;
29
82
private chart: uPlot | null = null;
30
83
private abortController: AbortController | null = null;
31
84
private originalRange: { start: number; end: number } | null = null;
32
85
private currentRange: { start: number; end: number } | null = null;
86
+
private lodCache: Partial<Record<Granularity, LodCacheEntry>> = {};
87
+
private activeGranularity: Granularity | null = null;
88
+
private isLoading = false;
89
+
private dblClickHandler: (() => void) | null = null;
33
90
34
91
private readonly totalHitsEl = document.getElementById(
35
92
"total-hits",
···
41
98
"image-list",
42
99
) as HTMLElement;
43
100
private readonly chartEl = document.getElementById("chart") as HTMLElement;
101
+
private readonly loadingEl = document.getElementById(
102
+
"chart-loading",
103
+
) as HTMLElement | null;
44
104
private readonly buttons = document.querySelectorAll<HTMLButtonElement>(
45
105
".time-selector button",
46
106
);
47
107
48
108
constructor() {
109
+
this.days = this.getDaysFromUrl();
110
+
this.updateActiveButton();
49
111
this.setupEventListeners();
50
112
this.fetchData();
51
113
window.addEventListener("resize", this.handleResize);
114
+
window.addEventListener("popstate", this.handlePopState);
52
115
}
53
116
117
+
private getDaysFromUrl(): number {
118
+
const params = new URLSearchParams(window.location.search);
119
+
const days = parseInt(params.get("days") || "7", 10);
120
+
if ([1, 7, 30, 90, 365].includes(days)) {
121
+
return days;
122
+
}
123
+
return 7;
124
+
}
125
+
126
+
private updateUrl(days: number) {
127
+
const url = new URL(window.location.href);
128
+
url.searchParams.set("days", String(days));
129
+
window.history.pushState({ days }, "", url.toString());
130
+
}
131
+
132
+
private handlePopState = (event: PopStateEvent) => {
133
+
const days = event.state?.days ?? this.getDaysFromUrl();
134
+
if (days !== this.days) {
135
+
this.days = days;
136
+
this.currentRange = null;
137
+
this.originalRange = null;
138
+
this.lodCache = {};
139
+
this.activeGranularity = null;
140
+
this.updateActiveButton();
141
+
this.fetchData();
142
+
}
143
+
};
144
+
54
145
private setupEventListeners() {
55
146
this.buttons.forEach((btn) => {
56
147
btn.addEventListener("click", () => {
57
148
const newDays = parseInt(btn.dataset.days || "7", 10);
58
149
if (newDays !== this.days) {
59
150
this.days = newDays;
60
-
this.currentRange = null; // Reset zoom
151
+
this.currentRange = null;
61
152
this.originalRange = null;
153
+
this.lodCache = {};
154
+
this.activeGranularity = null;
62
155
this.updateActiveButton();
156
+
this.updateUrl(newDays);
63
157
this.fetchData();
64
158
}
65
159
});
···
75
169
});
76
170
}
77
171
172
+
private setLoading(loading: boolean) {
173
+
this.isLoading = loading;
174
+
if (this.loadingEl) {
175
+
this.loadingEl.classList.toggle("visible", loading);
176
+
}
177
+
}
178
+
179
+
private getGranularityForRange(start: number, end: number): Granularity {
180
+
const spanDays = (end - start) / 86400;
181
+
if (spanDays <= 1) return "10min";
182
+
if (spanDays <= 30) return "hourly";
183
+
return "daily";
184
+
}
185
+
78
186
private async fetchData() {
79
187
this.abortController?.abort();
80
188
this.abortController = new AbortController();
81
189
const signal = this.abortController.signal;
82
190
191
+
this.setLoading(true);
192
+
83
193
try {
84
194
let trafficUrl = `/api/stats/traffic?days=${this.days}`;
85
195
86
-
// If we have a current range from zooming, use start/end instead
87
196
if (this.currentRange) {
88
197
trafficUrl = `/api/stats/traffic?start=${this.currentRange.start}&end=${this.currentRange.end}`;
89
198
}
···
100
209
if (signal.aborted) return;
101
210
102
211
this.renderOverview(overview);
103
-
this.renderChart(traffic);
212
+
213
+
const { timestamps, hits } = this.transformTraffic(traffic);
214
+
215
+
if (timestamps.length === 0) {
216
+
return;
217
+
}
218
+
219
+
if (!this.chart) {
220
+
this.initChart(timestamps, hits);
221
+
}
222
+
223
+
this.updateCache(traffic);
104
224
} catch (e) {
105
225
if ((e as Error).name !== "AbortError") {
106
226
console.error("Failed to fetch data:", e);
227
+
}
228
+
} finally {
229
+
if (!signal.aborted) {
230
+
this.setLoading(false);
107
231
}
108
232
}
109
233
}
···
137
261
});
138
262
}
139
263
140
-
private renderChart(data: TrafficData) {
264
+
private transformTraffic(data: TrafficData): {
265
+
timestamps: number[];
266
+
hits: number[];
267
+
} {
141
268
const timestamps: number[] = [];
142
269
const hits: number[] = [];
143
270
···
147
274
hits.push(point.hits);
148
275
}
149
276
150
-
if (timestamps.length === 0) {
277
+
return { timestamps, hits };
278
+
}
279
+
280
+
private updateCache(traffic: TrafficData) {
281
+
const { timestamps, hits } = this.transformTraffic(traffic);
282
+
if (timestamps.length === 0) return;
283
+
284
+
const first = timestamps[0]!;
285
+
const last = timestamps[timestamps.length - 1]!;
286
+
287
+
const gran = traffic.granularity as Granularity;
288
+
289
+
this.lodCache[gran] = {
290
+
granularity: gran,
291
+
range: { start: first, end: last },
292
+
timestamps,
293
+
hits,
294
+
};
295
+
296
+
this.activeGranularity = gran;
297
+
298
+
if (!this.currentRange) {
299
+
this.originalRange = { start: first, end: last };
300
+
this.renderCurrentViewport({ min: first, max: last });
301
+
} else {
302
+
this.renderCurrentViewport();
303
+
}
304
+
}
305
+
306
+
private getBestCacheForRange(
307
+
minX: number,
308
+
maxX: number,
309
+
): LodCacheEntry | null {
310
+
const lodPriority: Granularity[] = ["10min", "hourly", "daily"];
311
+
312
+
for (const lod of lodPriority) {
313
+
const cache = this.lodCache[lod];
314
+
if (cache && cache.range.start <= minX && cache.range.end >= maxX) {
315
+
return cache;
316
+
}
317
+
}
318
+
319
+
for (const lod of lodPriority) {
320
+
const cache = this.lodCache[lod];
321
+
if (cache) return cache;
322
+
}
323
+
324
+
return null;
325
+
}
326
+
327
+
private renderCurrentViewport(forceRange?: { min: number; max: number }) {
328
+
if (!this.chart) return;
329
+
330
+
let minX: number | undefined;
331
+
let maxX: number | undefined;
332
+
333
+
if (forceRange) {
334
+
minX = forceRange.min;
335
+
maxX = forceRange.max;
336
+
} else {
337
+
const xScale = this.chart.scales.x;
338
+
minX =
339
+
xScale && xScale.min != null ? xScale.min : this.originalRange?.start;
340
+
maxX =
341
+
xScale && xScale.max != null ? xScale.max : this.originalRange?.end;
342
+
}
343
+
344
+
if (minX == null || maxX == null) return;
345
+
346
+
const cache = this.getBestCacheForRange(minX, maxX);
347
+
if (!cache) return;
348
+
349
+
this.activeGranularity = cache.granularity;
350
+
351
+
const width = this.chartEl.clientWidth || 600;
352
+
const maxPoints = Math.min(width, 800);
353
+
354
+
const { timestamps, hits } = downsample(
355
+
cache.timestamps,
356
+
cache.hits,
357
+
minX,
358
+
maxX,
359
+
maxPoints,
360
+
);
361
+
362
+
if (timestamps.length === 0) return;
363
+
364
+
this.chart.setData([timestamps, hits]);
365
+
this.chart.setScale("x", { min: minX, max: maxX });
366
+
}
367
+
368
+
private handleSelect(u: uPlot) {
369
+
if (u.select.width <= 10) return;
370
+
371
+
const min = Math.floor(u.posToVal(u.select.left, "x"));
372
+
const max = Math.floor(u.posToVal(u.select.left + u.select.width, "x"));
373
+
374
+
u.setSelect({ left: 0, top: 0, width: 0, height: 0 }, false);
375
+
376
+
this.currentRange = { start: min, end: max };
377
+
378
+
const bestCache = this.getBestCacheForRange(min, max);
379
+
const targetGran = this.getGranularityForRange(min, max);
380
+
381
+
if (bestCache && bestCache.granularity === targetGran) {
382
+
this.renderCurrentViewport({ min, max });
151
383
return;
152
384
}
153
385
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
-
}
386
+
this.renderCurrentViewport({ min, max });
387
+
this.fetchData();
388
+
}
161
389
162
-
const chartData: uPlot.AlignedData = [timestamps, hits];
390
+
private resetZoom() {
391
+
this.currentRange = null;
163
392
393
+
if (this.originalRange && this.chart) {
394
+
this.chart.setScale("x", {
395
+
min: this.originalRange.start,
396
+
max: this.originalRange.end,
397
+
});
398
+
this.renderCurrentViewport();
399
+
}
400
+
}
401
+
402
+
private initChart(timestamps: number[], hits: number[]) {
164
403
const opts: uPlot.Options = {
165
404
width: this.chartEl.clientWidth,
166
405
height: 280,
···
201
440
},
202
441
],
203
442
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();
215
-
216
-
u.setSelect({ left: 0, top: 0, width: 0, height: 0 }, false);
217
-
}
218
-
},
219
-
],
443
+
setSelect: [(u) => this.handleSelect(u)],
220
444
},
221
445
};
222
446
223
-
if (this.chart) {
224
-
this.chart.destroy();
225
-
}
226
-
227
447
this.chartEl.innerHTML = "";
228
-
this.chart = new uPlot(opts, chartData, this.chartEl);
448
+
this.chart = new uPlot(opts, [timestamps, hits], this.chartEl);
229
449
230
-
// Add double-click to reset zoom
231
-
this.chartEl.addEventListener("dblclick", () => {
232
-
this.currentRange = null;
233
-
this.fetchData();
234
-
});
450
+
if (this.dblClickHandler) {
451
+
this.chartEl.removeEventListener("dblclick", this.dblClickHandler);
452
+
}
453
+
this.dblClickHandler = () => this.resetZoom();
454
+
this.chartEl.addEventListener("dblclick", this.dblClickHandler);
235
455
}
236
456
237
457
private handleResize = () => {
···
240
460
width: this.chartEl.clientWidth,
241
461
height: 280,
242
462
});
463
+
this.renderCurrentViewport();
243
464
}
244
465
};
245
466
}
+3
-2
src/index.ts
+3
-2
src/index.ts
···
159
159
const end = parseInt(endParam, 10);
160
160
const spanDays = (end - start) / 86400;
161
161
162
-
// Use span as "days" parameter, pass end time
163
-
return Response.json(getTraffic(spanDays, end));
162
+
return Response.json(
163
+
getTraffic(spanDays, { startTime: start, endTime: end }),
164
+
);
164
165
}
165
166
166
167
// Normal mode: last N days
+20
-77
src/stats.ts
+20
-77
src/stats.ts
···
137
137
.all(since) as { bucket_day: number; hits: number }[];
138
138
}
139
139
140
-
export function getTraffic(sinceDays: number = 7, endTime?: number) {
140
+
export function getTraffic(
141
+
sinceDays: number = 7,
142
+
options?: { startTime?: number; endTime?: number },
143
+
) {
141
144
const now = Math.floor(Date.now() / 1000);
142
-
const since = now - sinceDays * 86400;
143
-
const end = endTime || now;
145
+
const since = options?.startTime ?? now - sinceDays * 86400;
146
+
const end = options?.endTime ?? now;
144
147
145
-
// Calculate actual span (in case we're querying a specific range)
146
148
const spanSeconds = end - since;
147
149
const spanDays = spanSeconds / 86400;
148
150
149
-
// For <= 1 day, use 10-minute data if available
151
+
// <= 1 day: 10min data if available
150
152
if (spanDays <= 1) {
151
153
const data = db
152
154
.prepare(
···
161
163
}
162
164
}
163
165
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
-
166
+
// 1-30 days: always hourly
167
+
if (spanDays <= 30) {
194
168
const data = db
195
169
.prepare(
196
-
`SELECT (bucket_day / ?1) * ?1 as bucket, SUM(hits) as hits
197
-
FROM image_stats_daily WHERE bucket_day >= ?2 AND bucket_day <= ?3
198
-
GROUP BY bucket ORDER BY bucket`,
170
+
`SELECT bucket_hour as bucket, SUM(hits) as hits
171
+
FROM image_stats WHERE bucket_hour >= ? AND bucket_hour <= ?
172
+
GROUP BY bucket_hour ORDER BY bucket_hour`,
199
173
)
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;
174
+
.all(since, end) as { bucket: number; hits: number }[];
227
175
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`;
176
+
return { granularity: "hourly", data };
235
177
}
236
178
179
+
// > 30 days: daily
237
180
const data = db
238
181
.prepare(
239
-
`SELECT (bucket_hour / ?1) * ?1 as bucket, SUM(hits) as hits
240
-
FROM image_stats WHERE bucket_hour >= ?2 AND bucket_hour <= ?3
241
-
GROUP BY bucket ORDER BY bucket`,
182
+
`SELECT bucket_day as bucket, SUM(hits) as hits
183
+
FROM image_stats_daily WHERE bucket_day >= ? AND bucket_day <= ?
184
+
GROUP BY bucket_day ORDER BY bucket_day`,
242
185
)
243
-
.all(bucketSize, since, end) as { bucket: number; hits: number }[];
186
+
.all(since, end) as { bucket: number; hits: number }[];
244
187
245
-
return { granularity: bucketLabel, data };
188
+
return { granularity: "daily", data };
246
189
}