P0 — stop the bleeding:
- Honor place.image_url in MapView and TripPlannerPage to skip redundant fetchPhoto calls
- Trim Place Details field mask (drop reviews/editorialSummary from default; new getPlaceDetailsExpanded for inspector)
- Admin toggle places_photos_enabled (default ON) to kill Google photo fetches under quota pressure; Wikimedia unaffected
- Return { photoUrl: null } instead of 204 so client handles disabled state cleanly
P1 — structural fix:
- New placePhotoCache service: persistent disk cache at uploads/photos/google/<sha1>.jpg, atomic writes, stampede dedup via in-flight Map
- Migrations 105-107: google_place_photo_meta table, place_details_cache table, backfill signed Google URLs to stable proxy URLs
- getPlacePhoto rewrites to fetch image bytes directly, store on disk, return /api/maps/place-photo/:id/bytes proxy URL
- Stable proxy URLs written to places.image_url — survive container restarts, no expiry
- New GET /api/maps/place-photo/:placeId/bytes route serving cached files with long-lived Cache-Control
- Place Details DB row cache with 7-day TTL; ?refresh=1 escape hatch
- photoService fast-path: proxy URLs bypass the mapsApi round-trip and go straight to urlToBase64
Bug fixes:
- MapView now requests base64 thumbs for places with proxy image_url (markers were showing color fallback)
- createPlaceIcon accepts /api/maps/place-photo/ URLs as interim fallback while thumb generates
- setSelectedAssignmentId ReferenceError in mobile day-detail handler (use selectAssignment)
- Remove redundant decodeURIComponent on already-decoded Express route param
- Use SHA1 hash for disk filenames to prevent coords:lat:lng pseudo-ID collisions
- Add checkSsrf guard to Wikimedia byte fetch
- Tighten migration 107 LIKE filter to avoid rewriting manually-pasted Google image URLs
- Validate enabled is boolean on PUT /admin/places-photos
- Drop aggressive iconCache.clear() on every thumb arrival
Observability:
- googleFetch() wrapper counts and debug-logs every outbound Google API call with running total
Store & re-render optimization:
- TripPlannerPage uses selective Zustand selectors instead of full store
- placesSlice only updates affected days on place update/delete
- Route calculation only reacts to selected day's assignments
- DayPlanSidebar uses stable action refs instead of full store
Map marker performance:
- Shared photoService for PlaceAvatar and MapView (single cache, no duplicate requests)
- Client-side base64 thumbnail generation via canvas (CORS-safe for Wikimedia)
- Map markers use base64 data URL <img> tags for smooth zoom (no external image decode)
- Sidebar uses same base64 thumbnails with IntersectionObserver for visible-first loading
- Icon cache prevents duplicate L.divIcon creation
- MarkerClusterGroup with animate:false and optimized chunk settings
- Photo fetch deduplication and batched state updates
Server optimizations:
- Wikimedia image size reduced to 400px (from 600px)
- Photo cache: 5min TTL for errors (was 12h), prevents stale 404 caching
- Removed unused image-proxy endpoint
UX improvements:
- Splash screen with plane animation during initial photo preload
- Markdown rendering in DayPlanSidebar place descriptions
- Missing i18n keys added, all 12 languages synced to 1376 keys
- Category filter is now a multi-select dropdown with checkboxes
- PlaceAvatar: replace 200ms polling intervals with event-based
notification + React.memo for major performance improvement
- Map photo fetches: concurrency limited to 3 + lazy loading on images
- PlacesSidebar: content-visibility + useMemo for smooth scrolling
- Accommodation labels: check-out now appears before check-in on same day
- Timed places auto-sort chronologically when time is added