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

3.7 KiB

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

# 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