From a7b7e07e86a4cf6fa033a2f2178d1114f84aaa41 Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Thu, 10 Sep 2026 11:38:43 +0700 Subject: [PATCH] Add spot "Shelter" (weather protection) field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Skaters need to know if a spot is roofed/indoor so weather info isn't misleading — a covered or indoor spot is still skateable in the rain. Added a 4-way shelter level: outdoor (default) / partial / covered / indoor. Data model (types.ts + skateTags.ts): - SkateSpot.shelter parsed from a `['shelter', value]` tag (validated to known values; absent/unknown → treated as outdoor). - shelterOptions / DEFAULT_SHELTER / SHELTERED_VALUES + ShelterType exported. - SHELTER_INFO display map (emoji/label/short/color) for badges. Publishing (CreateSpotPage + EditSpotPage): - New "Shelter" selector reusing the exact selectable-Card pattern used by Access Type / Spot Type (cn() borders, neon ring, corner check badge). - Emits `['shelter', value]` always, plus a queryable `['t', value]` for sheltered spots (indoor/covered/partial) so "dry spots" filtering + Sasquatch search work at the relay level. Outdoor adds no t tag. - EditSpotPage initialises from the existing spot and preserves the value. Display: - Shelter badge on SpotDetailPage, SpotCard, and the MapPage popup — only shown for sheltered spots (outdoor stays unbadged to avoid clutter). Docs: - NIP.md: shelter added to the optional-tags table, a dedicated Shelter section explaining values + the queryable-t-tag behaviour, and an indoor example. Build clean, no console errors. Co-authored-by: shakespeare.diy --- NIP.md | 41 +++++++++++++++++++++++++++++ src/components/SpotCard.tsx | 13 +++++++++- src/lib/skateTags.ts | 25 ++++++++++++++++++ src/lib/types.ts | 28 ++++++++++++++++++++ src/pages/CreateSpotPage.tsx | 50 +++++++++++++++++++++++++++++++++++- src/pages/EditSpotPage.tsx | 47 ++++++++++++++++++++++++++++++++- src/pages/MapPage.tsx | 13 +++++++++- src/pages/SpotDetailPage.tsx | 14 +++++++++- 8 files changed, 226 insertions(+), 5 deletions(-) diff --git a/NIP.md b/NIP.md index b035a73..4955ac6 100644 --- a/NIP.md +++ b/NIP.md @@ -175,6 +175,7 @@ A skate spot is an addressable event that represents a skateboarding location an | `lat` | Latitude (decimal) | | `lon` | Longitude (decimal) | | `access` | Access type: `public`, `private`, `locals` | +| `shelter` | Weather-protection level: `outdoor` (default), `partial`, `covered`, `indoor` (see Shelter below) | | `contact` | Contact info for the spot | | `t` | Freeform hashtags for features, surfaces, vibes (max 20) | @@ -189,6 +190,25 @@ The `category` tag defines the primary type of spot. Only ONE category per spot: | `diy` | DIY/homemade spots | | `plaza` | Public plazas with skate-friendly architecture | +### Shelter (weather protection) + +The optional `shelter` tag records how protected a spot is from the weather, so +clients can avoid misleading skaters (e.g. showing "bad conditions" for an +indoor park just because it's raining outside). Exactly one value: + +| Value | Description | +|-------|-------------| +| `outdoor` | Fully exposed — the default; rain stops play | +| `partial` | Partly covered — some obstacles/flat stay dry | +| `covered` | Outdoor but under a roof (car park, band shell, overpass) | +| `indoor` | Fully enclosed facility — skateable rain or shine | + +**Relay-level filtering:** sheltered spots (`partial`, `covered`, `indoor`) also +carry a matching `t` tag (e.g. `["t", "indoor"]`) so clients can query "dry +spots" efficiently with `#t`. `outdoor` is the implicit default and adds **no** +`t` tag, to avoid tagging the vast majority of street spots. When `shelter` is +absent, clients should treat the spot as `outdoor`. + ### Freeform Tags (`t` tags) In addition to the required `skatespot` tag, spots can have up to **20 freeform tags** for detailed categorization. Tags are stored lowercase without `#` prefix. @@ -254,6 +274,27 @@ In addition to the required `skatespot` tag, spots can have up to **20 freeform } ``` +An indoor spot additionally carries the `shelter` tag plus its matching `t` tag +so it surfaces in "dry spots" queries: + +```json +{ + "kind": 30818, + "content": "Warehouse park — full street course, open when it rains.", + "tags": [ + ["d", "the-warehouse"], + ["title", "The Warehouse"], + ["g", "gcpvj0"], + ["category", "park"], + ["access", "public"], + ["shelter", "indoor"], + ["t", "skatespot"], + ["t", "indoor"], + ["alt", "Skate spot: The Warehouse"] + ] +} +``` + ## Querying Skate Spots ### All Spots diff --git a/src/components/SpotCard.tsx b/src/components/SpotCard.tsx index 5ba2301..702cd12 100644 --- a/src/components/SpotCard.tsx +++ b/src/components/SpotCard.tsx @@ -22,7 +22,7 @@ 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 { SPOT_TYPE_LABELS, ACCESS_TYPE_INFO, SHELTER_INFO } from '@/lib/types'; import type { AggregatedSpotStatus } from '@/hooks/useSpotStatus'; import { cn } from '@/lib/utils'; @@ -104,6 +104,17 @@ export function SpotCard({ spot, status }: SpotCardProps) { {ACCESS_TYPE_INFO[spot.accessType].emoji} {ACCESS_TYPE_INFO[spot.accessType].label.split(' - ')[0]} )} + {spot.shelter && spot.shelter !== 'outdoor' && SHELTER_INFO[spot.shelter] && ( + + {SHELTER_INFO[spot.shelter].emoji} {SHELTER_INFO[spot.shelter].short} + + )} {spot.types.length > 0 && (
{spot.types.slice(0, 2).map((type) => ( diff --git a/src/lib/skateTags.ts b/src/lib/skateTags.ts index 72ad988..02c50d2 100644 --- a/src/lib/skateTags.ts +++ b/src/lib/skateTags.ts @@ -420,5 +420,30 @@ export const spotCategories = [ export type SpotCategory = typeof spotCategories[number]['value']; +/** + * Shelter / weather-protection level of a spot. Skaters distinguish between a + * fully-exposed street spot and a roofed/indoor one, because rain kills the + * former but not the latter — this is what stops weather info from being + * misleading ("it's raining, but this spot is covered / indoors"). + * + * Stored on the spot event as a `['shelter', value]` tag plus a matching + * `['t', value]` tag for relay-level filtering (indoor/covered/partial). + * 'outdoor' is the default and adds no `t` tag (avoids tagging every spot). + */ +export const shelterOptions = [ + { value: 'outdoor', emoji: '☀️', label: 'Outdoor', description: 'Fully exposed — rain stops play' }, + { value: 'partial', emoji: '🌤️', label: 'Partly covered', description: 'Some obstacles stay dry' }, + { value: 'covered', emoji: '🏠', label: 'Covered / roofed', description: 'Outdoor but under a roof' }, + { value: 'indoor', emoji: '🏢', label: 'Indoor', description: 'Fully enclosed — skate rain or shine' }, +] as const; + +export type ShelterType = typeof shelterOptions[number]['value']; + +/** The default shelter value when the user doesn't pick one. */ +export const DEFAULT_SHELTER: ShelterType = 'outdoor'; + +/** Shelter values that get a queryable `t` tag (outdoor is the implicit default). */ +export const SHELTERED_VALUES: ShelterType[] = ['partial', 'covered', 'indoor']; + // Max tags allowed per spot export const MAX_TAGS = 20; diff --git a/src/lib/types.ts b/src/lib/types.ts index fe4c1ec..a550bcf 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -668,6 +668,8 @@ export interface SkateSpot { tags: string[]; // Freeform tags (features, surfaces, vibes) types: string[]; // Legacy - combined category + tags for backwards compat accessType?: 'public' | 'private' | 'locals'; + /** Weather-protection level: outdoor (default) | partial | covered | indoor. */ + shelter?: 'outdoor' | 'partial' | 'covered' | 'indoor'; contactInfo?: string; createdAt: number; event: NostrEvent; @@ -795,6 +797,16 @@ export function parseSkateSpot(event: NostrEvent): SkateSpot | null { const types = allTags; const accessType = event.tags.find(([name]) => name === 'access')?.[1] as 'public' | 'private' | 'locals' | undefined; + + // Shelter / weather-protection level. Accept only known values; anything else + // (or absent) falls back to undefined so the UI can treat it as outdoor. + const shelterRaw = event.tags.find(([name]) => name === 'shelter')?.[1]; + const shelter = (['outdoor', 'partial', 'covered', 'indoor'] as const).includes( + shelterRaw as 'outdoor' | 'partial' | 'covered' | 'indoor', + ) + ? (shelterRaw as 'outdoor' | 'partial' | 'covered' | 'indoor') + : undefined; + const contactInfo = event.tags.find(([name]) => name === 'contact')?.[1]; return { @@ -810,6 +822,7 @@ export function parseSkateSpot(event: NostrEvent): SkateSpot | null { tags, types, accessType, + shelter, contactInfo: clampStr(contactInfo, 200), createdAt: event.created_at, event, @@ -1275,6 +1288,21 @@ export const ACCESS_TYPE_INFO = { locals: { emoji: '👥', label: 'Ask Locals First', color: '#ccff00' }, }; +/** + * Display metadata for a spot's shelter / weather-protection level. `short` is + * used in tight spots (cards, map popups); `label` in the detail view. Only + * sheltered spots are worth badging — 'outdoor' is the default and returns no + * badge, so we don't clutter the vast majority of street spots. + */ +export const SHELTER_INFO: Record< + 'partial' | 'covered' | 'indoor', + { emoji: string; label: string; short: string; color: string } +> = { + partial: { emoji: '🌤️', label: 'Partly Covered', short: 'Partly Covered', color: '#38bdf8' }, + covered: { emoji: '🏠', label: 'Covered / Roofed', short: 'Covered', color: '#22d3ee' }, + indoor: { emoji: '🏢', label: 'Indoor', short: 'Indoor', color: '#818cf8' }, +}; + export const EVENT_TYPE_LABELS: Record = { contest: 'Contest', demo: 'Demo', diff --git a/src/pages/CreateSpotPage.tsx b/src/pages/CreateSpotPage.tsx index 6dc2dc9..33f4208 100644 --- a/src/pages/CreateSpotPage.tsx +++ b/src/pages/CreateSpotPage.tsx @@ -17,7 +17,7 @@ import { LoginArea } from '@/components/auth/LoginArea'; import { QualityMeter } from '@/components/QualityMeter'; import { TagInput } from '@/components/TagInput'; import { SKATE_SPOT_KIND } from '@/lib/types'; -import { spotCategories, MAX_TAGS } from '@/lib/skateTags'; +import { spotCategories, MAX_TAGS, shelterOptions, DEFAULT_SHELTER, SHELTERED_VALUES, type ShelterType } from '@/lib/skateTags'; import { cn } from '@/lib/utils'; import { MapPin, @@ -57,6 +57,7 @@ export default function CreateSpotPage() { const [selectedCategory, setSelectedCategory] = useState(''); const [tags, setTags] = useState([]); const [accessType, setAccessType] = useState<'public' | 'private' | 'locals'>('public'); + const [shelter, setShelter] = useState(DEFAULT_SHELTER); const [contactInfo, setContactInfo] = useState(''); const [images, setImages] = useState([]); const [isGettingLocation, setIsGettingLocation] = useState(false); @@ -320,6 +321,16 @@ export default function CreateSpotPage() { eventTags.push(['contact', contactInfo.trim()]); } + // Shelter / weather-protection level. Always record the explicit value so + // the spot detail + map can show it; additionally add a queryable `t` tag + // for sheltered spots (indoor/covered/partial) so "dry spots" filtering and + // Sasquatch search work at the relay level. Outdoor is the implicit default + // and adds no `t` tag (keeps the common case from tagging everything). + eventTags.push(['shelter', shelter]); + if (SHELTERED_VALUES.includes(shelter)) { + eventTags.push(['t', shelter]); + } + // Add category as dedicated tag (new format) if (selectedCategory) { eventTags.push(['category', selectedCategory]); @@ -618,6 +629,43 @@ export default function CreateSpotPage() {
+ {/* Shelter / weather protection */} +
+ +

+ Is this spot exposed to the weather? Helps skaters find dry spots when it rains. +

+
+ {shelterOptions.map((option) => ( + setShelter(option.value)} + > + +
+
{option.emoji}
+
+
{option.label}
+
{option.description}
+
+
+ {shelter === option.value && ( +
+ +
+ )} +
+
+ ))} +
+
+ {/* Freeform Tags */}
diff --git a/src/pages/EditSpotPage.tsx b/src/pages/EditSpotPage.tsx index 58f67df..fa51350 100644 --- a/src/pages/EditSpotPage.tsx +++ b/src/pages/EditSpotPage.tsx @@ -17,7 +17,7 @@ import { useSkateSpot } from '@/hooks/useSkateSpots'; import { LoginArea } from '@/components/auth/LoginArea'; import { TagInput } from '@/components/TagInput'; import { SKATE_SPOT_KIND } from '@/lib/types'; -import { spotCategories, MAX_TAGS } from '@/lib/skateTags'; +import { spotCategories, MAX_TAGS, shelterOptions, DEFAULT_SHELTER, SHELTERED_VALUES, type ShelterType } from '@/lib/skateTags'; import { cn } from '@/lib/utils'; import { MapPin, @@ -77,6 +77,7 @@ export default function EditSpotPage() { const [selectedCategory, setSelectedCategory] = useState(''); const [tags, setTags] = useState([]); const [accessType, setAccessType] = useState<'public' | 'private' | 'locals'>('public'); + const [shelter, setShelter] = useState(DEFAULT_SHELTER); const [contactInfo, setContactInfo] = useState(''); const [images, setImages] = useState([]); const [isGettingLocation, setIsGettingLocation] = useState(false); @@ -94,6 +95,7 @@ export default function EditSpotPage() { setDescription(spot.description || ''); setLocation(spot.location || ''); setAccessType(spot.accessType || 'public'); + setShelter(spot.shelter || DEFAULT_SHELTER); setContactInfo(spot.contactInfo || ''); setImages(spot.images); @@ -248,6 +250,12 @@ export default function EditSpotPage() { eventTags.push(['contact', contactInfo.trim()]); } + // Shelter / weather-protection level (see CreateSpotPage for rationale). + eventTags.push(['shelter', shelter]); + if (SHELTERED_VALUES.includes(shelter)) { + eventTags.push(['t', shelter]); + } + // Add category as dedicated tag (new format) if (selectedCategory) { eventTags.push(['category', selectedCategory]); @@ -571,6 +579,43 @@ export default function EditSpotPage() {
+ {/* Shelter / weather protection */} +
+ +

+ Is this spot exposed to the weather? Helps skaters find dry spots when it rains. +

+
+ {shelterOptions.map((option) => ( + setShelter(option.value)} + > + +
+
{option.emoji}
+
+
{option.label}
+
{option.description}
+
+
+ {shelter === option.value && ( +
+ +
+ )} +
+
+ ))} +
+
+ {/* Freeform Tags */}
diff --git a/src/pages/MapPage.tsx b/src/pages/MapPage.tsx index f3a5c0c..ee0e302 100644 --- a/src/pages/MapPage.tsx +++ b/src/pages/MapPage.tsx @@ -41,7 +41,7 @@ import { useSatsToUsd } from '@/hooks/useExchangeRate'; import { useLocalStorage } from '@/hooks/useLocalStorage'; import { genUserName } from '@/lib/genUserName'; import { useTranslation } from '@/contexts/LanguageContext'; -import { SPOT_CATEGORIES, SPOT_TYPE_LABELS } from '@/lib/types'; +import { SPOT_CATEGORIES, SPOT_TYPE_LABELS, SHELTER_INFO } from '@/lib/types'; import type { SkateSpot, SkateShop, SkateTour } from '@/lib/types'; import ngeohash from 'ngeohash'; import { @@ -481,6 +481,17 @@ function SpotBottomSheet({ spot, onClose }: { spot: SkateSpot; onClose: () => vo )} {/* Type badges */}
+ {spot.shelter && spot.shelter !== 'outdoor' && SHELTER_INFO[spot.shelter] && ( + + {SHELTER_INFO[spot.shelter].emoji} {SHELTER_INFO[spot.shelter].short} + + )} {spot.types.slice(0, 3).map((type) => ( )} + {spot.shelter && spot.shelter !== 'outdoor' && SHELTER_INFO[spot.shelter] && ( + + {SHELTER_INFO[spot.shelter].emoji} {SHELTER_INFO[spot.shelter].label} + + )} {spot.types.map((type) => ( {SPOT_TYPE_LABELS[type] || type}