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 (
+
+ );
+}
diff --git a/src/components/SpotCard.tsx b/src/components/SpotCard.tsx
index 877c07d..5ba2301 100644
--- a/src/components/SpotCard.tsx
+++ b/src/components/SpotCard.tsx
@@ -20,15 +20,19 @@ import { TrustBadge } from '@/components/TrustBadge';
import { OfficialBadge } from '@/components/OfficialBadge';
import { AddToCollectionDialog } from '@/components/AddToCollectionDialog';
import { ReportDialog } from '@/components/ReportDialog';
+import { SpotStatusBadge } from '@/components/SpotStatusBadge';
import type { SkateSpot } from '@/lib/types';
import { SPOT_TYPE_LABELS, ACCESS_TYPE_INFO } from '@/lib/types';
+import type { AggregatedSpotStatus } from '@/hooks/useSpotStatus';
import { cn } from '@/lib/utils';
interface SpotCardProps {
spot: SkateSpot;
+ /** Optional community status, passed in batched from the parent list/map. */
+ status?: AggregatedSpotStatus | null;
}
-export function SpotCard({ spot }: SpotCardProps) {
+export function SpotCard({ spot, status }: SpotCardProps) {
const author = useAuthor(spot.pubkey);
const { user } = useCurrentUser();
const metadata = author.data?.metadata;
@@ -41,13 +45,17 @@ export function SpotCard({ spot }: SpotCardProps) {
const isOwner = user?.pubkey === spot.pubkey;
const route = isShop ? `/shop/${spot.pubkey}/${spot.identifier}` : `/spot/${spot.pubkey}/${spot.identifier}`;
+ // Dim spots the community has reported as gone/demolished.
+ const isGone = status?.status === 'gone';
+
return (
{/* Image */}