Most "trip-planning" software collapses two genuinely different jobs into one product. The job of planning together with a group is not the same job as going on the trip and remembering it after. Different audience, different cadence, different data shapes, different lifespan.
This pattern keeps them as two apps, lets them share an engineering method
(see METHOD.md) and a substrate (see ARCHITECTURE.md), and connects
them with a one-way data handoff at a specific moment in the trip arc.
The reference implementation is two sister projects from the same author — a multi-household trip-planning portal and an offline-first field-companion app. Both run today behind Cloudflare Access. This doc captures the concept so you can build your own version.
| Planning-side app | Execution-side app | |
|---|---|---|
| Audience | The whole group · N households · many participants | The intimate party · the actual travelers |
| Mode | Deciding | Doing + remembering |
| Data shape | Votes · RSVPs · picks · proposals | Journal · check-ins · lounges visited · weather observed |
| Cadence | Slow burn over months · async voting | Real-time during the trip · burst-write |
| Lifespan | Retires when the trip starts (the plan is done) | Becomes the lasting record after the trip ends |
| Network assumption | Always online · everyone votes from home | Often offline · alpine valleys, bad signal |
| Auth | Allowlist of all participating households | Just the travelers themselves |
These differences are real. Building one app to cover both ends up with a schema bloated with optional columns, a UI cluttered with mode toggles, and an offline strategy that has to handle voting (which doesn't really need offline) at the same priority as journal entry (which absolutely does).
Two apps, one method, one shared substrate is the cleaner answer.
- The same engineering method (see
METHOD.md) — Maxims, memory ritual, plan-first, mode-of-tuples voting awareness, canonical-data discipline. The Sidebar engineer agent definition works for both projects without modification. - The same Cloudflare Pages + D1 + Functions architecture
(see
ARCHITECTURE.md). - The same canonical-data primitives —
_shared/modules with trip-related identifiers (city catalog, hotel directory) live in a shared library both apps consume. - The same identity layer — Cloudflare Access policy covers both domains; one sign-in works for both. Optionally, the planning audience is a superset of the execution audience.
- The same visual language — typographic palette, accent palette, pacing. Distinct accent colors so they're recognizably siblings, not duplicates.
- Database — separate D1 instances. the planning-side app's vote data has nothing to do with the execution-side app's journal entries. Joining them at the storage layer would be over-coupling.
- Domain — separate Pages projects on separate hostnames.
- Deploy cadence — the planning-side app gets quiet between trips; the execution-side app gets daily writes during the trip itself.
When a planning cycle finalizes (typically T-30 days from departure, or whenever the family declares "we're done deciding"), the planning-side app produces a frozen JSON itinerary and the execution-side app imports it as the spine of its journal.
the planning-side app (planning)
│
│ /api/trip-export?trip_id=...
│ ─────────────────────────►
│ { trip: { id, name, dates, cities[], lodging[], dinners[], … } }
│
▼
the execution-side app (execution)
│ /api/trip-import (POST)
│ ─────────────────────────►
│ creates trip row, scopes incoming data with new trip_id
Critical properties:
- One-way only. the execution-side app doesn't write back to the planning-side app. The plan is frozen at handoff; subsequent reality (delays, swaps, "we ended up going to Y instead") lives only in the execution-side app.
- Idempotent. Re-importing the same export updates the existing trip row rather than creating a duplicate. Useful when the plan gets revised post-handoff.
- Authenticated via shared identity. The export endpoint trusts the Cloudflare Access JWT on the import side; no separate API key.
- Append-only at the receiver. Existing journal entries are preserved; the import only seeds new entities (cities, lodging slots, dinners) that the journal can hang from.
The handoff is a single moment, not a continuous sync. Continuous sync would entangle the two apps and defeat the separation.
If the execution-side app (the execution-side app) is already in active use for one trip when the planning-side app (the planning-side app) is mid-cycle on the next trip, the two trip's data has to coexist in the same database without clobbering. This is the most subtle part of the pattern.
The fix: make trip identity a first-class dimension of the execution app's schema from day one.
CREATE TABLE trips (
id TEXT PRIMARY KEY, -- 'alps2026' | 'bavaria2027' | ...
name TEXT NOT NULL, -- 'Alpine Adventure 2026'
start_date TEXT NOT NULL, -- 'YYYY-MM-DD'
end_date TEXT NOT NULL,
status TEXT NOT NULL, -- 'active' | 'upcoming' | 'past'
imported_from_url TEXT, -- nullable · the planning-app export URL
imported_at INTEGER, -- nullable · when import landed
created_at INTEGER NOT NULL
);
-- And every existing data table gains:
ALTER TABLE journal_entries ADD COLUMN trip_id TEXT NOT NULL DEFAULT 'current';
ALTER TABLE lounge_decisions ADD COLUMN trip_id TEXT NOT NULL DEFAULT 'current';
-- ... and so on for every per-trip-scoped tableAll queries gain a WHERE trip_id = ? clause. A single
getActiveTripId() helper provides the value, defaulting to whichever
trip is flagged status='active'.
Existing data backfills to the current trip's ID
(UPDATE journal_entries SET trip_id = 'alps2026'). Going forward, every
new row gets the active trip's ID. When the planning-side app hands off the next
trip, the import handler creates a new trips row and scopes the
incoming data to that ID.
The UI gets a small trip indicator at the top — text-only is fine for a
two-trip case, with a sidebar selector emerging if you accumulate
several. Status transitions automate cleanly: at midnight on
start_date, the imported trip flips from upcoming to active; at
midnight after end_date, from active to past.
The deconfliction migration is cheap when the execution app has
hundreds of rows; it gets expensive at thousands. More importantly, the
migration introduces a hidden risk every time it's deferred: any
not-yet-written query that doesn't include trip_id would later silently
leak data across trips when the second trip's data lands.
Ship the multi-trip schema as soon as you commit to the two-phase pattern, even if you only have one trip in flight today. The cost is ~1 migration + ~30 minutes of query updates. The benefit is that the handoff itself becomes "INSERT scoped rows," not "refactor schema while data is live."
Two apps that share a method benefit from a small umbrella story. Possible framings:
- Brand-led: "The Alps Suite" or whatever umbrella matches your domain. Both apps are products under it. This works if you ever want to productize.
- Method-led: the two apps are named after their phase, the method (Sidebar) is what unites them. The OSS bundle is Sidebar; the apps are Sidebar demonstrations. This is the framing in the rest of this bundle.
- Tag-team: name them complements (the planning-side app · the execution-side app in the reference implementation — the "elf" pre-trip, the "tage" daytime). Cross-link in footers. No formal umbrella; the connection is cultural.
We use the third for the planning-side app + the execution-side app. Your call depends on whether you ever expect either app to be productized — if yes, brand-lead from the start.
Pick the depth that matches your runway:
- Footer chip on each site pointing at the sibling
- One-paragraph "the suite" blurb on each landing page
- Same Cloudflare Access policy means a signed-in user can hop without re-auth
This is the absolute minimum and it's a worthwhile minimum. Stops here for many real cases.
- Single repo with both apps under it (
apps/planning/,apps/execution/) OR two repos with a shared library pulled in via git submodule / npm - Shared
_shared/library: canonical data, helpers, the Sidebar primitives - Each app keeps its own
wrangler.jsonc, its own D1, its own deploy - The OSS-method bundle (this directory) becomes "the method that produced both"
Tier 2 makes the OSS narrative substantially more credible — two working sibling apps demonstrating the same patterns is more compelling than one.
- Multi-trip schema in the execution app (deconfliction migration)
/api/trip-exporton the planning-side app · idempotent JSON export/api/trip-importon the execution app · idempotent receiver- UI: a "send to " affordance on the planning side at the finalization moment
- Optional: a sync-back of "actual outcome" metadata from execution to planning post-trip, never during
Tier 3 is the deepest version of the pattern and where the genuine "two apps, one journey" story comes alive. Recommend only if both apps are durably built and the handoff is a real recurring need, not a hypothetical.
- You have one app and one trip and you're done. The pattern is designed for repeated trip cycles with a stable group. A one-time trip doesn't justify the architecture.
- The "planning" and "execution" audiences are identical. If only the two travelers vote on their own trip, there's no group-coordination job; one app is correct.
- You don't expect to do this again. Two apps is more infrastructure to maintain. If this is a one-shot, just build one.
- Planning-side app · ~6,800-line single-HTML SPA + Pages Functions. Multi-household voting · RSVPs · picks · proposals · encrypted notes · weekly digest cron · phased per-household departure briefings via Cloudflare Email Routing → Worker (forwarded confirmation emails parsed into the database) + Resend Send (briefings out).
- Execution-side app · journal-shape with chapter zones, lounge tracking, weather observations, photo gallery. Designed for offline use in cell-dead alpine valleys.
- This OSS bundle was extracted from both, intended to be forkable as a starting point for similar two-phase trip suites.