234 lines
13 KiB
Markdown
234 lines
13 KiB
Markdown
# 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.
|