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(() => {