From a679d9cdcb09a3eb8e9746367a4b507c9ccee894 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Tue, 26 May 2026 22:38:14 +0200 Subject: [PATCH] =?UTF-8?q?fix(ui):=20F8-04=20profile=20polish=20=E2=80=94?= =?UTF-8?q?=20IANA=20timezone=20picker,=20save-stay,=20shared=20identity?= =?UTF-8?q?=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()); + }); });