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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user