From eed5c2fc5a5ea412d774e7b73db1b20db560ea3d Mon Sep 17 00:00:00 2001 From: filemon Date: Tue, 12 May 2026 22:49:38 -0300 Subject: [PATCH] Add hybrid NIP-45 COUNT stabilizer for dashboard KPIs Use relay-side COUNT as a stable floor for totalPosts and state-level leaderboard/distribution counts. Falls back gracefully to event-based counts if the relay does not support NIP-45. Surfaces legacy content-scan municipality matches as a separate hint on the KPI tile. - totalPosts: globalCount ?? viewPosts.length - Leaderboard/distribution: Math.max(eventCount, stateCountFromRelay) - Participants: stays fully event-based (preserves live/activity semantics) - Municipalities: stays fully event-based (no per-muni COUNT queries) - legacyDetected: deduplicated count of posts attributed via content scan --- src/components/event-dashboard/KpiGrid.tsx | 14 ++- src/components/event-dashboard/types.ts | 2 + src/hooks/useDashboardCounts.ts | 128 +++++++++++++++++++++ src/hooks/useEventDashboard.ts | 60 ++++++++-- 4 files changed, 193 insertions(+), 11 deletions(-) create mode 100644 src/hooks/useDashboardCounts.ts diff --git a/src/components/event-dashboard/KpiGrid.tsx b/src/components/event-dashboard/KpiGrid.tsx index 47fb83e2..b0232419 100644 --- a/src/components/event-dashboard/KpiGrid.tsx +++ b/src/components/event-dashboard/KpiGrid.tsx @@ -11,9 +11,11 @@ interface KpiTileProps { label: string; value: number | string; accent?: boolean; + /** Small secondary text below the value. Easy to remove later. */ + hint?: string; } -function KpiTile({ icon: Icon, label, value, accent }: KpiTileProps) { +function KpiTile({ icon: Icon, label, value, accent, hint }: KpiTileProps) { return (
@@ -25,16 +27,24 @@ function KpiTile({ icon: Icon, label, value, accent }: KpiTileProps) { {value} + {hint && ( + + {hint} + + )}
); } export function KpiGrid({ kpis, territorialLevel }: KpiGridProps) { const scopeLabel = territorialLevel === 'states' ? 'Covered States' : 'Tracked Municipalities'; + const legacyHint = territorialLevel === 'municipalities' && kpis.legacyDetected > 0 + ? `+${kpis.legacyDetected} content-matched municipalities` + : undefined; return (
- + diff --git a/src/components/event-dashboard/types.ts b/src/components/event-dashboard/types.ts index 8649eac4..f8e1c285 100644 --- a/src/components/event-dashboard/types.ts +++ b/src/components/event-dashboard/types.ts @@ -12,6 +12,8 @@ export interface DashboardKpis { allCodesTracked: number; last5min: number; uniquePosters: number; + /** Posts attributed to municipalities only via content-scan fallback (no municipality t-tag). */ + legacyDetected: number; } /** A single bucket in the publishing activity time-series chart. */ diff --git a/src/hooks/useDashboardCounts.ts b/src/hooks/useDashboardCounts.ts new file mode 100644 index 00000000..3b59d13e --- /dev/null +++ b/src/hooks/useDashboardCounts.ts @@ -0,0 +1,128 @@ +import { useMemo } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { useNostr } from '@nostrify/react'; +import type { NostrFilter } from '@nostrify/nostrify'; +import { DITTO_RELAY } from '@/lib/appRelays'; +import type { TrackedRegion } from '@/hooks/useEventDashboardConfig'; +import { getStateCodeForHashtag } from '@/lib/venezuelaTerritorial'; + +interface DashboardCounts { + /** Global NIP-45 COUNT for all tracked hashtags, or null if unavailable. */ + globalCount: number | null; + /** Per-state NIP-45 COUNTs keyed by state code, or null if unavailable. */ + stateCounts: Map | null; +} + +const COUNT_TIMEOUT_MS = 8000; + +/** + * Wraps relay.count() with graceful failure: returns null when the relay + * does not support NIP-45, times out, or refuses the query. + */ +async function safeCount( + relay: { count?: (filters: NostrFilter[], opts?: { signal?: AbortSignal }) => Promise<{ count: number; approximate?: boolean }> }, + filters: NostrFilter[], + signal: AbortSignal, +): Promise { + if (!relay.count) return null; + const timeoutSignal = AbortSignal.timeout(COUNT_TIMEOUT_MS); + const combined = AbortSignal.any([signal, timeoutSignal]); + try { + const result = await relay.count(filters, { signal: combined }); + return result.count; + } catch { + return null; + } +} + +/** + * NIP-45 COUNT queries for the event dashboard. + * + * Provides a stable global count and per-state counts as a floor for + * the event-based aggregation. Falls back to null if COUNT is unsupported. + */ +export function useDashboardCounts( + regions: TrackedRegion[], + since: number | null, + options: { enabled: boolean }, +) { + const { nostr } = useNostr(); + const relay = useMemo(() => nostr.relay(DITTO_RELAY), [nostr]); + + // Collect all hashtags and group by state + const { allHashtags, stateHashtagMap } = useMemo(() => { + const all = new Set(); + const byState = new Map>(); + + for (const region of regions) { + if (!region.type) continue; + const codes = region.hashtags.length > 0 ? region.hashtags : (region.code ? [region.code] : []); + for (const hashtag of codes) { + all.add(hashtag); + const stateCode = getStateCodeForHashtag(hashtag); + if (stateCode) { + if (!byState.has(stateCode)) byState.set(stateCode, new Set()); + byState.get(stateCode)!.add(hashtag); + } + } + } + + return { + allHashtags: Array.from(all).sort(), + stateHashtagMap: byState, + }; + }, [regions]); + + const queryKeyParts = allHashtags.join(','); + + const { data } = useQuery({ + queryKey: ['dashboard-counts', queryKeyParts, since ?? 0], + queryFn: async ({ signal }) => { + if (allHashtags.length === 0) { + return { globalCount: null, stateCounts: null }; + } + + const baseFilter: Partial = { + kinds: [1111], + '#t': allHashtags, + ...(since ? { since } : {}), + }; + + // Global count + const globalCount = await safeCount(relay, [baseFilter as NostrFilter], signal); + + // Per-state counts (parallel, max 24 queries) + let stateCounts: Map | null = null; + + if (globalCount !== null) { + // Only attempt per-state if global succeeded (relay supports COUNT) + const entries = Array.from(stateHashtagMap.entries()); + const results = await Promise.all( + entries.map(async ([stateCode, hashtags]) => { + const filter: NostrFilter = { + kinds: [1111], + '#t': Array.from(hashtags).sort(), + ...(since ? { since } : {}), + }; + const count = await safeCount(relay, [filter], signal); + return [stateCode, count] as const; + }), + ); + + stateCounts = new Map(); + for (const [code, count] of results) { + if (count !== null) { + stateCounts.set(code, count); + } + } + } + + return { globalCount, stateCounts }; + }, + enabled: options.enabled && allHashtags.length > 0, + staleTime: 60_000, + refetchInterval: 60_000, + }); + + return data ?? { globalCount: null, stateCounts: null }; +} diff --git a/src/hooks/useEventDashboard.ts b/src/hooks/useEventDashboard.ts index af6c7454..db579ccf 100644 --- a/src/hooks/useEventDashboard.ts +++ b/src/hooks/useEventDashboard.ts @@ -1,8 +1,9 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import type { NostrEvent } from '@nostrify/nostrify'; import { useEventDashboardConfig } from '@/hooks/useEventDashboardConfig'; import type { TrackedRegion } from '@/hooks/useEventDashboardConfig'; import { useMultiHashtagFeed, type RegionFeed } from '@/hooks/useMultiHashtagFeed'; +import { useDashboardCounts } from '@/hooks/useDashboardCounts'; import { getStateCodeForHashtag, getStateByCode, getMunicipalityLabel, extractMunicipalityFromContent } from '@/lib/venezuelaTerritorial'; import { getActiveTrackedCodes, getCoveredStates } from '@/lib/territorialCoverage'; import type { @@ -116,6 +117,8 @@ export function useEventDashboard({ enabled, territorialLevel }: UseEventDashboa const { regionFeeds, globalPosts, isLoading, isBackfilling, error } = useMultiHashtagFeed(config.regions, config.since, { enabled }); + const { globalCount, stateCounts } = useDashboardCounts(config.regions, config.since, { enabled }); + // Clock tick (10s) so time-relative KPIs recompute even when data is unchanged. const [now, setNow] = useState(() => Math.floor(Date.now() / 1000)); useEffect(() => { @@ -240,6 +243,40 @@ export function useEventDashboard({ enabled, territorialLevel }: UseEventDashboa return [...muniFeeds, ...customFeeds]; }, [regionFeeds, regionById, territorialLevel]); + // Helper: apply relay COUNT as a stable floor for state-level counts. + // Used only in leaderboard/distribution, NOT in participants. + const getStableCount = useCallback((feed: AggregatedFeed): number => + territorialLevel === 'states' + ? Math.max(feed.count, stateCounts?.get(feed.code) ?? 0) + : feed.count, + [territorialLevel, stateCounts]); + + // Count posts attributed to municipalities via content-scan fallback only. + // These posts have the state-level t-tag but lack a municipality t-tag. + // Deduplicated by event ID since the same post can appear in multiple feeds. + const legacyDetected = useMemo(() => { + const legacyIds = new Set(); + for (const feed of regionFeeds) { + const region = regionById.get(feed.regionId); + if (region?.type !== 'state') continue; + const stateCode = region.code || region.hashtags[0] || ''; + for (const post of feed.posts) { + if (legacyIds.has(post.id)) continue; + // Check if post already has a municipality t-tag + const hasMuniTag = post.tags.some( + ([name, value]) => name === 't' && getMunicipalityLabel(value) !== undefined, + ); + if (hasMuniTag) continue; + // Check if content-scan would resolve a municipality + const candidate = extractMunicipalityFromContent(post.content); + if (candidate && candidate.slice(0, 2) === stateCode) { + legacyIds.add(post.id); + } + } + } + return legacyIds.size; + }, [regionFeeds, regionById]); + // Derive KPIs const kpis = useMemo(() => { const viewPostMap = new Map(); @@ -256,7 +293,7 @@ export function useEventDashboard({ enabled, territorialLevel }: UseEventDashboa const activeCodes = getActiveTrackedCodes(config); return { - totalPosts: viewPosts.length, + totalPosts: globalCount ?? viewPosts.length, activeRegions: displayFeeds.filter((f) => f.posts[0]?.created_at > thirtySecondsAgo).length, trackedCount: territorialLevel === 'states' ? getCoveredStates(activeCodes).length @@ -264,22 +301,27 @@ export function useEventDashboard({ enabled, territorialLevel }: UseEventDashboa allCodesTracked: activeCodes.size, last5min: viewPosts.filter((p) => p.created_at > fiveMinutesAgo).length, uniquePosters: new Set(viewPosts.map((p) => p.pubkey)).size, + legacyDetected, }; - }, [displayFeeds, config, territorialLevel, now]); + }, [displayFeeds, config, territorialLevel, now, globalCount, legacyDetected]); // Time series const timeSeries = useMemo(() => buildTimeSeries(regionFeeds), [regionFeeds]); - // Leaderboard (top 5) + // Leaderboard (top 5) — uses stable COUNT floor for state-level const leaderboard = useMemo(() => { return [...displayFeeds] - .sort((a, b) => b.count - a.count) + .map((feed) => ({ ...feed, stableCount: getStableCount(feed) })) + .sort((a, b) => b.stableCount - a.stableCount) .slice(0, 5) - .map((feed, i) => ({ rank: i + 1, regionId: feed.regionId, label: feed.label, count: feed.count })); - }, [displayFeeds]); + .map((feed, i) => ({ rank: i + 1, regionId: feed.regionId, label: feed.label, count: feed.stableCount })); + }, [displayFeeds, getStableCount]); - // Distribution donut - const distribution = useMemo(() => buildDistribution(displayFeeds), [displayFeeds]); + // Distribution donut — uses stable COUNT floor for state-level + const distribution = useMemo(() => { + const stableFeeds = displayFeeds.map((feed) => ({ ...feed, count: getStableCount(feed) })); + return buildDistribution(stableFeeds); + }, [displayFeeds, getStableCount]); // Participants (full sorted list) const participants = useMemo(() => {