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.
+