- HTML 52.7%
- Python 45.8%
- Nix 1.5%
| frunza | ||
| frunza_queue | ||
| .gitignore | ||
| ARCHITECTURE.md | ||
| devenv.lock | ||
| devenv.nix | ||
| devenv.yaml | ||
| HARDENING_REPORT.md | ||
| manage.py | ||
| pyproject.toml | ||
| README.md | ||
| uv.lock | ||
Frunza Queue — Volleyball Court Rotation
A mobile-first Django application that replaces a paper notebook for managing volleyball court rotation queues at Frunzenskaya ("Frunza").
The Problem
At Frunza, volleyball teams rotate through a single court. The current process is entirely verbal:
- Two teams are actively playing.
- Additional teams line up in a queue.
- The next team in queue is responsible for keeping score.
- When a match finishes, the winning team stays on court.
- The losing team leaves the court.
- The next queued team enters the court.
- New teams may arrive throughout the day and join the queue.
This becomes confusing quickly — people forget the queue order, score disputes arise, and there is no shared source of truth.
Frunza Queue is that source of truth. It runs on any phone browser and requires no login, no registration, and no setup beyond typing a one-word admin password.
Queue Rules
- Two teams play a single match at a time.
- All other teams wait in a first-in-first-out queue.
- The first team in the queue is "Next Up" — they keep score and enter the court when the current match ends.
- When a match ends, the winner stays on court. The loser leaves. The next queued team enters.
- If there is no queued team, the winner waits on court until a new team arrives.
- A match is never declared automatically — an admin always confirms which team won (or acknowledges a draw / cancel).
- Points are recorded manually by an admin for each active team (+1 / -1 per tap).
- Teams may arrive at any time. The system starts empty.
Architecture
┌─────────────────────────────────────┐
│ nginx / gunicorn │ ← HTTPS + WSGI
│ frunza.sudakov.site │
├─────────────────────────────────────┤
│ Django (frunza/) │ ← Models, Views, Templates
├─────────────────────────────────────┤
│ SQLite (db.sqlite3) │ ← Single file, no DB server
└─────────────────────────────────────┘
Stack
| Layer | Choice | Rationale |
|---|---|---|
| Framework | Django 5.x | Mature, batteries-included, SQLite-ready |
| Database | SQLite | Zero config, survives reboot, single file |
| Templating | Django Templates | Server-rendered, no JS framework needed |
| Frontend | HTMX + CSS | Minimal client-side, no build step |
| WSGI Server | Gunicorn | Production-grade, easy to configure |
| Web Server | nginx | Reverse proxy, static files, HTTPS |
Design Decisions
- No user accounts. The system is shared by everyone at the court. A single admin password prevents random people from editing scores.
- One court, one match. There is exactly one active match at any time.
- No tournament logic. No brackets, no elimination rounds, no automatic advancement. Just a queue.
- State lives in the database. Server restarts are safe.
- Minimal JS. HTMX for dynamic updates, a few lines of vanilla JS for score buttons. No build step.
Mobile-First UI
The layout is designed for a phone screen held in one hand:
- Header — Site name, admin unlock button, admin status indicator.
- Active Match — Two team names + scores. Large, bold, tap-friendly.
- Queue — List of waiting teams. First one highlighted as "Next Up".
- Recent Activity — Small, scrollable event log.
Admin controls appear inline only when admin mode is unlocked.
Database Schema
Team
| Column | Type | Notes |
|---|---|---|
| id | AutoField (PK) | |
| name | CharField(100) | Unique, e.g. "Team Eagles" |
| is_active | BooleanField | Currently playing |
| score | IntegerField | Current match score |
| queue_order | IntegerField | Position in queue (nullable) |
| created_at | DateTimeField | Auto-now-add |
is_active is True for the two teams on court. queue_order is NULL
for active teams and a sequential integer for waiting teams. This avoids
an extra join table and makes queue reordering trivial.
Match
| Column | Type | Notes |
|---|---|---|
| id | AutoField (PK) | |
| team_a | ForeignKey(Team) | |
| team_b | ForeignKey(Team) | |
| score_a | IntegerField | Final score for team_a |
| score_b | IntegerField | Final score for team_b |
| winner | ForeignKey(Team) | Nullable — admin picks winner |
| started_at | DateTimeField | |
| ended_at | DateTimeField | Nullable until match ends |
EventLog
| Column | Type | Notes |
|---|---|---|
| id | AutoField (PK) | |
| timestamp | DateTimeField | Auto-now-add |
| event_type | CharField(50) | E.g. "team_joined", "match_end" |
| description | CharField(255) | Human-readable text |
AdminSettings
| Column | Type | Notes |
|---|---|---|
| id | AutoField (PK) | Singleton row |
| admin_password | CharField(128) | Django-hashed password |
Only one row ever exists. The password is set via Django createsuperuser
or a management command, never in the admin UI (it would be a security risk
to have a self-service password field on a public page).
Setup Instructions
Prerequisites
- Python 3.11+
uv(recommended) orpip- (Optional)
direnvfor automatic environment loading
Local Development
# Clone the repo
git clone <url> frunza
cd frunza
# Create virtual environment and install dependencies
uv sync
# Run database migrations
uv run python manage.py migrate
# Set the admin password (you'll be prompted)
uv run python manage.py set_admin_password
# Start the development server
uv run python manage.py runserver
Visit http://127.0.0.1:8000.
With direnv
If you have direnv and devenv installed:
cd frunza
direnv allow
This will activate the devenv shell automatically when you enter the directory.
Management Commands
set_admin_password
Prompts for an admin password and stores it as a Django-hashed password in the database. Run once during initial setup.
uv run python manage.py set_admin_password
If the password row already exists, it will be updated.
reset
Resets the system to a clean state (deletes all teams, matches, and events).
uv run python manage.py reset
Deployment
Production Server (with SQLite)
The application uses SQLite. For most real-world deployments this is fine — this application has at most ~20 teams and ~100 matches per day. SQLite can handle millions of requests at that scale.
Do not use SQLite over a network filesystem (NFS, distributed storage).
Keep db.sqlite3 on local disk.
nginx + Gunicorn
# Install gunicorn (already in dependencies)
uv sync
# Collect static files
uv run python manage.py collectstatic --noinput
# Run with gunicorn (3 workers should be plenty)
uv run gunicorn --workers 3 --bind 127.0.0.1:8001 frunza.wsgi:application
Example nginx config:
server {
listen 80;
server_name frunza.sudakov.site;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
server_name frunza.sudakov.site;
ssl_certificate /etc/nginx/ssl/frunza.sudakov.site.crt;
ssl_certificate_key /etc/nginx/ssl/frunza.sudakov.site.key;
client_max_body_size 4M;
location /static/ {
alias /home/denis/frunza/static/;
expires 30d;
}
location / {
proxy_pass http://127.0.0.1:8001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
systemd service
[Unit]
Description=Frunza Queue Gunicorn
After=network.target
[Service]
Type=simple
User=denis
WorkingDirectory=/home/denis/frunza
Environment=DJANGO_SETTINGS_MODULE=frunza.settings
ExecStart=/home/denis/frunza/.venv/bin/gunicorn \
--workers 3 \
--bind 127.0.0.1:8001 \
frunza.wsgi:application
Restart=always
[Install]
WantedBy=multi-user.target
Hosting on frunza.sudakov.site
- Set up DNS A record pointing
frunza.sudakov.siteto your server IP. - Configure nginx as above with SSL certificates (Let's Encrypt).
- Create the
db.sqlite3file in/home/denis/frunza/with proper permissions so the gunicorn user can read/write. - Run
set_admin_passwordonce. - Start the gunicorn service.
Routes
| Method | Path | Description |
|---|---|---|
| GET | / | Main page |
| POST | /admin/unlock/ | Unlock admin mode (accepts password) |
| POST | /admin/lock/ | Lock admin mode |
| POST | /teams/add/ | Add a team (accepts name) |
| POST | /teams//rename/ | Rename a team (accepts name) |
| POST | /teams//delete/ | Delete a team |
| POST | /teams//move-up/ | Move team up in queue |
| POST | /teams//move-down/ | Move team down in queue |
| POST | /match/score/ | Adjust score (accepts team_id, points) |
| POST | /match/winner/ | Declare winner (accepts winner_id) |
| POST | /match/start/ | Start a match with two teams |
HTTP + HTMX Notes
All POST endpoints redirect back to / on success. If HTMX is enabled on
the client, the response carries HX-Redirect headers so the page
automatically refreshes.
For score updates, a targeted HTMX swap pattern is used: the match card is replaced in-place without a full page reload.
Assumptions
- One court only. This is not configurable. The app models a single volleyball court with a single queue.
- No user accounts. Everyone at the court sees the same view. The admin password is a shared secret (like a WiFi password).
- The admin is present. An admin must add teams, record scores, and declare winners. This mirrors the current paper system where someone is always managing the queue.
- No automatic winner detection. The admin decides who won and triggers the rotation. This handles edge cases like draws, walk-offs, and informal games gracefully.
- Teams arrive gradually. The system starts empty. Teams are added one by one as people show up.
- SQLite is sufficient. Daily traffic is at most a few hundred requests from a handful of phones. SQLite handles this easily.
- Phone-first. The UI targets a mobile viewport. Desktop is supported but not optimised.
- The database is the source of truth. No state is stored in memory. Server restarts are safe.
Future Roadmap (V2+ Ideas)
These are explicitly not part of V1. They are listed here to document what was considered and deferred.
- Match timer / clock display — Show elapsed time on the active match.
- History page — Browse completed matches and past events.
- Team stats — Win/loss records, streak tracking.
- Persistent team database — Teams re-joined later in the day are recognised and their history preserved.
- Dark mode — Mobile-friendly dark theme.
- Multiple courts — If Frunza ever adds a second court.
- Web push notifications — Notify the next-up team it's their turn.
- Export / share — Share queue status as a simple text message.
- API — For future integrations or bots.
Development
Project Structure
frunza/
├── README.md
├── pyproject.toml
├── manage.py
├── frunza/ # Django project package
│ ├── __init__.py
│ ├── settings.py
│ ├── urls.py
│ ├── wsgi.py
│ └── asgi.py
├── frunza_queue/ # Main app (named to avoid stdlib `queue` conflict)
│ ├── __init__.py
│ ├── admin.py
│ ├── apps.py
│ ├── models.py
│ ├── views.py
│ ├── urls.py
│ ├── templates/
│ │ └── frunza_queue/
│ │ └── index.html
│ ├── static/
│ │ └── frunza_queue/
│ │ └── style.css
│ └── management/
│ └── commands/
│ ├── set_admin_password.py
│ └── reset.py
├── static/ # Collected static files
└── db.sqlite3 # Created after first migrate
Adding Features
- Model changes →
python manage.py makemigrations && migrate - View changes → edit
frunza_queue/views.py - Template changes → edit
frunza_queue/templates/frunza_queue/index.html - Style changes → edit
frunza_queue/static/frunza_queue/style.css
License
MIT