diff --git a/backend/src/stats/statsRepository.test.ts b/backend/src/stats/statsRepository.test.ts index 9c05852..e8ed525 100644 --- a/backend/src/stats/statsRepository.test.ts +++ b/backend/src/stats/statsRepository.test.ts @@ -129,7 +129,7 @@ test('canonical charts coalesce six high-volume dimensions into one maintained a assert.match(aggregateSql, /SELECT \* FROM rollup_24h\s+UNION ALL\s+SELECT \* FROM raw_24h/); assert.equal( calls.filter((sql) => - sql.includes("time_bucket('1 hour', p.time) AS bucket, COUNT(*)::int AS count")).length, + sql.includes("time_bucket('1 hour', p.time) AS bucket, COUNT(DISTINCT p.packet_hash)::int AS count")).length, 0, ); assert.deepEqual(result.ptResult.rows, [{ packet_type: 4, count: '3' }]); @@ -139,7 +139,7 @@ test('canonical charts coalesce six high-volume dimensions into one maintained a await repository.fetchChartsData('ukmesh', 'A'.repeat(64)); assert.equal(calls.filter((sql) => sql.includes('FROM packet_hourly_stats')).length, 0); assert.ok(calls.some((sql) => - sql.includes("time_bucket('1 hour', p.time) AS bucket, COUNT(*)::int AS count"))); + sql.includes("time_bucket('1 hour', p.time) AS bucket, COUNT(DISTINCT p.packet_hash)::int AS count"))); }); test('map summary uses the same coordinate, role, and 14-day freshness rules as the map', async () => { diff --git a/backend/src/stats/statsRepository.ts b/backend/src/stats/statsRepository.ts index f6348a3..a35752f 100644 --- a/backend/src/stats/statsRepository.ts +++ b/backend/src/stats/statsRepository.ts @@ -185,6 +185,7 @@ export function createStatsRepository(deps: StatsRepositoryDeps) { return query<{ channel: string; count: string; total_count: string }>(` WITH decoded_group_packets AS ( SELECT + p.packet_hash AS packet_hash, COALESCE(p.payload->>'_summary', '') AS summary, NULLIF(TRIM((regexp_match(COALESCE(p.payload->>'_summary', ''), '^\\[([^\\]]+)\\]'))[1]), '') AS parsed_channel FROM packets p @@ -194,6 +195,7 @@ export function createStatsRepository(deps: StatsRepositoryDeps) { ), group_packets AS ( SELECT + packet_hash, COALESCE( CASE WHEN parsed_channel IS NOT NULL @@ -208,7 +210,7 @@ export function createStatsRepository(deps: StatsRepositoryDeps) { FROM decoded_group_packets ), channel_counts AS ( - SELECT channel, COUNT(*)::text AS count + SELECT channel, COUNT(DISTINCT p.packet_hash)::text AS count FROM group_packets GROUP BY channel ) @@ -707,7 +709,7 @@ export function createStatsRepository(deps: StatsRepositoryDeps) { ) AS bucket ), counts AS ( - SELECT time_bucket('1 hour', p.time) AS bucket, COUNT(*)::int AS count + SELECT time_bucket('1 hour', p.time) AS bucket, COUNT(DISTINCT p.packet_hash)::int AS count FROM packets p WHERE p.time > NOW() - INTERVAL '24 hours' ${filters.packetsAlias('p')} @@ -719,7 +721,7 @@ export function createStatsRepository(deps: StatsRepositoryDeps) { ORDER BY b.bucket `, filters.params)), aggregateRows('packetsPerDay', () => query(` - SELECT time_bucket('1 day', time) AS day, COUNT(*) AS count + SELECT time_bucket('1 day', time) AS day, COUNT(DISTINCT packet_hash) AS count FROM packets WHERE time > NOW() - INTERVAL '7 days' ${filters.packets} GROUP BY day ORDER BY day @@ -754,13 +756,13 @@ export function createStatsRepository(deps: StatsRepositoryDeps) { GROUP BY day ORDER BY day `, filters.params), aggregateRows('packetTypes', () => query(` - SELECT packet_type, COUNT(*) AS count + SELECT packet_type, COUNT(DISTINCT packet_hash) AS count FROM packets WHERE time > NOW() - INTERVAL '24 hours' ${filters.packets} GROUP BY packet_type ORDER BY count DESC `, filters.params)), aggregateRows('hopDistribution', () => query(` - SELECT hop_count AS hops, COUNT(*) AS count + SELECT hop_count AS hops, COUNT(DISTINCT packet_hash) AS count FROM packets WHERE time > NOW() - INTERVAL '7 days' AND hop_count IS NOT NULL @@ -789,8 +791,8 @@ export function createStatsRepository(deps: StatsRepositoryDeps) { `, filters.params), query(` SELECT - (SELECT COUNT(*) FROM packets WHERE time > NOW() - INTERVAL '24 hours' ${filters.packets}) AS total_24h, - (SELECT COUNT(*) FROM packets WHERE time > NOW() - INTERVAL '7 days' ${filters.packets}) AS total_7d, + (SELECT COUNT(DISTINCT packet_hash) FROM packets WHERE time > NOW() - INTERVAL '24 hours' ${filters.packets}) AS total_24h, + (SELECT COUNT(DISTINCT packet_hash) FROM packets WHERE time > NOW() - INTERVAL '7 days' ${filters.packets}) AS total_7d, (SELECT COUNT(DISTINCT meshcore_canonical_node_id(src_node_id)) FROM packets WHERE time > NOW() - INTERVAL '24 hours' @@ -1061,7 +1063,7 @@ export function createStatsRepository(deps: StatsRepositoryDeps) { filters.params, ), aggregateRows('routeTypes', () => query<{ route_type: string; count: string }>( - `SELECT COALESCE(p.route_type::text, 'Unknown') AS route_type, COUNT(*)::text AS count + `SELECT COALESCE(p.route_type::text, 'Unknown') AS route_type, COUNT(DISTINCT p.packet_hash)::text AS count FROM packets p WHERE p.time > NOW() - INTERVAL '24 hours' ${filters.packetsAlias('p')} @@ -1073,10 +1075,10 @@ export function createStatsRepository(deps: StatsRepositoryDeps) { `SELECT NULLIF(TRIM(p.transport_codes), '') AS transport_code, NULLIF(TRIM(p.region_scope), '') AS region_scope, - COUNT(*)::text AS count - FROM packets p - WHERE p.time > NOW() - INTERVAL '24 hours' - AND NULLIF(TRIM(p.transport_codes), '') IS NOT NULL + COUNT(DISTINCT p.packet_hash)::text AS count + FROM packets + WHERE time > NOW() - INTERVAL '24 hours' + AND NULLIF(TRIM(p.transport_codes), '') IS NOT NULL ${filters.packetsAlias('p')} GROUP BY 1, 2 ORDER BY COUNT(*) DESC, region_scope ASC, transport_code ASC @@ -1303,7 +1305,7 @@ export function createStatsRepository(deps: StatsRepositoryDeps) { AND rx_node_id NOT IN (SELECT rx_node_id FROM test_active) ${filters.packets} `, filters.params), - query(`SELECT COUNT(*) AS count FROM packets WHERE time > NOW() - INTERVAL '24 hours' ${filters.packets}`, filters.params), + query(`SELECT COUNT(DISTINCT packet_hash) AS count FROM packets WHERE time > NOW() - INTERVAL '24 hours' ${filters.packets}`, filters.params), query(`SELECT COUNT(*) AS count FROM node_identity_nodes nodes WHERE ${publicMapBasePredicate('nodes')} AND ${nodeEffectiveLastSeenSql('nodes')}