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:
parent
9c7cd5ec4e
commit
a9e0c1dcb7
9 changed files with 861 additions and 4 deletions
76
NIP.md
76
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": "<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.
|
||||
|
|
|
|||
212
src/components/ReportSpotStatusDialog.tsx
Normal file
212
src/components/ReportSpotStatusDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 ? (
|
||||
|
|
|
|||
85
src/components/SpotStatusBadge.tsx
Normal file
85
src/components/SpotStatusBadge.tsx
Normal 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
280
src/hooks/useSpotStatus.ts
Normal 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
87
src/lib/spotStatus.ts
Normal 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';
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue