Add statistics endpoint

This commit is contained in:
Jack Kingsman
2026-02-15 12:54:42 -08:00
parent 3756579f9d
commit 1f3042f360
12 changed files with 696 additions and 1 deletions

View File

@@ -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

View File

@@ -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`

View File

@@ -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

View File

@@ -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

View File

@@ -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,
}

12
app/routers/statistics.py Normal file
View File

@@ -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)

View File

@@ -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<StatisticsResponse>('/statistics'),
};

View File

@@ -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<string | null>(null);
const [editingNameValue, setEditingNameValue] = useState('');
// Statistics state
const [stats, setStats] = useState<StatisticsResponse | null>(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) {
)}
</div>
)}
{shouldRenderSection('statistics') && (
<div className={sectionWrapperClass}>
{renderSectionHeader('statistics')}
{isSectionVisible('statistics') && (
<div className={sectionContentClass}>
{statsLoading && !stats ? (
<div className="py-8 text-center text-muted-foreground">Loading statistics...</div>
) : stats ? (
<div className="space-y-6">
{/* Network */}
<div>
<h4 className="text-sm font-medium mb-2">Network</h4>
<div className="grid grid-cols-3 gap-3">
<div className="text-center p-3 bg-muted/50 rounded-md">
<div className="text-2xl font-bold">{stats.contact_count}</div>
<div className="text-xs text-muted-foreground">Contacts</div>
</div>
<div className="text-center p-3 bg-muted/50 rounded-md">
<div className="text-2xl font-bold">{stats.repeater_count}</div>
<div className="text-xs text-muted-foreground">Repeaters</div>
</div>
<div className="text-center p-3 bg-muted/50 rounded-md">
<div className="text-2xl font-bold">{stats.channel_count}</div>
<div className="text-xs text-muted-foreground">Channels</div>
</div>
</div>
</div>
<Separator />
{/* Messages */}
<div>
<h4 className="text-sm font-medium mb-2">Messages</h4>
<div className="grid grid-cols-3 gap-3">
<div className="text-center p-3 bg-muted/50 rounded-md">
<div className="text-2xl font-bold">{stats.total_dms}</div>
<div className="text-xs text-muted-foreground">Direct Messages</div>
</div>
<div className="text-center p-3 bg-muted/50 rounded-md">
<div className="text-2xl font-bold">{stats.total_channel_messages}</div>
<div className="text-xs text-muted-foreground">Channel Messages</div>
</div>
<div className="text-center p-3 bg-muted/50 rounded-md">
<div className="text-2xl font-bold">{stats.total_outgoing}</div>
<div className="text-xs text-muted-foreground">Sent (Outgoing)</div>
</div>
</div>
</div>
<Separator />
{/* Packets */}
<div>
<h4 className="text-sm font-medium mb-2">Packets</h4>
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground">Total stored</span>
<span className="font-medium">{stats.total_packets}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-green-500">Decrypted</span>
<span className="font-medium text-green-500">
{stats.decrypted_packets}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-yellow-500">Undecrypted</span>
<span className="font-medium text-yellow-500">
{stats.undecrypted_packets}
</span>
</div>
</div>
</div>
<Separator />
{/* Activity */}
<div>
<h4 className="text-sm font-medium mb-2">Activity</h4>
<table className="w-full text-sm">
<thead>
<tr className="text-muted-foreground">
<th className="text-left font-normal pb-1"></th>
<th className="text-right font-normal pb-1">1h</th>
<th className="text-right font-normal pb-1">24h</th>
<th className="text-right font-normal pb-1">7d</th>
</tr>
</thead>
<tbody>
<tr>
<td className="py-1">Contacts heard</td>
<td className="text-right py-1">{stats.contacts_heard.last_hour}</td>
<td className="text-right py-1">{stats.contacts_heard.last_24_hours}</td>
<td className="text-right py-1">{stats.contacts_heard.last_week}</td>
</tr>
<tr>
<td className="py-1">Repeaters heard</td>
<td className="text-right py-1">{stats.repeaters_heard.last_hour}</td>
<td className="text-right py-1">{stats.repeaters_heard.last_24_hours}</td>
<td className="text-right py-1">{stats.repeaters_heard.last_week}</td>
</tr>
</tbody>
</table>
</div>
{/* Busiest Channels */}
{stats.busiest_channels_24h.length > 0 && (
<>
<Separator />
<div>
<h4 className="text-sm font-medium mb-2">Busiest Channels (24h)</h4>
<div className="space-y-1">
{stats.busiest_channels_24h.map((ch, i) => (
<div
key={ch.channel_key}
className="flex justify-between items-center text-sm"
>
<span>
<span className="text-muted-foreground mr-2">{i + 1}.</span>
{ch.channel_name}
</span>
<span className="text-muted-foreground">{ch.message_count} msgs</span>
</div>
))}
</div>
</div>
</>
)}
</div>
) : null}
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -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<SettingsSection, string> = {
@@ -14,4 +21,5 @@ export const SETTINGS_SECTION_LABELS: Record<SettingsSection, string> = {
connectivity: '📡 Connectivity',
database: '🗄️ Database',
bot: '🤖 Bot',
statistics: '📊 Statistics',
};

View File

@@ -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();
});
});

View File

@@ -211,3 +211,30 @@ export interface UnreadCounts {
mentions: Record<string, boolean>;
last_message_times: Record<string, number>;
}
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;
}

267
tests/test_statistics.py Normal file
View File

@@ -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