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.
- 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
| Capability | Mobile (React Native / Expo) | Web (Next.js) |
|---|---|---|
| Interactive map of approved spots | ✅ Live | ✅ Live |
| Map technology | Google map base + projected RN-View overlay markers (custom) | Real Google Maps JS SDK markers (@vis.gl/react-google-maps) |
| Clustering | ✅ supercluster (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) |
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
- Open the Spots tab → the All Spots Map loads centered near you (location permission requested on first use).
- Pins load for the current viewport only — as you pan/zoom, the app re-queries
GET /map-pinsfor the new bounding box (debounced ~350 ms). - Zoom with pinch only. There are intentionally no
+/–buttons (see Section 3). Pinch out to see clusters, pinch in to break them apart. - 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.
- 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
- Tap the magnifying-glass control → a full-screen search panel opens.
- Type a query. Two result groups return in parallel: Spots (
searchSpots) and Google Places (searchPlaces, biased to the current viewport). - Tap a Spot result → the map flies to it and shows its card.
- 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
- 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=5000if that 404s). - 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
categoryfield or membership in itssportTypesarray.) - 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.
- Click "View Details →" → the spot detail page at
/spots/{state}/{spotSlug}?id={id}. - 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 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
- On the map, tap
+. - Set the location one of two ways: drop a pin on the map, or search a place and pick it.
- Tap Continue → the details form.
- Fill in name, category, sport types, and Public / Private.
- Add photos — pick from Gallery or take one with the Camera (permissions requested as needed). The first photo becomes the cover.
- Submit (Public → enters the admin review queue as
pending) or Save (Private → stays yours,private).- Under the hood:
createSpotruns first, then each photo is sent viauploadSpotPhoto.
- Under the hood:
Web
- On
/spots, click Add Spot (logged-out users are redirected to/login?redirect=/spots/add). - 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. - Fill in name, description, tags, and an image URL.
- Check "Submit for public listing" to send it to admin review (
pending), or leave it unchecked to keep it private. - Submit → on success you're redirected to the new spot's detail page.
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-spotsand manage everything at/admin/spots.
2d. Tag a spot in a video/post (+ the spot chip)
Mobile
- Go to Upload (media).
- In the composer, use Tag a spot → a
searchSpotspicker. - Select a spot → its
spotIdis attached to the post.
Web
- Go to
/media/feed/upload, choose a video/image. - Use the spot search field to find and select a spot.
- The chosen
spotIdis 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
- 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.
- Long-press the bookmark → a list picker to add the spot to a named Collection.
- View saved/created spots under My Spots (buckets: Mine =
getMySpots, Saved =getSavedSpots) or open a Collection (namedSpotList).
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.
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)
- On a spot's detail page, the gallery combines user photos and Google photos.
- Any logged-in user taps Add Photo → choose Gallery or Camera → the photo uploads and appears right away.
Report / moderate (mobile)
- Long-press a user photo → Report or (if it's yours / you're admin) Delete. Google photos are read-only.
- Reporting is idempotent per user (each distinct reporter counts once).
- 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 (viarequestAnimationFrame) so a press that lands as a marker is repositioning doesn't fire mid-reconcile. patch-packageRCTAssertsafety net. A patched assertion prevents a hard crash if the registry ever does desync — belt and suspenders.
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.
Google-Maps-style in-map search
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
| Piece | Detail |
|---|---|
| Map base | react-native-maps MapView with PROVIDER_GOOGLE, rendered empty (no native markers on the main map) |
| Markers | Plain RN Views positioned by projectToScreenXY in src/lib/mapProjection.ts |
| Off-screen markers | Mounted but hidden (never unmounted mid-gesture) — RN #53303 mitigation |
| Regions | settled region → clustering + which pins load; projectionRegion → marker screen positions |
| Clustering | supercluster via src/hooks/useMapClusters.ts (radius 50 / maxZoom 18 / minPoints 3) |
| Pin loading | Viewport-loaded via getMapPins → GET /spots/map-pins, debounced ~350 ms on pan/zoom |
| Controls | Filter · Center · Search · Fullscreen · List — no +/– zoom |
| Search | searchPlaces (Google Places, viewport-biased) + searchSpots, in one panel |
| Save | getMySpots (Mine) + getSavedSpots (Saved); named lists via SpotList |
| Add | app/(tabs)/spots/add.tsx — still renders a native <Marker> for the drop-pin (with a code warning); submits createSpot then uploadSpotPhoto (first = cover) |
| Detail | app/(tabs)/spots/[spotId].tsx — merges userPhotos + googlePhotos; Add Photo (any user), Report/Delete (user photos), Google read-only; owner Edit/Delete |
| Tag | media/upload.tsx — searchSpots 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
| Piece | Detail |
|---|---|
| Map component | components/SpotsMap.js |
| Map SDK | @vis.gl/react-google-maps (APIProvider / Map / InfoWindow / useMap) |
| Markers | Real new google.maps.Marker(...) with SVG-data-URI pins colored by category |
| Clustering | @googlemaps/markerclusterer (ClusteredMarkers), yellow #fcf150 cluster bubbles sized by count |
| API key | process.env.NEXT_PUBLIC_GOOGLE_MAPS_KEY (missing key → config message, not a crash) |
| Pin fetch | Once from ${API_BASE}/spots/map-pins, fallback to /spots?limit=5000 on 404 |
| Filtering | Client-side over all pins; category matches category or sportTypes |
| Tapped pin | Google InfoWindow card → View Details → link to /spots/{state}/{spotSlug}?id={id} |
| Rendering | SpotsMap dynamically imported with ssr: false (avoids Next.js SSR breaking on Google Maps globals) |
| Browse page | pages/spots.js — map/list toggle (default map), Category + Country dropdowns |
| List view | organizeByCountry → country → state → spot; state pages at pages/spots/[state].js (debounced 300 ms search, tag filters, SpotCard grid) |
| Detail | pages/spots/[state]/[spotSlug].js — carousel, tags, resort/ski-pass info, lodging, videos, press, Trick History |
| Add | pages/spots/add.js (public) · pages/admin/create-spot.js (admin CRUD + rating) |
| Data layer | lib/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.
| Method | Endpoint | Auth | Purpose |
|---|---|---|---|
| GET | /spots/map-pins | Public | Marker 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 | /spots | Public | Paginated approved list; sort/order/country/sportType/category/q filters |
| GET | /spots/search | Public | Approved search: q/city/state/category/tags |
| GET | /spots/:id | Public* | Full detail. Approved is public; private/pending/rejected returned only to owner/admin (optionalUser); hidden user photos stripped |
| GET | /spots/my-spots | JWT | Requester's own spots at all statuses |
| GET | /spots/saved | JWT | Spots from the user's default Saved Spots list, newest-first |
| POST | /spots | JWT | Create. isPublic:false → private; isPublic:true → pending (+submittedAt); lat/long dedupe |
| PUT | /spots/:id | JWT | Edit — owner or admin (403 else) |
| DELETE | /spots/:id | JWT | Delete — owner or admin; also deletes S3 photos and $pulls the id from all spotlists |
| POST | /spots/:id/save | JWT | One-tap save into the hidden default list (upsert). {saved:true} |
| DELETE | /spots/:id/save | JWT | Unsave ($pull). {saved:false} |
| GET | /spots/all | Admin | Every-status list |
| GET | /spots/pending | Admin | Review queue (pending, by submittedAt desc) |
| PUT | /spots/:id/approve | Admin | approved (+ reviewedAt/By) |
| PUT | /spots/:id/reject | Admin | rejected (+ rejectionReason) |
| GET | /spots/:id/photos | Public | {googlePhotos, userPhotos (hidden filtered), mainImage} |
| POST | /spots/:id/photos | JWT | Upload user photo (multipart photo, ≤10 MB, images only → S3) |
| DELETE | /spots/:id/photos/:photoKey | JWT | Delete — uploader or admin |
| POST | /spots/:id/photos/:photoKey/report | JWT | Report. Idempotent per user; auto-hide at 3 distinct reporters (PHOTO_REPORT_HIDE_THRESHOLD = 3) |
| GET | /spots/:id/places-info | Public | Fetch/cache Google Places data (30-day cache) |
| GET | /spots/places-search · /places/:placeId · /reverse-geocode | JWT | Google 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-spot | JWT (owner) | Attach/clear a spotId on a post; enriched onto posts via populatePostUsers |
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
| # | Scenario | Platform | Expected |
|---|---|---|---|
| F1 | Open map | Both | Approved pins render; mobile centers near user; web shows worldwide clustered |
| F2 | Pinch zoom out/in | Mobile | Clusters form/break; no +/– buttons present |
| F3 | Pan across viewport | Mobile | Pins re-query for new bbox (~350 ms debounce); no flicker; no crash |
| F4 | Tap cluster | Both | Expands / zooms into the cluster |
| F5 | Tap single pin | Mobile | Selects, flies to it, shows card |
| F6 | Tap pin | Web | InfoWindow card with name/location/rating/category chip/description |
| F7 | Tap background | Mobile | Deselects |
| F8 | Filter by sport/category | Both | Pins re-filter; web is instant (client-side, dual-keyed category/sportTypes) |
| F9 | Filter by country | Web | Pins filter to country |
| F10 | Center button | Mobile | Recenters on current location |
| F11 | Fullscreen toggle | Mobile | Edge-to-edge; region/selection/filters preserved |
| F12 | List toggle | Both | Country → state → spot hierarchy |
| F13 | In-map search — spot | Mobile | Flies and shows card |
| F14 | In-map search — place | Mobile | Flies only (no card) |
| F15 | Add spot (drop pin) | Mobile | createSpot then photo upload; first photo = cover |
| F16 | Add spot (search place) | Mobile | Place selectable as location |
| F17 | Add spot (lat/lng) | Web | Coordinates accepted |
| F18 | Add spot (paste Maps URL) | Web | Coords parsed; city/state reverse-geocoded |
| F19 | Public vs private on add | Both | Public → pending (queue); private → private (owner-only) |
| F20 | Edit spot (owner) | Both | Patch persists; non-owner/non-admin gets 403 |
| F21 | Delete spot | Both | Spot gone; S3 photos removed; id pulled from all Collections |
| F22 | Tag spot on post | Both | spotId on post; enriched everywhere (feed/profile/single) |
| F23 | Spot chip render | Web | Location text shows (known: not clickable) |
| F24 | One-tap save | Mobile | Optimistic add to Saved; toggles off; no limit |
| F25 | Long-press save | Mobile | List picker → adds to named Collection |
| F26 | My Spots buckets | Mobile | Mine (all statuses) + Saved (newest-first) correct |
| F27 | Named Collection limits | Both | Subscription limits enforced on named lists; default bucket exempt |
| F28 | Add photo | Mobile | Gallery/camera upload appears immediately |
| F29 | Trick history submit/upvote | Web | Logged-in submit/upvote; logged-out read-only |
| F30 | Missing Google Maps key | Web | Config 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/:idreturns 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.
| # | Test | Expected |
|---|---|---|
| C1 | Load a dense region (many pins in viewport) on a physical iOS 26 device | Renders; smooth; no crash |
| C2 | Aggressive continuous panning across dense areas | No touch-registry desync; no crash |
| C3 | Aggressive pinch in/out repeatedly over dense data | Clusters form/break; no crash |
| C4 | Tap a marker mid-pan as it scrolls off-screen | Deferred onPress; marker hidden not unmounted; no crash |
| C5 | Rapid select → deselect → re-select while panning | Stable cluster keys hold; no remount churn crash |
| C6 | Confirm off-screen markers are hidden, not unmounted (instrument/inspect) | Touch targets never destroyed mid-gesture |
| C7 | Confirm patch-package RCTAssert patch present in the build | Safety net in place |
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-pinspayload 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 /:idand/: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
| Item | Status | Notes |
|---|---|---|
| Reanimated-driven marker positions | 🚧 In progress | Drive projected-marker positions through Reanimated shared values for buttery pan/zoom smoothness (fewer JS-thread position updates) |
| My-Spots row unsave affordance | 📋 Planned | The My Spots list rows need a clearer unsave/remove control (parity with the map bookmark toggle) |
add.tsx <Marker> migration | 📋 Planned | The 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 | 📋 Planned | Tighten the flow so selecting a Google Places result in search flows directly into add-spot with the place pre-filled |
| Offline support | 📋 Planned | Cache viewport pins / user Collections and tiles for offline browse; sync on reconnect |
| Web clickable spot chip | 🚧 Gap | Feed 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 | 🚧 Cleanup | The mobile detail page has a leftover local-only heart favorite superseded by one-tap save; remove to avoid confusion |
map-pins uncapped payload | 🚧 Risk | No 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 | 🚧 Risk | Bulk-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 | 🚧 Inconsistency | Cross-collection type mismatch in ownership comparisons; harmonize to avoid subtle auth bugs |
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.