Skip to content

INFO

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

Architecture ​

What exists in this repository, in the present tense. Why it is that way lives in DECISIONS.md; what is only an idea so far lives in BACKLOG.md; how to run, configure and deploy it lives in README.md. This file describes only what is built. Any change to the Firestore schema, the security rules, a Cloud Function's contract, or the assistant pipeline updates this file in the same commit.

Current state: a working full-stack MVP. Planet4.Us is a chat-first hangout planner: a group chat with decision widgets dropped into the thread, a Google Places–backed venue search and map, and a quiet Claude-powered assistant ("Nova") that keeps a draft plan current from the conversation. The organizer locks the plan; the assistant never does. Everything runs as an installable PWA on Firebase, and every external dependency — the model, Places, email, push — is optional: without its key the feature disables itself and the rest of the app runs unchanged.

Stack ​

PieceChoice
BuildVite 5, React 18, TypeScript 5 (strict), vite-plugin-pwa in injectManifest mode with a hand-written service worker
UIInline styles authored as CSS strings and converted by css() (src/style.ts) over the design tokens in src/index.css; vaul for bottom drawers; embla-carousel-react for photo carousels; the Google Maps JavaScript SDK for maps
Routingreact-router-dom 7 with HashRouter — every screen is a /#/… URL
BackendFirebase 12: Auth (anonymous by default; Google and email-link upgrades), Firestore, Cloud Functions v2 on Node 20, Cloud Messaging (web push), Hosting (two sites)
AssistantAnthropic Claude through @anthropic-ai/sdk, structured JSON-schema output; default model claude-sonnet-5, overridable with ANTHROPIC_MODEL
VenuesGoogle Places API (New) server-side (Text Search, Place Details, Place Photos); the Maps JS SDK client-side
EmailResend, called through its plain HTTPS API
TestsVitest 4 for the pure server logic; @firebase/rules-unit-testing 5 for the security rules, against the Firestore emulator
LintESLint 10 flat config, typescript-eslint, eslint-plugin-react-hooks; covers src, functions/src, tests
DocsVitePress 1 (docs/), published as a second Hosting site — see "The docs site"
ProjectFirebase project wavelength-d0d74; the app at planet4.us (Hosting site wavelength-d0d74), the docs at docs.planet4.us (Hosting site wavelength-docs); functions in us-central1

The npm package is still called wavelength (the project's working name) and the Firebase project id carries it too; the product is Planet4.Us everywhere a person sees it.

Shape of the code ​

Two runtimes and one rules file, with the trust boundary between them:

src/                 the client: React screens over a Firebase-backed data layer
functions/src/       the server: Cloud Functions (callables, triggers, schedules, one HTTP endpoint)
firestore.rules      what a client may read and write — the real authorization boundary

On the client, screens render and call into src/data.ts, which owns every Firestore subscription (onSnapshot lives there and in src/adminData.ts, never in a component), every client-side write, and a typed httpsCallable for each Cloud Function. src/types.ts holds the domain types plus the pure helpers both sides of a widget tap need. Nothing on the client is trusted: a client gate (the admin route, a disabled button) is user experience, and the equivalent check in firestore.rules or in the callable is the enforcement.

On the server, functions/src/index.ts orchestrates: it reads and writes Firestore with the Admin SDK (which bypasses the rules — every write it makes is one the rules deliberately deny clients), calls the model, and fans out notifications. The policy — what a push says, how a card mutates, which venue searches the model may request, whose memory notes may be written — is pure functions in functions/src/logic.ts, unit-tested without firebase-admin. The two sides share their pure steps by mirroring: applyCardVote / applyMultiToggle / applyAnswer in data.ts and applyEmailAction / applySetMulti / applyQuestionEdit in logic.ts are the same mutations, so an optimistic tap, a transaction, and an emailed vote produce the same card.

Several constants are mirrored between the rules and both runtimes and must be kept in step; each site says so in a comment: the member display-name rule (validDisplayName ↔ sanitizeDisplayName), the photo-URL rule (validPhotoUrl ↔ safePhotoUrl in src/types.ts and functions/src/logic.ts), the reaction palette (REACTION_EMOJI ↔ validReaction), the widget kinds (CardKind ↔ validCardKind), the avatar palette (AVATAR_COLORS in src/types.ts, functions/src/membership.ts, scripts/seed.mjs) and the assistant's name (ASSISTANT_NAME in src/config.ts and functions/src/anthropic.ts).

Client modules (src/) ​

ModuleHolds
main.tsxEntry: mounts App in StrictMode, imports index.css
App.tsxHashRouter, the route table, and the gate chain: Splash while auth restores → ConfirmEmail for a magic link opened on a device that didn't request it → NameGate until the profile has a name → the routes. Also AppFrame (the sized shell — see "Layout tiers"), a last-resort ErrorBoundary, RequireAdmin (client gate for /#/admin, with one forced token refresh so a freshly granted claim works without re-login), PrivacyBypass (/#/privacy renders above the gate chain, in every auth state), and the on-screen-keyboard inset: iOS never shrinks the layout viewport for the keyboard, so the visual-viewport overlap is mirrored into --kb-inset, which the shell subtracts from 100dvh
firebase.tsClient init from VITE_FIREBASE_*, falling back to the local demo-wavelength project with no env at all. Emulators connect only in dev builds (env.DEV && VITE_USE_EMULATORS !== 'false'). In production authDomain is the host the page was served from; the configured domain is used only on localhost
config.tsThe re-skin knobs (--primary / --accent written onto the frame) and ASSISTANT_NAME
types.tsDomain types (Profile, Hangout, Member, Message, Card, Friend, HangoutInvite, AiRun, …) and the pure card helpers: selectedKeys, optionCount, hasResponded, everyoneResponded (all excluding orphaned votes), SINGLETON_KINDS, VENUE_KINDS, avatarColor, safePhotoUrl, photoUrlAtSize
data.tsThe data layer: auth (anonymous sign-in, email-link and Google link/sign-in, redirect completion, boot-time error publishing, the emulator-only devSignIn), profile and entitlement hooks, hangout/member/message/friend/invite subscriptions, every client write (plan, venue area, member prefs, ready, messages, card transactions, reactions, deletes), the typed callables, and inviteUrl / extractInviteCode
adminData.tsThe admin panel's data layer — same hook idiom, but every read crosses membership lines and passes the rules only through the admin claim. Imported only from src/screens/admin/*, so it stays in the lazy admin chunk
push.tsPer-device web-push opt-in: permission, FCM token minting and registration under users/{uid}/fcmTokens, per-launch re-assertion, detach on sign-out, the foreground display of test pings
sw.tsThe service worker: Workbox precache + SPA navigation fallback (denylisting /__/* and /email/*), Google Fonts cache, and FCM's background handlers, in one worker
sponsored.tsThe monetization prototype: the sponsored-card cadence and house inventory, safeHttpUrl, and the contextual booking/map/ticket links
venueMap.tsxThe Maps SDK loader (browser key), the color-coded "all suggestions" map, pin derivation from venue widgets, coarse geocoding, and the map panel in its panel and canvas presentations
ui.tsxThe shared kit: Screen, TopBar, Avatar (photo with initial fallback), BottomSheet, PlanetLoader, the no-access screen, submitOnEnter (IME-safe), one-time-tip storage
style.tscss() — CSS declaration string → React style object — and merge()
useWide.tsThe three viewport tiers as matchMedia hooks (useWide, useWideFull, useWideDesktop)
index.cssThe design tokens (the single source of the palette), resets, fonts, safe-area layout, and the media-query-driven presentational rules (.wl-sheet, .wl-gate, .wl-map-search, …)

Screens (src/screens/) ​

RouteScreenWhat it is
(gate)NameGateThe signed-out landing page (public, so an OAuth verifier can see the app without signing in) and the one-time display-name prompt, with Continue with Google for a returning user on a new device; carries the emulator-only DevSignIn panel
(gate)ConfirmEmailFinishes an email magic link opened on a device that didn't request it
/HangoutsListHome: the For you inbox (Inbox), the hangout rows (swipe/drag left to delete or leave), paste-a-link join, new hangout, the first-sign-in preferences nudge
/newNewHangoutName the hangout; creates the doc and the organizer's own member doc client-side
/h/:idredirect→ /h/:id/chat
/h/:id/chatChatThe primary surface — see below
/h/:id/itineraryItineraryThe draft plan with {field → value → why} reasoning, the to-do list, the organizer's edit / refresh / lock controls; on wide screens a details pane with the venue map
/h/:id/inviteOrgInviteShare link and code, in-app friend invites, member management (kick), the danger zone (delete)
/h/:id/confirmedConfirmedThe locked plan, with map / booking links
/h/:id/join, /inviteInviteeJoinInvite landing: previews the hangout by code, joins through joinHangout, drops into the chat
/profileProfileSettings in three tabs — You (name, location and area sharing, account linking, Nova memory), Activity (cuisines, budget, interests, travel), App behavior (push per device, email digest, friends) — plus the dev sign-in panel under the emulator
/admin/*admin/AdminRoutesThe operator panel, lazy-loaded behind RequireAdmin
/privacyPrivacyPolicyStatic plain-language policy, reachable signed out
—QuestionComposerThe yes/no widget composer (create and edit), opened from the chat

The chat (src/screens/chat/) ​

Chat.tsx is a picker: one headless hook, useChatController, owns every piece of layout-agnostic state and behavior (subscriptions, drafts, optimistic card overlays, scroll pinning, sheet state, the ready-up and send handlers), and the picker mounts one of two bodies around it — ChatPhone (single column: collapsible plan banner, thread with the widget rail, composer, full-screen map sheet) or ChatWide (plan-status toolbar, then thread pane | widgets column or rail | map pane). Crossing the 820 px breakpoint live (an iPad rotation) swaps the body and keeps the brain mounted, so nothing in flight is lost. Overlays shared by every tier — the ToolSheet (+ menu), the QuestionComposer, the WidgetDrawer — render from the picker.

The rest of the directory is the pieces both bodies compose: ChatThread (the scroll region and jump-to-latest), MessageRow (bubbles, the long-press/right-click reaction picker and chips, pasted-invite unfurls, the slim notice a widget leaves in the thread), ComposerBar (+, the message-Nova toggle, input, send), widgets.tsx (the rail buttons with their response-progress rings, the map rail button, the wide column's section chrome), WidgetCard / CardBubble (every card kind's live body), CalendarVote (the month-grid ballot), venueSearch.tsx (the debounced Places query hook, the widget search box, the map panel's search field with its Suggest dropdown, the inline results map), placeDetails.tsx (photo carousel with pull-to-stretch, the lightbox, the details drawer), venueDetail.tsx (the detail renderer both map surfaces share, with its vote / add-to-poll footers), statusPills.tsx (status pill, ready tally, KPI row, the toolbar info popover), NotifNudge (the turn-on-notifications funnel), WidgetAddMenu (the wide-layout add dropdown), cardMeta.ts, cardPresets.ts, options.ts, icons.tsx.

The admin panel (src/screens/admin/) ​

/#/admin is the operator's oversight surface, built above all to watch the model: Overview (AdminHome — counts, the last seven days of LLM usage, the service error log, recent chats), Chats (AdminChats → AdminChatDetail: the full transcript with a pager next to an inspector showing Nova's persisted summary, reasoning and signals, the per-run telemetry timeline, and a flag-for-review workflow), Users (AdminUsers — profiles, entitlements, each user's hangouts, the digest opt-in switch) and Digest (AdminDigest — the live digest configuration, preview and send-now, the last run's outcome, a check that the /email/action rewrite is deployed). It is one lazy chunk; adminData.ts must not be imported from anywhere else or the split silently collapses.

Identity and accounts ​

Opening the app signs the visitor in anonymously — a stable, server-verified uid persisted in this browser — and asks only for a display name. That uid carries across every hangout joined on the device. A Google account or an email link can be attached later (Profile → Account, or Continue with Google on the name gate); either links to the same anonymous uid so memberships survive, and if the Google account already owns another Planet4.Us account the app switches to it instead — unless the current guest account has hangouts a switch would abandon, in which case it refuses with an explanation. Google uses a popup where popups work and the full-page redirect flow in an installed (home-screen) PWA; both deferred completions (a redirect, a link opened from the inbox) finish at boot in useAuth, and their failures are published through useAuthCompletionError so the screen that started the flow can still show the message. Sign-out first detaches this device's push token so the next account in the browser is not buzzed about the previous one's hangouts.

The profile doc (users/{uid}) is owner-only. Everything another person sees of you is denormalized: display name, avatar color and Google photo onto your member doc in each hangout (re-synced on rename or photo change), name and photo onto friend edges, title and host onto invites. Photo URLs are pinned to Google's own CDN in three places (rules, client, server) because every client renders them as <img> for the whole crew.

Under the emulator, DevSignIn offers one-tap sign-in to the seeded accounts (maya@, devon@, priya@, admin@wavelength.test) by generating and redeeming an email link through the Auth emulator's REST API; the panel only renders when import.meta.env.DEV && isEmulator, so production builds tree-shake it away.

Firestore ​

users/{uid}                      profile — name, email, photoUrl, home, shareLocation, work,
                                 maxTravel, mode, cuisines, budget, setting, interests,
                                 calendar, remind, reachBy, digest, memory[]
  fcmTokens/{token}              one per opted-in device: ua, updatedAt
  friends/{otherUid}             one side of a mirrored edge: status in|out|friends, name, photoUrl
  invites/{hangoutId}            inbox invite: title, hostUid, hostName
entitlements/{uid}               paid upgrades: memory
digests/{uid}                    email-digest send state: lastDigestAt
rateLimits/{uid}                 <key>WindowStart / <key>Count per rate-limited endpoint
placePhotos/{placeId}            cached keyless venue photo URL (30-day TTL)
internal/emailActionKey          the HMAC key for emailed vote links
hangouts/{hangoutId}             title, organizerId, status planning|confirmed|archived, inviteCode,
                                 memberIds[], vibe, plan{date, dateText, venue, activities[], price,
                                 todos[]}, ai{summary, reasoning[], leaning, status, signals},
                                 venueArea, suggestedTimes[], aiSearched[], aiUpdating, aiSpeaking,
                                 aiAwaitingReply, pendingMessages, lastSummarizedAt, bannedIds[],
                                 createdAt, updatedAt, lastMessageAt
  members/{uid}                  uid, role organizer|invitee, displayName, avatarColor, photoUrl,
                                 status, availability{Mon..Sun}, vibePref, ready,
                                 originLat, originLng, originLabel (opt-in coarse area)
  messages/{messageId}           senderId, senderName, avatarColor, type user|system|ai, text,
                                 card{kind, title, options[], votes{}, multi, multiSel{}, questions[]},
                                 reactions{uid → emoji}, muted, toAssistant, createdAt
  invites/{invitedUid}           member-visible marker of a pending in-app invite: uid, name, invitedBy
  aiRuns/{runId}                 per-summarization telemetry (model, tokens, latency, trigger, …)
adminReviews/{hangoutId}         flagged, note, resolved
adminStats/llm                   monotonic LLM counters; adminStats/llm/daily/{YYYY-MM-DD} rollups
adminStats/digest                the last real digest run's outcome
adminConfig/digest               operator knobs: enabled, windowHours, minGapHours, days[], maxHangouts,
                                 subject, intro
serviceErrors/{id}               backend failures: scope, message, context, at (30-day retention)

Timestamps are Firestore Timestamps written with serverTimestamp(); updatedAt on a hangout is the activity clock everything else keys off (the cleanup sweep, the digest window, the home list's ordering). Dates in the plan are free text (dateText); plan.date is a legacy field nothing writes.

Security rules (firestore.rules) ​

The rules are the authorization model, tested scenario by scenario in tests/firestore-rules.test.ts. The shape:

  • Membership is server-granted. A hangout's memberIds and its member docs are written by joinHangout / respondInvite (Admin SDK). A client may create a hangout only as its organizer and only as its single initial member, with a whitelisted key set (so server-owned bookkeeping like pendingMessages can't arrive on a create); it may never delete one.
  • Members read only their own hangouts. The organizer may update plan (and updatedAt, pinned to the server clock); any member may correct venueArea (bounded); everything else on the doc — status, memberIds, the ai draft, the queue counters — is server-owned.
  • Messages are signed. A client creates only type: 'user' messages, with senderName and avatarColor pinned to its own member doc, createdAt pinned to the server clock, text bounded (it flows into the prompt), and a card that is well-formed, of a known kind, and response-free. Updates come in two shapes: card edits where the votes / multiSel / per-question answers diffs may touch only the caller's own uid — with no exception for the author or organizer — plus title edits for those two and option add/remove for any member; or a change to the caller's own entry in reactions, palette-bound. Only widget messages can be deleted, by their author or the organizer.
  • Member docs are self-edit only, over a whitelist (displayName — never the assistant's name — photoUrl on Google's CDN, availability, vibePref, ready, the origin fields); role, status and avatarColor are pinned.
  • Profiles are owner-only and bounded, field-whitelisted, with size limits on the fields that reach the prompt.
  • Functions-only collections deny every client write: entitlements, digests, friends, invites (both sides), aiRuns, adminStats, serviceErrors. Collections with no match block at all — rateLimits, placePhotos, internal — are denied to every client by default and read only through the Admin SDK.
  • Admin is a custom claim (request.auth.token.admin == true, boolean-strict), never a document: read access across users, hangouts, messages, members, entitlements, runs and stats, and exactly two writable surfaces, adminReviews and adminConfig, both whitelisted and bounded.

Rules can't loop, so the per-question checks on a question card are unrolled per index up to the composer's cap of 8.

Indexes ​

One composite index (firestore.indexes.json): hangouts on memberIds (array-contains) + updatedAt (descending) — the home list. Everything else is a single-field query.

Cloud Functions (functions/src/) ​

All functions are v2, Node 20. Secrets come from Secret Manager (defineSecret) and are declared per function; plain configuration is env vars (functions/.env). Every callable requires a signed-in caller (anonymous counts), and the endpoints an anonymous token could abuse are additionally rate-limited per uid in rateLimits/{uid} (ratelimit.ts, a fixed window per key).

ModuleHolds
index.tsThe callables below, the Firestore triggers, the three schedules, runSummary (the assistant pipeline), the digest sweep, hangout cleanup, and the telemetry writers
anthropic.tsThe model call: SYSTEM prompt, the output SCHEMA, userPrompt() assembly, summarize() with its deterministic-vs-transient error split, ASSISTANT_NAME, MODEL
logic.tsPure policy: address state, transcript tallies (describeCard), poll completion, option minting, venue-search planning, memory-note planning, push payloads, digest content and rendering, emailed-action tokens and their card mutation, structural card edits, run telemetry shaping, display-name and photo-URL sanitizing
group.tsThe crew's aggregated planning context from private profiles: coarseArea, deriveGroupArea, deriveGroupAreaFor (the one location-consent gate), cuisine and interest aggregation, groupTravel
places.tsThe Google Places proxy: search, details, coarse geocoding, restaurant and activity suggestions, keyless photo resolution, the abuse guard
membership.tsaddMemberToHangout (the one join transaction and "👋 joined" notice) and deleteHangoutFully
friends.tsThe friend graph and in-app hangout invites
push.tsFCM fan-out to a member set with dead-token pruning; appOrigin()
email.tsOne sendEmail() over Resend's HTTPS API; emulator logs instead of sending
emailActions.tsThe emailAction HTTP endpoint (vote from the inbox) and the HMAC key
errlog.tsrecordServiceError — best-effort, never throws
ratelimit.tsrateLimit(uid, key, max, windowMs)

Callables (client → server, httpsCallable in data.ts / adminData.ts):

CallableDoesGuard
getHangoutPreviewTitle / host / date for an invite code, for the landing screen and pasted-link unfurls30/min
joinHangoutThe only path to membership by invite code; refuses bannedIds20/min
lockHangoutOrganizer only; claims planning → confirmed in a transaction, posts the "locked in" note, pushes everyone but the organizerorganizer
kickMemberOrganizer removes a member, bans the uid, strips their votes from every cardorganizer
deleteHangout / leaveHangoutDelete for everyone (organizer) / remove yourself (no ban, rejoinable)organizer / member
regenerateSummary"Update the plan from chat now" — an immediate run that bypasses the ambient throttle; skips when nothing is pendingmember, 4/min
editCardStructureThe two structural widget edits that rewrite other members' entries: single ↔ multi choice, question-list editsauthor or organizer
groupPlanContextThe crew's shared cuisines, interests, search area and travel constraint, as aggregates onlymember
setMemoryUpgradeGrant / revoke the memory entitlement — the stub-checkout seam a real payment flow replaces; grants only under ALLOW_STUB_UPGRADE or the emulator, and only to a Google- or email-linked accountsigned in
sendTestPushPings the caller's own devices through the real FCM pipeline6/min
searchVenues / venueDetails / geocodeArea / suggestRestaurants / suggestActivitiesThe Places proxy (see "Venues and maps")member of some hangout, 40/min shared
sendFriendRequest / respondFriendRequest / removeFriendThe friend graph15 / 30 / 30 per min
inviteFriends / respondInviteIn-app hangout invites10 / 20 per min
setDigestOptIn / runDigestOperator controls for the email digestadmin claim

Triggers: onHangoutCreated writes the fixed-id intro notice; onMessageCreated bumps the activity counters, seeds venueArea when a venue widget is dropped into a hangout with none, pushes the crew, and claims a summary slot; onMessageUpdated treats a vote or answer as activity and fast-tracks a poll that just reached full participation; onMessageDeleted marks the hangout dirty so a removed widget is re-read out of the plan.

Schedules (Cloud Scheduler, so they run only against a deployed backend or the pub/sub emulator): summarizeSweep every 5 minutes (hangouts with pendingMessages > 0 past the throttle), cleanupSweep every 24 hours (cold hangouts, plus service-error retention), digestSweep every 24 hours.

HTTP: emailAction behind the Hosting rewrite /email/action — the vote-from-the-inbox endpoint.

The assistant (Nova) ​

Nova is runSummary in functions/src/index.ts around one model call in functions/src/anthropic.ts. It is not a chat bot: its job is to keep the hangout's ai draft and plan current from the conversation, silently, and to speak only when spoken to.

When it runs. Three paths, each of which claims the hangout's summary slot in a transaction (lastSummarizedAt) so concurrent triggers produce one model call:

  1. Ambient — a new user message or a vote, at most once per SUMMARY_INTERVAL_MIN minutes per hangout (default 12). summarizeSweep catches the tail every 5 minutes for hangouts left with pendingMessages > 0.
  2. Engaged — when the crew is addressing the assistant (the newest message names it, was sent in message-Nova mode, or answers a question it asked; or it was named within the last SUMMARY_LISTEN_WINDOW = 5 spoken messages), the debounce drops to 8 seconds. A poll reaching full participation is fast-tracked the same way, without inviting a reply.
  3. Manual — the organizer's ↻ Update the plan from chat now (regenerateSummary), rate-limited, skipping the model entirely when pendingMessages is 0.

pendingMessages counts new messages and votes since the last run and resets to 0 on every run; a widget deletion bumps it too, so a plan resting on a removed widget gets re-read.

What it reads (SummaryInput): the hangout's title as its purpose; each member's name, role, availability, vibe, interests and cuisines from their profile, plus their coarsened home area only if they turned on location sharing, plus their memory id and notes only if they hold the memory entitlement; the organizer's budget / setting / cuisines / interests; the plan so far (dateText, never the legacy date); the current venueArea; the last 60 messages, with each widget rendered as one bracketed tally line (describeCard) and each typed line clamped and stripped of square brackets so nobody can type a fake tally; the address state; whether a poll just completed; and the venue searches it has already run.

What it returns (SummaryOutput, enforced by a JSON schema with output_config.format and effort: 'medium', max_tokens 4096): the summary, per-field reasoning, leaning picks, a status (still_deciding → almost_there → ready_to_lock), the banner signals, a venueArea, a silent plan edit where blank means "unchanged" and a separate clear list for fields the crew dropped, an optional chatMessage, an optional widget to create or populate, awaitingReply, every clock time floated in the chat (timeSuggestions), memoryUpdates, and proactive venueSearches.

What it writes. The ai block and any plan edits (with aiUpdating set for the duration); venueArea when the model names one and nothing was set (a member's manual correction always wins); suggestedTimes, appended transactionally as options to an existing time poll; a chat message only when the address state permits it, length-clamped, with aiSpeaking set while it composes; a widget — or, when a singleton of that kind already exists anywhere in the chat (found by index, not by scanning the transcript window), its options appended to that card; memory notes for entitled members only, capped at 20 per user (planMemoryUpdates re-validates that each target is both entitled and a member); and the proactive venue suggestions — at most 2 searches per run, 1–3 places each, only onto a food or activity poll with 2 or fewer options, each term remembered in aiSearched so it isn't re-searched. aiAwaitingReply is set when it asked the crew something, so the next message reaches it as if addressed.

What it never does. It never locks the plan, never posts unprompted (a completed poll updates the plan silently), never narrates its own edits, and never sees a memory id, a street address, or an un-shared home area.

Failure handling. summarize() returns null only when no ANTHROPIC_API_KEY is configured — the pipeline then no-ops and the app runs without an assistant. A deterministic failure (a 4xx from the API, no text block, unparseable JSON) is thrown with a summarize: prefix; runSummary clears pendingMessages first so the sweep doesn't re-bill the same doomed call every five minutes, then rethrows (the manual path surfaces it to the organizer). Transient failures (429, 5xx, transport) leave the counter set so the sweep retries. The flags aiUpdating / aiSpeaking are always cleared.

Telemetry. Every run — including failed ones whose token spend is real — lands as hangouts/{id}/aiRuns/{runId} (aiRunRecord: trigger, address state, model, tokens, latency, stop reason, whether it spoke, the widget it made, the plan keys it edited, the venue searches it ran) and bumps the monotonic counters in adminStats/llm and its daily rollup. Recording is best-effort and never fails a run.

Widgets (cards) ​

A widget is a card on a message. The kinds:

KindWhat it decidesSingleton
dateSurveyNamed weekdaysyes
dateCalendarSpecific dates picked from a month grid; the calendar is the ballot and options mirrors the dates that currently hold a vote; multi-day by defaultyes
timeSurveyClock times; seeded by the assistant's timeSuggestionsyes
foodVenue / activityVenueWhere to eat / where to do the thing, Places-backedyes each
venueSuggestionThe legacy combined venue poll, still rendered for old chatsyes
activityVoteActivity typesno
pollAny multiple-choice voteno
questionOne to eight yes/no questionsno
readyCheck"I'm in" — but readiness itself lives on member docs (ready), not on the cardyes

Singletons exist at most once per chat (SINGLETON_KINDS); the tool sheet disables their entries once dropped, and the assistant appends to the existing one rather than creating a duplicate. Choice widgets are single-choice (votes: uid → key) or multiple-choice (multiSel: uid → keys); switching migrates every member's response and therefore runs through editCardStructure, as does editing a question list. Options carry a by uid when a member added them inline (only the author gets the remove control), and Places-picked options carry placeId, rating, ratingCount, mapsUri, lat, lng. A vote for a removed option is orphaned: it stays stored but is excluded from every tally and completion check on both client and server.

Every card write on the client is a read-modify-write transaction on the message (updateCard in data.ts), chained one at a time per message so a burst of taps doesn't contend, and each tap paints immediately through an optimistic overlay in useChatController that hands back to the live card once the echo satisfies the tap's own settled predicate (or a safety timeout, or the write fails). Reactions are field-path updates on the caller's own key and need no overlay.

Venues and maps ​

functions/src/places.ts proxies Google Places so the key never reaches a browser. searchVenues is Text Search (6 results, paged with nextPageToken, the area folded into the query text because Text Search offers no coordinate bias); venueDetails fetches the pricier fields (hours, price, phone, website, editorial summary, photos) only when someone opens the details drawer; suggestRestaurants / suggestActivities run a search biased on the crew's aggregated cuisines / interests in the crew's area; geocodeArea coarsely geocodes a free-text home area for the map's opt-in origin bubbles. Photos are resolved with skipHttpRedirect=true into keyless googleusercontent URLs, and the one photo a digest email shows per venue is cached in placePhotos/{placeId} for 30 days. Without a Places key the search callable reports disabled and every venue input in the session falls back to free-text "add a place"; the assistant's proactive searches simply add nothing.

The interactive maps are drawn client-side by the Maps JavaScript SDK using the ordinary browser key (VITE_FIREBASE_API_KEY); with no real key the SDK doesn't load and the map views show a "switch to List" notice. src/venueMap.tsx flattens every venue-poll option that carries coordinates into pins tagged with their standing (leading in its poll, the viewer's pick), and the chat surfaces add live search-result pins on top, published up from the search fields through refs and kept until the field remounts. On wide screens the map is a persistent pane whose detail dock votes on an option or adds a searched place (or a clicked base-map POI) to the food or activity poll; on phones the same panel is a full-screen sheet with a vaul drawer for details.

The search area. hangouts.venueArea is the coarse city / neighborhood every venue search is biased to. It is derived server-side by deriveGroupAreaFor — the locality the most members share, ties broken toward the least-mobile member's area, then the organizer's — from the homes of members who turned on Show my area on group maps, and only those; coarseArea strips any comma-segment carrying a digit first, so a street address never enters a prompt or a member-visible field. It is seeded when a venue widget is dropped, served by groupPlanContext and the suggest callables, read by the assistant (which keeps a set value unless the chat names a new area), and correctable by any member from the widget's "Searching near … · Change" row. The same opt-in is what publishes a member's originLat / originLng / originLabel (rounded to roughly 1 km) onto their member docs for the map's "coming from" bubbles, and what lets their near: area appear in the prompt.

Push notifications ​

Web push over Firebase Cloud Messaging, as a per-device choice (Profile → App behavior → Notifications), not a profile setting. src/push.ts asks permission from a user gesture, mints the FCM token against the app's own service worker, stores it at users/{uid}/fcmTokens/{token}, re-asserts it on every launch (tokens rotate), and detaches it on sign-out. functions/src/push.ts fans a notification out to every token the target members hold, in multicast batches of 500, and deletes the tokens FCM reports as permanently dead. Notifications share a per-hangout tag so a chatty thread updates one lock-screen entry; the lock-in gets its own tag so the next chat push can't replace it.

Senders: a new chat message (everyone but the sender, pushPayloadForMessage), the organizer locking the plan (everyone but the organizer, deep-linked to the Confirmed screen), a friend request and its acceptance, and an in-app hangout invite. Send a test notification on the Profile pings the caller's own devices through the real pipeline; because FCM displays nothing while the app is focused, the page itself shows messages flagged test: '1'. There is no FCM emulator: pushSupported() is false in dev builds, and the server sender no-ops under the emulator so a local run can't buzz real devices. The NotifNudge card in the chat is the funnel: it appears after the reader's own first message, pitching Home-Screen installation on mobile browsers (iOS can't push to a tab) or the enable prompt where push already works.

Email digest and voting from the inbox ​

digestSweep runs daily: one range query on hangouts.updatedAt finds everything that changed in the window (DIGEST_WINDOW_HOURS, default 24), each hangout's digest line is computed once (digestItemForHangout; message counts are aggregate queries) and shared by all its recipients, and each opted-in member with an email on their Auth record — never the client-writable profile mirror — gets one email (renderDigestEmail, inline-styled because email clients have no stylesheet) unless digests/{uid}.lastDigestAt is inside the send gap (DIGEST_MIN_GAP_HOURS, default 20). The operator tunes it live through adminConfig/digest (kill switch, weekday cadence, window and gap, a per-email hangout cap, subject and intro templates), which resolveDigestConfig re-clamps on every run so a bad write can change behavior but never crash the sweep; the outcome of each real run lands in adminStats/digest. runDigest fires the same sweep by hand — a dry run with per-recipient previews by default, { send: true } to send, force to ignore the gap — and is the only way to fire it locally.

Each email renders the recipient's unanswered widgets (up to two per hangout, newest first; venue options as rich rows with address, rating, maps link and a cached photo) as links carrying a signed token: uid + hangout + message + choice + a 14-day expiry, HMAC'd with a key minted once into internal/emailActionKey. The emailAction endpoint verifies the token; a GET only renders a confirm page (mail scanners prefetch links), and only the confirm button's POST applies the vote — the same pure mutation as an in-app tap, except that a multiple-choice click adds rather than toggles so a repeat request can't retract a vote. The token is the authentication, so no sign-in is needed on that device, and the write cascades through onMessageUpdated like any vote. The service worker's navigation fallback denylists /email/* so the app shell never swallows these links.

Friends and in-app invites ​

An explicit friend graph, written only by functions/src/friends.ts: each edge is a doc on both sides (users/{a}/friends/{b} and users/{b}/friends/{a}, status out / in / friends), written in one transaction so the mirrors can never disagree. sendFriendRequest creates the pair or, if the target already asked, accepts (which is also how simultaneous mutual requests resolve); respondFriendRequest accepts (both sides → friends, requester notified) or declines (pair deleted, nobody told); removeFriend severs an edge in any state, quietly. inviteFriends lets any member of a hangout invite accepted friends only: each target gets an inbox doc (users/{to}/invites/{hangoutId}, carrying title and host because the recipient can't read the hangout yet) plus a member-visible marker (hangouts/{id}/invites/{to}), then one push. respondInvite re-checks bannedIds and joins through the same addMemberToHangout transaction as the invite-code path, or deletes the pair on decline. The home screen's For you inbox and the Profile's Friends tab are pure listeners over these docs; "people you've hung out with" is a one-shot read of co-members from shared hangouts.

The admin panel ​

Access is the admin: true custom claim, set only through scripts/grantAdmin.mjs (Admin SDK; --emulator for local, service-account credentials for production). The seed grants it to admin@wavelength.test. The rules read the claim from the token, so nothing a client writes can grant access, and admin access is read-only over user data except adminReviews (flag, note, resolved) and adminConfig. The panel is a lazy chunk and the only importer of adminData.ts.

Lifecycle and cleanup ​

A hangout is planning from creation, confirmed once locked, and gone when the organizer deletes it (Invite screen or a swipe on the list) or cleanupSweep removes it. The sweep measures inactivity — updatedAt, which every message, vote, plan edit and assistant run bumps — not a calendar date, because the plan's date is free text: a confirmed hangout goes CONFIRMED_TTL_DAYS (30) after its last activity, anything else STALE_TTL_DAYS (90). Deletion (deleteHangoutFully, shared with deleteHangout) removes the recipient-side invite docs first, then the doc and every subcollection, then the review flag. A member who leaves (leaveHangout) can rejoin with the code; a member the organizer kicks is banned, and either way their votes are stripped from every card so a departed member can't hold a poll at "complete". The same sweep prunes serviceErrors older than 30 days.

Monetization prototype (src/sponsored.ts) ​

Two light-touch placements: contextual links on the itinerary and Confirmed screens (venue → the exact Google Maps URL from the venue-poll option when there is one, sanitized through safeHttpUrl because any member can edit option data, else a name search; activity → a ticket search), with AFFILIATE_TAG appended once a partner program exists; and an inline Sponsored · Planet4.Us Picks card after every SPONSORED_EVERY (25) user messages, rendered client-side only from a house inventory and never written to Firestore. No ad network and no tracking.

Layout tiers and theming ​

useWide.ts defines three matchMedia tiers: wide at ≥ 820 px (an iPad Air's portrait width — chat and itinerary go multi-pane, light task screens present as centered dialogs), wideFull at ≥ 1100 px (room for the expanded widgets column beside thread and map instead of the icon rail), wideDesktop at ≥ 1200 px (the chat toolbar fits its status pills inline; tablets fold them into the ⓘ popover). AppFrame in App.tsx sizes the shell per route: a 480 px column below wide (byte-identical to the original phone design), full-bleed for chat, itinerary and admin, a 520 px dialog for new / invite / join / profile, 960 px for the signed-out gate, 560 px otherwise. useWide is reserved for genuinely structural differences (sheet vs pane, a different component tree); purely presentational differences are media queries in index.css, keyed to the same 820 px.

Colors are the tokens in src/index.css — a light, Mars-inspired skin: terracotta primary (--primary), Martian-dusk blue accent (--accent), warm clay surfaces, espresso ink, plus --*-rgb triplets for translucent washes. Components reference tokens through css(); the only literal hex values in components are the ones that must be — persisted avatar colors, map-marker SVGs, email HTML — and each says so. --primary / --accent are additionally written onto the frame from config.ts, so a group could override the palette.

PWA and service worker ​

vite.config.ts runs vite-plugin-pwa in injectManifest mode with src/sw.ts as the worker: Workbox precaching with skipWaiting + clientsClaim (a new build takes over immediately), a navigation fallback to index.html that denylists /__/* (Firebase's same-origin auth helper) and /email/* (the vote endpoint), a cache-first Google Fonts cache, and Firebase Messaging's background push / notificationclick handling — all in one worker because FCM's default separate worker would fight the PWA worker for the scope. The manifest names the app "Planet4.Us" (the OS shows that name on every notification), allows any orientation (installed tablets want landscape for the wide layout), and ships maskable icons. index.html bounces the raw *.web.app / *.firebaseapp.com hosts to planet4.us before the app boots, leaving preview channels and localhost alone, and sets interactive-widget=resizes-content so Chromium keeps the composer above the keyboard.

Configuration ​

Client (.env.local for dev, .env.production.local for vite build; only VITE_-prefixed vars reach the bundle): VITE_FIREBASE_API_KEY, VITE_FIREBASE_AUTH_DOMAIN (localhost only), VITE_FIREBASE_PROJECT_ID, VITE_FIREBASE_STORAGE_BUCKET, VITE_FIREBASE_MESSAGING_SENDER_ID, VITE_FIREBASE_APP_ID, VITE_FIREBASE_VAPID_KEY (web push, deployed builds only), VITE_USE_EMULATORS (dev builds only), VITE_PUBLIC_URL (the canonical origin baked into share links, default https://planet4.us). None of these is a secret; with no env file at all the client targets the local demo-wavelength project.

Server secrets (Secret Manager in production, functions/.secret.local under the emulator): ANTHROPIC_API_KEY (the assistant), GOOGLE_PLACES_API_KEY (venue search, details, suggestions, geocoding, digest photos — a key with Places API (New) and no referrer restriction, separate from the browser key), RESEND_API_KEY (email). Each feature disables itself cleanly without its key.

Server env vars (functions/.env): ANTHROPIC_MODEL (default claude-sonnet-5), SUMMARY_INTERVAL_MIN (12), SUMMARY_LISTEN_WINDOW (5), CONFIRMED_TTL_DAYS (30), STALE_TTL_DAYS (90), DIGEST_WINDOW_HOURS (24), DIGEST_MIN_GAP_HOURS (20), EMAIL_FROM (default Resend's shared test sender, which delivers only to the account owner), APP_ORIGIN (push and email links when served from a custom domain), ALLOW_STUB_UPGRADE (dev projects only; the emulator qualifies automatically). The root .env* files never reach Cloud Functions.

Live operator config: adminConfig/digest from the panel's Digest tab overrides the digest env defaults from the next run, no deploy needed.

Local development ​

npm run serve builds the functions and starts the emulator suite under the project id everything else uses (scripts/localProject.mjs reads VITE_FIREBASE_PROJECT_ID from the env files Vite would, defaulting to demo-wavelength): Auth on 9099, Firestore on 8080, Functions on 5001, Hosting on 5000, the Emulator UI on 4000. npm run seed (scripts/seed.mjs, Admin SDK pointed at the emulators) is idempotent and writes the demo crew (Maya, Devon, Priya, Ada the admin) with filled-in profiles, the friend graph and a pending invite, and four hangouts in different stages — a clean slate, one mid-planning with history and partial votes, one confirmed with todos, and a solo one with an outstanding invite. npm run dev is Vite on 5173; in a dev build the emulators are on by default and the name gate shows the dev sign-in panel. The Auth emulator prints email links to the serve terminal and fakes the Google picker; schedules need the pub/sub emulator, and push and email are inert (logged, not sent).

Tests ​

  • npm test — functions/src/logic.test.ts and group.test.ts: the pure server policy, fixtures in and values out, no network and no firebase-admin. This is where the subtle logic lives — address states, tallies, poll completion, option keys, venue-search planning, memory-note trust boundaries, push and digest content, emailed-action tokens, structural card edits, the location-consent gate, area coarsening.
  • npm run test:rules — tests/firestore-rules.test.ts under firebase emulators:exec against a throwaway project: every rule scenario above, written as the threat it closes (sender spoofing, vote forgery in both its one-write and two-write forms, prompt-cost amplifiers, TTL evasion, admin least privilege, functions-only collections).
  • npm run build / npm run build:functions — the two typechecks, and npm run lint.
  • There is no CI configured; these run locally before a push. End-to-end checks are driven by hand against the seeded emulator app with Playwright — .claude/skills/verify/SKILL.md records the launch and drive steps.

Commands ​

bash
npm install            # root deps; a postinstall installs functions/ deps too
npm run serve          # build functions + Firebase emulators (Auth, Firestore, Functions, Hosting, UI)
npm run seed           # demo crew + hangouts into the emulators (idempotent)
npm run dev            # Vite dev server on :5173, emulators on by default
npm run build          # tsc + vite build → dist/
npm run build:functions
npm run lint           # eslint src functions/src tests
npm test               # functions unit tests (vitest)
npm run test:rules     # firestore.rules against the emulator
npm run docs:dev       # the docs site with live reload
npm run docs:build     # → docs/.vitepress/dist
npm run deploy:prod    # firebase deploy — hosting (both sites), functions, rules, indexes
npm run deploy:docs    # the docs site alone
node scripts/grantAdmin.mjs [--emulator] [--revoke] <email-or-uid>

The docs site ​

docs/ is a VitePress site published at docs.planet4.us — the Hosting site wavelength-docs, also at wavelength-docs.web.app, built by a predeploy hook so firebase deploy ships it with the app. The Guide (docs/guide/*.md) is the user manual, written against the screens as built, with screenshots in docs/public/shots/ captured from the seeded emulator app at phone (390 px, 2×) and desktop (1280 px) widths. The Developers section (docs/developers/*.md) is this repository's own documents — README.md, this file, DECISIONS.md, BACKLOG.md, CLAUDE.md — each page a thin wrapper that includes the file from the repository root as it stands, so the site can never say something the repository doesn't. The sidebar and site settings are docs/.vitepress/config.ts; the Planet4.Us skin (tokens mirrored from src/index.css) is docs/.vitepress/theme/custom.css. docs/ABOUT.md is an older internal product overview, excluded from the site.

Project layout ​

README.md, ARCHITECTURE.md, DECISIONS.md, BACKLOG.md, CLAUDE.md   the documents (also under docs → Developers)
firebase.json, .firebaserc                     hosting (two sites), functions, emulator ports
firestore.rules, firestore.indexes.json        the authorization model + the one composite index
vite.config.ts, index.html, tsconfig.json      the client build and PWA manifest
eslint.config.js                               lint for src, functions/src, tests
package.json                                   scripts (see Commands); functions/ is its own package
src/                                           the client (see "Client modules")
  screens/                                     one file per screen; chat/ and admin/ subtrees
functions/src/                                 Cloud Functions (see "Cloud Functions")
tests/firestore-rules.test.ts                  security-rules tests
scripts/                                       emulators.mjs, seed.mjs, grantAdmin.mjs, localProject.mjs
docs/                                          VitePress: guide/, developers/, .vitepress/, public/
public/                                        icons, landing photos, the email logo
design/                                        the original Roundup design bundle (prototypes + brief), reference only
.claude/skills/verify/                         how to launch and drive the app end-to-end