qkeeper/queue-spec.md
Aleksandr Berkuta 5806fe84c4 init commit
2026-03-27 20:27:56 +03:00

234 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.