@nixxie-cms/fields-timezone 1.0.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/LICENSE ADDED
@@ -0,0 +1,23 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nixxie International DMCC
4
+ Portions Copyright (c) 2023 Thinkmill Labs Pty Ltd and contributors
5
+ (this software is derived from the KeystoneJS project, https://keystonejs.com)
6
+
7
+ Permission is hereby granted, free of charge, to any person obtaining a copy
8
+ of this software and associated documentation files (the "Software"), to deal
9
+ in the Software without restriction, including without limitation the rights
10
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11
+ copies of the Software, and to permit persons to whom the Software is
12
+ furnished to do so, subject to the following conditions:
13
+
14
+ The above copyright notice and this permission notice shall be included in all
15
+ copies or substantial portions of the Software.
16
+
17
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,21 @@
1
+ # @nixxie-cms/fields-timezone
2
+
3
+ A timezone field for Nixxie CMS. Stores a validated IANA timezone id (e.g. `Asia/Karachi`).
4
+ Validation uses `Intl.supportedValuesOf('timeZone')` when the runtime provides it (cached),
5
+ falling back to probing `Intl.DateTimeFormat` otherwise.
6
+
7
+ ```ts
8
+ import { timezone } from '@nixxie-cms/fields-timezone'
9
+
10
+ fields: {
11
+ tz: timezone({ validation: { isRequired: true } }),
12
+ }
13
+ ```
14
+
15
+ Also exports:
16
+
17
+ - `listTimezones()` — all IANA ids known to the runtime, or a curated list of ~30 common
18
+ zones as a fallback
19
+ - `currentOffset(tz)` — the zone's current UTC offset, e.g. `"UTC+05:00"` (DST-aware, so it
20
+ varies across the year; on very old runtimes the short style may yield an abbreviation
21
+ like `"PST"`, returned as-is)
@@ -0,0 +1,28 @@
1
+ import type { TextFieldConfig } from '@nixxie-cms/core/fields';
2
+ import type { BaseCollectionTypeInfo, FieldTypeFunc } from '@nixxie-cms/core/types';
3
+ /**
4
+ * All IANA timezone ids known to the runtime (via `Intl.supportedValuesOf`), or a curated list
5
+ * of ~30 common zones on runtimes without it.
6
+ */
7
+ export declare function listTimezones(): string[];
8
+ /**
9
+ * The current UTC offset of a timezone, e.g. `currentOffset('Asia/Karachi')` → `"UTC+05:00"`.
10
+ * Computed via `Intl.DateTimeFormat` with `timeZoneName: 'longOffset'`; on older runtimes the
11
+ * `'short'` style is used instead and may yield an abbreviation (e.g. `"PST"`), which is
12
+ * returned as-is. Throws a `RangeError` for unknown timezones. Note the offset is "current" —
13
+ * DST-observing zones return different values across the year.
14
+ */
15
+ export declare function currentOffset(tz: string): string;
16
+ export type TimezoneFieldConfig<CollectionTypeInfo extends BaseCollectionTypeInfo> = Omit<TextFieldConfig<CollectionTypeInfo>, 'validation'> & {
17
+ validation?: {
18
+ /** Reject saving when no value is present. */
19
+ isRequired?: boolean;
20
+ };
21
+ };
22
+ /**
23
+ * A timezone field storing an IANA timezone id (e.g. `Asia/Karachi`). Built on the core `text`
24
+ * field; values are validated against `Intl.supportedValuesOf('timeZone')` when available,
25
+ * otherwise by probing `Intl.DateTimeFormat`. Empty/null values pass through untouched.
26
+ */
27
+ export declare function timezone<CollectionTypeInfo extends BaseCollectionTypeInfo>(config?: TimezoneFieldConfig<CollectionTypeInfo>): FieldTypeFunc<CollectionTypeInfo>;
28
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"../../../src","sources":["index.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,yBAAyB,CAAA;AAC9D,OAAO,KAAK,EAAE,sBAAsB,EAAE,aAAa,EAAE,MAAM,wBAAwB,CAAA;AAmEnF;;;GAGG;AACH,wBAAgB,aAAa,IAAI,MAAM,EAAE,CAGxC;AAED;;;;;;GAMG;AACH,wBAAgB,aAAa,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,CAmBhD;AAED,MAAM,MAAM,mBAAmB,CAAC,kBAAkB,SAAS,sBAAsB,IAAI,IAAI,CACvF,eAAe,CAAC,kBAAkB,CAAC,EACnC,YAAY,CACb,GAAG;IACF,UAAU,CAAC,EAAE;QACX,8CAA8C;QAC9C,UAAU,CAAC,EAAE,OAAO,CAAA;KACrB,CAAA;CACF,CAAA;AAED;;;;GAIG;AACH,wBAAgB,QAAQ,CAAC,kBAAkB,SAAS,sBAAsB,EACxE,MAAM,GAAE,mBAAmB,CAAC,kBAAkB,CAAM,GACnD,aAAa,CAAC,kBAAkB,CAAC,CA2BnC"}
@@ -0,0 +1,2 @@
1
+ export * from "./declarations/src/index.js";
2
+ //# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoibml4eGllLWNtcy1maWVsZHMtdGltZXpvbmUuY2pzLmQudHMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuL2RlY2xhcmF0aW9ucy9zcmMvaW5kZXguZC50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQSJ9
@@ -0,0 +1,120 @@
1
+ 'use strict';
2
+
3
+ Object.defineProperty(exports, '__esModule', { value: true });
4
+
5
+ var fields = require('@nixxie-cms/core/fields');
6
+
7
+ /** ~30 widely used zones, used only on runtimes without `Intl.supportedValuesOf`. */
8
+ const FALLBACK_TIMEZONES = ['UTC', 'Europe/London', 'Europe/Paris', 'Europe/Berlin', 'Europe/Madrid', 'Europe/Rome', 'Europe/Moscow', 'Europe/Istanbul', 'America/New_York', 'America/Chicago', 'America/Denver', 'America/Los_Angeles', 'America/Toronto', 'America/Mexico_City', 'America/Bogota', 'America/Sao_Paulo', 'America/Argentina/Buenos_Aires', 'Africa/Cairo', 'Africa/Lagos', 'Africa/Johannesburg', 'Africa/Nairobi', 'Asia/Dubai', 'Asia/Karachi', 'Asia/Kolkata', 'Asia/Dhaka', 'Asia/Bangkok', 'Asia/Singapore', 'Asia/Hong_Kong', 'Asia/Shanghai', 'Asia/Tokyo', 'Asia/Seoul', 'Australia/Sydney', 'Pacific/Auckland'];
9
+
10
+ // Cached result of Intl.supportedValuesOf('timeZone'): undefined = not yet computed,
11
+ // null = the runtime doesn't support it.
12
+ let supportedZones;
13
+ function getSupportedZones() {
14
+ if (supportedZones === undefined) {
15
+ supportedZones = typeof Intl.supportedValuesOf === 'function' ? new Set(Intl.supportedValuesOf('timeZone')) : null;
16
+ }
17
+ return supportedZones;
18
+ }
19
+ function isValidTimezone(tz) {
20
+ var _getSupportedZones;
21
+ // Prefer the runtime's canonical IANA list as a fast path when available.
22
+ if ((_getSupportedZones = getSupportedZones()) !== null && _getSupportedZones !== void 0 && _getSupportedZones.has(tz)) return true;
23
+ // Fallback: probe the constructor — it throws a RangeError for unknown timezones. This also
24
+ // covers valid ids missing from supportedValuesOf, which omits aliases such as 'UTC' and
25
+ // 'Asia/Calcutta' on some runtimes.
26
+ try {
27
+ new Intl.DateTimeFormat('en-US', {
28
+ timeZone: tz
29
+ });
30
+ return true;
31
+ } catch {
32
+ return false;
33
+ }
34
+ }
35
+
36
+ /**
37
+ * All IANA timezone ids known to the runtime (via `Intl.supportedValuesOf`), or a curated list
38
+ * of ~30 common zones on runtimes without it.
39
+ */
40
+ function listTimezones() {
41
+ const zones = getSupportedZones();
42
+ return zones ? [...zones] : [...FALLBACK_TIMEZONES];
43
+ }
44
+
45
+ /**
46
+ * The current UTC offset of a timezone, e.g. `currentOffset('Asia/Karachi')` → `"UTC+05:00"`.
47
+ * Computed via `Intl.DateTimeFormat` with `timeZoneName: 'longOffset'`; on older runtimes the
48
+ * `'short'` style is used instead and may yield an abbreviation (e.g. `"PST"`), which is
49
+ * returned as-is. Throws a `RangeError` for unknown timezones. Note the offset is "current" —
50
+ * DST-observing zones return different values across the year.
51
+ */
52
+ function currentOffset(tz) {
53
+ var _timeZoneName;
54
+ const now = new Date();
55
+ const timeZoneName = style => {
56
+ try {
57
+ var _Intl$DateTimeFormat$;
58
+ return (_Intl$DateTimeFormat$ = new Intl.DateTimeFormat('en-US', {
59
+ timeZone: tz,
60
+ timeZoneName: style
61
+ }).formatToParts(now).find(part => part.type === 'timeZoneName')) === null || _Intl$DateTimeFormat$ === void 0 ? void 0 : _Intl$DateTimeFormat$.value;
62
+ } catch {
63
+ // Either the runtime doesn't support this timeZoneName style, or tz is unknown.
64
+ return undefined;
65
+ }
66
+ };
67
+ const raw = (_timeZoneName = timeZoneName('longOffset')) !== null && _timeZoneName !== void 0 ? _timeZoneName : timeZoneName('short');
68
+ if (raw === undefined) throw new RangeError(`Unknown timezone: "${tz}"`);
69
+ // Normalise 'GMT+05:00', 'GMT+5:30', 'GMT-7' or bare 'GMT' (UTC) to 'UTC±HH:MM'.
70
+ const match = /^(?:GMT|UTC)(?:([+-])(\d{1,2})(?::(\d{2}))?)?$/.exec(raw);
71
+ if (!match) return raw;
72
+ const [, sign = '+', hours = '0', minutes = '00'] = match;
73
+ return `UTC${sign}${hours.padStart(2, '0')}:${minutes}`;
74
+ }
75
+ /**
76
+ * A timezone field storing an IANA timezone id (e.g. `Asia/Karachi`). Built on the core `text`
77
+ * field; values are validated against `Intl.supportedValuesOf('timeZone')` when available,
78
+ * otherwise by probing `Intl.DateTimeFormat`. Empty/null values pass through untouched.
79
+ */
80
+ function timezone(config = {}) {
81
+ const {
82
+ hooks,
83
+ validation,
84
+ ...rest
85
+ } = config;
86
+ const userValidate = hooks === null || hooks === void 0 ? void 0 : hooks.validate;
87
+ return fields.text({
88
+ ...rest,
89
+ validation: {
90
+ isRequired: validation === null || validation === void 0 ? void 0 : validation.isRequired
91
+ },
92
+ ui: {
93
+ description: 'IANA timezone, e.g. Asia/Karachi',
94
+ ...rest.ui
95
+ },
96
+ hooks: {
97
+ ...hooks,
98
+ validate: async args => {
99
+ var _userValidate;
100
+ // Preserve any user-supplied validate hook (function or { create, update, delete } form).
101
+ if (typeof userValidate === 'function') await userValidate(args);else await (userValidate === null || userValidate === void 0 || (_userValidate = userValidate[args.operation]) === null || _userValidate === void 0 ? void 0 : _userValidate.call(userValidate, args));
102
+ const {
103
+ resolvedData,
104
+ fieldKey,
105
+ addValidationError,
106
+ operation
107
+ } = args;
108
+ if (operation === 'delete') return;
109
+ const value = resolvedData[fieldKey];
110
+ if (typeof value === 'string' && value.length > 0 && !isValidTimezone(value)) {
111
+ addValidationError('Must be a valid IANA timezone (e.g. "Asia/Karachi")');
112
+ }
113
+ }
114
+ }
115
+ });
116
+ }
117
+
118
+ exports.currentOffset = currentOffset;
119
+ exports.listTimezones = listTimezones;
120
+ exports.timezone = timezone;
@@ -0,0 +1,114 @@
1
+ import { text } from '@nixxie-cms/core/fields';
2
+
3
+ /** ~30 widely used zones, used only on runtimes without `Intl.supportedValuesOf`. */
4
+ const FALLBACK_TIMEZONES = ['UTC', 'Europe/London', 'Europe/Paris', 'Europe/Berlin', 'Europe/Madrid', 'Europe/Rome', 'Europe/Moscow', 'Europe/Istanbul', 'America/New_York', 'America/Chicago', 'America/Denver', 'America/Los_Angeles', 'America/Toronto', 'America/Mexico_City', 'America/Bogota', 'America/Sao_Paulo', 'America/Argentina/Buenos_Aires', 'Africa/Cairo', 'Africa/Lagos', 'Africa/Johannesburg', 'Africa/Nairobi', 'Asia/Dubai', 'Asia/Karachi', 'Asia/Kolkata', 'Asia/Dhaka', 'Asia/Bangkok', 'Asia/Singapore', 'Asia/Hong_Kong', 'Asia/Shanghai', 'Asia/Tokyo', 'Asia/Seoul', 'Australia/Sydney', 'Pacific/Auckland'];
5
+
6
+ // Cached result of Intl.supportedValuesOf('timeZone'): undefined = not yet computed,
7
+ // null = the runtime doesn't support it.
8
+ let supportedZones;
9
+ function getSupportedZones() {
10
+ if (supportedZones === undefined) {
11
+ supportedZones = typeof Intl.supportedValuesOf === 'function' ? new Set(Intl.supportedValuesOf('timeZone')) : null;
12
+ }
13
+ return supportedZones;
14
+ }
15
+ function isValidTimezone(tz) {
16
+ var _getSupportedZones;
17
+ // Prefer the runtime's canonical IANA list as a fast path when available.
18
+ if ((_getSupportedZones = getSupportedZones()) !== null && _getSupportedZones !== void 0 && _getSupportedZones.has(tz)) return true;
19
+ // Fallback: probe the constructor — it throws a RangeError for unknown timezones. This also
20
+ // covers valid ids missing from supportedValuesOf, which omits aliases such as 'UTC' and
21
+ // 'Asia/Calcutta' on some runtimes.
22
+ try {
23
+ new Intl.DateTimeFormat('en-US', {
24
+ timeZone: tz
25
+ });
26
+ return true;
27
+ } catch {
28
+ return false;
29
+ }
30
+ }
31
+
32
+ /**
33
+ * All IANA timezone ids known to the runtime (via `Intl.supportedValuesOf`), or a curated list
34
+ * of ~30 common zones on runtimes without it.
35
+ */
36
+ function listTimezones() {
37
+ const zones = getSupportedZones();
38
+ return zones ? [...zones] : [...FALLBACK_TIMEZONES];
39
+ }
40
+
41
+ /**
42
+ * The current UTC offset of a timezone, e.g. `currentOffset('Asia/Karachi')` → `"UTC+05:00"`.
43
+ * Computed via `Intl.DateTimeFormat` with `timeZoneName: 'longOffset'`; on older runtimes the
44
+ * `'short'` style is used instead and may yield an abbreviation (e.g. `"PST"`), which is
45
+ * returned as-is. Throws a `RangeError` for unknown timezones. Note the offset is "current" —
46
+ * DST-observing zones return different values across the year.
47
+ */
48
+ function currentOffset(tz) {
49
+ var _timeZoneName;
50
+ const now = new Date();
51
+ const timeZoneName = style => {
52
+ try {
53
+ var _Intl$DateTimeFormat$;
54
+ return (_Intl$DateTimeFormat$ = new Intl.DateTimeFormat('en-US', {
55
+ timeZone: tz,
56
+ timeZoneName: style
57
+ }).formatToParts(now).find(part => part.type === 'timeZoneName')) === null || _Intl$DateTimeFormat$ === void 0 ? void 0 : _Intl$DateTimeFormat$.value;
58
+ } catch {
59
+ // Either the runtime doesn't support this timeZoneName style, or tz is unknown.
60
+ return undefined;
61
+ }
62
+ };
63
+ const raw = (_timeZoneName = timeZoneName('longOffset')) !== null && _timeZoneName !== void 0 ? _timeZoneName : timeZoneName('short');
64
+ if (raw === undefined) throw new RangeError(`Unknown timezone: "${tz}"`);
65
+ // Normalise 'GMT+05:00', 'GMT+5:30', 'GMT-7' or bare 'GMT' (UTC) to 'UTC±HH:MM'.
66
+ const match = /^(?:GMT|UTC)(?:([+-])(\d{1,2})(?::(\d{2}))?)?$/.exec(raw);
67
+ if (!match) return raw;
68
+ const [, sign = '+', hours = '0', minutes = '00'] = match;
69
+ return `UTC${sign}${hours.padStart(2, '0')}:${minutes}`;
70
+ }
71
+ /**
72
+ * A timezone field storing an IANA timezone id (e.g. `Asia/Karachi`). Built on the core `text`
73
+ * field; values are validated against `Intl.supportedValuesOf('timeZone')` when available,
74
+ * otherwise by probing `Intl.DateTimeFormat`. Empty/null values pass through untouched.
75
+ */
76
+ function timezone(config = {}) {
77
+ const {
78
+ hooks,
79
+ validation,
80
+ ...rest
81
+ } = config;
82
+ const userValidate = hooks === null || hooks === void 0 ? void 0 : hooks.validate;
83
+ return text({
84
+ ...rest,
85
+ validation: {
86
+ isRequired: validation === null || validation === void 0 ? void 0 : validation.isRequired
87
+ },
88
+ ui: {
89
+ description: 'IANA timezone, e.g. Asia/Karachi',
90
+ ...rest.ui
91
+ },
92
+ hooks: {
93
+ ...hooks,
94
+ validate: async args => {
95
+ var _userValidate;
96
+ // Preserve any user-supplied validate hook (function or { create, update, delete } form).
97
+ if (typeof userValidate === 'function') await userValidate(args);else await (userValidate === null || userValidate === void 0 || (_userValidate = userValidate[args.operation]) === null || _userValidate === void 0 ? void 0 : _userValidate.call(userValidate, args));
98
+ const {
99
+ resolvedData,
100
+ fieldKey,
101
+ addValidationError,
102
+ operation
103
+ } = args;
104
+ if (operation === 'delete') return;
105
+ const value = resolvedData[fieldKey];
106
+ if (typeof value === 'string' && value.length > 0 && !isValidTimezone(value)) {
107
+ addValidationError('Must be a valid IANA timezone (e.g. "Asia/Karachi")');
108
+ }
109
+ }
110
+ }
111
+ });
112
+ }
113
+
114
+ export { currentOffset, listTimezones, timezone };
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@nixxie-cms/fields-timezone",
3
+ "version": "1.0.0",
4
+ "license": "MIT",
5
+ "main": "dist/nixxie-cms-fields-timezone.cjs.js",
6
+ "module": "dist/nixxie-cms-fields-timezone.esm.js",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/nixxie-cms-fields-timezone.cjs.js",
10
+ "module": "./dist/nixxie-cms-fields-timezone.esm.js",
11
+ "default": "./dist/nixxie-cms-fields-timezone.cjs.js"
12
+ },
13
+ "./package.json": "./package.json"
14
+ },
15
+ "dependencies": {
16
+ "@babel/runtime": "^7.24.7"
17
+ },
18
+ "devDependencies": {
19
+ "@nixxie-cms/core": "^1.1.0"
20
+ },
21
+ "peerDependencies": {
22
+ "@nixxie-cms/core": "^1.0.1"
23
+ },
24
+ "preconstruct": {
25
+ "entrypoints": [
26
+ "index.ts"
27
+ ]
28
+ },
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "https://github.com/nixxiecms/nixxie/tree/main/packages/fields-timezone"
32
+ }
33
+ }
package/src/index.ts ADDED
@@ -0,0 +1,151 @@
1
+ import { text } from '@nixxie-cms/core/fields'
2
+ import type { TextFieldConfig } from '@nixxie-cms/core/fields'
3
+ import type { BaseCollectionTypeInfo, FieldTypeFunc } from '@nixxie-cms/core/types'
4
+
5
+ /** ~30 widely used zones, used only on runtimes without `Intl.supportedValuesOf`. */
6
+ const FALLBACK_TIMEZONES = [
7
+ 'UTC',
8
+ 'Europe/London',
9
+ 'Europe/Paris',
10
+ 'Europe/Berlin',
11
+ 'Europe/Madrid',
12
+ 'Europe/Rome',
13
+ 'Europe/Moscow',
14
+ 'Europe/Istanbul',
15
+ 'America/New_York',
16
+ 'America/Chicago',
17
+ 'America/Denver',
18
+ 'America/Los_Angeles',
19
+ 'America/Toronto',
20
+ 'America/Mexico_City',
21
+ 'America/Bogota',
22
+ 'America/Sao_Paulo',
23
+ 'America/Argentina/Buenos_Aires',
24
+ 'Africa/Cairo',
25
+ 'Africa/Lagos',
26
+ 'Africa/Johannesburg',
27
+ 'Africa/Nairobi',
28
+ 'Asia/Dubai',
29
+ 'Asia/Karachi',
30
+ 'Asia/Kolkata',
31
+ 'Asia/Dhaka',
32
+ 'Asia/Bangkok',
33
+ 'Asia/Singapore',
34
+ 'Asia/Hong_Kong',
35
+ 'Asia/Shanghai',
36
+ 'Asia/Tokyo',
37
+ 'Asia/Seoul',
38
+ 'Australia/Sydney',
39
+ 'Pacific/Auckland',
40
+ ]
41
+
42
+ // Cached result of Intl.supportedValuesOf('timeZone'): undefined = not yet computed,
43
+ // null = the runtime doesn't support it.
44
+ let supportedZones: ReadonlySet<string> | null | undefined
45
+
46
+ function getSupportedZones(): ReadonlySet<string> | null {
47
+ if (supportedZones === undefined) {
48
+ supportedZones =
49
+ typeof (Intl as any).supportedValuesOf === 'function'
50
+ ? new Set<string>((Intl as any).supportedValuesOf('timeZone'))
51
+ : null
52
+ }
53
+ return supportedZones
54
+ }
55
+
56
+ function isValidTimezone(tz: string): boolean {
57
+ // Prefer the runtime's canonical IANA list as a fast path when available.
58
+ if (getSupportedZones()?.has(tz)) return true
59
+ // Fallback: probe the constructor — it throws a RangeError for unknown timezones. This also
60
+ // covers valid ids missing from supportedValuesOf, which omits aliases such as 'UTC' and
61
+ // 'Asia/Calcutta' on some runtimes.
62
+ try {
63
+ new Intl.DateTimeFormat('en-US', { timeZone: tz })
64
+ return true
65
+ } catch {
66
+ return false
67
+ }
68
+ }
69
+
70
+ /**
71
+ * All IANA timezone ids known to the runtime (via `Intl.supportedValuesOf`), or a curated list
72
+ * of ~30 common zones on runtimes without it.
73
+ */
74
+ export function listTimezones(): string[] {
75
+ const zones = getSupportedZones()
76
+ return zones ? [...zones] : [...FALLBACK_TIMEZONES]
77
+ }
78
+
79
+ /**
80
+ * The current UTC offset of a timezone, e.g. `currentOffset('Asia/Karachi')` → `"UTC+05:00"`.
81
+ * Computed via `Intl.DateTimeFormat` with `timeZoneName: 'longOffset'`; on older runtimes the
82
+ * `'short'` style is used instead and may yield an abbreviation (e.g. `"PST"`), which is
83
+ * returned as-is. Throws a `RangeError` for unknown timezones. Note the offset is "current" —
84
+ * DST-observing zones return different values across the year.
85
+ */
86
+ export function currentOffset(tz: string): string {
87
+ const now = new Date()
88
+ const timeZoneName = (style: 'longOffset' | 'short'): string | undefined => {
89
+ try {
90
+ return new Intl.DateTimeFormat('en-US', { timeZone: tz, timeZoneName: style })
91
+ .formatToParts(now)
92
+ .find(part => part.type === 'timeZoneName')?.value
93
+ } catch {
94
+ // Either the runtime doesn't support this timeZoneName style, or tz is unknown.
95
+ return undefined
96
+ }
97
+ }
98
+ const raw = timeZoneName('longOffset') ?? timeZoneName('short')
99
+ if (raw === undefined) throw new RangeError(`Unknown timezone: "${tz}"`)
100
+ // Normalise 'GMT+05:00', 'GMT+5:30', 'GMT-7' or bare 'GMT' (UTC) to 'UTC±HH:MM'.
101
+ const match = /^(?:GMT|UTC)(?:([+-])(\d{1,2})(?::(\d{2}))?)?$/.exec(raw)
102
+ if (!match) return raw
103
+ const [, sign = '+', hours = '0', minutes = '00'] = match
104
+ return `UTC${sign}${hours.padStart(2, '0')}:${minutes}`
105
+ }
106
+
107
+ export type TimezoneFieldConfig<CollectionTypeInfo extends BaseCollectionTypeInfo> = Omit<
108
+ TextFieldConfig<CollectionTypeInfo>,
109
+ 'validation'
110
+ > & {
111
+ validation?: {
112
+ /** Reject saving when no value is present. */
113
+ isRequired?: boolean
114
+ }
115
+ }
116
+
117
+ /**
118
+ * A timezone field storing an IANA timezone id (e.g. `Asia/Karachi`). Built on the core `text`
119
+ * field; values are validated against `Intl.supportedValuesOf('timeZone')` when available,
120
+ * otherwise by probing `Intl.DateTimeFormat`. Empty/null values pass through untouched.
121
+ */
122
+ export function timezone<CollectionTypeInfo extends BaseCollectionTypeInfo>(
123
+ config: TimezoneFieldConfig<CollectionTypeInfo> = {}
124
+ ): FieldTypeFunc<CollectionTypeInfo> {
125
+ const { hooks, validation, ...rest } = config
126
+ const userValidate = hooks?.validate as
127
+ | ((args: any) => unknown)
128
+ | { create?: (args: any) => unknown; update?: (args: any) => unknown; delete?: (args: any) => unknown }
129
+ | undefined
130
+
131
+ return text<CollectionTypeInfo>({
132
+ ...(rest as TextFieldConfig<CollectionTypeInfo>),
133
+ validation: { isRequired: validation?.isRequired },
134
+ ui: { description: 'IANA timezone, e.g. Asia/Karachi', ...rest.ui },
135
+ hooks: {
136
+ ...hooks,
137
+ validate: async (args: any) => {
138
+ // Preserve any user-supplied validate hook (function or { create, update, delete } form).
139
+ if (typeof userValidate === 'function') await userValidate(args)
140
+ else await userValidate?.[args.operation as 'create' | 'update' | 'delete']?.(args)
141
+
142
+ const { resolvedData, fieldKey, addValidationError, operation } = args
143
+ if (operation === 'delete') return
144
+ const value = resolvedData[fieldKey]
145
+ if (typeof value === 'string' && value.length > 0 && !isValidTimezone(value)) {
146
+ addValidationError('Must be a valid IANA timezone (e.g. "Asia/Karachi")')
147
+ }
148
+ },
149
+ },
150
+ })
151
+ }