qkeeper/CLAUDE.md
Aleksandr Berkuta 5806fe84c4 init commit
2026-03-27 20:27:56 +03:00

91 lines
3.7 KiB
Markdown

# 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