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
This commit is contained in:
filemon
2026-05-12 22:49:38 -03:00
parent 20088b3d2b
commit eed5c2fc5a
4 changed files with 193 additions and 11 deletions
+12 -2
View File
@@ -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 (
<div className={`rounded-lg p-3 flex flex-col gap-1 ${accent ? 'bg-primary/10 border border-primary/20' : 'bg-muted/40'}`}>
<div className="flex items-center gap-1.5">
@@ -25,16 +27,24 @@ function KpiTile({ icon: Icon, label, value, accent }: KpiTileProps) {
<span className={`text-2xl font-bold tabular-nums leading-none ${accent ? 'text-primary' : 'text-foreground'}`}>
{value}
</span>
{hint && (
<span className="text-[10px] text-muted-foreground/70 font-normal leading-tight">
{hint}
</span>
)}
</div>
);
}
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 (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
<KpiTile icon={Globe} label="Total Posts" value={kpis.totalPosts} accent />
<KpiTile icon={Globe} label="Total Posts" value={kpis.totalPosts} accent hint={legacyHint} />
<KpiTile icon={Radio} label="Active Regions" value={kpis.activeRegions} />
<KpiTile icon={MapPin} label={scopeLabel} value={kpis.trackedCount} />
<KpiTile icon={Layers} label="All Codes Tracked" value={kpis.allCodesTracked} />
+2
View File
@@ -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. */
+128
View File
@@ -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<string, number> | 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<number | null> {
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<string>();
const byState = new Map<string, Set<string>>();
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<DashboardCounts>({
queryKey: ['dashboard-counts', queryKeyParts, since ?? 0],
queryFn: async ({ signal }) => {
if (allHashtags.length === 0) {
return { globalCount: null, stateCounts: null };
}
const baseFilter: Partial<NostrFilter> = {
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<string, number> | 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<string, number>();
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 };
}
+51 -9
View File
@@ -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<string>();
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<DashboardKpis>(() => {
const viewPostMap = new Map<string, NostrEvent>();
@@ -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<LeaderboardEntry[]>(() => {
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<ParticipantRow[]>(() => {