diff --git a/ui/docs/lobby.md b/ui/docs/lobby.md index e5a8ebd..f785ce8 100644 --- a/ui/docs/lobby.md +++ b/ui/docs/lobby.md @@ -16,11 +16,23 @@ 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 +The identity strip reads the caller's account from +`lib/account-store.svelte.ts` — a session-wide cache that fetches +`user.account.get` once on first access and is written through after +every Profile save. Both `lobby-screen.svelte` and +`profile-screen.svelte` populate the same cache through +`account.ensure(client)`, so switching Overview ⇄ Profile never +re-issues `user.account.get` and the strip never flashes the +`lobby.account_loading` placeholder mid-navigation. The cache is +cleared by `session.signOut("user")` / `signOut("revoked")` so a +different user signing in on the same browser does not briefly see +the previous identity. + +The strip falls back to `display_name` → immutable `user_name` → +`lobby.account_loading` while the first `ensure(...)` resolves. It +renders 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")`). @@ -49,22 +61,29 @@ 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: +On mount it reads the caller's account through `account.ensure(...)` +(see [Shell](#shell)) — the first visit issues `user.account.get`, +subsequent visits resolve from the session-wide cache without a +gateway round-trip. The form renders an identity read-out (immutable +`user_name`, `email`) plus three editable fields: | Field | Endpoint | Notes | | --------------------- | --------------------- | -------------------------------------------------------------- | | `display_name` | `user.profile.update` | Trimmed; empty value clears the stored name (backend PATCH semantics). | | `preferred_language` | `user.settings.update`| `` of every IANA zone the browser knows (`Intl.supportedValuesOf("timeZone")`), grouped by leading slash segment (Africa / America / …; singletons like `UTC` collapse into a trailing "Other" optgroup). When the form opens with no stored zone, the picker is pre-selected to `Intl.DateTimeFormat().resolvedOptions().timeZone`. A stored value the runtime no longer advertises is added as an extra "Other" entry so the round-trip never silently drops it. Browsers that lack `supportedValuesOf` fall back to a free-text input; the backend validates with `time.LoadLocation` in every shape. | Save fires `user.profile.update` and/or `user.settings.update` -conditionally on which fields actually changed, then returns to the -lobby (`appScreen.go("lobby")`). When the saved -`preferred_language` is one the UI also ships translations for, the -active i18n locale switches in-place so the rest of the session -matches the new preference. +conditionally on which fields actually changed, then **stays on the +profile** and surfaces a transient `profile-saved-notice` line +(`data-testid="profile-saved-notice"`). Editing any field clears the +notice. Only the explicit `cancel` button navigates back to the lobby +(`appScreen.go("lobby")`). When the saved `preferred_language` is one +the UI also ships translations for, the active i18n locale switches +in-place so the rest of the session matches the new preference. The +write-through is also pushed into the shared `account` store so the +shell identity strip picks up the new `display_name` without a second +`user.account.get`. `GameSummary` carries a `current_turn` field that the lobby UI does not display directly — the in-game shell reads it from the same diff --git a/ui/frontend/src/lib/account-store.svelte.ts b/ui/frontend/src/lib/account-store.svelte.ts new file mode 100644 index 0000000..d56493a --- /dev/null +++ b/ui/frontend/src/lib/account-store.svelte.ts @@ -0,0 +1,76 @@ +// `AccountStore` is the session-wide cache for the caller's +// `user.account.get` aggregate. The lobby shell and every post-login +// screen read the identity (display name, immutable user_name, time +// zone, …) from the same rune, so navigating between Overview and +// Profile does not refetch and does not flash the +// `lobby.account_loading` placeholder. +// +// `ensure(client)` fetches once on first call, dedupes concurrent +// callers onto a single in-flight promise, and resolves immediately +// from the cache thereafter. `set(account)` is the write-through +// path used by Profile after `user.profile.update` / +// `user.settings.update` succeeds — both the shell and the screen +// pick up the change without an extra round-trip. `clear()` resets +// the cache on logout so a different user signing in on the same +// browser does not briefly see the previous identity. +// +// The store is intentionally narrow: it caches one struct, never +// retries on failure (the caller decides), and exposes no error +// state of its own. Callers that need a tighter error surface (the +// Profile form) catch the rejection from `ensure(client)` directly. + +import type { GalaxyClient } from "../api/galaxy-client"; +import { getMyAccount, type Account } from "../api/account"; + +class AccountStore { + current: Account | null = $state(null); + #inFlight: Promise | null = null; + + /** + * ensure returns the cached `Account` when present, otherwise issues + * `user.account.get` through the supplied client and caches the + * result. Concurrent callers during the first fetch share the same + * in-flight promise so the gateway only sees one request per + * session. + */ + ensure(client: GalaxyClient): Promise { + if (this.current !== null) { + return Promise.resolve(this.current); + } + if (this.#inFlight !== null) { + return this.#inFlight; + } + const pending = getMyAccount(client) + .then((account) => { + this.current = account; + return account; + }) + .finally(() => { + this.#inFlight = null; + }); + this.#inFlight = pending; + return pending; + } + + /** + * set replaces the cached `Account` with the supplied value. Used + * by the Profile screen after a successful save so both the form + * and the shell identity strip pick up the new fields without a + * second round-trip. + */ + set(next: Account): void { + this.current = next; + } + + /** + * clear resets the cache. Called on logout so a different user + * signing in on the same browser does not briefly see the + * previous identity through the rune. + */ + clear(): void { + this.current = null; + this.#inFlight = null; + } +} + +export const account = new AccountStore(); diff --git a/ui/frontend/src/lib/i18n/locales/en.ts b/ui/frontend/src/lib/i18n/locales/en.ts index df384f7..e100a50 100644 --- a/ui/frontend/src/lib/i18n/locales/en.ts +++ b/ui/frontend/src/lib/i18n/locales/en.ts @@ -111,9 +111,10 @@ const en = { "profile.field.preferred_language": "preferred language", "profile.field.time_zone": "time zone", "profile.hint.display_name": "shown wherever Galaxy needs a friendlier name than the username handle. Leave empty to fall back to the username.", - "profile.hint.time_zone": "IANA time-zone name (e.g. Europe/Moscow, America/New_York). The placeholder shows your browser's current zone.", + "profile.hint.time_zone": "IANA zones grouped by continent. The form opens on your browser's current zone when no value is saved.", "profile.save": "save", "profile.saving": "saving…", + "profile.saved": "saved", "profile.cancel": "cancel", "profile.error.language_required": "language must not be empty", "profile.error.time_zone_required": "time zone must not be empty", diff --git a/ui/frontend/src/lib/i18n/locales/ru.ts b/ui/frontend/src/lib/i18n/locales/ru.ts index d83c2a4..a472fa8 100644 --- a/ui/frontend/src/lib/i18n/locales/ru.ts +++ b/ui/frontend/src/lib/i18n/locales/ru.ts @@ -112,9 +112,10 @@ const ru: Record = { "profile.field.preferred_language": "язык интерфейса", "profile.field.time_zone": "часовой пояс", "profile.hint.display_name": "показывается там, где нужно более «человеческое» имя, чем системный идентификатор. Пустое значение — вернётся к идентификатору.", - "profile.hint.time_zone": "имя часового пояса IANA (например, Europe/Moscow, America/New_York). В подсказке — текущий пояс твоего браузера.", + "profile.hint.time_zone": "пояса IANA, сгруппированные по континентам. Если сохранённого значения нет, форма открывается на поясе, который определил браузер.", "profile.save": "сохранить", "profile.saving": "сохраняем…", + "profile.saved": "сохранено", "profile.cancel": "отмена", "profile.error.language_required": "язык не должен быть пустым", "profile.error.time_zone_required": "часовой пояс не должен быть пустым", diff --git a/ui/frontend/src/lib/screens/lobby-screen.svelte b/ui/frontend/src/lib/screens/lobby-screen.svelte index 0aef599..7c8a0f8 100644 --- a/ui/frontend/src/lib/screens/lobby-screen.svelte +++ b/ui/frontend/src/lib/screens/lobby-screen.svelte @@ -17,7 +17,7 @@ type GameSummary, type InviteSummary, } from "../../api/lobby"; - import { AccountError, getMyAccount } from "../../api/account"; + import { account } from "$lib/account-store.svelte"; import { gatewayRpcBaseUrl, GATEWAY_RESPONSE_PUBLIC_KEY } from "$lib/env"; import { SyntheticReportError, @@ -28,8 +28,6 @@ import { session } from "$lib/session-store.svelte"; import LobbyShell from "./lobby-shell.svelte"; - let displayName = $state(""); - let userName = $state(""); let configError: string | null = $state(null); let listsLoading = $state(true); let lobbyError: string | null = $state(null); @@ -158,21 +156,6 @@ } } - async function loadIdentity(c: GalaxyClient): Promise { - try { - const account = await getMyAccount(c); - displayName = account.displayName; - userName = account.userName; - } catch (err) { - if (err instanceof AccountError) { - // Stay quiet: the lobby still works without a name; the - // identity strip falls back to a loading placeholder. - return; - } - throw err; - } - } - function gotoCreate(): void { appScreen.go("lobby-create"); } @@ -250,7 +233,11 @@ deviceSessionId: session.deviceSessionId, gatewayResponsePublicKey: GATEWAY_RESPONSE_PUBLIC_KEY, }); - loadIdentity(client).catch(() => {}); + // Populate the session-wide identity cache; the shell's + // identity strip reads from there. Swallowed errors leave + // the shell on the `lobby.account_loading` placeholder + // without breaking the rest of the lobby. + account.ensure(client).catch(() => {}); await refreshAll(); } catch (err) { lobbyError = describeLobbyError(err); @@ -259,7 +246,7 @@ }); - + {#if configError !== null}

{configError}

{:else if lobbyError !== null} diff --git a/ui/frontend/src/lib/screens/lobby-shell.svelte b/ui/frontend/src/lib/screens/lobby-shell.svelte index c116568..4fa07cd 100644 --- a/ui/frontend/src/lib/screens/lobby-shell.svelte +++ b/ui/frontend/src/lib/screens/lobby-shell.svelte @@ -4,23 +4,27 @@ landing and the editable profile. Renders a left page-list sidebar (mirroring the project site's VitePress layout) plus a top identity strip ("Player-xxxx" → opens profile, logout). Children fill the right-hand column. Pages mark themselves active via `activePage`. + +The identity strip reads directly from the session-wide `account` +store so navigating Overview ⇄ Profile never re-renders an empty +placeholder: both screens populate the same cache through +`account.ensure(client)` and the shell renders the latest value. --> - +

{i18n.t("profile.title")}

{#if configError !== null}

{configError}

@@ -183,6 +203,7 @@ @@ -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()); + }); });