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:
parent
993bea1b5b
commit
a7b7e07e86
8 changed files with 226 additions and 5 deletions
41
NIP.md
41
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
|
||||
|
|
|
|||
|
|
@ -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) => (
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Reference in a new issue