# P2P Poll App — Implementation Plan A simple peer-to-peer polling app where users can add options and vote, built with no central server (beyond a public relay for WebSocket sync). --- ## 1. Project Setup ```bash # Scaffold a Waku project with Bun bunx create-waku@latest . --template basic --package-manager bun # Install dependencies bun add @automerge/automerge @automerge/automerge-repo @automerge/automerge-repo-react-hooks @automerge/automerge-repo-network-websocket @automerge/automerge-repo-network-broadcastchannel @automerge/automerge-repo-storage-indexeddb # Tailwind CSS v4 bun add tailwindcss @tailwindcss/vite ``` Waku uses Vite under the hood, so add `@tailwindcss/vite` to the Vite plugin list in `waku.config.ts` (see file structure below). Import Tailwind in a global CSS file with `@import "tailwindcss"`. --- ## 2. Data Model A single Automerge document represents one poll: ```typescript interface Poll { title: string; options: PollOption[]; } interface PollOption { id: string; // nanoid or crypto.randomUUID() text: string; votes: string[]; // list of peer IDs who voted for this option } ``` - **votes as `string[]`** — storing peer IDs instead of a count lets us prevent double-voting and support vote retracting. The count is derived via `.length`. - Each peer generates a stable random ID on first visit (stored in `localStorage`). --- ## 3. File Structure ``` waku.config.ts — Waku + Vite config (Tailwind plugin) src/ pages/ _layout.tsx — root layout (server component), imports Providers + global CSS index.tsx — landing page: create new poll or join via URL poll/ [id].tsx — main poll view (resolved from Automerge document URL) components/ Providers.tsx — 'use client': initializes Automerge Repo + RepoContext.Provider PollView.tsx — displays poll title, options, votes; contains AddOption form and VoteButtons ConnectionStatus.tsx — shows sync/connection state lib/ repo.ts — creates and exports the Automerge Repo singleton types.ts — Poll and PollOption type definitions peer.ts — get-or-create local peer ID from localStorage poll.ts — poll mutation logic (pure functions) __tests__/ poll.test.ts — tests for poll logic styles/ global.css — Tailwind import ``` **Total: ~12 files** (5 components/pages + 1 config + 4 lib modules + 1 test file + 1 CSS) --- ## 4. Implementation Steps ### Step 1 — Scaffold and configure - Run the setup commands from Section 1. - Create `waku.config.ts`: ```typescript import { defineConfig } from 'waku/config'; import tailwindcss from '@tailwindcss/vite'; export default defineConfig({ vite: { plugins: [tailwindcss()], }, }); ``` - Create `src/styles/global.css` with `@import "tailwindcss"`. - Verify `bun run dev` starts without errors. ### Step 2 — Define types, peer identity, and poll logic - Create `src/lib/types.ts` with the `Poll` and `PollOption` interfaces. - Create `src/lib/peer.ts`: read `localStorage.getItem("peerId")`, or generate one with `crypto.randomUUID()` and persist it. - Create `src/lib/poll.ts`: extract poll mutation logic into pure functions (`createPoll`, `addOption`, `vote`, `unvote`, `hasVoted`) so they are testable independently of Automerge and React. ### Step 3 — Initialize Automerge Repo - Create `src/lib/repo.ts`: ```typescript import { Repo } from "@automerge/automerge-repo"; import { WebSocketClientAdapter } from "@automerge/automerge-repo-network-websocket"; import { BroadcastChannelNetworkAdapter } from "@automerge/automerge-repo-network-broadcastchannel"; import { IndexedDBStorageAdapter } from "@automerge/automerge-repo-storage-indexeddb"; export const repo = new Repo({ network: [ new WebSocketClientAdapter("wss://sync.automerge.org"), new BroadcastChannelNetworkAdapter(), ], storage: new IndexedDBStorageAdapter("p2p-poll"), }); ``` > **Sync server note:** `wss://sync.automerge.org` is a free public relay provided by the Automerge team. For production use or better reliability, you can run your own sync server using `@automerge/automerge-repo-network-websocket`'s server adapter with a simple Node/Bun WebSocket server. ### Step 4 — Providers (client component) and root layout (server component) Waku layouts are **server components** — they cannot use browser APIs or React context directly. Instead, create a client component wrapper: - Create `src/components/Providers.tsx`: ```typescript 'use client'; import { RepoContext } from "@automerge/automerge-repo-react-hooks"; import { repo } from "../lib/repo"; export const Providers = ({ children }: { children: React.ReactNode }) => { return ( {children} ); }; ``` - Create `src/pages/_layout.tsx` (server component — no `'use client'`): ```typescript import '../styles/global.css'; import { Providers } from '../components/Providers'; export default async function RootLayout({ children }: { children: React.ReactNode }) { return ( {children} ); } ``` ### Step 5 — Landing page (create / join) - `src/pages/index.tsx` (`'use client'`): - "Create Poll" button: calls `repo.create()` to make a new Automerge doc, initializes it with a default title and empty options array, then navigates to `/poll/`. - "Join Poll" input: paste a document ID or full URL, navigate to `/poll/`. - Show a list of recently visited polls from `localStorage` for convenience. ### Step 6 — Poll view page - `src/pages/poll/[id].tsx` (`'use client'`): - Read `id` from the route segment prop (Waku passes dynamic segments as props automatically). - Reconstruct the Automerge `DocumentUrl` from `id`. - Use `useDocument(url)` from `@automerge/automerge-repo-react-hooks` to get `[doc, changeDoc]`. - Wrap the document-dependent UI in `Loading poll...}>` — `useDocument` may suspend while the document is being fetched/synced. - Render `` and ``. ### Step 7 — PollView component - `src/components/PollView.tsx` (`'use client'`): - Display poll title (editable inline). - List each option with its text, vote count (`option.votes.length`), and a Vote/Unvote button. - Vote button logic: ```typescript changeDoc(d => { const opt = d.options.find(o => o.id === optionId); if (!opt.votes.includes(peerId)) { opt.votes.push(peerId); } }); ``` - Unvote: filter out the peer ID. - "Add Option" form at the bottom: text input + submit button that pushes a new `PollOption` into `doc.options`. - Show a shareable link/document ID so users can invite peers. ### Step 8 — ConnectionStatus component - `src/components/ConnectionStatus.tsx` (`'use client'`): - Simple indicator (green dot / grey dot) based on whether the WebSocket adapter is connected. - Can use `repo.networkSubsystem.on("peer", ...)` to track connected peers. ### Step 9 — Polish and test - Style with Tailwind: center layout, card-style poll, colored vote bars. - Test with two browser tabs (BroadcastChannel sync). - Test with two devices on the same network pointing at the sync server. - Handle loading state (doc is `undefined` until synced — use `` or a null check). - Handle invalid/missing document IDs gracefully. --- ## 5. UI Components Summary | Component | Responsibility | |---|---| | `_layout.tsx` | Server-component root layout, imports Providers + global styles | | `Providers.tsx` | Client component: Repo initialization, RepoContext provider | | `index.tsx` | Create new poll, join existing poll by ID | | `[id].tsx` | Load Automerge doc by URL, wrap in Suspense, pass to PollView | | `PollView.tsx` | Render poll title, options list, vote buttons, add-option form, share link | | `ConnectionStatus.tsx` | Show online/offline and peer count | --- ## 6. P2P Setup Details **How peers connect:** 1. Creator makes a new poll — Automerge generates a unique document URL (e.g., `automerge:2Xp9Q3...`). 2. The app extracts the document ID and puts it in the page URL: `https://localhost:3000/poll/2Xp9Q3...`. 3. Creator shares that URL with others (copy button in UI). 4. When another peer opens the URL, the app reconstructs the Automerge document URL and calls `useDocument(url)` — Automerge syncs the doc via the WebSocket relay automatically. 5. Cross-tab sync happens instantly via BroadcastChannel (no network needed). 6. IndexedDB ensures the poll persists across page reloads. **No server to deploy** — the public `wss://sync.automerge.org` relay handles document sync. For production, consider running your own sync server for reliability and control. --- ## 8. Tests Tests use **Bun's built-in test runner** (`bun test`) — no extra test dependencies needed. Bun provides `describe`, `it`, and `expect` out of the box. Tests target the pure functions in `src/lib/poll.ts`, not React components. Test file: `src/lib/__tests__/poll.test.ts`. **Pure functions under test:** - `createPoll(title: string): Poll` - `addOption(poll: Poll, text: string): Poll` - `vote(poll: Poll, optionId: string, peerId: string): Poll` - `unvote(poll: Poll, optionId: string, peerId: string): Poll` - `hasVoted(poll: Poll, optionId: string, peerId: string): boolean` **Test cases (6 total):** - **Peer ID management** — `getPeerId()` generates a UUID and returns the same value on subsequent calls (persisted in localStorage) - **Poll creation** — `createPoll("Lunch?")` returns a poll with the given title and an empty options array - **Adding an option** — `addOption(poll, "Pizza")` appends an option with the given text, a generated ID, and an empty votes array - **Voting** — `vote(poll, optionId, peerId)` adds the peer ID to that option's votes array - **Double-vote prevention** — calling `vote` twice with the same peer ID and option does not duplicate the entry - **Vote retracting** — `unvote(poll, optionId, peerId)` removes the peer ID from the votes array > Run with: `bun test` --- ## 9. Nice-to-Haves (if time permits) - **Poll title editing** — inline editable title field (trivial with `changeDoc`). - **Vote retracting** — toggle vote on/off (already supported by the data model). - **Peer count display** — show how many peers are currently connected to this document. - **Option reordering** — drag to reorder or sort by vote count. - **Results visualization** — simple bar chart showing vote distribution using Tailwind width utilities. - **Multiple polls** — list/manage multiple polls from the landing page. - **Poll closing** — creator can lock a poll to prevent further votes.