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>
This commit is contained in:
shakespeare.diy 2026-07-24 23:06:10 +07:00
parent 9c7cd5ec4e
commit a9e0c1dcb7
9 changed files with 861 additions and 4 deletions

76
NIP.md
View file

@ -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": "<optional freeform note, e.g. 'new knobs on the ledge'>",
"tags": [
["a", "30818:<spot-owner-pubkey>:<spot-d-tag>"],
["K", "30818"],
["status", "gone"],
["t", "spot-status"],
["alt", "Spot status report: \"<spot title>\" 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:<pubkey>:<d-tag>` |
| `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.

View file

@ -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 (
<div className="flex items-start gap-2 py-2 border-b border-border/50 last:border-0">
<span
className="mt-1.5 inline-block rounded-full flex-shrink-0"
style={{ backgroundColor: meta.dot, width: 8, height: 8 }}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 text-xs">
<span className="font-semibold">{meta.label}</span>
<span className="text-muted-foreground truncate">by {name}</span>
<span className="text-muted-foreground ml-auto flex-shrink-0">
{statusTimeAgo(report.createdAt)}
</span>
</div>
{report.note && (
<p className="text-xs text-muted-foreground mt-0.5 break-words">{report.note}</p>
)}
</div>
</div>
);
}
export function ReportSpotStatusDialog({
spotPubkey,
spotIdentifier,
spotTitle,
trigger,
}: ReportSpotStatusDialogProps) {
const { user } = useCurrentUser();
const { toast } = useToast();
const [open, setOpen] = useState(false);
const [selected, setSelected] = useState<SpotStatusValue | null>(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 (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{trigger ?? (
<Button variant="outline" size="sm" className="gap-2">
<Flag className="h-4 w-4" />
Report Status
</Button>
)}
</DialogTrigger>
<DialogContent className="sm:max-w-md max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Flag className="h-5 w-5 text-[#ff0040]" />
Spot Status
</DialogTitle>
<DialogDescription>
Is "{spotTitle}" still skateable? Let the community know its current state.
</DialogDescription>
</DialogHeader>
{/* Current consensus */}
<div className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground">Current:</span>
{aggregated.status ? (
<SpotStatusBadge aggregated={aggregated} />
) : (
<span className="text-muted-foreground italic">No reports yet</span>
)}
</div>
{user ? (
<div className="space-y-4">
{/* Status picker */}
<div className="grid grid-cols-1 gap-2">
{SPOT_STATUS_ORDER.map((value) => {
const meta = SPOT_STATUS_META[value];
const isSel = selected === value;
return (
<button
key={value}
type="button"
onClick={() => setSelected(value)}
className={cn(
'flex items-center gap-3 rounded-lg border p-3 text-left transition-colors',
isSel
? 'border-[#ff0040] bg-[#ff0040]/10'
: 'border-border hover:border-border/80 hover:bg-muted/50',
)}
>
<span
className="inline-block rounded-full flex-shrink-0"
style={{ backgroundColor: meta.dot, width: 12, height: 12 }}
/>
<div className="min-w-0 flex-1">
<p className="font-semibold text-sm">{meta.label}</p>
<p className="text-xs text-muted-foreground">{meta.description}</p>
</div>
{isSel && <Check className="h-4 w-4 text-[#ff0040] flex-shrink-0" />}
</button>
);
})}
</div>
{/* Optional note */}
<Textarea
placeholder="Optional: add details (e.g. 'new knobs on the ledge', 'guard kicks out after 6pm')"
value={note}
onChange={(e) => setNote(e.target.value.slice(0, 500))}
rows={2}
className="resize-none"
/>
<Button
onClick={handleSubmit}
disabled={!selected || isPending}
className="w-full bg-[#ff0040] hover:bg-[#ff0040]/90"
>
{isPending ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Submitting...
</>
) : (
'Submit Report'
)}
</Button>
</div>
) : (
<div className="space-y-2">
<p className="text-sm text-muted-foreground">
Log in to report this spot's status.
</p>
<LoginArea className="w-full" />
</div>
)}
{/* Recent reports */}
{reports.length > 0 && (
<div className="mt-2">
<h4 className="text-xs font-semibold uppercase text-muted-foreground mb-1">
Recent reports
</h4>
<div className="max-h-48 overflow-y-auto">
{reports.slice(0, 20).map((r) => (
<ReportRow key={r.id} report={r} />
))}
</div>
</div>
)}
</DialogContent>
</Dialog>
);
}

View file

@ -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 (
<Link to={route}>
<Card className={cn(
"group overflow-hidden hover:shadow-2xl transition-all duration-300 border-2 bg-black/90 backdrop-blur",
isShop
? "border-[#ccff00]/30 hover:border-[#ccff00]/70 hover:shadow-[#ccff00]/30"
: "border-[#ff0040]/20 hover:border-[#ff0040]/60 hover:shadow-[#ff0040]/30"
: "border-[#ff0040]/20 hover:border-[#ff0040]/60 hover:shadow-[#ff0040]/30",
isGone && "opacity-60 grayscale hover:opacity-90"
)}>
{/* Image */}
<div className="relative aspect-video overflow-hidden bg-black">
@ -71,6 +79,12 @@ export function SpotCard({ spot }: SpotCardProps) {
)}
</div>
)}
{/* Community status badge (non-shop) */}
{!isShop && status?.status && (
<div className="absolute top-3 right-3">
<SpotStatusBadge aggregated={status} size="sm" />
</div>
)}
{/* Shop badge or Type badges overlay */}
<div className="absolute top-3 left-3 flex flex-col gap-1">
{isShop ? (

View file

@ -0,0 +1,85 @@
import { cn } from '@/lib/utils';
import { SPOT_STATUS_META, statusTimeAgo } from '@/lib/spotStatus';
import type { AggregatedSpotStatus } from '@/hooks/useSpotStatus';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
interface SpotStatusBadgeProps {
aggregated: AggregatedSpotStatus | null | undefined;
/** Compact = dot + label only, no meta. */
size?: 'sm' | 'md';
className?: string;
/** Hide the badge entirely when there are no reports (default true). */
hideWhenEmpty?: boolean;
}
/**
* Displays the community-consensus status for a spot.
* - No reports → renders nothing (unless hideWhenEmpty=false, then "Unverified").
* - Otherwise → colored badge with the winning status + reporter count tooltip.
*/
export function SpotStatusBadge({
aggregated,
size = 'md',
className,
hideWhenEmpty = true,
}: SpotStatusBadgeProps) {
const status = aggregated?.status ?? null;
if (!status) {
if (hideWhenEmpty) return null;
return (
<span
className={cn(
'inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium',
'text-muted-foreground bg-muted/40 border-border',
className,
)}
>
Unverified
</span>
);
}
const meta = SPOT_STATUS_META[status];
const reporterCount = aggregated?.reporterCount ?? 0;
const lastAt = aggregated?.lastReportedAt ?? null;
const badge = (
<span
className={cn(
'inline-flex items-center gap-1 rounded-full border font-semibold',
meta.badgeClass,
size === 'sm' ? 'px-1.5 py-0.5 text-[10px]' : 'px-2 py-0.5 text-xs',
className,
)}
>
<span
className="inline-block rounded-full"
style={{
backgroundColor: meta.dot,
width: size === 'sm' ? 6 : 8,
height: size === 'sm' ? 6 : 8,
}}
/>
{meta.label}
</span>
);
return (
<Tooltip>
<TooltipTrigger asChild>{badge}</TooltipTrigger>
<TooltipContent className="max-w-56">
<p className="font-medium">{meta.label}</p>
<p className="text-xs text-muted-foreground">{meta.description}</p>
<p className="mt-1 text-xs text-muted-foreground">
{reporterCount} {reporterCount === 1 ? 'report' : 'reports'}
{lastAt ? ` · last ${statusTimeAgo(lastAt)}` : ''}
</p>
</TooltipContent>
</Tooltip>
);
}

280
src/hooks/useSpotStatus.ts Normal file
View file

@ -0,0 +1,280 @@
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'] });
},
});
}

87
src/lib/spotStatus.ts Normal file
View file

@ -0,0 +1,87 @@
import type { SpotStatusValue } from '@/lib/types';
export interface SpotStatusMeta {
value: SpotStatusValue;
/** Short human label. */
label: string;
/** Emoji used in compact contexts. */
emoji: string;
/** One-line description for pickers. */
description: string;
/** Tailwind classes for badge text + background + border. */
badgeClass: string;
/** Hex dot color for map/legend use. */
dot: string;
}
export const SPOT_STATUS_META: Record<SpotStatusValue, SpotStatusMeta> = {
active: {
value: 'active',
label: 'Active',
emoji: '🟢',
description: 'Good to go — skate it.',
badgeClass: 'text-green-400 bg-green-500/10 border-green-500/30',
dot: '#22c55e',
},
bust: {
value: 'bust',
label: 'Bust',
emoji: '🟡',
description: 'Heavy kick-out risk — security or cops.',
badgeClass: 'text-yellow-400 bg-yellow-500/10 border-yellow-500/30',
dot: '#eab308',
},
skate_stopped: {
value: 'skate_stopped',
label: 'Skate-stopped',
emoji: '🟠',
description: 'Knobbed / capped, but partially skateable.',
badgeClass: 'text-orange-400 bg-orange-500/10 border-orange-500/30',
dot: '#f97316',
},
closed: {
value: 'closed',
label: 'Closed',
emoji: '⚪',
description: 'Temporarily closed — construction or event.',
badgeClass: 'text-gray-300 bg-gray-500/10 border-gray-500/30',
dot: '#9ca3af',
},
gone: {
value: 'gone',
label: 'Gone',
emoji: '🔴',
description: 'Demolished, paved over, or fenced off for good.',
badgeClass: 'text-red-400 bg-red-500/10 border-red-500/40',
dot: '#ef4444',
},
};
/** Ordered list for pickers/legends (best → worst). */
export const SPOT_STATUS_ORDER: SpotStatusValue[] = [
'active',
'bust',
'skate_stopped',
'closed',
'gone',
];
/** Human "time ago" for a unix-seconds timestamp. */
export function statusTimeAgo(unixSeconds: number): string {
const diff = Math.max(0, Math.floor(Date.now() / 1000) - unixSeconds);
const days = Math.floor(diff / 86400);
if (days >= 365) {
const y = Math.floor(days / 365);
return `${y}y ago`;
}
if (days >= 30) {
const m = Math.floor(days / 30);
return `${m}mo ago`;
}
if (days >= 1) return `${days}d ago`;
const hours = Math.floor(diff / 3600);
if (hours >= 1) return `${hours}h ago`;
const mins = Math.floor(diff / 60);
if (mins >= 1) return `${mins}m ago`;
return 'just now';
}

View file

@ -55,6 +55,7 @@ export const MARKETPLACE_ITEM_KIND = 30402; // NIP-99 Classified Listings
export const SPOT_COLLECTION_KIND = 30003; // NIP-51 Bookmark sets for spot collections
export const SKATE_TOUR_KIND = 30823; // Local skate tours - guides offer to show hidden spots
export const SPOT_CHAT_KIND = 24899; // Ephemeral geohash-anchored local chat ("Spot Chat"). SkateSpots-native.
export const SPOT_STATUS_KIND = 2259; // Regular event - community "spot status report" (active/bust/skate-stopped/gone/closed). SkateSpots-native.
/**
* Lab provider entity type.
@ -350,6 +351,67 @@ export function parseSkateSpot(event: NostrEvent): SkateSpot | null {
};
}
// ─── Spot Status Reports (kind 2259) ──────────────────────────────────────────
/**
* Possible physical states of a spot, reported by the community.
* Ordered roughly best → worst for display purposes.
*/
export const SPOT_STATUS_VALUES = [
'active', // 🟢 Good to go
'bust', // 🟡 Heavy kick-out risk / security
'skate_stopped', // 🟠 Knobbed / capped but partially skateable
'closed', // ⚪ Temporarily closed (construction / event)
'gone', // 🔴 Demolished / paved over / no longer exists
] as const;
export type SpotStatusValue = (typeof SPOT_STATUS_VALUES)[number];
export interface SpotStatusReport {
id: string;
pubkey: string;
/** Coordinate of the spot this report references: `30818:pubkey:dtag` */
spotRef: string;
status: SpotStatusValue;
/** Optional freeform note from the reporter */
note: string;
createdAt: number;
event: NostrEvent;
}
const MAX_STATUS_NOTE_LEN = 500;
/** Type guard for a valid spot status value. */
export function isSpotStatusValue(value: string): value is SpotStatusValue {
return (SPOT_STATUS_VALUES as readonly string[]).includes(value);
}
/**
* Parse a kind 2259 event into a SpotStatusReport.
* Requires:
* - an `a` tag referencing a skate spot (`30818:pubkey:dtag`)
* - a `status` tag holding one of SPOT_STATUS_VALUES
*/
export function parseSpotStatusReport(event: NostrEvent): SpotStatusReport | null {
if (event.kind !== SPOT_STATUS_KIND) return null;
const spotRef = event.tags.find(([name]) => name === 'a')?.[1];
if (!spotRef || !spotRef.startsWith(`${SKATE_SPOT_KIND}:`)) return null;
const status = event.tags.find(([name]) => name === 'status')?.[1];
if (!status || !isSpotStatusValue(status)) return null;
return {
id: event.id,
pubkey: event.pubkey,
spotRef,
status,
note: clampStr(event.content, MAX_STATUS_NOTE_LEN) ?? '',
createdAt: event.created_at,
event,
};
}
export function parseSkateShop(event: NostrEvent): SkateShop | null {
if (event.kind !== SKATE_SHOP_KIND) return null;

View file

@ -31,6 +31,7 @@ import { useBtcMapPlaces } from '@/hooks/useBtcMapPlaces';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useFollowing } from '@/hooks/useFollowing';
import { useBatchTrustScores } from '@/hooks/useBatchTrustScores';
import { useBatchSpotStatus } from '@/hooks/useSpotStatus';
import { useMultipleSpotWeather } from '@/hooks/useSpotWeather';
import { useComments } from '@/hooks/useComments';
import { useStash, STASH_CATEGORIES, CONDITION_LABELS, RARITY_TIERS, type StashItem } from '@/hooks/useStash';
@ -722,6 +723,13 @@ export default function MapPage() {
const getTrustScore = (pubkey: string) => trustScores?.get(pubkey);
// Fetch community status reports for all spots (not shops), batched in one query.
const statusSpotRefs = useMemo(
() => (spots || []).map((s) => ({ pubkey: s.pubkey, identifier: s.identifier })),
[spots],
);
const { getStatus: getSpotStatus } = useBatchSpotStatus(statusSpotRefs);
// Fetch comments for popularity sorting
const spotIds = useMemo(() => allLocations.map((l) => l.event.id), [allLocations]);
const { data: allComments } = useComments(spotIds);
@ -923,6 +931,17 @@ export default function MapPage() {
return filtered;
}, [allLocations, searchQuery, selectedCategories, weatherEnabled, weatherData, weatherFilter, showSpots, showShops, trustFilter, trustScores, sortBy, userLocation, allComments]);
// Spots shown as MAP MARKERS. Hide spots the community has confirmed as
// "gone" (demolished) so they don't clutter the map — they still appear
// (dimmed, with a badge) in the card list below so the record isn't lost.
const mapSpots = useMemo(() => {
return filteredSpots.filter((item) => {
if (item.isShop) return true;
const status = getSpotStatus(item.pubkey, item.identifier);
return status?.status !== 'gone';
});
}, [filteredSpots, getSpotStatus]);
const toggleCategory = (category: string) => {
setSelectedCategories((prev) =>
prev.includes(category)
@ -1511,7 +1530,7 @@ export default function MapPage() {
</div>
}>
<SpotMap
spots={filteredSpots}
spots={mapSpots}
marketplaceItems={marketplaceItems || []}
tours={tours || []}
btcMapPlaces={btcMapPlaces || []}
@ -1792,7 +1811,11 @@ export default function MapPage() {
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 stagger-children">
{filteredSpots.map((spot) => (
<SpotCard key={spot.id} spot={spot} />
<SpotCard
key={spot.id}
spot={spot}
status={spot.isShop ? null : getSpotStatus(spot.pubkey, spot.identifier)}
/>
))}
</div>
</>

View file

@ -68,6 +68,9 @@ import { AddToCollectionDialog } from '@/components/AddToCollectionDialog';
import { AddPhotoToSpot } from '@/components/AddPhotoToSpot';
import { AddTagsToSpot } from '@/components/AddTagsToSpot';
import { SpotOwnership } from '@/components/SpotOwnership';
import { SpotStatusBadge } from '@/components/SpotStatusBadge';
import { ReportSpotStatusDialog } from '@/components/ReportSpotStatusDialog';
import { useSpotStatus } from '@/hooks/useSpotStatus';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useSpotPhotos, useAggregatedSpotTags } from '@/hooks/useSpotContributions';
import {
@ -132,6 +135,9 @@ export default function SpotDetailPage() {
const { data: communityPhotos } = useSpotPhotos(pubkey || '', identifier || '');
const aggregatedTags = useAggregatedSpotTags(pubkey || '', identifier || '');
// Community spot status (active / bust / skate-stopped / closed / gone)
const { aggregated: spotStatus } = useSpotStatus(pubkey || '', identifier || '');
useSeoMeta({
title: spot ? `${spot.title} - SkateSpots` : 'Skate Spot - SkateSpots',
description: spot?.description || 'View this skateboarding spot on SkateSpots.',
@ -291,6 +297,11 @@ export default function SpotDetailPage() {
<span className="break-words">{spot.location}</span>
</p>
)}
{!isShop && spotStatus.status && (
<div className="mt-2">
<SpotStatusBadge aggregated={spotStatus} />
</div>
)}
</div>
<div className="flex items-center gap-2 flex-wrap">
{isOwner && (
@ -332,6 +343,13 @@ export default function SpotDetailPage() {
</Button>
</>
)}
{!isShop && (
<ReportSpotStatusDialog
spotPubkey={spot.pubkey}
spotIdentifier={spot.identifier}
spotTitle={spot.title}
/>
)}
<Button variant="outline" size="icon" onClick={handleShare}>
<Share2 className="h-4 w-4" />
</Button>