Adds a shared ErrorState/PageError with a retry action. Detail pages (spot,
shop, collection, coach, crew, event) now show an honest failure + Try again
instead of '404 Not Found' — a relay hiccup previously implied the content was
deleted. The zap/bounty leaderboards surface errors instead of 'no content yet',
and the DM inbox gains a retry button.
- Stash: remove the handler-less 'Share via DM' button; action overlay stays
visible on touch (was hover-only, so edit/delete were unreachable on mobile)
- Crew invite accept no longer claims membership is confirmed (only the founder
ratifies the roster) — both the toast and the accepted state now say so
- Crew invite share message no longer promises a one-click join
- Map marker: don't bind the Leaflet popup when a host supplies onSpotClick
(MapPage's bottom sheet) — both used to appear at once
- SpotDetailPage: 'Claim This Shop' was a no-op button; wire to NIP-05 settings
(and stop the full-page <a href> reload)
- TourDetailPage: 'Contact Guide to Book' used /messages?to= which nothing read,
so booking silently opened an empty inbox -> /messages/:npub
- ContactsPage: row 'Message' went to the generic inbox -> the contact's thread
- SettingsPage: ?section=follows|language|stash rendered a blank dead end;
unknown sections now fall back to the menu
- DirectMessages/Conversation: logged-out states were dead ends -> add LoginArea
- Conversation: failed sends were silent -> error toast
Deploys replace hashed chunk filenames; a tab that was already open can load a
chunk that no longer exists (404) and hit the error boundary ('THIS PAGE
SLAMMED'). Listen for Vite's vite:preloadError (and dynamic-import failures) and
reload once, guarded against loops.
CreateEventPage gains an optional 'Funding Goal (sats)' field that publishes a
'goal' tag. EventDetailPage shows a FundingGoalCard with live raised/target
progress, backer count and a Fund button — funded peer-to-peer via NIP-57 zaps
(no escrow).
Derives First Zap / Regular / Generous / Patron / Top 10 from the batched
30-day supporter leaderboard and shows earned vs locked badges in the profile
sidebar. Client-side recognition only — publishes no events.
Shows the four most-zapped spots (last 30 days) on the home page, with their
sats totals, linking through to /leaderboards. Renders nothing when there is
no zap activity or no linkable spot, so the section never appears empty.
New /leaderboards route (linked from the More menu): aggregates kind-9735
receipts into top-zapped spots and top supporters, with week/month/all-time
tabs. Reuses the batched zap helpers. Also hardens the deploy smoke test to
boot lazy routes (/, /leaderboards, /bounties), since route chunks can fail
independently.
SortDropdown offered 'Most Zapped' but MapPage's sort switch had no zaps case,
so picking it silently fell back to newest-first. Adds a batched useZapTotals
hook (ONE receipt query for every location) and implements the sort.
ZapButton already fetched zap receipts for every visitor but returned null
unless you were logged in, so visitors saw no social proof. Now it renders a
read-only '⚡ <total>' whenever there are zaps (with a zap-count tooltip) and
only requires login to OPEN the zap dialog. No extra network cost — the query
already ran unconditionally.
Gallery images were inert (click did nothing) and only the first five were
reachable. Adds ImageLightbox: click any photo to view full size, arrow-key /
button navigation across the whole gallery, position counter, caption and
community-photo attribution. Extra images show a +N overlay.
The spot page (and the other detail pages) used `grid lg:grid-cols-3` with
no explicit mobile column. Below the `lg` breakpoint the grid falls back to
a single implicit `auto` track, whose minimum is the min-content of its
items. A long mapper name ("Tip the Mapper" card) gave the sidebar a
~559px min-content, so the whole grid widened past the viewport and was
clipped by the global `html, body { overflow-x: hidden }` — content cut off
on the right with no way to scroll to it.
Fixes:
- Add `grid-cols-1` to detail-page grids so the mobile track is
`minmax(0, 1fr)` and can never exceed the container. Applied to Spot,
Shop, Event, NIP-19, Crew and Coach detail pages.
- Add `min-w-0` to their sidebar/main columns so long content can shrink
and truncate instead of forcing the track wider.
- Make the Spot Chat header wrap (`flex-wrap` + `gap-y`) so the trust
filter and channel badge no longer get clipped on narrow phones.
Adopt PosterChan's "encrypted events are the datastore" idea for two
per-browser bits of state, so they follow the user's npub across devices.
New shared engine (src/hooks/useSyncedAppData.ts):
- Stores one encrypted kind-30078 (NIP-78) event per d-tag, NIP-44
encrypt-to-self, modeled on the proven useStash pattern.
- Clobber-guard: re-fetch + re-decrypt the freshest remote copy and
MERGE the local patch in before publishing (never overwrites a field
another device just set).
- Debounced publisher (coalesces rapid writes, flushes on pagehide/
beforeunload) so frequent read-marker changes don't spam relays.
- No-op for anon / non-NIP-44 signers.
Read-state sync (src/hooks/useReadState.ts), d-tag skatespots:read-state:
- Unifies the four previously localStorage-only markers (notification
bell, DMs, crew chat, newsletter) into one synced blob.
- Merge = field-wise MAX (markers only move forward → commutative, safe).
- The four legacy hooks now re-export drop-in replacements with identical
signatures, so NO consumer component changed. localStorage stays as the
instant/offline/anon fast path; the relay copy hydrates and max-merges.
- Fixes the real bug where clearing a badge on one device left it lit on
another.
Preference sync (src/hooks/usePreferences.ts), d-tag skatespots:preferences:
- Mobile + desktop nav layouts now sync (language field wired for later).
- Merge = last-write-wins by updatedAt (a pref replaces, not accumulates).
- useNavPreferences / useDesktopNavPreferences adopt a synced layout when
present and mirror changes; public APIs unchanged, localStorage fallback
intact.
Docs: NIP.md gains a "Cross-device app data (kind 30078, encrypted)"
section documenting both d-tags, their content shapes, merge policies, and
the clobber-guard/debounce/anon safety notes.
Build + typecheck pass.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
The bolt11 amount decoder is correct — the three failing tests used the
wrong invoice fixture. Per BOLT11, `lnbc2100n` = 2100 x 1e-9 BTC x 1e8
sats = 210 sats, not 21. A 21-sat invoice is `lnbc210n`. The passing
`lnbc1000n = 100 sats` case confirms the decoder's math.
Corrected the three cases in bolt11.test.ts and verifyInvoice.test.ts
to use `lnbc210n` (21 sats) so they exercise the intended 21-sat / 1-sat
slack scenarios against the correct implementation.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
- Replace single-assignment `let timer` with a `timers[]` array in
citrine.ts and relayProbe.ts so stricter prefer-const builds pass
(the newer ESLint pulled in by `npm audit fix` flagged the let as
never-reassigned).
- Drop two now-redundant `eslint-disable react-hooks/exhaustive-deps`
directives in NostrProvider and MapPage. Newer react-hooks no longer
reports those lines, so the directives were flagged as unused errors
under reportUnusedDisableDirectives; removing them is safe in both
old (warning only) and new (silent) ESLint.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Two type errors (surfaced by `npm run test` / tsc --noEmit on a full
toolchain; the in-browser bundler does not run tsc, so they were latent):
- useCVMClient.ts: remove a malformed cast
`spots as typeof spots & { tags; created_at }[]` feeding
deduplicateByIdentity(). The intersection dropped pubkey/kind and broke
the reassignment to `spots`. deduplicateAddressable() already returns
the correct shape, so the cast was both wrong and unnecessary.
- queryPersister.ts: import PersistedClient / Persister from
@tanstack/query-persist-client-core (their canonical source) instead of
@tanstack/react-query-persist-client, whose `export *` re-export tsc
won't resolve for named type imports. Added the core package (5.102.8,
matching the pinned wrapper) as an explicit dependency.
Both are pre-existing issues on the branch, unrelated to the audit
remediation commits.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Cover the three highest-value pure functions in the security/payments
path — previously untested:
- bolt11.getSatoshisFromBolt11: m/u/n/p multiplier decoding, amountless
invoices, 21-sat zap case, case-insensitivity, testnet/regtest
prefixes, and malformed-input throwing.
- verifyInvoice.assertInvoiceAmount: accepts matching amounts + 1-sat
rounding slack; rejects the core attack (over-charging invoice),
amountless-when-expected, malformed, and empty input.
- sanitizeUrl + companions: https passthrough, http→https upgrade,
javascript:/data:/file: rejection, relative-path rejection, plus
sanitizeCssString / urlDomain / sanitizeTel / sanitizeEmail.
Note: run `npm run test` on a full toolchain to execute these — the
in-browser builder bundles but does not run vitest.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Add aria-live="polite" to the three dynamic surfaces screen-reader
users otherwise miss:
- Notification bell unread-count badge (with an "N unread notifications"
label so the number is announced meaningfully, not read as bare digits)
- Cashu wallet balance value (announces the new balance after a
send/receive completes)
- Direct-message conversation thread: role="log" + aria-live so
incoming messages are announced as they arrive
ToastViewport already announces via the Radix default and was left
untouched.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Closes two items from the platform/completeness audit appendices.
Amber signature verification (audit "open verification item", LOW):
reconstructSignedEvent() previously trusted whatever `sig` the external
Amber signer app returned — it recomputed the event id but never
verified the Schnorr signature. A compromised or buggy signer could
therefore return a forged/garbage signature that the app would treat as
valid and publish. Now verifyEvent() is run on the reconstructed event
before it is returned; failures return null, which every caller already
handles as an "incomplete signature" error. This single choke point
covers all Amber paths: the native AmberIntent signer, AmberCallbackPage,
and AmberCallbackHandler.
F9 (LOW): pin MCP server versions in .mcp.json. Both js-dev-mcp and
nostrbook/mcp were fetched at @latest, silently pulling the newest
release into the agent tool environment every session. Pinned to the
current exact versions (0.6.3 / 0.5.3) so upgrades are deliberate.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
C (marker): Reuse the existing ~24 KB logo.png for the "you are here"
map marker instead of the 2.9 MB user-location.png (byte-identical copy
of logo-sticker). Removed the unused 2.9 MB PNG from public/images,
cutting deploy/APK size. Same artwork, ~99% smaller payload.
B (chunking): Extend the existing manualChunks in vite.config.ts to
split the largest page-specific libraries into their own vendor chunks
(hls.js, html5-qrcode, recharts) so they load only on the routes that
use them. Capacitor single-bundle branch and React core grouping left
untouched.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
The empty-state migration removed the `cn` import from both files on the
mistaken belief it was unused. In fact each file still uses cn() ~22 times
(ProfileCard, FilterDrawer, event cards, etc.), so /lab and /events crashed
at runtime with "cn is not defined" and hit the route error boundary.
Re-added `import { cn } from '@/lib/utils'` to both.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Empty states:
- LabPage and NotificationBell now use the shared <EmptyState> component.
- LabPage's logged-out "Log in to create a profile" prompt preserved via
the hint slot; removed now-unused cn import.
Page titles (U2):
- Audited all page routes for document titles. Only 3 were missing:
- EditTourPage: now sets "Edit <tour> - SkateSpots".
- AmberCallbackPage: now sets "Signing in with Amber - SkateSpots".
- NIP19Page ProfilePage: sets "<name> - SkateSpots" with about as
description; other NIP-19 branches already delegate to NotFound /
NewsletterIssueView which set their own titles.
ScrollToTop (U1): verified already correct — resets window scroll on every
pathname change, wired in AppRouter. No change needed.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Empty states (P4 follow-up):
- CrewsPage and EventsPage now render the shared <EmptyState> instead of
their own local copies, keeping spacing/actions/footer consistent.
Become-role routes (P1):
- Add src/lib/becomeRoles.tsx: single registry mapping each role slug to
either a CraftProfileForm config (artist/graffiti/musician/judge/tattoo/
trainer) or a lazy bespoke page (coach/filmer/editor/photographer/
builder/skater). The 6 craft roles were already thin config wrappers, so
their configs move into the registry and the wrapper files are deleted.
- Replace 12 hardcoded /become-* routes + 12 imports with one
/become/:role route (BecomeRolePage) that renders from the registry.
- Legacy /become-<slug> URLs now redirect to /become/<slug> so shared/
bookmarked links keep working.
- Update all internal links (LabPage, Index, SkatersPage, SpotDetailPage,
NIP19Page, SkaterDirectoryCard) to the canonical /become/<slug> paths.
Note: the 5 large bespoke forms were intentionally NOT merged into one
config — they carry distinct machinery (SpotPicker, wallet/Pro, geohashing)
and forcing them together would increase complexity, not reduce it.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
- Light-mode --accent-foreground changed from white (2.35:1, fails WCAG AA)
to near-black 0 0% 10% (~9.5:1) so filled lime CTAs/badges are readable.
- Add src/components/EmptyState.tsx: a single reusable empty-state with
icon/emoji, title, hint, action slot(s) and optional footer link, to
consolidate the ~39 ad-hoc empty states over time. Action slot turns
dead-ends into loops (e.g. "No crews nearby → Create one").
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
The cold-start loading state scattered fuzzy pink "ghost pins" across the map
(the same blob look that was disliked on real markers) which read as broken.
Removed MapSkeletonPins entirely.
New loading state, per the agreed plan (keep the map instant, never cover it):
- A slim animated top progress bar ("ss-loadbar" sweep on a faint track) that
sits above the map while spots stream in.
- During a genuine cold start (no markers yet), a small labeled "Loading spots"
pill with a spinner near the top — honest about what's happening (querying
relays), not fake pins on the ocean.
- Reduced-motion respected (bar becomes static).
The map itself is untouched and still renders immediately; only the placeholder
changed. Dropped the now-unused MapSkeletonPins import. Build clean, no console
errors.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Replaced the per-category emoji map (street/park/DIY/plaza/transition) with one
generic skateboard glyph on every spot pin, per request. The pin's crisp shape,
tight halo and shelter roof-cap (covered/indoor) are unchanged. Dropped the now-
unused `category` option from createSkateSpotIcon and its call sites.
Build clean; the earlier "CATEGORY_GLYPH is not defined" console errors were
from a stale intermediate bundle and do not occur in the current build.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Visual foundation for the map (ideas #2, #3, #4, #6 — features #5/#7 intentionally
deferred to a later pass):
#2 Crisp pins, not blobs:
- Reworked createSkateSpotIcon — solid neon teardrop with a TIGHT glow halo
(inset dark ring + focused shadow) instead of the soft box-shadow that read as
a fuzzy cloud on light tiles. Cluster badges given the same crisp treatment.
#3 Marker = spot type (a readable language):
- Category emoji inside each pin: 🛹 street / 🏞️ park / 🔧 DIY / 🏛️ plaza / 🌊 transition.
- Covered/indoor spots get a cyan roof "cap" so dry spots stand out at a glance —
the payoff of the shelter data living on the map, not just a badge.
- createSkateSpotIcon now takes an options object (weatherColor/weatherIcon/
category/shelter); all call sites updated. Weather-layer colouring unchanged.
#4 Less scrim, floating loading:
- Suspense map-load fallback dimming reduced (bg-black/50 → /20).
- "Finding spots" pill moved from dead-centre (top 46%) to a slim top pill so it
never blocks the map where the spots are.
#6 Micro-interactions (CSS, GPU-friendly, reduced-motion aware):
- New pins drop in with a spring; hover/focus/tap lifts the pin and pulses its
neon halo once.
Build clean, no console errors. No tile/theme change — the "og" light map is
untouched per request.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
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>
Audit found the Pro paywall promised three things with no implementation:
- "Priority AI model, higher limits & longer memory" — Pro and free hit the
exact same model/limits/history (only difference is the personal-tools prompt)
- "Proactive nudges (weather + spot tips)" — no nudge/scheduler exists anywhere
- "Pro-only Sasquatch skins" — single hardcoded avatar; no skin system
- "Supporter badge" — gating exists but no badge is rendered
Rewrote all Pro copy to promise ONLY what actually ships (personal assistant
over the user's own Stash/crews/spots/listings, confirm-before-publish action
drafts, personal-context answers, and directly funding an ad-free app):
- SasquatchProDialog PERKS list (dropped Sparkles import now unused)
- usePurchasePro receipt DM
- 6 STATIC_FAQ Pro answers in SasquatchGuide
Pricing, payment target (skatespots@minibits.cash) and npub-bound zap-receipt
verification were already accurate and left unchanged. Build clean, no console
errors.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
The chat's data layer (useCVMClient) queries Nostr relays directly from the
browser — the CVM/ContextVM server round-trip was removed earlier for speed.
But the UI and system prompt still claimed data came "from the CVM server",
which is no longer true. Aligned every user-facing claim with reality:
- System prompt: reframed "CVM Tools / MUST route through CVM" as "Live data
tools that query the Nostr relays directly". Tool-calling behaviour unchanged.
- Streaming status: "Querying CVM for X" → "Searching the relays for X".
- Tool-failure message: no longer blames a non-existent "CVM server"; now
correctly frames it as relays being slow, with a retry nudge.
- Empty results: stop asserting "not added yet" as fact — an empty result can
also mean slow relays or a misspelled place. Now offers retry + add-a-spot,
and names the place searched. Applied to the local formatter, the system
prompt rules, and the second-pass AI instruction.
- Chat footer: "Powered by ContextVM & Nostr Relays Data" → "Live data from
Nostr relays" (links the primary community relay).
- About page credit: "AI powered by ContextVM" → "Trust & crew chat via
ContextVM" (its real role: Relatr trust scores + encrypted crew chat).
- Owner CVM health panel: clarified it probes the standalone MCP server and
that the in-app chat does not depend on it.
- Updated stale internal comments in SasquatchGuide + CreateSpotPage.
ContextVM references that ARE accurate (Relatr trust scores, crew-chat Cordn
coordinator) were left intact. Build clean, no console errors.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
The old identity dedupe keyed on `name|place` where place was a single field
(geohash OR location OR city, first non-empty). Two copies of the same spot —
one carrying a geohash, one carrying only a city — produced different keys and
both survived (the Yellow Park duplicate).
Now:
- Name is reduced to a signature that strips trailing generic words
(skatepark/park/plaza/spot/diy/...) and leading articles, so "Yellow Park",
"Yellow Park Skatepark" and "Yellow Park " collapse to "yellow".
- Places are compared on human-readable tokens (location/city/country) only;
the opaque geohash is excluded. An empty place on either side is treated as a
wildcard, so a geohash-only copy still merges with a "Bangkok" copy.
- Genuinely different spots (different name signatures, or non-empty places with
no shared token) are still kept separate; single-word names are preserved so
generic names don't over-collapse.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
The local fast-path was stricter than the AI it replaced: a typo like "bangok"
won't substring-match "Bangkok", so the fast-path returned a false "No spots
listed there yet" even though Bangkok has spots.
Two-layer fix:
1. Safety net (most important): if a finder fast-path comes back EMPTY, fall
through to the AI path instead of rendering "nothing found". The AI normalizes
spelling and can still find results. Stats/weather always render immediately.
Net effect: fast when the local match works, correct-always via AI when it
doesn't — never a false empty.
2. Typo tolerance: findSpots city matching now uses placeMatches() — substring
OR edit-distance-1 against candidate word tokens (only for queries ≥4 chars,
so short words can't over-match). So "bangok" matches "Bangkok" on the fast
path too. Freeform content stays a strict substring check to avoid false hits.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Root cause of the ~35s "spots in bangkok" response: every live-data question
first waited on an AI round-trip just to produce a tool call, and the keyless
backup model (Pollinations) can take tens of seconds. The relay query and local
formatting were already fast — the AI was the bottleneck.
Fix: before touching the AI, try parseLocalIntent(). For clear finder questions
(spots/events/coaches/crews/shops/tours/gear + community stats + weather with a
parseable location) we now go STRAIGHT to the relays (capped at 4.5s) and render
locally — zero AI calls. Expected ~35s → ~2–4s on the most common queries.
Deliberately still routed through the AI:
- trip planning (needs the rich itinerary card)
- personal "my …" Pro queries (get_my_* tools)
- anything without a confidently-parseable intent/location
If the fast-path relay query errors, it falls through to the existing AI loop as
a backup, so nothing regresses.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Two bugs from "spots in bangkok" (said "Found 20" but showed 6, with repeats):
1. Misleading count: formatLocalResults hard-capped the visible list to 6 while
the headline reported the full count. Now shows up to 10 and appends a
truthful "…and N more — see them all on the map" line, so the number and the
list always agree.
2. Duplicate spots: coordinate dedupe (kind:pubkey:d) can't catch the same
real-world spot re-posted under a different identifier or by another account
(e.g. "Yellow Park – Sukhumvit 50" appearing twice). Added
deduplicateByIdentity() — a second-pass dedupe on a normalized
name + geohash/location signature, keeping the freshest event — applied in
findSpots so the count and list are clean.
Both fixes flow through the AI tool-call path and the offline fallback path.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Three low-risk speedups focused on the slowest path — questions that hit live
relay data:
1. Relay timeout 10s → 4.5s (6s for the fan-out community stats). The useful
relays answer in ~2s; the old 10s just let one slow/dead relay stall the
whole reply.
2. Results-first: for finder tools (spots/events/coaches/gear/crews/shops/
tours/stats/weather) format the CVM result locally and SKIP the second
"prettify" AI call. Removes a full model round-trip (~2–4s) — users want the
list, not extra prose. New toolCallToIntent() maps a tool call to the existing
formatLocalResults() path. Personal/Pro tools (get_my_*) still use AI synthesis.
3. Faster AI fallback: cap OpenRouter attempts to the top 3 free models before
the keyless backup, and shorten 429 backoff (≤800ms vs ≤2500ms). Avoids the
worst-case 15–20s grind through 8 models on a busy free tier.
Conversational/knowledge answers (single AI call) keep Sasquatch's personality
and are untouched.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
The shared EventsCalendar already renders tours alongside events with live
countdowns on cards/detail pages. This refines the agenda time display: local
tours now carry a specific session time, so the calendar shows that clock time
instead of a blanket "All day". Multi-day national/international/destruction
tours remain date-only ("All day" / date range), and events are unaffected.
- Add allDay flag to CalendarItem (set from tour category)
- Local sessions show their start time; other tours stay all-day
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Countdown badges (upcoming tours):
- New getCountdown() in TourCard: "In 3 days" / "Tomorrow" / "In 2 weeks" /
"Happening now" (pulsing) based on start/end dates, category-tinted
- Shown on both compact and full TourCard, and in the TourDetailPage schedule card
NIP-05 provider fix (removes obsolete nip05.social):
- New single source of truth src/lib/nip05Providers.ts with working, curated
providers: nostrcheck.me + nostr.directory (free), Nostr Plebs + nostr.band (paid)
- New reusable Nip05Required component (full-page "gate" + inline "banner"
variants) listing those providers and deep-linking to /settings?section=nip05
- Wired into CreateTourPage (gate), CreateListingPage (banner) and all four
Become* pages (Coach/Filmer/Photographer/Builder) for a consistent prompt
- SettingsPage NIP-05 screen now reads from the shared provider list
- Updated internal planning docs to match
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
- Add a unified Schedule section across all four tour categories:
- Local: choose a fixed session (date+time) or flexible/by-appointment
- National/International: required departure + optional return trip dates
- Destruction: existing competition window, now with end>=start validation
- Reuse start-date/end-date tags for every category (parser already supported them)
- Fix datetime handling: dedicated toDateInput/inputToSecs helpers so date and
datetime-local inputs round-trip correctly (previous mix of toLocalInput +
type="date" was inconsistent)
- Consistent, logical mandatory-field validation with clear required markers
- Redesign the header into an immersive, category-tinted, grunge-striped banner
and rebuild the category selector as elegant glowing cards
- New calendar utilities (.ics + Google Calendar) and AddToCalendar component
- TourDetailPage is now fully category-aware (badge, guide label, duration,
SEO title) with a Schedule card + Add to Calendar for attendees
- TourCard shows the schedule for all categories (time for local sessions)
- Update NIP.md to document start-date/end-date across categories
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Three UX wins so the app feels "already loaded" the moment it opens:
1. IndexedDB React Query persistence (lib/queryPersister.ts)
- PersistQueryClientProvider rehydrates the last-seen spots, shops,
events, tours, crews, coaches, listings and author profiles from
IndexedDB on cold start, so returning users land on a populated map
instead of a blank one; fresh data refetches quietly in the
background.
- Strict allow-list / deny-list: only public, JSON-serializable,
cold-start-relevant queries are persisted. Private/encrypted data
(stash, DMs, contacts, wallet/nwc/cashu/bitcoin, signer) is NEVER
written to disk. 24h maxAge + version buster keep it fresh & safe.
- Pinned @tanstack/react-query + query-core + persist-client to a
single 5.102.8 to avoid a query-core mismatch (partialMatchKey).
2. Optimistic geolocation (lib/lastLocation.ts)
- getCurrentPosition now caches every successful fix.
- The map opens ALREADY centered on the user's last-known area
(city zoom) instead of the world view; "you are here" + proximity
sort seed from the cache instantly.
- "Near Me" flies to the cached location immediately on tap, then
refines to the fresh GPS fix. Silent background warm-up on web map
mount so a later tap resolves instantly.
3. Cold-start skeleton (components/MapSkeletonPins.tsx)
- When the map is genuinely empty + loading, show elegant pulsing
ghost pins + a "Finding spots" pill instead of a blank map; they
vanish the instant real (or cached) spots render. Splash now
signals content-ready when spots actually resolve.
Respects prefers-reduced-motion throughout. Build + runtime verified
clean (no console errors).
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Security & reliability audit follow-up. The app was already well-hardened
(strict CSP synced across index.html/_headers/netlify.toml, careful
textContent/escapeHtml in the Leaflet DOM, broad sanitizeUrl usage,
consistent author-filtering on trust-sensitive queries). This closes the
remaining gaps:
- sanitizeUrl is now HTTPS-only: bare http:// links are upgraded to
https:// (not silently dropped); every other scheme (javascript:,
data:, file:, relative) is rejected. Removes a downgrade/mixed-content/
IP-leak vector and matches the native HTTPS-only WebView. Added a
sanitizeCssString() allowlist helper for future CSS interpolation.
- Route-level ErrorBoundary (RouteErrorBoundary) wraps the routed pages,
keyed by pathname: a crash in one page now shows a recoverable
"this page slammed → go back" message while the header/nav/rest of the
app keep working, instead of the single top-level boundary blanking the
ENTIRE app. Resets automatically on navigation.
- ErrorBoundary fallback no longer leaks stack traces to users (full
detail still logged to console for us); redesigned on-brand ("Slammed",
neon, reassures that account + data are safe). Updated its test.
Author-filtering spot-check across admin/newsletter/featured/crew/stash
paths found no gaps — all trust-sensitive queries correctly constrain
authors.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Rebuilt the cold-start splash to a best-in-class standard:
- Determinate progress bar driven by real milestones (mount creep →
relays-warm nudge → 100% on ready) with a sliding shine highlight —
reads as genuine loading, not an infinite spinner.
- Choreographed staggered entrance (logo → wordmark → bar → status rise
in sequence) on an expressive ease curve.
- Signature logo treatment: rotating red→lime conic halo ring + a gentle
breathing pulse, over a dual breathing glow and a masked skate-map grid
texture for depth.
- Satisfying "ready" beat: bar fills to 100%, status swaps to a lime
check "Ready", then a soft scale-up crossfade reveals the app.
- 8px-grid spacing, refined tracking/typography.
- Fully respects prefers-reduced-motion (all motion gated) and safe-area
insets on all four edges; identical on phone and desktop; still capped
hard at 2.6s so it never traps the user.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Make the landing feel "already loaded" instead of dismissing on bare
connection:
- MapPage fires `skatespots:content-ready` once the first spots query
settles. The splash now dismisses on that signal (populated reveal),
falling back to a short grace window after `relays-warm` for non-map
cold-start routes, and still capped hard at 2.2s so it never traps.
- App shell eases in under the departing splash via a pure-opacity
`appReveal` animation (no translate, so a full-bleed map never shifts),
respecting prefers-reduced-motion. Result is a smooth crossfade onto
content that's already there rather than a pop-in.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
A. Relay pre-warming — NostrProvider now opens the read-relay WebSockets
the moment it mounts (fire-and-forget limit:0 probes with a 4s abort),
instead of NPool's lazy open on first query. Sockets are already
connected by the time the map queries run, so first events land sooner.
Failures are swallowed so a dead relay never delays or breaks boot.
B. Branded SplashScreen — an intentional cold-start moment (logo with a
neon glow pulse, a red→lime progress sweep, and rotating skater-voiced
status lines) that replaces the "is it broken?" empty-map flash. It
dismisses the instant relays report warm (`skatespots:relays-warm`
event fired on the first relay response) OR at a hard 1.6s cap —
whichever comes first — with a 650ms minimum to avoid flicker. Shown
once per session. Fully respects prefers-reduced-motion and safe-area
insets; works identically on phone and desktop, never blocks the user.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>