@anchrd/intel-ui 0.13.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/agent/agent-profile/agent-profile.tsx +5 -14
- package/src/app/settings-dialog/settings-dialog.tsx +10 -28
- package/src/components/ui/popover.tsx +41 -0
- package/src/i18n/de.json +4 -0
- package/src/i18n/en.json +4 -0
- package/src/i18n/es.json +4 -0
- package/src/timezone/timezone-combobox/timezone-combobox.tsx +149 -0
- package/src/timezone/timezone.ts +99 -0
package/package.json
CHANGED
|
@@ -32,7 +32,7 @@ import { EntryPicker, type PickerKind } from "@/entry-picker/entry-picker.tsx";
|
|
|
32
32
|
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
33
33
|
import { Modal } from "@/modal/modal.tsx";
|
|
34
34
|
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
35
|
-
import {
|
|
35
|
+
import { TimezoneCombobox } from "@/timezone/timezone-combobox/timezone-combobox.tsx";
|
|
36
36
|
import { useTimezone } from "@/timezone/timezone-context.tsx";
|
|
37
37
|
|
|
38
38
|
// No dividers between sections — the space is the separation (#204). The explanation sits behind
|
|
@@ -660,8 +660,6 @@ function ScheduleDialog({ close, add }: { close(): void; add(schedule: AgentSche
|
|
|
660
660
|
// means the same hour to everybody else reading the agent.
|
|
661
661
|
const preferred = useTimezone();
|
|
662
662
|
const [timezone, setTimezone] = useState(preferred);
|
|
663
|
-
const zones = availableTimezones();
|
|
664
|
-
const options = zones.includes(timezone) ? zones : [timezone, ...zones];
|
|
665
663
|
const [target, setTarget] = useState<{ id: string; kind: "document" | "flow" } | null>(null);
|
|
666
664
|
|
|
667
665
|
return (
|
|
@@ -692,19 +690,12 @@ function ScheduleDialog({ close, add }: { close(): void; add(schedule: AgentSche
|
|
|
692
690
|
<label className="block text-sm font-medium" htmlFor="agent-schedule-timezone">
|
|
693
691
|
{i18n.t("agent.scheduleTimezone")}
|
|
694
692
|
</label>
|
|
695
|
-
<
|
|
693
|
+
<TimezoneCombobox
|
|
696
694
|
id="agent-schedule-timezone"
|
|
697
695
|
value={timezone}
|
|
698
|
-
onChange={
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
>
|
|
702
|
-
{options.map((zone) => (
|
|
703
|
-
<option key={zone} value={zone}>
|
|
704
|
-
{zone.replace(/_/g, " ")}
|
|
705
|
-
</option>
|
|
706
|
-
))}
|
|
707
|
-
</select>
|
|
696
|
+
onChange={setTimezone}
|
|
697
|
+
describedBy="agent-timezone-hint"
|
|
698
|
+
/>
|
|
708
699
|
<span id="agent-timezone-hint" className="-mt-2 block text-xs text-muted-foreground">
|
|
709
700
|
{i18n.t("agent.scheduleTimezoneHint")}
|
|
710
701
|
</span>
|
|
@@ -16,7 +16,7 @@ import { useI18n, useLanguage } from "@/i18n/i18n-context.tsx";
|
|
|
16
16
|
import { languageName } from "@/i18n/i18n-languages/i18n-languages.ts";
|
|
17
17
|
import { type ThemeChoice, ThemeChoices } from "@/theme/theme.ts";
|
|
18
18
|
import { useTheme } from "@/theme/theme-context.tsx";
|
|
19
|
-
import {
|
|
19
|
+
import { TimezoneCombobox } from "@/timezone/timezone-combobox/timezone-combobox.tsx";
|
|
20
20
|
import { useTimezoneSelection } from "@/timezone/timezone-context.tsx";
|
|
21
21
|
|
|
22
22
|
// The settings that belong to the person rather than to the installation: language, appearance and
|
|
@@ -38,18 +38,6 @@ export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
|
|
|
38
38
|
const theme = useTheme();
|
|
39
39
|
const timezone = useTimezoneSelection();
|
|
40
40
|
|
|
41
|
-
// ⚠️ The active zone is put in front of the list unless it is already there, and that is not
|
|
42
|
-
// belt-and-braces: `Intl.supportedValuesOf("timeZone")` returns only CANONICAL IANA names, and
|
|
43
|
-
// "UTC" is not among them (it is an alias of Etc/UTC). A reader whose browser reports "UTC" —
|
|
44
|
-
// every CI machine, plenty of containers and servers — would otherwise get a Select whose value
|
|
45
|
-
// matches no item, and Radix renders that as an EMPTY field. Found by CI, not by the test.
|
|
46
|
-
//
|
|
47
|
-
// The same line covers the engine without `supportedValuesOf` at all: the list is empty and the
|
|
48
|
-
// reader still has their own zone to look at.
|
|
49
|
-
const zones = availableTimezones();
|
|
50
|
-
const current = timezone?.current ?? browserTimezone();
|
|
51
|
-
const options = zones.includes(current) ? zones : [current, ...zones];
|
|
52
|
-
|
|
53
41
|
return (
|
|
54
42
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
55
43
|
<DialogContent className="sm:max-w-md">
|
|
@@ -109,21 +97,15 @@ export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
|
|
|
109
97
|
<label className="text-sm font-medium" htmlFor="settings-timezone">
|
|
110
98
|
{i18n.t("settings.timezone")}
|
|
111
99
|
</label>
|
|
112
|
-
<
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
{zone.replace(/_/g, " ")}
|
|
122
|
-
</SelectItem>
|
|
123
|
-
))}
|
|
124
|
-
</SelectContent>
|
|
125
|
-
</Select>
|
|
126
|
-
<p className="text-xs text-muted-foreground">{i18n.t("settings.timezoneHint")}</p>
|
|
100
|
+
<TimezoneCombobox
|
|
101
|
+
id="settings-timezone"
|
|
102
|
+
value={timezone.current}
|
|
103
|
+
onChange={timezone.select}
|
|
104
|
+
describedBy="settings-timezone-hint"
|
|
105
|
+
/>
|
|
106
|
+
<p id="settings-timezone-hint" className="text-xs text-muted-foreground">
|
|
107
|
+
{i18n.t("settings.timezoneHint")}
|
|
108
|
+
</p>
|
|
127
109
|
</div>
|
|
128
110
|
) : null}
|
|
129
111
|
</div>
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Popover as PopoverPrimitive } from "radix-ui";
|
|
4
|
+
import type * as React from "react";
|
|
5
|
+
import { cn } from "@/lib/utils";
|
|
6
|
+
|
|
7
|
+
function Popover({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
|
8
|
+
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function PopoverTrigger({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
|
12
|
+
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function PopoverContent({
|
|
16
|
+
className,
|
|
17
|
+
align = "center",
|
|
18
|
+
sideOffset = 4,
|
|
19
|
+
...props
|
|
20
|
+
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
|
21
|
+
return (
|
|
22
|
+
<PopoverPrimitive.Portal>
|
|
23
|
+
<PopoverPrimitive.Content
|
|
24
|
+
data-slot="popover-content"
|
|
25
|
+
align={align}
|
|
26
|
+
sideOffset={sideOffset}
|
|
27
|
+
className={cn(
|
|
28
|
+
"z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
|
29
|
+
className,
|
|
30
|
+
)}
|
|
31
|
+
{...props}
|
|
32
|
+
/>
|
|
33
|
+
</PopoverPrimitive.Portal>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function PopoverAnchor({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
|
38
|
+
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export { Popover, PopoverAnchor, PopoverContent, PopoverTrigger };
|
package/src/i18n/de.json
CHANGED
|
@@ -379,6 +379,10 @@
|
|
|
379
379
|
"settings.appearance.dark": "Dunkel",
|
|
380
380
|
"settings.timezone": "Zeitzone",
|
|
381
381
|
"settings.timezoneHint": "Zum Lesen von Zeiten, und als Vorschlag, wenn du einem Agenten einen Zeitplan gibst. Ein Zeitplan behält die Zeitzone, die er bekommen hat — eine Reise verschiebt also keine Läufe, die du bereits gesetzt hast.",
|
|
382
|
+
"settings.timezoneSearch": "Stadt, Zone oder Versatz suchen …",
|
|
383
|
+
"settings.timezoneNoMatch": "Keine Zeitzone passt.",
|
|
384
|
+
"settings.timezoneOwn": "Dein Gerät",
|
|
385
|
+
"settings.timezoneAll": "Alle Zeitzonen",
|
|
382
386
|
"auth.signOut": "Abmelden",
|
|
383
387
|
"auth.signOutFailed": "Das Abmelden ist fehlgeschlagen. Prüfe deine Verbindung und versuche es erneut.",
|
|
384
388
|
"signIn.refusedTitle": "Angemeldet, aber nicht angenommen",
|
package/src/i18n/en.json
CHANGED
|
@@ -379,6 +379,10 @@
|
|
|
379
379
|
"settings.appearance.dark": "Dark",
|
|
380
380
|
"settings.timezone": "Timezone",
|
|
381
381
|
"settings.timezoneHint": "Used to read times, and suggested when you give an agent a schedule. A schedule keeps the timezone it was given, so travelling does not move the runs you already set.",
|
|
382
|
+
"settings.timezoneSearch": "Search city, zone or offset…",
|
|
383
|
+
"settings.timezoneNoMatch": "No timezone matches.",
|
|
384
|
+
"settings.timezoneOwn": "Your device",
|
|
385
|
+
"settings.timezoneAll": "All timezones",
|
|
382
386
|
"auth.signOut": "Sign out",
|
|
383
387
|
"auth.signOutFailed": "Signing out failed. Check your connection and try again.",
|
|
384
388
|
"signIn.refusedTitle": "Signed in, but not accepted",
|
package/src/i18n/es.json
CHANGED
|
@@ -379,6 +379,10 @@
|
|
|
379
379
|
"settings.appearance.dark": "Oscuro",
|
|
380
380
|
"settings.timezone": "Zona horaria",
|
|
381
381
|
"settings.timezoneHint": "Se usa para leer las horas y se propone cuando le das un horario a un agente. Un horario conserva la zona con la que se creó, así que viajar no mueve las ejecuciones que ya has fijado.",
|
|
382
|
+
"settings.timezoneSearch": "Buscar ciudad, zona o desfase…",
|
|
383
|
+
"settings.timezoneNoMatch": "Ninguna zona horaria coincide.",
|
|
384
|
+
"settings.timezoneOwn": "Tu dispositivo",
|
|
385
|
+
"settings.timezoneAll": "Todas las zonas horarias",
|
|
382
386
|
"auth.signOut": "Cerrar sesión",
|
|
383
387
|
"auth.signOutFailed": "El cierre de sesión ha fallado. Comprueba tu conexión e inténtalo de nuevo.",
|
|
384
388
|
"signIn.refusedTitle": "Sesión iniciada, pero no aceptada",
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { Check, ChevronsUpDown } from "lucide-react";
|
|
2
|
+
import { useMemo, useState } from "react";
|
|
3
|
+
import {
|
|
4
|
+
Command,
|
|
5
|
+
CommandEmpty,
|
|
6
|
+
CommandGroup,
|
|
7
|
+
CommandInput,
|
|
8
|
+
CommandItem,
|
|
9
|
+
CommandList,
|
|
10
|
+
} from "@/components/ui/command";
|
|
11
|
+
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
|
12
|
+
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
13
|
+
import { cn } from "@/lib/utils";
|
|
14
|
+
import {
|
|
15
|
+
browserTimezone,
|
|
16
|
+
type TimezoneOption,
|
|
17
|
+
timezoneOption,
|
|
18
|
+
timezoneOptions,
|
|
19
|
+
} from "../timezone.ts";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Picking one of 418 timezones.
|
|
23
|
+
*
|
|
24
|
+
* ⚠️ A `Select` was the wrong shape and this replaces it (#243). Four hundred entries cannot be
|
|
25
|
+
* scrolled to, and a bare zone name does not say whether you got the right one — `Europe/Berlin`
|
|
26
|
+
* and `Europe/Madrid` are the same clock, `America/New_York` is not. So: type to search, and the
|
|
27
|
+
* current offset stands in front of every name.
|
|
28
|
+
*
|
|
29
|
+
* ⚠️ The label is computed here, at render time, and never stored. A zone's offset AND its
|
|
30
|
+
* abbreviation move with daylight saving, so a written-down label is wrong for half of every year.
|
|
31
|
+
*/
|
|
32
|
+
interface TimezoneComboboxProps {
|
|
33
|
+
id: string;
|
|
34
|
+
value: string;
|
|
35
|
+
onChange(zone: string): void;
|
|
36
|
+
/** Handed in rather than read from the clock, so a test does not have to travel in time. */
|
|
37
|
+
now?: Date;
|
|
38
|
+
describedBy?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function OptionLabel({ option }: { option: TimezoneOption }) {
|
|
42
|
+
return (
|
|
43
|
+
<>
|
|
44
|
+
{/* Fixed width, so the zone names line up into a column instead of stepping left and right
|
|
45
|
+
with every offset. Tabular figures keep "GMT+05:30" and "GMT-04:00" the same width, and
|
|
46
|
+
⚠️ `whitespace-nowrap` is not decoration: the longest label ("GMT-09:00 · HADT") wrapped
|
|
47
|
+
onto a second line and pushed its own row out of the column. */}
|
|
48
|
+
<span className="w-36 shrink-0 whitespace-nowrap text-xs tabular-nums text-muted-foreground">
|
|
49
|
+
{option.offsetLabel}
|
|
50
|
+
{option.abbreviation ? ` · ${option.abbreviation}` : ""}
|
|
51
|
+
</span>
|
|
52
|
+
<span className="truncate">{option.zone.replace(/_/g, " ")}</span>
|
|
53
|
+
</>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function TimezoneCombobox({ id, value, onChange, now, describedBy }: TimezoneComboboxProps) {
|
|
58
|
+
const i18n = useI18n();
|
|
59
|
+
const [open, setOpen] = useState(false);
|
|
60
|
+
const at = now ?? new Date();
|
|
61
|
+
// The clock is a dependency on purpose: without it in the key, a session that crossed a DST
|
|
62
|
+
// boundary would keep showing yesterday's offsets.
|
|
63
|
+
const at_ = at.getTime();
|
|
64
|
+
|
|
65
|
+
const options = useMemo(
|
|
66
|
+
() => timezoneOptions(value, new Date(at_), i18n.locale),
|
|
67
|
+
[value, at_, i18n.locale],
|
|
68
|
+
);
|
|
69
|
+
const selected = useMemo(
|
|
70
|
+
() => timezoneOption(value, new Date(at_), i18n.locale),
|
|
71
|
+
[value, at_, i18n.locale],
|
|
72
|
+
);
|
|
73
|
+
// The reader's own zone, pinned above the list: it is the answer in the overwhelming majority of
|
|
74
|
+
// cases, and finding it in a sorted list of 418 is the work this saves.
|
|
75
|
+
const own = useMemo(
|
|
76
|
+
() => timezoneOption(browserTimezone(), new Date(at_), i18n.locale),
|
|
77
|
+
[at_, i18n.locale],
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
const choose = (zone: string) => {
|
|
81
|
+
onChange(zone);
|
|
82
|
+
setOpen(false);
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<Popover open={open} onOpenChange={setOpen}>
|
|
87
|
+
<PopoverTrigger asChild>
|
|
88
|
+
<button
|
|
89
|
+
id={id}
|
|
90
|
+
type="button"
|
|
91
|
+
role="combobox"
|
|
92
|
+
aria-expanded={open}
|
|
93
|
+
aria-describedby={describedBy}
|
|
94
|
+
className="flex w-full items-center gap-2 rounded-md border bg-background px-3 py-2 text-left text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
95
|
+
>
|
|
96
|
+
<OptionLabel option={selected} />
|
|
97
|
+
<ChevronsUpDown aria-hidden="true" className="ml-auto size-4 shrink-0 opacity-50" />
|
|
98
|
+
</button>
|
|
99
|
+
</PopoverTrigger>
|
|
100
|
+
<PopoverContent className="w-(--radix-popover-trigger-width) p-0" align="start">
|
|
101
|
+
{/* ⚠️ A substring filter, not cmdk's default fuzzy one. Fuzzy matches a scattered
|
|
102
|
+
subsequence, so over 418 zones "berlin" also surfaced Brisbane, Dublin and Lisbon —
|
|
103
|
+
technically a match, useless as an answer. Prefix beats contains, so typing a city puts
|
|
104
|
+
that city first. An empty search returns 1 for everything, which leaves the list in DOM
|
|
105
|
+
order: sorted west to east. */}
|
|
106
|
+
<Command
|
|
107
|
+
filter={(value, search, keywords) => {
|
|
108
|
+
const needle = search.trim().toLowerCase();
|
|
109
|
+
if (!needle) return 1;
|
|
110
|
+
const haystack = [value.toLowerCase(), ...(keywords ?? [])];
|
|
111
|
+
if (haystack.some((word) => word.startsWith(needle))) return 2;
|
|
112
|
+
return haystack.some((word) => word.includes(needle)) ? 1 : 0;
|
|
113
|
+
}}
|
|
114
|
+
>
|
|
115
|
+
<CommandInput placeholder={i18n.t("settings.timezoneSearch")} />
|
|
116
|
+
<CommandList>
|
|
117
|
+
<CommandEmpty>{i18n.t("settings.timezoneNoMatch")}</CommandEmpty>
|
|
118
|
+
{own.zone !== value ? (
|
|
119
|
+
<CommandGroup heading={i18n.t("settings.timezoneOwn")}>
|
|
120
|
+
<CommandItem value={own.zone} keywords={own.keywords} onSelect={choose}>
|
|
121
|
+
<OptionLabel option={own} />
|
|
122
|
+
</CommandItem>
|
|
123
|
+
</CommandGroup>
|
|
124
|
+
) : null}
|
|
125
|
+
<CommandGroup heading={i18n.t("settings.timezoneAll")}>
|
|
126
|
+
{options.map((option) => (
|
|
127
|
+
<CommandItem
|
|
128
|
+
key={option.zone}
|
|
129
|
+
value={option.zone}
|
|
130
|
+
keywords={option.keywords}
|
|
131
|
+
onSelect={choose}
|
|
132
|
+
>
|
|
133
|
+
<OptionLabel option={option} />
|
|
134
|
+
<Check
|
|
135
|
+
aria-hidden="true"
|
|
136
|
+
className={cn(
|
|
137
|
+
"ml-auto size-4 shrink-0",
|
|
138
|
+
option.zone === value ? "opacity-100" : "opacity-0",
|
|
139
|
+
)}
|
|
140
|
+
/>
|
|
141
|
+
</CommandItem>
|
|
142
|
+
))}
|
|
143
|
+
</CommandGroup>
|
|
144
|
+
</CommandList>
|
|
145
|
+
</Command>
|
|
146
|
+
</PopoverContent>
|
|
147
|
+
</Popover>
|
|
148
|
+
);
|
|
149
|
+
}
|
package/src/timezone/timezone.ts
CHANGED
|
@@ -58,3 +58,102 @@ export function rememberTimezone(zone: string, store?: Pick<Storage, "setItem">)
|
|
|
58
58
|
// A preference that cannot be written is still a preference for this session.
|
|
59
59
|
}
|
|
60
60
|
}
|
|
61
|
+
|
|
62
|
+
/** One zone as the picker shows it. Everything here is derived, nothing is stored. */
|
|
63
|
+
export interface TimezoneOption {
|
|
64
|
+
zone: string;
|
|
65
|
+
/** "GMT+02:00", "GMT-04:00", "GMT" — present for every zone, which is why it carries the column. */
|
|
66
|
+
offsetLabel: string;
|
|
67
|
+
/** "MESZ", "JST" … or null where the runtime only repeats the offset. */
|
|
68
|
+
abbreviation: string | null;
|
|
69
|
+
/** Minutes east of UTC at `at`, for sorting. */
|
|
70
|
+
offsetMinutes: number;
|
|
71
|
+
/** What the search matches on, lowercased. */
|
|
72
|
+
keywords: string[];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function part(zone: string, style: "short" | "longOffset", at: Date, locale: string): string {
|
|
76
|
+
try {
|
|
77
|
+
return (
|
|
78
|
+
new Intl.DateTimeFormat(locale, { timeZone: zone, timeZoneName: style })
|
|
79
|
+
.formatToParts(at)
|
|
80
|
+
.find((piece) => piece.type === "timeZoneName")?.value ?? ""
|
|
81
|
+
);
|
|
82
|
+
} catch {
|
|
83
|
+
return "";
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// "GMT+02:00" → 120, "GMT-04:00" → -240, "GMT" → 0. Read from the same string the label shows, so
|
|
88
|
+
// the sort order and the text can never tell different stories.
|
|
89
|
+
function minutesFrom(offsetLabel: string): number {
|
|
90
|
+
const match = /^GMT([+-])(\d{2}):(\d{2})$/.exec(offsetLabel);
|
|
91
|
+
if (!match) return 0;
|
|
92
|
+
const [, sign, hours, minutes] = match;
|
|
93
|
+
const total = Number(hours) * 60 + Number(minutes);
|
|
94
|
+
return sign === "-" ? -total : total;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The label of one zone, at a given moment.
|
|
99
|
+
*
|
|
100
|
+
* ⚠️ Computed at display time and never stored. A zone's offset AND its abbreviation move with
|
|
101
|
+
* daylight saving — Berlin is MEZ/+01:00 in January and MESZ/+02:00 in July — so a label written
|
|
102
|
+
* into a field would be wrong for half of every year.
|
|
103
|
+
*
|
|
104
|
+
* ⚠️ The abbreviation is dropped where `Intl` only echoes the offset — and WHICH zones have one
|
|
105
|
+
* depends on the READER'S LANGUAGE, not on the zone. CLDR ships short names only where a locale has
|
|
106
|
+
* its own word for that zone: a German reader gets "MEZ/MESZ" for Berlin and a bare "GMT-4" for New
|
|
107
|
+
* York; an English reader gets "EST/EDT" for New York and a bare "GMT+2" for Berlin. That is the
|
|
108
|
+
* useful way round — you get the abbreviation for the zones your language actually names — but it
|
|
109
|
+
* means this function's output is not the same in two languages, and a test has to say which one it
|
|
110
|
+
* is asserting. `Asia/Kolkata` has none in either ("GMT+5:30"), which is the common case worldwide.
|
|
111
|
+
*/
|
|
112
|
+
export function timezoneOption(zone: string, at: Date, locale: string): TimezoneOption {
|
|
113
|
+
const offsetLabel = part(zone, "longOffset", at, locale) || "GMT";
|
|
114
|
+
const short = part(zone, "short", at, locale);
|
|
115
|
+
// Dropped when it repeats the offset, and also when it repeats the zone's own name: "UTC" would
|
|
116
|
+
// otherwise render as "GMT · UTC UTC", which stutters without adding anything.
|
|
117
|
+
const abbreviation =
|
|
118
|
+
short && !short.startsWith("GMT") && short !== offsetLabel && short !== zone ? short : null;
|
|
119
|
+
const city = zone.split("/").pop()?.replace(/_/g, " ") ?? zone;
|
|
120
|
+
return {
|
|
121
|
+
zone,
|
|
122
|
+
offsetLabel,
|
|
123
|
+
abbreviation,
|
|
124
|
+
offsetMinutes: minutesFrom(offsetLabel),
|
|
125
|
+
// The offset appears twice on purpose: as written ("GMT+02:00") and as typed ("+2"), because
|
|
126
|
+
// nobody searches for the leading zero.
|
|
127
|
+
keywords: [zone, city, offsetLabel, abbreviation ?? "", shorthandOffset(offsetLabel)]
|
|
128
|
+
.filter(Boolean)
|
|
129
|
+
.map((word) => word.toLowerCase()),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// "GMT+02:00" → "+2", "GMT+05:30" → "+5:30", "GMT" → "+0". What a person types.
|
|
134
|
+
function shorthandOffset(offsetLabel: string): string {
|
|
135
|
+
const match = /^GMT([+-])(\d{2}):(\d{2})$/.exec(offsetLabel);
|
|
136
|
+
if (!match) return "+0";
|
|
137
|
+
const [, sign, hours, minutes] = match;
|
|
138
|
+
return `${sign}${Number(hours)}${minutes === "00" ? "" : `:${minutes}`}`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Every selectable zone, sorted west to east.
|
|
143
|
+
*
|
|
144
|
+
* ⚠️ `current` is folded in even when `supportedValuesOf` does not list it. That is not a corner
|
|
145
|
+
* case: the list holds only CANONICAL names, and "UTC" is not one of them (it is an alias of
|
|
146
|
+
* Etc/UTC) — so the zone every CI machine and many servers report would otherwise be missing from
|
|
147
|
+
* the picker that is supposed to be showing it.
|
|
148
|
+
*/
|
|
149
|
+
export function timezoneOptions(current: string, at: Date, locale: string): TimezoneOption[] {
|
|
150
|
+
const zones = availableTimezones();
|
|
151
|
+
const all = zones.includes(current) ? zones : [current, ...zones];
|
|
152
|
+
return all
|
|
153
|
+
.map((zone) => timezoneOption(zone, at, locale))
|
|
154
|
+
.sort((left, right) =>
|
|
155
|
+
left.offsetMinutes === right.offsetMinutes
|
|
156
|
+
? left.zone.localeCompare(right.zone)
|
|
157
|
+
: left.offsetMinutes - right.offsetMinutes,
|
|
158
|
+
);
|
|
159
|
+
}
|