Merge pull request 'fix(ui): F8-04b lobby — auto-expand first available games sub + hide empty invitations' (#64) from feature/lobby-default-expand-and-empty-invitations into development
Deploy · Dev / deploy (push) Successful in 55s
Tests · UI / test (push) Successful in 2m52s

This commit was merged in pull request #64.
This commit is contained in:
2026-05-27 08:31:07 +00:00
6 changed files with 307 additions and 85 deletions
+12 -4
View File
@@ -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). | | `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. | | `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. | | `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 Clicking the `games` parent without choosing a sub-panel — or
the first visible sub-panel in the canonical order (e.g. with no arriving on the bare `lobby` alias (a pre-split persisted snapshot,
games yet it lands on `recruitment`). 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 ### `recruitment` — inline application form
+66
View File
@@ -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]!;
}
@@ -35,8 +35,14 @@ accept / decline actions. Accepted invites move the inviting game into
actionError = null; actionError = null;
try { try {
await redeemInvite(client, invite.gameId, invite.inviteId); 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.removeInvitation(invite.inviteId);
lobbyData.setMyGames(await listMyGames(client));
} catch (err) { } catch (err) {
actionError = describeLobbyError(err); actionError = describeLobbyError(err);
} finally { } finally {
+42 -11
View File
@@ -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 the historical `lobby` alias (e.g. a snapshot persisted before the
split, or programmatic `appScreen.go("lobby")` from non-shell code). split, or programmatic `appScreen.go("lobby")` from non-shell code).
The resolver navigates to the first visible games sub-page at mount On mount the resolver awaits the lobby fan-out + account fetch, then
time and renders a thin LobbyShell placeholder while the redirect hands off to `firstVisibleGamesScreen()` — the same helper the shell
runs. The destination depends on the account tier and on whether the sidebar uses, so the auto-expanded sub-page picked here is the same
caller has any games yet — both are computed by the shell, so we just one the sidebar will highlight (e.g. `games-active-past` when the
pick `games-recruitment` here as the canonical landing (always player has games, `games-invitations` when there are pending invites
visible) and let the shell's submenu logic surface the others. but no games, etc.). While the data is in flight the user sees the
lobby chrome with the i18n list-loading placeholder.
--> -->
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from "svelte";
import { appScreen } from "$lib/app-nav.svelte"; import { appScreen } from "$lib/app-nav.svelte";
import { i18n } from "$lib/i18n/index.svelte"; import { i18n } from "$lib/i18n/index.svelte";
import { lobbyData } from "$lib/lobby-data.svelte";
import { account } from "$lib/account-store.svelte";
import {
firstVisibleGamesScreen,
type LobbyNavState,
} from "$lib/lobby-nav";
import LobbyShell from "./lobby-shell.svelte"; import LobbyShell from "./lobby-shell.svelte";
onMount(() => { const DEV_AFFORDANCES =
// The shell's own $effect keeps the user on a valid sub-page if import.meta.env.VITE_GALAXY_DEV_AFFORDANCES === "true";
// the resolved choice ever becomes invisible; we just kick off
// the redirect on first paint. onMount(async () => {
appScreen.go("games-recruitment"); // Bootstrap the lobby client + fan-out; share the in-flight
// promise the games-* screens would otherwise kick off.
const client = await lobbyData.ensure();
// `account.ensure` is required for the paid-tier predicate.
// Failures (e.g. transient network blip) fall back to the
// non-paid path quietly — `account.current` simply stays null.
if (client !== null) {
try {
await account.ensure(client);
} catch {
/* not fatal — first-visible falls back to recruitment. */
}
}
const navState: LobbyNavState = {
hasMyGames: lobbyData.myGames.length > 0,
hasInvitations: lobbyData.invitations.length > 0,
isPaidOrDev:
DEV_AFFORDANCES || account.current?.entitlement.isPaid === true,
};
// Guard against the user navigating away mid-await (signOut,
// browser Back). If the screen state has changed since mount,
// leave it alone.
if (appScreen.screen === "lobby") {
appScreen.go(firstVisibleGamesScreen(navState));
}
}); });
</script> </script>
+66 -69
View File
@@ -8,8 +8,11 @@ F8-04b extends the sidebar to a two-level hierarchy:
- top-level items: `games` (with submenu), `profile`, and DEV-only - top-level items: `games` (with submenu), `profile`, and DEV-only
`synthetic test reports`; `synthetic test reports`;
- `games` submenu: `active-past` (hidden when the player has no games), - `games` submenu: `active-past` (hidden when the player has no games),
`recruitment` (always), `invitations` (always), `private games` `recruitment` (always), `invitations` (hidden when no pending invites),
(paid-tier only; DEV overrides). `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 Desktop (>640px): the submenu stays expanded as long as the active
screen is one of the games sub-panels. Mobile (≤640px, existing screen is one of the games sub-panels. Mobile (≤640px, existing
@@ -24,16 +27,17 @@ placeholder.
--> -->
<script lang="ts"> <script lang="ts">
import { onMount, type Snippet } from "svelte"; import { onMount, type Snippet } from "svelte";
import { import { appScreen, isLobbySubScreen, type AppScreen } from "$lib/app-nav.svelte";
appScreen,
isLobbySubScreen,
LOBBY_SUB_SCREENS,
type AppScreen,
} from "$lib/app-nav.svelte";
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte"; import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
import { session } from "$lib/session-store.svelte"; import { session } from "$lib/session-store.svelte";
import { account } from "$lib/account-store.svelte"; import { account } from "$lib/account-store.svelte";
import { lobbyData } from "$lib/lobby-data.svelte"; import { lobbyData } from "$lib/lobby-data.svelte";
import {
firstVisibleGamesScreen,
visibleGamesSubScreens,
type GamesSubScreen,
type LobbyNavState,
} from "$lib/lobby-nav";
interface Props { interface Props {
children: Snippet; children: Snippet;
@@ -44,48 +48,22 @@ placeholder.
const DEV_AFFORDANCES = const DEV_AFFORDANCES =
import.meta.env.VITE_GALAXY_DEV_AFFORDANCES === "true"; import.meta.env.VITE_GALAXY_DEV_AFFORDANCES === "true";
type GamesSubId = (typeof LOBBY_SUB_SCREENS)[number]; const SUB_LABELS: Record<GamesSubScreen, TranslationKey> = {
"games-active-past": "lobby.nav.games.active_past",
"games-recruitment": "lobby.nav.games.recruitment",
"games-invitations": "lobby.nav.games.invitations",
"games-private-games": "lobby.nav.games.private_games",
};
interface GamesSubItem { let navState = $derived.by<LobbyNavState>(() => ({
id: GamesSubId; hasMyGames: lobbyData.myGames.length > 0,
labelKey: TranslationKey; hasInvitations: lobbyData.invitations.length > 0,
visible: () => boolean; isPaidOrDev:
} DEV_AFFORDANCES || account.current?.entitlement.isPaid === true,
}));
const GAMES_SUBS: ReadonlyArray<GamesSubItem> = [ let visibleSubs = $derived(visibleGamesSubScreens(navState));
{ let firstVisible = $derived(firstVisibleGamesScreen(navState));
id: "games-active-past",
labelKey: "lobby.nav.games.active_past",
visible: () => lobbyData.myGames.length > 0,
},
{
id: "games-recruitment",
labelKey: "lobby.nav.games.recruitment",
visible: () => true,
},
{
id: "games-invitations",
labelKey: "lobby.nav.games.invitations",
visible: () => true,
},
{
id: "games-private-games",
labelKey: "lobby.nav.games.private_games",
visible: () =>
DEV_AFFORDANCES || account.current?.entitlement.isPaid === true,
},
];
function visibleGamesSubs(): ReadonlyArray<GamesSubItem> {
return GAMES_SUBS.filter((sub) => sub.visible());
}
function firstVisibleGamesScreen(): AppScreen {
const visible = visibleGamesSubs();
// recruitment is unconditionally visible, so visible is never
// empty — but keep the fallback for type safety.
return visible[0]?.id ?? "games-recruitment";
}
function gotoScreen(screen: AppScreen): void { function gotoScreen(screen: AppScreen): void {
if (appScreen.screen !== screen) { if (appScreen.screen !== screen) {
@@ -94,11 +72,20 @@ placeholder.
} }
function gotoGamesParent(): void { function gotoGamesParent(): void {
gotoScreen(firstVisibleGamesScreen()); gotoScreen(firstVisible);
} }
let activeGamesSub = $derived.by<GamesSubItem | null>(() => { let activeGamesSub = $derived.by<GamesSubScreen | null>(() => {
return GAMES_SUBS.find((s) => s.id === appScreen.screen) ?? null; const s = appScreen.screen;
if (
s === "games-active-past" ||
s === "games-recruitment" ||
s === "games-invitations" ||
s === "games-private-games"
) {
return s;
}
return null;
}); });
let gamesActive = $derived.by(() => isLobbySubScreen(appScreen.screen)); let gamesActive = $derived.by(() => isLobbySubScreen(appScreen.screen));
@@ -171,11 +158,11 @@ placeholder.
const screen = appScreen.screen; const screen = appScreen.screen;
if (screen === "games-private-games") { if (screen === "games-private-games") {
if (!DEV_AFFORDANCES && account.current?.entitlement.isPaid !== true) { if (!DEV_AFFORDANCES && account.current?.entitlement.isPaid !== true) {
appScreen.go(firstVisibleGamesScreen()); appScreen.go(firstVisible);
} }
} else if (screen === "synthetic-reports") { } else if (screen === "synthetic-reports") {
if (!DEV_AFFORDANCES) { if (!DEV_AFFORDANCES) {
appScreen.go(firstVisibleGamesScreen()); appScreen.go(firstVisible);
} }
} else if (screen === "games-active-past") { } else if (screen === "games-active-past") {
// Hide-when-empty is asymmetric: we only kick the user out if // Hide-when-empty is asymmetric: we only kick the user out if
@@ -184,7 +171,17 @@ placeholder.
// fresh navigation would bounce off this screen before the // fresh navigation would bounce off this screen before the
// fan-out resolves. // fan-out resolves.
if (!lobbyData.loading && lobbyData.myGames.length === 0) { if (!lobbyData.loading && lobbyData.myGames.length === 0) {
appScreen.go(firstVisibleGamesScreen()); appScreen.go(firstVisible);
}
} else if (screen === "games-invitations") {
// Same asymmetric rule as `games-active-past`: hide-when-empty
// fires only after the fan-out resolves, so a fresh nav onto
// invitations does not bounce while the list is still loading.
// An invite redeemed / declined from this screen drops the
// only pending entry → user is moved to the first visible
// sub-page on the next tick.
if (!lobbyData.loading && lobbyData.invitations.length === 0) {
appScreen.go(firstVisible);
} }
} }
}); });
@@ -225,17 +222,17 @@ placeholder.
class="submenu desktop-only" class="submenu desktop-only"
aria-label={i18n.t("lobby.nav.games.aria_label")} aria-label={i18n.t("lobby.nav.games.aria_label")}
> >
{#each visibleGamesSubs() as sub (sub.id)} {#each visibleSubs as sub (sub)}
<li> <li>
<button <button
type="button" type="button"
class="nav-link sub" class="nav-link sub"
class:active={appScreen.screen === sub.id} class:active={appScreen.screen === sub}
aria-current={appScreen.screen === sub.id ? "page" : undefined} aria-current={appScreen.screen === sub ? "page" : undefined}
onclick={() => gotoScreen(sub.id)} onclick={() => gotoScreen(sub)}
data-testid="lobby-nav-{sub.id}" data-testid="lobby-nav-{sub}"
> >
{i18n.t(sub.labelKey)} {i18n.t(SUB_LABELS[sub])}
</button> </button>
</li> </li>
{/each} {/each}
@@ -258,9 +255,9 @@ placeholder.
data-testid="lobby-nav-games-mobile" data-testid="lobby-nav-games-mobile"
> >
{i18n.t("lobby.nav.games.mobile_toggle", { {i18n.t("lobby.nav.games.mobile_toggle", {
label: activeGamesSub label: i18n.t(
? i18n.t(activeGamesSub.labelKey) SUB_LABELS[activeGamesSub ?? "games-recruitment"],
: i18n.t("lobby.nav.games.recruitment"), ),
})} })}
<span aria-hidden="true"></span> <span aria-hidden="true"></span>
</button> </button>
@@ -270,18 +267,18 @@ placeholder.
role="listbox" role="listbox"
aria-label={i18n.t("lobby.nav.games.aria_label")} aria-label={i18n.t("lobby.nav.games.aria_label")}
> >
{#each visibleGamesSubs() as sub (sub.id)} {#each visibleSubs as sub (sub)}
<li> <li>
<button <button
type="button" type="button"
class="popover-item" class="popover-item"
class:active={appScreen.screen === sub.id} class:active={appScreen.screen === sub}
role="option" role="option"
aria-selected={appScreen.screen === sub.id} aria-selected={appScreen.screen === sub}
onclick={() => selectMobileSub(sub.id)} onclick={() => selectMobileSub(sub)}
data-testid="lobby-nav-{sub.id}-mobile" data-testid="lobby-nav-{sub}-mobile"
> >
{i18n.t(sub.labelKey)} {i18n.t(SUB_LABELS[sub])}
</button> </button>
</li> </li>
{/each} {/each}
+114
View File
@@ -0,0 +1,114 @@
// Pure-state coverage for the lobby `games` submenu visibility rules.
// The same helper drives the sidebar's auto-expanded sub-item and the
// bare-`lobby` resolver's redirect target; if they ever disagree the
// player lands on the wrong sub-page on entry.
import { describe, expect, test } from "vitest";
import {
firstVisibleGamesScreen,
visibleGamesSubScreens,
type LobbyNavState,
} from "../src/lib/lobby-nav";
function state(overrides: Partial<LobbyNavState> = {}): LobbyNavState {
return {
hasMyGames: false,
hasInvitations: false,
isPaidOrDev: false,
...overrides,
};
}
describe("visibleGamesSubScreens", () => {
test("free-tier player with nothing — only recruitment is visible", () => {
expect(visibleGamesSubScreens(state())).toEqual(["games-recruitment"]);
});
test("player with games — active-past leads recruitment", () => {
expect(visibleGamesSubScreens(state({ hasMyGames: true }))).toEqual([
"games-active-past",
"games-recruitment",
]);
});
test("player with pending invites — invitations appears after recruitment", () => {
expect(visibleGamesSubScreens(state({ hasInvitations: true }))).toEqual([
"games-recruitment",
"games-invitations",
]);
});
test("paid / DEV tier — private-games tails the list", () => {
expect(visibleGamesSubScreens(state({ isPaidOrDev: true }))).toEqual([
"games-recruitment",
"games-private-games",
]);
});
test("all four flags on — full canonical order", () => {
expect(
visibleGamesSubScreens(
state({
hasMyGames: true,
hasInvitations: true,
isPaidOrDev: true,
}),
),
).toEqual([
"games-active-past",
"games-recruitment",
"games-invitations",
"games-private-games",
]);
});
test("recruitment is always present regardless of input", () => {
const variants: LobbyNavState[] = [
state(),
state({ hasMyGames: true }),
state({ hasInvitations: true }),
state({ isPaidOrDev: true }),
state({ hasMyGames: true, hasInvitations: true, isPaidOrDev: true }),
];
for (const v of variants) {
expect(visibleGamesSubScreens(v)).toContain("games-recruitment");
}
});
});
describe("firstVisibleGamesScreen", () => {
test("free-tier nothing → recruitment", () => {
expect(firstVisibleGamesScreen(state())).toBe("games-recruitment");
});
test("player with games → active-past wins", () => {
expect(firstVisibleGamesScreen(state({ hasMyGames: true }))).toBe(
"games-active-past",
);
});
test("invitee-only (no games yet) → recruitment still wins over invitations by canonical order", () => {
// Pending invites do NOT promote `invitations` above `recruitment`;
// the canonical order places `recruitment` second only to
// `active-past`. This guards against accidentally re-ordering the
// sub-items in a way that surprises invitee-only sessions.
expect(firstVisibleGamesScreen(state({ hasInvitations: true }))).toBe(
"games-recruitment",
);
});
test("games + invites → active-past still wins", () => {
expect(
firstVisibleGamesScreen(
state({ hasMyGames: true, hasInvitations: true }),
),
).toBe("games-active-past");
});
test("paid tier with no games → recruitment, not private-games", () => {
expect(firstVisibleGamesScreen(state({ isPaidOrDev: true }))).toBe(
"games-recruitment",
);
});
});