# 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
