fix(ui): F8-04 profile polish — IANA timezone picker, save-stay, shared identity cache
PR-feedback round on #60: - Time-zone field is now a continent-grouped <select> populated from `Intl.supportedValuesOf("timeZone")`, with the browser-detected zone pre-selected when no value is stored. A stored zone the runtime no longer advertises is preserved as an "Other" entry. - Saving the profile no longer kicks the user back to the lobby: the form stays put and shows a transient `saved` notice, cleared on the next edit. Only `cancel` returns to the lobby. - New `lib/account-store.svelte.ts` caches `user.account.get` for the session; lobby + profile share it through `account.ensure()`, so navigating Overview ⇄ Profile no longer flashes the "loading account…" placeholder or fires a second gateway call. Profile save writes through to the store so the shell identity strip picks up the new display name without refetching. Cleared on logout to prevent identity bleed between accounts. - e2e: existing 4 cases adjusted for save-stay; added two new ones for the timezone dropdown and identity-strip stability across navigation. - Docs: `ui/docs/lobby.md` updated to describe the shared cache, the new timezone picker shape, and the save-stay behaviour.
This commit is contained in:
@@ -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<Account> | 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<Account> {
|
||||
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();
|
||||
@@ -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",
|
||||
|
||||
@@ -112,9 +112,10 @@ const ru: Record<keyof typeof en, string> = {
|
||||
"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": "часовой пояс не должен быть пустым",
|
||||
|
||||
@@ -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<void> {
|
||||
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 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<LobbyShell activePage="overview" {displayName} {userName}>
|
||||
<LobbyShell activePage="overview">
|
||||
{#if configError !== null}
|
||||
<p role="alert" data-testid="account-error">{configError}</p>
|
||||
{:else if lobbyError !== null}
|
||||
|
||||
@@ -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.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
import { appScreen } from "$lib/app-nav.svelte";
|
||||
import { i18n } from "$lib/i18n/index.svelte";
|
||||
import { session } from "$lib/session-store.svelte";
|
||||
import { account } from "$lib/account-store.svelte";
|
||||
|
||||
type Page = "overview" | "profile";
|
||||
|
||||
interface Props {
|
||||
activePage: Page;
|
||||
displayName: string;
|
||||
userName: string;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { activePage, displayName, userName, children }: Props = $props();
|
||||
let { activePage, children }: Props = $props();
|
||||
|
||||
const PAGES: ReadonlyArray<{ id: Page; labelKey: "lobby.nav.overview" | "lobby.nav.profile"; screen: "lobby" | "profile" }> = [
|
||||
{ id: "overview", labelKey: "lobby.nav.overview", screen: "lobby" },
|
||||
@@ -28,9 +32,12 @@ right-hand column. Pages mark themselves active via `activePage`.
|
||||
];
|
||||
|
||||
let identityLabel = $derived.by(() => {
|
||||
const trimmed = displayName.trim();
|
||||
if (trimmed.length > 0) return trimmed;
|
||||
if (userName.length > 0) return userName;
|
||||
const current = account.current;
|
||||
if (current !== null) {
|
||||
const trimmed = current.displayName.trim();
|
||||
if (trimmed.length > 0) return trimmed;
|
||||
if (current.userName.length > 0) return current.userName;
|
||||
}
|
||||
return i18n.t("lobby.account_loading");
|
||||
});
|
||||
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
// `lobby-create`). Loads the caller's account aggregate, lets the
|
||||
// user edit `display_name`, `preferred_language`, and `time_zone`,
|
||||
// and posts the changes through `user.profile.update` /
|
||||
// `user.settings.update`. Returns to the lobby on save or cancel.
|
||||
// `user.settings.update`. The form stays on screen after a
|
||||
// successful save (the shell-level identity strip picks up the
|
||||
// new value through the shared `account` store) — only `cancel`
|
||||
// returns to the lobby.
|
||||
import { onMount } from "svelte";
|
||||
import { appScreen } from "$lib/app-nav.svelte";
|
||||
|
||||
@@ -11,7 +14,6 @@
|
||||
import { GalaxyClient } from "../../api/galaxy-client";
|
||||
import {
|
||||
AccountError,
|
||||
getMyAccount,
|
||||
updateMyProfile,
|
||||
updateMySettings,
|
||||
type Account,
|
||||
@@ -25,6 +27,13 @@
|
||||
} from "$lib/i18n/index.svelte";
|
||||
import { loadCore } from "../../platform/core/index";
|
||||
import { session } from "$lib/session-store.svelte";
|
||||
import { account } from "$lib/account-store.svelte";
|
||||
import {
|
||||
browserTimeZone,
|
||||
supportedTimeZones,
|
||||
withPreservedValue,
|
||||
type TimeZoneGroup,
|
||||
} from "$lib/time-zones";
|
||||
import LobbyShell from "./lobby-shell.svelte";
|
||||
|
||||
let loaded: Account | null = $state(null);
|
||||
@@ -36,6 +45,7 @@
|
||||
let configError: string | null = $state(null);
|
||||
let saveError: string | null = $state(null);
|
||||
let saving = $state(false);
|
||||
let savedNotice = $state(false);
|
||||
|
||||
let client: GalaxyClient | null = null;
|
||||
|
||||
@@ -43,6 +53,16 @@
|
||||
SUPPORTED_LOCALES.map((entry) => entry.code),
|
||||
);
|
||||
|
||||
// Built once: the IANA list is static for the page lifetime. The
|
||||
// stored value is folded in lazily so a zone the runtime no longer
|
||||
// advertises still renders.
|
||||
const TIME_ZONE_GROUPS_BASE: readonly TimeZoneGroup[] = supportedTimeZones();
|
||||
|
||||
let timeZoneGroups = $derived<readonly TimeZoneGroup[]>(
|
||||
withPreservedValue(TIME_ZONE_GROUPS_BASE, timeZoneInput),
|
||||
);
|
||||
let timeZoneFallbackToText = $derived(TIME_ZONE_GROUPS_BASE.length === 0);
|
||||
|
||||
async function sha256(payload: Uint8Array): Promise<Uint8Array> {
|
||||
const digest = await crypto.subtle.digest("SHA-256", payload as BufferSource);
|
||||
return new Uint8Array(digest);
|
||||
@@ -58,24 +78,26 @@
|
||||
return err instanceof Error ? err.message : "request failed";
|
||||
}
|
||||
|
||||
function browserTimeZone(): string {
|
||||
try {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone ?? "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
function applyAccount(next: Account): void {
|
||||
loaded = next;
|
||||
displayNameInput = next.displayName;
|
||||
preferredLanguageInput = next.preferredLanguage;
|
||||
// Seed an empty stored zone with the browser's current zone so
|
||||
// the picker lands on a sensible default rather than the first
|
||||
// IANA entry. The form treats "no change" as not posting, so
|
||||
// the seeded value is only persisted on an explicit save.
|
||||
timeZoneInput = next.timeZone.length > 0 ? next.timeZone : browserTimeZone();
|
||||
}
|
||||
|
||||
function applyAccount(account: Account): void {
|
||||
loaded = account;
|
||||
displayNameInput = account.displayName;
|
||||
preferredLanguageInput = account.preferredLanguage;
|
||||
timeZoneInput = account.timeZone;
|
||||
function markDirty(): void {
|
||||
// Any edit invalidates the "Saved" notice.
|
||||
savedNotice = false;
|
||||
saveError = null;
|
||||
}
|
||||
|
||||
async function loadAccount(c: GalaxyClient): Promise<void> {
|
||||
try {
|
||||
applyAccount(await getMyAccount(c));
|
||||
applyAccount(await account.ensure(c));
|
||||
} catch (err) {
|
||||
loadError = describe(err);
|
||||
}
|
||||
@@ -97,6 +119,7 @@
|
||||
}
|
||||
saving = true;
|
||||
saveError = null;
|
||||
savedNotice = false;
|
||||
try {
|
||||
let next: Account = loaded;
|
||||
if (trimmedDisplay !== loaded.displayName) {
|
||||
@@ -109,6 +132,7 @@
|
||||
next = await updateMySettings(client, trimmedLanguage, trimmedZone);
|
||||
}
|
||||
applyAccount(next);
|
||||
account.set(next);
|
||||
// When the user picks a language the UI supports, switch the
|
||||
// active locale immediately so the rest of the session sees
|
||||
// the change without a reload. Unsupported BCP 47 codes are
|
||||
@@ -116,7 +140,7 @@
|
||||
if (SUPPORTED_LOCALE_CODES.has(next.preferredLanguage)) {
|
||||
i18n.setLocale(next.preferredLanguage as Locale);
|
||||
}
|
||||
appScreen.go("lobby");
|
||||
savedNotice = true;
|
||||
} catch (err) {
|
||||
saveError = describe(err);
|
||||
} finally {
|
||||
@@ -157,11 +181,7 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<LobbyShell
|
||||
activePage="profile"
|
||||
displayName={loaded?.displayName ?? ""}
|
||||
userName={loaded?.userName ?? ""}
|
||||
>
|
||||
<LobbyShell activePage="profile">
|
||||
<h1>{i18n.t("profile.title")}</h1>
|
||||
{#if configError !== null}
|
||||
<p role="alert" data-testid="profile-config-error">{configError}</p>
|
||||
@@ -183,6 +203,7 @@
|
||||
<input
|
||||
type="text"
|
||||
bind:value={displayNameInput}
|
||||
oninput={markDirty}
|
||||
autocomplete="nickname"
|
||||
data-testid="profile-display-name"
|
||||
/>
|
||||
@@ -193,6 +214,7 @@
|
||||
<span>{i18n.t("profile.field.preferred_language")}</span>
|
||||
<select
|
||||
bind:value={preferredLanguageInput}
|
||||
onchange={markDirty}
|
||||
data-testid="profile-preferred-language"
|
||||
>
|
||||
{#each SUPPORTED_LOCALES as entry (entry.code)}
|
||||
@@ -212,18 +234,44 @@
|
||||
|
||||
<label>
|
||||
<span>{i18n.t("profile.field.time_zone")}</span>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={timeZoneInput}
|
||||
placeholder={browserTimeZone()}
|
||||
autocomplete="off"
|
||||
data-testid="profile-time-zone"
|
||||
/>
|
||||
{#if timeZoneFallbackToText}
|
||||
<!--
|
||||
Browser lacks `Intl.supportedValuesOf("timeZone")` —
|
||||
fall back to a free-text field so a viable runtime can
|
||||
still save a zone. The backend remains the validator.
|
||||
-->
|
||||
<input
|
||||
type="text"
|
||||
bind:value={timeZoneInput}
|
||||
oninput={markDirty}
|
||||
placeholder={browserTimeZone()}
|
||||
autocomplete="off"
|
||||
data-testid="profile-time-zone"
|
||||
/>
|
||||
{:else}
|
||||
<select
|
||||
bind:value={timeZoneInput}
|
||||
onchange={markDirty}
|
||||
data-testid="profile-time-zone"
|
||||
>
|
||||
{#each timeZoneGroups as group (group.label)}
|
||||
<optgroup label={group.label}>
|
||||
{#each group.values as zone (zone)}
|
||||
<option value={zone}>{zone}</option>
|
||||
{/each}
|
||||
</optgroup>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
<small>{i18n.t("profile.hint.time_zone")}</small>
|
||||
</label>
|
||||
|
||||
{#if saveError !== null}
|
||||
<p role="alert" data-testid="profile-save-error">{saveError}</p>
|
||||
{:else if savedNotice}
|
||||
<p role="status" data-testid="profile-saved-notice">
|
||||
{i18n.t("profile.saved")}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="actions">
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
// Time-zone option helpers for the Profile screen's `<select>`.
|
||||
//
|
||||
// The browser ships the full IANA list through
|
||||
// `Intl.supportedValuesOf("timeZone")` (Chrome 99+, Firefox 93+,
|
||||
// Safari 15.4+ — all within the PWA target). This module reads that
|
||||
// list, groups the entries by their first slash-delimited segment
|
||||
// (`Africa`, `America`, …), sorts both groups and entries within each
|
||||
// group, and yields a shape that maps 1:1 onto `<optgroup>` /
|
||||
// `<option>`.
|
||||
//
|
||||
// Two corner cases:
|
||||
// * Singletons like `UTC` / `GMT` / `EST` have no slash, so they
|
||||
// collapse into a single "Other" bucket at the bottom of the
|
||||
// dropdown.
|
||||
// * A stored value that is not in the browser-supplied list (an
|
||||
// older zone the runtime no longer ships, or a name from a
|
||||
// freshly-imported account) is appended as a one-entry "Other"
|
||||
// option through `withPreservedValue`. The Profile form calls
|
||||
// that helper so saving an unchanged form never silently
|
||||
// downgrades a stored value to the default.
|
||||
|
||||
const OTHER_GROUP = "Other";
|
||||
|
||||
export interface TimeZoneGroup {
|
||||
readonly label: string;
|
||||
readonly values: readonly string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* supportedTimeZones returns the browser-supplied IANA list, grouped
|
||||
* by leading segment and sorted alphabetically. Returns an empty
|
||||
* array when the runtime does not implement
|
||||
* `Intl.supportedValuesOf("timeZone")` so callers can fall back to a
|
||||
* text input.
|
||||
*/
|
||||
export function supportedTimeZones(): readonly TimeZoneGroup[] {
|
||||
const zones = listSupportedZones();
|
||||
if (zones.length === 0) return [];
|
||||
return groupZones(zones);
|
||||
}
|
||||
|
||||
/**
|
||||
* withPreservedValue returns `groups` unchanged when the supplied
|
||||
* `value` is empty or already appears in one of the groups.
|
||||
* Otherwise it appends a single-entry "Other" group carrying the
|
||||
* value so the `<select>` 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<string, string[]>();
|
||||
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;
|
||||
}
|
||||
@@ -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 <select>; saving an edited zone posts user.settings.update", async ({
|
||||
page,
|
||||
}) => {
|
||||
const mocks = await mockGateway(page, {
|
||||
userId: "user-1",
|
||||
email: "pilot@example.com",
|
||||
userName: "player-abc12345",
|
||||
displayName: "Pilot",
|
||||
preferredLanguage: "en",
|
||||
timeZone: "Europe/London",
|
||||
});
|
||||
await completeLogin(page);
|
||||
await page.getByTestId("lobby-account-name").click();
|
||||
await expect(page.getByTestId("profile-form")).toBeVisible();
|
||||
|
||||
const select = page.getByTestId("profile-time-zone");
|
||||
// The field renders as a <select> 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());
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user