Files
exif-test/FEATURE_PLAN.md

630 lines
25 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# EXIF Scanner — Feature Roadmap
> Living feature specification document. Covers all proposed features for the photo
> upload / OCR / GPS-push app at `~/projects/exif-test/`.
**Legend:** ✅ done · 💡 proposed
---
## 1. ✅ Offline Queue — Service Worker + IndexedDB
### Use Case
Facility audits happen in warehouses, basements, parking lots, and rural sites — all
places with unreliable or zero cellular signal. Currently if the server is unreachable
the app shows nothing useful and queued photos are lost on page refresh. The user needs
to "fire and forget": snap photos, move to the next machine, and have the uploads drain
silently when signal returns.
### User Experience
1. Open the app — service worker installs, page loads from cache instantly
2. If online: everything works as today (live upload via fetch to `/api/analyze`)
3. If offline (navigator.onLine === false or fetch fails):
- Photo thumbnails still generate (client-side EXIF via exifr.js works offline)
- The gallery, summary, filter chips all work from IndexedDB-local
- "Bulk Process" button changes to "📡 Queue for Upload (N pending)"
- A small banner at the top: "📡 Offline — N photos queued"
4. When signal returns (online event or periodic background sync):
- Queue drains FIFO, one at a time, with retry (3 attempts, exponential backoff)
- Banner updates: "📡 Syncing… (3/12 remaining)"
- On completion: banner disappears, results load from server `/api/photos`
5. Page refresh mid-queue: IndexedDB persists the queue, resumes on next load
### Spec
```
IndexedDB schema — "photos-queue" store:
{ id: auto_inc, file_blob: Blob, filename: string, timestamp: number,
retries: number, status: 'queued'|'uploading'|'done'|'failed' }
Service worker:
- Cache-first strategy for static assets (/, /static/*)
- Network-first for /api/* (falls back to cached error page)
- Listen for 'sync' events from periodicSync API if available
```
### Tech
- **Service Worker registration** in `<script>` at page top
- **IndexedDB** via `idb-keyval` or raw `indexedDB` API (no extra dep)
- **Background Sync API** (`navigator.serviceWorker.ready.then(r => r.sync.register('sync-photos'))`) — falls back to `online` event listener
- **Queue manager** — `class PhotoQueue` in the main JS, not in SW (SW can't read blobs efficiently)
- **Retry** — exponential backoff: 2s, 5s, 15s, then mark failed. Failed queue items show "❌ Retry" button in UI.
### Files Touched
- `static/index.html` — add `<script>` for sw registration, queue manager init
- `static/sw.js` — new file, ~40 lines
- `static/index.html` — add offline banner and queue status UI (~50 lines)
- `static/index.html` — modify `uploadSelected()` and `startBulkProcess()` to check `navigator.onLine` and route to queue if offline
### Test Cases
| # | Scenario | Expected |
|---|----------|----------|
| 1 | Load app while offline | Page renders from cache, banner shows "Offline" |
| 2 | Select photos while offline | Gallery thumbnails appear, summary counts work |
| 3 | Click "Bulk Process" while offline | Photos go to IndexedDB queue, banner shows count |
| 4 | Go online while queue has items | Queue drains, results appear once all uploaded |
| 5 | Refresh mid-queue | Queue persists, resumes draining on reload |
| 6 | Server returns 500 during drain | Retry 3 times, then mark failed with "❌" |
| 7 | Select 100 photos while offline | All queued, memory usage stays reasonable (Blob refs) |
| 8 | Delete queue item | Removed from IndexedDB, count decrements |
---
## 4. ✅ Export — CSV / KML / Copy Coordinates
### Use Case
After a walk-around audit, the collected data (photo GPS, OCR'd machine IDs, matched
asset names) needs to get into spreadsheets for facility management, Google Earth for
visual review, or a clipboard for pasting into an internal tool. Currently the data is
trapped in the photos.db SQLite on the server.
### User Experience
1. After any bulk process, a new **Export** button appears in the bulk section header
2. Tap Export → modal/action sheet shows three options:
- **📊 CSV** — downloads `exif-export-YYYY-MM-DD.csv`
- **🌍 KML** — downloads `exif-export-YYYY-MM-DD.kml` (open in Google Earth)
- **📋 Copy all GPS** — copies `lat,lng` pairs to clipboard
3. Also available from the **Previously Processed** section header
4. CSV columns: `filename, lat, lng, machine_id, asset_name, building, floor, room, make, model, ocr_engine, sticker_color, timestamp`
5. KML: each photo is a Placemark with name=asset name (or filename), coords from GPS, description with all metadata
### Spec
```
GET /api/export?format=csv&session_id=optional
GET /api/export?format=kml&session_id=optional
GET /api/export?format=clipboard&session_id=optional # returns plain text lat,lng pairs
Defaults to all photos if no session_id. Supports ?limit=500 for large exports.
```
### Tech
- **CSV**: FastAPI `StreamingResponse` with `csv.writer` — streams line by line, no memory spike
- **KML**: Build XML manually (simple template, no library needed). Validates against KML 2.2 schema.
- **Clipboard**: Frontend-only — parse server response, use `navigator.clipboard.writeText()`
- **Character encoding**: All text fields UTF-8, CSV with BOM for Excel compatibility
### Files Touched
- `server.py` — add `/api/export` endpoint (~60 lines)
- `static/index.html` — add export button + modal, clipboard handler (~40 lines)
### Test Cases
| # | Scenario | Expected |
|---|----------|----------|
| 1 | Export CSV from bulk results | Downloads valid CSV, opens in Excel without encoding issues |
| 2 | Export KML from bulk results | Downloads KML, opens in Google Earth, pins show at correct coords |
| 3 | Copy GPS from prev photos | Clipboard contains `lat,lng` pairs, one per line |
| 4 | Export with 0 results | Returns empty CSV with headers only |
| 5 | Export 200 photos | Streams correctly, no timeout |
| 6 | CSV column count | All 11 columns present, no missing data |
---
## 5. ✅ Inline Machine ID Edit
### Use Case
OCR is not perfect — stickers get dirty, bent, faded, or partially covered. A human
can still read "12345" from the sticker even when OCR returns gibberish. Currently
the only way to assign the correct machine ID is to re-upload the photo with a
different engine/model, or use the manual entry field in the detail view. Neither
works in bulk mode. The user needs to tap a failed OCR result and type the correct
ID inline, then push GPS immediately.
### User Experience
1. In bulk results, a card with **"No OCR match"** is tappable
2. Tap the label → it turns into a text `<input>` pre-filled with any partial digits
3. Type the machine ID (e.g. `12345-678901` or just `12345`)
4. Press Enter or tap away → app calls `/api/lookup?machine_id=...`
5. If found: card updates to show the asset name, and a **Push GPS** button appears
6. If not found: shows "⚠️ No asset match" — user can try again
7. Works identically in the **Previously Processed** section
### Spec
```
On card: <span class="ocr-edit" data-idx="N" data-photoid="PHOTO_ID">
No OCR match
</span>
On click → replace with:
<input class="ocr-input" data-idx="N" data-photoid="PHOTO_ID"
placeholder="Type machine ID…" value="">
On blur/Enter → POST /api/assign-machine-id with {photo_id, machine_id}
→ updates photos DB → returns {asset, needs_gps}
→ re-renders bulk card
```
### Tech
- No backend changes needed for lookup (already has `/api/lookup`)
- New endpoint `POST /api/assign-machine-id` to persist the override to `photos.db` (sets `machine_id` field)
- Frontend: event delegation on bulk cards for the tap handler
- Debounced lookup (300ms) to avoid hammering the API while typing
### Files Touched
- `server.py` — add `POST /api/assign-machine-id` (~20 lines)
- `static/index.html` — modify `renderBulkResults()` to make OCR labels editable (~40 lines)
### Test Cases
| # | Scenario | Expected |
|---|----------|----------|
| 1 | Tap "No OCR match" | Label becomes editable input |
| 2 | Type valid machine ID | Asset loads, card shows asset details, Push GPS appears |
| 3 | Type invalid machine ID | "⚠️ No asset match" shown, input stays editable |
| 4 | Type partial ID (5 digits) | Lookup by first 5 digits works |
| 5 | Clear input and blur | Input reverts to "No OCR match" |
| 6 | Assign ID on photo that already has GPS | Push GPS button works immediately |
| 7 | Assign ID when GPS missing | Card shows "No GPS — upload new photo" |
---
## 6. ✅ GPS Proximity Comparison
### Use Case
When a photo has GPS coordinates, there may be canteen assets nearby that you
haven't photographed yet — or you may have photographed the same machine twice
from different angles. Knowing what's within, say, 10 meters helps the auditor
decide: "did I already get that one?" or "there's another machine right there."
Also useful for discovering machines whose DB address is wrong (GPS says
they're 200m from their listed building).
### User Experience
1. In bulk results, each card with GPS shows a new line:
**"📍 Nearby: 2 assets within 10m"**
2. Tap the line → expands a list of nearby machines:
- `🆔 12345 · Ice Machine 300 · 8m away · 📶 has GPS` or
- `🆔 67890 · Soda Dispenser · 12m away ⚠️ no GPS yet`
3. The proximity badge color indicates:
- 🟢 Green: 0-5m (probable same machine)
- 🟡 Amber: 5-20m (nearby machines to check)
- 🔴 Red: >20m but <50m (far neighbor)
4. Also shown on the bulk map: proximity rings (10m circles) around each new photo pin
### Spec
```
GET /api/nearby?lat=<float>&lng=<float>&radius=<meters>&exclude_photo_id=<optional>
Returns:
{ nearby: [
{ asset_id, machine_id, name, distance_m, latitude, longitude,
has_gps: bool, building, floor, room }
],
count: N,
radius_m: 10 }
```
### Tech
- **Spatial query**: SQLite with `(lat - ?)^2 + (lng - ?)^2 < (rad_deg)^2` approximation.
At small radii (<1km), the Euclidean approximation over lat/lng is accurate enough.
Formula: `1° lat ≈ 111,320m`, `1° lng ≈ 111,320 * cos(lat)` at given latitude.
- **Radius default**: 10m, configurable via query param
- **Exclusion**: pass `exclude_photo_id` to avoid self-matches (the photo's own asset)
- **Frontend**: Leaflet circle markers + expandable inline list in bulk cards
### Files Touched
- `server.py` — add `GET /api/nearby` endpoint (~30 lines with spatial math)
- `static/index.html` — modify `renderBulkResults()` to fetch and display nearby data (~60 lines)
- `static/index.html` — add Leaflet circle overlays in `renderBulkResults()` (~10 lines)
### Test Cases
| # | Scenario | Expected |
|---|----------|----------|
| 1 | Photo GPS at (28.5, -81.5), asset at (28.5001, -81.5001) | Nearby returns asset, distance ~12m |
| 2 | No assets within 10m | `count: 0`, card shows "No nearby assets" |
| 3 | Asset at exactly 10.5m | Not returned (radius is <10m, not <=) |
| 4 | Photo has no GPS | Nearby line hidden |
| 5 | 5 assets within 10m | All returned, sorted by distance ascending |
| 6 | Nearby includes asset already photographed | Excluded by `exclude_photo_id` |
| 7 | Spam-tap the nearby label | Only one request fires (debounce) |
---
## 7. ✅ Walk-Path Timeline
### Use Case
After a session, the auditor needs to see their actual route through the building
to spot coverage gaps. A polyline connecting photo locations in chronological order
reveals skipped aisles, missed corners, or areas where photos were taken out of
walking order (suggesting you backtracked because you missed something).
### User Experience
1. After bulk processing, a toggle switch above the map: **🗺️ Show Walk Path**
2. On: the map draws a connected polyline between photo pins in upload order,
with numbered markers (① → ② → ③ …) showing the sequence
3. Each segment is color-coded by time gap:
- 🟢 Green: <30s between photos (natural pace)
- 🟡 Amber: 30s2m (stopped to look around)
- 🔴 Red: >2m (took a break or got distracted)
4. Hover/tap a segment → tooltip: "Walked 15m from Machine A to Machine B in 45s"
5. Off the map, a timeline list shows each photo in order with distance from previous
### Spec
```
No new backend endpoint. The bulk results already include filename, exif.gps, and
we can derive "upload order" from the response array order. Frontend-only feature.
Timeline data shape (derived client-side):
[{ step: 1, lat, lng, name, filename, time_gap_s, distance_m }]
```
### Tech
- **Polyline**: Leaflet `L.polyline(latlngs, {color, weight, opacity})` with gradient
segments using `L.polylineDecorator` or manual segment drawing
- **Segment coloring**: Draw N-1 polylines between consecutive points, each with its
own color based on `time_gap`
- **Distance calc**: Haversine formula in JS between consecutive GPS coords
- **Time gap**: From client-side `Date.now()` at photo selection time (stored in
`allPhotos[]` array)
### Files Touched
- `static/index.html` — add walk-path toggle + polyline drawing in `renderBulkResults()` (~60 lines)
- `static/index.html` — store selection timestamps in `allPhotos[]` (~5 lines)
- `static/index.html` — add Leaflet polyline-decorator plugin (CDN) (~1 line)
### Test Cases
| # | Scenario | Expected |
|---|----------|----------|
| 1 | 5 photos in a straight line | Polyline connects ①→②→③→④→⑤ in order |
| 2 | Photos taken 10s apart | All segments green |
| 3 | One 5-minute gap between photo 3 and 4 | Segment between them is red |
| 4 | Toggle off → on | Polyline clears and redraws |
| 5 | Only 1 photo has GPS | No polyline drawn, toggle hidden |
| 6 | Consecutive photos 500m apart | Segment shows correct distance in tooltip |
| 7 | Timeline list scrollable with 50 entries | Virtual scroll works smoothly |
---
## 8. 💡 Batch Machine ID Assign
### Use Case
Large equipment (walk-in coolers, industrial washers, HVAC units) may need 3-5 photos
from different angles to properly document. Currently each photo gets its own card,
processed independently. The auditor needs to say "these 4 photos are all of the same
machine" and push GPS once for all of them.
Also useful when a machine ID sticker appears in multiple shots (e.g. zoomed out +
close up) but only one photo has a readable barcode. The auditor can set the machine
ID once and apply it to all linked photos.
### User Experience
1. In bulk results, each card gets a checkbox on the left (hidden by default)
2. Tap **📎 Batch Select** button in the header → checkboxes appear on all cards
3. Check 2+ cards → a floating action bar appears:
**"📎 3 selected → Assign all to Machine ID: [________] [🔍 Lookup] [📤 Push GPS]"**
4. Type a machine ID, tap Lookup → confirms the asset name
5. Tap **Push GPS** → pushes GPS from every selected photo to that asset
(only photos that have GPS; ones without are listed as "no GPS available")
6. Selected cards get a batch icon and link to the same asset
### Spec
```
POST /api/bulk-assign
Body: { photo_ids: [int], machine_id: str }
→ Updates photos.machine_id for each photo in DB
→ If any photos have GPS and asset has NULL lat/lng, optionally push GPS
Returns:
{ updated: N, gps_pushed: M, no_gps: [...filenames] }
```
### Tech
- **New endpoint**: `POST /api/bulk-assign` — similar to `assign-machine-id` but array
- **Frontend**: checkbox state array, floating action bar with `position: sticky; bottom: 0`
- **No extra dependencies**: pure CSS for sticky bar, vanilla JS for selection state
### Files Touched
- `server.py` — add `POST /api/bulk-assign` (~30 lines)
- `static/index.html` — add checkbox mode, selection state, floating bar (~80 lines)
- `static/index.html` — modify `renderBulkResults()` to render checkboxes when in batch mode
### Test Cases
| # | Scenario | Expected |
|---|----------|----------|
| 1 | Select 3 photos, assign machine ID | All 3 photos show the matched asset |
| 2 | Select 1 photo only | Floating bar shows "Select 2+ photos" |
| 3 | Assign with all photos having GPS | GPS pushed to asset, button shows "✓ Pushed!" |
| 4 | Assign with 2 photos having GPS, 1 without | GPS pushed, warning lists the one without |
| 5 | Deselect all | Floating bar disappears |
| 6 | Refresh page after batch assign | Prev photos section shows the assignment |
---
## 9. ✅ Session Management
### Use Case
An auditor does weekly walk-throughs of different buildings. Each session (a batch
of photos taken at a specific time/place) should be a first-class object: named,
tagged with a building/floor, and reviewable later. Currently all photos go into
a flat `photos` table with no grouping.
Without sessions, answering "when was Building B, Floor 2 last audited?" requires
scanning all photo timestamps and cross-referencing GPS against building boundaries.
### User Experience
1. Before starting a bulk process, the app shows: **"Session: [📂 New Session ▼]"**
2. Tap it → name the session (e.g. "Building B Floor 2 — May 25")
3. Or tap **📋** → pick from recent buildings/floors in the DB:
`Building A Floor 1` · `Building A Floor 2` · `Building B Floor 1`
4. After processing, the session shows in a new **Sessions** section above "Previously Processed"
5. Each session card:
- **"Building B Floor 2 — May 25"**
- `📍 23 photos · 18 matched · 2 need GPS · Last: 10m ago`
- Tap → expands to show all photos from that session
- Share button → export CSV/KML for just this session
- Calendar icon → shows on calendar view
6. **Calendar view**: toggle to see sessions on a calendar grid. Green = full coverage,
amber = partial, red = no data. Tap a date → that session's results.
### Spec
```
New DB table: sessions
id INTEGER PRIMARY KEY
name TEXT
building TEXT
floor TEXT
photo_count INTEGER
matched_count INTEGER
needs_gps_count INTEGER
created_at TEXT DEFAULT (datetime('now'))
photos table: add session_id INTEGER REFERENCES sessions(id)
GET /api/sessions
→ [{ id, name, building, floor, photo_count, matched_count, needs_gps_count, created_at }]
GET /api/sessions/{id}
→ session detail + all photos in it
POST /api/sessions
Body: { name?, building?, floor? }
→ creates session, returns { id }
POST /api/sessions/{id}/close
→ finalizes session (no more photos can be added)
```
### Migration
```sql
ALTER TABLE photos ADD COLUMN session_id INTEGER REFERENCES sessions(id);
```
No data loss — existing photos get `session_id = NULL` and appear in "Unassigned"
section.
### Files Touched
- `server.py` — add sessions table, CRUD endpoints, migration (~80 lines)
- `server.py` — modify `/api/bulk-process` to accept `session_id` param (~10 lines)
- `static/index.html` — add session picker UI, sessions section, calendar view (~120 lines)
### Test Cases
| # | Scenario | Expected |
|---|----------|----------|
| 1 | Create new session, process 5 photos | Session shows in list with count 5 |
| 2 | Process photos without a session | Go to "Unassigned" section (backward compatible) |
| 3 | Open session detail | Shows all photos with map |
| 4 | Calendar view with 3 sessions on different dates | Green cells on correct dates |
| 5 | Export session as CSV | Only that session's photos in the CSV |
| 6 | Delete session | All photos moved to "Unassigned" |
---
## 10. ✅ GPS Drift Check
### Use Case
The canteen DB stores an asset's expected location (from its address/building). When
a photo's GPS is far from that expected location, something is wrong:
- The photo is of a **different machine** than the sticker suggests (wrong sticker on site?)
- The **asset was moved** (machines get relocated without updating the DB)
- **GPS drift** (tall buildings, narrow corridors cause coordinate noise up to 30m)
- The **asset's address is wrong** in the DB
A flag alerts the auditor to investigate rather than blindly pushing GPS data.
### User Experience
1. In bulk results, a matched asset shows a new badge next to its name:
- 🟢 **GPS OK**: photo within 10m of asset's DB coordinates
- 🟡 **⚠️ Drift 30m**: photo is 30m from asset's expected location
- 🔴 **⚠️⚠️ Drift 200m**: photo is 200m away — probable mismatch
2. Tap the drift badge → explanation tooltip:
"Photo GPS is 30m from this asset's DB location (28.5383, -81.3794).
Possible: GPS drift, asset was moved, or wrong sticker."
3. If asset has no DB coordinates → no drift check (show "📍 No DB coords to compare")
4. On the map: a line connects the photo pin to the asset's DB pin with the distance
labeled. Color = drift severity.
### Spec
```
No new endpoint. Drift is calculated client-side from existing data:
- Photo GPS: from exif.gps (already in bulk result)
- Asset DB coords: from asset.latitude/asset.longitude (already in bulk result)
- Distance: Haversine calculation in JS
Severity thresholds:
< 10m → green "GPS OK"
10-50m → amber "⚠️ Drift Nm"
> 50m → red "⚠️⚠️ Drift Nm"
```
### Tech
- **Haversine**: copy the existing Python Haversine or use a small JS library
- **Rendering**: modify `renderBulkResults()` card template to include drift badge
- **Map line**: Leaflet `L.polyline` between photo pin and asset DB pin (dashed, color = severity)
- **No backend changes**: all data already available in the bulk response
### Files Touched
- `static/index.html` — add haversine function (~10 lines)
- `static/index.html` — modify `renderBulkResults()` to render drift badges (~30 lines)
- `static/index.html` — add dashed connecting lines on map (~15 lines)
### Test Cases
| # | Scenario | Expected |
|---|----------|----------|
| 1 | Photo GPS 5m from asset DB coords | 🟢 GPS OK badge |
| 2 | Photo GPS 25m from asset DB coords | 🟡 ⚠️ Drift 25m badge, amber line on map |
| 3 | Photo GPS 100m from asset DB coords | 🔴 ⚠️⚠️ Drift 100m badge, red line on map |
| 4 | Asset has NULL lat/lng in DB | "📍 No DB coords to compare" shown |
| 5 | No asset match (OCR failed) | No drift check shown |
| 6 | Drift line on map matches compass direction | Line goes from photo pin toward asset DB pin |
---
## 11. ✅ Coverage Heatmap
### Use Case
After auditing a large floor, the auditor needs to know *did I miss anything?*
A heatmap overlay on the map shows photo density as a color gradient:
- **Red/hot** areas = many photos taken (well covered)
- **Blue/cold** areas = few or no photos (potential gaps)
- **White** = no data
Combined with the walk-path timeline, this is the fastest way to spot skipped aisles,
unvisited rooms, or areas where you didn't take enough establishing shots.
### User Experience
1. In the bulk results map, a toggle **🔥 Heatmap** (alongside "🗺️ Show Walk Path")
2. On: a smooth color gradient overlay appears on the map
3. The heatmap is computed from all photo GPS points + all asset DB locations
4. Adjustable **intensity** slider: low → shows broad coverage patterns;
high → shows tight clusters (multiple photos of the same machine)
5. **Radius** slider: how far each photo's "influence" extends (default 15m)
6. Tap a hot spot → shows how many photos contributed to that area
### Spec
```
Frontend-only. Uses leaflet-heat (https://github.com/Leaflet/Leaflet.heat).
Input data:
- Photo GPS coordinates (from bulk results or prev photos)
- Asset DB coordinates (from matched assets)
Heatmap options:
- radius: 15 (default), adjustable 5-50
- blur: 15 (default)
- maxZoom: 18
- gradient: { 0.0: 'blue', 0.3: 'cyan', 0.5: 'lime', 0.8: 'yellow', 1.0: 'red' }
```
### Tech
- **Leaflet.heat**: CDN script tag — lightweight (~10KB), no build step
- **Data preparation**: Extract `[lat, lng, intensity]` tuples where intensity is
`1.0` for each photo (or higher if multiple photos at the same point)
- **Asset overlay**: Optionally include asset DB locations at 0.5 intensity to show
where machines exist but have no photos yet (double-check areas)
- **Sliders**: HTML `<input type="range">` for radius and intensity
### Files Touched
- `static/index.html` — add Leaflet.heat CDN script (~1 line)
- `static/index.html` — add heatmap toggle + sliders (~20 lines)
- `static/index.html` — add heatmap layer control in `renderBulkResults()` and `loadPreviousPhotos()` (~30 lines)
### Test Cases
| # | Scenario | Expected |
|---|----------|----------|
| 1 | 10 photos clustered within 20m | Red hotspot at that area |
| 2 | 2 photos 100m apart | Two separate blue spots |
| 3 | Toggle heatmap on → off → on | Clears and redraws correctly |
| 4 | Adjust radius from 15 to 50 | Hotspots get larger/smoother |
| 5 | 50 photos spread across a 200m floor | Gradient shows red in high-density areas, blue in gaps |
| 6 | Leaflet.heat not loaded (network error) | Toggle hidden, no errors in console |
---
## Appendix: Implementation Order
### Phase 1 (Current) — Core UX fixes
- Fix API key loading ✅
- Add reset endpoint ✅
- Improve maps ✅
### Phase 2 — High-impact additions
1. [x] Inline Machine ID Edit (#5) — smallest scope, biggest daily-ux gain
2. [x] GPS Proximity (#6) — catches missed machines
3. [x] GPS Drift Check (#10) — data quality
4. [x] Walk-Path Timeline (#7) — visual route validation
### Phase 3 — Offline + Export + Sessions
5. [x] Export (#4) — gets data out of the app
6. [x] Offline Queue (#1) — works anywhere
7. [x] Session Management (#9) — historical tracking
### Phase 4 — Power-user features
8. [x] Batch Machine ID Assign (#8) — speed for multi-photo equipment
9. [x] Coverage Heatmap (#11) — completeness guarantee
---
*Last updated: 2026-05-25*
*Proposed by: Hermes Agent / Shawn*