Architecture Overview

Two independent frontends sharing one Firebase Realtime Database.

EJ Website (ej/) Mobile App (app/)
Style Desktop editorial, sidebar list Mobile social, feed waterfall
Navigation Panel stack (nested modals) Tab switching (flat)
Discovery Search + sidebar filter Feed + nearby GPS
Map default 3D globe (MapLibre/globe.gl) Flat MapLibre
JS style ES6 (const, arrow, hooks) ES5 (var, function)

Firebase Schema

/foods/{foodId}
  name, englishName, emoji, type ('food'|'want')
  lat, lng, country, region, province (+ English variants)
  description, englishDescription
  tags[], restaurants[{name, address, url, note}]
  recipe, recipeLink, origin, originLink
  createdBy, createdAt, isMinefield
  source{type, collection}        ← curated foods only

/images/{foodId}
  [url, url, ...]                 ← stored SEPARATELY from /foods to keep reads fast

/users/{username}
  avatar                          ← base64 string
  bio, createdAt
  followers/{username: true}
  following/{username: true}
  collects/{foodId: true}
  collections/{id} { name, createdAt, items/{foodId: true} }
  favFolders/{id}  { name, createdAt, items/{foodId: true} }
  hiddenFoods/{foodId: true}
  blockedUsers/{username: true}
  trips/{tripId}
    name, date, description, visibility ('public'|'followers'|'private')
    coverImage                    ← base64, compressed 300×300 @50%
    routeMode ('foot'|'bicycle'|'car')
    stops[{foodId?, note} | {placeName, lat, lng, note}]  ← food stop OR custom place stop
    routeGeometry                 ← GeoJSON LineString from OSRM
    routeDistance (m), routeDuration (s)
    collaborators/{username: true}
    shareRequests/{username: timestamp}  ← pending join requests; cleared on accept/decline
    createdAt, updatedAt, sharedFrom

/notifications/{username}/{notifId}
  action  'like'|'collect'|'follow'|'comment'|'trip-invite'|'trip-request'
  fromUser, timestamp             ← timestamp required for unread query
  foodId, foodName                ← for like/collect/comment
  tripId, tripOwner, tripName     ← for trip-invite/trip-request

/stats
  userCount, pageViews

Shared Conventions

Images: Lazy-loaded from /images/{id}

Foods in /foods have no images field. Images live at /images/{foodId}.

// When saving a food:
const foodData = { ...data };
delete foodData.images;
writes[`foods/${id}`]  = foodData;
writes[`images/${id}`] = images || null;

// When displaying:
// detail.js — lazy loads on panel open
// profile.js — thumbImages{} state, loads all userFoods on profile open

Risk: If you read food.images directly (e.g. in feed cards, profile grids) without going through the lazy-load path, it will always be empty. Use thumbImages[f.id] fallback in profile.js and the lazyImages state in detail.js.

Notifications: Require timestamp

// App notification count listener:
ref.orderByChild('timestamp').startAt(lastRead + 1)
// Messages panel:
ref.orderByChild('timestamp').limitToLast(50)

Always include timestamp: Date.now() when pushing to /notifications/. Without it, Firebase orderByChild('timestamp') sorts nulls before all numbers, so the notification will be invisible once there are 50+ timestamped ones.

Routing (OSRM)

  • routeStops[] — food IDs in stop order
  • routeData{ geometry: GeoJSON LineString, distance: meters, duration: seconds }
  • routeGeometry — the LineString stored inside a trip; rendered as route-line MapLibre source
  • Requests debounced with OSRM_MIN_GAP = 1000ms
  • Map source key: route-line (GeoJSON, set via src.setData(...))

Trip Sharing Flow

Owner invites user    → push notifications/{target}  {action:'trip-invite', tripId, tripOwner, tripName}
User requests share   → push notifications/{owner}   {action:'trip-request', ...}
                      → set users/{owner}/trips/{id}/shareRequests/{user} = timestamp
Accept (messages):    → set collaborators/{user}=true + remove shareRequests/{user}
Accept (inline):      → same, triggered from TripDetailView pending requests list
Decline:              → remove shareRequests/{user}
Remove collaborator:  → remove collaborators/{user}
Leave trip:           → remove collaborators/{currentUser} on tripOwner's trip
Copy trip:            → new trip at users/{newOwner}/trips/{newId} with sharedFrom field, no collaborators

Collaborators can edit the trip (canEdit = isOwn || isCollaborator).

shareRequests is read by TripDetailView to show “已申请/Requested” state and the owner’s pending list.


IndexedDB Cache

Both apps cache the full /foods list in IndexedDB.

  • TTL: 30 min (EJ), configurable (App)
  • On load: show cached data immediately → fetch Firebase → overwrite cache
  • Gotcha: Firebase data has no images field. Saving from Firebase overwrites the cache and strips any images previously stored inline. This is expected — images are always fetched from /images/{id}.

Debugging Checklist

Symptom Likely cause Where to look
Food has no photos in detail panel /images/{foodId} empty in Firebase Firebase console → images node
Profile thumbnails all blank thumbImages state not populated yet profile.js useEffect([userFoods.length])
Notification badge stuck at 0 Notification missing timestamp Check push call in app.js
Notifications not appearing in messages timestamp is null → sorts before existing Same as above
Nearby sort not working noShuffle not passed / if(noShuffle) return after quality sort feed.js
Route not showing on map route-line source not set / fitBounds not called globe.js route-line source, route.js useEffect
Trip detail route stays after close useEffect cleanup missing setData(empty) route.js TripDetailView cleanup return
App white screen React error in a component Browser devtools console