Stage 17 (contour round 2): Grafana Live/reload, rate-limit, iOS reconnect, hint/plaque/make-move UX

- Grafana: disable Live (GF_LIVE_MAX_CONNECTIONS=0) so its WebSocket no longer trips caddy Basic-Auth and re-prompts; admin console gains a Grafana nav link
- deploy: force-recreate config-only services so reseeded Grafana dashboards / Caddyfile are actually picked up (the move-duration panel was invisible because the bind-mount went stale)
- rate-limit: raise per-user budget 120/40 -> 300/80; UI skips reloading on the echo of the player's own move (fewer requests, no double-load)
- iOS/Telegram reconnect: suppress the connection banner while backgrounded and for a short grace after resume; reconnect silently; wire visibilitychange + pageshow/pagehide + Telegram activated/deactivated (Bot API 8.0)
- hint button disabled when 0 hints remain; nudge button shows a disabled state on your own turn
- players plaque: invert so the active seat pops (accent chip, raised) and others recede
- make-move UX: a direct  commit button (no hold/popover); the Shuffle tab becomes ↩️ Reset while tiles are pending
This commit is contained in:
Ilia Denisov
2026-06-06 12:33:52 +02:00
parent 09fec2b83c
commit c94cd3c3bf
10 changed files with 110 additions and 48 deletions
+47 -20
View File
@@ -9,7 +9,7 @@ import { GatewayError } from './client';
import { navigate, router } from './router.svelte';
import { errorKey, localeFrom, setLocale, t, type Locale } from './i18n/index.svelte';
import { applyReduceMotion, applyTelegramTheme, applyTheme, type ThemePref } from './theme';
import { insideTelegram, onTelegramPath, telegramColorScheme, telegramLaunch } from './telegram';
import { insideTelegram, onTelegramPath, telegramColorScheme, telegramLaunch, telegramOnEvent } from './telegram';
import { parseStartParam } from './deeplink';
import { clearSession, loadPrefs, loadSession, saveSession, savePrefs } from './session';
import { clearGameCache } from './gamecache';
@@ -52,11 +52,38 @@ let streamAlive = false;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let toastTimer: ReturnType<typeof setTimeout> | null = null;
/** documentHidden reports whether the app is currently backgrounded. */
// Background/foreground tracking, to silence the reconnect banner during a normal app
// suspend (iOS lock / home, Telegram tab switch) and reconnect quietly on return.
let backgrounded = false;
let foregroundedAt = 0;
const reconnectGraceMs = 4000;
/** documentHidden reports whether the page is currently hidden. */
function documentHidden(): boolean {
return typeof document !== 'undefined' && document.visibilityState === 'hidden';
}
/**
* bannerSuppressed reports whether the connection banner should stay hidden: while
* backgrounded, and for a short grace after returning to the foreground — a connection
* dropped while suspended surfaces its error on resume, before the silent reconnect lands.
*/
function bannerSuppressed(): boolean {
return backgrounded || documentHidden() || Date.now() - foregroundedAt < reconnectGraceMs;
}
function goBackground(): void {
backgrounded = true;
}
function goForeground(): void {
backgrounded = false;
foregroundedAt = Date.now();
if (!app.session) return;
if (!streamAlive) openStream(); // silently re-establish a stream dropped while away
void refreshNotifications();
}
export function showToast(text: string, kind: Toast['kind'] = 'info'): void {
app.toast = { kind, text };
if (toastTimer) clearTimeout(toastTimer);
@@ -96,14 +123,10 @@ function openStream(): void {
},
() => {
streamAlive = false;
// A background suspend (iOS / Telegram) drops the single-shot stream. Don't
// alarm the user with the connection banner while hidden — reconnect silently
// on return (the visibilitychange handler). Show the banner only on a failure
// seen in the foreground, and retry it.
if (!documentHidden()) {
showToast(t('error.unavailable'), 'error');
scheduleReconnect();
}
// A background suspend drops the single-shot stream. Keep the banner hidden while
// backgrounded or just-resumed (bannerSuppressed); always schedule a retry.
if (!bannerSuppressed()) showToast(t('error.unavailable'), 'error');
scheduleReconnect();
},
);
}
@@ -114,7 +137,7 @@ function scheduleReconnect(): void {
if (reconnectTimer || !app.session) return;
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
if (app.session && !streamAlive && !documentHidden()) openStream();
if (app.session && !streamAlive && !backgrounded && !documentHidden()) openStream();
}, 4000);
}
@@ -353,14 +376,18 @@ export function setBoardLabels(mode: BoardLabelMode): void {
persistPrefs();
}
// On return to the foreground: silently re-establish a stream dropped while the app
// was backgrounded (iOS/Telegram suspend it), and refresh the lobby badge for any
// push 'notify' missed while hidden (poll + push, see §10).
// Background/foreground lifecycle: silence the reconnect banner during a suspend and
// reconnect quietly on return (and refresh the lobby badge for any push missed while
// hidden, §10). Several signals cover the platforms: the page Visibility API, the
// pageshow/pagehide pair (iOS), and Telegram's own activated/deactivated (Bot API 8.0).
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible' && app.session) {
if (!streamAlive) openStream();
void refreshNotifications();
}
});
document.addEventListener('visibilitychange', () =>
document.visibilityState === 'visible' ? goForeground() : goBackground(),
);
}
if (typeof window !== 'undefined') {
window.addEventListener('pageshow', goForeground);
window.addEventListener('pagehide', goBackground);
}
telegramOnEvent('activated', goForeground);
telegramOnEvent('deactivated', goBackground);