Files
galaxy-game/ui/frontend/src/lib/time-zones.ts
T
Ilia Denisov a679d9cdcb
Tests · UI / test (push) Has been cancelled
Tests · Go / test (push) Successful in 2m30s
Tests · UI / test (pull_request) Successful in 2m49s
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.
2026-05-26 22:38:14 +02:00

141 lines
4.3 KiB
TypeScript

// 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;
}