init commit
This commit is contained in:
commit
5806fe84c4
90
CLAUDE.md
Normal file
90
CLAUDE.md
Normal file
@ -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
|
||||||
18
Dockerfile
Normal file
18
Dockerfile
Normal file
@ -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"]
|
||||||
143
ROADMAP.md
Normal file
143
ROADMAP.md
Normal file
@ -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
|
||||||
BIN
data/queues.db
Normal file
BIN
data/queues.db
Normal file
Binary file not shown.
12
docker-compose.local.yml
Normal file
12
docker-compose.local.yml
Normal file
@ -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
|
||||||
30
docker-compose.yml
Normal file
30
docker-compose.yml
Normal file
@ -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
|
||||||
14
go.mod
Normal file
14
go.mod
Normal file
@ -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
|
||||||
|
)
|
||||||
10
go.sum
Normal file
10
go.sum
Normal file
@ -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=
|
||||||
257
hub.go
Normal file
257
hub.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
177
main.go
Normal file
177
main.go
Normal file
@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
233
queue-spec.md
Normal file
233
queue-spec.md
Normal file
@ -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 `<img>` 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.
|
||||||
319
queue.go
Normal file
319
queue.go
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
210
registry.go
Normal file
210
registry.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
147
static/about.html
Normal file
147
static/about.html
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1">
|
||||||
|
<title>Электронная очередь</title>
|
||||||
|
<style>
|
||||||
|
*{box-sizing:border-box;margin:0;padding:0}
|
||||||
|
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:#f0f0f0;color:#111;min-height:100vh}
|
||||||
|
header{background:#fff;border-bottom:1px solid #e0e0e0;padding:14px 16px}
|
||||||
|
header h1{font-size:1.2rem;font-weight:700}
|
||||||
|
header p{font-size:.85rem;color:#888;margin-top:2px}
|
||||||
|
main{max-width:520px;margin:0 auto;padding:16px 12px 48px}
|
||||||
|
section{background:#fff;border-radius:10px;padding:18px 16px;margin-bottom:14px;box-shadow:0 1px 3px rgba(0,0,0,.07)}
|
||||||
|
h2{font-size:1rem;font-weight:700;margin-bottom:12px}
|
||||||
|
.qlist{list-style:none}
|
||||||
|
.qlist li{border-bottom:1px solid #f0f0f0;padding:10px 0}
|
||||||
|
.qlist li:last-child{border-bottom:none}
|
||||||
|
.qlist a{text-decoration:none;color:#111;font-weight:600;font-size:.95rem}
|
||||||
|
.qlist a:hover{text-decoration:underline}
|
||||||
|
.qmeta{font-size:.78rem;color:#999;margin-top:2px}
|
||||||
|
.empty{color:#aaa;font-size:.9rem;padding:4px 0}
|
||||||
|
form{display:flex;gap:8px;flex-wrap:wrap}
|
||||||
|
#qname{flex:1;min-width:0;border:1.5px solid #ccc;border-radius:8px;padding:11px 14px;font-size:1rem;outline:none;-webkit-appearance:none}
|
||||||
|
#qname:focus{border-color:#111}
|
||||||
|
form button{background:#111;color:#fff;border:none;border-radius:8px;padding:11px 20px;font-size:.9rem;font-weight:700;cursor:pointer;white-space:nowrap;-webkit-tap-highlight-color:transparent}
|
||||||
|
form button:active{background:#333}
|
||||||
|
ol{padding-left:20px}
|
||||||
|
ol li{margin-bottom:8px;font-size:.9rem;line-height:1.5;color:#444}
|
||||||
|
.note{font-size:.82rem;color:#666;line-height:1.5;background:#fef9c3;border-radius:8px;padding:14px 16px;border:1px solid #fde047}
|
||||||
|
.note p+p{margin-top:8px}
|
||||||
|
footer{text-align:center;color:#bbb;font-size:.78rem;padding:8px 16px 24px}
|
||||||
|
footer a{color:#999}
|
||||||
|
#err{color:#dc2626;font-size:.85rem;margin-top:6px;display:none}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>Электронная очередь</h1>
|
||||||
|
<p>Самоорганизуйтесь без бумажных талончиков</p>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Активные очереди</h2>
|
||||||
|
<ul class="qlist" id="ql"><li class="empty">Загрузка…</li></ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Создать очередь</h2>
|
||||||
|
<form id="nf" onsubmit="createQueue(event)">
|
||||||
|
<input id="qname" type="text" placeholder="Название, напр. «Кабинет 428»"
|
||||||
|
maxlength="100" autocomplete="off" autocorrect="off">
|
||||||
|
<button type="submit">Создать</button>
|
||||||
|
</form>
|
||||||
|
<div id="err">Не удалось создать очередь, попробуйте ещё раз</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Как пользоваться</h2>
|
||||||
|
<ol>
|
||||||
|
<li>Найдите нужную очередь в списке выше или создайте новую.</li>
|
||||||
|
<li>Откройте ссылку или отсканируйте QR-код на странице очереди.</li>
|
||||||
|
<li>Нажмите «Взять номер» — приложение покажет вашу позицию и ориентировочное время ожидания.</li>
|
||||||
|
<li>Когда подойдёт очередь, отметьте себя как ушедшего кнопкой «Ухожу».</li>
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="note">
|
||||||
|
<p><strong>Это неофициальный инструмент самоорганизации</strong>, сделанный энтузиастом, которому надоело стоять в живых очередях. Никаких гарантий бесперебойной работы не даётся.</p>
|
||||||
|
<p>Вопросы и пожелания: <a href="mailto:line.keeper@berkuta.xyz">line.keeper@berkuta.xyz</a></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
<footer>Очереди без записи, без регистрации</footer>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// XSS SAFETY NOTE
|
||||||
|
// All user-supplied strings (q.name only) are passed through esc() before
|
||||||
|
// any innerHTML insertion. q.id is server-generated hex (safe in href).
|
||||||
|
// q.count is an integer. q.createdAt is an integer (Unix ms). No raw
|
||||||
|
// user content is inserted unsanitised.
|
||||||
|
|
||||||
|
function fmtDate(ms) {
|
||||||
|
const d = new Date(ms);
|
||||||
|
return d.toLocaleDateString('ru-RU', {day:'numeric',month:'long',year:'numeric'});
|
||||||
|
}
|
||||||
|
function ruPeople(n) {
|
||||||
|
const m = n % 100, k = n % 10;
|
||||||
|
if (m >= 11 && m <= 19) return 'человек';
|
||||||
|
if (k === 1) return 'человек';
|
||||||
|
if (k >= 2 && k <= 4) return 'человека';
|
||||||
|
return 'человек';
|
||||||
|
}
|
||||||
|
// esc() HTML-encodes a string before inserting into innerHTML.
|
||||||
|
function esc(s) {
|
||||||
|
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadQueues() {
|
||||||
|
const ul = document.getElementById('ql');
|
||||||
|
try {
|
||||||
|
const res = await fetch('/queues');
|
||||||
|
const list = await res.json();
|
||||||
|
if (!list || list.length === 0) {
|
||||||
|
ul.innerHTML = '<li class="empty">Пока нет ни одной очереди — создайте первую!</li>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// q.id is hex (safe in href); q.name is user-supplied — esc() applied.
|
||||||
|
// q.count is integer; fmtDate receives integer ms — no user data.
|
||||||
|
ul.innerHTML = list.map(q => {
|
||||||
|
const cnt = q.count > 0 ? q.count + '\u00a0' + ruPeople(q.count) : 'пусто';
|
||||||
|
return '<li>' +
|
||||||
|
'<a href="/q/' + esc(q.id) + '">' + esc(q.name) + '</a>' +
|
||||||
|
'<div class="qmeta">Создана ' + fmtDate(q.createdAt) + ' · ' + cnt + '</div>' +
|
||||||
|
'</li>';
|
||||||
|
}).join('');
|
||||||
|
} catch(e) {
|
||||||
|
ul.innerHTML = '<li class="empty">Не удалось загрузить список</li>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createQueue(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const name = document.getElementById('qname').value.trim();
|
||||||
|
const err = document.getElementById('err');
|
||||||
|
err.style.display = 'none';
|
||||||
|
fetch('/new', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||||
|
body: 'name=' + encodeURIComponent(name),
|
||||||
|
redirect: 'follow'
|
||||||
|
}).then(res => {
|
||||||
|
if (res.redirected) {
|
||||||
|
location.href = res.url;
|
||||||
|
} else {
|
||||||
|
err.style.display = 'block';
|
||||||
|
}
|
||||||
|
}).catch(() => { err.style.display = 'block'; });
|
||||||
|
}
|
||||||
|
|
||||||
|
loadQueues();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
79
static/help.html
Normal file
79
static/help.html
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1">
|
||||||
|
<title>Справка — Электронная очередь</title>
|
||||||
|
<style>
|
||||||
|
*{box-sizing:border-box;margin:0;padding:0}
|
||||||
|
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:#f0f0f0;color:#111;min-height:100vh}
|
||||||
|
header{background:#fff;border-bottom:1px solid #e0e0e0;padding:14px 16px;display:flex;align-items:center;justify-content:space-between}
|
||||||
|
header h1{font-size:1.1rem;font-weight:700}
|
||||||
|
header a{font-size:.85rem;color:#2563eb;text-decoration:none}
|
||||||
|
header a:hover{text-decoration:underline}
|
||||||
|
main{max-width:520px;margin:0 auto;padding:16px 12px 48px}
|
||||||
|
section{background:#fff;border-radius:10px;padding:18px 16px;margin-bottom:14px;box-shadow:0 1px 3px rgba(0,0,0,.07)}
|
||||||
|
h2{font-size:1rem;font-weight:700;margin-bottom:12px}
|
||||||
|
h3{font-size:.9rem;font-weight:700;margin-bottom:6px;color:#374151}
|
||||||
|
ol{padding-left:20px}
|
||||||
|
ol li,ul li{margin-bottom:8px;font-size:.9rem;line-height:1.5;color:#444}
|
||||||
|
ul{padding-left:20px}
|
||||||
|
p.sub{font-size:.9rem;line-height:1.5;color:#444;margin-bottom:10px}
|
||||||
|
p.sub+p.sub{margin-top:0}
|
||||||
|
.badge{display:inline-block;background:#f3f4f6;color:#374151;border:1px solid #d1d5db;border-radius:5px;padding:1px 7px;font-size:.8rem;font-weight:600;white-space:nowrap}
|
||||||
|
.badge.red{background:#fee2e2;color:#dc2626;border-color:#fca5a5}
|
||||||
|
.badge.yellow{background:#fef9c3;color:#854d0e;border-color:#fde047}
|
||||||
|
.note{font-size:.82rem;color:#666;line-height:1.5;background:#fef9c3;border-radius:8px;padding:14px 16px;border:1px solid #fde047;margin-bottom:14px}
|
||||||
|
.note p+p{margin-top:8px}
|
||||||
|
.spread{font-size:.85rem;color:#555;line-height:1.5;text-align:center;padding:4px 8px}
|
||||||
|
.spread a{color:#2563eb}
|
||||||
|
footer{text-align:center;color:#bbb;font-size:.78rem;padding:8px 16px 24px}
|
||||||
|
footer a{color:#999}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>Справка</h1>
|
||||||
|
<a href="javascript:history.back()">← назад</a>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Как пользоваться</h2>
|
||||||
|
<ol>
|
||||||
|
<li>Отсканируйте QR-код или откройте ссылку на очередь.</li>
|
||||||
|
<li>Нажмите <span class="badge">Взять номер</span> — можно указать имя или псевдоним, или пропустить.</li>
|
||||||
|
<li>Ждите своей очереди. Приложение показывает позицию и примерное время ожидания (рассчитывается после первых трёх завершённых сессий).</li>
|
||||||
|
<li>Когда закончите — нажмите <span class="badge red">Ухожу</span>, чтобы освободить место.</li>
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Правила очереди</h2>
|
||||||
|
|
||||||
|
<h3>Первый человек прошёл в кабинет</h3>
|
||||||
|
<p class="sub">Человек зашёл — и забыл нажать <span class="badge red">Ухожу</span>. Не беда: любой участник очереди может нажать <span class="badge yellow">Отметить ушедшим</span> и освободить место. Запись перейдёт в режим ожидания — у первого есть 60 секунд, чтобы нажать <strong>Вернуть моё место</strong>, если это была ошибка. Не нажмёт — удалится автоматически. Очередь справляется сама.</p>
|
||||||
|
|
||||||
|
<h3>Участник из середины очереди ушёл</h3>
|
||||||
|
<p class="sub">Любой участник может проголосовать за удаление записи кнопкой <span class="badge">Голосовать за удаление</span>. Нужно набрать <strong>min(3, ⌈n/2⌉)</strong> голосов, где n — число человек в очереди. Например: при 4 участниках нужно 2 голоса, при 6 — 3.</p>
|
||||||
|
<p class="sub">Как только порог достигнут — та же 60-секундная пауза: владелец может нажать <strong>Вернуть моё место</strong>. Один участник может голосовать только за одного человека одновременно.</p>
|
||||||
|
|
||||||
|
<h3>Рядом человек без смартфона</h3>
|
||||||
|
<p class="sub">Отсутствие телефона — не стигма. Объясните ему, что здесь есть электронная очередь, и помогите встать. Скажите: «Я сейчас последний — номер #N. Вы встаёте между мной и следующим. Когда подойдёт кто-то новый с телефоном — предупредите его, что вы здесь и стоите без номера, между #N и #N+1».</p>
|
||||||
|
<p class="sub">Каждому достаточно помнить одного соседа — того, кто стоит перед ним. Это надёжнее, чем пытаться удержать в голове всю цепочку и восстанавливать её при разрыве.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="note">
|
||||||
|
<p><strong>Это неофициальный инструмент самоорганизации.</strong> Он никак не связан с организацией, у которой вы стоите в очереди. Никаких гарантий бесперебойной работы не даётся.</p>
|
||||||
|
<p>Здесь нет администратора и организатора. <strong>Вы сами организуете очередь — не очередь организует вас.</strong> Это означает ответственность: отмечайте себя как ушедшего, когда закончили; голосуйте за удаление тех, кто явно ушёл и не реагирует. Без взаимного уважения инструмент не работает.</p>
|
||||||
|
<p>Вопросы и пожелания: <a href="mailto:line.keeper@berkuta.xyz">line.keeper@berkuta.xyz</a></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="spread">
|
||||||
|
<p>Знаете место, где люди часто стоят в живой очереди? <a href="/">Создайте свою очередь</a> и распечатайте QR-код.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
<footer>Очереди без записи, без регистрации</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
415
static/index.html
Normal file
415
static/index.html
Normal file
@ -0,0 +1,415 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1">
|
||||||
|
<title>Очередь…</title>
|
||||||
|
<style>
|
||||||
|
*{box-sizing:border-box;margin:0;padding:0}
|
||||||
|
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:#f0f0f0;color:#111;min-height:100vh}
|
||||||
|
|
||||||
|
header{background:#fff;border-bottom:1px solid #e0e0e0;padding:14px 16px;display:flex;align-items:center;justify-content:space-between;position:sticky;top:0;z-index:10}
|
||||||
|
#ht{display:flex;align-items:center;gap:8px}
|
||||||
|
header h1{font-size:1.1rem;font-weight:700;letter-spacing:-.01em}
|
||||||
|
#ib{font-size:.72rem;color:#555;text-decoration:none;font-weight:600;background:#f3f4f6;border:1.5px solid #d1d5db;border-radius:6px;padding:4px 9px;line-height:1;-webkit-tap-highlight-color:transparent}
|
||||||
|
#ib:hover{background:#e5e7eb;border-color:#9ca3af}
|
||||||
|
#qr-wrap{text-align:center}
|
||||||
|
#qr-img{width:44px;height:44px;border-radius:6px;display:block;cursor:pointer}
|
||||||
|
#qr-lbl{font-size:.6rem;color:#999;margin-top:2px;letter-spacing:.02em}
|
||||||
|
|
||||||
|
main{max-width:520px;margin:0 auto;padding:12px 12px 32px}
|
||||||
|
|
||||||
|
/* restore banner */
|
||||||
|
#rb{background:#fef9c3;border:1px solid #fde047;border-radius:10px;padding:14px 16px;margin-bottom:12px}
|
||||||
|
#rb p{font-size:.9rem;color:#713f12;font-weight:500;margin-bottom:10px}
|
||||||
|
#rb button{background:#111;color:#fff;border:none;border-radius:8px;padding:10px 18px;font-size:.9rem;font-weight:600;cursor:pointer;-webkit-tap-highlight-color:transparent}
|
||||||
|
|
||||||
|
/* join section */
|
||||||
|
#js{background:#fff;border-radius:10px;padding:16px;margin-bottom:12px;box-shadow:0 1px 3px rgba(0,0,0,.07)}
|
||||||
|
#jh{font-size:.9rem;color:#555;margin-bottom:12px;font-weight:500}
|
||||||
|
#jb{width:100%;background:#111;color:#fff;border:none;border-radius:8px;padding:14px;font-size:1rem;font-weight:700;cursor:pointer;letter-spacing:.01em;-webkit-tap-highlight-color:transparent}
|
||||||
|
#jb:active{background:#333}
|
||||||
|
#jf{margin-top:4px}
|
||||||
|
#ni{width:100%;border:1.5px solid #ccc;border-radius:8px;padding:12px 14px;font-size:1rem;margin-bottom:10px;outline:none;-webkit-appearance:none}
|
||||||
|
#ni:focus{border-color:#111}
|
||||||
|
.row{display:flex;gap:8px}
|
||||||
|
.row button{flex:1;padding:12px;border-radius:8px;font-size:.9rem;font-weight:600;cursor:pointer;border:1.5px solid transparent;-webkit-tap-highlight-color:transparent}
|
||||||
|
|
||||||
|
/* queue */
|
||||||
|
#ql-hdr{font-size:.75rem;font-weight:600;color:#999;text-transform:uppercase;letter-spacing:.07em;margin-bottom:8px;padding-left:2px}
|
||||||
|
.empty{text-align:center;padding:40px 20px;color:#aaa;font-size:.95rem}
|
||||||
|
|
||||||
|
/* entry card */
|
||||||
|
.card{background:#fff;border-radius:10px;margin-bottom:10px;padding:14px 14px 10px;box-shadow:0 1px 3px rgba(0,0,0,.07);transition:opacity .25s}
|
||||||
|
.card.mine{border:2px solid #2563eb}
|
||||||
|
.card.dim{opacity:.45}
|
||||||
|
.top{display:flex;align-items:center;gap:12px}
|
||||||
|
.num{width:36px;height:36px;border-radius:50%;background:#111;color:#fff;display:flex;align-items:center;justify-content:center;font-size:.85rem;font-weight:700;flex-shrink:0}
|
||||||
|
.num.me{background:#2563eb}
|
||||||
|
.inf{flex:1;min-width:0}
|
||||||
|
.nm{font-size:.95rem;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:flex;align-items:center;gap:6px}
|
||||||
|
.you{font-size:.7rem;background:#eff6ff;color:#2563eb;border-radius:4px;padding:1px 6px;font-weight:700;flex-shrink:0}
|
||||||
|
.meta{font-size:.78rem;color:#888;margin-top:2px}
|
||||||
|
.wi{color:#2563eb;font-weight:500}
|
||||||
|
|
||||||
|
/* pending info */
|
||||||
|
.pi{background:#fef9c3;border-radius:7px;padding:8px 12px;margin-top:8px;font-size:.82rem;color:#713f12;font-weight:500}
|
||||||
|
.pi span{font-weight:700;font-size:.9rem}
|
||||||
|
/* vote count */
|
||||||
|
.vc{font-size:.78rem;color:#b45309;background:#fffbeb;border-radius:5px;padding:3px 8px;margin-top:6px;display:inline-block;font-weight:600}
|
||||||
|
|
||||||
|
/* action buttons */
|
||||||
|
.acts{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px}
|
||||||
|
.btn{padding:9px 14px;border-radius:7px;font-size:.82rem;font-weight:600;cursor:pointer;border:1.5px solid transparent;-webkit-tap-highlight-color:transparent}
|
||||||
|
.btn:disabled{opacity:.4;cursor:default}
|
||||||
|
.bd{background:#fee2e2;color:#dc2626;border-color:#fca5a5}
|
||||||
|
.bw{background:#fef9c3;color:#854d0e;border-color:#fde047}
|
||||||
|
.bn{background:#f3f4f6;color:#374151;border-color:#d1d5db}
|
||||||
|
|
||||||
|
/* inline confirm */
|
||||||
|
.cf{background:#fee2e2;border-radius:8px;padding:10px 12px;margin-top:8px}
|
||||||
|
.cf p{font-size:.85rem;color:#991b1b;font-weight:500;margin-bottom:8px}
|
||||||
|
.cf .row button{padding:9px}
|
||||||
|
.cfok{background:#dc2626;color:#fff;border-color:#dc2626}
|
||||||
|
.cfno{background:#f3f4f6;color:#374151;border-color:#d1d5db}
|
||||||
|
|
||||||
|
/* toast */
|
||||||
|
#toast{position:fixed;bottom:20px;left:50%;transform:translateX(-50%);background:#1f2937;color:#fff;padding:10px 18px;border-radius:8px;font-size:.85rem;opacity:0;transition:opacity .2s;pointer-events:none;max-width:88vw;text-align:center;z-index:100}
|
||||||
|
#toast.show{opacity:1}
|
||||||
|
|
||||||
|
/* disconnected banner */
|
||||||
|
#disc{display:none;position:fixed;top:0;left:0;right:0;background:#dc2626;color:#fff;font-size:.82rem;font-weight:600;text-align:center;padding:6px;z-index:20}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="disc">Переподключение…</div>
|
||||||
|
<header>
|
||||||
|
<div id="ht">
|
||||||
|
<h1 id="qn">Очередь…</h1>
|
||||||
|
<a href="/help.html" target="_blank" id="ib">как пользоваться</a>
|
||||||
|
</div>
|
||||||
|
<div id="qr-wrap">
|
||||||
|
<a id="qr-lnk" href="#" target="_blank" title="Показать QR-код">
|
||||||
|
<img id="qr-img" src="" alt="QR-код">
|
||||||
|
</a>
|
||||||
|
<div id="qr-lbl">поделиться</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<div id="rb" hidden>
|
||||||
|
<p id="rb-txt"></p>
|
||||||
|
<button onclick="doRestore()">Вернуть моё место</button>
|
||||||
|
</div>
|
||||||
|
<div id="js">
|
||||||
|
<p id="jh"></p>
|
||||||
|
<button id="jb" onclick="showForm()">Взять номер</button>
|
||||||
|
<div id="jf" hidden>
|
||||||
|
<input id="ni" type="text" placeholder="Ваше имя или псевдоним — необязательно"
|
||||||
|
maxlength="50" autocomplete="off" autocorrect="off">
|
||||||
|
<div class="row">
|
||||||
|
<button class="btn bn" onclick="doJoin('')">Пропустить</button>
|
||||||
|
<button class="btn bd" onclick="doJoin(V('ni'))">Встать</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="ql-hdr">В очереди</div>
|
||||||
|
<div id="ql"></div>
|
||||||
|
</main>
|
||||||
|
<div id="toast"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// XSS SAFETY NOTE
|
||||||
|
// The only user-supplied string that reaches other browsers is entry.name,
|
||||||
|
// which is sanitised server-side (max 50 runes, no null bytes) and HTML-escaped
|
||||||
|
// on the client via esc() before any innerHTML insertion.
|
||||||
|
// All other dynamic values injected into HTML are server-generated hex IDs
|
||||||
|
// (safe in attribute values) or integers (always numeric, never user input).
|
||||||
|
|
||||||
|
// Queue ID is the third path segment: /q/{id}
|
||||||
|
const queueId = location.pathname.split('/')[2] || '';
|
||||||
|
const TK = 'qt:' + queueId; // localStorage key for token, scoped per queue
|
||||||
|
|
||||||
|
// Set QR link/src now so they're correct before first state arrives.
|
||||||
|
const qrBase = '/q/' + queueId + '/qr.png';
|
||||||
|
document.getElementById('qr-img').src = qrBase;
|
||||||
|
document.getElementById('qr-lnk').href = qrBase;
|
||||||
|
|
||||||
|
// ---- State ----
|
||||||
|
const S = {
|
||||||
|
entries: [], avgDuration: null,
|
||||||
|
myEntryId: null,
|
||||||
|
myToken: localStorage.getItem(TK) || '',
|
||||||
|
showForm: false,
|
||||||
|
confirmId: null // own-entry confirm = entryId; mark-done confirm = "mark:<id>"
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- WebSocket ----
|
||||||
|
let ws;
|
||||||
|
function connect() {
|
||||||
|
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||||
|
ws = new WebSocket(proto + '://' + location.host + '/q/' + queueId + '/ws');
|
||||||
|
ws.onopen = () => {
|
||||||
|
document.getElementById('disc').style.display = 'none';
|
||||||
|
ws.send(JSON.stringify({type: 'init', token: S.myToken}));
|
||||||
|
};
|
||||||
|
ws.onmessage = e => onMsg(JSON.parse(e.data));
|
||||||
|
ws.onclose = () => {
|
||||||
|
document.getElementById('disc').style.display = 'block';
|
||||||
|
setTimeout(connect, 2000);
|
||||||
|
};
|
||||||
|
ws.onerror = () => ws.close();
|
||||||
|
}
|
||||||
|
function send(obj) {
|
||||||
|
if (ws && ws.readyState === 1) ws.send(JSON.stringify(obj));
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMsg(m) {
|
||||||
|
if (m.type === 'hello') {
|
||||||
|
S.myEntryId = m.yourEntryId || null;
|
||||||
|
render();
|
||||||
|
} else if (m.type === 'joined') {
|
||||||
|
S.myToken = m.yourToken;
|
||||||
|
S.myEntryId = m.yourEntryId;
|
||||||
|
localStorage.setItem(TK, m.yourToken);
|
||||||
|
render();
|
||||||
|
} else if (m.type === 'state') {
|
||||||
|
S.entries = m.entries;
|
||||||
|
S.avgDuration = m.avgDuration;
|
||||||
|
if (m.name) {
|
||||||
|
document.getElementById('qn').textContent = m.name;
|
||||||
|
document.title = m.name;
|
||||||
|
}
|
||||||
|
// Entry may have been removed externally (voted out, timer expired).
|
||||||
|
if (S.myEntryId && !m.entries.find(e => e.id === S.myEntryId)) {
|
||||||
|
S.myEntryId = null;
|
||||||
|
S.myToken = '';
|
||||||
|
localStorage.removeItem(TK);
|
||||||
|
}
|
||||||
|
render();
|
||||||
|
} else if (m.type === 'error' && m.reason === 'already_voting') {
|
||||||
|
toast('Вы уже голосуете за удаление №' + m.onNumber);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Actions ----
|
||||||
|
function showForm() {
|
||||||
|
S.showForm = true;
|
||||||
|
render();
|
||||||
|
setTimeout(() => document.getElementById('ni').focus(), 50);
|
||||||
|
}
|
||||||
|
function hideForm() { S.showForm = false; render(); }
|
||||||
|
function doJoin(name) { send({type: 'join', name}); hideForm(); }
|
||||||
|
function doDone() { S.confirmId = S.myEntryId; render(); }
|
||||||
|
function cancelConfirm() { S.confirmId = null; render(); }
|
||||||
|
function confirmDone() {
|
||||||
|
send({type: 'done'});
|
||||||
|
S.myEntryId = null;
|
||||||
|
S.myToken = '';
|
||||||
|
localStorage.removeItem(TK);
|
||||||
|
S.confirmId = null;
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
function markDone(id) { S.confirmId = 'mark:' + id; render(); }
|
||||||
|
function confirmMark(id) {
|
||||||
|
send({type: 'mark_done', entryId: id});
|
||||||
|
S.confirmId = null;
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
function voteRemove(id) { send({type: 'vote_remove', entryId: id}); }
|
||||||
|
function doRestore() { send({type: 'restore'}); }
|
||||||
|
|
||||||
|
// ---- Countdown ----
|
||||||
|
let ticker;
|
||||||
|
function startTicker() { if (!ticker) ticker = setInterval(tick, 1000); }
|
||||||
|
function stopTicker() { clearInterval(ticker); ticker = null; }
|
||||||
|
function tick() {
|
||||||
|
document.querySelectorAll('[data-pa]').forEach(el => {
|
||||||
|
const rem = 60 - Math.floor((Date.now() - +el.dataset.pa) / 1000);
|
||||||
|
el.textContent = rem > 0 ? rem + 'с' : 'удаление\u2026';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Render helpers ----
|
||||||
|
|
||||||
|
// esc() HTML-encodes a string before inserting into innerHTML.
|
||||||
|
function esc(s) {
|
||||||
|
return String(s)
|
||||||
|
.replace(/&/g, '&').replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
function V(id) { return document.getElementById(id).value.trim(); }
|
||||||
|
function fmt(ms) {
|
||||||
|
const d = new Date(ms);
|
||||||
|
const p = n => String(n).padStart(2, '0');
|
||||||
|
return p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds());
|
||||||
|
}
|
||||||
|
function fmtWait(secs) {
|
||||||
|
return secs < 90 ? '~' + Math.round(secs) + ' с' : '~' + Math.round(secs / 60) + ' мин';
|
||||||
|
}
|
||||||
|
function ruPeople(n) {
|
||||||
|
const m = n % 100, k = n % 10;
|
||||||
|
if (m >= 11 && m <= 19) return 'человек';
|
||||||
|
if (k === 1) return 'человек';
|
||||||
|
if (k >= 2 && k <= 4) return 'человека';
|
||||||
|
return 'человек';
|
||||||
|
}
|
||||||
|
function thresh(n) { return Math.min(3, Math.max(1, Math.ceil(n / 2))); }
|
||||||
|
|
||||||
|
// ---- Main render ----
|
||||||
|
function render() {
|
||||||
|
const entries = S.entries;
|
||||||
|
const myE = entries.find(e => e.id === S.myEntryId) || null;
|
||||||
|
const inQ = !!myE;
|
||||||
|
|
||||||
|
// Restore banner (shown when own entry is in pending_removal)
|
||||||
|
const rb = document.getElementById('rb');
|
||||||
|
if (myE && myE.status === 'pending_removal') {
|
||||||
|
const by = (myE.pendingBy || []).map(n => '#' + n).join(', ');
|
||||||
|
const verb = (myE.pendingBy || []).length > 1
|
||||||
|
? ' проголосовали за ваше удаление' : ' отметил вас как обслуженного';
|
||||||
|
// rb-txt is set via textContent — safe from XSS regardless.
|
||||||
|
document.getElementById('rb-txt').textContent = by + verb + ' \u2014 это ошибка?';
|
||||||
|
rb.hidden = false;
|
||||||
|
} else {
|
||||||
|
rb.hidden = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Join section
|
||||||
|
const js = document.getElementById('js');
|
||||||
|
if (inQ) {
|
||||||
|
js.hidden = true;
|
||||||
|
} else {
|
||||||
|
js.hidden = false;
|
||||||
|
// jh uses textContent — safe.
|
||||||
|
document.getElementById('jh').textContent =
|
||||||
|
entries.length === 0
|
||||||
|
? 'Очередь пуста \u2014 будьте первым!'
|
||||||
|
: 'В очереди: ' + entries.length + ' ' + ruPeople(entries.length);
|
||||||
|
document.getElementById('jb').hidden = S.showForm;
|
||||||
|
document.getElementById('jf').hidden = !S.showForm;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Queue list
|
||||||
|
const ql = document.getElementById('ql');
|
||||||
|
if (entries.length === 0) {
|
||||||
|
ql.innerHTML = '<p class="empty">Отсканируйте QR-код, чтобы встать в очередь</p>';
|
||||||
|
stopTicker();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let hasPending = false;
|
||||||
|
|
||||||
|
// Which entry is this user currently voting to remove (if any)?
|
||||||
|
let myVoteOn = 0;
|
||||||
|
if (myE) {
|
||||||
|
for (const e of entries) {
|
||||||
|
if ((e.pendingBy || []).includes(myE.number)) { myVoteOn = e.number; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build HTML for each entry card.
|
||||||
|
// IMPORTANT: all user-supplied strings (only e.name) are passed through esc().
|
||||||
|
// e.id is server-generated hex (safe in attribute positions).
|
||||||
|
// e.number is an integer (safe).
|
||||||
|
// pendingBy values are integers (safe).
|
||||||
|
const parts = entries.map((e, idx) => {
|
||||||
|
const mine = e.id === S.myEntryId;
|
||||||
|
const pending = e.status === 'pending_removal';
|
||||||
|
const first = idx === 0;
|
||||||
|
if (pending) hasPending = true;
|
||||||
|
|
||||||
|
// Wait estimate line
|
||||||
|
let waitHtml;
|
||||||
|
if (idx === 0) {
|
||||||
|
waitHtml = '<span class="wi">первый</span>';
|
||||||
|
} else if (S.avgDuration != null) {
|
||||||
|
waitHtml = '<span class="wi">ожид. ' + fmtWait(S.avgDuration * idx) + '</span>';
|
||||||
|
} else {
|
||||||
|
waitHtml = '<span class="wi">ожид. ?</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pending / vote-count info block
|
||||||
|
let pendHtml = '';
|
||||||
|
if (pending && e.pendingAt) {
|
||||||
|
const rem = Math.max(0, 60 - Math.floor((Date.now() - e.pendingAt) / 1000));
|
||||||
|
// pendingBy contains integers — no esc() needed, but we join them safely.
|
||||||
|
const by = (e.pendingBy || []).map(n => '#' + n).join(', ');
|
||||||
|
pendHtml = '<div class="pi">Отметил ' + by +
|
||||||
|
' \u2014 удаление через <span data-pa="' + e.pendingAt + '">' + rem + 'с</span></div>';
|
||||||
|
} else if (!pending && (e.pendingBy || []).length > 0) {
|
||||||
|
const t = thresh(entries.length);
|
||||||
|
pendHtml = '<div class="vc">' + e.pendingBy.length + '/' + t + ' за удаление</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Action area (inline confirm or buttons)
|
||||||
|
let actHtml = '';
|
||||||
|
if (S.confirmId === e.id) {
|
||||||
|
// Inline confirm for own "I'm done"
|
||||||
|
actHtml = '<div class="cf"><p>Покинуть очередь?</p>' +
|
||||||
|
'<div class="row">' +
|
||||||
|
'<button class="btn cfno" onclick="cancelConfirm()">Отмена</button>' +
|
||||||
|
'<button class="btn cfok" onclick="confirmDone()">Ухожу</button>' +
|
||||||
|
'</div></div>';
|
||||||
|
} else if (S.confirmId === 'mark:' + e.id) {
|
||||||
|
// Inline confirm for "Mark as done" on #1
|
||||||
|
actHtml = '<div class="cf"><p>Отметить №' + e.number + ' как ушедшего?</p>' +
|
||||||
|
'<div class="row">' +
|
||||||
|
'<button class="btn cfno" onclick="cancelConfirm()">Отмена</button>' +
|
||||||
|
'<button class="btn cfok" onclick="confirmMark(\'' + e.id + '\')">Отметить</button>' +
|
||||||
|
'</div></div>';
|
||||||
|
} else if (mine && !pending) {
|
||||||
|
actHtml = '<div class="acts"><button class="btn bd" onclick="doDone()">Ухожу</button></div>';
|
||||||
|
} else if (!mine && first && !pending && inQ) {
|
||||||
|
actHtml = '<div class="acts"><button class="btn bw" onclick="markDone(\'' + e.id + '\')">Отметить ушедшим</button></div>';
|
||||||
|
} else if (!mine && !first && !pending && inQ) {
|
||||||
|
const alreadyVotedThis = (e.pendingBy || []).includes(myE.number);
|
||||||
|
if (alreadyVotedThis) {
|
||||||
|
actHtml = '<div class="acts"><button class="btn bn" disabled>Голосовать за удаление</button></div>';
|
||||||
|
} else if (myVoteOn !== 0) {
|
||||||
|
actHtml = '<div class="acts"><button class="btn bn" disabled>Голосовать за удаление</button></div>';
|
||||||
|
} else {
|
||||||
|
const t = thresh(entries.length);
|
||||||
|
const v = (e.pendingBy || []).length;
|
||||||
|
const label = v > 0 ? 'Голосовать за удаление (' + v + '/' + t + ')' : 'Голосовать за удаление';
|
||||||
|
actHtml = '<div class="acts"><button class="btn bn" onclick="voteRemove(\'' + e.id + '\')">' +
|
||||||
|
label + '</button></div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// e.name is user-supplied — MUST use esc().
|
||||||
|
const nameStr = e.name ? esc(e.name) : '#' + e.number;
|
||||||
|
const youBadge = mine ? '<span class="you">вы</span>' : '';
|
||||||
|
|
||||||
|
return '<div class="card' + (mine ? ' mine' : '') + (pending ? ' dim' : '') + '">' +
|
||||||
|
'<div class="top">' +
|
||||||
|
'<div class="num' + (mine ? ' me' : '') + '">' + e.number + '</div>' +
|
||||||
|
'<div class="inf">' +
|
||||||
|
'<div class="nm">' + nameStr + youBadge + '</div>' +
|
||||||
|
'<div class="meta">' + fmt(e.joinedAt) + ' · ' + waitHtml + '</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>' +
|
||||||
|
pendHtml + actHtml +
|
||||||
|
'</div>';
|
||||||
|
});
|
||||||
|
|
||||||
|
ql.innerHTML = parts.join('');
|
||||||
|
hasPending ? startTicker() : stopTicker();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Toast ----
|
||||||
|
let toastT;
|
||||||
|
function toast(msg) {
|
||||||
|
const el = document.getElementById('toast');
|
||||||
|
el.textContent = msg; // textContent is always XSS-safe
|
||||||
|
el.classList.add('show');
|
||||||
|
clearTimeout(toastT);
|
||||||
|
toastT = setTimeout(() => el.classList.remove('show'), 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Boot ----
|
||||||
|
connect();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
x
Reference in New Issue
Block a user