Skip to content

Lantern Lifecycle Enforcement Plan โ€‹

Reliable TTL expiry, opt-in geo-exit detection, and active lantern UX improvements

Created: 2026-03-16 Status: Planning Related Work:

  • Lanterns API scaffold (services/api/lanterns/)
  • Bonfire (group lanterns) โ€” future
  • Scheduled lanterns โ€” future
  • Frens integration โ€” future

Problem Statement โ€‹

Lanterns have a 2-hour TTL defined consistently across client, API, and Cloud Functions, but enforcement is lazy โ€” expired lanterns only get cleaned up when someone reads them. There is no proactive server-side cleanup, no countdown UI, and the left_venue extinguish reason is dead code with zero geo-exit detection.

Current Gaps โ€‹

GapImpact
No proactive TTL enforcement (no scheduled cleanup, no Firestore TTL policy)Expired lanterns accumulate in Firestore; activeLanternCount on venues becomes stale
subscribeToActiveLanterns() doesn't filter expired recordsDashboard can show stale "active" lanterns to users
No countdown timer in UIUsers have no idea when their lantern expires
left_venue extinguish reason is dead codeSchema supports it but nothing triggers it
No geo-exit detection after lightingUser can leave venue and lantern stays lit for full 2 hours

Plan โ€‹

Phase 0: Single-Lantern Enforcement (Client + Server) โ€‹

Goal: Users cannot light a second lantern anywhere while they already have one active. Fail fast in the UI before they go through the full flow.

Current State โ€‹

LayerEnforcementGap
Cloud Function (lightLanternSecure)โœ… Checks globally โ€” queries all active lanterns for userNone
Lanterns API (lantern.service.js)โœ… Checks globally โ€” same logicNone
Client fallback (lightLanternClientSide)โœ… Checks globally via getActiveLanterns()Deprecated path
LightLanternModal UIโš ๏ธ Only checks per-venue (getActiveLanternAtVenue)User goes through entire venue picker + form before hitting server error
LanternHub UIโš ๏ธ Shows "Light Lantern" button even when myLantern exists in a different view stateConfusing UX path
Dashboardโœ… myLantern state tracks active lanternNot passed as a gate to the modal

Changes Needed โ€‹

0A. Early UI Gate in LightLanternModal โ€‹

Before showing the venue picker, check if user already has an active lantern anywhere. If yes, show a clear message with option to extinguish or view the existing one.

File: apps/web/src/components/LightLanternModal.jsx

On mount:
1. Check activeLanterns (from Dashboard state or fresh getActiveLanterns() call)
2. If active lantern exists at ANY venue:
   - Show: "You already have a lantern lit at [venueName]"
   - Actions: "View Lantern" / "Extinguish & Light New"
   - Do NOT show venue picker
3. If no active lantern: proceed to venue picker as normal
0B. Pass Active Lantern State to Modal โ€‹

Dashboard already tracks myLantern state. Pass it to LightLanternModal so it can gate immediately without an extra Firestore query:

<LightLanternModal
  userId={currentUser.uid}
  activeLantern={myLantern}        // โ† new prop
  initialVenues={venues}
  initialUserLocation={userLocation}
  onClose={...}
  onLanternLit={...}
/>
0C. Hide "Light Lantern" in LanternHub When Active โ€‹

LanternHub already shows different views for active vs. no lantern, but verify the "Light Lantern" action card is completely hidden (not just visually de-emphasized) when hasActiveLantern is true.

0D. Remove Per-Venue-Only Check โ€‹

In LightLanternModal, the current getActiveLanternAtVenue() per-venue check during venue enrichment is redundant once we gate globally. Remove or repurpose it to show a badge ("You were here last") rather than blocking.


Phase 1: Reliable TTL Enforcement (Server-Side) โ€‹

Goal: Expired lanterns are always cleaned up, even if no one reads them.

1A. Firestore TTL Policy โ€‹

Configure a Firestore TTL policy on the lanterns collection using the expiresAt field. Firestore will automatically delete documents where expiresAt โ‰ค now.

  • Where: Firebase console or firebase.json / CLI config
  • Field: expiresAt
  • Behavior: Firestore deletes the document automatically (no code needed)
  • Caveat: Firestore TTL deletions don't trigger onDelete Cloud Functions by default โ€” we need to handle activeLanternCount decrement separately

1B. Scheduled Cleanup Cloud Function (Belt-and-Suspenders) โ€‹

A scheduled Cloud Function runs every 15 minutes to catch anything the TTL policy misses and to properly decrement activeLanternCount on venues.

Schedule: every 15 minutes
Location: services/functions/firebase/modules/lanternCleanup.js

Logic:
1. Query lanterns WHERE status == 'active' AND expiresAt <= now
2. Batch update: set status โ†’ 'extinguished', extinguishReason โ†’ 'expired'
3. Decrement activeLanternCount on each affected venue
4. Log count of cleaned-up lanterns
  • File: services/functions/firebase/modules/lanternCleanup.js
  • Export: cleanupExpiredLanterns (onSchedule)
  • Register in: services/functions/firebase/index.js

1C. Filter Expired in Real-Time Listener โ€‹

Update subscribeToActiveLanterns() in apps/web/src/lib/lanternService.js to filter out expired lanterns before passing to the callback:

js
const lanterns = snapshot.docs
  .map(doc => ({ id: doc.id, ...doc.data() }))
  .filter(l => l.expiresAt?.toDate?.() > new Date())

This prevents the Dashboard from ever showing a stale lantern to the user.


Phase 2: Countdown Timer UI โ€‹

Goal: Users see how much time remains on their active lantern.

2A. Countdown Hook โ€‹

Create useLanternCountdown(lantern) hook that returns a live countdown string, updating every minute:

File: apps/web/src/hooks/useLanternCountdown.js

Returns: { timeRemaining: "1h 23m", percentRemaining: 0.71, isExpiring: false }
- isExpiring: true when < 15 min left (for visual urgency)
- Auto-extinguishes via callback when timer hits 0

Uses existing getTimeRemaining() and formatTimeRemaining() from lanternService.js.

2B. Wire into ActiveLanternView โ€‹

Update apps/web/src/components/dashboard/ActiveLanternView.jsx:

  • Show countdown timer (replacing hardcoded "1h 45m" in LanternHub)
  • Visual urgency state when < 15 min left (amber โ†’ red pulsing)
  • Auto-transition to extinguished state when timer hits 0

2C. Wire into LanternHub โ€‹

Replace the hardcoded timeRemaining: '1h 45m' in Dashboard.jsx LanternHub props with real calculated value.


Phase 3: Opt-In Geo-Exit Detection โ€‹

Goal: Users who opt in get their lantern auto-extinguished when they leave venue proximity. Users who don't opt in keep the default 2-hour TTL behavior.

Design Principles โ€‹

  • Opt-in only โ€” users choose whether to enable this, default is off
  • Periodic checks, not continuous watch โ€” check every 10โ€“15 minutes instead of watchPosition() running constantly
  • Graceful backoff โ€” if the browser throttles background location, don't fight it
  • Battery-conscious โ€” minimize wake-ups; no check if app is backgrounded/closed
  • Clear user messaging โ€” explain what it does and the battery tradeoff

3A. User Preference โ€‹

Add a "Smart Extinguish" toggle to the lantern lighting flow or profile settings:

Field: smartExtinguish (boolean, default: false)
Storage: User's Firestore profile OR localStorage (no server round-trip needed)
UI Location: LightLanternForm (toggle before confirming) + Profile settings page
Copy: "Auto-extinguish when you leave the venue area (checks every 10 min)"

3B. Geo-Exit Monitor Hook โ€‹

File: apps/web/src/hooks/useGeoExitMonitor.js

Inputs:
  - lantern: active lantern object (has venueId, lat/lng of venue)
  - enabled: boolean (from user preference)
  - checkIntervalMs: 10 * 60 * 1000 (10 minutes)

Behavior:
  1. If !enabled or no active lantern โ†’ no-op
  2. On mount + every 10 min: call getCurrentPosition()
  3. Calculate Haversine distance to venue
  4. If distance > exitRadius (e.g., 200m, larger than the 100m entry geofence):
     a. Show confirmation toast: "You've left [venue]. Extinguish your lantern?"
     b. Auto-extinguish after 2 min if no response
     c. OR immediate extinguish if user confirms
  5. On error (permission denied, timeout): skip this check, try again next interval
  6. Cleanup: clear interval on unmount or lantern extinguish

Exit radius deliberately larger than entry radius (200m vs 100m)
to avoid flapping at the boundary.

3C. Graceful Backoff Strategy โ€‹

Check Schedule:
  - First check: 10 min after lighting
  - Subsequent: every 10 min
  - If getCurrentPosition() fails: double interval (10 โ†’ 20 โ†’ 40 min, cap at 40)
  - If succeeds: reset to 10 min
  - If app is backgrounded: pause checks (use document.visibilitychange)
  - Max checks per lantern session: ~12 (covers full 2-hour TTL)

3D. Integration Points โ€‹

  • Dashboard.jsx: Mount useGeoExitMonitor when user has active lantern + enabled
  • LanternService: Add extinguishLantern(id, 'left_venue') โ€” finally uses the existing reason
  • Analytics: Fire lantern_extinguished with label: 'left_venue_auto' to distinguish from manual

Phase 4: Flash + Forge Analytics Integration โ€‹

Goal: Every lantern lifecycle event is tracked โ€” server-side via Forge (BigQuery + optionally Firestore) and client-side via Flash (batched to Analytics API). Currently the Lanterns API has zero analytics and Flash calls are inconsistent.

Current State โ€‹

LayerToolStatus
Lanterns API (server)ForgeโŒ Not imported or used anywhere
Client lantern operationsFlashโš ๏ธ Partial โ€” lantern_lit and lantern_extinguished tracked in Dashboard but not in all paths
Scheduled cleanup functionForgeโŒ Doesn't exist yet (Phase 1B)
Geo-exit auto-extinguishFlashโŒ Doesn't exist yet (Phase 3)

4A. Forge Tracking in Lanterns API โ€‹

Add @lantern/forge tracking to every Lanterns API route handler as fire-and-forget (non-blocking, graceful failure):

File: services/api/lanterns/src/routes/lanterns.js

POST /lanterns/light โ†’ after successful light:
  forge.track({
    serviceId: 'lanterns-api',
    eventType: 'lantern_lit',
    userId: req.user.uid,
    entityId: lantern.venueId,
    entityType: 'venue',
    metadata: {
      lanternId: lantern.id,
      distanceMeters: lantern.distanceMeters,
      mood: lantern.mood || null,
      hasMood: !!lantern.mood,
      hasInterest: !!lantern.interest,
    },
  }).catch(() => {})

POST /lanterns/extinguish โ†’ after successful extinguish:
  forge.track({
    serviceId: 'lanterns-api',
    eventType: 'lantern_extinguished',
    userId: req.user.uid,
    entityId: result.id,
    entityType: 'lantern',
    metadata: {
      reason: parsed.reason,
      venueId: lantern.venueId,
    },
  }).catch(() => {})

GET /lanterns/venue/:venueId โ†’ after successful query:
  forge.track({
    serviceId: 'lanterns-api',
    eventType: 'venue_lanterns_viewed',
    userId: req.user.uid,
    entityId: venueId,
    entityType: 'venue',
    metadata: { lanternCount: lanterns.length },
  }).catch(() => {})

Also add to future routes as they're implemented:

  • Schedule create/cancel โ†’ lantern_scheduled, lantern_schedule_cancelled
  • Bonfire create/join/leave โ†’ bonfire_created, bonfire_joined, bonfire_left

4B. Flash Tracking on Client โ€‹

Ensure Flash calls are consistent across all client-side lantern flows. Some flash.track() calls exist in Dashboard.jsx but need to cover:

Existing (verify still present):
  flash.track('lantern_lit', { entityId: venueId, entityType: 'venue' })
  flash.track('lantern_extinguished', { entityId: lanternId, entityType: 'lantern', metadata: { reason } })

Add missing:
  flash.track('lantern_light_blocked', { entityType: 'lantern', metadata: { reason: 'active_exists' } })
    โ†’ When Phase 0 gate prevents lighting (useful for understanding user intent)

  flash.track('lantern_geo_exit_prompted', { entityId: lanternId, entityType: 'lantern' })
    โ†’ When Phase 3 geo-exit toast appears

  flash.track('lantern_geo_exit_confirmed', { entityId: lanternId, entityType: 'lantern' })
    โ†’ User confirms extinguish after leaving venue

  flash.track('lantern_geo_exit_dismissed', { entityId: lanternId, entityType: 'lantern' })
    โ†’ User dismisses the geo-exit toast (wants to keep lantern)

  flash.track('lantern_expired_client', { entityId: lanternId, entityType: 'lantern' })
    โ†’ Phase 2 countdown reaches zero

4C. Forge Tracking in Scheduled Cleanup โ€‹

When the Phase 1B scheduled cleanup function runs, track aggregate cleanup events:

File: services/functions/firebase/modules/lanternCleanup.js

After cleanup batch:
  forge.track({
    serviceId: 'lantern-cleanup',
    eventType: 'lanterns_expired_batch',
    entityType: 'lantern',
    metadata: {
      cleanedCount: expiredLanterns.length,
      venuesAffected: uniqueVenueIds.length,
    },
  }).catch(() => {})

This lets the analytics dashboard show how many lanterns expire naturally vs. are manually extinguished.


Implementation Order โ€‹

Phase 0 (Single-lantern enforcement) โ€” prevent duplicate lanterns
  0A. Early UI gate in LightLanternModal  ~30 min
  0B. Pass activeLantern prop from Dashboard ~15 min
  0C. Verify LanternHub hides light action ~15 min
  0D. Remove per-venue-only check          ~15 min

Phase 1 (Server reliability) โ€” no UI changes needed
  1A. Firestore TTL policy               ~30 min
  1B. Scheduled cleanup function          ~1 hour
  1C. Filter expired in listener          ~15 min

Phase 2 (User-facing countdown) โ€” visible improvement
  2A. useLanternCountdown hook            ~30 min
  2B. ActiveLanternView countdown UI      ~45 min
  2C. LanternHub real time remaining      ~15 min

Phase 3 (Geo-exit) โ€” opt-in feature
  3A. User preference toggle              ~30 min
  3B. useGeoExitMonitor hook              ~1 hour
  3C. Backoff strategy                    ~30 min (part of 3B)
  3D. Dashboard + analytics integration   ~30 min

Phase 4 (Flash + Forge analytics) โ€” observability
  4A. Forge tracking in Lanterns API      ~45 min
  4B. Flash tracking on client            ~30 min
  4C. Scheduled cleanup tracking          ~15 min

Decisions to Make โ€‹

DecisionOptionsRecommendation
How to gate duplicate lanterns in UIServer error after form / early check on modal openEarly check on modal open โ€” fail fast, don't waste user's time
Duplicate lantern UX when blockedError message only / offer to extinguish + relightOffer to extinguish & relight โ€” actionable, not a dead end
Firestore TTL policy vs. scheduled function onlyTTL auto-deletes docs; function updates statusBoth โ€” TTL as primary, function as fallback that also fixes venue counts
Geo-exit check interval5 / 10 / 15 minutes10 minutes โ€” good balance of responsiveness and battery
Exit radius vs. entry radiusSame (100m) / larger (200m)200m exit โ€” prevents boundary flapping
Auto-extinguish on geo-exit or prompt first?Silent auto / confirmation toastConfirmation toast with 2-min auto-extinguish โ€” respects user intent
Store geo-exit preferenceFirestore profile / localStoragelocalStorage โ€” no server round-trip, survives refresh, fast read
What happens to activeLanternCount when Firestore TTL deletes?Accept slight lag / scheduled function fixesScheduled function reconciles โ€” runs every 15 min anyway
Analytics granularity (Flash + Forge)Track every event / aggregate onlyTrack every event โ€” individual events now, aggregate in BigQuery later
Forge failure handlingThrow / fire-and-forgetFire-and-forget .catch(() => {}) โ€” analytics must never block user actions

Out of Scope โ€‹

  • Continuous watchPosition() โ€” too battery-intensive for a PWA
  • Background Geofencing API โ€” limited browser support, requires service worker complexity
  • Server-side geo-exit (push-based) โ€” would require user to continuously report location to server
  • Changing the 2-hour TTL duration โ€” separate product decision
  • Bonfire/group lantern TTL rules โ€” will be addressed in bonfire feature design

Built with VitePress