From ca83351e9dc6d6e5387bf4152de9b6a2cff14294 Mon Sep 17 00:00:00 2001 From: derekc Date: Tue, 24 Mar 2026 21:53:50 -0700 Subject: [PATCH] Add bottle size, bourbon list modal, and stat improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add bottle_size field to User model and UserResponse/UserUpdate schemas - Settings modal includes bottle size input (shots capacity) - Community bottles and My Bottle page show fill bar based on bottle size - Community bottle cards are clickable — opens searchable bourbon list modal - Add total_shots_added stat to replace duplicate net volume on dashboard - Reorder dashboard stats: Bourbons Added, Total Poured In, Shots Remaining, Est. Proof - Theme-matched custom scrollbar (amber on dark) Co-Authored-By: Claude Sonnet 4.6 --- backend/app/models/user.py | 1 + backend/app/routers/entries.py | 10 ++--- backend/app/routers/public.py | 4 ++ backend/app/routers/users.py | 1 + backend/app/schemas/entry.py | 3 ++ backend/app/schemas/user.py | 2 + frontend/css/style.css | 6 +++ frontend/dashboard.html | 21 +++++++++-- frontend/index.html | 69 +++++++++++++++++++++++++++++----- frontend/js/auth.js | 14 +++++-- 10 files changed, 108 insertions(+), 23 deletions(-) diff --git a/backend/app/models/user.py b/backend/app/models/user.py index ffea867..c52c8bb 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -14,6 +14,7 @@ class User(Base): password_hash: Mapped[str] = mapped_column(String(255), nullable=False) display_name: Mapped[Optional[str]] = mapped_column(String(100)) timezone: Mapped[str] = mapped_column(String(50), default="UTC") + bottle_size: Mapped[Optional[float]] = mapped_column(default=None) is_admin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) is_disabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) diff --git a/backend/app/routers/entries.py b/backend/app/routers/entries.py index 0ec3b2a..526b65b 100644 --- a/backend/app/routers/entries.py +++ b/backend/app/routers/entries.py @@ -25,6 +25,7 @@ def _calc_stats(entries: list[Entry]) -> BottleStats: return BottleStats( total_add_entries=len(adds), + total_shots_added=round(total_add_shots, 2), current_total_shots=round(current_total, 2), estimated_proof=round(estimated_proof, 1) if estimated_proof is not None else None, ) @@ -61,9 +62,8 @@ async def create_entry( amount_shots=body.amount_shots, notes=body.notes, ) - async with db.begin(): - db.add(entry) - + db.add(entry) + await db.commit() await db.refresh(entry) return entry @@ -81,8 +81,8 @@ async def delete_entry( if not entry: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Entry not found") - async with db.begin(): - await db.delete(entry) + await db.delete(entry) + await db.commit() @router.get("/stats", response_model=BottleStats) diff --git a/backend/app/routers/public.py b/backend/app/routers/public.py index ed90136..4296738 100644 --- a/backend/app/routers/public.py +++ b/backend/app/routers/public.py @@ -31,11 +31,15 @@ async def public_stats(db: AsyncSession = Depends(get_db)): proof_shot_total = sum(e.amount_shots for e in adds if e.proof is not None) estimated_proof = round(weighted_proof_sum / proof_shot_total, 1) if proof_shot_total > 0 else None + bourbons = sorted({e.bourbon_name for e in adds if e.bourbon_name}, key=str.casefold) + stats.append(PublicUserStats( display_name=user.display_name or user.email.split("@")[0], total_add_entries=len(adds), current_total_shots=round(current_total, 2), estimated_proof=estimated_proof, + bottle_size=user.bottle_size, + bourbons=bourbons, )) return stats diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py index 1d93325..db7093f 100644 --- a/backend/app/routers/users.py +++ b/backend/app/routers/users.py @@ -24,6 +24,7 @@ async def update_me( current_user.display_name = body.display_name if body.timezone is not None: current_user.timezone = body.timezone + current_user.bottle_size = body.bottle_size db.add(current_user) await db.commit() diff --git a/backend/app/schemas/entry.py b/backend/app/schemas/entry.py index fa9c01f..1962a7b 100644 --- a/backend/app/schemas/entry.py +++ b/backend/app/schemas/entry.py @@ -29,6 +29,7 @@ class EntryResponse(BaseModel): class BottleStats(BaseModel): total_add_entries: int + total_shots_added: float current_total_shots: float estimated_proof: Optional[float] @@ -38,3 +39,5 @@ class PublicUserStats(BaseModel): total_add_entries: int current_total_shots: float estimated_proof: Optional[float] + bottle_size: Optional[float] + bourbons: list[str] diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index 8825074..863b316 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -12,6 +12,7 @@ class UserCreate(BaseModel): class UserUpdate(BaseModel): display_name: Optional[str] = None timezone: Optional[str] = None + bottle_size: Optional[float] = None class PasswordChange(BaseModel): @@ -24,6 +25,7 @@ class UserResponse(BaseModel): email: str display_name: Optional[str] timezone: str + bottle_size: Optional[float] is_admin: bool created_at: datetime diff --git a/frontend/css/style.css b/frontend/css/style.css index 6a25c54..21e8a0f 100644 --- a/frontend/css/style.css +++ b/frontend/css/style.css @@ -475,6 +475,12 @@ tbody td { .tab.active, .tab:hover { color: var(--amber-light); } .tab.active { border-bottom-color: var(--amber); } +/* ---- Custom scrollbar ---- */ +::-webkit-scrollbar { width: 8px; } +::-webkit-scrollbar-track { background: var(--bg-card); } +::-webkit-scrollbar-thumb { background: var(--amber-dim); border-radius: 4px; } +::-webkit-scrollbar-thumb:hover { background: var(--amber); } + /* ---- Responsive ---- */ @media (max-width: 640px) { .hero h1 { font-size: 2rem; } diff --git a/frontend/dashboard.html b/frontend/dashboard.html index 4dc43b7..2705cb5 100644 --- a/frontend/dashboard.html +++ b/frontend/dashboard.html @@ -27,6 +27,11 @@
Shots Remaining
Total Poured In
+
@@ -69,6 +74,7 @@ document.addEventListener('DOMContentLoaded', async () => { async function loadStats() { try { const s = await API.entries.stats(); + const user = Auth.getUser(); const grid = document.getElementById('stats-grid'); grid.innerHTML = `
@@ -76,18 +82,25 @@ async function loadStats() { Bourbons Added
- ${s.estimated_proof != null ? s.estimated_proof : '—'} - Est. Proof + ${s.total_shots_added} + Total Poured In
${s.current_total_shots} Shots Remaining
- ${(s.total_add_entries > 0 ? s.current_total_shots : 0)} - Net Volume (shots) + ${s.estimated_proof != null ? s.estimated_proof : '—'} + Est. Proof
`; + + const bottleSize = user?.bottle_size; + if (bottleSize > 0) { + const pct = Math.min(100, (s.current_total_shots / bottleSize) * 100); + document.getElementById('bottle-bar-fill').style.width = pct + '%'; + document.getElementById('bottle-level').style.display = ''; + } } catch (_) {} } diff --git a/frontend/index.html b/frontend/index.html index e8ad213..6f71524 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -33,9 +33,23 @@
+ + + diff --git a/frontend/js/auth.js b/frontend/js/auth.js index 8721312..cf35caf 100644 --- a/frontend/js/auth.js +++ b/frontend/js/auth.js @@ -114,10 +114,14 @@ const Auth = (() => {

Profile

-
+
+
+ + +

@@ -179,10 +183,12 @@ const Auth = (() => { } async function saveProfile() { - const displayName = document.getElementById('settings-display-name').value.trim(); - const timezone = document.getElementById('settings-tz').value; + const displayName = document.getElementById('settings-display-name').value.trim(); + const timezone = document.getElementById('settings-tz').value; + const bottleSizeRaw = document.getElementById('settings-bottle-size').value; + const bottleSize = bottleSizeRaw !== '' ? parseFloat(bottleSizeRaw) : null; try { - const user = await API.users.update({ display_name: displayName || null, timezone }); + const user = await API.users.update({ display_name: displayName || null, timezone, bottle_size: bottleSize }); saveUser(user); const usernameEl = document.querySelector('.nav-username'); if (usernameEl) usernameEl.textContent = user.display_name || user.email || 'Account';