From 2ecdecad1e9f6548c2fe32cd1473f0ac218f55f3 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Tue, 26 May 2026 13:42:10 +0200 Subject: [PATCH 1/2] feat(ui): lobby site-style sidebar + profile screen (#47) - Wrap lobby and profile in a shared `lobby-shell.svelte` chrome: page-list sidebar (Overview/Profile) and a top "Player-xxxx" identity strip mirroring the project site's monospace look. - Strip the legacy `lobby.title`, device-session-id ``, and `lobby.greeting` paragraph; the identity strip both names the user and opens the profile editor. - Add a top-level `profile` AppScreen with a three-field form (`display_name`, `preferred_language`, `time_zone`) backed by a new `src/api/account.ts` wrapper around `user.account.get`, `user.profile.update`, and `user.settings.update`. Saving switches the active i18n locale in-place when the new preferred language is one the UI ships translations for. - Update e2e fixture + auth-flow / lobby-flow specs to use the new `lobby-account-name` testid and wait for the loaded identity before releasing pending `SubscribeEvents` (webkit revocation race). New `profile-screen.spec.ts` covers navigation, edit-save, and cancel. - Sync `ui/docs/lobby.md` and `ui/docs/navigation.md` to the new layout. Closes #47 --- ui/docs/lobby.md | 63 +++- ui/docs/navigation.md | 18 +- ui/frontend/src/api/account.ts | 132 +++++++ ui/frontend/src/app.d.ts | 2 +- ui/frontend/src/lib/app-nav.svelte.ts | 9 +- ui/frontend/src/lib/i18n/locales/en.ts | 21 +- ui/frontend/src/lib/i18n/locales/ru.ts | 21 +- .../src/lib/screens/lobby-screen.svelte | 132 +++---- .../src/lib/screens/lobby-shell.svelte | 220 ++++++++++++ .../src/lib/screens/profile-screen.svelte | 333 ++++++++++++++++++ ui/frontend/src/routes/+page.svelte | 5 + ui/frontend/tests/e2e/auth-flow.spec.ts | 27 +- ui/frontend/tests/e2e/fixtures/lobby-fbs.ts | 9 +- ui/frontend/tests/e2e/lobby-flow.spec.ts | 2 +- ui/frontend/tests/e2e/profile-screen.spec.ts | 260 ++++++++++++++ 15 files changed, 1122 insertions(+), 132 deletions(-) create mode 100644 ui/frontend/src/api/account.ts create mode 100644 ui/frontend/src/lib/screens/lobby-shell.svelte create mode 100644 ui/frontend/src/lib/screens/profile-screen.svelte create mode 100644 ui/frontend/tests/e2e/profile-screen.spec.ts diff --git a/ui/docs/lobby.md b/ui/docs/lobby.md index 634134c..e5a8ebd 100644 --- a/ui/docs/lobby.md +++ b/ui/docs/lobby.md @@ -2,16 +2,36 @@ The lobby is the first authenticated view; the user lands here after the email-code login completes (see -[`docs/auth-flow.md`](auth-flow.md)). This doc captures the -sections, the application / invite lifecycle the user sees, and -the defaults baked into the create-game form. +[`docs/auth-flow.md`](auth-flow.md)). This doc captures the shared +shell, the Overview sections, the profile sub-screen, and the +defaults baked into the create-game form. -## Sections +## Shell -The lobby renders one column of sections, top to bottom, with the -common content max-width capped at `32rem` (same convention as the -login page). Cards inside each section take the full available -width. +Lobby and profile share a single chrome implemented in +`lib/screens/lobby-shell.svelte`. The chrome mirrors the project +site's VitePress layout: a left page-list sidebar (Overview / +Profile), a top identity strip on the right, and the page content in +the right-hand column. The shell uses `var(--font-mono)` so the +post-login pages adopt the "nerdy" type stack that the public site +already uses. + +The identity strip renders the caller's `display_name` (falling back +to the immutable `user_name` handle, then to a loading placeholder +while `user.account.get` resolves) as a `data-testid="lobby-account-name"` +button. Clicking it switches the top-level screen to `profile` +(`appScreen.go("profile")`); the e2e suites use that testid as their +lobby-loaded signal. The logout button sits next to it +(`session.signOut("user")`). + +The sidebar always renders both pages; clicking the active page is a +no-op. The shell collapses to a horizontal scrolling strip below +640px. + +## Overview sections + +The Overview page renders one column of sections, top to bottom. +Cards inside each section take the full available width. | Section | Empty state | Source | Action | | -------------------- | --------------------- | -------------------------- | --------------------------------------------------------- | @@ -21,9 +41,30 @@ width. | `my applications` | `no applications` | `lobby.my.applications.list` | Status badge (`pending` / `approved` / `rejected`) | | `public games` | `no public games` | `lobby.public.games.list` | Submit application via inline race-name form (`lobby.application.submit`) | -The header preserves the device-session-id `` block (kept as -a debug affordance) plus a greeting if the gateway returns a -`display_name` for the caller. +## Profile sub-screen + +`lib/screens/profile-screen.svelte` is a top-level `AppScreen` (peer of +`lobby` and `lobby-create`). The browser Back stack treats it the +same as the create screen — pushing a fresh history entry on entry, +falling back to lobby on Back/Forward (see +[`navigation.md`](navigation.md)). + +On mount it issues `user.account.get` through `src/api/account.ts` +and renders an identity read-out (immutable `user_name`, `email`) +plus a three-field form: + +| Field | Endpoint | Notes | +| --------------------- | --------------------- | -------------------------------------------------------------- | +| `display_name` | `user.profile.update` | Trimmed; empty value clears the stored name (backend PATCH semantics). | +| `preferred_language` | `user.settings.update`| ` + {i18n.t("profile.hint.display_name")} + + + + + + + {#if saveError !== null} +

{saveError}

+ {/if} + +
+ + +
+ + {/if} + + + diff --git a/ui/frontend/src/routes/+page.svelte b/ui/frontend/src/routes/+page.svelte index 182154d..d7505b5 100644 --- a/ui/frontend/src/routes/+page.svelte +++ b/ui/frontend/src/routes/+page.svelte @@ -19,6 +19,7 @@ import LoginScreen from "$lib/screens/login-screen.svelte"; import LobbyScreen from "$lib/screens/lobby-screen.svelte"; import LobbyCreateScreen from "$lib/screens/lobby-create-screen.svelte"; + import ProfileScreen from "$lib/screens/profile-screen.svelte"; import GameShell from "$lib/game/game-shell.svelte"; import { pushState } from "$app/navigation"; import { page } from "$app/state"; @@ -67,6 +68,8 @@ pushState("", { screen: "game", gameId: appScreen.gameId }); } else if (appScreen.screen === "lobby-create") { pushState("", { screen: "lobby-create" }); + } else if (appScreen.screen === "profile") { + pushState("", { screen: "profile" }); } } }); @@ -83,6 +86,8 @@ {#if session.status === "authenticated"} {#if appScreen.screen === "lobby-create"} + {:else if appScreen.screen === "profile"} + {:else if appScreen.screen === "game" && appScreen.gameId !== null} {:else} diff --git a/ui/frontend/tests/e2e/auth-flow.spec.ts b/ui/frontend/tests/e2e/auth-flow.spec.ts index de3e286..66d9d89 100644 --- a/ui/frontend/tests/e2e/auth-flow.spec.ts +++ b/ui/frontend/tests/e2e/auth-flow.spec.ts @@ -159,9 +159,9 @@ async function completeLogin(page: Page): Promise { await page.getByTestId("login-code-input").click(); await page.getByTestId("login-code-input").fill("123456"); await page.getByTestId("login-code-submit").click(); - // Sign-in switches the in-memory screen to the lobby; the device - // session id surfaces only on the lobby screen. - await expect(page.getByTestId("device-session-id")).toBeVisible(); + // Sign-in switches the in-memory screen to the lobby; the identity + // strip rendered by `lobby-shell.svelte` is the lobby-loaded signal. + await expect(page.getByTestId("lobby-account-name")).toBeVisible(); } test.describe("Phase 7 — auth flow", () => { @@ -174,10 +174,7 @@ test.describe("Phase 7 — auth flow", () => { }) => { const mocks = await mockGatewayHappyPath(page, "Pilot"); await completeLogin(page); - await expect(page.getByTestId("device-session-id")).toHaveText( - "dev-test-1", - ); - await expect(page.getByTestId("account-greeting")).toContainText("Pilot"); + await expect(page.getByTestId("lobby-account-name")).toContainText("Pilot"); mocks.pendingSubscribes.forEach((resolve) => resolve()); }); @@ -187,13 +184,13 @@ test.describe("Phase 7 — auth flow", () => { }) => { const mocks = await mockGatewayHappyPath(page, "Pilot"); await completeLogin(page); - await expect(page.getByTestId("account-greeting")).toBeVisible(); + await expect(page.getByTestId("lobby-account-name")).toBeVisible(); await page.reload(); // The restored session re-renders the lobby screen directly (no // `/lobby` route to land on). - await expect(page.getByTestId("device-session-id")).toHaveText( - "dev-test-1", + await expect(page.getByTestId("lobby-account-name")).toContainText( + "Pilot", ); mocks.pendingSubscribes.forEach((resolve) => resolve()); @@ -204,7 +201,15 @@ test.describe("Phase 7 — auth flow", () => { }) => { const mocks = await mockGatewayHappyPath(page, "Pilot"); await completeLogin(page); - await expect(page.getByTestId("account-greeting")).toBeVisible(); + // `lobby-account-name` becomes visible on lobby mount with the + // "loading account…" placeholder before the gateway responds. + // Wait for the loaded name to settle so the event-stream effect + // has had a chance to issue its `SubscribeEvents` request — the + // release below targets that pending stream, and an empty + // `pendingSubscribes` list defeats the test. + await expect(page.getByTestId("lobby-account-name")).toContainText( + "Pilot", + ); // Fire all pending SubscribeEvents requests with an empty 200 // response. Connect-Web's server-streaming reader sees no frames diff --git a/ui/frontend/tests/e2e/fixtures/lobby-fbs.ts b/ui/frontend/tests/e2e/fixtures/lobby-fbs.ts index 23a0580..9488ede 100644 --- a/ui/frontend/tests/e2e/fixtures/lobby-fbs.ts +++ b/ui/frontend/tests/e2e/fixtures/lobby-fbs.ts @@ -215,6 +215,9 @@ export interface AccountFixture { email: string; userName: string; displayName: string; + preferredLanguage?: string; + timeZone?: string; + declaredCountry?: string; } export function buildAccountResponsePayload(account: AccountFixture): Uint8Array { @@ -237,9 +240,9 @@ export function buildAccountResponsePayload(account: AccountFixture): Uint8Array const email = builder.createString(account.email); const userName = builder.createString(account.userName); const displayName = builder.createString(account.displayName); - const preferredLanguage = builder.createString("en"); - const timeZone = builder.createString("UTC"); - const declaredCountry = builder.createString(""); + const preferredLanguage = builder.createString(account.preferredLanguage ?? "en"); + const timeZone = builder.createString(account.timeZone ?? "UTC"); + const declaredCountry = builder.createString(account.declaredCountry ?? ""); AccountView.startAccountView(builder); AccountView.addUserId(builder, userId); AccountView.addEmail(builder, email); diff --git a/ui/frontend/tests/e2e/lobby-flow.spec.ts b/ui/frontend/tests/e2e/lobby-flow.spec.ts index 958e3e7..7d14cf5 100644 --- a/ui/frontend/tests/e2e/lobby-flow.spec.ts +++ b/ui/frontend/tests/e2e/lobby-flow.spec.ts @@ -251,7 +251,7 @@ async function completeLogin(page: Page): Promise { await page.getByTestId("login-code-input").fill("123456"); await page.getByTestId("login-code-submit").click(); // Sign-in switches the in-memory screen to the lobby. - await expect(page.getByTestId("device-session-id")).toBeVisible(); + await expect(page.getByTestId("lobby-account-name")).toBeVisible(); } test.describe("Phase 8 — lobby flow", () => { diff --git a/ui/frontend/tests/e2e/profile-screen.spec.ts b/ui/frontend/tests/e2e/profile-screen.spec.ts new file mode 100644 index 0000000..dc7640c --- /dev/null +++ b/ui/frontend/tests/e2e/profile-screen.spec.ts @@ -0,0 +1,260 @@ +// F8-04 profile screen — end-to-end coverage. Mocks the gateway so the +// lobby boots with an account aggregate, then exercises the sidebar +// navigation into the profile, the edit form, and the save round-trip +// against the FlatBuffers-decoded `user.profile.update` / +// `user.settings.update` payloads. + +import { fromJson, type JsonValue } from "@bufbuild/protobuf"; +import { ByteBuffer } from "flatbuffers"; +import { expect, test, type Page } from "@playwright/test"; +import { ExecuteCommandRequestSchema } from "../../src/proto/edge/v1/edge_gateway_pb"; +import { + UpdateMyProfileRequest, + UpdateMySettingsRequest, +} from "../../src/proto/galaxy/fbs/user"; +import { forgeExecuteCommandResponseJson } from "./fixtures/sign-response"; +import { + buildAccountResponsePayload, + buildMyApplicationsListPayload, + buildMyGamesListPayload, + buildMyInvitesListPayload, + buildPublicGamesListPayload, + type AccountFixture, +} from "./fixtures/lobby-fbs"; + +interface ProfileMocks { + pendingSubscribes: Array<() => void>; + account: AccountFixture; + profileUpdates: Array<{ displayName: string }>; + settingsUpdates: Array<{ preferredLanguage: string; timeZone: string }>; +} + +async function mockGateway( + page: Page, + initial: AccountFixture, +): Promise { + const mocks: ProfileMocks = { + pendingSubscribes: [], + account: { ...initial }, + profileUpdates: [], + settingsUpdates: [], + }; + + await page.route("**/api/v1/public/auth/send-email-code", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ challenge_id: "ch-test-1" }), + }); + }); + await page.route("**/api/v1/public/auth/confirm-email-code", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ device_session_id: "dev-test-1" }), + }); + }); + + await page.route("**/edge.v1.Gateway/ExecuteCommand", async (route) => { + const reqText = route.request().postData(); + if (reqText === null) { + await route.fulfill({ status: 400 }); + return; + } + const req = fromJson( + ExecuteCommandRequestSchema, + JSON.parse(reqText) as JsonValue, + ); + let payload: Uint8Array; + switch (req.messageType) { + case "user.account.get": + payload = buildAccountResponsePayload(mocks.account); + break; + case "user.profile.update": { + const decoded = UpdateMyProfileRequest.getRootAsUpdateMyProfileRequest( + new ByteBuffer(req.payloadBytes), + ); + const next = decoded.displayName() ?? ""; + mocks.profileUpdates.push({ displayName: next }); + mocks.account = { ...mocks.account, displayName: next }; + payload = buildAccountResponsePayload(mocks.account); + break; + } + case "user.settings.update": { + const decoded = UpdateMySettingsRequest.getRootAsUpdateMySettingsRequest( + new ByteBuffer(req.payloadBytes), + ); + const preferredLanguage = decoded.preferredLanguage() ?? ""; + const timeZone = decoded.timeZone() ?? ""; + mocks.settingsUpdates.push({ preferredLanguage, timeZone }); + mocks.account = { ...mocks.account, preferredLanguage, timeZone }; + payload = buildAccountResponsePayload(mocks.account); + break; + } + case "lobby.my.games.list": + payload = buildMyGamesListPayload([]); + break; + case "lobby.public.games.list": + payload = buildPublicGamesListPayload([]); + break; + case "lobby.my.invites.list": + payload = buildMyInvitesListPayload([]); + break; + case "lobby.my.applications.list": + payload = buildMyApplicationsListPayload([]); + break; + default: + payload = new Uint8Array(); + } + const responseJson = await forgeExecuteCommandResponseJson({ + requestId: req.requestId, + timestampMs: BigInt(Date.now()), + resultCode: "ok", + payloadBytes: payload, + }); + await route.fulfill({ + status: 200, + contentType: "application/json", + body: responseJson, + }); + }); + + await page.route("**/edge.v1.Gateway/SubscribeEvents", async (route) => { + const action = await new Promise<"endOfStream" | "abort">((resolve) => { + mocks.pendingSubscribes.push(() => resolve("endOfStream")); + }); + if (action === "abort") { + await route.abort(); + return; + } + const body = new TextEncoder().encode("{}"); + const frame = new Uint8Array(5 + body.length); + frame[0] = 0x02; + new DataView(frame.buffer).setUint32(1, body.length, false); + frame.set(body, 5); + await route.fulfill({ + status: 200, + contentType: "application/connect+json", + body: Buffer.from(frame), + }); + }); + + return mocks; +} + +async function completeLogin(page: Page): Promise { + await page.goto("/"); + await expect(page.getByTestId("login-email-input")).toBeVisible(); + await page.getByTestId("login-email-input").click(); + await page.getByTestId("login-email-input").fill("pilot@example.com"); + await page.getByTestId("login-email-submit").click(); + await expect(page.getByTestId("login-code-input")).toBeVisible(); + await page.getByTestId("login-code-input").click(); + await page.getByTestId("login-code-input").fill("123456"); + await page.getByTestId("login-code-submit").click(); + await expect(page.getByTestId("lobby-account-name")).toBeVisible(); +} + +test.describe("F8-04 — profile screen", () => { + test("clicking the identity strip opens the profile and renders the form", async ({ + page, + }) => { + const mocks = await mockGateway(page, { + userId: "user-1", + email: "pilot@example.com", + userName: "player-abc12345", + displayName: "Pilot", + }); + await completeLogin(page); + await expect(page.getByTestId("lobby-account-name")).toContainText("Pilot"); + + await page.getByTestId("lobby-account-name").click(); + await expect(page.getByTestId("profile-form")).toBeVisible(); + await expect(page.getByTestId("profile-display-name")).toHaveValue("Pilot"); + await expect(page.getByTestId("profile-identity")).toContainText( + "player-abc12345", + ); + await expect(page.getByTestId("profile-identity")).toContainText( + "pilot@example.com", + ); + + mocks.pendingSubscribes.forEach((resolve) => resolve()); + }); + + test("saving an edited display name posts user.profile.update and returns to lobby", async ({ + page, + }) => { + const mocks = await mockGateway(page, { + userId: "user-1", + email: "pilot@example.com", + userName: "player-abc12345", + displayName: "Pilot", + }); + await completeLogin(page); + await page.getByTestId("lobby-account-name").click(); + await expect(page.getByTestId("profile-form")).toBeVisible(); + + await page.getByTestId("profile-display-name").fill("Captain"); + await page.getByTestId("profile-save").click(); + + await expect(page.getByTestId("lobby-my-games-empty")).toBeVisible(); + await expect(page.getByTestId("lobby-account-name")).toContainText( + "Captain", + ); + expect(mocks.profileUpdates).toEqual([{ displayName: "Captain" }]); + expect(mocks.settingsUpdates).toEqual([]); + + mocks.pendingSubscribes.forEach((resolve) => resolve()); + }); + + test("changing the language posts user.settings.update and switches the active locale", async ({ + page, + }) => { + const mocks = await mockGateway(page, { + userId: "user-1", + email: "pilot@example.com", + userName: "player-abc12345", + displayName: "Pilot", + }); + await completeLogin(page); + await page.getByTestId("lobby-account-name").click(); + + await page.getByTestId("profile-preferred-language").selectOption("ru"); + await page.getByTestId("profile-save").click(); + + await expect(page.getByTestId("lobby-account-name")).toBeVisible(); + // The lobby switches to the Russian dictionary after the save — + // the "create new game" button label is the visible signal. + await expect(page.getByTestId("lobby-create-button")).toHaveText( + "создать новую игру", + ); + expect(mocks.settingsUpdates).toEqual([ + { preferredLanguage: "ru", timeZone: "UTC" }, + ]); + + mocks.pendingSubscribes.forEach((resolve) => resolve()); + }); + + test("cancel returns to the lobby without posting anything", async ({ + page, + }) => { + const mocks = await mockGateway(page, { + userId: "user-1", + email: "pilot@example.com", + userName: "player-abc12345", + displayName: "Pilot", + }); + await completeLogin(page); + await page.getByTestId("lobby-account-name").click(); + await expect(page.getByTestId("profile-form")).toBeVisible(); + + await page.getByTestId("profile-display-name").fill("ignored"); + await page.getByTestId("profile-cancel").click(); + + await expect(page.getByTestId("lobby-my-games-empty")).toBeVisible(); + expect(mocks.profileUpdates).toEqual([]); + expect(mocks.settingsUpdates).toEqual([]); + + mocks.pendingSubscribes.forEach((resolve) => resolve()); + }); +}); -- 2.52.0 From a679d9cdcb09a3eb8e9746367a4b507c9ccee894 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Tue, 26 May 2026 22:38:14 +0200 Subject: [PATCH 2/2] =?UTF-8?q?fix(ui):=20F8-04=20profile=20polish=20?= =?UTF-8?q?=E2=80=94=20IANA=20timezone=20picker,=20save-stay,=20shared=20i?= =?UTF-8?q?dentity=20cache?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR-feedback round on #60: - Time-zone field is now a continent-grouped ` over `SUPPORTED_LOCALES`; if the stored value is unsupported the option is preserved verbatim so a round-trip save does not silently switch it. | -| `time_zone` | `user.settings.update`| Free-text IANA name. Placeholder shows the browser's current zone; backend validates with `time.LoadLocation`. | +| `time_zone` | `user.settings.update`| ` @@ -193,6 +214,7 @@ {i18n.t("profile.field.preferred_language")} + {#if timeZoneFallbackToText} + + + {:else} + + {/if} {i18n.t("profile.hint.time_zone")} {#if saveError !== null}

{saveError}

+ {:else if savedNotice} +

+ {i18n.t("profile.saved")} +

{/if}
@@ -303,6 +351,12 @@ font-size: var(--text-xs); } + [data-testid="profile-saved-notice"] { + color: var(--color-text-muted); + font-size: var(--text-sm); + margin: 0; + } + .actions { display: flex; gap: var(--space-3); diff --git a/ui/frontend/src/lib/session-store.svelte.ts b/ui/frontend/src/lib/session-store.svelte.ts index ba4ff1c..f75572c 100644 --- a/ui/frontend/src/lib/session-store.svelte.ts +++ b/ui/frontend/src/lib/session-store.svelte.ts @@ -31,6 +31,7 @@ import { loadDeviceSession, setDeviceSessionId, } from "../api/session"; +import { account } from "./account-store.svelte"; export type SessionStatus = | "loading" @@ -94,6 +95,10 @@ export class SessionStore { this.keypair = fresh.keypair; this.deviceSessionId = null; this.status = "anonymous"; + // Drop the cached identity so a different user signing in on the + // same browser does not briefly see the previous display name + // through the post-login shell. + account.clear(); if (reason === "revoked") { console.info("session store: device session revoked by gateway"); } diff --git a/ui/frontend/src/lib/time-zones.ts b/ui/frontend/src/lib/time-zones.ts new file mode 100644 index 0000000..a6dd3b3 --- /dev/null +++ b/ui/frontend/src/lib/time-zones.ts @@ -0,0 +1,140 @@ +// Time-zone option helpers for the Profile screen's `` can render it without losing the saved + * zone. The original groups are not mutated. + */ +export function withPreservedValue( + groups: readonly TimeZoneGroup[], + value: string, +): readonly TimeZoneGroup[] { + const trimmed = value.trim(); + if (trimmed === "") return groups; + for (const group of groups) { + if (group.values.includes(trimmed)) return groups; + } + const extra: TimeZoneGroup = { label: OTHER_GROUP, values: [trimmed] }; + // Merge with an existing "Other" group if one is already present, + // otherwise append a fresh one. + const next: TimeZoneGroup[] = []; + let mergedIntoOther = false; + for (const group of groups) { + if (group.label === OTHER_GROUP) { + mergedIntoOther = true; + next.push({ + label: OTHER_GROUP, + values: [...group.values, trimmed].sort((a, b) => a.localeCompare(b)), + }); + } else { + next.push(group); + } + } + if (!mergedIntoOther) next.push(extra); + return next; +} + +/** + * browserTimeZone returns the time zone the runtime believes the + * user is in. An empty string is returned when `Intl.DateTimeFormat` + * is missing or rejects the resolution. + */ +export function browserTimeZone(): string { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone ?? ""; + } catch { + return ""; + } +} + +interface IntlWithSupportedValues { + supportedValuesOf?: (key: "timeZone") => string[]; +} + +function listSupportedZones(): string[] { + const intl = Intl as unknown as IntlWithSupportedValues; + if (typeof intl.supportedValuesOf !== "function") return []; + try { + const zones = intl.supportedValuesOf("timeZone"); + return Array.isArray(zones) ? zones.slice() : []; + } catch { + return []; + } +} + +function groupZones(zones: readonly string[]): readonly TimeZoneGroup[] { + const buckets = new Map(); + const others: string[] = []; + for (const zone of zones) { + const slash = zone.indexOf("/"); + if (slash === -1) { + others.push(zone); + continue; + } + const prefix = zone.slice(0, slash); + const bucket = buckets.get(prefix); + if (bucket === undefined) { + buckets.set(prefix, [zone]); + } else { + bucket.push(zone); + } + } + const groups: TimeZoneGroup[] = []; + const sortedPrefixes = Array.from(buckets.keys()).sort((a, b) => + a.localeCompare(b), + ); + for (const prefix of sortedPrefixes) { + const values = (buckets.get(prefix) ?? []).slice().sort((a, b) => + a.localeCompare(b), + ); + groups.push({ label: prefix, values }); + } + if (others.length > 0) { + groups.push({ + label: OTHER_GROUP, + values: others.slice().sort((a, b) => a.localeCompare(b)), + }); + } + return groups; +} diff --git a/ui/frontend/tests/e2e/profile-screen.spec.ts b/ui/frontend/tests/e2e/profile-screen.spec.ts index dc7640c..a8ff3a8 100644 --- a/ui/frontend/tests/e2e/profile-screen.spec.ts +++ b/ui/frontend/tests/e2e/profile-screen.spec.ts @@ -1,8 +1,7 @@ // F8-04 profile screen — end-to-end coverage. Mocks the gateway so the // lobby boots with an account aggregate, then exercises the sidebar -// navigation into the profile, the edit form, and the save round-trip -// against the FlatBuffers-decoded `user.profile.update` / -// `user.settings.update` payloads. +// navigation into the profile, the edit form, the save-stay flow, and +// the time-zone dropdown. import { fromJson, type JsonValue } from "@bufbuild/protobuf"; import { ByteBuffer } from "flatbuffers"; @@ -25,6 +24,7 @@ import { interface ProfileMocks { pendingSubscribes: Array<() => void>; account: AccountFixture; + accountGetCount: number; profileUpdates: Array<{ displayName: string }>; settingsUpdates: Array<{ preferredLanguage: string; timeZone: string }>; } @@ -36,6 +36,7 @@ async function mockGateway( const mocks: ProfileMocks = { pendingSubscribes: [], account: { ...initial }, + accountGetCount: 0, profileUpdates: [], settingsUpdates: [], }; @@ -68,6 +69,7 @@ async function mockGateway( let payload: Uint8Array; switch (req.messageType) { case "user.account.get": + mocks.accountGetCount += 1; payload = buildAccountResponsePayload(mocks.account); break; case "user.profile.update": { @@ -181,7 +183,7 @@ test.describe("F8-04 — profile screen", () => { mocks.pendingSubscribes.forEach((resolve) => resolve()); }); - test("saving an edited display name posts user.profile.update and returns to lobby", async ({ + test("saving an edited display name posts user.profile.update, stays on the form, and refreshes the identity strip", async ({ page, }) => { const mocks = await mockGateway(page, { @@ -197,17 +199,26 @@ test.describe("F8-04 — profile screen", () => { await page.getByTestId("profile-display-name").fill("Captain"); await page.getByTestId("profile-save").click(); - await expect(page.getByTestId("lobby-my-games-empty")).toBeVisible(); + // Form stays on screen; the saved notice surfaces and the + // shell-level identity strip picks up the new name without a + // second `user.account.get`. + await expect(page.getByTestId("profile-saved-notice")).toBeVisible(); + await expect(page.getByTestId("profile-form")).toBeVisible(); await expect(page.getByTestId("lobby-account-name")).toContainText( "Captain", ); expect(mocks.profileUpdates).toEqual([{ displayName: "Captain" }]); expect(mocks.settingsUpdates).toEqual([]); + // Editing the form again clears the notice so a follow-up save is + // unambiguous. + await page.getByTestId("profile-display-name").fill("Pilot"); + await expect(page.getByTestId("profile-saved-notice")).toHaveCount(0); + mocks.pendingSubscribes.forEach((resolve) => resolve()); }); - test("changing the language posts user.settings.update and switches the active locale", async ({ + test("changing the language posts user.settings.update, stays on the form, and switches the active locale", async ({ page, }) => { const mocks = await mockGateway(page, { @@ -222,15 +233,13 @@ test.describe("F8-04 — profile screen", () => { await page.getByTestId("profile-preferred-language").selectOption("ru"); await page.getByTestId("profile-save").click(); - await expect(page.getByTestId("lobby-account-name")).toBeVisible(); - // The lobby switches to the Russian dictionary after the save — - // the "create new game" button label is the visible signal. - await expect(page.getByTestId("lobby-create-button")).toHaveText( - "создать новую игру", - ); - expect(mocks.settingsUpdates).toEqual([ - { preferredLanguage: "ru", timeZone: "UTC" }, - ]); + // Profile stays on screen; the Russian dictionary now drives the + // form copy. The save button label is the visible signal. + await expect(page.getByTestId("profile-form")).toBeVisible(); + await expect(page.getByTestId("profile-save")).toHaveText("сохранить"); + await expect(page.getByTestId("profile-saved-notice")).toBeVisible(); + expect(mocks.settingsUpdates).toHaveLength(1); + expect(mocks.settingsUpdates[0]?.preferredLanguage).toBe("ru"); mocks.pendingSubscribes.forEach((resolve) => resolve()); }); @@ -257,4 +266,74 @@ test.describe("F8-04 — profile screen", () => { mocks.pendingSubscribes.forEach((resolve) => resolve()); }); + + test("time zone is a continent-grouped with at least the Europe and + // America optgroups present and the stored zone selected. + expect(await select.evaluate((el) => el.tagName)).toBe("SELECT"); + const optgroupLabels = await select.evaluate((el) => + Array.from((el as HTMLSelectElement).querySelectorAll("optgroup")).map( + (g) => g.label, + ), + ); + expect(optgroupLabels).toContain("Europe"); + expect(optgroupLabels).toContain("America"); + await expect(select).toHaveValue("Europe/London"); + + await select.selectOption("America/New_York"); + await page.getByTestId("profile-save").click(); + + await expect(page.getByTestId("profile-saved-notice")).toBeVisible(); + expect(mocks.settingsUpdates).toEqual([ + { preferredLanguage: "en", timeZone: "America/New_York" }, + ]); + + mocks.pendingSubscribes.forEach((resolve) => resolve()); + }); + + test("the identity strip persists across Overview ⇄ Profile without a second user.account.get", async ({ + page, + }) => { + const mocks = await mockGateway(page, { + userId: "user-1", + email: "pilot@example.com", + userName: "player-abc12345", + displayName: "Pilot", + }); + await completeLogin(page); + await expect(page.getByTestId("lobby-account-name")).toContainText("Pilot"); + const firstCount = mocks.accountGetCount; + expect(firstCount).toBeGreaterThanOrEqual(1); + + // Navigate Overview → Profile: identity must NOT flash the + // loading placeholder, and the cache must answer without a + // second gateway call. + await page.getByTestId("lobby-nav-profile").click(); + await expect(page.getByTestId("profile-form")).toBeVisible(); + await expect(page.getByTestId("lobby-account-name")).toContainText("Pilot"); + + // Navigate back to Overview. + await page.getByTestId("lobby-nav-overview").click(); + await expect(page.getByTestId("lobby-my-games-empty")).toBeVisible(); + await expect(page.getByTestId("lobby-account-name")).toContainText("Pilot"); + + expect(mocks.accountGetCount).toBe(firstCount); + + mocks.pendingSubscribes.forEach((resolve) => resolve()); + }); }); -- 2.52.0