# Guerrilla Queue Zero-admin self-hosted digital queue. Single URL, QR code on the wall, no login. Full spec: `queue-spec.md` | Architecture: `ROADMAP.md` ## Commands ```bash # Run locally (no Docker) go run . # starts on :8080 APP_URL=http://localhost:8080 go run . # Build binary go build -ldflags="-s -w" -o queue . # Docker — local network HOST_IP=192.168.1.50 docker compose -f docker-compose.local.yml up -d --build # Docker — behind Traefik (production) # Set APP_URL and QUEUE_HOSTNAME in .env first docker compose up -d --build ``` ## File map | File | Responsibility | |---|---| | `main.go` | HTTP server, routes, gzip middleware, QR handler, embed | | `hub.go` | WebSocket hub, client lifecycle, all message dispatch | | `queue.go` | QueueState, Entry, every queue mutation + 60 s timers | | `static/index.html` | Entire frontend — HTML + CSS + JS inline (~15 KB) | | `docker-compose.yml` | Traefik production deployment | | `docker-compose.local.yml` | Local network, port-exposed, no Traefik | ## Architecture **One goroutine owns everything.** `hub.run()` is the single goroutine that reads from all channels and calls queue mutations. There is no mutex on `QueueState` — all access is serialised through the hub. Do not call queue methods from outside `hub.run()`. **Timer safety.** The 60 s removal timer fires by sending an entry ID to `hub.timerC` (buffered channel). The hub reads it and calls `q.timerRemove()`. Never call queue methods directly from a timer goroutine. **State is ephemeral.** The queue lives only in memory. Server restart = empty queue. This is intentional per the spec. Do not add a database. **Frontend is one file.** `static/index.html` contains HTML, CSS, and JS inline. Keep it that way — one HTTP request, no build step. ## WebSocket protocol Client connects → sends `init` with stored token → server replies `hello` + `state`. Client messages: `init` · `join` · `done` · `mark_done` · `vote_remove` · `restore` Server messages: `hello` · `joined` · `state` · `error` The secret token is sent to the client exactly once (`joined` response) and stored in `localStorage`. It is never included in `state` broadcasts. ## Key invariants - `voteThreshold(n) = min(3, ceil(n/2))` — recalculated live, never stored - `cleanupVotes` runs *after* `removeEntry` so threshold uses the post-removal count - `pending_removal` entries with empty `PendingBy` after cleanup are NOT restored (Case A: marker left, let the 60 s timer run). Only entries with remaining-but- insufficient votes are restored (Case B: quorum lost). - `CompletedSlots` records only self-removals (`done`). Voted-out entries are excluded. - `avgDuration` returns `nil` until 3+ completions — frontend shows `?` until then. ## Frontend rules - Vanilla JS only. No framework, no npm, no bundler. - `esc(s)` must wrap every user-supplied string before `innerHTML` insertion. Currently the only user string is `entry.name`. - Server-generated values (hex IDs, integers) are safe to interpolate directly. - Countdown ticks via `setInterval` updating `[data-pa]` nodes — no full re-render. ## Deployment env vars | Var | Used by | Notes | |---|---|---| | `APP_URL` | `/qr.png` handler | Full public URL in QR code. Falls back to `X-Forwarded-Proto` + `r.Host` if unset. Always set in production. | | `QUEUE_HOSTNAME` | `docker-compose.yml` | Traefik `Host()` rule. Must match domain in `APP_URL`. | | `HOST_IP` | `docker-compose.local.yml` | LAN IP for local-network runs. | ## Out of scope (do not add) Admin panel · push notifications · DB persistence · multiple queues · auth · entry editing after creation · vote retraction · IP-based rate limiting