fix(ui): F8-04b lobby — auto-expand first available games sub + hide empty invitations
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
let navState = $derived.by<LobbyNavState>(() => ({
|
||||
hasMyGames: lobbyData.myGames.length > 0,
|
||||
hasInvitations: lobbyData.invitations.length > 0,
|
||||
isPaidOrDev:
|
||||
DEV_AFFORDANCES || account.current?.entitlement.isPaid === true,
|
||||
}));
|
||||
|
||||
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: () =>
|
||||
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}
|
||||
|
||||
Reference in New Issue
Block a user