SkateSpots/src/components/SpotStatusBadge.tsx
shakespeare.diy a9e0c1dcb7 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>
2026-07-24 23:06:10 +07:00

85 lines
2.4 KiB
TypeScript

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>
);
}