Summary
"Import from Camera Roll" (in-app UI: "Retrace my steps") lets a user backfill
their Scout collection from photos they already have, instead of only
unlocking patches by physically carrying the phone there. The app reads GPS
and timestamp metadata off the user's photo library (or one album, or a
hand-picked selection), sends the coordinates to the backend in batches, gets
back which patches' boundaries contain each point, and lets the user pick
which of the found patches to add to their collection. The patch's "collected"
date is set to the photo's own creation date, not the moment of import.
Patches earned this way are explicitly tagged source: 'import' and are
excluded from the achievement system — only in-person GPS unlocks count there.
No image ever leaves the device; only latitude/longitude and a timestamp are
sent to the backend.
Status (shipped / beta-badged / flagged off)
Shipped, fully live, no feature flag. There is no photo_import-style
entry in the feature-flag registries (mobile/src/config/feature-flags.ts,
backend/src/app-config/app-config-schema.ts) and openSource/usePhotoImport
run unconditionally. Unlike sibling drawer items ("The Album", "My Patches"
grouping, "Day Trip", "Road Trip"), the drawer's "Import from Camera Roll" row
carries no trailing: betaBadge — it is not marked beta
(mobile/src/components/navigation/drawerSections.ts:246-251). The only gate
is isVisible: (ctx) => ctx.hasSession, and guests get a session, so it is
visible to essentially every user, signed-in or not.
User-facing surfaces (screens, routes, deep links, entry points)
- Drawer entry: Drawer → YOU → "Import from Camera Roll"
(
mobile/src/components/navigation/drawerSections.ts:246-251),href: '/import-modal'. - Deep link:
scout://import-modal, registered inmobile/src/dev/deepLinkRoutes.ts:105(category "Patches"). - Screen:
mobile/app/import-modal.tsx— a single pushed full screen (the filename is historical; a comment at the top notes it used to be a page-sheet before the flow grew multiple "beats"). It renders different content perusePhotoImportstate:idle/no_matches→RetraceInvite(.../retrace/RetraceInvite.tsx) — the entry card with a "Retrace my steps" primary CTA and a "Choose what to scan" secondary link.choosing_source→ImportSourceSheet— pick "Entire library" / "An album" / "Specific photos".choosing_album→AlbumPicker— a two-up grid of device albums, richest first.requesting_permission/selecting→ a brass spinner (ScanDial).permission_denied/limited_access/error→ dedicated cards, each with a "Pick photos instead" escape hatch.processing(phasetracing),confirming(phaseclaiming),saving/success(phasefinishing) → all three renderRetraceSurface, one continuous surface that morphs from a live scan animation into a claim grid into a gold reward stamp.
- No other screen embeds this flow — it is reached only via the drawer / deep link, not from a patch detail page or the map.
How it works (end-to-end mechanism)
- Source selection.
usePhotoImport.runImport(source)(mobile/src/hooks/usePhotoImport.ts:216) drives everything from here on;scanAllPhotos/scanAlbum/selectPhotosare thin wrappers that pick aImportSource({kind:'library'},{kind:'album', albumId, title}, or{kind:'picked'}). - Acquisition (
mobile/src/hooks/photoImportSources.ts,openSource):library/album: requestsexpo-media-librarypermission on demand (tied to the user's tap, not screen mount), then pages throughMediaLibrary.getAssetsAsyncin batches of 100 (BATCH_SIZE), reading up to 15 assets concurrently per batch (READ_CONCURRENCY).picked: opensexpo-image-picker's native multi-select (launchImageLibraryAsyncwithexif: true), which needs no library permission grant at all.
- Per-photo GPS read. For library/album, each asset's location comes from
expo-media-library/build/next'sAsset(nextAssetRef(id)).getLocation()— deliberately notMediaLibrary.getAssetInfoAsync. A code comment (photoImportSources.ts:128-137) documents the measured trap: on a 131-photo simulator library,getAssetInfoAsynctook 174ms/photo (its iOS path resolves aPHContentEditingInputand decodes the file off disk to build a full EXIF dictionary), ran on the main thread, and dragged the UI to ~4fps for the whole scan (27.1s total).getLocation()reads onlyPHAsset.location(Android: the EXIF header viaExifInterface), measured at 1.3ms/photo with identical results (0.30s total for the same 131 photos).mediaAssetRef.nextAssetRef()(mobile/src/utils/mediaAssetRef.ts) translates the legacy asset id into thenextAPI's id shape (ph://on iOS, a rebuiltcontent://media/external/images/media/<id>URI on Android).- For
pickedphotos, GPS instead comes from the EXIF dict ImagePicker already returns (extractGpsCoordinates,mobile/src/lib/exif.ts), applying the N/S/E/W sign to the raw magnitudes. - A read failure is tallied separately from "no GPS" (
LocationRead'sok: falsebranch) so a systematic failure surfaces in logs rather than silently reading as "none of your photos have location."
- For
- Matching, page by page. As each page of GPS-bearing photos comes back,
usePhotoImportchunks it to 100 (MATCH_CHUNK, since the batch endpoint caps at 100 locations — see below) and callsmatchPhotosToPatches(mobile/src/utils/unified-photo-matching.ts:336), which callsPOST /api/location/check-batch(mobile/src/utils/unified-photo-matching.ts:140, backend atbackend/src/location/location.controller.ts:39/backend/src/location/location.service.ts:89). If the backend call throws, matching falls back to a client-side Haversine centroid-plus-radius check (matchViaClientFallback) — which cannot see polygon boundaries at all, only patches with a plain lat/lng, so an offline/degraded import silently misses every polygon-backed (city, state, NPS, OSM) patch until a later backend-connected pass runs.- A previous page's match is awaited before the next page's match starts (keeps memory/accumulator updates serial), while the acquisition generator is already fetching/reading the next page in parallel — so disk reads and the network round-trip overlap.
- Server-side containment (
LocationService.checkBatch,backend/src/location/location.service.ts:89-203) runs two SQL queries per batch:- Point match: for patches with neither a
protectedAreaIdnor ageofenceId(a bare lat/lng pin),ST_DWithinwithinphotoMatchRadiusMeters(AppConfig key, default 150m — deliberately larger than live-tracking tolerance because photo GPS is less accurate). - Polygon match:
ST_Containsagainstprotected_areas.geomUNIONgeofences.geom— plain containment, no buffer. A comment (location.service.ts:144-159) documents that this replaced an earlier blanketphotoPolygonBufferMeters(10m) applied to every polygon, which let a whole stadium be "earned" from the pavement outside it; any tolerance a subject needs (a statue, a monument) is now baked into the geometry itself (geofences.source = 'osm_buffered'), not into the import path. This makes camera-roll import agree with the live-tracking unlock rule (checkPolygons) for every polygon-backed patch — the one documented exception is aPatchPhotonear-miss buffer used elsewhere for photo check-in verification, not this batch path; seelocation.geometry.db.spec.ts's own comments for that history. - Because containment is queried directly (not per-photo iteration), a
single photo inside nested boundaries (e.g. a zoo inside a city park
inside a city inside a state) returns every containing patch as an
independent direct match — there is no separate "cascade" step; that
logic was deleted (
unified-photo-matching.ts:257-266) when city/state patches got real boundary polygons.
- Point match: for patches with neither a
- Accumulation.
createMatchAccumulator()(mobile/src/hooks/photoImportMatching.ts) dedupes matches by patch id as pages stream in, keeping the earliest photo date per patch (so if the same place appears in five photos, the earliest one's date is what gets recorded), while preserving first-seen order for the "recently found" strip shown during the scan. - Visit photos (device-local, not the community gallery). Every matched
still photo (not video) that has a durable MediaLibrary asset id is kept as
a
(patchId, assetId, takenAt)pair (visitPhotoPairsFrom,mobile/src/hooks/visitPhotoCapture.ts:40) and committed to the Zustand store'svisitPhotosmap (addVisitPhotos→mobile/src/domain/visitPhotos.ts:37, deduped by(patchId, assetId)) as soon as the scan finishes — independent of which patches the user later chooses to collect. This is what populates a patch's "My Visit" photo carousel. These are private on-device references (never uploaded as image bytes here); they are distinct from the publicPatchPhotogallery rows (see "What this feature does NOT do"). - Confirm. The user reviews the claim grid (already-collected patches
shown disabled but not re-collectable) and taps "Add N to my guide" →
confirmImport()→store.collectPatchBatch()(mobile/src/domain/store.ts:1639), which enqueues aPendingPatchper newly-selected patch withcollectedAt: <the photo's creationTime>andsource: 'import', then relies on the ordinary sync push (POST /api/sync/push, JWT-guarded) to write it toUserPatchon the server. Anything already inuserPatchesis silently skipped (no duplicate row, no re-collect). - Analytics-only reporting of misses. Photos that had GPS but matched no
patch (capped at 500,
MAX_POTENTIAL_MATCHES) are POSTed toPOST /api/potential-matches(no auth guard; carries only lat/lng, a null place name, and the photo's date) so Scout can see where users go that aren't covered by a patch yet. This is the only place is genuinely "leaves the device" beyond the check-batch coordinates themselves — still never an image.
Data model (Prisma models and key fields)
UserPatch(backend/prisma/schema.prisma:785-808) — the collected badge.source: String @default("unknown")records how it was earned;'gps'is the only value that counts for achievements,'import'(this feature) and'unknown'(pre-column rows) do not (backend/src/achievements/feature-context.ts:12—IN_PERSON_SOURCE = 'gps').collectedAt: DateTimeis set from the photo's own creation time, notnow()— see step 8 above.PotentialMatch(schema.prisma:1055-1070) — the "no patch matched this location" analytics row:userId?,deviceId?,latitude,longitude,placeName?(always null from this path — seeusePhotoImport.ts:322-327),photoDate?.Patch.protectedAreaId/Patch.geofenceId— which polygon table (if any) a patch's boundary lives in;checkBatchunions both, and a patch with neither falls back to the point-radius path.PatchPhoto(schema.prisma:405-431) is the public community gallery row and is not written by this feature (see "does NOT do"); it is a separate, unrelated upload path.- Visit photos (
VisitPhotoRef/VisitPhotosMap,mobile/src/domain/visitPhotos.ts) are not a backend table — they live only in the Zustand store on-device (persisted via MMKV), keyed by patch id.
API surface (endpoints, auth requirements)
| Endpoint | Method | Guard | Used for |
|---|---|---|---|
/api/location/check-batch |
POST | none (backend/src/location/location.controller.ts:39) |
Batch GPS→patch matching during the scan. Body: { locations: [{id, lat, lng}] }, max 100 per CheckBatchDto (@ArrayMaxSize(100)). Response: per-location pointMatches/polygonMatches patch id arrays. |
/api/location/check-polygons |
POST | none | The live-tracking unlock path (checkPolygons), not import — cited here only because the two share a service and a plain-containment rule; camera-roll import calls check-batch, never this one. |
/api/potential-matches |
POST | none | Uploads GPS-only "no match" rows for analytics after a completed import. |
/api/potential-matches/admin |
GET | AdminGuard |
Admin dashboard read of aggregated potential matches (out of this feature's mobile flow). |
/api/sync/push |
POST | JwtAuthGuard |
The generic sync push that actually writes UserPatch rows (including source: 'import' ones) to the server — not a dedicated import endpoint. |
There is no dedicated "import" backend endpoint; the mechanism reuses the generic location-matching and sync infrastructure.
Key files (annotated)
mobile/app/import-modal.tsx— screen orchestrator, state→UI mapping, back navigation, post-success sync trigger. Split into the house view-model pattern:ImportModalScreenViewModelImplowns every hook (theusePhotoImportmachine, the no-matches alert, the post-success sync and auto-dismiss,Linking.openSettings), andImportModalScreenLayoutis a pure layout that draws the view model. Two pure helpers are exported so the screen-mock gallery derives the same chrome the screen does rather than asserting it by hand:importModalSurface(state)maps anImportStateonto theImportModalSurfacediscriminant (invite/choosing-source/choosing-album/waiting+ its dial label /permission-denied/limited-access/retracing+ itsRetracePhase/error), andimportModalShowBack(state)is the back-chevron rule (hidden onsavingandsuccess). The view model carries the scan as DATA —progress(scanned/total/matchesFound/recentMatches),sourceLabel, the per-matchmatcheswith theiralreadyCollectedflag,selectedPatchIds, and the reward counts — never a rendered node.mobile/src/hooks/usePhotoImport.ts— the state machine: acquire → page → match → accumulate → confirm → save.MATCH_CHUNK = 100(line ~140) is deliberately decoupled from the media page size (100) but coincidentally equal to the batch endpoint's cap.mobile/src/hooks/photoImportSources.ts— where photos come from (openSource); thegetAssetInfoAsyncperformance trap is documented at lines 128-149;BATCH_SIZE/READ_CONCURRENCYtuning at lines 74-75.mobile/src/hooks/photoImportMatching.ts—MatchResultflattening and the dedupe accumulator (earliest-date-wins).mobile/src/hooks/visitPhotoCapture.ts— turns a match result into device-local visit-photo pairs; the video/no-asset-id exclusion rule lives here (lines 21-39).mobile/src/utils/unified-photo-matching.ts— the shared matcher used by both camera-roll import and single-photo check-in; backend-first with a polygon-blind client fallback (lines 105-256); per-photo bucketing into new/already-collected (lines 297-314).mobile/src/utils/mediaAssetRef.ts— thenext-API id translation (iOSph://…, Android reconstructedcontent://…).mobile/src/lib/exif.ts— shared EXIF GPS parser (used by both the picked path here and the separate upload picker).mobile/src/domain/visitPhotos.ts— pure reducer for the on-device patch→photo map (add/remove/dedupe).mobile/src/domain/store.ts:1639(collectPatchBatch) — writes thesource: 'import'pending patches; comment at line ~1671 states plainly these "earn no achievements."mobile/src/components/import/retrace/*— the presentation layer:RetraceInvite(entry card),ImportSourceSheet(source picker),AlbumPicker(album grid),RetraceSurface(the combined scan/claim/reward surface),RetraceMap+retracePath.ts(the animated route drawing — see below),RetracePatchGrid(TraceCell/ClaimCell).backend/src/location/location.service.ts—checkPolygons(live tracking, hard containment) vscheckBatch(import: point-radius for pin-only patches, hard containment for polygon patches).backend/src/location/dto/location.dto.ts—CheckBatchDto(100-location cap),LocationInput.backend/src/potential-matches/potential-matches.service.ts— stores unmatched-photo analytics rows.backend/src/app-config/app-config-schema.ts:51-67—photoMatchRadiusMeters(used, default 150m) andphotoPolygonBufferMeters(still present in the admin-editable schema but not read anywhere in currentlocation.service.ts— see Open Questions).
Configuration and flags
- No feature flag gates this feature (see Status).
photoMatchRadiusMeters(AppConfig, admin-editable, default 150m, range 50–500m) — the only tunable that affects matching: it sets the point- match radius for patches that have neither a geofence nor a protected-area polygon. Mobile has its own local fallback default of 150m (mobile/src/domain/app-config/types.ts:30,DEFAULT_APP_CONFIG) used if the app-config fetch hasn't landed yet, and also as the client-fallback Haversine radius when the backend call fails outright.MAX_POTENTIAL_MATCHES = 500(mobile, hardcoded) — caps how many unmatched-photo rows one scan will upload.BATCH_SIZE = 100/READ_CONCURRENCY = 15(mobile, hardcoded, tuning constants for the native photo sweep, not user- or server-configurable).
Edge cases and known limits
- No GPS on any scanned photo: if the whole sweep finds zero GPS-bearing
photos and the source was not limited access, the user sees "Scanned N
photos but none had location data. Please enable GPS/Location when taking
photos." (
usePhotoImport.ts:380-382) and lands back in an error state. - iOS limited-library selection:
MediaLibrary.requestPermissionsAsync()reportsaccessPrivileges === 'limited'. If the totalCount visible is 0, the flow shows a distinct "limited_access" card rather than implying the library has no GPS photos (photoImportSources.ts:346-350); if photos are visible but zero of them have GPS, the same "limited_access" branch fires instead of the generic no-GPS error, because "the odds a hand-picked subset happens to include GPS photos are low" (usePhotoImport.ts:372-378).expandPhotoAccess()callsMediaLibrary.presentPermissionsPickerAsync(['photo'])to widen access (re-opens the system picker on Android 14+/limited-library manager on iOS); if that call itself throws, it falls back toLinking.openSettings(). - Android ≤12 (API ≤32) breakage (regression-guarded, not currently live):
a Regression test (
mobile/src/config/__tests__/androidMediaPermissions.test.ts) pins thatWRITE_EXTERNAL_STORAGEand every permissionexpo-media-library's config plugin requests must not be listed inandroid.blockedPermissions. If it were blocked,MediaLibraryModule.hasReadPermissions()'s pre-Tiramisu branch would demandREAD_EXTERNAL_STORAGE+WRITE_EXTERNAL_STORAGEtogether with no manifest check, so the read gate would fail forever; the failure would be silent up front (requestPermissionsAsync()still resolvesgranted, since it adapts to what the manifest actually declares) and only surface later asgetAssetsAsyncthrowingERR_PERMISSIONS. API 33+ (READ_MEDIA_IMAGES) is unaffected either way. - Video handling: on iOS, videos are swept alongside photos
(
sweepMediaTypes()includesMediaLibrary.MediaType.video) becausePHAsset.locationis populated for videos the same cheap way as stills, so a video's GPS can unlock a patch. On Android, videos are excluded from the sweep entirely —ExifInterface(the fast path used there) has no MP4/MOV parser, a video's coordinates live in the QuickTimeudtaatom reachable only viaMediaMetadataRetriever(whichexpo-media-librarydoesn't expose), andnextAssetRef()only builds an imagescontent://URI. On both platforms, a matched video is used to unlock the patch but is never kept as a "My Visit" photo reference —visitPhotoPairsFromdrops anykind === 'video'photo, because that list feeds the cloud-album upload path, which is built for still images only. - Photos with no location: silently skipped during the sweep (not
reported as errors; only a genuine read failure is tallied and logged
separately — see
LocationRead). - Backend call failure: falls back to client-side Haversine matching, which is polygon-blind (centroid + radius only) — city/state/NPS/OSM polygon-backed patches will not be found until a backend-connected re-scan.
- Cancel mid-scan:
reset()/unmount flips aScanToken.abortedflag checked at every page boundary and everyREAD_CONCURRENCYchunk inside a page; partial results are discarded, not surfaced. - Repeat scans / dedup: matching a patch that's already in the user's
collection buckets it into
alreadyCollectedMatches(shown disabled in the claim grid, not re-collectable, not re-synced) but the photo is still attached to visit photos. Visit-photo refs dedupe by(patchId, assetId)(addVisitPhotoRefs), so rescanning the same library twice does not duplicate "My Visit" entries. - The animated map is decorative, not geographic:
RetraceMap/retracePath.tsdraws one fixed, hand-authored SVG route (ROUTE_D, 320×190 design space) with a reticle and 7 fixed "pin" positions that "bloom" as a subset — none of this reflects the user's actual photo locations. It is driven purely by the real scan'sscanned/totalfraction (via a shared value animated withwithTiming), so its pacing is real but its geography is not. no_matchesis invisible to anything but a person watching the screen. It renders the sameRetraceInviteasidle— the only thing that marks it is a nativeAlert.alert("No patches matched", …)fired from an effect, which is not part of the view tree. So a screenshot, a screen test and the screen-mock gallery cannot tellno_matchesfromidle, and the gallery deliberately does not register a state for it.- The user is never told how many photos matched nothing. Photos that had
GPS but hit no patch are counted only for
POST /api/potential-matches; the claim header reports found patches and the scan bar reports photos read, so the miss count only ever exists as the arithmetic gap between them (e.g. "1,864 of 4,182" read, "12 patches found"). There is no "N photos matched nothing" line anywhere in the flow. - The scan radius silently falls back when app-config has not landed.
usePhotoImportreadsphotoMatchRadiusMetersviauseQuery(appConfigQuery())and drops bothisLoadinganderror(usePhotoImport.ts:149-151), defaulting toDEFAULT_APP_CONFIG .photoMatchRadiusMeters(150m). A scan begun before the config resolves, or while the config request is failing, matches point-only patches at the client's local default rather than the admin-configured value, with nothing on screen to say so. It does not produce a false terminal state — the screen has no loading or error surface of its own to get wrong — but the results are quietly computed at a different radius. - A failed post-import sync still reads as success. The
successeffect firessyncNow(true)andsyncCloudAlbum()and onlyconsole.errors a rejection; the gold stamp plays and the modal auto-dismisses afterREWARD_HOLD_MS(1100ms) regardless. The claim is safe (it is already in the local pending queue and a later sync pushes it), but the user is told the import landed on the server when it may not have. - Two number formats on one screen. The scan readout uses
toLocaleString()("1,864 of 4,182") while the no-GPS error prints the rawprocessedCount("Scanned 4182 photos but none had location data"). - Confirm skips already-in-progress-elsewhere duplicates:
collectPatchBatchchecksexistingPatchIdsat call time and silently drops anything already collected —confirmImportcan legitimately reportcollectedCount: 0for a non-empty selection if another sync landed those same patches first.
What this feature does NOT do
- Does not earn achievements.
UserPatch.source = 'import'is explicitly excluded fromIN_PERSON_SOURCE("gps") inbackend/src/achievements/achievements.service.ts:41-47andfeature-context.ts:7-12— importing your whole camera roll and unlocking 200 patches at once earns zero achievement progress. This is a deliberate, explicit product rule ("Achievements are for being somewhere in person"), not an oversight. - Does not upload or publish any photo. No image bytes are ever sent to
the backend by this feature. Only GPS coordinates and a timestamp travel to
/api/location/check-batchand/api/potential-matches. Visit photos stay as on-device MediaLibrary references (VisitPhotoRef.assetId); they are not automatically part of the user's cloud album backup or the public gallery — see the next point. - Does not write to the public
PatchPhotogallery.backend/src/patch-photos/is a completely separate upload path (POST /patches/:patchId/photos) that this feature never calls. A photo matched during import only becomes a device-local "My Visit" reference; making it visible to other users (via "publish to gallery") is a distinct, user-initiated action elsewhere in the app, outside this feature's scope. - Does not perform live GPS tracking or drive the compass. That is a
separate always-on background feature (
checkPolygons/mobile/src/services/location/tracker.ts); camera-roll import is a one-shot retrospective sweep the user explicitly triggers. - Does not apply any tolerance/buffer to polygon-backed patches beyond what
live tracking gets. The old blanket 10m
photoPolygonBufferMetersbuffer is gone from the code path; a photo just outside a small building's footprint will not match it, by the same rule that applies to walking up to it in person. - Does not show the user's real travel route. The "map" animation during scanning is a fixed decorative graphic, not a rendering of the photos' true locations (see Edge cases).
- Does not gate on any feature flag or beta badge — it is treated as a fully mainstream, always-on feature, not an experiment.
Tests that cover it
- Unit / hook tests (mobile, Jest):
mobile/src/hooks/__tests__/photoImportSources.test.ts(23 cases) — acquisition, permission branches, page paging, location-read success/failure handling.mobile/src/hooks/__tests__/photoImportMatching.test.ts(9 cases) — accumulator dedupe/earliest-date behavior.mobile/src/hooks/__tests__/visitPhotoCapture.test.ts— visit-photo pair derivation, video/no-asset-id exclusion.mobile/src/hooks/__tests__/usePhotoImport.featureEvents.test.tsx(3 cases) — analytics event firing.mobile/src/utils/__tests__/unified-photo-matching.test.ts(7 cases) — backend-first/client-fallback matching, bucketing.mobile/src/utils/__tests__/mediaAssetRef.test.ts(2 cases) — iOS/Android id translation.mobile/src/components/import/retrace/__tests__/retracePath.test.ts(8 cases) — pure route-geometry sampling.mobile/src/config/__tests__/androidMediaPermissions.test.ts— the Android ≤12 regression guard described above.mobile/src/dev/mocks/import-modal.tsx+ the rot guard inmobile/screen-tests/screen-mocks.test.tsx— fifteen gallery states (scout://dev-screen-mock/import-modal?state=<slug>) covering the beats a device cannot reach without a seeded camera roll:invite,choosing-source,choosing-album,requesting-permission,opening-picker,permission-denied,limited-access,tracing,claiming,claiming-none-selected,claiming-all-collected,claiming-large,finishing,no-gpsanderror. Each asserts a string or testID only that state renders; the patches are 39 real Massachusetts catalog rows (none seeded from anadmin_onlycollection) and only the photographs and the scan counts are synthetic.mobile/screen-tests/import-modal.test.tsx— the screen registry's integration test; explicitly documents thatstateis local to the hook and can only be driven by real interaction, so it stubsusePhotoImportfor the terminalsuccesspath and drives the defaultidle→RetraceInviterender for real; also covers the post-success effect (kickssyncCloudAlbum, does not sync mid-scan).
- Backend tests (NestJS/Jest):
backend/src/location/location.service.spec.ts—checkBatchunit-level behavior includingphotoMatchRadiusMeterssourcing from AppConfig.backend/src/location/location.geometry.db.spec.ts— real-PostGIS fixture tests assertingcheckBatchmatches via plain polygon containment (no buffer) and via the point radius; the file's own comments narrate the removal of the old buffer.backend/src/location/location.catalog.live.spec.ts— sweeps the real catalogue throughcheckBatch(interior/exterior points per geometry kind).
- End-to-end (Maestro):
mobile/maestro/tests/photo-import.yamlandmobile/maestro/tests/photo-import-android.yaml— drives a real scan against curated GPS-tagged fixture photos (mobile/maestro/fixtures/geo-photos/geo_*.jpg, seeded onto the simulator viaxcrun simctl addmedia), asserts nested-boundary matching (a photo at the Saint Louis Zoo unlocking both the zoo and the containing Forest Park patch), and — per this file's own header comment — also absorbed the formeralbum-photo-removal.yamlflow, since removing an imported "My Visit" photo needs an import to have already happened.
Open questions
photoPolygonBufferMetersremains a live, admin-editable AppConfig key (backend/src/app-config/app-config-schema.ts:63-70) but is not read anywhere in currentlocation.service.ts— it appears to be dead configuration left over from the buffer-removal described in that file's own comments. Unconfirmed whether it's still wired into some other code path (e.g. the single-photo check-in flow) not read for this document, or is simply stale and safe to remove.- Whether
photoMatchRadiusMetersis the only config surfaced in the admin UI for tuning import matching, or whether an admin-facing screen exposes more, was not verified — this document only traces what the mobile client andlocation.service.tsactually consume. - The exact wall-clock/UX behavior for a very large library (tens of thousands of photos) beyond the documented 131-photo benchmark was not measured directly for this document; only the code's own stated measurements are cited.