a679d9cdcb
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.
141 lines
4.3 KiB
TypeScript
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;
|
|
}
|