feat(ui): cross-screen caches + invitation delta channel + hint recenter on a zoomed board #56

Merged
developer merged 5 commits from feature/lobby-cache-any-screen-invitation-delta into development 2026-06-14 10:57:37 +00:00
Owner

Продолжение работы по межэкранной свежести кэшей (поверх #55). Теперь свежесть лобби/игры держится с любого активного экрана, а приглашения становятся живым дельта-каналом.

Клиент

  • Граница #1 (opponent_joined off-screen): advanceCached теперь сворачивает и opponent_joined в кэш не-просматриваемой игры через новый чистый редьюсер applyOpponentJoined (вынесен и переиспользован смонтированной доской). Открыть open-игру, в которую вошли, пока ты был на другом экране — без мерцания доски.
  • Приглашения: patchLobbyInvitation — upsert ещё-pending приглашения, удаление терминального (started/declined/cancelled/expired). Глобальный notify-хендлер зовёт его на sub invitation / invitation_update.

Дельта-канал приглашений (без изменений wire/gateway/connector)

Ключевое: уведомление уже несёт полный Invitation с id/status/game_id сквозь все слои (создание приглашений так работает сегодня), а неизвестные notify-sub'ы Telegram-сервис не рендерит. Поэтому:

  • notify: новый in-app-only sub NotifyInvitationUpdate + конструктор NotificationInvitationUpdate (общий кодер с NotificationInvitation). Коннектор для него сообщение не рендерит → decline/cancel никогда не станут out-of-app пушем.
  • lobby: эмиссия изменённого приглашения каждому участнику на respond (accept/decline), на старте игры при финальном accept и на cancel. Авторитетный список содержит только pending — поэтому правило клиента «pending → upsert, иначе remove» совпадает с ним точно.

Осознанные границы

  • Ленивая экспирация приглашения (при ответе на протухшее) события не шлёт — редкий пассивный переход, покрыт cold-load на входе в лобби.
  • Список incoming (друзья) в снапшоте точечно не патчится — у него свой notify-путь и бейдж (refreshNotifications).

Тесты

TDD (RED→GREEN): юниты applyOpponentJoined, patchLobbyInvitation, кодирование NotificationInvitationUpdate; интеграционные ассерты, что decline/cancel/accept публикуют invitation_update каждому участнику. Полный локальный набор зелёный: backend unit + integration (./backend/...), UI check / unit 184 / build / bundle / e2e 114 (Chromium + WebKit). Документация: ARCHITECTURE §10 + UI_DESIGN.


Дополнительно: фокус доски при подсказке в zoom-in (отдельный коммit)

Мелкая шероховатость: подсказка, взятая когда доска уже приближена, применялась, но доска не скроллилась на новое слово (эффект скролла отслеживал только переключение зума, а focus читался через untrack). Добавлен нонс recenter, который подсказка бампает; при запросе без смены зума доска панится прямо к focus. Выкладывание/перетаскивание фишек по-прежнему не дёргают доску. e2e RED→GREEN на обоих движках.


Ещё два фикса в этой же ветке

  • fix(ui): заморозка игрового экрана при присоединении оппонента. In-game $effect на app.lastEvent читал view/placement (через cacheSnapshot) и сам же писал view в ветках → запись становилась его зависимостью → самоинвалидирующийся бесконечный цикл, блокирующий поток (доска/rack «намертво»). opponent_moved спасала идемпотентность дельты, у opponent_joined/game_over её нет. Фикс: обернуть тело эффекта в untrack — эффект зависит только от app.lastEvent и обрабатывает событие один раз. Регрессионный e2e: после джойна выставление фишки рендерит pending (RED без фикса → GREEN).
  • refactor(ui): укрепление recenter подсказки. void recenter мог быть выкинут продакшен-минификатором (e2e гоняет только немінифицированный dev-сервер). Нонс читается в используемую переменную и пан гейтится на реальное изменение — зависимость устойчива к минификации и не панит на ресайзе.

test(ui): e2e против минифицированной сборки

E2e гонялся против немінифицированного dev-сервера, поэтому продакшен-минификация была невидима (как раз так проскользнул бы выкинутый void recenter). Теперь webServer собирает mock-сборку и отдаёт минифицированный артефакт через vite preview — тот же бандл, что на контуре. Сборка в dist-e2e/ (gitignored, не затирает dist/ для bundle-size), --base / чтобы SPA-фолбэк грузил и подпуть /telegram/. Все 118 спеков зелёные против сборки на обоих движках, включая тест recenter — подтверждает, что укреплённая зависимость переживает минификацию.

Продолжение работы по межэкранной свежести кэшей (поверх #55). Теперь свежесть лобби/игры держится **с любого активного экрана**, а приглашения становятся живым **дельта-каналом**. ## Клиент - **Граница #1 (opponent_joined off-screen):** `advanceCached` теперь сворачивает и `opponent_joined` в кэш не-просматриваемой игры через новый чистый редьюсер `applyOpponentJoined` (вынесен и переиспользован смонтированной доской). Открыть open-игру, в которую вошли, пока ты был на другом экране — без мерцания доски. - **Приглашения:** `patchLobbyInvitation` — upsert ещё-pending приглашения, удаление терминального (started/declined/cancelled/expired). Глобальный notify-хендлер зовёт его на sub `invitation` / `invitation_update`. ## Дельта-канал приглашений (без изменений wire/gateway/connector) Ключевое: уведомление **уже несёт полный `Invitation` с `id`/`status`/`game_id`** сквозь все слои (создание приглашений так работает сегодня), а неизвестные notify-sub'ы Telegram-сервис не рендерит. Поэтому: - **notify:** новый **in-app-only** sub `NotifyInvitationUpdate` + конструктор `NotificationInvitationUpdate` (общий кодер с `NotificationInvitation`). Коннектор для него сообщение не рендерит → decline/cancel никогда не станут out-of-app пушем. - **lobby:** эмиссия изменённого приглашения **каждому участнику** на respond (accept/decline), на старте игры при финальном accept и на cancel. Авторитетный список содержит только pending — поэтому правило клиента «pending → upsert, иначе remove» совпадает с ним точно. ## Осознанные границы - Ленивая экспирация приглашения (при ответе на протухшее) события не шлёт — редкий пассивный переход, покрыт cold-load на входе в лобби. - Список `incoming` (друзья) в снапшоте точечно не патчится — у него свой `notify`-путь и бейдж (`refreshNotifications`). ## Тесты TDD (RED→GREEN): юниты `applyOpponentJoined`, `patchLobbyInvitation`, кодирование `NotificationInvitationUpdate`; интеграционные ассерты, что decline/cancel/accept публикуют `invitation_update` каждому участнику. Полный локальный набор зелёный: backend unit + **integration** (`./backend/...`), UI check / unit **184** / build / bundle / e2e **114** (Chromium + WebKit). Документация: ARCHITECTURE §10 + UI_DESIGN. --- ## Дополнительно: фокус доски при подсказке в zoom-in (отдельный коммit) Мелкая шероховатость: подсказка, взятая когда доска **уже** приближена, применялась, но доска не скроллилась на новое слово (эффект скролла отслеживал только переключение зума, а `focus` читался через `untrack`). Добавлен нонс `recenter`, который подсказка бампает; при запросе без смены зума доска панится прямо к `focus`. Выкладывание/перетаскивание фишек по-прежнему не дёргают доску. e2e RED→GREEN на обоих движках. --- ## Ещё два фикса в этой же ветке - **fix(ui): заморозка игрового экрана при присоединении оппонента.** In-game `$effect` на `app.lastEvent` читал `view`/`placement` (через `cacheSnapshot`) и сам же писал `view` в ветках → запись становилась его зависимостью → самоинвалидирующийся бесконечный цикл, блокирующий поток (доска/rack «намертво»). `opponent_moved` спасала идемпотентность дельты, у `opponent_joined`/`game_over` её нет. Фикс: обернуть тело эффекта в `untrack` — эффект зависит только от `app.lastEvent` и обрабатывает событие один раз. Регрессионный e2e: после джойна выставление фишки рендерит pending (RED без фикса → GREEN). - **refactor(ui): укрепление recenter подсказки.** `void recenter` мог быть выкинут продакшен-минификатором (e2e гоняет только немінифицированный dev-сервер). Нонс читается в используемую переменную и пан гейтится на реальное изменение — зависимость устойчива к минификации и не панит на ресайзе. --- ## test(ui): e2e против минифицированной сборки E2e гонялся против немінифицированного dev-сервера, поэтому продакшен-минификация была невидима (как раз так проскользнул бы выкинутый `void recenter`). Теперь webServer собирает mock-сборку и отдаёт **минифицированный** артефакт через `vite preview` — тот же бандл, что на контуре. Сборка в `dist-e2e/` (gitignored, не затирает `dist/` для bundle-size), `--base /` чтобы SPA-фолбэк грузил и подпуть `/telegram/`. Все 118 спеков зелёные против сборки на обоих движках, включая тест recenter — подтверждает, что укреплённая зависимость переживает минификацию.
developer added 1 commit 2026-06-14 09:23:09 +00:00
feat(lobby): keep lobby/game caches fresh from any screen + invitation delta channel
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 48s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m7s
56dbf86472
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.
owner approved these changes 2026-06-14 09:24:29 +00:00
Dismissed
developer changed title from feat(lobby): caches fresh from any screen + invitation delta channel to feat(ui): cross-screen caches + invitation delta channel + hint recenter on a zoomed board 2026-06-14 09:44:16 +00:00
developer added 1 commit 2026-06-14 09:44:16 +00:00
fix(ui): recentre the board on a hint taken while already zoomed in
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 48s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 57s
f3914af793
The board only scrolled to the hint's word when the hint also toggled zoom (the
zoom-out case): the recenter effect tracked `zoomed` and read `focus` untracked,
so a hint taken while ALREADY zoomed in — no zoom change — never recentred and
the board stayed parked where the player was looking.

Add a `recenter` nonce the hint bumps; the board's scroll effect tracks it and,
when it fires without a zoom toggle, pans straight to `focus` (the board width is
stable, so the width-driven zoom tween has nothing to ride). Placing a 2nd+ tile
or hovering a dragged tile still set `focus` without the nonce, so the board
never jumps on those — the original no-jump intent is preserved.

e2e: zoom into the corner, then hint (the mock plays at the centre) — the board
pans toward the centre. Verified RED without the fix (both engines), GREEN with.
developer dismissed owner's review 2026-06-14 09:44:16 +00:00
Reason:

New commits pushed, approval review dismissed automatically according to repository settings

developer added 2 commits 2026-06-14 10:40:27 +00:00
The in-game live-event $effect read `view`/`moves`/`placement` (via cacheSnapshot
and direct reads) AND wrote `view` in its branches, so those reads became the
effect's own dependencies: writing `view = …` re-ran the effect, and with
app.lastEvent unchanged it re-entered the same branch and wrote `view` again — a
tight self-invalidating loop that pinned the main thread, freezing the board and
rack. opponent_moved escaped it only because applyMoveDelta is idempotent on the
move count (no cache → no write on the second pass); opponent_joined and game_over
have no such guard, so an opponent joining hung the whole screen. Tracking
`placement` similarly re-fired the handler on every tile the player placed after
an opponent's move (a spurious reload).

Fix: the effect must depend only on app.lastEvent and process each event once.
Wrap the branch body in `untrack`, scoping its view/moves/placement reads out of
the effect's dependency set; the writes inside no longer re-trigger it.

e2e: after the opponent joins, placing a rack tile must render a pending tile —
verified RED (frozen, 0 pending tiles, both engines) before the fix, GREEN after.
refactor(ui): harden the board's recenter dependency and gate it on a real bump
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m0s
553152e195
The recenter effect declared its dependency with a bare `void recenter`, which a
production minifier could drop as a side-effect-free statement (the e2e runs only
the unminified dev server, so it would not catch that). Read the nonce into a
used variable and gate the pan on it actually changing (`recentered`), so the
reactive dependency is robust to minification and the board pans only on an
explicit hint recenter — never on an incidental re-run such as a viewport resize.
owner approved these changes 2026-06-14 10:46:47 +00:00
Dismissed
developer added 1 commit 2026-06-14 10:54:46 +00:00
test(ui): run e2e against the minified vite build, not the dev server
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Successful in 46s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m9s
abe1038333
The e2e booted the unminified Vite dev server, so production-minification bugs
were invisible to it — exactly how a minifier dropping a bare `void recenter`
reactive read slipped through. Build the app in mock mode and serve the minified
artifact via `vite preview` instead, so the smoke exercises the same bundle the
contour ships. Build to dist-e2e/ (gitignored) so it never clobbers the dist/ the
bundle-size gate measures, and with `--base /` so the SPA-fallback also boots a
subpath like /telegram/ (the production relative base needs the gateway's path
mapping, absent under a plain preview). All 118 specs pass against the build on
both engines, including the hint-recenter spec — confirming the hardened
dependency survives minification.
developer dismissed owner's review 2026-06-14 10:54:46 +00:00
Reason:

New commits pushed, approval review dismissed automatically according to repository settings

owner approved these changes 2026-06-14 10:55:00 +00:00
developer merged commit c7e177f911 into development 2026-06-14 10:57:37 +00:00
developer deleted branch feature/lobby-cache-any-screen-invitation-delta 2026-06-14 10:57:37 +00:00
Sign in to join this conversation.
No Reviewers
No Label
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: developer/scrabble-game#56