- Magic Kingdom: accurate 60-point polygon from OSM way 297428432
- All parks: adjusted boundaries for better alignment
- Increased stroke weight (3px) and opacity for visibility
- Higher fill opacity (0.15)
- Polygons for Magic Kingdom, Epcot, Hollywood Studios, Animal Kingdom, Disney Springs
- Resort area outline (dashed) for Pop Century / Art of Animation
- Color-coded to match existing park badge colors
- Labels with park icons at polygon centers
- Toggle chip (🏰 Parks) in map controls to show/hide
- Outlines visible by default on map load
Replaced the Snack category emoji with an actual chips illustration
served from static/icons/chips.png. The icon renders in the asset
list category badges as a 28x28 object-fit:contain image within
the 42x42 circular badge.
- Added is_disney INTEGER DEFAULT 0 column to assets table
- Backfilled 996 Disney assets based on D- customer prefix
- Server: is_disney in AssetCreate/AssetUpdate models, INSERT/UPDATE
- Server: disney_filter query param now uses is_disney column (no JOIN needed)
- import_cantaloupe.py: sets is_disney=1 when customer starts with D-
- import_machines.py: sets is_disney=1 when customer starts with D-
- disney_classify.py: sets is_disney=1 when classifying
- Frontend: new Location Type dropdown with Disney/Non-Disney toggle
(replaces the __disney__/__non_disney__ park dropdown pseudo-options)
Backend:
- disney_filter query param on /api/assets (values: 'disney' | 'non_disney')
- Detects Disney by checking disney_park IS NOT NULL OR customer name LIKE 'D-%'
- Catches ESPN, Wide World of Sports, all Disney resorts, etc.
- Adds LEFT JOIN to customers table when disney_filter is active
Frontend:
- '🏰 Disney only' and '🏢 Non-Disney' options in Disney Park dropdown
- Active filter tag shows which is selected
- Works together with other dropdown filters
Backend:
- GET /api/customers — lists all customers for filter dropdown
- GET /api/locations?customer_id=X — cascading locations per customer
- GET /api/users — lists users for assigned_to filter
Frontend:
- Collapsible 'Filters' panel with Customer→Location cascade, Status, Assigned To
- Active filter tags showing what's currently filtering, removable individually
- 'Clear all filters' button to reset all at once
- Result count display ('X assets found')
- Better search placeholder showing searchable fields
- Filter count badge on the toggle button
- Added disney_park TEXT DEFAULT NULL to assets table in _create_v2_tables
- Created service_entrances table with id, location_id, name, latitude,
longitude, notes, created_at, updated_at; location_id FK to locations
with ON DELETE CASCADE; name/lat/lon are NOT NULL
- Added v4 migration in init_db: ALTER TABLE for disney_park on existing
DBs, schema-aware recreation of service_entrances if name column lacks
NOT NULL constraint
- 44/45 tests pass (1 pre-existing 404 failure on stats endpoint)
- Retake full set of p9a_admin_*.png screenshots (login, dashboard, settings, users, customers, activity, export, import, sidebar)
- Each screenshot inspected for layout errors
- Proper 1280px desktop viewport for all pages, clean empty states
- Save to docs/images directory
- Replace p9a_admin_sync.png with p9a_admin_import.png (new Import page)
- Add p9a_admin_sidebar.png (mobile sidebar with hamburger open)
- Update ADMIN_GUIDE.md with mobile navigation section
- Add screenshot reference in Import section
- Replace Cantaloupe Sync section with Excel Import section
- Update troubleshooting tips for import (not sync)
- Mention hamburger menu in mobile description
The test suite login as 'admin'/'changeme' but only 'shawn' was seeded.
Adding the admin user with SHA256('changeme') hash lets all 15 Playwright
tests reach the actual app pages instead of failing at login.
- Removed hardcoded display:none from GPS center chip on map tab
- Chip now shows when GPS position is obtained in startVisitTracking
- Added leaflet-draw CSS and JS to HTML head
- Added draw control (polygon, rectangle, circle) to map init
- Emits geofenceDrawn custom event for external hooks
- Manual photo area now shows two clear choices:
📁 Choose from Gallery — plain accept=image/* (EXIF preserved)
📸 Take Photo — calls openManualCamera() for quick capture
- Each has a label explaining EXIF behavior
- openManualCamera() now appends input to DOM before click()
(required by mobile Chrome for programmatic file input clicks)
- Dynamically created camera input is cleaned up after use
- camera-area overflow:visible to not clip the taller placeholder
- manPhotoInput now uses accept=image/* without capture attribute
-> opens gallery/file picker which gives raw file bytes (EXIF preserved)
- Camera shortcut preserved via openManualCamera() creates a temp
capture=environment input for quick field capture (no EXIF but available)
- Label updated: 'Tap to take photo' -> 'Tap to choose photo'
- Manual photo capture row references openManualCamera() instead
GPS:
- initGPS() now shows tappable GPS badge instead of failing silently
- Badge pulses amber with '📍 Tap for GPS' — click triggers permission prompt
- fillGpsParking() auto-triggers badge click if GPS not yet available
- requestLocation() extracted as reusable Promise
EXIF bypass:
- submitManualAsset() now uses server-extracted GPS from raw upload bytes
- Server returns exif_gps in /api/upload/photo response (was already working)
- Client was ignoring it — now fills parking + map link fields automatically
- Bypasses Android content provider EXIF stripping since server reads raw bytes
Admin separation:
- admin.canteen.ourpad.casa now served by systemd service (canteen-admin.service)
- Main server also systemd (canteen-main.service) — both auto-restart on crash/boot
- extractExifData() now async — reads real EXIF tags (Make, Model,
DateTimeOriginal, ISO, FNumber, ExposureTime, FocalLength, GPS)
via exifr.parse() and displays them in the UI
- handlePickedPhoto() + reader.onload made async to support this
- GPS extraction already auto-triggers on photo pick (tryExtractGpsFromPicked)
- Server-side exif_data round-trip already functional (verified)
- DNG/RAW-02: .dng allowed, 20MB limit
- Added .dng to PHOTO_ALLOWED_EXTS
- Bumped PHOTO_MAX_SIZE from 10MB to 20MB
- DNG files (~13MB from Pixel 9a) now accepted by all upload paths:
/api/upload/photo, /api/ocr, connect_labels
- Server validates extension+size only; DNG EXIF preserved naturally
by write_bytes(); _re_embed_exif() correctly skips non-JPEG;
_extract_gps_from_bytes() opens DNG as TIFF via PIL (already works)
Verified: .dng upload via /api/upload/photo and /api/ocr both succeed,
file saved byte-identical.
- Removed duplicate clearPickedPhoto() at old line ~1772
(kept newer version at line ~3857 with pickedPhotoGps clearing)
- Removed duplicate extractGpsFromPicked() at old line ~1831
(kept newer async version at line ~3766 that uses exifr)
- Both ocrPickedPhoto() and createAssetFromPicked() were already
singular — they were refactored in a prior edit
Verified: no duplicate function names, JS syntax OK, no regressions
- Set manualPhotoFile = pickedPhotoFile directly (preserves EXIF)
- Set manualPhotoBlob = pickedPhotoFile (preserves EXIF)
- Keep FileReader for preview display only
- Trigger EXIF GPS extraction via extractGpsFromPhoto()
- Fill GPS coordinates into manual form (parking + map link)
Previously the function used readAsDataURL -> atob -> Blob
which stripped all EXIF metadata. Now the original File
flows through to submitManualAsset where exifr.parse()
re-embeds EXIF on the server.
- Added global let map/heatLayer/heatVisible declarations
- Leaflet map initializes on first visit to Map tab (OSM tiles)
- Implemented loadAssetPins() - fetches assets with coords from API,
creates Leaflet markers with popups, auto-fits bounds
- Guard toggleHeatmap() with proper declared globals
- Closes#31 H1, #32 H2
Also confirmed M1 (empty asset list) is not a bug: API returns [] because
DB has no assets yet. Empty state renders correctly.
- Client: exifr.parse() reads full EXIF before upload, sent as exif_data
- Server: piexif re-embeds EXIF into saved JPEG after upload
- Auto-extract GPS from picked gallery photos on selection
- Removed Google Photos button (needs OAuth, not feasible)
- Replaced getUserMedia() + canvas.toBlob() with <input type=file capture=environment>
→ native camera app preserves full EXIF data including GPS
- Defined missing extractGpsFromPhoto() using exifr.gps() library
- Manual mode now uploads the original file (with EXIF) instead of a canvas-encoded blob
- Server-side exif_gps fallback if client-side extraction fails
- Renamed manualPhotoBlob → manualPhotoFile for clarity
- backup-db.sh: copies assets.db to /home/oplabs/backups/canteen-db/
- start.sh: runs backup automatically before every launch
- Daily cron at 3am for unattended backups
- Keeps last 30 backups, prunes oldest