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