From 1f3042f360c357aefb83cf204866cbcad81aa504 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Sun, 15 Feb 2026 12:54:42 -0800 Subject: [PATCH] Add statistics endpoint --- AGENTS.md | 1 + app/AGENTS.md | 4 + app/main.py | 2 + app/models.py | 27 ++ app/repository.py | 125 +++++++++ app/routers/statistics.py | 12 + frontend/src/api.ts | 4 + frontend/src/components/SettingsModal.tsx | 166 ++++++++++++ frontend/src/components/settingsConstants.ts | 10 +- frontend/src/test/settingsModal.test.tsx | 52 ++++ frontend/src/types.ts | 27 ++ tests/test_statistics.py | 267 +++++++++++++++++++ 12 files changed, 696 insertions(+), 1 deletion(-) create mode 100644 app/routers/statistics.py create mode 100644 tests/test_statistics.py diff --git a/AGENTS.md b/AGENTS.md index bc351a4..c723300 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -278,6 +278,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`). | PATCH | `/api/settings` | Update app settings | | POST | `/api/settings/favorites/toggle` | Toggle favorite status | | POST | `/api/settings/migrate` | One-time migration from frontend localStorage | +| GET | `/api/statistics` | Aggregated mesh network statistics | | WS | `/api/ws` | Real-time updates | ## Key Concepts diff --git a/app/AGENTS.md b/app/AGENTS.md index 7d052ee..45188b3 100644 --- a/app/AGENTS.md +++ b/app/AGENTS.md @@ -40,6 +40,7 @@ app/ ├── packets.py ├── read_state.py ├── settings.py + ├── statistics.py └── ws.py ``` @@ -138,6 +139,9 @@ app/ - `POST /settings/favorites/toggle` - `POST /settings/migrate` +### Statistics +- `GET /statistics` — aggregated mesh network stats (entity counts, message/packet splits, activity windows, busiest channels) + ### WebSocket - `WS /ws` diff --git a/app/main.py b/app/main.py index a6a6035..0cecd60 100644 --- a/app/main.py +++ b/app/main.py @@ -23,6 +23,7 @@ from app.routers import ( radio, read_state, settings, + statistics, ws, ) @@ -83,6 +84,7 @@ app.include_router(messages.router, prefix="/api") app.include_router(packets.router, prefix="/api") app.include_router(read_state.router, prefix="/api") app.include_router(settings.router, prefix="/api") +app.include_router(statistics.router, prefix="/api") app.include_router(ws.router, prefix="/api") # Serve frontend static files in production diff --git a/app/models.py b/app/models.py index e31442f..fd4f952 100644 --- a/app/models.py +++ b/app/models.py @@ -296,3 +296,30 @@ class AppSettings(BaseModel): default_factory=list, description="List of bot configurations", ) + + +class BusyChannel(BaseModel): + channel_key: str + channel_name: str + message_count: int + + +class ContactActivityCounts(BaseModel): + last_hour: int + last_24_hours: int + last_week: int + + +class StatisticsResponse(BaseModel): + busiest_channels_24h: list[BusyChannel] + contact_count: int + repeater_count: int + channel_count: int + total_packets: int + decrypted_packets: int + undecrypted_packets: int + total_dms: int + total_channel_messages: int + total_outgoing: int + contacts_heard: ContactActivityCounts + repeaters_heard: ContactActivityCounts diff --git a/app/repository.py b/app/repository.py index b4859be..b090b69 100644 --- a/app/repository.py +++ b/app/repository.py @@ -20,6 +20,11 @@ from app.models import ( logger = logging.getLogger(__name__) +SECONDS_1H = 3600 +SECONDS_24H = 86400 +SECONDS_7D = 604800 + + class AmbiguousPublicKeyPrefixError(ValueError): """Raised when a public key prefix matches multiple contacts.""" @@ -1013,3 +1018,123 @@ class AppSettingsRepository: ) return settings, True + + +class StatisticsRepository: + @staticmethod + async def _activity_counts(type_condition: str) -> dict[str, int]: + """Get time-windowed counts for contacts/repeaters heard.""" + now = int(time.time()) + cursor = await db.conn.execute( + f""" + SELECT + SUM(CASE WHEN last_seen >= ? THEN 1 ELSE 0 END) AS last_hour, + SUM(CASE WHEN last_seen >= ? THEN 1 ELSE 0 END) AS last_24_hours, + SUM(CASE WHEN last_seen >= ? THEN 1 ELSE 0 END) AS last_week + FROM contacts + WHERE {type_condition} AND last_seen IS NOT NULL + """, + (now - SECONDS_1H, now - SECONDS_24H, now - SECONDS_7D), + ) + row = await cursor.fetchone() + assert row is not None # Aggregate query always returns a row + return { + "last_hour": row["last_hour"] or 0, + "last_24_hours": row["last_24_hours"] or 0, + "last_week": row["last_week"] or 0, + } + + @staticmethod + async def get_all() -> dict: + """Aggregate all statistics from existing tables.""" + now = int(time.time()) + + # Top 5 busiest channels in last 24h + cursor = await db.conn.execute( + """ + SELECT m.conversation_key, COALESCE(c.name, m.conversation_key) AS channel_name, + COUNT(*) AS message_count + FROM messages m + LEFT JOIN channels c ON m.conversation_key = c.key + WHERE m.type = 'CHAN' AND m.received_at >= ? + GROUP BY m.conversation_key + ORDER BY COUNT(*) DESC + LIMIT 5 + """, + (now - SECONDS_24H,), + ) + rows = await cursor.fetchall() + busiest_channels_24h = [ + { + "channel_key": row["conversation_key"], + "channel_name": row["channel_name"], + "message_count": row["message_count"], + } + for row in rows + ] + + # Entity counts + cursor = await db.conn.execute("SELECT COUNT(*) AS cnt FROM contacts WHERE type != 2") + row = await cursor.fetchone() + assert row is not None + contact_count: int = row["cnt"] + + cursor = await db.conn.execute("SELECT COUNT(*) AS cnt FROM contacts WHERE type = 2") + row = await cursor.fetchone() + assert row is not None + repeater_count: int = row["cnt"] + + cursor = await db.conn.execute("SELECT COUNT(*) AS cnt FROM channels") + row = await cursor.fetchone() + assert row is not None + channel_count: int = row["cnt"] + + # Packet split + cursor = await db.conn.execute( + """ + SELECT COUNT(*) AS total, + SUM(CASE WHEN message_id IS NOT NULL THEN 1 ELSE 0 END) AS decrypted + FROM raw_packets + """ + ) + pkt_row = await cursor.fetchone() + assert pkt_row is not None + total_packets = pkt_row["total"] or 0 + decrypted_packets = pkt_row["decrypted"] or 0 + undecrypted_packets = total_packets - decrypted_packets + + # Message type counts + cursor = await db.conn.execute("SELECT COUNT(*) AS cnt FROM messages WHERE type = 'PRIV'") + row = await cursor.fetchone() + assert row is not None + total_dms: int = row["cnt"] + + cursor = await db.conn.execute("SELECT COUNT(*) AS cnt FROM messages WHERE type = 'CHAN'") + row = await cursor.fetchone() + assert row is not None + total_channel_messages: int = row["cnt"] + + # Outgoing count + cursor = await db.conn.execute("SELECT COUNT(*) AS cnt FROM messages WHERE outgoing = 1") + row = await cursor.fetchone() + assert row is not None + total_outgoing: int = row["cnt"] + + # Activity windows + contacts_heard = await StatisticsRepository._activity_counts("type != 2") + repeaters_heard = await StatisticsRepository._activity_counts("type = 2") + + return { + "busiest_channels_24h": busiest_channels_24h, + "contact_count": contact_count, + "repeater_count": repeater_count, + "channel_count": channel_count, + "total_packets": total_packets, + "decrypted_packets": decrypted_packets, + "undecrypted_packets": undecrypted_packets, + "total_dms": total_dms, + "total_channel_messages": total_channel_messages, + "total_outgoing": total_outgoing, + "contacts_heard": contacts_heard, + "repeaters_heard": repeaters_heard, + } diff --git a/app/routers/statistics.py b/app/routers/statistics.py new file mode 100644 index 0000000..00dcbc8 --- /dev/null +++ b/app/routers/statistics.py @@ -0,0 +1,12 @@ +from fastapi import APIRouter + +from app.models import StatisticsResponse +from app.repository import StatisticsRepository + +router = APIRouter(prefix="/statistics", tags=["statistics"]) + + +@router.get("", response_model=StatisticsResponse) +async def get_statistics() -> StatisticsResponse: + data = await StatisticsRepository.get_all() + return StatisticsResponse(**data) diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 11d964d..2b94af7 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -12,6 +12,7 @@ import type { MigratePreferencesResponse, RadioConfig, RadioConfigUpdate, + StatisticsResponse, TelemetryResponse, TraceResponse, UnreadCounts, @@ -216,4 +217,7 @@ export const api = { method: 'POST', body: JSON.stringify(request), }), + + // Statistics + getStatistics: () => fetchJson('/statistics'), }; diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index f6b3a7c..acbd707 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -10,6 +10,7 @@ import type { HealthStatus, RadioConfig, RadioConfigUpdate, + StatisticsResponse, } from '../types'; import { Input } from './ui/input'; import { Label } from './ui/label'; @@ -109,6 +110,7 @@ export function SettingsModal(props: SettingsModalProps) { connectivity: false, database: false, bot: false, + statistics: false, }; }); @@ -185,6 +187,10 @@ export function SettingsModal(props: SettingsModalProps) { const [editingNameId, setEditingNameId] = useState(null); const [editingNameValue, setEditingNameValue] = useState(''); + // Statistics state + const [stats, setStats] = useState(null); + const [statsLoading, setStatsLoading] = useState(false); + useEffect(() => { if (config) { setName(config.name); @@ -239,6 +245,31 @@ export function SettingsModal(props: SettingsModalProps) { setSectionError(null); }, [externalSidebarNav, desktopSection]); + // Fetch statistics when the section becomes visible + const statisticsVisible = externalSidebarNav + ? desktopSection === 'statistics' + : expandedSections.statistics; + + useEffect(() => { + if (!statisticsVisible) return; + let cancelled = false; + setStatsLoading(true); + api.getStatistics().then( + (data) => { + if (!cancelled) { + setStats(data); + setStatsLoading(false); + } + }, + () => { + if (!cancelled) setStatsLoading(false); + } + ); + return () => { + cancelled = true; + }; + }, [statisticsVisible]); + // Detect current preset from form values const currentPreset = useMemo(() => { const freqNum = parseFloat(freq); @@ -1207,6 +1238,141 @@ export function SettingsModal(props: SettingsModalProps) { )} )} + + {shouldRenderSection('statistics') && ( +
+ {renderSectionHeader('statistics')} + {isSectionVisible('statistics') && ( +
+ {statsLoading && !stats ? ( +
Loading statistics...
+ ) : stats ? ( +
+ {/* Network */} +
+

Network

+
+
+
{stats.contact_count}
+
Contacts
+
+
+
{stats.repeater_count}
+
Repeaters
+
+
+
{stats.channel_count}
+
Channels
+
+
+
+ + + + {/* Messages */} +
+

Messages

+
+
+
{stats.total_dms}
+
Direct Messages
+
+
+
{stats.total_channel_messages}
+
Channel Messages
+
+
+
{stats.total_outgoing}
+
Sent (Outgoing)
+
+
+
+ + + + {/* Packets */} +
+

Packets

+
+
+ Total stored + {stats.total_packets} +
+
+ Decrypted + + {stats.decrypted_packets} + +
+
+ Undecrypted + + {stats.undecrypted_packets} + +
+
+
+ + + + {/* Activity */} +
+

Activity

+ + + + + + + + + + + + + + + + + + + + + + + +
1h24h7d
Contacts heard{stats.contacts_heard.last_hour}{stats.contacts_heard.last_24_hours}{stats.contacts_heard.last_week}
Repeaters heard{stats.repeaters_heard.last_hour}{stats.repeaters_heard.last_24_hours}{stats.repeaters_heard.last_week}
+
+ + {/* Busiest Channels */} + {stats.busiest_channels_24h.length > 0 && ( + <> + +
+

Busiest Channels (24h)

+
+ {stats.busiest_channels_24h.map((ch, i) => ( +
+ + {i + 1}. + {ch.channel_name} + + {ch.message_count} msgs +
+ ))} +
+
+ + )} +
+ ) : null} +
+ )} +
+ )} ); } diff --git a/frontend/src/components/settingsConstants.ts b/frontend/src/components/settingsConstants.ts index b3bfdf4..22931b8 100644 --- a/frontend/src/components/settingsConstants.ts +++ b/frontend/src/components/settingsConstants.ts @@ -1,4 +1,10 @@ -export type SettingsSection = 'radio' | 'identity' | 'connectivity' | 'database' | 'bot'; +export type SettingsSection = + | 'radio' + | 'identity' + | 'connectivity' + | 'database' + | 'bot' + | 'statistics'; export const SETTINGS_SECTION_ORDER: SettingsSection[] = [ 'radio', @@ -6,6 +12,7 @@ export const SETTINGS_SECTION_ORDER: SettingsSection[] = [ 'connectivity', 'database', 'bot', + 'statistics', ]; export const SETTINGS_SECTION_LABELS: Record = { @@ -14,4 +21,5 @@ export const SETTINGS_SECTION_LABELS: Record = { connectivity: '📡 Connectivity', database: '🗄️ Database', bot: '🤖 Bot', + statistics: '📊 Statistics', }; diff --git a/frontend/src/test/settingsModal.test.tsx b/frontend/src/test/settingsModal.test.tsx index 579546e..ba8a56c 100644 --- a/frontend/src/test/settingsModal.test.tsx +++ b/frontend/src/test/settingsModal.test.tsx @@ -8,6 +8,7 @@ import type { HealthStatus, RadioConfig, RadioConfigUpdate, + StatisticsResponse, } from '../types'; import type { SettingsSection } from '../components/SettingsModal'; @@ -289,4 +290,55 @@ describe('SettingsModal', () => { }); expect(onClose).not.toHaveBeenCalled(); }); + + it('renders statistics section with fetched data', async () => { + const mockStats: StatisticsResponse = { + busiest_channels_24h: [ + { channel_key: 'AA'.repeat(16), channel_name: 'general', message_count: 42 }, + ], + contact_count: 10, + repeater_count: 3, + channel_count: 5, + total_packets: 200, + decrypted_packets: 150, + undecrypted_packets: 50, + total_dms: 25, + total_channel_messages: 80, + total_outgoing: 30, + contacts_heard: { last_hour: 2, last_24_hours: 7, last_week: 10 }, + repeaters_heard: { last_hour: 1, last_24_hours: 3, last_week: 3 }, + }; + + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(JSON.stringify(mockStats), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ); + + renderModal({ + externalSidebarNav: true, + desktopSection: 'statistics', + }); + + await waitFor(() => { + expect(screen.getByText('Network')).toBeInTheDocument(); + }); + + // Verify key labels are present + expect(screen.getByText('Contacts')).toBeInTheDocument(); + expect(screen.getByText('Repeaters')).toBeInTheDocument(); + expect(screen.getByText('Direct Messages')).toBeInTheDocument(); + expect(screen.getByText('Channel Messages')).toBeInTheDocument(); + expect(screen.getByText('Sent (Outgoing)')).toBeInTheDocument(); + expect(screen.getByText('Total stored')).toBeInTheDocument(); + expect(screen.getByText('Decrypted')).toBeInTheDocument(); + expect(screen.getByText('Undecrypted')).toBeInTheDocument(); + expect(screen.getByText('Contacts heard')).toBeInTheDocument(); + expect(screen.getByText('Repeaters heard')).toBeInTheDocument(); + + // Busiest channels + expect(screen.getByText('general')).toBeInTheDocument(); + expect(screen.getByText('42 msgs')).toBeInTheDocument(); + }); }); diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 94c16a3..ec52d4b 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -211,3 +211,30 @@ export interface UnreadCounts { mentions: Record; last_message_times: Record; } + +export interface BusyChannel { + channel_key: string; + channel_name: string; + message_count: number; +} + +export interface ContactActivityCounts { + last_hour: number; + last_24_hours: number; + last_week: number; +} + +export interface StatisticsResponse { + busiest_channels_24h: BusyChannel[]; + contact_count: number; + repeater_count: number; + channel_count: number; + total_packets: number; + decrypted_packets: number; + undecrypted_packets: number; + total_dms: number; + total_channel_messages: number; + total_outgoing: number; + contacts_heard: ContactActivityCounts; + repeaters_heard: ContactActivityCounts; +} diff --git a/tests/test_statistics.py b/tests/test_statistics.py new file mode 100644 index 0000000..243f108 --- /dev/null +++ b/tests/test_statistics.py @@ -0,0 +1,267 @@ +"""Tests for the statistics repository and endpoint.""" + +import time + +import pytest + +from app.database import Database +from app.repository import StatisticsRepository + + +@pytest.fixture +async def test_db(): + """Create an in-memory test database with the module-level db swapped in.""" + import app.repository as repo_module + + db = Database(":memory:") + await db.connect() + + original_db = repo_module.db + repo_module.db = db + + try: + yield db + finally: + repo_module.db = original_db + await db.disconnect() + + +class TestStatisticsEmpty: + @pytest.mark.asyncio + async def test_empty_database(self, test_db): + """All counts should be zero on an empty database.""" + result = await StatisticsRepository.get_all() + + assert result["contact_count"] == 0 + assert result["repeater_count"] == 0 + assert result["channel_count"] == 0 + assert result["total_packets"] == 0 + assert result["decrypted_packets"] == 0 + assert result["undecrypted_packets"] == 0 + assert result["total_dms"] == 0 + assert result["total_channel_messages"] == 0 + assert result["total_outgoing"] == 0 + assert result["busiest_channels_24h"] == [] + assert result["contacts_heard"]["last_hour"] == 0 + assert result["contacts_heard"]["last_24_hours"] == 0 + assert result["contacts_heard"]["last_week"] == 0 + assert result["repeaters_heard"]["last_hour"] == 0 + assert result["repeaters_heard"]["last_24_hours"] == 0 + assert result["repeaters_heard"]["last_week"] == 0 + + +class TestStatisticsCounts: + @pytest.mark.asyncio + async def test_counts_contacts_and_repeaters(self, test_db): + """Contacts and repeaters are counted separately by type.""" + now = int(time.time()) + conn = test_db.conn + # type=1 is client, type=2 is repeater + await conn.execute( + "INSERT INTO contacts (public_key, type, last_seen) VALUES (?, ?, ?)", + ("aa" * 32, 1, now), + ) + await conn.execute( + "INSERT INTO contacts (public_key, type, last_seen) VALUES (?, ?, ?)", + ("bb" * 32, 1, now), + ) + await conn.execute( + "INSERT INTO contacts (public_key, type, last_seen) VALUES (?, ?, ?)", + ("cc" * 32, 2, now), + ) + await conn.commit() + + result = await StatisticsRepository.get_all() + + assert result["contact_count"] == 2 + assert result["repeater_count"] == 1 + + @pytest.mark.asyncio + async def test_channel_count(self, test_db): + conn = test_db.conn + await conn.execute( + "INSERT INTO channels (key, name) VALUES (?, ?)", + ("AA" * 16, "test-chan"), + ) + await conn.commit() + + result = await StatisticsRepository.get_all() + assert result["channel_count"] == 1 + + @pytest.mark.asyncio + async def test_message_type_counts(self, test_db): + """DM, channel, and outgoing messages are counted correctly.""" + now = int(time.time()) + conn = test_db.conn + # 2 DMs, 3 channel messages, 1 outgoing + await conn.execute( + "INSERT INTO messages (type, conversation_key, text, received_at, outgoing) VALUES (?, ?, ?, ?, ?)", + ("PRIV", "aa" * 32, "dm1", now, 0), + ) + await conn.execute( + "INSERT INTO messages (type, conversation_key, text, received_at, outgoing) VALUES (?, ?, ?, ?, ?)", + ("PRIV", "bb" * 32, "dm2", now, 0), + ) + await conn.execute( + "INSERT INTO messages (type, conversation_key, text, received_at, outgoing) VALUES (?, ?, ?, ?, ?)", + ("CHAN", "CC" * 16, "ch1", now, 0), + ) + await conn.execute( + "INSERT INTO messages (type, conversation_key, text, received_at, outgoing) VALUES (?, ?, ?, ?, ?)", + ("CHAN", "CC" * 16, "ch2", now, 0), + ) + await conn.execute( + "INSERT INTO messages (type, conversation_key, text, received_at, outgoing) VALUES (?, ?, ?, ?, ?)", + ("CHAN", "DD" * 16, "ch3", now, 1), + ) + await conn.commit() + + result = await StatisticsRepository.get_all() + + assert result["total_dms"] == 2 + assert result["total_channel_messages"] == 3 + assert result["total_outgoing"] == 1 + + @pytest.mark.asyncio + async def test_packet_split(self, test_db): + """Packets are split into decrypted and undecrypted.""" + now = int(time.time()) + conn = test_db.conn + # Insert a message to link to + await conn.execute( + "INSERT INTO messages (type, conversation_key, text, received_at) VALUES (?, ?, ?, ?)", + ("CHAN", "AA" * 16, "msg", now), + ) + msg_id = (await (await conn.execute("SELECT last_insert_rowid() AS id")).fetchone())["id"] + + # 2 decrypted packets (linked to message), 1 undecrypted + await conn.execute( + "INSERT INTO raw_packets (timestamp, data, message_id, payload_hash) VALUES (?, ?, ?, ?)", + (now, b"\x01", msg_id, "hash1"), + ) + await conn.execute( + "INSERT INTO raw_packets (timestamp, data, message_id, payload_hash) VALUES (?, ?, ?, ?)", + (now, b"\x02", msg_id, "hash2"), + ) + await conn.execute( + "INSERT INTO raw_packets (timestamp, data, payload_hash) VALUES (?, ?, ?)", + (now, b"\x03", "hash3"), + ) + await conn.commit() + + result = await StatisticsRepository.get_all() + + assert result["total_packets"] == 3 + assert result["decrypted_packets"] == 2 + assert result["undecrypted_packets"] == 1 + + +class TestBusiestChannels: + @pytest.mark.asyncio + async def test_busiest_channels_returns_top_5(self, test_db): + """Only the top 5 channels are returned, ordered by message count.""" + now = int(time.time()) + conn = test_db.conn + + # Create 6 channels with varying message counts + for i in range(6): + key = f"{i:02X}" * 16 + await conn.execute( + "INSERT INTO channels (key, name) VALUES (?, ?)", + (key, f"chan-{i}"), + ) + for j in range(i + 1): + await conn.execute( + "INSERT INTO messages (type, conversation_key, text, received_at) VALUES (?, ?, ?, ?)", + ("CHAN", key, f"msg-{j}", now), + ) + await conn.commit() + + result = await StatisticsRepository.get_all() + + assert len(result["busiest_channels_24h"]) == 5 + # Most messages first + counts = [ch["message_count"] for ch in result["busiest_channels_24h"]] + assert counts == sorted(counts, reverse=True) + assert counts[0] == 6 # channel 5 has 6 messages + + @pytest.mark.asyncio + async def test_busiest_channels_excludes_old_messages(self, test_db): + """Messages older than 24h are not counted.""" + now = int(time.time()) + old = now - 90000 # older than 24h + conn = test_db.conn + + key = "AA" * 16 + await conn.execute("INSERT INTO channels (key, name) VALUES (?, ?)", (key, "old-chan")) + await conn.execute( + "INSERT INTO messages (type, conversation_key, text, received_at) VALUES (?, ?, ?, ?)", + ("CHAN", key, "old-msg", old), + ) + await conn.commit() + + result = await StatisticsRepository.get_all() + assert result["busiest_channels_24h"] == [] + + @pytest.mark.asyncio + async def test_busiest_channels_shows_key_when_no_channel_name(self, test_db): + """When channel has no name in channels table, conversation_key is used.""" + now = int(time.time()) + conn = test_db.conn + + key = "FF" * 16 + # Don't insert into channels table + await conn.execute( + "INSERT INTO messages (type, conversation_key, text, received_at) VALUES (?, ?, ?, ?)", + ("CHAN", key, "msg", now), + ) + await conn.commit() + + result = await StatisticsRepository.get_all() + assert len(result["busiest_channels_24h"]) == 1 + assert result["busiest_channels_24h"][0]["channel_name"] == key + + +class TestActivityWindows: + @pytest.mark.asyncio + async def test_activity_windows(self, test_db): + """Contacts are bucketed into time windows based on last_seen.""" + now = int(time.time()) + conn = test_db.conn + + # Contact seen 30 min ago (within 1h, 24h, 7d) + await conn.execute( + "INSERT INTO contacts (public_key, type, last_seen) VALUES (?, ?, ?)", + ("aa" * 32, 1, now - 1800), + ) + # Contact seen 12h ago (within 24h, 7d but not 1h) + await conn.execute( + "INSERT INTO contacts (public_key, type, last_seen) VALUES (?, ?, ?)", + ("bb" * 32, 1, now - 43200), + ) + # Contact seen 3 days ago (within 7d but not 1h or 24h) + await conn.execute( + "INSERT INTO contacts (public_key, type, last_seen) VALUES (?, ?, ?)", + ("cc" * 32, 1, now - 259200), + ) + # Contact seen 10 days ago (outside all windows) + await conn.execute( + "INSERT INTO contacts (public_key, type, last_seen) VALUES (?, ?, ?)", + ("dd" * 32, 1, now - 864000), + ) + # Repeater seen 30 min ago + await conn.execute( + "INSERT INTO contacts (public_key, type, last_seen) VALUES (?, ?, ?)", + ("ee" * 32, 2, now - 1800), + ) + await conn.commit() + + result = await StatisticsRepository.get_all() + + assert result["contacts_heard"]["last_hour"] == 1 + assert result["contacts_heard"]["last_24_hours"] == 2 + assert result["contacts_heard"]["last_week"] == 3 + + assert result["repeaters_heard"]["last_hour"] == 1 + assert result["repeaters_heard"]["last_24_hours"] == 1 + assert result["repeaters_heard"]["last_week"] == 1