Add README documentation and refactor test suite
Replace minimal README with full project docs (setup, usage, architecture). Remove trivial tests and add meaningful edge case coverage.
This commit is contained in:
267
PLAN.md
267
PLAN.md
@@ -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 (
|
||||
<RepoContext.Provider value={repo}>
|
||||
{children}
|
||||
</RepoContext.Provider>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
- 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 (
|
||||
<Providers>
|
||||
{children}
|
||||
</Providers>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 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/<documentId>`.
|
||||
- "Join Poll" input: paste a document ID or full URL, navigate to `/poll/<id>`.
|
||||
- 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<Poll>(url)` from `@automerge/automerge-repo-react-hooks` to get `[doc, changeDoc]`.
|
||||
- Wrap the document-dependent UI in `<Suspense fallback={<div>Loading poll...</div>}>` — `useDocument` may suspend while the document is being fetched/synced.
|
||||
- Render `<PollView doc={doc} changeDoc={changeDoc} />` and `<ConnectionStatus />`.
|
||||
|
||||
### 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 `<Suspense>` 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.
|
||||
90
README.md
90
README.md
@@ -1 +1,91 @@
|
||||
# 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
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user