diff --git a/PLAN.md b/PLAN.md deleted file mode 100644 index 07a8d7f..0000000 --- a/PLAN.md +++ /dev/null @@ -1,267 +0,0 @@ -# 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. diff --git a/README.md b/README.md index 0217c70..4a90c77 100644 --- a/README.md +++ b/README.md @@ -1 +1,91 @@ -# P2P Poll App \ No newline at end of file +# P2P Poll App + +A peer-to-peer polling application where users can create polls, add options, and vote — all without a central server. Built for the [Evocracy democratic coding research experiment](https://demcode.evocracy.org/). + +## How It Works + +Data is synchronized directly between peers using [Automerge](https://automerge.org/) CRDTs (Conflict-free Replicated Data Types). There is no backend database — every client holds a full copy of the poll document and changes merge automatically, even when made offline or concurrently. + +**Sync layers:** + +- **WebSocket** (`wss://sync.automerge.org`) — cross-device sync via a public relay +- **BroadcastChannel** — instant cross-tab sync within the same browser +- **IndexedDB** — local persistence across page reloads and offline use + +## Requirements + +- [Bun](https://bun.sh/) (standalone runtime, no Node.js needed) + +## Getting Started + +```bash +bun install # Install dependencies +bun run dev # Start dev server (http://localhost:3000) +``` + +## Usage + +1. **Create a poll** — Enter a title on the home page and click "Create" +2. **Share it** — Copy the shareable link and send it to others +3. **Vote** — Click an option to vote; click again to unvote +4. **Add options** — Anyone with the link can add new poll options + +Each browser gets a stable peer ID (stored in localStorage) so votes are tracked per-device and double-voting is prevented. + +## Tech Stack + +| Layer | Technology | +|---|---| +| Runtime | [Bun](https://bun.sh/) | +| Framework | [Waku](https://waku.gg/) (React Server Components) | +| Styling | Tailwind CSS v4 | +| P2P sync | Automerge + automerge-repo | +| Storage | IndexedDB (client-side) | + +## Project Structure + +``` +src/ +├── pages/ # Waku page router +│ ├── _root.tsx # HTML document shell +│ ├── _layout.tsx # Root layout + Providers +│ ├── index.tsx # Home page (create/join polls) +│ └── poll/[id].tsx # Poll view page +├── components/ +│ ├── Providers.tsx # Automerge Repo initialization +│ ├── HomeClient.tsx # Create/join poll UI +│ ├── PollPageClient.tsx # Poll ID validation +│ ├── PollView.tsx # Poll display, voting, options +│ └── ConnectionStatus.tsx # P2P connection indicator +├── lib/ +│ ├── types.ts # Poll & PollOption interfaces +│ ├── repo.ts # Automerge Repo singleton +│ ├── poll.ts # Pure poll mutation functions +│ ├── peer.ts # Peer ID management +│ └── __tests__/ # Unit tests +└── styles/ + └── global.css # Tailwind CSS +``` + +## Testing + +```bash +bun test +``` + +Covers poll creation, voting/unvoting, double-vote prevention, option management, and peer ID persistence. + +## Architecture Notes + +- **Pure business logic** — Poll mutations in `src/lib/poll.ts` are pure functions, used inside Automerge's `changeDoc()` for CRDT-safe updates +- **No server state** — The WebSocket relay only forwards sync messages; it never stores or processes poll data +- **Offline-first** — The app works fully offline; changes sync when connectivity resumes +- **Conflict-free** — Concurrent edits (e.g., two users voting at the same time) merge automatically without conflicts + +## Built With + +This project was built in collaboration with [Claude Code](https://claude.ai/code), Anthropic's agentic coding tool. + +## License + +MIT diff --git a/src/lib/__tests__/peer.test.ts b/src/lib/__tests__/peer.test.ts index 5ca39bb..059a4f6 100644 --- a/src/lib/__tests__/peer.test.ts +++ b/src/lib/__tests__/peer.test.ts @@ -26,13 +26,6 @@ describe("getPeerId", () => { storage.clear(); }); - test("generates a UUID", () => { - const id = getPeerId(); - expect(id).toMatch( - /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/ - ); - }); - test("persists and returns the same ID on subsequent calls", () => { const id1 = getPeerId(); const id2 = getPeerId(); @@ -43,4 +36,21 @@ describe("getPeerId", () => { const id = getPeerId(); expect(storage.get("p2p-poll-peer-id")).toBe(id); }); + + test("returns a new UUID each time when localStorage is unavailable", () => { + const saved = globalThis.localStorage; + // @ts-expect-error — deliberately removing localStorage to test fallback + globalThis.localStorage = undefined; + + const id1 = getPeerId(); + const id2 = getPeerId(); + + expect(id1).not.toBe(id2); + expect(id1).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/ + ); + + // Restore + globalThis.localStorage = saved; + }); }); diff --git a/src/lib/__tests__/poll.test.ts b/src/lib/__tests__/poll.test.ts index f3bb1f5..ea4b1cd 100644 --- a/src/lib/__tests__/poll.test.ts +++ b/src/lib/__tests__/poll.test.ts @@ -1,47 +1,7 @@ import { describe, test, expect } from "bun:test"; import { createPoll, addOption, vote, unvote, hasVoted } from "../poll.js"; -describe("createPoll", () => { - test("returns poll with title and empty options", () => { - const poll = createPoll("Favorite color?"); - expect(poll.title).toBe("Favorite color?"); - expect(poll.options).toEqual([]); - }); -}); - -describe("addOption", () => { - test("appends option with text, id, and empty votes", () => { - const poll = createPoll("Test"); - addOption(poll, "Red"); - - expect(poll.options).toHaveLength(1); - expect(poll.options[0]!.text).toBe("Red"); - expect(poll.options[0]!.id).toBeDefined(); - expect(poll.options[0]!.votes).toEqual([]); - }); - - test("appends multiple options", () => { - const poll = createPoll("Test"); - addOption(poll, "Red"); - addOption(poll, "Blue"); - - expect(poll.options).toHaveLength(2); - expect(poll.options[0]!.text).toBe("Red"); - expect(poll.options[1]!.text).toBe("Blue"); - }); -}); - describe("vote", () => { - test("adds peer ID to option's votes array", () => { - const poll = createPoll("Test"); - addOption(poll, "Red"); - const optionId = poll.options[0]!.id; - - vote(poll, optionId, "peer-1"); - - expect(poll.options[0]!.votes).toEqual(["peer-1"]); - }); - test("prevents double-vote (no duplicate entries)", () => { const poll = createPoll("Test"); addOption(poll, "Red"); @@ -64,6 +24,33 @@ describe("vote", () => { expect(poll.options[0]!.votes).toEqual(["peer-1", "peer-2"]); }); + + test("is a no-op for non-existent option ID", () => { + const poll = createPoll("Test"); + addOption(poll, "Red"); + + vote(poll, "non-existent-id", "peer-1"); + + expect(poll.options[0]!.votes).toEqual([]); + }); + + test("voting on one option does not affect another option", () => { + const poll = createPoll("Test"); + addOption(poll, "Red"); + addOption(poll, "Blue"); + const redId = poll.options[0]!.id; + const blueId = poll.options[1]!.id; + + vote(poll, redId, "peer-1"); + + expect(poll.options[0]!.votes).toEqual(["peer-1"]); + expect(poll.options[1]!.votes).toEqual([]); + + vote(poll, blueId, "peer-2"); + + expect(poll.options[0]!.votes).toEqual(["peer-1"]); + expect(poll.options[1]!.votes).toEqual(["peer-2"]); + }); }); describe("unvote", () => { @@ -100,14 +87,6 @@ describe("hasVoted", () => { expect(hasVoted(poll, optionId, "peer-1")).toBe(true); }); - test("returns false when peer has not voted", () => { - const poll = createPoll("Test"); - addOption(poll, "Red"); - const optionId = poll.options[0]!.id; - - expect(hasVoted(poll, optionId, "peer-1")).toBe(false); - }); - test("returns false for non-existent option", () => { const poll = createPoll("Test"); expect(hasVoted(poll, "non-existent", "peer-1")).toBe(false);