Add bottle size, bourbon list modal, and stat improvements
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,7 @@ class User(Base):
|
|||||||
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
display_name: Mapped[Optional[str]] = mapped_column(String(100))
|
display_name: Mapped[Optional[str]] = mapped_column(String(100))
|
||||||
timezone: Mapped[str] = mapped_column(String(50), default="UTC")
|
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_admin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
is_disabled: 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())
|
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ def _calc_stats(entries: list[Entry]) -> BottleStats:
|
|||||||
|
|
||||||
return BottleStats(
|
return BottleStats(
|
||||||
total_add_entries=len(adds),
|
total_add_entries=len(adds),
|
||||||
|
total_shots_added=round(total_add_shots, 2),
|
||||||
current_total_shots=round(current_total, 2),
|
current_total_shots=round(current_total, 2),
|
||||||
estimated_proof=round(estimated_proof, 1) if estimated_proof is not None else None,
|
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,
|
amount_shots=body.amount_shots,
|
||||||
notes=body.notes,
|
notes=body.notes,
|
||||||
)
|
)
|
||||||
async with db.begin():
|
|
||||||
db.add(entry)
|
db.add(entry)
|
||||||
|
await db.commit()
|
||||||
await db.refresh(entry)
|
await db.refresh(entry)
|
||||||
return entry
|
return entry
|
||||||
|
|
||||||
@@ -81,8 +81,8 @@ async def delete_entry(
|
|||||||
if not entry:
|
if not entry:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Entry not found")
|
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)
|
@router.get("/stats", response_model=BottleStats)
|
||||||
|
|||||||
@@ -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)
|
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
|
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(
|
stats.append(PublicUserStats(
|
||||||
display_name=user.display_name or user.email.split("@")[0],
|
display_name=user.display_name or user.email.split("@")[0],
|
||||||
total_add_entries=len(adds),
|
total_add_entries=len(adds),
|
||||||
current_total_shots=round(current_total, 2),
|
current_total_shots=round(current_total, 2),
|
||||||
estimated_proof=estimated_proof,
|
estimated_proof=estimated_proof,
|
||||||
|
bottle_size=user.bottle_size,
|
||||||
|
bourbons=bourbons,
|
||||||
))
|
))
|
||||||
|
|
||||||
return stats
|
return stats
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ async def update_me(
|
|||||||
current_user.display_name = body.display_name
|
current_user.display_name = body.display_name
|
||||||
if body.timezone is not None:
|
if body.timezone is not None:
|
||||||
current_user.timezone = body.timezone
|
current_user.timezone = body.timezone
|
||||||
|
current_user.bottle_size = body.bottle_size
|
||||||
|
|
||||||
db.add(current_user)
|
db.add(current_user)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ class EntryResponse(BaseModel):
|
|||||||
|
|
||||||
class BottleStats(BaseModel):
|
class BottleStats(BaseModel):
|
||||||
total_add_entries: int
|
total_add_entries: int
|
||||||
|
total_shots_added: float
|
||||||
current_total_shots: float
|
current_total_shots: float
|
||||||
estimated_proof: Optional[float]
|
estimated_proof: Optional[float]
|
||||||
|
|
||||||
@@ -38,3 +39,5 @@ class PublicUserStats(BaseModel):
|
|||||||
total_add_entries: int
|
total_add_entries: int
|
||||||
current_total_shots: float
|
current_total_shots: float
|
||||||
estimated_proof: Optional[float]
|
estimated_proof: Optional[float]
|
||||||
|
bottle_size: Optional[float]
|
||||||
|
bourbons: list[str]
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ class UserCreate(BaseModel):
|
|||||||
class UserUpdate(BaseModel):
|
class UserUpdate(BaseModel):
|
||||||
display_name: Optional[str] = None
|
display_name: Optional[str] = None
|
||||||
timezone: Optional[str] = None
|
timezone: Optional[str] = None
|
||||||
|
bottle_size: Optional[float] = None
|
||||||
|
|
||||||
|
|
||||||
class PasswordChange(BaseModel):
|
class PasswordChange(BaseModel):
|
||||||
@@ -24,6 +25,7 @@ class UserResponse(BaseModel):
|
|||||||
email: str
|
email: str
|
||||||
display_name: Optional[str]
|
display_name: Optional[str]
|
||||||
timezone: str
|
timezone: str
|
||||||
|
bottle_size: Optional[float]
|
||||||
is_admin: bool
|
is_admin: bool
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
|
|||||||
@@ -475,6 +475,12 @@ tbody td {
|
|||||||
.tab.active, .tab:hover { color: var(--amber-light); }
|
.tab.active, .tab:hover { color: var(--amber-light); }
|
||||||
.tab.active { border-bottom-color: var(--amber); }
|
.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 ---- */
|
/* ---- Responsive ---- */
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.hero h1 { font-size: 2rem; }
|
.hero h1 { font-size: 2rem; }
|
||||||
|
|||||||
@@ -27,6 +27,11 @@
|
|||||||
<div class="stat-box"><span class="stat-value">—</span><span class="stat-label">Shots Remaining</span></div>
|
<div class="stat-box"><span class="stat-value">—</span><span class="stat-label">Shots Remaining</span></div>
|
||||||
<div class="stat-box"><span class="stat-value">—</span><span class="stat-label">Total Poured In</span></div>
|
<div class="stat-box"><span class="stat-value">—</span><span class="stat-label">Total Poured In</span></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="bottle-level" style="display:none;margin-bottom:1.5rem">
|
||||||
|
<div class="bottle-bar-wrap" style="height:14px;border-radius:4px">
|
||||||
|
<div id="bottle-bar-fill" class="bottle-bar" style="width:0%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Entries -->
|
<!-- Entries -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
@@ -69,6 +74,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
async function loadStats() {
|
async function loadStats() {
|
||||||
try {
|
try {
|
||||||
const s = await API.entries.stats();
|
const s = await API.entries.stats();
|
||||||
|
const user = Auth.getUser();
|
||||||
const grid = document.getElementById('stats-grid');
|
const grid = document.getElementById('stats-grid');
|
||||||
grid.innerHTML = `
|
grid.innerHTML = `
|
||||||
<div class="stat-box">
|
<div class="stat-box">
|
||||||
@@ -76,18 +82,25 @@ async function loadStats() {
|
|||||||
<span class="stat-label">Bourbons Added</span>
|
<span class="stat-label">Bourbons Added</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-box">
|
<div class="stat-box">
|
||||||
<span class="stat-value">${s.estimated_proof != null ? s.estimated_proof : '—'}</span>
|
<span class="stat-value">${s.total_shots_added}</span>
|
||||||
<span class="stat-label">Est. Proof</span>
|
<span class="stat-label">Total Poured In</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-box">
|
<div class="stat-box">
|
||||||
<span class="stat-value">${s.current_total_shots}</span>
|
<span class="stat-value">${s.current_total_shots}</span>
|
||||||
<span class="stat-label">Shots Remaining</span>
|
<span class="stat-label">Shots Remaining</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-box">
|
<div class="stat-box">
|
||||||
<span class="stat-value">${(s.total_add_entries > 0 ? s.current_total_shots : 0)}</span>
|
<span class="stat-value">${s.estimated_proof != null ? s.estimated_proof : '—'}</span>
|
||||||
<span class="stat-label">Net Volume (shots)</span>
|
<span class="stat-label">Est. Proof</span>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
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 (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,9 +33,23 @@
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<!-- Bourbon list modal -->
|
||||||
|
<div id="bourbon-modal" class="modal-overlay" style="display:none">
|
||||||
|
<div class="modal-box" style="max-width:480px">
|
||||||
|
<h2 id="bourbon-modal-title"></h2>
|
||||||
|
<input type="text" id="bourbon-search" placeholder="Search bourbons…" style="margin-bottom:1rem" oninput="filterBourbons()" />
|
||||||
|
<div id="bourbon-list" style="max-height:380px;overflow-y:auto"></div>
|
||||||
|
<div style="display:flex;justify-content:flex-end;margin-top:1rem">
|
||||||
|
<button class="btn btn-ghost" onclick="closeBourbonModal()">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script src="/js/api.js"></script>
|
<script src="/js/api.js"></script>
|
||||||
<script src="/js/auth.js"></script>
|
<script src="/js/auth.js"></script>
|
||||||
<script>
|
<script>
|
||||||
|
let allBourbons = [];
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', async () => {
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
await Auth.renderNav('home');
|
await Auth.renderNav('home');
|
||||||
|
|
||||||
@@ -54,12 +68,16 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
container.innerHTML = stats.map(u => {
|
container.innerHTML = stats.map((u, i) => {
|
||||||
const maxShots = 25;
|
|
||||||
const pct = Math.min(100, (u.current_total_shots / maxShots) * 100);
|
|
||||||
const proof = u.estimated_proof != null ? `${u.estimated_proof}` : '—';
|
const proof = u.estimated_proof != null ? `${u.estimated_proof}` : '—';
|
||||||
|
const hasSize = u.bottle_size != null && u.bottle_size > 0;
|
||||||
|
const pct = hasSize ? Math.min(100, (u.current_total_shots / u.bottle_size) * 100) : 0;
|
||||||
|
const barHtml = hasSize ? `
|
||||||
|
<div class="bottle-bar-wrap">
|
||||||
|
<div class="bottle-bar" style="width:${pct}%"></div>
|
||||||
|
</div>` : '';
|
||||||
return `
|
return `
|
||||||
<div class="user-card">
|
<div class="user-card" style="cursor:pointer" onclick="openBourbonModal(${i})">
|
||||||
<div class="user-card-name">${escHtml(u.display_name)}</div>
|
<div class="user-card-name">${escHtml(u.display_name)}</div>
|
||||||
<div class="stats-grid" style="margin-bottom:.75rem">
|
<div class="stats-grid" style="margin-bottom:.75rem">
|
||||||
<div class="stat-box">
|
<div class="stat-box">
|
||||||
@@ -75,20 +93,51 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
<span class="stat-label">Shots Left</span>
|
<span class="stat-label">Shots Left</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="bottle-bar-wrap">
|
${barHtml}
|
||||||
<div class="bottle-bar" style="width:${pct}%"></div>
|
|
||||||
</div>
|
|
||||||
<div class="bottle-label">${u.current_total_shots} shots remaining</div>
|
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
|
// Store stats for modal use
|
||||||
|
window._communityStats = stats;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
container.innerHTML = `<div class="alert alert-error">Could not load stats: ${err.message}</div>`;
|
container.innerHTML = `<div class="alert alert-error">Could not load stats: ${err.message}</div>`;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function escHtml(s) {
|
function openBourbonModal(index) {
|
||||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
const u = window._communityStats[index];
|
||||||
|
allBourbons = u.bourbons || [];
|
||||||
|
|
||||||
|
document.getElementById('bourbon-modal-title').textContent = `${u.display_name}'s Bottle`;
|
||||||
|
document.getElementById('bourbon-search').value = '';
|
||||||
|
renderBourbonList(allBourbons);
|
||||||
|
document.getElementById('bourbon-modal').style.display = 'flex';
|
||||||
|
document.getElementById('bourbon-search').focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function closeBourbonModal() {
|
||||||
|
document.getElementById('bourbon-modal').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterBourbons() {
|
||||||
|
const q = document.getElementById('bourbon-search').value.toLowerCase();
|
||||||
|
renderBourbonList(allBourbons.filter(b => b.toLowerCase().includes(q)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBourbonList(list) {
|
||||||
|
const el = document.getElementById('bourbon-list');
|
||||||
|
if (list.length === 0) {
|
||||||
|
el.innerHTML = `<div style="color:var(--cream-dim);font-size:.9rem;padding:.5rem 0">No bourbons found.</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.innerHTML = list.map(b => `
|
||||||
|
<div style="padding:.5rem 0;border-bottom:1px solid var(--border);color:var(--cream)">${escHtml(b)}</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (e.target.id === 'bourbon-modal') closeBourbonModal();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -114,10 +114,14 @@ const Auth = (() => {
|
|||||||
<div id="settings-msg"></div>
|
<div id="settings-msg"></div>
|
||||||
|
|
||||||
<h3 class="settings-section-title">Profile</h3>
|
<h3 class="settings-section-title">Profile</h3>
|
||||||
<div class="form-group" style="margin-bottom:1.25rem">
|
<div class="form-group">
|
||||||
<label for="settings-display-name">Display Name</label>
|
<label for="settings-display-name">Display Name</label>
|
||||||
<input type="text" id="settings-display-name" value="${escHtml(user?.display_name || '')}" maxlength="100" />
|
<input type="text" id="settings-display-name" value="${escHtml(user?.display_name || '')}" maxlength="100" />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group" style="margin-bottom:1.25rem">
|
||||||
|
<label for="settings-bottle-size">Bottle Size (shots)</label>
|
||||||
|
<input type="number" id="settings-bottle-size" value="${user?.bottle_size ?? ''}" min="1" step="0.5" placeholder="e.g. 25" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<hr class="settings-divider">
|
<hr class="settings-divider">
|
||||||
|
|
||||||
@@ -181,8 +185,10 @@ const Auth = (() => {
|
|||||||
async function saveProfile() {
|
async function saveProfile() {
|
||||||
const displayName = document.getElementById('settings-display-name').value.trim();
|
const displayName = document.getElementById('settings-display-name').value.trim();
|
||||||
const timezone = document.getElementById('settings-tz').value;
|
const timezone = document.getElementById('settings-tz').value;
|
||||||
|
const bottleSizeRaw = document.getElementById('settings-bottle-size').value;
|
||||||
|
const bottleSize = bottleSizeRaw !== '' ? parseFloat(bottleSizeRaw) : null;
|
||||||
try {
|
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);
|
saveUser(user);
|
||||||
const usernameEl = document.querySelector('.nav-username');
|
const usernameEl = document.querySelector('.nav-username');
|
||||||
if (usernameEl) usernameEl.textContent = user.display_name || user.email || 'Account';
|
if (usernameEl) usernameEl.textContent = user.display_name || user.email || 'Account';
|
||||||
|
|||||||
Reference in New Issue
Block a user