diff --git a/app/api/routes/users.py b/app/api/routes/users.py index 173025c..dd87ce0 100644 --- a/app/api/routes/users.py +++ b/app/api/routes/users.py @@ -1,10 +1,11 @@ from fastapi import APIRouter, Depends, HTTPException, status from pydantic import BaseModel +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.auth.dependencies import get_current_user from app.auth.security import hash_password, verify_password -from app.db.models import User +from app.db.models import Round, RoundParticipant, User from app.db.session import get_session router = APIRouter(prefix="/users", tags=["users"]) @@ -52,3 +53,42 @@ async def change_password( user.password_hash = hash_password(body.new_password) await session.commit() + + +class LastRoundResultResponse(BaseModel): + round_id: int | None = None + won: bool = False + amount_sats: int | None = None + + +@router.get("/me/last-round-result", response_model=LastRoundResultResponse) +async def last_round_result( + user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +) -> LastRoundResultResponse: + """The most recent *closed* round this user participated in, with its outcome. + + Deliberately independent of /rounds/current: that endpoint only exposes + winner_user_id while the round is "paying_out", and drops it entirely once + the round flips to "closed" (see rounds/service.get_active_round). A client + that misses that narrow window (backgrounded tab, missed poll, page loaded + late) would otherwise never learn the outcome of a round it bet in. This + endpoint reads the durable DB record instead, so the frontend can always + catch up regardless of polling timing.""" + row = await session.execute( + select(Round) + .join(RoundParticipant, RoundParticipant.round_id == Round.id) + .where(RoundParticipant.user_id == user.id, Round.status == "closed") + .order_by(Round.id.desc()) + .limit(1) + ) + round_ = row.scalar_one_or_none() + if round_ is None: + return LastRoundResultResponse() + + won = round_.winner_user_id == user.id + return LastRoundResultResponse( + round_id=round_.id, + won=won, + amount_sats=round_.winner_amount_sats if won else None, + ) diff --git a/app/static/index.html b/app/static/index.html index 827eeae..70042c1 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -263,10 +263,26 @@ async function withLoading(button, label, fn) { } } +const REQUEST_TIMEOUT_MS = 15000; + +// Without a timeout, a single request that never resolves (server-side hang — +// stuck DB session, unresponsive Electrum connection...) would stall the whole +// sequential polling chain forever: the UI just freezes on whatever was last +// rendered, with no error and no "connessione persa" (that only fires on a +// rejected fetch, never on one that's merely stuck). async function call(method, path, body) { const headers = { 'Content-Type': 'application/json' }; if (token) headers['Authorization'] = 'Bearer ' + token; - const res = await fetch(path, { method, headers, body: body ? JSON.stringify(body) : undefined }); + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + let res; + try { + res = await fetch(path, { method, headers, body: body ? JSON.stringify(body) : undefined, signal: controller.signal }); + } catch (e) { + throw new Error(e.name === 'AbortError' ? 'Richiesta al server scaduta.' : e.message); + } finally { + clearTimeout(timeoutId); + } const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.detail || res.statusText); return data; @@ -286,6 +302,12 @@ function switchPanel(name) { } } +// Bumped on every logout/login so an in-flight refreshRound() started under a +// previous session can detect it's now stale — a fetch can still be awaiting +// its response after logout() clears the timeout-based poll chain, and without +// this guard it would re-arm scheduleNextRoundPoll() and resurrect a "zombie" +// dashboard poll running in parallel with the logged-out chain-only poll. +let sessionEpoch = 0; let roundCloseAt = null; let serverTimeOffsetMs = 0; // serverNow - clientNow, so every client's countdown agrees regardless of local clock skew function serverNow() { return new Date(Date.now() + serverTimeOffsetMs); } @@ -297,6 +319,7 @@ let roundRequestSeq = 0; let roundAppliedSeq = 0; let roundTimerInterval = null; let roundPollTimeout = null; +let lastResultInterval = null; const ROUND_STATUS_LABELS = { open: 'aperto', @@ -411,9 +434,44 @@ document.addEventListener('visibilitychange', () => { refreshChainStatusOnly(); } else if (token) { refreshRound(); + checkLastRoundResult(); } }); +const LAST_SEEN_RESULT_KEY = 'plm_last_seen_result_round_id'; + +function markResultSeen(roundId) { + localStorage.setItem(LAST_SEEN_RESULT_KEY, String(roundId)); +} + +// Backstop for the live reveal in refreshRound(): that one only works if a poll +// happens to land while the round is still "paying_out" (winner_user_id is +// dropped from /rounds/current the instant the round flips to "closed" — see +// rounds/service.get_active_round). A backgrounded tab, a missed poll, or a +// late page load can miss that window entirely, in which case the live path +// never fires and the player would otherwise never learn the outcome. This +// reads GET /users/me/last-round-result, which reports the durable DB record +// instead of an ephemeral snapshot, so it always catches up eventually. +async function checkLastRoundResult() { + if (!token) return; + let data; + try { + data = await call('GET', '/users/me/last-round-result'); + } catch (e) { + return; // silent — this is a backstop, refreshRound()'s own error handling already covers the primary path + } + if (data.round_id == null) return; + if (String(data.round_id) === localStorage.getItem(LAST_SEEN_RESULT_KEY)) return; // already surfaced (live or backstop) + markResultSeen(data.round_id); + if (data.won) { + const won = data.amount_sats / SATS_PER_PLM; + toast('Hai vinto il round #' + data.round_id + '! +' + won + ' PLM', 'success'); + refreshMe(); + } else { + toast('Round #' + data.round_id + ' concluso: non hai vinto questa volta.', 'info'); + } +} + // Per round_id: when we first saw it enter a drawing status (client clock), and // whether the win/lose result has already been shown for it. Local-only state, // not persisted — a page reload just re-derives it from the next poll. @@ -471,8 +529,10 @@ function showNormalState() { async function refreshRound() { const seq = ++roundRequestSeq; + const epoch = sessionEpoch; try { const data = await call('GET', '/rounds/current'); + if (epoch !== sessionEpoch) return; // session ended (or a new one started) while this was in flight if (seq < roundAppliedSeq) return; // a newer refreshRound() call already applied its result roundAppliedSeq = seq; noteFetchOutcome(true); @@ -505,10 +565,12 @@ async function refreshRound() { if (data.winner_user_id != null && elapsedMs >= minMs && !revealedRounds.has(data.round_id)) { revealedRounds.add(data.round_id); activeResultRoundId = data.round_id; + markResultSeen(data.round_id); if (myUserId != null && data.winner_user_id === myUserId) { const won = (data.winner_amount_sats / SATS_PER_PLM); showResultState('🎉 Hai vinto! +' + won + ' PLM', 'win'); toast('Hai vinto il round #' + data.round_id + '! +' + won + ' PLM', 'success'); + refreshMe(); // the win toast is useless if the balance card still shows the pre-payout amount } else { showResultState('Non hai vinto questa volta.', 'lose'); } @@ -536,6 +598,7 @@ async function refreshRound() { scheduleNextRoundPoll(isDrawing); } catch (e) { + if (epoch !== sessionEpoch) return; // session ended (or a new one started) while this was in flight noteFetchOutcome(false); scheduleNextRoundPoll(false); } @@ -547,6 +610,7 @@ function scheduleNextRoundPoll(fast) { } function showDashboard() { + sessionEpoch++; // invalidate any dashboard poll chain left over from a previous login stopChainOnlyPolling(); document.getElementById('landing-hero').classList.add('hidden'); document.getElementById('auth-section').classList.add('hidden'); @@ -557,6 +621,9 @@ function showDashboard() { document.getElementById('dash-qr').src = '/qr/' + encodeURIComponent(address); refreshMe(); refreshRound(); + checkLastRoundResult(); + clearInterval(lastResultInterval); + lastResultInterval = setInterval(checkLastRoundResult, 20000); clearInterval(roundTimerInterval); roundTimerInterval = setInterval(updateRoundTimer, 1000); } @@ -606,6 +673,7 @@ async function login() { } function resetToLoggedOutUI() { + sessionEpoch++; // invalidate any refreshRound() still in flight from the dashboard we're leaving token = username = address = null; myUserId = null; activeResultRoundId = null; @@ -613,6 +681,8 @@ function resetToLoggedOutUI() { for (const key of Object.keys(drawStartedAt)) delete drawStartedAt[key]; clearInterval(roundTimerInterval); clearTimeout(roundPollTimeout); + clearInterval(lastResultInterval); + lastResultInterval = null; document.getElementById('app-navbar').classList.add('hidden'); document.getElementById('dashboard-section').classList.add('hidden'); document.getElementById('auth-section').classList.remove('hidden'); diff --git a/app/static/style.css b/app/static/style.css index 20f2620..dd50636 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -246,6 +246,7 @@ button.link:hover, a.link:hover { filter: none; color: var(--color-foreground); } .toast.success { background: var(--color-success-bg); color: var(--color-success); } .toast.error { background: var(--color-destructive-bg); color: var(--color-destructive); } +.toast.info { background: var(--color-surface-inset); color: var(--color-foreground); } @keyframes toast-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } } /* --- landing hero (shown only when logged out) --- */