From a9e0c1dcb7a45a15637d7fc8162b67078c1b4044 Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Fri, 24 Jul 2026 23:06:10 +0700 Subject: [PATCH] Add community Spot Status Reports (kind 2259) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- NIP.md | 76 ++++++ src/components/ReportSpotStatusDialog.tsx | 212 ++++++++++++++++ src/components/SpotCard.tsx | 18 +- src/components/SpotStatusBadge.tsx | 85 +++++++ src/hooks/useSpotStatus.ts | 280 ++++++++++++++++++++++ src/lib/spotStatus.ts | 87 +++++++ src/lib/types.ts | 62 +++++ src/pages/MapPage.tsx | 27 ++- src/pages/SpotDetailPage.tsx | 18 ++ 9 files changed, 861 insertions(+), 4 deletions(-) create mode 100644 src/components/ReportSpotStatusDialog.tsx create mode 100644 src/components/SpotStatusBadge.tsx create mode 100644 src/hooks/useSpotStatus.ts create mode 100644 src/lib/spotStatus.ts diff --git a/NIP.md b/NIP.md index 89f943f..694280c 100644 --- a/NIP.md +++ b/NIP.md @@ -1991,3 +1991,79 @@ typing always works. |-----|-------------| | `n` | Namespace marker, always `skate` for SkateSpots channels | | `alt` | NIP-31 human-readable description | + +## Spot Status Reports (kind 2259) + +Skate spots change: a ledge gets knobbed, a plaza gets demolished, security +starts busting a spot, or construction closes it temporarily. Because spots +(kind 30818) are addressable events owned by one author, and that author often +isn't the person who notices the change, SkateSpots uses **community status +reports** to keep the map honest. + +A status report is a lightweight **regular event (kind 2259)** that any user can +publish about any spot. The client aggregates recent reports — weighted by +**recency** and reporter **trust score** — into a single consensus status shown +as a badge on the spot. Reports are append-only; nothing is deleted, so a spot's +history is preserved and a "gone" spot can naturally recover if it's later +reported "active" again. + +This is **SkateSpots-native** and not intended to interoperate with other +clients. + +### Event Format + +```json +{ + "kind": 2259, + "content": "", + "tags": [ + ["a", "30818::"], + ["K", "30818"], + ["status", "gone"], + ["t", "spot-status"], + ["alt", "Spot status report: \"\" is gone"] + ] +} +``` + +### Status Values + +The `status` tag MUST hold one of the following values: + +| Value | Meaning | +|-------|---------| +| `active` | 🟢 Good to go — skate it. | +| `bust` | 🟡 Heavy kick-out risk (security / cops). | +| `skate_stopped` | 🟠 Knobbed / capped, but partially skateable. | +| `closed` | ⚪ Temporarily closed (construction / event). | +| `gone` | 🔴 Demolished, paved over, or fenced off for good. | + +### Required Tags + +| Tag | Description | +|-----|-------------| +| `a` | Coordinate of the referenced spot: `30818::` | +| `status` | One of the status values above | + +### Optional Tags + +| Tag | Description | +|-----|-------------| +| `K` | Kind of the referenced event, always `30818` | +| `t` | Category marker, always `spot-status` (relay-indexed for querying) | +| `alt` | NIP-31 human-readable description | + +### Aggregation (client behavior) + +- Reports older than **180 days** are ignored (spots change back). +- Only the **most recent report per reporter** counts (no ballot-stuffing). +- Each report's weight = `recencyWeight × trustWeight`: + - **Recency**: exponential decay with a 30-day half-life — fresh reports + dominate stale ones. + - **Trust**: reporter trust score (0–100) mapped to a 0.2–1.0 multiplier, so + everyone has some voice but followed/verified users carry more. +- The status with the highest total weight wins; `confidence` = winning weight / + total weight. +- Spots with a winning status of `gone` are **hidden from map markers** (but + still shown dimmed, with a badge, in card lists) so the map isn't cluttered by + demolished spots while preserving the record. diff --git a/src/components/ReportSpotStatusDialog.tsx b/src/components/ReportSpotStatusDialog.tsx new file mode 100644 index 0000000..45762b4 --- /dev/null +++ b/src/components/ReportSpotStatusDialog.tsx @@ -0,0 +1,212 @@ +import { useState } from 'react'; +import { Loader2, Flag, Check } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Textarea } from '@/components/ui/textarea'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { LoginArea } from '@/components/auth/LoginArea'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useToast } from '@/hooks/useToast'; +import { usePublishSpotStatus, useSpotStatus } from '@/hooks/useSpotStatus'; +import { SpotStatusBadge } from '@/components/SpotStatusBadge'; +import { SPOT_STATUS_META, SPOT_STATUS_ORDER, statusTimeAgo } from '@/lib/spotStatus'; +import { useAuthor } from '@/hooks/useAuthor'; +import { genUserName } from '@/lib/genUserName'; +import type { SpotStatusValue, SpotStatusReport } from '@/lib/types'; +import { cn } from '@/lib/utils'; + +interface ReportSpotStatusDialogProps { + spotPubkey: string; + spotIdentifier: string; + spotTitle: string; + trigger?: React.ReactNode; +} + +function ReportRow({ report }: { report: SpotStatusReport }) { + const author = useAuthor(report.pubkey); + const name = author.data?.metadata?.name ?? genUserName(report.pubkey); + const meta = SPOT_STATUS_META[report.status]; + + return ( +
+ +
+
+ {meta.label} + by {name} + + {statusTimeAgo(report.createdAt)} + +
+ {report.note && ( +

{report.note}

+ )} +
+
+ ); +} + +export function ReportSpotStatusDialog({ + spotPubkey, + spotIdentifier, + spotTitle, + trigger, +}: ReportSpotStatusDialogProps) { + const { user } = useCurrentUser(); + const { toast } = useToast(); + const [open, setOpen] = useState(false); + const [selected, setSelected] = useState(null); + const [note, setNote] = useState(''); + + const { aggregated, reports } = useSpotStatus(spotPubkey, spotIdentifier); + const { mutateAsync: publishStatus, isPending } = usePublishSpotStatus(); + + const handleSubmit = async () => { + if (!selected) return; + try { + await publishStatus({ + spotPubkey, + spotIdentifier, + spotTitle, + status: selected, + note: note.trim() || undefined, + }); + toast({ + title: 'Status reported', + description: 'Thanks for keeping the map fresh. Your report is live.', + }); + setSelected(null); + setNote(''); + setOpen(false); + } catch (error) { + toast({ + title: 'Could not submit', + description: error instanceof Error ? error.message : 'Something went wrong.', + variant: 'destructive', + }); + } + }; + + return ( + + + {trigger ?? ( + + )} + + + + + + Spot Status + + + Is "{spotTitle}" still skateable? Let the community know its current state. + + + + {/* Current consensus */} +
+ Current: + {aggregated.status ? ( + + ) : ( + No reports yet + )} +
+ + {user ? ( +
+ {/* Status picker */} +
+ {SPOT_STATUS_ORDER.map((value) => { + const meta = SPOT_STATUS_META[value]; + const isSel = selected === value; + return ( + + ); + })} +
+ + {/* Optional note */} +