import { useMemo } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useNostr } from '@nostrify/react'; import type { NostrEvent } from '@nostrify/nostrify'; import { SPOT_STATUS_KIND, SKATE_SPOT_KIND, parseSpotStatusReport, type SpotStatusReport, type SpotStatusValue, } from '@/lib/types'; import { useBatchTrustScores } from '@/hooks/useBatchTrustScores'; import { useNostrPublish } from '@/hooks/useNostrPublish'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useSkateRelays } from '@/hooks/useSkateRelays'; /** Reports older than this are ignored entirely (spots do change back). */ const REPORT_MAX_AGE_SECONDS = 180 * 24 * 60 * 60; // 180 days /** Half-life for recency weighting — a report loses half its weight every 30 days. */ const RECENCY_HALF_LIFE_SECONDS = 30 * 24 * 60 * 60; export interface AggregatedSpotStatus { /** The winning (highest-weighted) status, or null if no reports. */ status: SpotStatusValue | null; /** Confidence 0–1 = winning weight / total weight. */ confidence: number; /** Number of distinct reporters contributing. */ reporterCount: number; /** Timestamp of the most recent report used. */ lastReportedAt: number | null; /** Per-status weighted tallies (for a breakdown UI if desired). */ tallies: Record; } function spotRefFor(pubkey: string, identifier: string): string { return `${SKATE_SPOT_KIND}:${pubkey}:${identifier}`; } /** * Compute a recency weight in (0, 1]. A brand-new report = 1; a report one * half-life old = 0.5; and so on. This makes fresh reports dominate stale ones, * so a spot that was "gone" but got rebuilt naturally recovers over time. */ function recencyWeight(createdAt: number, now: number): number { const age = Math.max(0, now - createdAt); return Math.pow(0.5, age / RECENCY_HALF_LIFE_SECONDS); } /** * Aggregate a list of reports into a single consensus status. * * Each report contributes `recencyWeight × trustWeight`. Trust weight comes from * the reporter's trust score (0–100) mapped to a floor of 0.2 so that even * untrusted users have *some* voice, but followed/verified users dominate. * Only the most recent report from each reporter counts (people change their * minds; we don't let one person stuff the ballot). */ function aggregate( reports: SpotStatusReport[], trustScores: Map | undefined, now: number, ): AggregatedSpotStatus { const empty: AggregatedSpotStatus = { status: null, confidence: 0, reporterCount: 0, lastReportedAt: null, tallies: { active: 0, bust: 0, skate_stopped: 0, closed: 0, gone: 0 }, }; if (reports.length === 0) return empty; // Keep only the most recent report per reporter, within the max age window. const latestByReporter = new Map(); for (const r of reports) { if (now - r.createdAt > REPORT_MAX_AGE_SECONDS) continue; const existing = latestByReporter.get(r.pubkey); if (!existing || r.createdAt > existing.createdAt) { latestByReporter.set(r.pubkey, r); } } if (latestByReporter.size === 0) return empty; const tallies: Record = { active: 0, bust: 0, skate_stopped: 0, closed: 0, gone: 0, }; let totalWeight = 0; let lastReportedAt = 0; for (const r of latestByReporter.values()) { const trust = trustScores?.get(r.pubkey) ?? 0; // Map trust 0–100 → 0.2–1.0 so everyone counts a little, trusted users more. const trustWeight = 0.2 + (Math.min(100, Math.max(0, trust)) / 100) * 0.8; const weight = recencyWeight(r.createdAt, now) * trustWeight; tallies[r.status] += weight; totalWeight += weight; if (r.createdAt > lastReportedAt) lastReportedAt = r.createdAt; } // Find the winning status. let winning: SpotStatusValue = 'active'; let winningWeight = -1; (Object.keys(tallies) as SpotStatusValue[]).forEach((s) => { if (tallies[s] > winningWeight) { winningWeight = tallies[s]; winning = s; } }); return { status: winning, confidence: totalWeight > 0 ? winningWeight / totalWeight : 0, reporterCount: latestByReporter.size, lastReportedAt: lastReportedAt || null, tallies, }; } /** * Fetch raw status reports for a SINGLE spot (used on the detail page). */ export function useSpotStatusReports(spotPubkey: string, spotIdentifier: string) { const { nostr } = useNostr(); const spotRef = spotRefFor(spotPubkey, spotIdentifier); return useQuery({ queryKey: ['spot-status-reports', spotRef], queryFn: async (c) => { const signal = AbortSignal.any([c.signal, AbortSignal.timeout(5000)]); const events = await nostr.query( [{ kinds: [SPOT_STATUS_KIND], '#a': [spotRef], limit: 200 }], { signal }, ); return events .map(parseSpotStatusReport) .filter((r): r is SpotStatusReport => r !== null) .sort((a, b) => b.createdAt - a.createdAt); }, enabled: !!spotPubkey && !!spotIdentifier, staleTime: 60_000, }); } /** * Aggregated consensus status for a SINGLE spot, trust- and recency-weighted. */ export function useSpotStatus(spotPubkey: string, spotIdentifier: string) { const { data: reports, isLoading } = useSpotStatusReports(spotPubkey, spotIdentifier); const reporterPubkeys = useMemo( () => Array.from(new Set((reports ?? []).map((r) => r.pubkey))), [reports], ); const { data: trustScores } = useBatchTrustScores(reporterPubkeys); const aggregated = useMemo( () => aggregate(reports ?? [], trustScores, Math.floor(Date.now() / 1000)), [reports, trustScores], ); return { aggregated, reports: reports ?? [], isLoading }; } /** * Batch: aggregated status for MANY spots at once (used on the map + cards). * Returns a Map keyed by spot coordinate (`30818:pubkey:dtag`). */ export function useBatchSpotStatus(spots: Array<{ pubkey: string; identifier: string }>) { const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); const spotRefs = useMemo( () => spots.map((s) => spotRefFor(s.pubkey, s.identifier)), [spots], ); const reportsQuery = useQuery({ queryKey: ['batch-spot-status', getQueryKeySuffix(), spotRefs.slice().sort().join(',')], queryFn: async (c) => { if (spotRefs.length === 0) return [] as NostrEvent[]; const signal = AbortSignal.any([c.signal, AbortSignal.timeout(8000)]); // Relays index single-letter tags, so '#a' with many refs is one query. const events = await querySkateContent( [{ kinds: [SPOT_STATUS_KIND], '#a': spotRefs, limit: 1000 }], { signal }, ); return events; }, enabled: spotRefs.length > 0, staleTime: 60_000, }); const reportsByRef = useMemo(() => { const map = new Map(); for (const ev of reportsQuery.data ?? []) { const report = parseSpotStatusReport(ev); if (!report) continue; const list = map.get(report.spotRef) ?? []; list.push(report); map.set(report.spotRef, list); } return map; }, [reportsQuery.data]); const reporterPubkeys = useMemo(() => { const set = new Set(); for (const list of reportsByRef.values()) { for (const r of list) set.add(r.pubkey); } return Array.from(set); }, [reportsByRef]); const { data: trustScores } = useBatchTrustScores(reporterPubkeys); const statusByRef = useMemo(() => { const now = Math.floor(Date.now() / 1000); const map = new Map(); for (const [ref, list] of reportsByRef.entries()) { map.set(ref, aggregate(list, trustScores, now)); } return map; }, [reportsByRef, trustScores]); return { statusByRef, isLoading: reportsQuery.isLoading, getStatus: (pubkey: string, identifier: string) => statusByRef.get(spotRefFor(pubkey, identifier)) ?? null, }; } /** * Publish a new status report for a spot. */ export function usePublishSpotStatus() { const { mutateAsync: publishEvent } = useNostrPublish(); const { user } = useCurrentUser(); const queryClient = useQueryClient(); return useMutation({ mutationFn: async (params: { spotPubkey: string; spotIdentifier: string; spotTitle: string; status: SpotStatusValue; note?: string; }) => { if (!user) throw new Error('You must be logged in to report a spot status.'); const spotRef = spotRefFor(params.spotPubkey, params.spotIdentifier); await publishEvent({ kind: SPOT_STATUS_KIND, content: (params.note ?? '').slice(0, 500), tags: [ ['a', spotRef], ['K', String(SKATE_SPOT_KIND)], ['status', params.status], ['t', 'spot-status'], [ 'alt', `Spot status report: "${params.spotTitle}" is ${params.status.replace('_', '-')}`, ], ], }); return { spotRef }; }, onSuccess: ({ spotRef }) => { queryClient.invalidateQueries({ queryKey: ['spot-status-reports', spotRef] }); queryClient.invalidateQueries({ queryKey: ['batch-spot-status'] }); }, }); }