fix(ui): F8-04b lobby — auto-expand first available games sub + hide empty invitations #64

Merged
developer merged 1 commits from feature/lobby-default-expand-and-empty-invitations into development 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). |
| `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
+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;
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 {
+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
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.
-->
<script lang="ts">
import { onMount } from "svelte";
import { appScreen } from "$lib/app-nav.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";
onMount(() => {
// The shell's own $effect keeps the user on a valid sub-page if
// the resolved choice ever becomes invisible; we just kick off
// the redirect on first paint.
appScreen.go("games-recruitment");
const DEV_AFFORDANCES =
import.meta.env.VITE_GALAXY_DEV_AFFORDANCES === "true";
onMount(async () => {
// 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>
+65 -68
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
`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.
-->
<script lang="ts">
import { onMount, type Snippet } from "svelte";
import {
appScreen,
isLobbySubScreen,
LOBBY_SUB_SCREENS,
type AppScreen,
} from "$lib/app-nav.svelte";
import { appScreen, isLobbySubScreen, type AppScreen } from "$lib/app-nav.svelte";
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
import { session } from "$lib/session-store.svelte";
import { account } from "$lib/account-store.svelte";
import { lobbyData } from "$lib/lobby-data.svelte";
import {
firstVisibleGamesScreen,
visibleGamesSubScreens,
type GamesSubScreen,
type LobbyNavState,
} from "$lib/lobby-nav";
interface Props {
children: Snippet;
@@ -44,48 +48,22 @@ placeholder.
const DEV_AFFORDANCES =
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 {
id: GamesSubId;
labelKey: TranslationKey;
visible: () => boolean;
}
const GAMES_SUBS: ReadonlyArray<GamesSubItem> = [
{
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: () =>
let navState = $derived.by<LobbyNavState>(() => ({
hasMyGames: lobbyData.myGames.length > 0,
hasInvitations: lobbyData.invitations.length > 0,
isPaidOrDev:
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";
}
let visibleSubs = $derived(visibleGamesSubScreens(navState));
let firstVisible = $derived(firstVisibleGamesScreen(navState));
function gotoScreen(screen: AppScreen): void {
if (appScreen.screen !== screen) {
@@ -94,11 +72,20 @@ placeholder.
}
function gotoGamesParent(): void {
gotoScreen(firstVisibleGamesScreen());
gotoScreen(firstVisible);
}
let activeGamesSub = $derived.by<GamesSubItem | null>(() => {
return GAMES_SUBS.find((s) => s.id === appScreen.screen) ?? null;
let activeGamesSub = $derived.by<GamesSubScreen | 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));
@@ -171,11 +158,11 @@ placeholder.
const screen = appScreen.screen;
if (screen === "games-private-games") {
if (!DEV_AFFORDANCES && account.current?.entitlement.isPaid !== true) {
appScreen.go(firstVisibleGamesScreen());
appScreen.go(firstVisible);
}
} else if (screen === "synthetic-reports") {
if (!DEV_AFFORDANCES) {
appScreen.go(firstVisibleGamesScreen());
appScreen.go(firstVisible);
}
} else if (screen === "games-active-past") {
// 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
// fan-out resolves.
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"
aria-label={i18n.t("lobby.nav.games.aria_label")}
>
{#each visibleGamesSubs() as sub (sub.id)}
{#each visibleSubs as sub (sub)}
<li>
<button
type="button"
class="nav-link sub"
class:active={appScreen.screen === sub.id}
aria-current={appScreen.screen === sub.id ? "page" : undefined}
onclick={() => gotoScreen(sub.id)}
data-testid="lobby-nav-{sub.id}"
class:active={appScreen.screen === sub}
aria-current={appScreen.screen === sub ? "page" : undefined}
onclick={() => gotoScreen(sub)}
data-testid="lobby-nav-{sub}"
>
{i18n.t(sub.labelKey)}
{i18n.t(SUB_LABELS[sub])}
</button>
</li>
{/each}
@@ -258,9 +255,9 @@ placeholder.
data-testid="lobby-nav-games-mobile"
>
{i18n.t("lobby.nav.games.mobile_toggle", {
label: activeGamesSub
? i18n.t(activeGamesSub.labelKey)
: i18n.t("lobby.nav.games.recruitment"),
label: i18n.t(
SUB_LABELS[activeGamesSub ?? "games-recruitment"],
),
})}
<span aria-hidden="true"></span>
</button>
@@ -270,18 +267,18 @@ placeholder.
role="listbox"
aria-label={i18n.t("lobby.nav.games.aria_label")}
>
{#each visibleGamesSubs() as sub (sub.id)}
{#each visibleSubs as sub (sub)}
<li>
<button
type="button"
class="popover-item"
class:active={appScreen.screen === sub.id}
class:active={appScreen.screen === sub}
role="option"
aria-selected={appScreen.screen === sub.id}
onclick={() => selectMobileSub(sub.id)}
data-testid="lobby-nav-{sub.id}-mobile"
aria-selected={appScreen.screen === sub}
onclick={() => selectMobileSub(sub)}
data-testid="lobby-nav-{sub}-mobile"
>
{i18n.t(sub.labelKey)}
{i18n.t(SUB_LABELS[sub])}
</button>
</li>
{/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",
);
});
});