Add spot "Shelter" (weather protection) field

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 <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-09-10 11:38:43 +07:00
parent 993bea1b5b
commit a7b7e07e86
8 changed files with 226 additions and 5 deletions

41
NIP.md
View file

@ -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

View file

@ -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]}
</Badge>
)}
{spot.shelter && spot.shelter !== 'outdoor' && SHELTER_INFO[spot.shelter] && (
<Badge
className="bg-black/90 backdrop-blur-sm border-2 font-bold uppercase text-[10px]"
style={{
borderColor: SHELTER_INFO[spot.shelter].color,
color: SHELTER_INFO[spot.shelter].color,
}}
>
{SHELTER_INFO[spot.shelter].emoji} {SHELTER_INFO[spot.shelter].short}
</Badge>
)}
{spot.types.length > 0 && (
<div className="flex flex-wrap gap-1">
{spot.types.slice(0, 2).map((type) => (

View file

@ -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;

View file

@ -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<string, string> = {
contest: 'Contest',
demo: 'Demo',

View file

@ -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<string>('');
const [tags, setTags] = useState<string[]>([]);
const [accessType, setAccessType] = useState<'public' | 'private' | 'locals'>('public');
const [shelter, setShelter] = useState<ShelterType>(DEFAULT_SHELTER);
const [contactInfo, setContactInfo] = useState('');
const [images, setImages] = useState<string[]>([]);
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() {
</div>
</div>
{/* Shelter / weather protection */}
<div className="space-y-3">
<Label className="text-base font-bold">Shelter</Label>
<p className="text-xs text-gray-400 -mt-1">
Is this spot exposed to the weather? Helps skaters find dry spots when it rains.
</p>
<div className="grid grid-cols-2 gap-2">
{shelterOptions.map((option) => (
<Card
key={option.value}
className={cn(
"cursor-pointer transition-all border-2 touch-manipulation active:scale-[0.98]",
shelter === option.value
? "bg-[#ff0040]/10 border-[#ff0040] ring-2 ring-[#ff0040]/50"
: "border-gray-700 hover:border-[#ff0040]/50"
)}
onClick={() => setShelter(option.value)}
>
<CardContent className="p-3 sm:p-4 relative">
<div className="flex items-start gap-2.5">
<div className="text-xl leading-none mt-0.5">{option.emoji}</div>
<div className="flex-1 min-w-0">
<div className="font-bold text-sm sm:text-base text-white">{option.label}</div>
<div className="text-xs text-gray-400 mt-0.5">{option.description}</div>
</div>
</div>
{shelter === option.value && (
<div className="absolute top-1 right-1 w-4 h-4 sm:w-5 sm:h-5 rounded-full bg-[#ff0040] flex items-center justify-center">
<Check className="h-2.5 w-2.5 sm:h-3 sm:w-3 text-white" />
</div>
)}
</CardContent>
</Card>
))}
</div>
</div>
{/* Freeform Tags */}
<div className="space-y-3">
<Label className="text-base font-bold">Tags</Label>

View file

@ -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<string>('');
const [tags, setTags] = useState<string[]>([]);
const [accessType, setAccessType] = useState<'public' | 'private' | 'locals'>('public');
const [shelter, setShelter] = useState<ShelterType>(DEFAULT_SHELTER);
const [contactInfo, setContactInfo] = useState('');
const [images, setImages] = useState<string[]>([]);
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() {
</div>
</div>
{/* Shelter / weather protection */}
<div className="space-y-3">
<Label className="text-base font-bold">Shelter</Label>
<p className="text-xs text-gray-400 -mt-1">
Is this spot exposed to the weather? Helps skaters find dry spots when it rains.
</p>
<div className="grid grid-cols-2 gap-2">
{shelterOptions.map((option) => (
<Card
key={option.value}
className={cn(
"cursor-pointer transition-all border-2 touch-manipulation active:scale-[0.98]",
shelter === option.value
? "bg-[#ff0040]/10 border-[#ff0040] ring-2 ring-[#ff0040]/50"
: "border-gray-700 hover:border-[#ff0040]/50"
)}
onClick={() => setShelter(option.value)}
>
<CardContent className="p-3 sm:p-4 relative">
<div className="flex items-start gap-2.5">
<div className="text-xl leading-none mt-0.5">{option.emoji}</div>
<div className="flex-1 min-w-0">
<div className="font-bold text-sm sm:text-base text-white">{option.label}</div>
<div className="text-xs text-gray-400 mt-0.5">{option.description}</div>
</div>
</div>
{shelter === option.value && (
<div className="absolute top-1 right-1 w-4 h-4 sm:w-5 sm:h-5 rounded-full bg-[#ff0040] flex items-center justify-center">
<Check className="h-2.5 w-2.5 sm:h-3 sm:w-3 text-white" />
</div>
)}
</CardContent>
</Card>
))}
</div>
</div>
{/* Freeform Tags */}
<div className="space-y-3">
<Label className="text-base font-bold">Tags</Label>

View file

@ -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 */}
<div className="flex flex-wrap gap-1 mt-1.5">
{spot.shelter && spot.shelter !== 'outdoor' && SHELTER_INFO[spot.shelter] && (
<span
className="inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-bold"
style={{
color: SHELTER_INFO[spot.shelter].color,
backgroundColor: `${SHELTER_INFO[spot.shelter].color}25`,
}}
>
{SHELTER_INFO[spot.shelter].emoji} {SHELTER_INFO[spot.shelter].short}
</span>
)}
{spot.types.slice(0, 3).map((type) => (
<span
key={type}

View file

@ -27,7 +27,7 @@ import { useMarketplaceAtSpot } from '@/hooks/useMarketplace';
import { useSpotServices } from '@/hooks/useSpotServices';
import { MarketplaceItemCard } from '@/components/MarketplaceItemCard';
import { genUserName } from '@/lib/genUserName';
import { SPOT_TYPE_LABELS, ACCESS_TYPE_INFO } from '@/lib/types';
import { SPOT_TYPE_LABELS, ACCESS_TYPE_INFO, SHELTER_INFO } from '@/lib/types';
import NotFound from './NotFound';
import {
MapPin,
@ -404,6 +404,18 @@ export default function SpotDetailPage() {
{ACCESS_TYPE_INFO[spot.accessType].emoji} {ACCESS_TYPE_INFO[spot.accessType].label}
</Badge>
)}
{spot.shelter && spot.shelter !== 'outdoor' && SHELTER_INFO[spot.shelter] && (
<Badge
className="border-2 font-bold uppercase"
style={{
borderColor: SHELTER_INFO[spot.shelter].color,
color: SHELTER_INFO[spot.shelter].color,
backgroundColor: `${SHELTER_INFO[spot.shelter].color}20`,
}}
>
{SHELTER_INFO[spot.shelter].emoji} {SHELTER_INFO[spot.shelter].label}
</Badge>
)}
{spot.types.map((type) => (
<Badge key={type} className="bg-[#ccff00]/20 text-[#ccff00] border border-[#ccff00]/50 font-bold uppercase">
{SPOT_TYPE_LABELS[type] || type}