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
| Piece | Choice |
|---|---|
| Build | Vite 5, React 18, TypeScript 5 (strict), vite-plugin-pwa in injectManifest mode with a hand-written service worker |
| UI | Inline 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 |
| Routing | react-router-dom 7 with HashRouter — every screen is a /#/… URL |
| Backend | Firebase 12: Auth (anonymous by default; Google and email-link upgrades), Firestore, Cloud Functions v2 on Node 20, Cloud Messaging (web push), Hosting (two sites) |
| Assistant | Anthropic Claude through @anthropic-ai/sdk, structured JSON-schema output; default model claude-sonnet-5, overridable with ANTHROPIC_MODEL |
| Venues | Google Places API (New) server-side (Text Search, Place Details, Place Photos); the Maps JS SDK client-side |
| Resend, called through its plain HTTPS API | |
| Tests | Vitest 4 for the pure server logic; @firebase/rules-unit-testing 5 for the security rules, against the Firestore emulator |
| Lint | ESLint 10 flat config, typescript-eslint, eslint-plugin-react-hooks; covers src, functions/src, tests |
| Docs | VitePress 1 (docs/), published as a second Hosting site — see "The docs site" |
| Project | Firebase 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 boundaryOn 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/)
| Module | Holds |
|---|---|
main.tsx | Entry: mounts App in StrictMode, imports index.css |
App.tsx | HashRouter, 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.ts | Client 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.ts | The re-skin knobs (--primary / --accent written onto the frame) and ASSISTANT_NAME |
types.ts | Domain 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.ts | The 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.ts | The 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.ts | Per-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.ts | The service worker: Workbox precache + SPA navigation fallback (denylisting /__/* and /email/*), Google Fonts cache, and FCM's background handlers, in one worker |
sponsored.ts | The monetization prototype: the sponsored-card cadence and house inventory, safeHttpUrl, and the contextual booking/map/ticket links |
venueMap.tsx | The 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.tsx | The shared kit: Screen, TopBar, Avatar (photo with initial fallback), BottomSheet, PlanetLoader, the no-access screen, submitOnEnter (IME-safe), one-time-tip storage |
style.ts | css() — CSS declaration string → React style object — and merge() |
useWide.ts | The three viewport tiers as matchMedia hooks (useWide, useWideFull, useWideDesktop) |
index.css | The 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/)
| Route | Screen | What it is |
|---|---|---|
| (gate) | NameGate | The 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) | ConfirmEmail | Finishes an email magic link opened on a device that didn't request it |
/ | HangoutsList | Home: 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 |
/new | NewHangout | Name the hangout; creates the doc and the organizer's own member doc client-side |
/h/:id | redirect | → /h/:id/chat |
/h/:id/chat | Chat | The primary surface — see below |
/h/:id/itinerary | Itinerary | The 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/invite | OrgInvite | Share link and code, in-app friend invites, member management (kick), the danger zone (delete) |
/h/:id/confirmed | Confirmed | The locked plan, with map / booking links |
/h/:id/join, /invite | InviteeJoin | Invite landing: previews the hangout by code, joins through joinHangout, drops into the chat |
/profile | Profile | Settings 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/AdminRoutes | The operator panel, lazy-loaded behind RequireAdmin |
/privacy | PrivacyPolicy | Static plain-language policy, reachable signed out |
| — | QuestionComposer | The 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
memberIdsand its member docs are written byjoinHangout/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 likependingMessagescan't arrive on a create); it may never delete one. - Members read only their own hangouts. The organizer may update
plan(andupdatedAt, pinned to the server clock); any member may correctvenueArea(bounded); everything else on the doc — status,memberIds, theaidraft, the queue counters — is server-owned. - Messages are signed. A client creates only
type: 'user'messages, withsenderNameandavatarColorpinned to its own member doc,createdAtpinned 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 thevotes/multiSel/ per-questionanswersdiffs 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 inreactions, 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 —photoUrlon Google's CDN,availability,vibePref,ready, the origin fields); role, status andavatarColorare 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 nomatchblock 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,adminReviewsandadminConfig, 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).
| Module | Holds |
|---|---|
index.ts | The callables below, the Firestore triggers, the three schedules, runSummary (the assistant pipeline), the digest sweep, hangout cleanup, and the telemetry writers |
anthropic.ts | The model call: SYSTEM prompt, the output SCHEMA, userPrompt() assembly, summarize() with its deterministic-vs-transient error split, ASSISTANT_NAME, MODEL |
logic.ts | Pure 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.ts | The crew's aggregated planning context from private profiles: coarseArea, deriveGroupArea, deriveGroupAreaFor (the one location-consent gate), cuisine and interest aggregation, groupTravel |
places.ts | The Google Places proxy: search, details, coarse geocoding, restaurant and activity suggestions, keyless photo resolution, the abuse guard |
membership.ts | addMemberToHangout (the one join transaction and "👋 joined" notice) and deleteHangoutFully |
friends.ts | The friend graph and in-app hangout invites |
push.ts | FCM fan-out to a member set with dead-token pruning; appOrigin() |
email.ts | One sendEmail() over Resend's HTTPS API; emulator logs instead of sending |
emailActions.ts | The emailAction HTTP endpoint (vote from the inbox) and the HMAC key |
errlog.ts | recordServiceError — best-effort, never throws |
ratelimit.ts | rateLimit(uid, key, max, windowMs) |
Callables (client → server, httpsCallable in data.ts / adminData.ts):
| Callable | Does | Guard |
|---|---|---|
getHangoutPreview | Title / host / date for an invite code, for the landing screen and pasted-link unfurls | 30/min |
joinHangout | The only path to membership by invite code; refuses bannedIds | 20/min |
lockHangout | Organizer only; claims planning → confirmed in a transaction, posts the "locked in" note, pushes everyone but the organizer | organizer |
kickMember | Organizer removes a member, bans the uid, strips their votes from every card | organizer |
deleteHangout / leaveHangout | Delete 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 pending | member, 4/min |
editCardStructure | The two structural widget edits that rewrite other members' entries: single ↔ multi choice, question-list edits | author or organizer |
groupPlanContext | The crew's shared cuisines, interests, search area and travel constraint, as aggregates only | member |
setMemoryUpgrade | Grant / 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 account | signed in |
sendTestPush | Pings the caller's own devices through the real FCM pipeline | 6/min |
searchVenues / venueDetails / geocodeArea / suggestRestaurants / suggestActivities | The Places proxy (see "Venues and maps") | member of some hangout, 40/min shared |
sendFriendRequest / respondFriendRequest / removeFriend | The friend graph | 15 / 30 / 30 per min |
inviteFriends / respondInvite | In-app hangout invites | 10 / 20 per min |
setDigestOptIn / runDigest | Operator controls for the email digest | admin 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:
- Ambient — a new user message or a vote, at most once per
SUMMARY_INTERVAL_MINminutes per hangout (default 12).summarizeSweepcatches the tail every 5 minutes for hangouts left withpendingMessages > 0. - 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. - Manual — the organizer's ↻ Update the plan from chat now (
regenerateSummary), rate-limited, skipping the model entirely whenpendingMessagesis 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:
| Kind | What it decides | Singleton |
|---|---|---|
dateSurvey | Named weekdays | yes |
dateCalendar | Specific dates picked from a month grid; the calendar is the ballot and options mirrors the dates that currently hold a vote; multi-day by default | yes |
timeSurvey | Clock times; seeded by the assistant's timeSuggestions | yes |
foodVenue / activityVenue | Where to eat / where to do the thing, Places-backed | yes each |
venueSuggestion | The legacy combined venue poll, still rendered for old chats | yes |
activityVote | Activity types | no |
poll | Any multiple-choice vote | no |
question | One to eight yes/no questions | no |
readyCheck | "I'm in" — but readiness itself lives on member docs (ready), not on the card | yes |
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.tsandgroup.test.ts: the pure server policy, fixtures in and values out, no network and nofirebase-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.tsunderfirebase emulators:execagainst 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, andnpm 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.mdrecords the launch and drive steps.
Commands
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