SkateSpots/src/lib/spotStatus.ts
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

87 lines
2.4 KiB
TypeScript

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