commit 5806fe84c4cf74920b249aa915ff97b65eacdabb Author: Aleksandr Berkuta Date: Fri Mar 27 20:27:56 2026 +0300 init commit diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..404b792 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,90 @@ +# 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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6ab0934 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +# Stage 1: build +FROM golang:1.22-alpine AS builder +WORKDIR /app + +# Cache dependency downloads separately from source changes. +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o queue . + +# Stage 2: minimal runtime image +# alpine (not scratch) so the filesystem supports the bbolt DB file at /data. +FROM alpine:3.19 +RUN mkdir -p /data +COPY --from=builder /app/queue /queue +EXPOSE 8080 +ENTRYPOINT ["/queue"] diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..d75afb0 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,143 @@ +# 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 diff --git a/data/queues.db b/data/queues.db new file mode 100644 index 0000000..b7f08fa Binary files /dev/null and b/data/queues.db differ diff --git a/docker-compose.local.yml b/docker-compose.local.yml new file mode 100644 index 0000000..a4becdd --- /dev/null +++ b/docker-compose.local.yml @@ -0,0 +1,12 @@ +services: + queue: + # build: . + image: git.berkuta.xyz/line-keeper:0.1 + restart: unless-stopped + ports: + - "8080:8080" + volumes: + - ./data:/data + environment: + - APP_URL=http://${HOST_IP:-192.168.1.100}:8080 + - DB_PATH=/data/queues.db diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e568a59 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,30 @@ +services: + queue: + build: . + image: queue:latest + restart: unless-stopped + volumes: + - ./data:/data + environment: + # Full public URL used to generate the QR code. + # Must match the hostname in the Traefik rule below. + - APP_URL=https://${QUEUE_HOSTNAME:-queue.example.com} + - DB_PATH=/data/queues.db + networks: + - traefik + labels: + - "traefik.enable=true" + # ---- routing ---- + - "traefik.http.routers.queue.rule=Host(`${QUEUE_HOSTNAME:-queue.example.com}`)" + - "traefik.http.routers.queue.entrypoints=websecure" + - "traefik.http.routers.queue.tls=true" + - "traefik.http.routers.queue.tls.certresolver=letsencrypt" + # ---- backend ---- + - "traefik.http.services.queue.loadbalancer.server.port=8080" + # WebSocket connections use the same router — Traefik v2+ passes + # Upgrade/Connection headers through by default, no extra middleware needed. + +networks: + # Must already exist: docker network create traefik + backend: + external: true diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4693cb0 --- /dev/null +++ b/go.mod @@ -0,0 +1,14 @@ +module github.com/al/queue + +go 1.22 + +require ( + github.com/gorilla/websocket v1.5.1 + github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e +) + +require ( + go.etcd.io/bbolt v1.3.9 // indirect + golang.org/x/net v0.17.0 // indirect + golang.org/x/sys v0.13.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..b986d53 --- /dev/null +++ b/go.sum @@ -0,0 +1,10 @@ +github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= +github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= +go.etcd.io/bbolt v1.3.9 h1:8x7aARPEXiXbHmtUwAIv7eV2fQFHrLLavdiJ3uzJXoI= +go.etcd.io/bbolt v1.3.9/go.mod h1:zaO32+Ti0PK1ivdPtgMESzuzL2VPoIG1PCQNvOdo/dE= +golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= +golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/hub.go b/hub.go new file mode 100644 index 0000000..e28003e --- /dev/null +++ b/hub.go @@ -0,0 +1,257 @@ +package main + +import ( + "encoding/json" + "net/http" + "sync/atomic" + + "github.com/gorilla/websocket" +) + +// ---- WebSocket hub ---- + +// Hub serialises all queue mutations through a single goroutine (run). +// This eliminates data races without requiring a queue-level mutex. +type Hub struct { + clients map[*Client]bool + registerC chan *Client + unregisterC chan *Client + broadcastC chan []byte // pre-serialised JSON pushed to all clients + dispatchC chan dispatch // inbound client messages + timerC chan string // entry IDs for 60s-timer-triggered removal + stopC chan struct{} + activeCount atomic.Int32 // len(q.Entries); read by Registry.List without locking +} + +type dispatch struct { + c *Client // nil for timer-triggered removals + payload []byte +} + +func newHub() *Hub { + return &Hub{ + clients: make(map[*Client]bool), + registerC: make(chan *Client, 16), + unregisterC: make(chan *Client, 16), + broadcastC: make(chan []byte, 256), + dispatchC: make(chan dispatch, 256), + timerC: make(chan string, 64), + stopC: make(chan struct{}), + } +} + +// shutdown signals the hub goroutine to exit. +func (h *Hub) shutdown() { + close(h.stopC) +} + +// run is the single goroutine that owns all hub and queue state. +func (h *Hub) run(q *QueueState, reg *Registry, queueID string) { + for { + select { + case <-h.stopC: + return + + case c := <-h.registerC: + h.clients[c] = true + + case c := <-h.unregisterC: + if _, ok := h.clients[c]; ok { + delete(h.clients, c) + close(c.send) + } + + case msg := <-h.broadcastC: + for c := range h.clients { + select { + case c.send <- msg: + default: + // Slow client: drop and disconnect. + delete(h.clients, c) + close(c.send) + } + } + + case d := <-h.dispatchC: + h.handleMessage(d.c, d.payload, q, reg, queueID) + + case entryID := <-h.timerC: + if q.timerRemove(entryID) { + h.pushState(q) + } + } + } +} + +// pushState broadcasts the full current queue state to all clients. +func (h *Hub) pushState(q *QueueState) { + h.activeCount.Store(int32(len(q.Entries))) + b := q.stateJSON() + select { + case h.broadcastC <- b: + default: + } +} + +// sendTo sends a JSON-serialisable value to one client. +func (h *Hub) sendTo(c *Client, v interface{}) { + b, err := json.Marshal(v) + if err != nil { + return + } + select { + case c.send <- b: + default: + } +} + +// ---- WebSocket client ---- + +type Client struct { + hub *Hub + conn *websocket.Conn + send chan []byte + token string // set after init/join; used for all subsequent actions +} + +var upgrader = websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { return true }, +} + +func (h *Hub) wsHandler(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + c := &Client{ + hub: h, + conn: conn, + send: make(chan []byte, 64), + } + h.registerC <- c + go c.writePump() + c.readPump() +} + +func (c *Client) readPump() { + defer func() { + c.hub.unregisterC <- c + c.conn.Close() + }() + c.conn.SetReadLimit(4096) + for { + _, raw, err := c.conn.ReadMessage() + if err != nil { + return + } + c.hub.dispatchC <- dispatch{c: c, payload: raw} + } +} + +func (c *Client) writePump() { + defer c.conn.Close() + for msg := range c.send { + if err := c.conn.WriteMessage(websocket.TextMessage, msg); err != nil { + return + } + } +} + +// ---- Message handling ---- + +type inMsg struct { + Type string `json:"type"` + Token string `json:"token,omitempty"` + Name string `json:"name,omitempty"` + EntryID string `json:"entryId,omitempty"` +} + +func (h *Hub) handleMessage(c *Client, raw []byte, q *QueueState, reg *Registry, queueID string) { + var msg inMsg + if err := json.Unmarshal(raw, &msg); err != nil { + return + } + + switch msg.Type { + + case "init": + // Client sends their stored token (empty string if none). + c.token = msg.Token + var entryID interface{} // nil → JSON null + if msg.Token != "" { + if e := q.findByToken(msg.Token); e != nil { + entryID = e.ID + } else { + c.token = "" // stale token + } + } + h.sendTo(c, map[string]interface{}{"type": "hello", "yourEntryId": entryID}) + // Send current state so client renders queue immediately. + h.sendTo(c, json.RawMessage(q.stateJSON())) + + case "join": + // Anti-spam: reject if this client already has an active entry. + if c.token != "" { + if e := q.findByToken(c.token); e != nil { + h.sendTo(c, map[string]interface{}{"type": "hello", "yourEntryId": e.ID}) + return + } + } + e, token := q.join(msg.Name) + c.token = token + h.sendTo(c, map[string]interface{}{ + "type": "joined", + "yourToken": token, + "yourEntryId": e.ID, + }) + h.pushState(q) + reg.Touch(queueID) + + case "done": + if c.token == "" { + return + } + if q.selfRemove(c.token) { + c.token = "" + h.pushState(q) + } + + case "mark_done": + if c.token == "" || msg.EntryID == "" { + return + } + actor := q.findByToken(c.token) + if actor == nil { + return + } + if q.markDone(actor.Number, msg.EntryID, h.timerC) { + h.pushState(q) + } + + case "vote_remove": + if c.token == "" || msg.EntryID == "" { + return + } + actor := q.findByToken(c.token) + if actor == nil { + return + } + switch q.voteRemove(actor.Number, msg.EntryID, h.timerC) { + case "ok": + h.pushState(q) + case "already_voting": + onNum := q.findVoteTarget(actor.Number) + h.sendTo(c, map[string]interface{}{ + "type": "error", "reason": "already_voting", "onNumber": onNum, + }) + } + + case "restore": + if c.token == "" { + return + } + if q.restore(c.token) { + h.pushState(q) + } + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..045e2c2 --- /dev/null +++ b/main.go @@ -0,0 +1,177 @@ +package main + +import ( + "compress/gzip" + "embed" + "encoding/json" + "io" + "io/fs" + "log" + "mime" + "net/http" + "os" + "path/filepath" + "strings" + + qrcode "github.com/skip2/go-qrcode" +) + +//go:embed static +var rawFS embed.FS + +func main() { + dbPath := os.Getenv("DB_PATH") + if dbPath == "" { + dbPath = "queues.db" + } + reg, err := openRegistry(dbPath) + if err != nil { + log.Fatalf("open registry: %v", err) + } + + appURL := os.Getenv("APP_URL") + + staticFS, err := fs.Sub(rawFS, "static") + if err != nil { + log.Fatal(err) + } + + mux := http.NewServeMux() + + // ---- About / home page ---- + mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) { + serveFile(w, r, staticFS, "about.html") + }) + + // ---- Help / info page ---- + mux.HandleFunc("GET /help.html", func(w http.ResponseWriter, r *http.Request) { + serveFile(w, r, staticFS, "help.html") + }) + + // ---- Queue list (JSON) ---- + mux.HandleFunc("GET /queues", func(w http.ResponseWriter, r *http.Request) { + list := reg.List() + b, _ := json.Marshal(list) + w.Header().Set("Content-Type", "application/json") + w.Write(b) + }) + + // ---- Create queue ---- + mux.HandleFunc("POST /new", func(w http.ResponseWriter, r *http.Request) { + name := sanitize(strings.TrimSpace(r.FormValue("name")), 100) + if name == "" { + name = "Очередь" + } + inst, err := reg.Create(name) + if err != nil { + http.Error(w, "не удалось создать очередь", http.StatusInternalServerError) + return + } + http.Redirect(w, r, "/q/"+inst.ID, http.StatusSeeOther) + }) + + // ---- Queue page ---- + mux.HandleFunc("GET /q/{id}", func(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if reg.Get(id) == nil { + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + serveFile(w, r, staticFS, "index.html") + }) + + // ---- Queue WebSocket ---- + mux.HandleFunc("GET /q/{id}/ws", func(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + inst := reg.Get(id) + if inst == nil { + http.NotFound(w, r) + return + } + inst.hub.wsHandler(w, r) + }) + + // ---- Queue QR code ---- + mux.HandleFunc("GET /q/{id}/qr.png", func(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if reg.Get(id) == nil { + http.NotFound(w, r) + return + } + url := appURL + if url == "" { + scheme := "http" + if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" { + scheme = "https" + } + url = scheme + "://" + r.Host + log.Printf("APP_URL not set, using derived URL: %s", url) + } + url += "/q/" + id + png, err := qrcode.Encode(url, qrcode.Medium, 256) + if err != nil { + http.Error(w, "qr generation failed", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "image/png") + w.Header().Set("Cache-Control", "public, max-age=86400") + w.Write(png) + }) + + addr := ":8080" + log.Printf("queue server listening on %s APP_URL=%q DB=%q", addr, appURL, dbPath) + log.Fatal(http.ListenAndServe(addr, mux)) +} + +// serveFile reads a file from an fs.FS and writes it with gzip if supported. +func serveFile(w http.ResponseWriter, r *http.Request, fsys fs.FS, name string) { + f, err := fsys.Open(name) + if err != nil { + http.NotFound(w, r) + return + } + defer f.Close() + data, err := io.ReadAll(f) + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + ct := mime.TypeByExtension(filepath.Ext(name)) + if ct == "" { + ct = http.DetectContentType(data) + } + w.Header().Set("Content-Type", ct) + if strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") { + w.Header().Set("Content-Encoding", "gzip") + gz, _ := gzip.NewWriterLevel(w, gzip.BestSpeed) + defer gz.Close() + gz.Write(data) + } else { + w.Write(data) + } +} + +// ---- Gzip middleware (for future use on static file handler) ---- + +type gzipResponseWriter struct { + http.ResponseWriter + gz *gzip.Writer +} + +func (g *gzipResponseWriter) Write(b []byte) (int, error) { + return g.gz.Write(b) +} + +func gzipMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") { + next.ServeHTTP(w, r) + return + } + w.Header().Set("Content-Encoding", "gzip") + w.Header().Del("Content-Length") + gz, _ := gzip.NewWriterLevel(w, gzip.BestSpeed) + defer gz.Close() + next.ServeHTTP(&gzipResponseWriter{w, gz}, r) + }) +} diff --git a/queue b/queue new file mode 100755 index 0000000..17ea0b7 Binary files /dev/null and b/queue differ diff --git a/queue-spec.md b/queue-spec.md new file mode 100644 index 0000000..52c1ba0 --- /dev/null +++ b/queue-spec.md @@ -0,0 +1,233 @@ +# Guerrilla Queue — Technical Specification + +## Concept + +A zero-administration, self-organizing digital queue. Deployed as a single URL, accessed via a printed QR code. No login, no backend operator, no persistent management UI. Strangers coordinate themselves through a shared live view and social contract enforced by the interface copy. + +The digital queue is a coordination aid, not an obligation. People without phones participate through physical presence and social interaction — the queue reflects reality, it does not enforce it. + +--- + +## Architecture Overview + +- **Single-page application** — one URL handles everything. No routing needed. +- **Realtime shared state** — all connected clients see the same queue, updated live without page refresh. Use WebSockets or a realtime database (e.g. Firebase Firestore, Supabase, or a simple WebSocket server with in-memory state). +- **No authentication** — identity is a randomly generated token stored in `localStorage`. This token is the only proof of ownership of a queue entry. +- **No admin interface, no privileged roles.** All users are equal. +- **Ephemeral by design** — the queue does not need to survive server restarts. Persistence across sessions for individual users (via localStorage token) is sufficient. + +--- + +## Data Model + +### Queue Entry + +``` +{ + id: string, // server-generated unique ID for this entry + token: string, // secret random token, stored only in client localStorage + // may be absent if entry was created by someone else's re-scan + number: integer, // sequential ticket number, assigned at creation, never reused + name: string?, // optional display name, set once, not editable + joinedAt: timestamp, + status: enum, // "active" | "pending_removal" + pendingBy: integer[]?, // list of queue numbers who voted/triggered removal + pendingAt: timestamp? // when pending_removal status was set +} +``` + +### Queue State (global) + +``` +{ + entries: Entry[], // ordered by number ascending + counter: integer, // monotonically increasing ticket counter, never resets + completedSlots: Duration[] // list of durations of completed (self-removed) entries, + // used for wait time estimation +} +``` + +--- + +## Core Flows + +### 1. Opening the URL + +- Default view: **the queue is shown immediately**, full screen. +- If the user already has a token in localStorage that matches an active entry → their entry is visually highlighted. Their "I'm done" and removal controls are visible. +- If the user has no active entry → a prominent **"Get my number"** button is shown. It does not block reading the queue. + +### 2. Joining the Queue + +- User taps "Get my number". +- Optional: a name input appears inline (single text field, placeholder "Your name or nickname — optional"). A "Skip" / "Join" split action. +- Server assigns the next sequential number and returns the entry + a secret token. +- Token is saved to localStorage. Entry appears in the live queue immediately. +- The user's entry is visually distinct (highlighted / "this is you" label). +- **Adding someone else to the queue:** re-scan the QR code on a second device (or hand the phone with the URL to another person). Each scan produces a new independent entry with its own token. Sequential numbers and close timestamps make the relationship self-evident to others in the queue. No special "add after me" UI is needed. + +### 3. Self-Removal ("I'm done") + +- Shown only on the user's own entry (matched via localStorage token). +- Single tap → inline confirmation ("Done? This removes you from the queue.") → entry deleted for all clients immediately. +- Server records the duration of this slot (joinedAt → now) into `completedSlots` for wait time estimation. +- No undo. + +### 4. Wait Time Estimation + +- Each time a user self-removes via "I'm done", the server appends the duration of their slot to `completedSlots`. +- Average slot duration is computed from all recorded completions. +- Each entry in the queue displays an estimated wait: `position × average_duration`. + - Position is zero-indexed from the front (position 0 = currently #1, estimated wait = 0 or "you're next"). +- If fewer than 3 completions have been recorded, show "?" — not enough data yet. +- Displayed as a rough hint, not a promise. Label it explicitly: "~8 min (estimated)". +- Only self-removals count toward the average. Voted-out entries are excluded — they don't represent normal service time. + +### 5. Timestamps + +- Every entry displays its absolute join time: `HH:MM:SS` format. +- No relative timestamps ("3 min ago"). Absolute time is unambiguous and directly useful for coordination — people can ask "who has 11:23?" without any app logic. + +--- + +## Removal Mechanics + +Two cases, both using the same pending/restore window. All entries are treated identically regardless of whether they have an owner token — the app makes no visible distinction. + +### Case A — Removing the #1 entry (anyone can act, single tap) + +The most common legitimate case: the person at the front is visibly gone. + +- Any user in the queue can tap "Mark as done" on the #1 entry. +- The entry immediately enters **pending_removal** state: + - Visually dimmed, labeled "Marked done by #NN — removing in 60s" + - A public countdown is shown +- If the entry owner has the page open, they see a prominent banner: *"#NN marked you as done — was this a mistake?"* with a **Restore** button. +- Tapping Restore returns the entry to active status instantly, visible to all. The pending_removal state and countdown are cleared. +- After 60 seconds with no restore → entry is permanently deleted. +- If the entry has no owner token (was created by someone else's re-scan), the restore banner simply never appears — the 60s window passes and the entry is removed. No special handling needed. +- Attribution (#NN who triggered it) is always shown — named accountability discourages abuse. + +### Case B — Removing a middle-queue entry (quorum vote) + +For entries not at position #1. Higher bar required because absence is less obvious and removal is more disruptive. + +- Any user in the queue can tap "Vote to remove" on any non-#1 entry. +- Their queue number is added to `pendingBy` on that entry. +- **A user may only have one active removal vote at a time across the entire queue.** If they try to vote on a second entry while their first vote is still pending, the action is blocked with a message: *"You're already voting to remove #NN."* This prevents bulk voting and coordinated abuse. +- Removal threshold: **3 votes OR 50% of total active queue size, whichever is smaller.** + - 4-person queue → 2 votes needed + - 20-person queue → 3 votes needed +- While votes are accumulating, the entry is visually marked "N/M votes to remove". +- Once threshold is reached → entry enters **pending_removal** state, same 60s restore window as Case A. +- If the entry owner restores: votes are cleared, entry returns to active. The voters' vote slots are freed — they may vote on another entry. +- If removed: the owner's restore banner shows the full list of who voted: *"#4, #6, #8 voted to remove you."* +- If the entry has no owner token, the restore banner never appears — 60s passes and the entry is removed. +- Users may not vote on their own entry. +- No vote retraction in MVP. + +--- + +## Identity & Session + +- On first join, the server generates a `token` (cryptographically random, e.g. 128-bit hex). +- Token is returned to the client and stored in `localStorage` under a fixed key (e.g. `queue_token`). +- On every page load, the client sends the token to the server to recover its entry reference. +- If the token matches no active entry → client silently drops the stale token and presents the "Get my number" flow. +- **The token is never shown to the user and never sent to other clients.** Only `id`, `number`, `name`, and `joinedAt` are public. +- Accidentally closing and reopening the browser recovers the session naturally via localStorage. + +--- + +## Anti-Spam + +- One active entry per token. If a client with an existing active token tries to join again, the server rejects and returns their current entry instead. +- This does not prevent helping others: each device that scans the QR gets its own independent token and entry. A person helping someone without a phone simply scans on their behalf on a second device or hands their phone to them. +- No IP-based limiting — unreliable and overly aggressive for this context. + +--- + +## The Printed QR Sheet + +This is part of the product. The physical paper should include: + +- The QR code (large, center). +- A short headline: e.g. *"Tired of the chaos? — Take a number."* +- 3-line instruction: + 1. Scan the code + 2. Get your number + 3. Remove yourself when you're done +- The URL in plain text below the QR code (for people who won't scan). + +The application UI should be consistent with this framing — it's a social proposal, not a corporate system. + +--- + +## UI / UX Constraints + +- **Mobile-first.** Large tap targets, minimal scrolling. +- **Queue visible before any interaction.** Never show a splash or join screen before the queue. People need to see it to trust it. +- **Copy matters.** "I'm done" not "Leave queue". "Get my number" not "Join". Interface should feel like a helpful stranger, not enterprise software. +- **No modals.** All interactions (join, confirm removal, vote) happen inline within the card or as expansion of existing UI. +- **No loading spinners on main queue view.** Realtime updates should feel instantaneous. Skeleton state on initial load is acceptable. +- **Timestamps are absolute:** `HH:MM:SS`. No relative time display. +- **Pending removal state** must be visually unambiguous — dimmed entry, countdown timer, attribution clearly labeled. +- **Vote count on middle-queue entries** is always visible once at least one vote exists: "2/3 votes to remove". +- **Blocked vote attempt** (user already has an active vote elsewhere) should fail gracefully inline, not silently. + +--- + +## Edge Cases to Handle + +| Scenario | Behavior | +|---|---| +| User clears localStorage | They lose their entry. They can rejoin but their old entry persists until quorum removes it or it reaches #1 and gets single-tapped. | +| User closes tab and reopens | Token recovered from localStorage, entry highlighted as before. | +| Two tabs same browser | Both recover the same token, both highlight the same entry. Removal from either works. | +| Queue is empty | Show empty state with clear "Be the first" prompt. | +| Single entry in queue | Normal display. No vote controls visible (no other users to vote). | +| Entry has no owner token (created by re-scan) | Treated identically to owned entries in all removal flows. Restore window passes silently with no notification. | +| Entry in pending_removal changes position | Pending state is positional-agnostic. Countdown continues regardless of position change. | +| All entries are pending_removal simultaneously | Unlikely but valid. Each has its own independent countdown. | +| User tries to vote on two entries | Second vote blocked with attribution to their active vote. | +| Voter's entry is removed while their vote is pending | Their vote is voided. The threshold recalculates without them. | +| Server restart | Queue lost. All localStorage tokens become stale, silently dropped on next load. | +| Very long queue (50+ entries) | Single scrollable list. No pagination in MVP. | +| Not enough data for estimation | Show "?" for all wait times until 3+ completions recorded. | + +--- + +## Frontend Size Constraint (optional, highly desirable) + +The frontend should aim to fit within **16 KB uncompressed** so it loads fast even on poor mobile signal — a realistic condition for people standing in a corridor scanning a QR code. + +This is achievable with the following constraints: + +- **Vanilla JS only.** No frontend framework (React, Vue, etc. cost 40–130 KB before any app code). +- **Native WebSocket.** Built into every browser. Do not use Socket.io or any WebSocket wrapper library. +- **No npm dependencies in the frontend bundle.** If the browser provides it natively, don't bundle it. +- **System fonts only.** `font-family: -apple-system, sans-serif`. No Google Fonts or external font imports. +- **No icon libraries.** Unicode symbols (✓ ✕ ⏳ etc.) cover all UI needs. +- **QR code generated server-side**, served as a plain `` tag. No QR generation library in the frontend. +- **Gzip or Brotli compression enabled on the server.** With gzip, a 14 KB uncompressed bundle becomes ~4–6 KB over the wire. + +If a deliberate tradeoff requires exceeding 16 KB uncompressed, document the reason explicitly. + +--- + +## Out of Scope (explicitly) + +- Admin panel or operator controls +- Notifications or push alerts +- Queue persistence across server restarts +- Multiple simultaneous queues +- Authentication or accounts +- Entry editing after creation +- Vote retraction +- Any distinction in UI between owned and unowned entries + +--- + +## Success Criteria + +A stranger with no context scans the QR code, understands what the app does within 5 seconds, joins the queue in under 15 seconds, and knows how to remove themselves when done — without reading any documentation. diff --git a/queue.go b/queue.go new file mode 100644 index 0000000..009c7f9 --- /dev/null +++ b/queue.go @@ -0,0 +1,319 @@ +package main + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "time" + "unicode/utf8" +) + +// ---- Helpers ---- + +func randHex(n int) string { + b := make([]byte, n) + rand.Read(b) + return hex.EncodeToString(b) +} + +func msNow() int64 { return time.Now().UnixMilli() } + +// voteThreshold returns the number of votes required to trigger pending_removal +// for a non-#1 entry. = min(3, ceil(n/2)), minimum 1. +func voteThreshold(n int) int { + half := (n + 1) / 2 + if half > 3 { + return 3 + } + if half < 1 { + return 1 + } + return half +} + +func sanitize(s string, maxRunes int) string { + runes := []rune(s) + if len(runes) > maxRunes { + runes = runes[:maxRunes] + } + out := make([]rune, 0, len(runes)) + for _, r := range runes { + if r != 0 && utf8.ValidRune(r) { + out = append(out, r) + } + } + return string(out) +} + +// ---- Data model ---- + +type Entry struct { + ID string + Number int + Name string + JoinedAt int64 // Unix ms + Status string // "active" | "pending_removal" + PendingBy []int + PendingAt *int64 // Unix ms + token string + cancelFn context.CancelFunc +} + +// pub returns the public (broadcast-safe) view of an entry. +type entryPub struct { + ID string `json:"id"` + Number int `json:"number"` + Name string `json:"name,omitempty"` + JoinedAt int64 `json:"joinedAt"` + Status string `json:"status"` + PendingBy []int `json:"pendingBy,omitempty"` + PendingAt *int64 `json:"pendingAt,omitempty"` +} + +func (e *Entry) pub() entryPub { + return entryPub{ + ID: e.ID, + Number: e.Number, + Name: e.Name, + JoinedAt: e.JoinedAt, + Status: e.Status, + PendingBy: e.PendingBy, + PendingAt: e.PendingAt, + } +} + +// ---- Queue state ---- + +type QueueState struct { + Name string // display name, set at creation + Entries []*Entry + Counter int + CompletedSlots []float64 // seconds; only self-removals, not voted-out entries +} + +// stateJSON serialises the full public state for broadcast. +func (q *QueueState) stateJSON() []byte { + views := make([]entryPub, len(q.Entries)) + for i, e := range q.Entries { + views[i] = e.pub() + } + b, _ := json.Marshal(map[string]interface{}{ + "type": "state", + "name": q.Name, + "entries": views, + "counter": q.Counter, + "avgDuration": q.avgDuration(), + }) + return b +} + +// avgDuration returns the average slot duration in seconds, or nil if <3 samples. +func (q *QueueState) avgDuration() interface{} { + if len(q.CompletedSlots) < 3 { + return nil + } + var sum float64 + for _, d := range q.CompletedSlots { + sum += d + } + return sum / float64(len(q.CompletedSlots)) +} + +func (q *QueueState) findByToken(token string) *Entry { + for _, e := range q.Entries { + if e.token == token { + return e + } + } + return nil +} + +func (q *QueueState) findByID(id string) *Entry { + for _, e := range q.Entries { + if e.ID == id { + return e + } + } + return nil +} + +func (q *QueueState) removeEntry(id string) { + for i, e := range q.Entries { + if e.ID == id { + q.Entries = append(q.Entries[:i], q.Entries[i+1:]...) + return + } + } +} + +// findVoteTarget returns the Number of the entry the given voter is targeting, +// or 0 if they have no active vote. +func (q *QueueState) findVoteTarget(voterNumber int) int { + for _, e := range q.Entries { + for _, n := range e.PendingBy { + if n == voterNumber { + return e.Number + } + } + } + return 0 +} + +// ---- Mutations ---- + +// join creates a new entry and returns it with its secret token. +func (q *QueueState) join(name string) (*Entry, string) { + name = sanitize(name, 50) + token := randHex(16) + q.Counter++ + e := &Entry{ + ID: randHex(8), + Number: q.Counter, + Name: name, + JoinedAt: msNow(), + Status: "active", + token: token, + } + q.Entries = append(q.Entries, e) + return e, token +} + +// selfRemove removes the entry owned by token, recording the slot duration. +func (q *QueueState) selfRemove(token string) bool { + e := q.findByToken(token) + if e == nil { + return false + } + dur := float64(msNow()-e.JoinedAt) / 1000.0 + q.CompletedSlots = append(q.CompletedSlots, dur) + if e.cancelFn != nil { + e.cancelFn() + } + removedNum := e.Number + q.removeEntry(e.ID) + q.cleanupVotes(removedNum) + return true +} + +// startPending transitions an entry into pending_removal and launches the 60s timer. +// When the timer fires it sends the entry ID to timerC. +func (q *QueueState) startPending(e *Entry, timerC chan<- string) { + now := msNow() + e.Status = "pending_removal" + e.PendingAt = &now + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + e.cancelFn = cancel + id := e.ID + go func() { + <-ctx.Done() + cancel() + if ctx.Err() == context.DeadlineExceeded { + select { + case timerC <- id: + default: + } + } + }() +} + +// markDone marks the #1 entry as pending_removal on behalf of byNumber. +// Returns false if the queue shifted or entry is already pending. +func (q *QueueState) markDone(byNumber int, entryID string, timerC chan<- string) bool { + if len(q.Entries) == 0 { + return false + } + target := q.Entries[0] + if target.ID != entryID { + return false // queue shifted since the client clicked + } + if target.Status == "pending_removal" { + return false + } + target.PendingBy = []int{byNumber} + q.startPending(target, timerC) + return true +} + +// voteRemove adds a vote against a non-#1 entry. +// Returns "ok", "already_voting", or "noop". +func (q *QueueState) voteRemove(voterNumber int, targetID string, timerC chan<- string) string { + target := q.findByID(targetID) + if target == nil { + return "noop" + } + // Cannot vote on own entry + if target.Number == voterNumber { + return "noop" + } + // Cannot vote on #1 (use markDone instead) + if len(q.Entries) > 0 && q.Entries[0].ID == targetID { + return "noop" + } + // One active vote per user across the whole queue + if existing := q.findVoteTarget(voterNumber); existing != 0 { + return "already_voting" + } + target.PendingBy = append(target.PendingBy, voterNumber) + if len(target.PendingBy) >= voteThreshold(len(q.Entries)) { + q.startPending(target, timerC) + } + return "ok" +} + +// restore cancels pending_removal for the entry owned by token. +func (q *QueueState) restore(token string) bool { + e := q.findByToken(token) + if e == nil || e.Status != "pending_removal" { + return false + } + if e.cancelFn != nil { + e.cancelFn() + e.cancelFn = nil + } + e.Status = "active" + e.PendingAt = nil + e.PendingBy = nil + return true +} + +// timerRemove is called when the 60s timer fires for an entry. +func (q *QueueState) timerRemove(entryID string) bool { + e := q.findByID(entryID) + if e == nil || e.Status != "pending_removal" { + return false // already restored + } + removedNum := e.Number + q.removeEntry(entryID) + q.cleanupVotes(removedNum) + return true +} + +// cleanupVotes removes a departed entry's number from all PendingBy lists. +// For entries in pending_removal with remaining votes below threshold, restores them. +// Called AFTER the entry has been removed from q.Entries so threshold uses updated count. +func (q *QueueState) cleanupVotes(removedNumber int) { + thresh := voteThreshold(len(q.Entries)) + for _, e := range q.Entries { + for i, n := range e.PendingBy { + if n == removedNumber { + e.PendingBy = append(e.PendingBy[:i], e.PendingBy[i+1:]...) + // Restore only if votes remain but are now below threshold (Case B). + // If PendingBy is empty after removal (Case A marker left), let the + // 60s timer run its course — the action was already taken. + if e.Status == "pending_removal" && + len(e.PendingBy) > 0 && + len(e.PendingBy) < thresh { + if e.cancelFn != nil { + e.cancelFn() + e.cancelFn = nil + } + e.Status = "active" + e.PendingAt = nil + e.PendingBy = nil + } + break + } + } + } +} diff --git a/registry.go b/registry.go new file mode 100644 index 0000000..65558e2 --- /dev/null +++ b/registry.go @@ -0,0 +1,210 @@ +package main + +import ( + "encoding/json" + "log" + "sort" + "sync" + "time" + + bolt "go.etcd.io/bbolt" +) + +const ( + bucketQueues = "queues" + inactivityTTL = 14 * 24 * time.Hour + cleanupInterval = time.Hour +) + +// QueueMeta is the shape persisted in bbolt. +type QueueMeta struct { + ID string `json:"id"` + Name string `json:"name"` + CreatedAt int64 `json:"createdAt"` // Unix ms + LastActiveAt int64 `json:"lastActiveAt"` // Unix ms +} + +// QueueSummary is the shape returned by GET /queues. +type QueueSummary struct { + ID string `json:"id"` + Name string `json:"name"` + CreatedAt int64 `json:"createdAt"` + Count int `json:"count"` // live, from in-memory hub +} + +// QueueInstance is the runtime object for one queue. +type QueueInstance struct { + QueueMeta + hub *Hub + state *QueueState +} + +// Registry owns all queue instances. +type Registry struct { + mu sync.RWMutex + queues map[string]*QueueInstance + db *bolt.DB +} + +func openRegistry(path string) (*Registry, error) { + db, err := bolt.Open(path, 0600, &bolt.Options{Timeout: 2 * time.Second}) + if err != nil { + return nil, err + } + if err := db.Update(func(tx *bolt.Tx) error { + _, err := tx.CreateBucketIfNotExists([]byte(bucketQueues)) + return err + }); err != nil { + db.Close() + return nil, err + } + + r := &Registry{queues: make(map[string]*QueueInstance), db: db} + if err := r.load(); err != nil { + db.Close() + return nil, err + } + go r.cleanupLoop() + return r, nil +} + +// load reads persisted queues from bbolt, skipping expired ones. +func (r *Registry) load() error { + return r.db.View(func(tx *bolt.Tx) error { + return tx.Bucket([]byte(bucketQueues)).ForEach(func(k, v []byte) error { + var m QueueMeta + if err := json.Unmarshal(v, &m); err != nil { + log.Printf("skipping corrupt queue %s: %v", k, err) + return nil + } + if time.Since(time.UnixMilli(m.LastActiveAt)) > inactivityTTL { + return nil // expired; cleanup loop will remove from DB + } + r.queues[m.ID] = r.newInstance(m) + return nil + }) + }) +} + +func (r *Registry) newInstance(m QueueMeta) *QueueInstance { + hub := newHub() + state := &QueueState{Name: m.Name} + inst := &QueueInstance{QueueMeta: m, hub: hub, state: state} + go hub.run(state, r, m.ID) + return inst +} + +// Create makes a new named queue, persists it, and returns the instance. +func (r *Registry) Create(name string) (*QueueInstance, error) { + now := msNow() + m := QueueMeta{ + ID: randHex(4), // 8 hex chars — short but hard to guess + Name: name, + CreatedAt: now, + LastActiveAt: now, + } + if err := r.persist(m); err != nil { + return nil, err + } + inst := r.newInstance(m) + r.mu.Lock() + r.queues[m.ID] = inst + r.mu.Unlock() + return inst, nil +} + +// Get returns a running queue instance by ID, or nil. +func (r *Registry) Get(id string) *QueueInstance { + r.mu.RLock() + defer r.mu.RUnlock() + return r.queues[id] +} + +// List returns a snapshot summary of all queues, sorted by creation time. +func (r *Registry) List() []QueueSummary { + r.mu.RLock() + defer r.mu.RUnlock() + out := make([]QueueSummary, 0, len(r.queues)) + for _, inst := range r.queues { + out = append(out, QueueSummary{ + ID: inst.ID, + Name: inst.Name, + CreatedAt: inst.CreatedAt, + Count: int(inst.hub.activeCount.Load()), + }) + } + sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt < out[j].CreatedAt }) + return out +} + +// Touch updates LastActiveAt for a queue (called on join). +// The DB write is async and best-effort. +func (r *Registry) Touch(id string) { + now := msNow() + r.mu.Lock() + inst, ok := r.queues[id] + if ok { + inst.LastActiveAt = now + } + r.mu.Unlock() + if !ok { + return + } + go func() { + _ = r.db.Update(func(tx *bolt.Tx) error { + b := tx.Bucket([]byte(bucketQueues)) + var m QueueMeta + if raw := b.Get([]byte(id)); raw != nil { + _ = json.Unmarshal(raw, &m) + } + m.LastActiveAt = now + raw, _ := json.Marshal(m) + return b.Put([]byte(id), raw) + }) + }() +} + +func (r *Registry) persist(m QueueMeta) error { + return r.db.Update(func(tx *bolt.Tx) error { + raw, _ := json.Marshal(m) + return tx.Bucket([]byte(bucketQueues)).Put([]byte(m.ID), raw) + }) +} + +func (r *Registry) drop(id string) { + r.mu.Lock() + inst, ok := r.queues[id] + if ok { + delete(r.queues, id) + } + r.mu.Unlock() + if ok { + inst.hub.shutdown() + } + _ = r.db.Update(func(tx *bolt.Tx) error { + return tx.Bucket([]byte(bucketQueues)).Delete([]byte(id)) + }) +} + +func (r *Registry) cleanupLoop() { + t := time.NewTicker(cleanupInterval) + defer t.Stop() + for range t.C { + r.runCleanup() + } +} + +func (r *Registry) runCleanup() { + r.mu.RLock() + var expired []string + for id, inst := range r.queues { + if time.Since(time.UnixMilli(inst.LastActiveAt)) > inactivityTTL { + expired = append(expired, id) + } + } + r.mu.RUnlock() + for _, id := range expired { + log.Printf("removing inactive queue %s", id) + r.drop(id) + } +} diff --git a/static/about.html b/static/about.html new file mode 100644 index 0000000..f3e5fe0 --- /dev/null +++ b/static/about.html @@ -0,0 +1,147 @@ + + + + + +Электронная очередь + + + +
+

Электронная очередь

+

Самоорганизуйтесь без бумажных талончиков

+
+
+ +
+

Активные очереди

+
  • Загрузка…
+
+ +
+

Создать очередь

+
+ + +
+
Не удалось создать очередь, попробуйте ещё раз
+
+ +
+

Как пользоваться

+
    +
  1. Найдите нужную очередь в списке выше или создайте новую.
  2. +
  3. Откройте ссылку или отсканируйте QR-код на странице очереди.
  4. +
  5. Нажмите «Взять номер» — приложение покажет вашу позицию и ориентировочное время ожидания.
  6. +
  7. Когда подойдёт очередь, отметьте себя как ушедшего кнопкой «Ухожу».
  8. +
+
+ +
+

Это неофициальный инструмент самоорганизации, сделанный энтузиастом, которому надоело стоять в живых очередях. Никаких гарантий бесперебойной работы не даётся.

+

Вопросы и пожелания: line.keeper@berkuta.xyz

+
+ +
+ + + + + diff --git a/static/help.html b/static/help.html new file mode 100644 index 0000000..2320012 --- /dev/null +++ b/static/help.html @@ -0,0 +1,79 @@ + + + + + +Справка — Электронная очередь + + + +
+

Справка

+ ← назад +
+
+ +
+

Как пользоваться

+
    +
  1. Отсканируйте QR-код или откройте ссылку на очередь.
  2. +
  3. Нажмите Взять номер — можно указать имя или псевдоним, или пропустить.
  4. +
  5. Ждите своей очереди. Приложение показывает позицию и примерное время ожидания (рассчитывается после первых трёх завершённых сессий).
  6. +
  7. Когда закончите — нажмите Ухожу, чтобы освободить место.
  8. +
+
+ +
+

Правила очереди

+ +

Первый человек прошёл в кабинет

+

Человек зашёл — и забыл нажать Ухожу. Не беда: любой участник очереди может нажать Отметить ушедшим и освободить место. Запись перейдёт в режим ожидания — у первого есть 60 секунд, чтобы нажать Вернуть моё место, если это была ошибка. Не нажмёт — удалится автоматически. Очередь справляется сама.

+ +

Участник из середины очереди ушёл

+

Любой участник может проголосовать за удаление записи кнопкой Голосовать за удаление. Нужно набрать min(3, ⌈n/2⌉) голосов, где n — число человек в очереди. Например: при 4 участниках нужно 2 голоса, при 6 — 3.

+

Как только порог достигнут — та же 60-секундная пауза: владелец может нажать Вернуть моё место. Один участник может голосовать только за одного человека одновременно.

+ +

Рядом человек без смартфона

+

Отсутствие телефона — не стигма. Объясните ему, что здесь есть электронная очередь, и помогите встать. Скажите: «Я сейчас последний — номер #N. Вы встаёте между мной и следующим. Когда подойдёт кто-то новый с телефоном — предупредите его, что вы здесь и стоите без номера, между #N и #N+1».

+

Каждому достаточно помнить одного соседа — того, кто стоит перед ним. Это надёжнее, чем пытаться удержать в голове всю цепочку и восстанавливать её при разрыве.

+
+ +
+

Это неофициальный инструмент самоорганизации. Он никак не связан с организацией, у которой вы стоите в очереди. Никаких гарантий бесперебойной работы не даётся.

+

Здесь нет администратора и организатора. Вы сами организуете очередь — не очередь организует вас. Это означает ответственность: отмечайте себя как ушедшего, когда закончили; голосуйте за удаление тех, кто явно ушёл и не реагирует. Без взаимного уважения инструмент не работает.

+

Вопросы и пожелания: line.keeper@berkuta.xyz

+
+ +
+

Знаете место, где люди часто стоят в живой очереди? Создайте свою очередь и распечатайте QR-код.

+
+ +
+ + + diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..382c999 --- /dev/null +++ b/static/index.html @@ -0,0 +1,415 @@ + + + + + +Очередь… + + + +
Переподключение…
+
+
+

Очередь…

+ как пользоваться +
+
+ + QR-код + +
поделиться
+
+
+
+ +
+

+ + +
+
В очереди
+
+
+
+ + + +