init commit

This commit is contained in:
Aleksandr Berkuta 2026-03-27 20:27:56 +03:00
commit 5806fe84c4
17 changed files with 2154 additions and 0 deletions

90
CLAUDE.md Normal file
View 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
View 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
View 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

Binary file not shown.

12
docker-compose.local.yml Normal file
View 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
View 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
View 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
View 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
View 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
View 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)
})
}

BIN
queue Executable file

Binary file not shown.

233
queue-spec.md Normal file
View 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 40130 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 ~46 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
View 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
View 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
View 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">Загрузка&hellip;</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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
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) + ' &nbsp;&middot;&nbsp; ' + 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
View 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&nbsp;секунд, чтобы нажать <strong>Вернуть моё место</strong>, если это была ошибка. Не нажмёт — удалится автоматически. Очередь справляется сама.</p>
<h3>Участник из середины очереди ушёл</h3>
<p class="sub">Любой участник может проголосовать за удаление записи кнопкой <span class="badge">Голосовать за удаление</span>. Нужно набрать <strong>min(3,&nbsp;⌈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
View 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">Переподключение&hellip;</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, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
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) + ' &nbsp;&middot;&nbsp; ' + 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>