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:
@@ -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} />
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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[]>(() => {
|
||||
|
||||
Reference in New Issue
Block a user