144 lines
5.5 KiB
Markdown
144 lines
5.5 KiB
Markdown
# 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
|