Files
eranos/src/hooks/useEventDashboard.ts
T

402 lines
15 KiB
TypeScript

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 {
TerritorialLevel,
DashboardStatus,
DashboardKpis,
TimeSeriesBucket,
LeaderboardEntry,
DistributionSlice,
ParticipantRow,
ActivityItem,
} from '@/components/event-dashboard/types';
const REGION_COLORS = [
'hsl(221, 83%, 53%)', 'hsl(142, 71%, 45%)', 'hsl(38, 92%, 50%)',
'hsl(0, 84%, 60%)', 'hsl(270, 70%, 60%)', 'hsl(180, 70%, 45%)',
'hsl(330, 80%, 55%)',
];
interface UseEventDashboardOptions {
/** Must be true for relay queries to fire. */
enabled: boolean;
/** Current territorial view level. */
territorialLevel: TerritorialLevel;
}
interface UseEventDashboardResult {
kpis: DashboardKpis;
timeSeries: TimeSeriesBucket[];
leaderboard: LeaderboardEntry[];
distribution: DistributionSlice[];
participants: ParticipantRow[];
activity: ActivityItem[];
status: DashboardStatus;
isLoading: boolean;
error: Error | null;
}
// ---------------------------------------------------------------------------
// Aggregation helpers
// ---------------------------------------------------------------------------
interface AggregatedFeed {
regionId: string;
label: string;
code: string;
posts: NostrEvent[];
count: number;
}
function buildTimeSeries(regionFeeds: RegionFeed[]): TimeSeriesBucket[] {
const now = Math.floor(Date.now() / 1000);
const bucketSize = 300;
const bucketCount = 12;
const start = now - bucketCount * bucketSize;
const counts: number[] = Array(bucketCount).fill(0);
const posterSets: Set<string>[] = Array.from({ length: bucketCount }, () => new Set());
const seen = new Set<string>();
for (const feed of regionFeeds) {
for (const post of feed.posts) {
if (seen.has(post.id)) continue;
seen.add(post.id);
if (post.created_at < start) continue;
const idx = Math.floor((post.created_at - start) / bucketSize);
if (idx >= 0 && idx < bucketCount) {
counts[idx]++;
posterSets[idx].add(post.pubkey);
}
}
}
return Array.from({ length: bucketCount }, (_, i) => {
const t = start + i * bucketSize;
const d = new Date(t * 1000);
return {
time: `${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`,
posts: counts[i],
posters: posterSets[i].size,
};
});
}
function buildDistribution(feeds: AggregatedFeed[]): DistributionSlice[] {
const sorted = [...feeds].sort((a, b) => b.count - a.count);
const top = sorted.slice(0, 7);
const rest = sorted.slice(7);
const othersCount = rest.reduce((sum, f) => sum + f.count, 0);
const slices: DistributionSlice[] = top.map((feed, i) => ({
name: feed.label,
value: feed.count,
fill: REGION_COLORS[i % REGION_COLORS.length],
}));
if (othersCount > 0) {
slices.push({ name: 'Others', value: othersCount, fill: 'hsl(0, 0%, 70%)' });
}
return slices;
}
// ---------------------------------------------------------------------------
// Main hook
// ---------------------------------------------------------------------------
export function useEventDashboard({ enabled, territorialLevel }: UseEventDashboardOptions): UseEventDashboardResult {
const { config } = useEventDashboardConfig();
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(() => {
if (!enabled) return;
const id = setInterval(() => setNow(Math.floor(Date.now() / 1000)), 10_000);
return () => clearInterval(id);
}, [enabled]);
// Pre-build region lookup for O(1) access inside displayFeeds aggregation.
const regionById = useMemo(() => {
const map = new Map<string, TrackedRegion>();
for (const r of config.regions) map.set(r.id, r);
return map;
}, [config.regions]);
// Aggregate regionFeeds into displayFeeds based on territorial level
const displayFeeds = useMemo<AggregatedFeed[]>(() => {
// Custom/free-form entries (no type) pass through as their own row in all views
const customFeeds: AggregatedFeed[] = [];
if (territorialLevel === 'states') {
const stateMap = new Map<string, { label: string; postMap: Map<string, NostrEvent> }>();
for (const feed of regionFeeds) {
const region = regionById.get(feed.regionId);
if (!region?.type) {
if (region && feed.count > 0) {
customFeeds.push({
regionId: feed.regionId,
label: region.label,
code: region.code || region.hashtags[0] || '',
posts: feed.posts,
count: feed.count,
});
}
continue;
}
const code = region.code || region.hashtags[0] || '';
const stateCode = getStateCodeForHashtag(code);
if (!stateCode) continue;
if (!stateMap.has(stateCode)) {
const stateInfo = getStateByCode(stateCode);
stateMap.set(stateCode, { label: stateInfo?.label ?? stateCode, postMap: new Map() });
}
const group = stateMap.get(stateCode)!;
for (const post of feed.posts) {
group.postMap.set(post.id, post);
}
}
const stateFeeds = Array.from(stateMap.entries()).map(([code, { label, postMap }]) => {
const posts = Array.from(postMap.values()).sort((a, b) => b.created_at - a.created_at);
return { regionId: code, label, code, posts, count: posts.length };
});
return [...stateFeeds, ...customFeeds];
}
// Municipalities view: use municipality-type regions directly
const muniMap = new Map<string, { label: string; postMap: Map<string, NostrEvent> }>();
for (const feed of regionFeeds) {
const region = regionById.get(feed.regionId);
if (!region?.type) {
if (region && feed.count > 0) {
customFeeds.push({
regionId: feed.regionId,
label: region.label,
code: region.code || region.hashtags[0] || '',
posts: feed.posts,
count: feed.count,
});
}
continue;
}
const code = region.code || region.hashtags[0] || '';
if (region.type === 'municipality') {
if (!muniMap.has(code)) {
muniMap.set(code, { label: getMunicipalityLabel(code) ?? region.label, postMap: new Map() });
}
const bucket = muniMap.get(code)!;
for (const post of feed.posts) {
bucket.postMap.set(post.id, post);
}
} else if (region.type === 'state') {
const stateCode = region.code || region.hashtags[0] || '';
// State feeds: distribute posts to municipality buckets via t-tags,
// with content-scan fallback for legacy posts that lack municipality tags.
for (const post of feed.posts) {
let resolved: string | undefined;
for (const [name, value] of post.tags) {
if (name !== 't') continue;
if (getMunicipalityLabel(value)) { resolved = value; break; }
}
// Content fallback: only if post has no municipality tag.
// Same-state guard: only accept if candidate belongs to this state.
if (!resolved) {
const candidate = extractMunicipalityFromContent(post.content);
if (candidate && candidate.slice(0, 2) === stateCode) {
resolved = candidate;
}
}
if (resolved) {
const resolvedLabel = getMunicipalityLabel(resolved);
if (!resolvedLabel) continue;
if (!muniMap.has(resolved)) {
muniMap.set(resolved, { label: resolvedLabel, postMap: new Map() });
}
muniMap.get(resolved)!.postMap.set(post.id, post);
}
}
}
}
const muniFeeds = Array.from(muniMap.entries()).map(([code, { label, postMap }]) => {
const posts = Array.from(postMap.values()).sort((a, b) => b.created_at - a.created_at);
return { regionId: code, label, code, posts, count: posts.length };
}).filter((f) => f.count > 0);
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>();
for (const feed of displayFeeds) {
for (const post of feed.posts) {
viewPostMap.set(post.id, post);
}
}
const viewPosts = Array.from(viewPostMap.values());
const fiveMinutesAgo = now - 300;
const thirtySecondsAgo = now - 30;
const activeCodes = getActiveTrackedCodes(config);
return {
totalPosts: territorialLevel === 'states'
? Math.max(globalCount ?? 0, viewPosts.length)
: viewPosts.length,
activeRegions: displayFeeds.filter((f) => f.posts[0]?.created_at > thirtySecondsAgo).length,
trackedCount: territorialLevel === 'states'
? getCoveredStates(activeCodes).length
: config.regions.filter((r) => r.type === 'municipality').length,
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, globalCount, legacyDetected]);
// Time series
const timeSeries = useMemo(() => buildTimeSeries(regionFeeds), [regionFeeds]);
// Leaderboard (top 5) — uses stable COUNT floor for state-level
const leaderboard = useMemo<LeaderboardEntry[]>(() => {
return [...displayFeeds]
.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.stableCount }));
}, [displayFeeds, getStableCount]);
// 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).
// Intentionally uses raw event-based counts (no COUNT floor) because each
// row derives live/activity state from the loaded events themselves.
const participants = useMemo<ParticipantRow[]>(() => {
const thirtySecondsAgo = now - 30;
return [...displayFeeds]
.sort((a, b) => b.count - a.count)
.map((feed, i) => ({
rank: i + 1,
regionId: feed.regionId,
label: feed.label,
hashtag: feed.code,
count: feed.count,
isActive: feed.posts.length > 0 && feed.posts[0].created_at > thirtySecondsAgo,
}));
}, [displayFeeds, now]);
// Activity items (from globalPosts with label resolution)
const activity = useMemo<ActivityItem[]>(() => {
// Build code → label map from displayFeeds
const labelMap = new Map<string, string>();
for (const f of displayFeeds) labelMap.set(f.code, f.label);
return globalPosts.slice(0, 50).map((post) => {
// Resolve region label from post's t-tags
let regionLabel = 'Unknown';
for (const [name, value] of post.tags) {
if (name !== 't') continue;
if (labelMap.has(value)) { regionLabel = labelMap.get(value)!; break; }
// Try state resolution
const stateCode = getStateCodeForHashtag(value);
if (stateCode && labelMap.has(stateCode)) { regionLabel = labelMap.get(stateCode)!; break; }
}
// Content fallback: resolve municipality from content for legacy posts
if (regionLabel === 'Unknown') {
const contentMuni = extractMunicipalityFromContent(post.content);
if (contentMuni && labelMap.has(contentMuni)) {
regionLabel = labelMap.get(contentMuni)!;
}
}
return {
id: post.id,
pubkey: post.pubkey,
content: post.content,
created_at: post.created_at,
regionLabel,
};
});
}, [globalPosts, displayFeeds]);
// Status — useMultiHashtagFeed swallows relay errors so `error` is always
// null in practice. We keep the DashboardStatus type unchanged (consumers
// may still reference 'disconnected') but never produce it here.
const hasData = globalPosts.length > 0;
const status: DashboardStatus = isLoading && !hasData
? 'connecting'
: isBackfilling
? 'syncing'
: 'live';
return {
kpis,
timeSeries,
leaderboard,
distribution,
participants,
activity,
status,
isLoading: isLoading && !hasData,
error: error as Error | null,
};
}