# Guerrilla Queue — Roadmap Self-hosted, zero-admin digital queue. Single URL, QR code, no login. Spec: `queue-spec.md` | Plan: `~/.claude/plans/peaceful-sprouting-squid.md` --- ## Stack - **Backend**: Go 1.22 · gorilla/websocket · skip2/go-qrcode - **Frontend**: Vanilla JS + CSS, single `static/index.html` (~15 KB uncompressed) - **Deploy**: Docker multi-stage (→ scratch) · docker-compose · Traefik --- ## File map | File | Purpose | | -------------------- | -------------------------------------------------- | | `main.go` | HTTP server, embed wiring, gzip middleware, routes | | `hub.go` | WebSocket hub, client registry, message dispatch | | `queue.go` | QueueState, Entry, all business logic + 60s timers | | `static/index.html` | Entire frontend: HTML + CSS + JS inline | | `Dockerfile` | Multi-stage build → scratch image | | `docker-compose.yml` | Traefik labels, APP_URL / QUEUE_HOSTNAME vars | --- ## Phases - [x] **Phase 1** — Go skeleton (go.mod, main.go, hub.go, queue.go, static/index.html stub) - [x] **Phase 2** — Queue core (join, selfRemove, findBy\*, stateJSON, avgDuration) - [x] **Phase 3** — Removal mechanics (markDone, voteRemove, restore, timerRemove, cleanupVotes) - [x] **Phase 4** — Frontend (full render loop, join form, confirm flows, countdown, wait time) - [x] **Phase 5** — QR code (/qr.png via skip2/go-qrcode), gzip middleware, ROADMAP.md - [x] **Phase 6** — Docker & deployment files (Dockerfile, docker-compose.yml) --- ## Deployment ### Environment variables | Variable | Default | Notes | | ---------------- | -------------------- | -------------------------------------------------------------------------- | | `APP_URL` | derived from request | Full public URL encoded in the QR code. **Always set this in production.** | | `QUEUE_HOSTNAME` | `queue.example.com` | Traefik `Host()` routing rule | ### docker-compose (quick start) ```bash # 1. Create .env next to docker-compose.yml: APP_URL=https://queue.yourdomain.com QUEUE_HOSTNAME=queue.yourdomain.com # 2. Make sure the Traefik network exists: docker network create traefik # 3. Build and start: docker compose up -d --build ``` ### Without Traefik (VPS, direct port) ```yaml services: queue: build: . restart: unless-stopped ports: - "8080:8080" environment: - APP_URL=https://queue.yourdomain.com ``` For Nginx in front: ```nginx location /ws { proxy_pass http://localhost:8080; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "Upgrade"; proxy_set_header Host $host; } location / { proxy_pass http://localhost:8080; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; } ``` --- ## WebSocket message protocol ### Client → Server | `type` | extra fields | when | | ------------- | ------------------------------ | ----------------------------------- | | `init` | `token` (empty string if none) | on every connect | | `join` | `name` (optional) | user taps "Get my number" | | `done` | — | user confirms "I'm done" | | `mark_done` | `entryId` | mark #1 as done (any queue member) | | `vote_remove` | `entryId` | vote to remove non-#1 entry | | `restore` | — | entry owner cancels pending_removal | ### Server → Client | `type` | extra fields | when | | -------- | ------------------------------------- | ------------------------------- | | `hello` | `yourEntryId` (null if not in queue) | response to `init` | | `joined` | `yourToken`, `yourEntryId` | response to `join` | | `state` | `entries[]`, `counter`, `avgDuration` | broadcast on every queue change | | `error` | `reason`, `onNumber` | e.g. `already_voting` | --- ## Key design decisions **Timer safety**: The 60 s removal timer fires by sending an entry ID to `hub.timerC` (a buffered channel). The hub's single goroutine reads from `timerC` and calls `q.timerRemove()` — this serialises all mutations through one goroutine with no queue-level mutex needed. **cleanupVotes logic**: When an entry is removed, its queue number is purged from all `PendingBy` lists. Entries in `pending_removal` whose vote count drops below threshold are restored to `active` (Case B: quorum lost). Entries with empty `PendingBy` after cleanup are NOT restored (Case A: the single marker left, let the timer run). **Token flow**: Token is generated by server on `join`, returned once, stored in `localStorage`. On reconnect the client sends it in `init`; server re-associates the `*Client` with the existing entry. Token is never broadcast to other clients. **Frontend XSS**: `entry.name` is the only user-supplied string that other browsers render. It is HTML-escaped via `esc()` on every insertion into `innerHTML`. All other dynamic values are server-generated hex IDs or integers. --- ## Out of scope (per spec) Admin panel · push notifications · persistence across server restarts · multiple queues · auth/accounts · entry editing · vote retraction