diff --git a/app/models.py b/app/models.py
index 96615c2..94bc2b3 100644
--- a/app/models.py
+++ b/app/models.py
@@ -884,6 +884,11 @@ class NoiseFloorHistoryStats(BaseModel):
samples: list[NoiseFloorSample] = Field(default_factory=list)
+class PacketsPerHourBucket(BaseModel):
+ timestamp: int = Field(description="Unix timestamp at the start of the hour")
+ count: int = Field(description="Number of packets received in that hour")
+
+
class StatisticsResponse(BaseModel):
busiest_channels_24h: list[BusyChannel]
contact_count: int
@@ -899,6 +904,7 @@ class StatisticsResponse(BaseModel):
repeaters_heard: ContactActivityCounts
known_channels_active: ContactActivityCounts
path_hash_width_24h: PathHashWidthStats
+ packets_per_hour_72h: list[PacketsPerHourBucket]
noise_floor_24h: NoiseFloorHistoryStats
diff --git a/app/repository/settings.py b/app/repository/settings.py
index aa4d703..c5dda0b 100644
--- a/app/repository/settings.py
+++ b/app/repository/settings.py
@@ -11,6 +11,7 @@ logger = logging.getLogger(__name__)
SECONDS_1H = 3600
SECONDS_24H = 86400
+SECONDS_72H = 259200
SECONDS_7D = 604800
RAW_PACKET_STATS_BATCH_SIZE = 500
@@ -274,6 +275,25 @@ class StatisticsRepository:
"last_week": row["last_week"] or 0,
}
+ @staticmethod
+ async def _packets_per_hour_72h() -> list[dict[str, int]]:
+ """Return packet counts bucketed by hour for the last 72 hours."""
+ now = int(time.time())
+ cutoff = now - SECONDS_72H
+ # Bucket timestamps to the start of each hour
+ cursor = await db.conn.execute(
+ """
+ SELECT (timestamp / 3600) * 3600 AS hour_ts, COUNT(*) AS count
+ FROM raw_packets
+ WHERE timestamp >= ?
+ GROUP BY hour_ts
+ ORDER BY hour_ts
+ """,
+ (cutoff,),
+ )
+ rows = await cursor.fetchall()
+ return [{"timestamp": row["hour_ts"], "count": row["count"]} for row in rows]
+
@staticmethod
async def _path_hash_width_24h() -> dict[str, int | float]:
"""Count parsed raw packets from the last 24h by hop hash width."""
@@ -350,6 +370,7 @@ class StatisticsRepository:
repeaters_heard = await StatisticsRepository._activity_counts(contact_type=2)
known_channels_active = await StatisticsRepository._known_channels_active()
path_hash_width_24h = await StatisticsRepository._path_hash_width_24h()
+ packets_per_hour_72h = await StatisticsRepository._packets_per_hour_72h()
return {
"busiest_channels_24h": busiest_channels_24h,
@@ -366,4 +387,5 @@ class StatisticsRepository:
"repeaters_heard": repeaters_heard,
"known_channels_active": known_channels_active,
"path_hash_width_24h": path_hash_width_24h,
+ "packets_per_hour_72h": packets_per_hour_72h,
}
diff --git a/frontend/src/components/settings/SettingsStatisticsSection.tsx b/frontend/src/components/settings/SettingsStatisticsSection.tsx
index 6605a03..51085d0 100644
--- a/frontend/src/components/settings/SettingsStatisticsSection.tsx
+++ b/frontend/src/components/settings/SettingsStatisticsSection.tsx
@@ -42,6 +42,87 @@ function formatTime(ts: number): string {
});
}
+function formatDateTime(ts: number): string {
+ const d = new Date(ts * 1000);
+ return (
+ d.toLocaleDateString([], { month: 'short', day: 'numeric' }) +
+ ' ' +
+ d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false })
+ );
+}
+
+function PacketsPerHourChart({ buckets }: { buckets: { timestamp: number; count: number }[] }) {
+ // Fill gaps so hours with zero packets still appear on the chart
+ const filled: { timestamp: number; count: number }[] = [];
+ if (buckets.length > 0) {
+ const first = buckets[0].timestamp;
+ const last = buckets[buckets.length - 1].timestamp;
+ const byTs = new Map(buckets.map((b) => [b.timestamp, b.count]));
+ for (let ts = first; ts <= last; ts += 3600) {
+ filled.push({ timestamp: ts, count: byTs.get(ts) ?? 0 });
+ }
+ }
+
+ const data = filled.map((b, i) => ({
+ idx: i,
+ label: formatDateTime(b.timestamp),
+ count: b.count,
+ }));
+
+ // Show ~6 evenly-spaced tick labels
+ const tickCount = Math.min(6, data.length);
+ const tickIndices: number[] = [];
+ if (data.length > 1) {
+ for (let i = 0; i < tickCount; i++) {
+ tickIndices.push(Math.round((i / (tickCount - 1)) * (data.length - 1)));
+ }
+ }
+
+ return (
+