Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
🥃 Bourbonacci
A self-hosted infinity bottle tracker. Log every bourbon you add to your blend, track what you've consumed, and watch your bottle's story unfold over time. Community bottles are visible to anyone who visits the site — click a card to browse the full bourbon list inside that bottle.
Built with FastAPI, vanilla JS, and MySQL — fully containerized with Docker Compose. No framework dependencies on the frontend.
The Name
Bourbonacci is a blend of Bourbon and the Fibonacci sequence. The Fibonacci sequence — 1, 1, 2, 3, 5, 8, 13… — builds endlessly on everything that came before it, with no defined end. An infinity bottle works the same way: every addition layers on top of the existing blend, compounding in complexity over time. Just like the sequence, your bottle is never truly finished.
Features
- Infinity bottle tracking — log
addentries (bourbon name, proof, shots) andremoveentries (shots consumed) - Live stats — bourbons added, total shots poured in, shots remaining, estimated weighted proof
- Bottle fill bar — visual level indicator based on your configured bottle size
- Community page — public leaderboard of all bottles; click any card to see a searchable, alphabetical bourbon list
- Settings modal — update display name, bottle size, timezone, and password from any page via the gear icon
- Admin panel — list all users, reset passwords, disable/enable accounts, delete users, and impersonate any user for debugging
- JWT auth — Bearer token auth with impersonation support (admin tokens carry an
admin_idclaim) - About page — public explainer on what an infinity bottle is, why to track it, and how to get started
Stack
| Layer | Technology |
|---|---|
| Backend | FastAPI (Python 3.12), async SQLAlchemy 2.0, Uvicorn |
| Database | MySQL 8 |
| Frontend | Vanilla HTML/CSS/JS (no framework) |
| Reverse Proxy | Nginx |
| Auth | JWT via python-jose, passwords hashed with passlib[bcrypt==4.0.1] |
| Container | Docker Compose |
Project Structure
bourbonacci/
├── backend/
│ ├── app/
│ │ ├── main.py # FastAPI app entry point, lifespan (DB init + admin seed)
│ │ ├── config.py # Pydantic settings (reads from .env)
│ │ ├── database.py # Async engine, session factory, Base, init_db()
│ │ ├── dependencies.py # get_db, get_current_user, get_current_admin
│ │ ├── models/
│ │ │ ├── user.py # User ORM model
│ │ │ └── entry.py # Entry ORM model (add/remove types)
│ │ ├── routers/
│ │ │ ├── auth.py # POST /api/auth/register, /api/auth/login
│ │ │ ├── users.py # GET/PUT /api/users/me, PUT /api/users/me/password
│ │ │ ├── entries.py # CRUD /api/entries, GET /api/entries/stats
│ │ │ ├── public.py # GET /api/public/stats (unauthenticated)
│ │ │ └── admin.py # Admin user management + impersonation
│ │ ├── schemas/
│ │ │ ├── user.py # Pydantic schemas for user/auth
│ │ │ └── entry.py # Pydantic schemas for entries/stats
│ │ └── utils/
│ │ └── security.py # JWT creation/decode, password hashing
│ ├── Dockerfile
│ └── requirements.txt
├── frontend/
│ ├── index.html # Landing page + public community bottles
│ ├── about.html # What is an infinity bottle? + Bourbonacci explainer
│ ├── login.html
│ ├── register.html
│ ├── dashboard.html # My Bottle — stats, fill bar, entry log
│ ├── log.html # Log add or remove entries
│ ├── profile.html # Profile settings (display name, timezone, password)
│ ├── admin.html # Admin user management panel
│ ├── css/style.css # Bourbon-themed dark UI
│ └── js/
│ ├── api.js # Fetch wrapper, token injection, escHtml, admin API
│ ├── auth.js # Auth state, nav rendering, settings modal
│ └── admin.js # Admin page logic (user table, modals, impersonation)
├── nginx/
│ └── default.conf # Serves frontend, proxies /api/ to backend:8000
├── docker-compose.yml
└── .env.example
Pages
| Page | Auth | Description |
|---|---|---|
/index.html |
No | Landing page with community bottle cards; click to browse bourbons |
/about.html |
No | What is an infinity bottle? Why Bourbonacci? How to get started |
/login.html |
No | Login form — redirects to /index.html on success |
/register.html |
No | Registration form |
/dashboard.html |
Yes | My Bottle — stats, fill bar, full entry log with delete |
/log.html |
Yes | Log an add or remove entry |
/profile.html |
Yes | Edit display name, timezone, bottle size, password |
/admin.html |
Admin | User management — reset PW, disable, impersonate, delete |
Navigation
The top-right nav area shows (when logged in):
- Display name — non-clickable label
- Admin button — only visible to admin accounts, links to
/admin.html - ⚙ Settings button — opens a modal to update display name, bottle size, timezone, and password
- Logout button
When impersonating a user, the nav shows "Viewing as [name]" and a Return to Admin button.
API Endpoints
Auth
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /api/auth/register |
No | Register new account, returns JWT |
| POST | /api/auth/login |
No | Login, returns JWT |
Users
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/users/me |
Yes | Get current user profile |
| PUT | /api/users/me |
Yes | Update display name, timezone, bottle size |
| PUT | /api/users/me/password |
Yes | Change password |
Entries
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/entries |
Yes | List all entries for current user |
| POST | /api/entries |
Yes | Create an entry (add or remove) |
| DELETE | /api/entries/{id} |
Yes | Delete an entry |
| GET | /api/entries/stats |
Yes | Aggregated stats for current user |
Public
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/public/stats |
No | Stats + bourbon list for all users |
Admin
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/admin/users |
Admin | List all users |
| POST | /api/admin/users |
Admin | Create a user |
| POST | /api/admin/users/{id}/reset-password |
Admin | Force-reset a user's password |
| POST | /api/admin/users/{id}/disable |
Admin | Disable a user account |
| POST | /api/admin/users/{id}/enable |
Admin | Re-enable a user account |
| DELETE | /api/admin/users/{id} |
Admin | Hard-delete a user and all their data |
| POST | /api/admin/users/{id}/impersonate |
Admin | Get a token scoped as that user |
| POST | /api/admin/unimpersonate |
Admin | Exchange impersonation token back for admin token |
Authenticated routes expect Authorization: Bearer <token> header.
Entry Schema
{
"entry_type": "add" | "remove",
"date": "YYYY-MM-DD",
"bourbon_name": "string (required for add)",
"proof": 90.0,
"amount_shots": 1.0,
"notes": "optional string"
}
Stats Response
{
"total_add_entries": 12,
"total_shots_added": 18.5,
"current_total_shots": 14.25,
"estimated_proof": 95.3
}
current_total_shots = total added − total removed. estimated_proof = weighted average proof across all add entries by shot count.
Getting Started
Prerequisites
- Docker + Docker Compose
Setup
cp .env.example .env
# Edit .env — set real passwords and a secure SECRET_KEY
Generate a secure secret key:
python3 -c "import secrets; print(secrets.token_hex(64))"
Run
docker compose up -d
The app will be available at http://localhost (or whichever port is mapped in docker-compose.yml).
The backend waits for MySQL to pass its healthcheck before starting. Tables are created automatically on first boot via SQLAlchemy's create_all. The admin account defined in .env is seeded/re-synced on every container start.
Stop
docker compose down
# To also remove the database volume:
docker compose down -v
Environment Variables
| Variable | Description |
|---|---|
MYSQL_ROOT_PASSWORD |
MySQL root password |
MYSQL_DATABASE |
Database name (default: bourbonacci) |
MYSQL_USER |
App DB user |
MYSQL_PASSWORD |
App DB password |
DATABASE_URL |
SQLAlchemy async DSN (mysql+aiomysql://user:pass@db:3306/dbname) |
SECRET_KEY |
JWT signing secret — keep long and random |
ACCESS_TOKEN_EXPIRE_MINUTES |
JWT TTL in minutes (default: 480) |
ADMIN_USERNAME |
Admin account email, seeded on every container start |
ADMIN_PASSWORD |
Admin account password, re-synced on every container start |
Data Model
users
| Column | Type | Notes |
|---|---|---|
id |
int PK | |
email |
varchar(255) unique | |
password_hash |
varchar(255) | bcrypt |
display_name |
varchar(100) nullable | |
timezone |
varchar(50) | default UTC |
bottle_size |
float nullable | user-configured bottle capacity in shots |
is_admin |
bool | default false |
is_disabled |
bool | default false |
created_at |
datetime | server default |
entries
| Column | Type | Notes |
|---|---|---|
id |
int PK | |
user_id |
int FK → users | cascade delete |
entry_type |
enum | add or remove |
date |
date | user-supplied |
bourbon_name |
varchar nullable | required for add entries |
proof |
float nullable | |
amount_shots |
float | default 1.0 |
notes |
text nullable | |
created_at |
datetime | server default |