# 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
