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 */} +