Skip to content

INFO

This page is README.md from the repository, as it stands.

Overview ​

Planet4.Us — a chat-first social hangout planner. A group chat that got smarter: drop in polls, dates, and venue picks right where the conversation lives, and let an AI assistant read the thread and pre-build the plan. When the crew's ready, the organizer locks it in.

This started as the Roundup front-end design (kept under design/) and is now a working full-stack MVP: a Vite + React PWA on a Firebase backend, with a Claude-powered assistant that summarizes the discussion into a live event plan.

The guide to using it is at docs.planet4.us; this file and its siblings — ARCHITECTURE.md (what exists), DECISIONS.md (why it is that way), BACKLOG.md (what is only an idea so far) and CLAUDE.md (working notes for anyone changing it) — are there too, under Developers.

Architecture ​

  • Frontend — Vite + React 18 + TypeScript, installable PWA (vite-plugin-pwa), routed with react-router-dom (hash routing, so invite links are shareable static URLs).
  • Backend — Firebase: Firestore (data + realtime chat via onSnapshot), Auth (anonymous by default — a link is all anyone needs; optional Google sign-in or email link to sync across devices), and Cloud Functions (join/preview/lock callables + the LLM pipeline).
  • Assistant — Anthropic Claude called server-side from a Cloud Function. It reads the recent chat + member availability + the plan-so-far and returns a structured summary, per-line reasoning, leaning date/activity/venue, and an optional chat message — written back to the hangout doc so every client updates live.

Guiding principle from the original design: the app suggests; humans decide. The assistant proposes; the organizer always locks the plan.

Screens & flows ​

  • Hangouts list (HangoutsList) — home hub: your live + past hangouts, entry to a new hangout and your profile. Swipe a row left (or drag it with a mouse) to reveal a destructive action behind a short inline confirm — Delete (for everyone) on a hangout you organize, Leave on one you're only a member of (you can rejoin later with the invite link). Old hangouts are also cleared automatically — see Cleanup.
  • Chat (Chat) — the primary surface. Realtime group messaging plus widgets (date survey, time survey, venue pick, vibe check, RSVP poll, and yes/no questions) and a persistent plan-status banner fed by the assistant's live read of the thread. Each widget leaves a slim tappable notice in the thread and lives as a round icon button on a right-hand rail, wearing a donut progress ring (share of the crew that's responded) and an orange dot while you still owe a response; tapping either opens the widget in a drawer. Once venue suggestions carry map locations, a square map-tile button pinned at the bottom of the rail (badged with the pin count) opens every suggestion on one color-coded map. Widget answers are folded into the assistant's context, so votes and yes/no replies shape the plan on the next update. Decision widgets (date / time / venue) are limited to one each per chat so answers don't get split.
  • Itinerary (Itinerary) — the AI-drafted, always-updating event summary with {field → value → why} reasoning; the organizer refreshes it and locks the plan here.
  • Profile (Profile) — set once, used everywhere: location, commute, cuisines, budget, interests, reminders. Feeds venue/availability reasoning.

Identity is anonymous by default. Opening the app signs you in anonymously (a stable uid persisted in the browser) and asks only for a display name — no signup. That identity carries across every hangout you join on that device. In Profile you can optionally connect Google or an email link to make the account durable and usable on other devices (either one links to the same anonymous account, keeping your hangouts). The onboarding screen also offers Continue with Google, so a returning user on a fresh device picks their account back up instead of starting a new anonymous one — if the Google account already belongs to a Planet4.Us account, sign-in switches to it; otherwise it upgrades the current anonymous account in place.

Organizer path: List → New → Invite → Chat → Itinerary → Lock → Confirmed. Opening an invite link (/#/invite?code=…) drops you into the invitee path: Invite → (name, once) → Availability + vibe → Chat.

Data model (Firestore) ​

users/{uid}                         profile (set once, cross-hangout)
  fcmTokens/{token}                 one per opted-in device (web push)
hangouts/{hangoutId}                title, organizerId, status, inviteCode,
                                    memberIds[], plan, ai{summary,reasoning,…}
  members/{uid}                     role, displayName, avatarColor, photoUrl?, availability,
                                    vibePref
  messages/{messageId}              senderId, type: user|system|ai, text,
                                    card?{kind, options, votes | questions[]{answers}}
  aiRuns/{runId}                    per-summarization telemetry (model, tokens,
                                    latency, trigger, spoke/widget) — admin-only
adminReviews/{hangoutId}            admin review flag + note + resolved state
adminStats/llm (+ /daily/{day})     monotonic LLM usage counters (runs, tokens)

Security rules (firestore.rules): members can only read hangouts they belong to; clients may create only their own user messages (never ai/system); only the organizer can lock the plan; membership is granted through the joinHangout Cloud Function. Accounts carrying the admin custom claim (see Admin panel) additionally get read-only access across all of it for oversight; their only write surface is adminReviews.

Getting started ​

Prerequisites: Node 20+ and a JDK (the Firestore/Auth emulators need Java). firebase-tools ships as a dev dependency, so no global install is required.

bash
# 1. Install deps (a postinstall hook also installs functions/ deps)
npm install

# 2. Start the backend — Auth + Firestore + Functions emulators
npm run serve

# 3. In another terminal, load demo data into the emulators
npm run seed

# 4. In a third terminal, run the app
npm run dev                       # http://localhost:5173

Local dev needs no config. With no env file, the client defaults to a local demo-wavelength project, so npm run dev just works. If you do have an env file with VITE_FIREBASE_PROJECT_ID, the emulator and seed automatically use that same project id (via scripts/localProject.mjs), so the app, emulator, and seed always agree — no "Multiple projectIds" mismatch. (Vite only exposes browser env vars prefixed with VITE_; a plain FIREBASE_API_KEY is ignored, and you must restart the dev server after editing an env file.)

npm run seed creates a demo crew and three hangouts in different stages:

HangoutStageGood for
Saturday bowling nightClean slate — empty chat + planWatching the assistant build a plan from scratch
Taco night 🌮Mid-planning — chat history, date survey + food poll with partial votesChat, widget, and plan-banner work
Sunrise hike + diner brunchConfirmed — full plan, todos, ready checkThe Confirmed/Itinerary screens

Reseeding is idempotent: npm run seed wipes and rewrites the demo hangouts, so votes and widgets you added while testing are cleared.

Just opening the app signs you in anonymously and asks for a name — you're in. Open #/invite?code=demo42 to land in the bowling hangout. For an organizer/existing-member view, the onboarding screen shows a DEV · EMULATOR SIGN-IN panel (emulator only) — tap a seeded account to sign in as it. The same panel sits at the bottom of Settings, so you can switch between seeded accounts at any time (no need to clear storage to get back to the gate):

AccountRole
maya@wavelength.testorganizes bowling + tacos
devon@wavelength.testorganizes the hike
priya@wavelength.testinvitee everywhere

To exercise the real email magic-link flow, use Profile → Account → add an email and click send — the emulator prints the sign-in link to the npm run serve terminal (and the Emulator UI at http://localhost:4000 → Logs); open it in the same browser. The dev panel only renders when emulating, so it never ships to production.

Continue with Google also works against the emulator: the Auth emulator replaces the real Google popup with a fake account picker where you can invent or reuse a Google identity — no real Google credentials involved.

To test multi-user flows, use a second (incognito) window — each browser gets its own anonymous identity.

The AI assistant ​

The assistant is a Cloud Function that calls Claude with a structured-output schema. It needs an Anthropic API key — and because Cloud Functions do not read the root .env.local, the key goes in one of these standard places:

  • Local (emulator): copy functions/.secret.local.example → functions/.secret.local (gitignored) and paste your key:
    ANTHROPIC_API_KEY=sk-ant-…
  • Production: store it in Secret Manager (never a file):
    firebase functions:secrets:set ANTHROPIC_API_KEY

Without a key the app still runs — the assistant is simply disabled.

The venue widget's search box is backed by Google Places, proxied through a Cloud Function so the key stays server-side. It needs a Google Cloud API key with Places API (New) enabled, stored the same two ways as the Anthropic key: GOOGLE_PLACES_API_KEY in functions/.secret.local for the emulator, or firebase functions:secrets:set GOOGLE_PLACES_API_KEY for production. Without it the widget falls back to a plain free-text "add a place" input.

Each result also carries an ⓘ details button that opens an animated drawer (the vaul bottom sheet — swipe down to dismiss) with a photo carousel (Embla — swipe, ‹ › arrows, dots) plus the place's hours, price, phone, website and a short blurb — fetched on demand via a venueDetails callable (Place Details, same server-side Places key), so those pricier fields aren't billed on every search. Photos are resolved server-side through the Place Photo endpoint with skipHttpRedirect=true, which returns keyless googleusercontent image URLs — so the browser loads them directly without ever seeing the Places key.

Search results can also be viewed on a map (the small List / Map toggle next to the search box). Unlike the search, this map is drawn client-side by the Google Maps JavaScript SDK, so it uses the ordinary browser key (VITE_FIREBASE_API_KEY) rather than the server-side Places key — tapping a pin opens that place's details, which you can dismiss or add to the poll. Enable Maps JavaScript API on that browser key (and allow the app's origin in its HTTP-referrer restriction). Without a real key — e.g. the emulator's demo-key — the SDK simply doesn't load and the map view shows a "switch to List" notice; everything else keeps working.

The Places search uses a dedicated, server-side-only key — not the browser/Firebase key. The function calls Places server-side, so the key sends no HTTP referrer. A referrer-restricted key (like the Firebase web key) is rejected with API_KEY_HTTP_REFERRER_BLOCKED → the callable returns 503 and the widget silently falls back to free-text. Configure the Places key with Application restrictions: None (or IP), API restrictions: Places API (New), and keep it separate from the referrer-locked browser key (which is the one the map SDK uses).

When it runs — Nova, the quiet assistant (ASSISTANT_NAME):

  • Ambient updates are throttled to at most once per SUMMARY_INTERVAL_MIN minutes per hangout (default 12) to control cost — deliberately infrequent, since the paths that matter bypass it (below). On each qualifying run it re-reads the conversation + availability and silently updates the plan: it can add, change, or remove fields (dateText / venue / activities / price) as the chat dictates, plus the summary, reasoning, leaning picks, and status.
  • It stays silent in chat and only posts a message when the crew addresses it by name ("Nova, …"). After an address it keeps listening for a few follow-up messages, but those default to silence — it replies to a follow-up only when the newest message unmistakably asks it something new, so one name-drop never turns into a string of replies. A poll reaching full participation updates the plan without a chat message. Addressed exchanges and completed polls are handled promptly (short debounce), bypassing the throttle.
  • Proactive venue suggestions are its one unprompted move. When a member floats a concrete place-to-eat or thing-to-do idea ("ramen?", "how about bowling") that nobody puts on a widget, Nova runs a Google Places search for it near the hangout's search area and drops 1-3 spots onto the matching food/activity poll — creating the poll if there is none, or appending if it has 2 or fewer options. A poll with 3+ options is left alone. Each idea is searched once per hangout (aiSearched), at most 2 searches per run, and it stays silent about it (the options appear; no chat message). Needs GOOGLE_PLACES_API_KEY; without it nothing is added.
  • Manual update: the organizer has an "↻ Update the plan from chat now" button on the itinerary (the regenerateSummary callable) that pulls the latest chat in immediately, bypassing the throttle.
  • A scheduled summarizeSweep (production only — needs the pub/sub emulator locally) catches the tail after the interval. The assistant never auto-locks the plan — it suggests; the organizer decides.

Config (plain env vars — set in functions/.env locally, or firebase functions:config/env for prod): SUMMARY_INTERVAL_MIN (throttle, min 1), SUMMARY_LISTEN_WINDOW (follow-up messages after an address, default 5), and ANTHROPIC_MODEL (defaults to claude-sonnet-5 — near-Opus quality, faster and cheaper; runs at effort: 'medium'). Set ANTHROPIC_MODEL=claude-opus-4-8 for max quality. At MVP volume, cost is single-digit dollars/month on any tier (Sonnet 5 ≈ $0.012/call, Opus ≈ $0.035, Haiku 4.5 ≈ $0.007).

Nova memory (paid upgrade) ​

Nova memory is a per-user paid upgrade: the assistant remembers your durable preferences ("vegetarian", "prefers early evenings") across every hangout you're in. It's per-member — upgrading yourself changes nothing for your crew.

  • Entitlement — entitlements/{uid} with memory: true, written only by Cloud Functions (rules deny all client writes, so it can't be forged; the owner can read theirs to drive the UI).
  • Durable account required — buying needs a Google- or email-linked account (Profile → Account): an anonymous uid lives only in this browser's storage, so a paid benefit on one would vanish with a cleared cache. The server enforces it (the callable checks the Admin Auth record and rejects anonymous buyers); the Profile card disables the button and points at the Account card instead. Turning the upgrade off is allowed for anyone.
  • Notes — stored on users/{uid}.memory (capped at 20, oldest dropped). Profile → ✦ Nova memory shows the list; you can add or delete notes there, and the assistant adds its own as you chat.
  • Pipeline — on each summary run the function re-checks who holds the entitlement and injects a [memory id:…] tag + remembers: list into the prompt for entitled members only — the model never sees a memory id for anyone else, so it can't write notes for them. New notes come back as memoryUpdates and are validated + persisted server-side.

Testing the upgrade: there's no real billing yet. In the local emulator the Profile card is a working toggle out of the box — ✦ Upgrade grants the entitlement and the Turn off button next to the ACTIVE badge revokes it, both through the setMemoryUpgrade callable (the stub-checkout seam a real payment flow will replace). Outside the emulator the stub only works where ALLOW_STUB_UPGRADE=true is set (a deployed dev project — never production); everywhere else the callable rejects with "Purchases are not enabled yet." and entitlements can only come from a real payment integration. If the toggle errors locally, make sure the emulators were restarted (npm run serve rebuilds functions) after pulling this feature.

Cleanup of old hangouts ​

Hangouts don't pile up forever. There are two paths:

  • Manual — the organizer can delete a hangout outright (Invite screen → Danger zone, or a left-swipe on the list), which removes it for everyone; a member who isn't the organizer can instead leave it (list swipe), dropping it off their list without touching anyone else's. Both run server-side (deleteHangout / leaveHangout callables) — client hangout deletes are blocked by the rules.
  • Automatic — a daily scheduled function (cleanupSweep) deletes hangouts that have gone cold, subcollections and all. It measures inactivity (updatedAt, bumped by every message, vote, plan edit, and assistant update), not a calendar date — the app stores only free-text dates, and keying off activity means a hangout the crew is still using (including a genuinely upcoming event) is never swept. A confirmed hangout is removed sooner — its plan is locked and, once the event's happened, the chat falls silent — while an unconfirmed one is kept longer before it's treated as an abandoned plan left sitting for a few months.

Windows are plain env vars (like the assistant's), min 1 day: CONFIRMED_TTL_DAYS (default 30) and STALE_TTL_DAYS (default 90). Like summarizeSweep, the schedule uses Cloud Scheduler, so it only fires against the deployed backend (or the pub/sub emulator locally), not a plain npm run serve.

Push notifications ​

Web push via Firebase Cloud Messaging. Notifications are a per-device choice (Profile → Notifications), not a profile setting: each opted-in browser registers one FCM token under users/{uid}/fcmTokens/{token}, and the sign-out flow unlinks the device so the browser's next (anonymous) account doesn't get the previous account's buzzes.

  • Client (src/push.ts) — permission prompt + token registration; the token is re-asserted on every app launch (they rotate silently).
  • Service worker (src/sw.ts) — one custom worker does both the PWA precaching (vite-plugin-pwa injectManifest) and FCM's background display + notification-click routing; FCM's default separate firebase-messaging-sw.js would fight the PWA worker for the scope.
  • Server (functions/src/push.ts) — fan-out to all of a hangout's members (sender excluded) and pruning of tokens FCM reports dead. Triggers: a new chat message (onMessageCreated) and the organizer locking the plan (lockHangout, everyone but the organizer, deep-links to the Confirmed screen). The event → payload policy is pure and unit-tested in functions/src/logic.ts (pushPayloadForMessage, pushPayloadForPlanLock); new triggers can call sendPushToUsers directly.
  • Testing delivery — Profile → Notifications → Send a test notification (shown once the toggle is on) pings the caller's own devices through the real pipeline via the sendTestPush callable (rate-limited) and reports how many devices were reached. FCM normally shows nothing while the app is focused — right for chat, but it would make the button look broken — so the page displays messages flagged test: '1' itself (bindForegroundPush in src/push.ts).

Setup: generate a Web Push certificate key pair (Firebase console → Project settings → Cloud Messaging) and put the public key in .env.production.local as VITE_FIREBASE_VAPID_KEY. If the app is served from a domain other than <project>.web.app, also set APP_ORIGIN=https://<that-domain> for functions (plain env var, like SUMMARY_INTERVAL_MIN) so notification taps deep-link to the right place.

Caveats: there is no FCM emulator — the whole feature is inert in local dev (the Profile toggle explains itself), and the functions sender no-ops under the emulator so local sends can't buzz real devices. On iOS (16.4+) web push only works once the app is installed to the Home Screen, and the permission prompt must come from a user gesture — which is why it's an explicit toggle rather than an on-load prompt. The OS labels every notification with the app's name (the "from Planet4.Us" attribution line) — that line can't be removed, only named: it comes from the PWA manifest name in vite.config.ts / apple-mobile-web-app-title in index.html, and iOS re-reads it only when the app is re-added to the Home Screen.

Email digest ​

A daily email summarizing what happened in each member's hangouts — new messages, votes and plan edits, lock-ins, and the assistant's read on momentum ("looks ready to lock"). Strictly opt-in (Profile → Email digest, the digest flag on users/{uid}), and only for accounts with a linked email: the address is read from the Auth record (the verified identity), never the client-writable profile mirror, so a profile edit can't aim mail at an arbitrary inbox. An admin can also opt individual users in (or out) from the panel's Users tab via the setDigestOptIn callable — profile writes stay owner-only in the rules; this one field is the single admin-mutable exception, Admin-SDK-side and logged with the acting admin's uid. It flips the same flag the user's own Profile toggle writes, so the user always keeps the last word: their toggle shows the current state and turns it off again any time.

  • Sweep (digestSweep in functions/src/index.ts) — daily schedule; one range query on hangouts.updatedAt finds everything that changed in the window (default 24h, DIGEST_WINDOW_HOURS), per-hangout message counts come from aggregate queries, and each hangout's digest line is computed once and shared by all its recipients. Per-user send state lives in digests/{uid} (functions-written only, like entitlements): lastDigestAt keeps a manual run from double-mailing right after the schedule (gap default 20h, DIGEST_MIN_GAP_HOURS).
  • Content policy — pure and unit-tested in functions/src/logic.ts (digestItemForHangout, renderDigestEmail), next to the push payload builders.
  • Vote from the inbox — each digest renders the recipient's unanswered widgets (polls, date/time/venue votes, yes/no questions; up to 2 per hangout, newest first) as link buttons. Venue options render as rich rows: the card's stored address, rating, and keyless Google Maps link (falling back to a maps search URL for options predating mapsUri), plus one photo per Places-backed venue — resolved server-side into a keyless googleusercontent URL and cached in placePhotos/{placeId} (30-day TTL, clients can never read it), so each venue costs at most one billed Places call per month regardless of digest volume. Photos require GOOGLE_PLACES_API_KEY; without it emails simply render without pictures. Email clients run no JavaScript, so the widget can't be live in the body; each option is instead a link carrying a signed token (uid + hangout + message + choice + 14-day expiry, HMAC'd with a server-minted key in internal/emailActionKey — no rules match block, so no client can ever read it; rules-tested). The emailAction endpoint (hosting rewrite /email/action, functions/src/emailActions.ts) verifies the token and applies the same pure card mutation an in-app tap would — GET only renders a confirm page (mail scanners prefetch links; nothing mutates until the button POSTs), the token is the authentication (no sign-in needed on that device), a multi-choice click adds rather than toggles (a repeat click can't retract a vote), and the vote lands only under the token's uid. The existing onMessageUpdated trigger then cascades activity + AI summary exactly as for in-app votes.
  • Delivery (functions/src/email.ts) — Resend's plain HTTPS API; swapping providers means reimplementing one function. Secret: firebase functions:secrets:set RESEND_API_KEY (emulator: functions/.secret.local). Set EMAIL_FROM (plain env var, like APP_ORIGIN) to a sender on a domain verified with the provider — the default is Resend's shared test sender, which only delivers to the Resend account owner. Without a key the sweep still runs and logs; nothing is sent.
  • Operator controls — the admin panel's Digest tab (/#/admin/digest) edits adminConfig/digest, which every sweep run reads, so changes apply from the next run with no deploy: a kill switch (stops every send, manual included), window/gap hours, a weekday cadence (UTC; picking e.g. only Mon turns the daily schedule into a weekly digest — the Cloud Scheduler trigger still fires daily and simply no-ops off-days), a max-hangouts cap per email, and subject/greeting templates ({count}, {title}, {name}). The doc is admin-only and bounded in the rules, and the server re-clamps everything (resolveDigestConfig, unit-tested) so a bad write can change behavior but never crash the sweep. Each real run's outcome lands in adminStats/digest and shows in the tab's status line.
  • Testing & diagnostics — the tab's Preview recipients button (or the underlying runDigest callable, admin custom claim only) fires the same sweep as a dry run: each previewed recipient row expands to the full rendered email body, so what would land in the inbox is readable before anything sends. Send now ({ send: true }) really sends, honoring the kill switch and per-user gap but not the weekday cadence (a manual send is explicit intent), and reports a per-recipient outcome — failures carry the provider's actual error message (e.g. Resend's test-sender restriction: the default EMAIL_FROM only delivers to the Resend account owner's own address). Skipped opted-in users are each named with the exact reason (no auth email / emailed Nh ago), and the Ignore send gap toggle re-mails gap-blocked users on demand — the testing path for hitting the same account repeatedly. Failures also persist to adminStats/digest and show in the tab's Status card until the next run. Deeper history lives in the Cloud Functions logs (firebase functions:log, or the Firebase console) and Resend's own dashboard logs post-acceptance (spam/suppression). Under the emulator sendEmail logs the rendered email instead of sending — the tab says so — and schedules need Cloud Scheduler, so runDigest is the only way to fire locally.

Service error log ​

Backend failures worth an operator's attention — email-send rejections, dead or tampered vote links, digest/summarize/cleanup crashes, push fan-out failures — are recorded to the serviceErrors collection (recordServiceError in functions/src/errlog.ts; admin-read-only, never client-writable, rules-tested) and shown on the admin Overview as a count tile plus a newest-first list with scope, message, and context. Recording is best-effort and never throws (an error log must not break the operation it's logging); everything still goes to the Cloud Functions logs (firebase functions:log) for the full trail. Entries age out after 30 days via cleanupSweep.

Admin panel ​

/#/admin is the operator's oversight surface — built above all to watch the LLM: read any chat next to an "AI inspector" showing Nova's persisted summary/reasoning/signals, a per-run telemetry timeline (model, tokens, latency, errors, whether it spoke or made a widget), a flag-for-review workflow, a usage dashboard, and a user browser.

Access is a Firebase custom claim (admin: true), never a document — rules check request.auth.token directly, so there's nothing a client could write to grant itself access. Everything the panel shows is enforced server-side by firestore.rules (the client gate in App.tsx is just UX). Admin access is read-only over user data; the only admin-writable surface is adminReviews.

bash
# Local: the seed creates admin@wavelength.test ("Ada", claim already set) —
# sign in via the dev panel and open http://localhost:5173/#/admin
npm run seed

# Grant/revoke against the emulators
node scripts/grantAdmin.mjs --emulator someone@wavelength.test
node scripts/grantAdmin.mjs --emulator --revoke someone@wavelength.test

# Production (Admin SDK credentials required). Grant to your GOOGLE-LINKED
# account — a claim on an anonymous account dies with its session.
GOOGLE_APPLICATION_CREDENTIALS=service-account.json \
  node scripts/grantAdmin.mjs you@example.com

Rollout order for the first deploy of the panel: rules → functions → hosting (npm run deploy:prod does all three; rules first matters only for partial deploys). Then grant the claim and open /#/admin — the gate force-refreshes your token once, so no re-login is needed. Revocation rides the ID token and can take up to an hour; at solo scale that's fine. Optional housekeeping: aiRuns docs are already deleted with their hangout (TTL sweep), but you can add a Firestore TTL policy on the aiRuns collection group in the console as belt-and-braces.

Deploy ​

bash
npm run deploy:prod   # = firebase deploy (Hosting + Functions + Firestore
                      # rules & indexes). predeploy hooks build both the
                      # frontend (dist/) and functions first, so a plain
                      # `firebase deploy` always ships a fresh build.

One-time setup before the first deploy:

bash
npx firebase login
npx firebase use --add                      # pick/create the real project
firebase functions:secrets:set ANTHROPIC_API_KEY
firebase functions:secrets:set GOOGLE_PLACES_API_KEY   # venue search (deploy fails if unset)

Production credentials ​

Put the real Firebase web config in .env.production.local (gitignored; Vite loads it only for vite build, so npm run dev stays on the emulators):

VITE_FIREBASE_API_KEY=…
VITE_FIREBASE_AUTH_DOMAIN=your-project.firebaseapp.com
VITE_FIREBASE_PROJECT_ID=your-project
VITE_FIREBASE_STORAGE_BUCKET=your-project.appspot.com
VITE_FIREBASE_MESSAGING_SENDER_ID=…
VITE_FIREBASE_APP_ID=…
VITE_FIREBASE_VAPID_KEY=…            # web push (see Push notifications)
VITE_USE_EMULATORS=false

Notes:

  • VITE_ prefix is required — Vite only exposes browser vars starting with VITE_. A plain FIREBASE_API_KEY is silently ignored.
  • Don't put prod values in .env / .env.local — those apply to every mode and would point npm run dev at production. Use .env.production.local.
  • The Firebase web config is not a secret — it's embedded in the client bundle by design; your data is protected by the Firestore rules + Auth, not by hiding this key. The only true secrets are ANTHROPIC_API_KEY and GOOGLE_PLACES_API_KEY, which live server-side in Cloud Functions (via functions:secrets:set / a gitignored functions/.secret.local) and never reach the client.
  • All .env* files except .env.example are gitignored, so real values won't be committed.
  • In the Firebase console → Authentication → Sign-in method, enable Anonymous (the default identity), Google, and Email link (passwordless) (the optional cross-device upgrades), and add your deploy domain under Authorized domains. The emulator doesn't enforce these, so it's easy to forget for prod. Google sign-in needs no client-side keys beyond the standard VITE_FIREBASE_* config — the console provisions the OAuth client when you enable the provider.
  • Google sign-in redirect URI. In production the app points authDomain at whatever domain it's served from (same-origin auth survives Safari's third-party-storage partitioning and the installed PWA — see src/firebase.ts). https://<project>.firebaseapp.com/__/auth/handler is authorized automatically; if people reach the app on any other domain (<project>.web.app or a custom domain), also add https://<that-domain>/__/auth/handler to the OAuth client's Authorized redirect URIs in the Google Cloud console (APIs & Services → Credentials → the auto-created web client). Otherwise Google sign-in on that domain fails with redirect_uri_mismatch. VITE_FIREBASE_AUTH_DOMAIN is only used on localhost, where there's no Hosting-served /__/auth handler.

Docs site ​

The docs site — the end-user guide, plus this repository's own documents under Developers — lives under docs/ as a VitePress site and is published at https://docs.planet4.us (Firebase Hosting site wavelength-docs, also reachable at https://wavelength-docs.web.app). The Developers pages (docs/developers/) include README.md, ARCHITECTURE.md, DECISIONS.md, BACKLOG.md and CLAUDE.md from the repository root as they stand, so the site can never say something the repository doesn't — edit the root file, not the page. docs/ABOUT.md is an older internal overview, kept out of the site.

bash
npm run docs:dev       # live-reloading preview at http://localhost:5173
npm run docs:build     # static build → docs/.vitepress/dist
npm run docs:preview   # serve the built site locally
npm run deploy:docs    # firebase deploy --only hosting:wavelength-docs (builds first)

npm run deploy:prod deploys the docs site alongside the app, since both are listed under hosting in firebase.json (site: wavelength-d0d74 for the app, site: wavelength-docs for the docs). One-time setup for the docs site: create the site (npx firebase hosting:sites:create wavelength-docs) if it doesn't exist yet, then in the Firebase console → Hosting → the wavelength-docs site → Add custom domain → docs.planet4.us and add the DNS records it shows.

Guide pages are plain Markdown in docs/guide/; the sidebar and site settings are in docs/.vitepress/config.ts, and the Planet4.Us skin (colors mirror src/index.css) in docs/.vitepress/theme/custom.css. Screenshots in docs/public/shots/ were captured from the seeded emulator app at phone (390px, 2×) and desktop (1280px) widths — retake them the same way when a screen changes.

Project structure ​

README.md, ARCHITECTURE.md,
  DECISIONS.md, BACKLOG.md, CLAUDE.md  the documents (also on the docs site under Developers)
firebase.json, firestore.rules, firestore.indexes.json   Firebase config, rules, the one index
functions/                     Cloud Functions (Node 20 + TS)
  src/index.ts                 callables, Firestore triggers, schedules, the assistant pipeline
  src/anthropic.ts             Claude client: system prompt, output schema, summarize()
  src/logic.ts                 pure, unit-tested policy (tallies, push/digest content, card edits)
  src/places.ts                Google Places proxy (search, details, suggestions, geocoding)
  src/group.ts                 crew planning context + the location-consent gate
  src/friends.ts               friend graph + in-app hangout invites
  src/membership.ts            the join transaction; full hangout deletion
  src/push.ts / email.ts       FCM fan-out / Resend delivery
  src/emailActions.ts          vote-from-the-inbox endpoint
  src/errlog.ts / ratelimit.ts service error log / per-uid rate limits
src/
  main.tsx / App.tsx           entry; router, auth gate chain, app shell
  firebase.ts / config.ts      Firebase client init (emulator-aware); re-skin knobs
  data.ts / adminData.ts       Firebase-backed hooks, mutations, callables; the admin panel's
  types.ts                     shared domain types + pure card helpers
  push.ts / sw.ts              per-device push opt-in; the service worker (PWA + FCM)
  sponsored.ts / venueMap.tsx  monetization prototype; the shared venue map
  ui.tsx / style.ts / useWide.ts / index.css   UI kit, css() helper, viewport tiers, tokens
  screens/                     one file per screen; chat/ (the chat's pieces), admin/ (the panel)
tests/                         firestore.rules tests
scripts/                       emulators, seed, grantAdmin, localProject
docs/                          VitePress: guide/, developers/, the skin, screenshots
design/                        the original Roundup design bundle

Design source ​

The original design bundle (HTML/CSS/JS prototypes plus the brief and chat transcript) is kept under design/ for reference.