Skip to main content

Spots Map

The interactive map that lets riders explore, add, tag, and save action-sports spots — the primary discovery surface of the Spots feature.

Status: ✅ Live · Last updated: 2026-07-11

This page is the source of truth for the Spots Map specifically — the map UI on both mobile and web, the flows that hang off it, the deliberate UX/architecture decisions behind the mobile map (which is unusual for a good reason), the backend that powers it, and the QA plan that keeps it from crashing. For the broader Spots feature (schema, list/detail pages, ratings, resort UI, Chrome Extension), see Spots.

Related surfaces
  • Spots — parent feature: browse/list, spot detail, ratings, resort info, Chrome Extension bulk import.
  • Media — the feed/upload surface where a spot gets tagged onto a video or post.
  • AI Companions — Kaori can search spots and submit spot drafts (admin-reviewed) via chat tools.
  • Kaori AI Architecture — background on the React Native New Architecture / Reanimated 4 constraints that also shape this map.

1. Product overview

What it is

The Spots Map is a full-screen, pannable, zoomable map of every approved spot in TrickBook. Riders open it to answer one question fast: "what can I skate/ride near here (or near there)?" Pins are colored by category, cluster when zoomed out, and expand as you zoom in. Tapping a pin surfaces a card; from there you go to the spot's detail page, get directions, or save it.

The map is not just a viewer. It is the hub for the whole spot lifecycle:

  • Explore approved spots (the primary use case).
  • Search spots and real-world places in one field.
  • Add a new spot by dropping a pin or searching a place.
  • Edit a spot you own.
  • Tag a spot onto a video/post so your content is tied to a location.
  • Save spots to "My Spots" (one-tap) or to named Collections.
  • Contribute and moderate photos community-style (Google-Maps model).

Why it exists

Discovery is the top of the funnel for the whole app. A rider who can instantly see nearby spots is a rider who films, tags, saves, and comes back. Before the map, spots were only reachable through hierarchical country → state → list browsing, which is great for planning a trip but poor for "I'm standing here, what's around me." The map closes that gap and doubles as the on-ramp for user-generated content and the community photo library.

Platform matrix — what's available where

CapabilityMobile (React Native / Expo)Web (Next.js)
Interactive map of approved spots✅ Live✅ Live
Map technologyGoogle map base + projected RN-View overlay markers (custom)Real Google Maps JS SDK markers (@vis.gl/react-google-maps)
Clusteringsupercluster (JS, 50/18/3)@googlemaps/markerclusterer
Filter by sport / category✅ Live (in-map Filter)✅ Live (Category dropdown)
Filter by country🚧 Via list, not the map✅ Live (Country dropdown)
In-map unified search (spots + Google Places)✅ Live🚧 Not on the map (list/state pages have text search)
Center-on-me button✅ Live📋 Planned
Fullscreen immersive map✅ Live🚧 Map is already the page; no separate mode
Tap pin → card✅ Overlay card (fly-to)✅ Google InfoWindow card
List view (country → state → spot)✅ Live (List toggle)✅ Live (List toggle)
Add a spot✅ Drop-pin / search-a-place✅ Lat-lng or paste Google Maps URL
Edit a spot (owner)✅ Live (detail Edit)✅ Admin form (/admin/create-spot); owners via API
Tag a spot on a post✅ Live (upload picker)✅ Live (upload picker)
Clickable spot chip on the post🚧 See known issues🚧 Plain text, not linked
One-tap save + long-press list picker✅ Live🚧 "Add to My Lists" links to /my-spots
Named Collections (Spot Lists)✅ Live✅ Live (/my-spots)
Contribute photos (gallery/camera)✅ Live🚧 Image URL only on public add form
Report a photo (auto-hide at 3)✅ Live🚧 Backend supports it; web UI not wired
Submit a trick to a spot's history🚧 Not on map surface✅ Live (spot detail)
Mobile and web are deliberately different map stacks

The web map uses the ordinary Google Maps JavaScript SDK. The mobile map uses a custom projected-overlay marker system that looks over-engineered until you know it exists to dodge a native crash under React Native's New Architecture. Section 3 explains exactly why. The two stacks share the same backend (Section 4), so the data model is identical.


2. Use cases — step-by-step (mobile and web)

2a. Explore the map (the primary use case)

This is the flow that matters most. Get it right and everything else follows.

Mobile

  1. Open the Spots tab → the All Spots Map loads centered near you (location permission requested on first use).
  2. Pins load for the current viewport only — as you pan/zoom, the app re-queries GET /map-pins for the new bounding box (debounced ~350 ms).
  3. Zoom with pinch only. There are intentionally no +/ buttons (see Section 3). Pinch out to see clusters, pinch in to break them apart.
  4. Tap a cluster to expand/zoom into it; tap a single pin to select it (a card appears and the map flies to it); tap the background to deselect.
  5. Use the control cluster on the map:
    • Filter — pick a sport and/or category; pins re-query filtered.
    • Center — recenter on your current location.
    • Search — opens the unified spots + Google Places search (see 2a-search).
    • Fullscreen — immersive edge-to-edge map (see Section 3).
    • List — switch to the country → state → spot list view.

Mobile — in-map search

  1. Tap the magnifying-glass control → a full-screen search panel opens.
  2. Type a query. Two result groups return in parallel: Spots (searchSpots) and Google Places (searchPlaces, biased to the current viewport).
  3. Tap a Spot result → the map flies to it and shows its card.
  4. Tap a Place result → the map just flies there (it's a real-world location, not necessarily a spot). This is the on-ramp to adding a spot at that place.

Web

  1. Go to /spots — it defaults to Map view. The Google Maps JS map loads all approved pins clustered worldwide (fetched once from /spots/map-pins, with a fallback to /spots?limit=5000 if that 404s).
  2. Filter with the Category dropdown (10 sports) and/or the Country dropdown. Filtering is client-side over the already-loaded pins, so it's instant. (Category matching is dual-keyed: it matches either the spot's single category field or membership in its sportTypes array.)
  3. Click a pin → a Google Maps InfoWindow card opens showing name, state/country, star rating, a category color chip, sport types, and a 2-line description clamp.
  4. Click "View Details →" → the spot detail page at /spots/{state}/{spotSlug}?id={id}.
  5. The footer under the map shows a "{n} spots worldwide" count and a category color legend (park=green, street=amber, indoor=blue, diy=red, other=purple).
Web loads everything, mobile loads the viewport

Web fetches all pins once and filters/clusters client-side. Mobile fetches only what's in the current viewport and re-queries on pan/zoom. Both are correct for their platform, but note the web behavior means very large datasets can produce a large single payload — see Known Issues.

2b. Add a spot (including photos)

Mobile

  1. On the map, tap +.
  2. Set the location one of two ways: drop a pin on the map, or search a place and pick it.
  3. Tap Continue → the details form.
  4. Fill in name, category, sport types, and Public / Private.
  5. Add photos — pick from Gallery or take one with the Camera (permissions requested as needed). The first photo becomes the cover.
  6. Submit (Public → enters the admin review queue as pending) or Save (Private → stays yours, private).
    • Under the hood: createSpot runs first, then each photo is sent via uploadSpotPhoto.

Web

  1. On /spots, click Add Spot (logged-out users are redirected to /login?redirect=/spots/add).
  2. On /spots/add, set coordinates either by entering lat/lng manually or by pasting a Google Maps URL and clicking Parse — the URL is regex-parsed for coordinates and the city/state are reverse-geocoded via Nominatim/OpenStreetMap.
  3. Fill in name, description, tags, and an image URL.
  4. Check "Submit for public listing" to send it to admin review (pending), or leave it unchecked to keep it private.
  5. Submit → on success you're redirected to the new spot's detail page.
Web add-spot takes an image URL, not file uploads

The public web add form (/spots/add) accepts a single image URL string, not uploaded photo files. Multi-photo contribution with gallery/camera is a mobile-only capability today. The web detail-page carousel is populated from spot.images / Google photos, not from a web file-upload path.

2c. Edit a spot (owner)

Mobile

  • Open the spot's detail page. If you're the owner, an Edit (and Delete) action appears. Edit patches the spot in place (name, location, category, sport types, description, etc.); Delete also removes the spot's photos from storage and pulls it out of every Collection.

Web

  • Owners edit via the API (PUT /spots/:id, owner-or-admin). The admin UI at /admin/create-spot?isEdit&spotId=... provides a full CRUD edit form and additionally lets admins set a numeric rating (a field not exposed in the public add form). Admins also run the approval queue at /admin/pending-spots and manage everything at /admin/spots.

2d. Tag a spot in a video/post (+ the spot chip)

Mobile

  1. Go to Upload (media).
  2. In the composer, use Tag a spot → a searchSpots picker.
  3. Select a spot → its spotId is attached to the post.

Web

  1. Go to /media/feed/upload, choose a video/image.
  2. Use the spot search field to find and select a spot.
  3. The chosen spotId is included in the upload payload.

The spot chip. Once tagged, the backend batch-enriches the spot (name, city, state, image, category) onto the post everywhere it appears (feed lists, profile, single-post view) — not just on the single-post page. On the web feed, the tagged location currently renders as plain text under the poster's name (post.location?.name), not a link to the spot page. Making the chip clickable (like trick tags are) is a tracked follow-up. See Media.

2e. Save / collect spots ("My Spots")

TrickBook uses a flat "My Spots" model: the things you created and the things you saved live together, with named Collections behind a toggle.

Mobile — one-tap save

  1. On any spot (card or detail), tap the bookmark → the spot is saved instantly (optimistic) to your default Saved Spots bucket. Tap again to unsave.
  2. Long-press the bookmark → a list picker to add the spot to a named Collection.
  3. View saved/created spots under My Spots (buckets: Mine = getMySpots, Saved = getSavedSpots) or open a Collection (named SpotList).

Web

  • On a spot detail page, the "Add to My Lists" button (logged-in only) links to /my-spots, which manages your Spot Lists (named collections). One-tap save from a map pin is a mobile-first affordance.
One-tap save is intentionally frictionless

Saving writes to a hidden per-user default list (isDefaultSaved: true) that's excluded from the normal Collections view and has no free-tier limit — unlike named Collections, which enforce subscription limits. This keeps "save this, I'll come back" a zero-friction gesture. See Section 3 and Section 4.

2f. Contribute and report photos (community model)

The photo system follows the Google Maps model: photos are public immediately, and the community moderates.

Contribute (mobile)

  1. On a spot's detail page, the gallery combines user photos and Google photos.
  2. Any logged-in user taps Add Photo → choose Gallery or Camera → the photo uploads and appears right away.

Report / moderate (mobile)

  1. Long-press a user photo → Report or (if it's yours / you're admin) Delete. Google photos are read-only.
  2. Reporting is idempotent per user (each distinct reporter counts once).
  3. At 3 distinct reports, the photo auto-hides and is filtered out of all public responses. Admins can still hard-delete.

Web — the backend fully supports the photo model, but the web UI for uploading files and reporting is not wired up yet; the web detail page shows the existing photo carousel read-only.


3. UX / UI decisions (mobile) — with rationale

The mobile map's architecture is unusual. Every choice below traces back to a hard constraint. Read this before changing anything on the map.

The projected-overlay marker architecture — and why

Decision: The main map (app/(tabs)/spots/index.tsx) renders an empty MapView (PROVIDER_GOOGLE) and draws markers as plain React Native Views positioned on top, using projectToScreenXY (src/lib/mapProjection.ts) to convert each spot's lat/lng into screen x/y. It does not use react-native-maps <Marker> on the main map.

Why: Under React Native's New Architecture, react-native-maps' native <Marker> crashes AIRGoogleMap. We can't just turn the New Architecture off, because Reanimated 4 requires it. So the map base comes from Google, but the interactive markers are our own RN views layered above it and kept in sync with the camera via projection. This is the entire reason the mobile stack diverges from the web stack (which has no RN dependency and therefore uses plain Google Maps JS markers).

The touch-registry crash — and the "never unmount a touch target mid-gesture" fix

Problem (RN #53303): When a touchable is unmounted while a gesture is in flight (e.g. a marker scrolls off-screen as you pan and we destroy it), React Native's touch registry desyncs and the app crashes.

Fixes, layered:

  • Hide, never unmount. Off-screen markers stay mounted but hidden (moved off-screen via projection), so a touch target is never destroyed mid-pan.
  • Two-region split. A settled region drives clustering + which pins exist; a separate projectionRegion drives marker screen positions. Decoupling them keeps the marker set stable while the camera moves.
  • Stable cluster keys. Clusters use stable keys so React doesn't churn/remount marker views on every frame.
  • Deferred onPress. Tap handlers defer their work (via requestAnimationFrame) so a press that lands as a marker is repositioning doesn't fire mid-reconcile.
  • patch-package RCTAssert safety net. A patched assertion prevents a hard crash if the registry ever does desync — belt and suspenders.
The load-bearing invariant

Never unmount a touch target during an active gesture. projectToScreenXY hides off-screen markers instead of unmounting them precisely to hold this invariant. Any refactor of the marker layer must preserve it or the RN #53303 crash comes back — and it only reproduces reliably on physical iOS with dense data under aggressive panning (see Section 5).

Clustering (supercluster)

Clustering runs in JS via supercluster (src/hooks/useMapClusters.ts), configured radius 50 / maxZoom 18 / minPoints 3. Combined with viewport-only pin loading, this bounds the number of rendered marker views at any time, which keeps panning smooth and keeps the projected-overlay approach affordable.

Pinch-to-zoom only — no +/ buttons

The +/ zoom buttons were removed. Rationale: pinch-to-zoom is the native, expected gesture on a touch map; the buttons were redundant chrome. Freeing that slot let us add the unified in-map Search control instead — a higher-value use of the same real estate.

Search is a single field that queries both TrickBook spots and Google Places (viewport-biased) at once. Selecting a spot flies to it and shows its card; selecting a place just flies there. This mirrors the Google Maps mental model and doubles as the entry point for adding a spot at a real-world place.

Fullscreen immersive mode

A Fullscreen control expands the map edge-to-edge, hiding surrounding app chrome for an immersive browse. It's a mode toggle, not a separate screen, so map state (region, selected pin, filters) is preserved.

Flat "My Spots" (created + saved) + one-tap-save vs long-press

  • Flat model. Authored + saved spots share one My Spots surface (Mine / Saved), with named Collections behind a toggle. This matches how riders think ("my spots") rather than forcing a folder decision up front.
  • One-tap save vs long-press. A single tap performs the frictionless default save (optimistic, no limit). A long-press opens the list picker for deliberate filing into a named Collection. Common action is one tap; power action is one long-press.

Google-Maps photo-contribution model + moderation

Photos are public on upload (no pre-moderation queue) to maximize contribution, with community reporting and auto-hide at 3 distinct reports as the safety valve, plus admin hard-delete. This trades a small window of possible bad content for a much richer, faster-growing photo library — the same bargain Google Maps makes.


4. Technical architecture

Mobile stack

PieceDetail
Map basereact-native-maps MapView with PROVIDER_GOOGLE, rendered empty (no native markers on the main map)
MarkersPlain RN Views positioned by projectToScreenXY in src/lib/mapProjection.ts
Off-screen markersMounted but hidden (never unmounted mid-gesture) — RN #53303 mitigation
Regionssettled region → clustering + which pins load; projectionRegion → marker screen positions
Clusteringsupercluster via src/hooks/useMapClusters.ts (radius 50 / maxZoom 18 / minPoints 3)
Pin loadingViewport-loaded via getMapPinsGET /spots/map-pins, debounced ~350 ms on pan/zoom
ControlsFilter · Center · Search · Fullscreen · List — no +/ zoom
SearchsearchPlaces (Google Places, viewport-biased) + searchSpots, in one panel
SavegetMySpots (Mine) + getSavedSpots (Saved); named lists via SpotList
Addapp/(tabs)/spots/add.tsxstill renders a native <Marker> for the drop-pin (with a code warning); submits createSpot then uploadSpotPhoto (first = cover)
Detailapp/(tabs)/spots/[spotId].tsx — merges userPhotos + googlePhotos; Add Photo (any user), Report/Delete (user photos), Google read-only; owner Edit/Delete
Tagmedia/upload.tsxsearchSpots picker attaches spotId to the post
add.tsx is the one place the mobile app still uses a native <Marker>

The main map avoids <Marker> entirely, but the add-spot screen still uses a native <Marker> for the drop-pin (guarded by an in-code warning). Migrating this to the projected-overlay approach is a tracked follow-up (Section 6). It hasn't crashed in practice because the add screen shows a single marker with no aggressive panning, but it's the last inconsistency in the marker story.

Web stack

PieceDetail
Map componentcomponents/SpotsMap.js
Map SDK@vis.gl/react-google-maps (APIProvider / Map / InfoWindow / useMap)
MarkersReal new google.maps.Marker(...) with SVG-data-URI pins colored by category
Clustering@googlemaps/markerclusterer (ClusteredMarkers), yellow #fcf150 cluster bubbles sized by count
API keyprocess.env.NEXT_PUBLIC_GOOGLE_MAPS_KEY (missing key → config message, not a crash)
Pin fetchOnce from ${API_BASE}/spots/map-pins, fallback to /spots?limit=5000 on 404
FilteringClient-side over all pins; category matches category or sportTypes
Tapped pinGoogle InfoWindow card → View Details → link to /spots/{state}/{spotSlug}?id={id}
RenderingSpotsMap dynamically imported with ssr: false (avoids Next.js SSR breaking on Google Maps globals)
Browse pagepages/spots.js — map/list toggle (default map), Category + Country dropdowns
List vieworganizeByCountry → country → state → spot; state pages at pages/spots/[state].js (debounced 300 ms search, tag filters, SpotCard grid)
Detailpages/spots/[state]/[spotSlug].js — carousel, tags, resort/ski-pass info, lodging, videos, press, Trick History
Addpages/spots/add.js (public) · pages/admin/create-spot.js (admin CRUD + rating)
Data layerlib/apiSpots.js — all getSpotsData/searchSpots/getSpotData/createSpot/updateSpot + SpotList CRUD

No React Native anywhere on web. The projected-overlay hack does not exist on the site; there's no react-native, react-native-web, RN <Marker>, or manual lat/lng→pixel projection. The browser has no react-native-maps crash to avoid, so it uses the ordinary Google Maps JS SDK.

Backend endpoints

Three Express routers, all in prod, mounted in Backend/index.js: /api/spots (routes/spots.js), /api/spotlists (routes/spotlists.js), /api/feed (routes/feed.js). Auth is JWT via the x-auth-token header. Only approvalStatus: 'approved' spots surface on public map/list/search. See Backend API Endpoints for the full reference.

MethodEndpointAuthPurpose
GET/spots/map-pinsPublicMarker data source. Viewport bbox (minLat/maxLat/minLng/maxLng, all four required), approved-only, antimeridian-aware, optional sportType/category. Returns a trimmed projection (name, lat/lng, category, sportTypes, rating, imageURL, city/state/country, description). Backed by index {approvalStatus, latitude, longitude}
GET/spotsPublicPaginated approved list; sort/order/country/sportType/category/q filters
GET/spots/searchPublicApproved search: q/city/state/category/tags
GET/spots/:idPublic*Full detail. Approved is public; private/pending/rejected returned only to owner/admin (optionalUser); hidden user photos stripped
GET/spots/my-spotsJWTRequester's own spots at all statuses
GET/spots/savedJWTSpots from the user's default Saved Spots list, newest-first
POST/spotsJWTCreate. isPublic:false → private; isPublic:true → pending (+submittedAt); lat/long dedupe
PUT/spots/:idJWTEdit — owner or admin (403 else)
DELETE/spots/:idJWTDelete — owner or admin; also deletes S3 photos and $pulls the id from all spotlists
POST/spots/:id/saveJWTOne-tap save into the hidden default list (upsert). {saved:true}
DELETE/spots/:id/saveJWTUnsave ($pull). {saved:false}
GET/spots/allAdminEvery-status list
GET/spots/pendingAdminReview queue (pending, by submittedAt desc)
PUT/spots/:id/approveAdminapproved (+ reviewedAt/By)
PUT/spots/:id/rejectAdminrejected (+ rejectionReason)
GET/spots/:id/photosPublic{googlePhotos, userPhotos (hidden filtered), mainImage}
POST/spots/:id/photosJWTUpload user photo (multipart photo, ≤10 MB, images only → S3)
DELETE/spots/:id/photos/:photoKeyJWTDelete — uploader or admin
POST/spots/:id/photos/:photoKey/reportJWTReport. Idempotent per user; auto-hide at 3 distinct reporters (PHOTO_REPORT_HIDE_THRESHOLD = 3)
GET/spots/:id/places-infoPublicFetch/cache Google Places data (30-day cache)
GET/spots/places-search · /places/:placeId · /reverse-geocodeJWTGoogle Places search / details / reverse-geocode
/spotlists/*JWT (owner-scoped)Named collections CRUD; default saved bucket excluded from list view; subscription limits enforced
POST/feed/:postId/link-spotJWT (owner)Attach/clear a spotId on a post; enriched onto posts via populatePostUsers
map-pins is uncapped

GET /spots/map-pins returns all matching approved pins in the bbox with no server-side limit or clustering. Mobile keeps payloads small by querying only the visible viewport; web fetches everything once. A very large viewport / very dense region can therefore return a large payload — tracked in Known Issues.


5. QA / test plan

Test on both platforms. The crash-regression suite (5.3) is mandatory before any mobile release that touches the map or marker layer and must run on a physical iOS device.

5.1 Functional — per use case

#ScenarioPlatformExpected
F1Open mapBothApproved pins render; mobile centers near user; web shows worldwide clustered
F2Pinch zoom out/inMobileClusters form/break; no +/ buttons present
F3Pan across viewportMobilePins re-query for new bbox (~350 ms debounce); no flicker; no crash
F4Tap clusterBothExpands / zooms into the cluster
F5Tap single pinMobileSelects, flies to it, shows card
F6Tap pinWebInfoWindow card with name/location/rating/category chip/description
F7Tap backgroundMobileDeselects
F8Filter by sport/categoryBothPins re-filter; web is instant (client-side, dual-keyed category/sportTypes)
F9Filter by countryWebPins filter to country
F10Center buttonMobileRecenters on current location
F11Fullscreen toggleMobileEdge-to-edge; region/selection/filters preserved
F12List toggleBothCountry → state → spot hierarchy
F13In-map search — spotMobileFlies and shows card
F14In-map search — placeMobileFlies only (no card)
F15Add spot (drop pin)MobilecreateSpot then photo upload; first photo = cover
F16Add spot (search place)MobilePlace selectable as location
F17Add spot (lat/lng)WebCoordinates accepted
F18Add spot (paste Maps URL)WebCoords parsed; city/state reverse-geocoded
F19Public vs private on addBothPublic → pending (queue); private → private (owner-only)
F20Edit spot (owner)BothPatch persists; non-owner/non-admin gets 403
F21Delete spotBothSpot gone; S3 photos removed; id pulled from all Collections
F22Tag spot on postBothspotId on post; enriched everywhere (feed/profile/single)
F23Spot chip renderWebLocation text shows (known: not clickable)
F24One-tap saveMobileOptimistic add to Saved; toggles off; no limit
F25Long-press saveMobileList picker → adds to named Collection
F26My Spots bucketsMobileMine (all statuses) + Saved (newest-first) correct
F27Named Collection limitsBothSubscription limits enforced on named lists; default bucket exempt
F28Add photoMobileGallery/camera upload appears immediately
F29Trick history submit/upvoteWebLogged-in submit/upvote; logged-out read-only
F30Missing Google Maps keyWebConfig message, no crash; pin fallback /spots?limit=5000 works

5.2 Approval / visibility

  • Approved spot appears on map-pins/list/search; private spot appears only to its owner; pending appears in the admin queue and not on the public map.
  • GET /spots/:id returns 404 for a private/pending/rejected spot to a non-owner; returns it to owner/admin.
  • Admin approve moves a spot onto the map; reject records rejectionReason.
  • Antimeridian: a viewport crossing ±180° returns pins from both sides.

5.3 CRASH regression (mandatory, physical iOS 26)

This suite reproduces the RN #53303 touch-registry crash and the AIRGoogleMap New-Architecture crash. If any of these crash, do not ship.

#TestExpected
C1Load a dense region (many pins in viewport) on a physical iOS 26 deviceRenders; smooth; no crash
C2Aggressive continuous panning across dense areasNo touch-registry desync; no crash
C3Aggressive pinch in/out repeatedly over dense dataClusters form/break; no crash
C4Tap a marker mid-pan as it scrolls off-screenDeferred onPress; marker hidden not unmounted; no crash
C5Rapid select → deselect → re-select while panningStable cluster keys hold; no remount churn crash
C6Confirm off-screen markers are hidden, not unmounted (instrument/inspect)Touch targets never destroyed mid-gesture
C7Confirm patch-package RCTAssert patch present in the buildSafety net in place
Physical device only, dense data only

The crash does not reproduce on the iOS Simulator or with sparse data. Every marker-layer change must be validated on a real iOS 26 device against a dense dataset with aggressive gestures. A "works on simulator" result proves nothing here.

5.4 Performance / smoothness

  • Panning/zooming a dense viewport stays visually smooth (target ~60fps; no long frame hitches).
  • Rendered marker count stays bounded by clustering even as data grows.
  • Viewport re-query debounce (~350 ms) prevents request storms during fast panning.
  • Web: large single map-pins payload loads without freezing the main thread (watch the uncapped-payload risk).

5.5 Moderation / photos

  • Photo is public immediately on upload.
  • Reporting is idempotent per user (same user reporting twice counts once).
  • 3 distinct reporters → auto-hide; hidden photo disappears from GET /:id and /:id/photos.
  • Admin hard-delete removes a photo regardless of report count.
  • Non-uploader, non-admin cannot delete another user's photo (403).
  • Google photos are read-only (no report/delete).

5.6 Permissions

  • Location — map requests it on first open; denial degrades gracefully (map still usable, no auto-center).
  • Photos (gallery) — requested at Add Photo / add-spot; denial blocks that path with a clear message, no crash.
  • Camera — requested when taking a photo; denial handled gracefully.

5.7 Acceptance criteria

  • ✅ Explore, search, filter, tap-pin, fullscreen all work on mobile; explore/filter/tap-pin/list work on web.
  • ✅ Add / edit / tag / save / photo-contribute flows complete end-to-end per the matrix in Section 1.
  • ✅ Only approved spots appear publicly; ownership/admin authorization enforced (403s correct).
  • ✅ Photo auto-hide triggers at exactly 3 distinct reporters.
  • Zero crashes across the entire 5.3 suite on a physical iOS 26 device with dense data.
  • ✅ No performance regression versus the current release on a dense viewport.

6. Known issues / follow-ups

ItemStatusNotes
Reanimated-driven marker positions🚧 In progressDrive projected-marker positions through Reanimated shared values for buttery pan/zoom smoothness (fewer JS-thread position updates)
My-Spots row unsave affordance📋 PlannedThe My Spots list rows need a clearer unsave/remove control (parity with the map bookmark toggle)
add.tsx <Marker> migration📋 PlannedThe add-spot drop-pin still uses a native <Marker>; migrate to the projected-overlay approach for full consistency with the main map
Add-spot from a searched place📋 PlannedTighten the flow so selecting a Google Places result in search flows directly into add-spot with the place pre-filled
Offline support📋 PlannedCache viewport pins / user Collections and tiles for offline browse; sync on reconnect
Web clickable spot chip🚧 GapFeed shows tagged location as plain text, not a link to the spot page (trick tags are clickable — spot chip should match)
Vestigial local-only "heart" favorite🚧 CleanupThe mobile detail page has a leftover local-only heart favorite superseded by one-tap save; remove to avoid confusion
map-pins uncapped payload🚧 RiskNo server-side limit/clustering on map-pins; a very large/dense viewport can return a large payload. Consider a server cap or server-side clustering
POST /spots/bulk has no admin gate / no owner/status🚧 RiskBulk-inserted spots set no userId/approvalStatus, so they're unowned and invisible on approved-only endpoints unless the payload pre-sets approvalStatus: 'approved'
feed.js stores userId as string vs spots.js ObjectId🚧 InconsistencyCross-collection type mismatch in ownership comparisons; harmonize to avoid subtle auth bugs
Where this sits on the roadmap

The Spots surface (including this map) is slated for a broader UX-excellence pass with analytics instrumented first. See Spots → Planned UX refactor and Roadmap Priorities.