SkateSpots/src/hooks/useSpotStatus.ts
shakespeare.diy a9e0c1dcb7 Add community Spot Status Reports (kind 2259)
Solves the "spots that change appearance or disappear" problem with
crowd-sourced, trust-weighted status reports — since the spot owner
often isn't the one who notices a spot got knobbed/demolished/busted.

New custom kind 2259 (regular, append-only): any user can report a
spot's current physical state — active / bust / skate-stopped / closed /
gone — with an optional note, referencing the spot via its `a`
coordinate.

Client aggregation (useSpotStatus / useBatchSpotStatus):
- recency-weighted (30-day half-life) so fresh reports dominate and a
  rebuilt spot naturally recovers
- trust-weighted via existing useBatchTrustScores (0.2–1.0 multiplier)
- most-recent-report-per-reporter (no ballot stuffing)
- reports older than 180 days ignored

UI:
- SpotStatusBadge (colored consensus badge + tooltip w/ report count)
- ReportSpotStatusDialog (status picker, optional note, recent reports)
- Badge + Report Status button on SpotDetailPage
- Batched status badge on SpotCard; "gone" spots dimmed/grayscaled
- "gone" spots hidden from map markers (still listed dimmed) to keep the
  map uncluttered while preserving the record

Documented the kind, tags, status values, and aggregation rules in
NIP.md.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-07-24 23:06:10 +07:00

280 lines
8.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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';
/** 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<SpotStatusValue, number>;
}
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<string, number> | 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<string, SpotStatusReport>();
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<SpotStatusValue, number> = {
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 { nostr } = useNostr();
const spotRefs = useMemo(
() => spots.map((s) => spotRefFor(s.pubkey, s.identifier)),
[spots],
);
const reportsQuery = useQuery({
queryKey: ['batch-spot-status', 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 nostr.query(
[{ 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<string, SpotStatusReport[]>();
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<string>();
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<string, AggregatedSpotStatus>();
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'] });
},
});
}