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 โ
| Gap | Impact |
|---|---|
| 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 records | Dashboard can show stale "active" lanterns to users |
| No countdown timer in UI | Users have no idea when their lantern expires |
left_venue extinguish reason is dead code | Schema supports it but nothing triggers it |
| No geo-exit detection after lighting | User 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 โ
| Layer | Enforcement | Gap |
|---|---|---|
Cloud Function (lightLanternSecure) | โ Checks globally โ queries all active lanterns for user | None |
Lanterns API (lantern.service.js) | โ Checks globally โ same logic | None |
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 state | Confusing UX path |
| Dashboard | โ
myLantern state tracks active lantern | Not 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 normal0B. 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
onDeleteCloud Functions by default โ we need to handleactiveLanternCountdecrement 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:
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 0Uses 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
useGeoExitMonitorwhen user has active lantern + enabled - LanternService: Add
extinguishLantern(id, 'left_venue')โ finally uses the existing reason - Analytics: Fire
lantern_extinguishedwithlabel: '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 โ
| Layer | Tool | Status |
|---|---|---|
| Lanterns API (server) | Forge | โ Not imported or used anywhere |
| Client lantern operations | Flash | โ ๏ธ Partial โ lantern_lit and lantern_extinguished tracked in Dashboard but not in all paths |
| Scheduled cleanup function | Forge | โ Doesn't exist yet (Phase 1B) |
| Geo-exit auto-extinguish | Flash | โ 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 zero4C. 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 minDecisions to Make โ
| Decision | Options | Recommendation |
|---|---|---|
| How to gate duplicate lanterns in UI | Server error after form / early check on modal open | Early check on modal open โ fail fast, don't waste user's time |
| Duplicate lantern UX when blocked | Error message only / offer to extinguish + relight | Offer to extinguish & relight โ actionable, not a dead end |
| Firestore TTL policy vs. scheduled function only | TTL auto-deletes docs; function updates status | Both โ TTL as primary, function as fallback that also fixes venue counts |
| Geo-exit check interval | 5 / 10 / 15 minutes | 10 minutes โ good balance of responsiveness and battery |
| Exit radius vs. entry radius | Same (100m) / larger (200m) | 200m exit โ prevents boundary flapping |
| Auto-extinguish on geo-exit or prompt first? | Silent auto / confirmation toast | Confirmation toast with 2-min auto-extinguish โ respects user intent |
| Store geo-exit preference | Firestore profile / localStorage | localStorage โ no server round-trip, survives refresh, fast read |
What happens to activeLanternCount when Firestore TTL deletes? | Accept slight lag / scheduled function fixes | Scheduled function reconciles โ runs every 15 min anyway |
| Analytics granularity (Flash + Forge) | Track every event / aggregate only | Track every event โ individual events now, aggregate in BigQuery later |
| Forge failure handling | Throw / fire-and-forget | Fire-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