Persist per-message read state as a chat_messages.unread_seats bitmask
(migration 00008): a text message seeds every recipient seat's bit, a nudge
only the awaited seat's. A seat's bit clears when the player opens the move
history or chat (POST /games/:id/chat/read, sent only when something is
unread), and a nudge additionally clears when its recipient answers by moving
(a wired game NudgeClearer, dependency-inverted so game keeps off social).
UI shows a per-viewer unread dot in the lobby (next to the opponent) and the
game score bar — the unread_chat game-view flag seeds it from authoritative
REST views, live chat/nudge events raise it. Opening the move history counts
as reading (even without entering chat): the 💬 fade-blinks twice and the
client acks. Admin Messages gains an unread-only filter, a read/unread column,
and a per-message card with the per-seat read breakdown. Observability:
chat_read_duration histogram + chat_unread_messages gauge + social tracing.
The "waiting for your move" popup was the nudge (chat.nudge), shown without a
sender name. Resolve the nudger's per-game seat name server-side and carry it on
a new NudgeEvent.sender_name field, so:
- the in-app toast reads "<opponent>: Waiting for your move 🤭" (chat.nudgeBy);
- the out-of-app Telegram push names the sender too (render nudgeBy);
falling back to the plain phrase when the name is absent (RU mirrored). The
your_turn toast already named the opponent and is unchanged.
Lobby: when a card transitions into "your turn" or "finished" while the lobby is
open, its status emoji blinks twice (two 1s fades); the opponent's-turn change
stays in place. Blink state is keyed by game id (SvelteSet + per-id nonce/timer)
so overlapping events animate in isolation; suppressed under reduce-motion.
Toast: a per-message seq re-keys Toast.svelte, so the freshest toast cancels the
previous one and replays its entrance, uniformly on every screen.
Tests: notify.Nudge round-trip + render named/fallback (Go), game.Service.SeatName
(integration), codec/i18n/gamePhase/shouldBlink (UI). Docs (FUNCTIONAL +_ru,
UI_DESIGN) + PLAN TODO-7 (deferred FLIP card-relocation animation) updated.
Consume the server-driven banner block (PR1) in the UI and retire the gate.
- banner.ts: createScheduler — a smooth weighted round-robin over campaigns (each
appears its weight share per cycle, evenly interleaved) with round-robin over a
campaign's messages; the rotator drives fade-in -> hold/scroll -> fade-out -> gap
-> fade-in, a lone message stays put, reduce-motion swaps instantly without scroll.
- model.ts/codec.ts: Profile.banner (Banner/BannerCampaign/BannerTimings) decoded
from the fbs block.
- Screen.svelte: drop the compile-time SHOW_AD_BANNER; render AdBanner from
app.profile.banner (campaigns + timings + reduceMotion).
- AdBanner.svelte: opacity-driven fades + scroll host; the rotator is recreated when
the campaigns/timings change (a `banner` notify re-fetch swaps them in place).
- app.svelte.ts: on the `notify` `banner` sub-kind, refreshProfile() so the banner
shows/hides in place.
- tests: scheduler distribution + round-robin, the fade sequence, single-message,
reduce-motion, stop(); codec banner decode. UI_DESIGN.md + trackers updated.
Replace the shared-link &mode=fullscreen (which would also force fullscreen on
desktop) with an imperative requestFullscreen() on launch, gated to mobile
clients (ios/android/android_x) — mirroring how Telegram's own Mini Apps go
immersive on phones while desktop keeps the bot's full-size window. It triggers
the existing fullscreenChanged -> safe-area resync; a no-op on clients predating
Bot API 8.0.
- Shared invite links open the Mini App fullscreen (mode=fullscreen), so a
shared link matches the bot's own fullscreen launch.
- A used or expired invite deep-link now lands the visitor in the lobby with a
gentle notice pointing at the right bot (@<username>, by service language),
instead of a red "code invalid/expired" error on the Friends screen.
- The in-game close confirmation is armed only on Telegram mobile clients; on
desktop (tdesktop/macOS/web) it is skipped, where the "changes may not be
saved" dialog is just noise (drafts auto-save).
- The share caption is now in the bot's language: 'Давай играть в Эрудит!' (ru bot),
"Let's play Scrabble!" (en bot) — picked by the session service language, not the
interface language.
- Redeeming your own invite (deep link or manual) no longer shows the scary
'can't do that to yourself' error; it shows a friendly neutral note instead.
On login the UI no longer overrides the interface language from the account's
preferred_language. The live interface follows the device — the explicit local
choice (saved, locked) or the system-language guess — so opening the mini-app via
the ru-bot on an English system keeps the interface English (it was forced to
Russian by the account seed). preferred_language is still written from Settings and
used for out-of-app push routing; it just no longer dictates the UI on launch.
The in-app "your turn" toast (shown when it becomes your turn in another
game) now reads "<Opponent>: Your turn!", naming the player who moved just
before this one — the previous seat in turn order, so it reads the same in
games with any number of players. Falls back to the bare "Your turn" label
when no name is present (an older peer, or a gap event without one).
YourTurnEvent already carries opponent_name end to end: the backend sets it
(game.displayName of the last mover) and the gateway forwards the payload
opaquely, so this is a client-only change — decode the field, thread it
through PushEvent, and pick the localized string.
Operator-driven hard block, the counterpart to the soft high-rate flag: permanent or until a date, with an optional reason chosen from an editable en+ru picklist (snapshotted onto the block). A block forfeits the player's active games (opponent wins, as a resignation) and cancels their open matchmaking games. A backend gate refuses a blocked account on every /api/v1/user/* route except the block-status probe with 403 account_blocked, which threads through the gateway as the Execute result_code; the UI surfaces it as a terminal blocked screen and stops all push/poll. Temporary blocks self-expire; the operator can unblock at any time (lost games stay lost). Sessions are not revoked, so the blocked client can still reach the exempt block-status endpoint.
Backend: migration 00003 (account_suspensions + suspension_reasons) + jet regen; account suspension store; game.ForfeitAllForAccount; requireNotSuspended gate + block-status endpoint; admin console block/unblock + Reasons CRUD. Wire: fbs BlockStatus + account.block_status gateway op. UI: blocked screen, app state, transport/codec, i18n. Docs: ARCHITECTURE, FUNCTIONAL(+ru), PRERELEASE (AB).
Builds on the cross-screen cache work: the global stream handler now keeps both
caches current no matter which screen is mounted, and invitations become a live
delta channel so the lobby's invitations list is fresh from any screen too.
Client (boundary already started):
- advanceCached now also folds opponent_joined into a not-currently-viewed game's
cache via a new pure reducer applyOpponentJoined (extracted and reused by the
mounted game board), so opening an open game that filled while you were elsewhere
is flash-free.
- patchLobbyInvitation upserts a still-pending invitation and removes a terminal
one (started/declined/cancelled/expired); the global notify handler calls it on
the invitation / invitation_update sub-kinds.
Invitations delta channel (no wire/gateway/connector change — the notification
already carries the full invitation with id/status/game_id end to end):
- notify: a new in-app-only NotifyInvitationUpdate sub + NotificationInvitationUpdate
constructor (shares encoding with NotificationInvitation). The Telegram connector
renders no message for it, so a decline/cancel never becomes an out-of-app push.
- lobby: emit the changed invitation to every participant on respond (accept/decline),
on the final accept's game start, and on cancel — so each participant's lobby patches
its list in place. The authoritative list holds only pending invitations, so the
client's pending-vs-terminal rule matches it exactly.
Tests: applyOpponentJoined + patchLobbyInvitation unit tests (TDD), the
NotificationInvitationUpdate encoding unit test, and integration assertions that
decline/cancel/accept publish invitation_update to every participant. Full local
suite green (backend unit+integration, UI check/unit/build/bundle/e2e). Docs:
ARCHITECTURE §10 (notify catalog + in-app-only note) and UI_DESIGN updated.
The per-screen in-memory caches (lobbycache, gamecache) were refreshed only by
the screen that owns them while it was mounted, so a state change that crossed
screens left the other screen's cache stale and it visibly redrew on the next
navigation:
- game -> lobby: the player's own move advanced the game cache but not the lobby
snapshot, and an own move carries no self-directed push event, so returning to
the lobby painted the pre-move status until the background refresh corrected it.
- lobby -> game (and from any other screen): an opponent's move / game-over
refreshed the lobby (while mounted) but never the per-game cache, so opening
that game flashed the pre-move board.
Make cache freshness cross-screen, owned by the single global stream handler
that runs for every live event regardless of the mounted screen:
- patchLobbyGame upserts the affected game's GameView into the lobby snapshot;
the global handler calls it on opponent_moved / game_over / opponent_joined and
on a match_found / game_started seed (so a game started elsewhere is present
too). The game board still mirrors the player's own move and its own load() —
the two updates no live event carries.
- advanceCached (a pure wrapper over the existing delta reducers) advances a
not-currently-viewed game's cache from opponent_moved / game_over; the game in
view is skipped so its mounted board stays the sole owner (no double apply).
End-state behaviour is unchanged (the background refresh always reconciled);
this removes the transient stale frame. Unit-tested patchLobbyGame and
advanceCached; docs/UI_DESIGN.md updated.
Closes the residual tail of the previous commit: when the live stream stays
alive across a brief suspend (Telegram/iOS can pause the socket without tearing
it down), an in-game event shed from a full hub buffer is never recovered by
the reconnect refetch (no reconnect) or the open-game poll (it only runs while
the stream is down). Mirror the lobby's focus re-poll: bump app.resync on a
foreground regain that did not drop the stream, and have Game.svelte refetch
the open game once per resync. Also rescues a missed move/game_over after a
suspend. Add a silent-join mock seam + an e2e isolating this path; extend the
ARCHITECTURE §10 fallback note.
PR #51 moved the auto-match "wait for an opponent" from the lobby matchmaking
screen into the open game but did not carry over that screen's poll fallback.
The notify hub is best-effort and never replays, the live-stream resubscribe
sends no cursor, and the game screen refreshed only from push events — so an
opponent_joined dropped while the stream was down (e.g. a mobile suspend) left
the starter stuck on "searching for opponent" until they re-entered the game.
Unlike opponent_moved/game_over, opponent_joined has no follow-up event to
trigger the existing move-count gap refetch.
Recover it in Game.svelte: (A) refetch once on stream reconnect (covers the
common suspend/resume case and rescues a missed move/game_over too), and
(B) poll game.state every 2.5s while still waiting with the stream down
(mirrors the old matchmaking startPoll). Add a mock-mode __stream e2e seam and
two specs isolating each path, fix the now-stale streamAlive comment, and
document the fallback in ARCHITECTURE §10.
Replace Menu.svelte (hamburger) everywhere with tab-bar navigation:
- Settings hub (SettingsHub) from the lobby ⚙️ tab: Settings/Profile/
Friends/About as in-place tabs, back → lobby; the lobby ⚙️ badge counts
incoming friend requests (invitations keep their own lobby section).
- Comms hub (CommsHub) from the move-history 💬: Chat/Dictionary tabs,
back → game; Dictionary only while the game is active.
- Game menu items relocate into the open history: 🏁 leave / 📤 export in
the header, 🤝 add-friend per opponent card, 💬 comms; unread chat is
badged on the score bar + the 💬.
- TapConfirm (tap → fading ✅ → tap) replaces the Skip/Hint press-and-hold
popovers and drives the add-friend confirm.
- Fix the move-history "jump": the slid board is inert and the stage can't
scroll, so a swipe up genuinely closes the history.
Remove Menu.svelte + HoldConfirm.svelte. Docs: UI_DESIGN, FUNCTIONAL(+ru),
PRERELEASE. UI check/unit/build/bundle/e2e (Chromium+WebKit) all green.
Enrich the in-app live stream into a delta channel so the UI renders a move from the event without a follow-up game.state, and make the matchmaking poll a stream-down fallback.
- pkg/fbs: trailing fields on opponent_moved (move+game+bag_len), your_turn (move_count), match_found (state), game_over (game), notify (account/invitation/state), MoveResult (rack+bag_len); regenerate Go + TS.
- backend: notify owns the FB encoding (encode.go + payload.go input structs); game/lobby/social map their domain types in. emitMove builds the move delta; game.Service.InitialState feeds match_found/game_started the recipient's initial StateView; friends/invitations notify carry their account/invitation. The move-commit response (submit_play/pass/exchange/resign) returns the actor's refilled rack + bag size.
- gateway: MoveResult transcode carries rack+bag_len.
- ui: pure lib/gamedelta.ts reducer advances the per-game cache keyed on move_count (idempotent + gap-safe); app.svelte seeds the cache on match_found/game_started; Game.svelte applies the delta (commit/pass/exchange/resign drop their load()); NewGame polls only while app.streamAlive is false.
- docs: ARCHITECTURE §10, FUNCTIONAL(+ru), backend/gateway/ui READMEs; PRERELEASE R4 marked done + Refinements.
A dropped/reset/timed-out connection can surface as a Connect code other than
Unavailable (Canceled/DeadlineExceeded/Unknown/…) which fell through to the generic
'internal' -> a red 'something went wrong' toast appeared alongside the Connecting
spinner. Now toGatewayError (moved to the pure retry.ts, unit-tested) collapses every
transport-level code to 'unavailable' so it is retried + flips offline; and handleError
suppresses the toast for any connection code AND whenever the app is mid-reconnect
(!connection.online), covering the race where a unary error lands before the stream
reports the drop. Genuine server-internal / domain errors still toast while online.
Connectivity failures become state, not a toast on every attempt. A global online
signal (lib/connection.svelte.ts) flips on a transport unavailable / rate_limited and
on the live stream's drop, driving a pure-CSS header spinner + 'Connecting…' in place
of the title and softly disabling the in-game server actions (commit / exchange / pass
/ hint; local board/rack/reset stay live).
- transport: exec auto-retries with capped exponential backoff — every op on a
rate-limit (rejected before processing, safe), reads only on unavailable (a mutation
is never blindly re-sent, to avoid double-applying one whose response was lost; its
button is disabled while offline so the player re-issues on reconnect). A reachability
watcher (profile.get probe) and any successful traffic clear the signal.
- the old red error.unavailable toast is gone (handleError suppresses connection codes;
the indicator replaces it). A server-data screen still opens with the spinner and
fills on reconnect (global indicator + read auto-retry), so navigation is never dead.
- pure retry policy unit-tested (retry.ts); a mock-only window.__conn hook drives a
Chromium+WebKit e2e (indicator shows offline, the action disables, both clear on
reconnect). Full suite + build green.
- docs: ARCHITECTURE transport note, FUNCTIONAL (+ _ru), PLAN tracker (incl. #1 — the
bot already drains all updates, no change).
Also records #1 as investigated/no-change in PLAN. Other server-action buttons (chat
send, profile save, …) still degrade to a safe no-op offline; visual disable is easy to
extend.
- Chat and word-check are now routed screens (/game/:id/chat, /game/:id/check) with a
header back to the game and no tab-bar, replacing their modals. The soft keyboard just
resizes the visible viewport (tracked into --vvh, which the Screen height uses since iOS
does not shrink dvh for the keyboard) with the input pinned to the bottom: no modal
relayout, no page jump. Supersedes the earlier bottom-sheet Modal attempt.
- A new chat message raises an unread badge on the in-game hamburger + the Chat menu row
(per game, cleared on opening the chat), mirroring the lobby badge.
- TG native back + the header back chevron return chat/check to their game.
- Exposes --tg-safe-top (device notch) for the finalised TG-fullscreen header.
Tests: e2e for chat/check opening as their own screens + back. Docs: PLAN, FUNCTIONAL(+ru).
- Edge-swipe back now listens at the window in the CAPTURE phase (the board's
pointer handlers can't swallow it) and is no longer skipped inside Telegram
(where the owner tests it).
- TG-fullscreen header: expose the device safe-area top (--tg-safe-top) and
centre the title + menu pair within Telegram's nav band ([safe-top,
content-top]) below the notch, keeping the band's height — lining up with
Telegram's own controls.
- DnD auto-zoom-on-hover delay reduced 1000ms -> 700ms.
(Client-IP: diagnosed as the owner's home-router SNAT — the host caddy already
receives 192.168.0.1 with no XFF, so the real IP is lost upstream of our stack;
correct in prod. No code change.)
Backlog item 2 of ~4 (owner review pass):
- USSR flag emblem redrawn (canonical hammer & sickle, scaled down 1.5x
below the star).
- Touch drag-and-drop: enlarge the drag ghost 1.5x on touch only (the finger
hides the tile); suppress the iOS tap-highlight that lingered on a rack tile
sliding into a dragged tile's slot.
- Telegram fullscreen: its native nav no longer hides our header -- the header
drops below the content-safe-area top inset and the menu (hamburger) lifts
into the nav band, centred (--tg-content-top from the SDK inset + a
tg-fullscreen class; new telegram.ts helper + app wiring).
Tests: UI check/test:unit/build + full e2e (60) green. The iOS tap-highlight
fix and the TG-fullscreen layout want on-device verification on the deploy.
Add a 'Grid lines' preference (default off): when off the board drops the 1px grid
gaps for a gapless checkerboard (plain cells alternate shades; tiles get rounded
corners and a soft right-side shadow so adjacent gapless tiles still read apart),
saving ~14px of width. When on, the classic lined grid returns. Persisted with the
other board-style prefs; wired through Board's new lines prop. e2e locks the default
and the toggle.
- Telegram (lib/telegram.ts): chrome colours (setHeaderColor/setBackgroundColor/setBottomBarColor) match Telegram's header/bg/bottom bar to the app; native BackButton on sub-screens (app chevron hidden in TG); HapticFeedback on tile place/commit/error; enableClosingConfirmation while a game is open; disableVerticalSwipes so swipe-to-minimise doesn't fight tile drag / board scroll
- #9 board-only vertical scroll: Screen 'column' mode lets the board area scroll while score/status/rack/tab bar stay fixed (zoom keeps its own scroll)
- #10 check-word dialog opens in Modal keyboard-overlay mode (top-anchored, keyboard overlays the empty area) — no resize/relayout jank; other modals stay keyboard-aware
- docs: UI_DESIGN Telegram integration + vertical fit/keyboard; PLAN round 2-3 follow-ups
- 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
- #6 align the UI variant id to the backend canonical 'russian_scrabble' (type, variants, Lobby, mock, tests) — fixes the New->Russian 400
- #11/#12 inside Telegram force the colour scheme from WebApp.colorScheme (over OS prefers-color-scheme, fixing the Telegram Desktop breakage) and hide the theme switcher
- #14/#15 nav bar takes Telegram's bg; announcement banner gets a dedicated subtle --ad-bg accent token
- #16 suppress the reconnect banner while backgrounded and silently reconnect the live stream on return to the foreground
- #17 hint zoom scrolls to the placement's bounding box, not the top-left
- #19/#20 players plaque: active seat raised with side shadows, others sunk; tap toggles history
- #21/#23 history: scrollbar-gutter:stable (no word jitter); fixed-height drawer pins the bottom shadow to the board
- #3 (UI) disable nudge on the player's own turn
- #18a directional screen slide transitions (forward in from the right, back reveals the lobby)
- #13 per-game in-memory cache: instant render on re-entry + background refresh
- e2e: openGame waits for the slide transition to settle
New platform/telegram connector (own container, bot token only there):
- go-telegram/bot long-poll loop: /start deep-links + Mini App launch button.
- gRPC API pkg/proto/telegram/v1 (Telegram service): ValidateInitData, Notify
(renders a localized message + deep-link button), SendToUser/SendToGameChannel
(admin, wired in Stage 10). Generic methods are platform-agnostic (external_id).
- Bot API base override for Telegram's test environment; Dockerfile + compose
(VPN sidecar, no public ingress); README.
Gateway:
- initData validation relocated from the gateway into the connector; the gateway
calls ValidateInitData over gRPC (GATEWAY_CONNECTOR_ADDR), drops the bot token,
and deletes internal/auth.
- Out-of-app push: runPushPump routes events whose recipient has no live in-app
stream to connector.Notify, gated by /internal/push-target + the in-app-only
flag (race-free de-dup); HasSubscribers added to the push hub.
Backend:
- Migration 00007 accounts.notifications_in_app_only (default true) + jetgen.
- ProvisionTelegram seeds a new account's language/display name from the launch
fields; IdentityExternalID reverse lookup; /internal/push-target handler.
UI:
- Telegram Mini App launch: detect initData, apply themeParams, authTelegram,
route the deep-link start_param (g/i/f); /telegram/ guard redirects outside
Telegram. Vite relative base + telegram-web-app.js. In-app-only profile toggle;
share-to-Telegram link for a friend code. Vitest + Playwright coverage.
Wire/docs/CI: fbs Profile/UpdateProfileRequest gain notifications_in_app_only
(Go + TS); go.work uses ./platform/telegram; go-unit.yaml covers it; PLAN,
ARCHITECTURE, FUNCTIONAL (+ru), UI_DESIGN, READMEs updated.
Wire the deferred Stage 7 surfaces end-to-end (UI -> gateway transcode ->
backend REST -> existing domain services): friends (incl. one-time friend
codes), per-user blocks, friend-game invitations, profile editing + email
binding, the statistics screen, and the in-game history + GCG export.
Friends gain two add paths (interview decision, a deliberate plan change):
one-time 6-digit codes (friend_codes table, 12h TTL, single-use, rate-limited
redeem); and play-gated requests (shared game required) where an explicit
decline is permanent, an ignored request lapses after 30 days, and a code
bypasses a decline. Migration 00006 widens friendships_status_chk and adds
friend_codes.
Lobby notification badge is poll + push: a new generic `notify` event drives
it live; the client polls on open/focus. Language stays a single Settings
control that writes through to the durable account's preferred_language. GCG
export is finished-only (game.ErrGameActive) and shares/downloads the .gcg file.
Tests: backend unit + inttest (friend gate/decline/code, ListInvitations,
GetStats, GCG gate), gateway transcode round-trips + notify constructor, UI
vitest (codecs, win-rate, share choice) + Playwright social specs. Docs: PLAN
(Stage 8 done + refinements + TODO-5), ARCHITECTURE, FUNCTIONAL(+ru), UI_DESIGN,
TESTING, module READMEs.