fix(ui): F8-04b lobby — auto-expand first available games sub + hide empty invitations #64
+12
-4
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user