From 6fbab5417fb7d1ae4538e93b3a5358ee83f684ca Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Wed, 27 May 2026 10:17:57 +0200 Subject: [PATCH] =?UTF-8?q?fix(ui):=20F8-04b=20lobby=20=E2=80=94=20auto-ex?= =?UTF-8?q?pand=20first=20available=20games=20sub=20+=20hide=20empty=20inv?= =?UTF-8?q?itations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-up nits on the F8-04b sidebar: 1. The bare-`lobby` resolver (lobby-screen.svelte) redirected to `games-recruitment` unconditionally on mount. With games already in the player's roster the sidebar then highlighted the wrong sub-page. The resolver now awaits the lobby fan-out + account fetch, then hands off to the same `firstVisibleGamesScreen` helper the sidebar uses — so a fresh entry with games lands on `active-past`, the canonical-order fallback stays `recruitment`. 2. `games-invitations` was unconditionally visible in the sidebar. Now it follows the `active-past` rule: hidden until the pending-invites list reports >=1. The lobby shell's auto-kick effect treats it symmetrically — accepting / declining the last invite moves the player to the next visible sub-page once the fan-out has resolved. Acceptance order in games-invitations-screen.acceptInvite was also swapped to setMyGames-before-removeInvitation: both mutations land in the same microtask, so the new auto-kick sees the freshly added game in `myGames` when invitations drop to zero and routes the player to `active-past` instead of bouncing through `recruitment`. The visibility predicates and canonical order live in the new `src/lib/lobby-nav.ts` pure helper, shared between the sidebar and the resolver so they cannot disagree. Unit tests cover every combination of (hasMyGames, hasInvitations, isPaidOrDev). Co-Authored-By: Claude Opus 4.7 (1M context) --- ui/docs/lobby.md | 16 ++- ui/frontend/src/lib/lobby-nav.ts | 66 +++++++++ .../screens/games-invitations-screen.svelte | 8 +- .../src/lib/screens/lobby-screen.svelte | 53 +++++-- .../src/lib/screens/lobby-shell.svelte | 135 +++++++++--------- ui/frontend/tests/lobby-nav.test.ts | 114 +++++++++++++++ 6 files changed, 307 insertions(+), 85 deletions(-) create mode 100644 ui/frontend/src/lib/lobby-nav.ts create mode 100644 ui/frontend/tests/lobby-nav.test.ts diff --git a/ui/docs/lobby.md b/ui/docs/lobby.md index 6620a3f..2edf145 100644 --- a/ui/docs/lobby.md +++ b/ui/docs/lobby.md @@ -61,12 +61,20 @@ contents follow the lobby-data store and the account tier: | --------------- | --------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | | `active-past` | `lobby.my.games.list` | Visible only when the list is non-empty. Empty → the sub-panel is hidden entirely (no empty card surfaces). | | `recruitment` | `lobby.public.games.list` ⨝ `lobby.my.applications.list` | Always visible. Public games where the caller is **not** the owner; each card surfaces the caller's application status as a chip (`pending` / `approved` / `rejected` / `unknown`) when there is one. Stale `pending`/`approved` applications on closed games render as standalone "applied" cards; stale `rejected`/`unknown` ones are hidden. | -| `invitations` | `lobby.my.invites.list` (status=`pending`) | Always visible. | +| `invitations` | `lobby.my.invites.list` (status=`pending`) | Visible only when the pending-invites list is non-empty. Empty (or while the fan-out is still in flight) → the sub-panel is hidden, mirroring the `active-past` rule. | | `private games` | `lobby.my.games.list` filtered by `owner_user_id === me` ∧ `game_type === "private"` | Paid tier only (`account.entitlement.is_paid === true`). `VITE_GALAXY_DEV_AFFORDANCES` overrides for DEV bundles. | -Clicking the `games` parent without choosing a sub-panel resolves to -the first visible sub-panel in the canonical order (e.g. with no -games yet it lands on `recruitment`). +Clicking the `games` parent without choosing a sub-panel — or +arriving on the bare `lobby` alias (a pre-split persisted snapshot, +or a programmatic `appScreen.go("lobby")`) — resolves to the first +visible sub-panel in the canonical order. The resolver awaits the +`lobby.*.list` fan-out and `account` fetch before deciding, so a +fresh entry with games already in the player's roster lands on +`active-past`, an invitee-only account lands on `invitations`, and +the deterministic fallback is `recruitment` (always visible). The +predicates live in `src/lib/lobby-nav.ts` and are shared between +this resolver and the sidebar's submenu, so the two surfaces never +disagree. ### `recruitment` — inline application form diff --git a/ui/frontend/src/lib/lobby-nav.ts b/ui/frontend/src/lib/lobby-nav.ts new file mode 100644 index 0000000..6a89667 --- /dev/null +++ b/ui/frontend/src/lib/lobby-nav.ts @@ -0,0 +1,66 @@ +// Pure helpers for the lobby `games` submenu. +// +// Both the sidebar (`lib/screens/lobby-shell.svelte`) and the bare-`lobby` +// resolver (`lib/screens/lobby-screen.svelte`) need to agree on which +// sub-screen is the "first available" — the one auto-expanded in the +// sidebar and the redirect target when the user lands on the bare lobby +// alias. Centralising the predicates here keeps the two surfaces from +// drifting apart. +// +// The functions are intentionally pure: they take a resolved snapshot of +// lobby / account state, return the visible sub-screen list (in canonical +// order) and the first entry, and never touch stores or globals. Callers +// derive the state from `lobbyData` / `account` and the DEV-affordances +// flag. + +export type GamesSubScreen = + | "games-active-past" + | "games-recruitment" + | "games-invitations" + | "games-private-games"; + +/** + * LobbyNavState is the resolved snapshot of the lobby state the + * games-submenu visibility rules depend on. Build it from the live + * stores at the call site; the helpers below stay pure on top. + */ +export interface LobbyNavState { + /** Player has at least one own / past game in `lobby.my-games.list`. */ + readonly hasMyGames: boolean; + /** Player has at least one pending invite in `lobby.invites.list`. */ + readonly hasInvitations: boolean; + /** Player is on a paid tier, or the build exposes DEV affordances. */ + readonly isPaidOrDev: boolean; +} + +/** + * visibleGamesSubScreens returns the games-submenu sub-screens that + * are currently visible, in canonical display order: + * + * 1. `games-active-past` — only when the player has games. + * 2. `games-recruitment` — always visible. + * 3. `games-invitations` — only when the player has pending invites. + * 4. `games-private-games` — only when paid-tier or DEV. + * + * `games-recruitment` is unconditional, so the returned array is + * always non-empty. + */ +export function visibleGamesSubScreens( + state: LobbyNavState, +): readonly GamesSubScreen[] { + const out: GamesSubScreen[] = []; + if (state.hasMyGames) out.push("games-active-past"); + out.push("games-recruitment"); + if (state.hasInvitations) out.push("games-invitations"); + if (state.isPaidOrDev) out.push("games-private-games"); + return out; +} + +/** + * firstVisibleGamesScreen returns the first entry from + * `visibleGamesSubScreens(state)`. Since `games-recruitment` is + * unconditional, the result is always defined. + */ +export function firstVisibleGamesScreen(state: LobbyNavState): GamesSubScreen { + return visibleGamesSubScreens(state)[0]!; +} diff --git a/ui/frontend/src/lib/screens/games-invitations-screen.svelte b/ui/frontend/src/lib/screens/games-invitations-screen.svelte index 5199ce7..4338006 100644 --- a/ui/frontend/src/lib/screens/games-invitations-screen.svelte +++ b/ui/frontend/src/lib/screens/games-invitations-screen.svelte @@ -35,8 +35,14 @@ accept / decline actions. Accepted invites move the inviting game into actionError = null; try { await redeemInvite(client, invite.gameId, invite.inviteId); + // Apply both mutations in the same microtask so the lobby + // shell's auto-kick-when-empty effect sees the freshly added + // game already in `myGames` when invitations drop to zero — + // otherwise the user briefly lands on `recruitment` between + // the two writes before the games list catches up. + const games = await listMyGames(client); + lobbyData.setMyGames(games); lobbyData.removeInvitation(invite.inviteId); - lobbyData.setMyGames(await listMyGames(client)); } catch (err) { actionError = describeLobbyError(err); } finally { diff --git a/ui/frontend/src/lib/screens/lobby-screen.svelte b/ui/frontend/src/lib/screens/lobby-screen.svelte index 8d1624a..884f84b 100644 --- a/ui/frontend/src/lib/screens/lobby-screen.svelte +++ b/ui/frontend/src/lib/screens/lobby-screen.svelte @@ -6,24 +6,55 @@ component is what the dispatcher renders when the active screen is the historical `lobby` alias (e.g. a snapshot persisted before the split, or programmatic `appScreen.go("lobby")` from non-shell code). -The resolver navigates to the first visible games sub-page at mount -time and renders a thin LobbyShell placeholder while the redirect -runs. The destination depends on the account tier and on whether the -caller has any games yet — both are computed by the shell, so we just -pick `games-recruitment` here as the canonical landing (always -visible) and let the shell's submenu logic surface the others. +On mount the resolver awaits the lobby fan-out + account fetch, then +hands off to `firstVisibleGamesScreen()` — the same helper the shell +sidebar uses, so the auto-expanded sub-page picked here is the same +one the sidebar will highlight (e.g. `games-active-past` when the +player has games, `games-invitations` when there are pending invites +but no games, etc.). While the data is in flight the user sees the +lobby chrome with the i18n list-loading placeholder. --> diff --git a/ui/frontend/src/lib/screens/lobby-shell.svelte b/ui/frontend/src/lib/screens/lobby-shell.svelte index fb8fe7a..815ffd5 100644 --- a/ui/frontend/src/lib/screens/lobby-shell.svelte +++ b/ui/frontend/src/lib/screens/lobby-shell.svelte @@ -8,8 +8,11 @@ F8-04b extends the sidebar to a two-level hierarchy: - top-level items: `games` (with submenu), `profile`, and DEV-only `synthetic test reports`; - `games` submenu: `active-past` (hidden when the player has no games), - `recruitment` (always), `invitations` (always), `private games` - (paid-tier only; DEV overrides). + `recruitment` (always), `invitations` (hidden when no pending invites), + `private games` (paid-tier only; DEV overrides). The visibility rules + live in `lib/lobby-nav.ts` so the bare-`lobby` resolver + (`lobby-screen.svelte`) and this shell agree on the auto-expanded + sub-page. Desktop (>640px): the submenu stays expanded as long as the active screen is one of the games sub-panels. Mobile (≤640px, existing @@ -24,16 +27,17 @@ placeholder. -->