diff --git a/packages/cli/e2e/__tests__/fixtures/deploy-project/maintenance-window.check.ts b/packages/cli/e2e/__tests__/fixtures/deploy-project/maintenance-window.check.ts index 5d81f480c..53575b771 100644 --- a/packages/cli/e2e/__tests__/fixtures/deploy-project/maintenance-window.check.ts +++ b/packages/cli/e2e/__tests__/fixtures/deploy-project/maintenance-window.check.ts @@ -8,4 +8,7 @@ new MaintenanceWindow('maintenance-window-1', { repeatInterval: 1, repeatUnit: 'MONTH', repeatEndsAt: new Date(new Date().valueOf() + (2160 * 60 * 60 * 1000)), // ~three months from now + // The window starts now, so it is active when redeployed; the API rejects timezone changes on an + // active window, so this value must stay the same across deploys. + timezone: 'Europe/Berlin', }) diff --git a/packages/cli/src/ai-context/context.fixtures.json b/packages/cli/src/ai-context/context.fixtures.json index 5f0652821..765a69b19 100644 --- a/packages/cli/src/ai-context/context.fixtures.json +++ b/packages/cli/src/ai-context/context.fixtures.json @@ -900,6 +900,12 @@ "repeatInterval": 1, "repeatUnit": "WEEK", "repeatEndsAt": "2025-08-01T00:00:00.000Z", + "timezone": "America/New_York", + "pauseAllChecks": false, + "silenceAlertsTags": [ + "app:webshop" + ], + "silenceAllAlerts": false, "created_at": "2025-07-07T08:43:03.466Z", "updated_at": "2025-07-07T08:43:03.465Z" } diff --git a/packages/cli/src/ai-context/references/configure-supporting-constructs.md b/packages/cli/src/ai-context/references/configure-supporting-constructs.md index 7095303e0..1f76479ba 100644 --- a/packages/cli/src/ai-context/references/configure-supporting-constructs.md +++ b/packages/cli/src/ai-context/references/configure-supporting-constructs.md @@ -78,8 +78,8 @@ new StatusPageV3AutomationRule('example-api-down-rule', { ## Maintenance Window - Import the `MaintenanceWindow` construct from `checkly/constructs`. -- Maintenance windows are used to pause checks during maintenance periods so no alerts are sent. -- Checks are referenced by their tags in the `tags` property. +- Maintenance windows pause checks (`tags` or `pauseAllChecks`) and/or silence their alerts while the checks keep running (`silenceAlertsTags` or `silenceAllAlerts`). +- Set `timezone` (an IANA name such as `America/New_York`) so recurring windows keep their local time. diff --git a/packages/cli/src/constructs/__tests__/maintenance-window-codegen.spec.ts b/packages/cli/src/constructs/__tests__/maintenance-window-codegen.spec.ts new file mode 100644 index 000000000..0409ce450 --- /dev/null +++ b/packages/cli/src/constructs/__tests__/maintenance-window-codegen.spec.ts @@ -0,0 +1,87 @@ +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' + +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { MaintenanceWindowCodegen, type MaintenanceWindowResource } from '../maintenance-window-codegen.js' +import { Context } from '../internal/codegen/index.js' +import { Program } from '../../sourcegen/index.js' + +describe('MaintenanceWindowCodegen', () => { + let rootDirectory: string + + beforeEach(async () => { + rootDirectory = await mkdtemp(path.join(tmpdir(), 'maintenance-window-codegen-')) + }) + + afterEach(async () => { + await rm(rootDirectory, { recursive: true, force: true }) + }) + + async function generate (resource: MaintenanceWindowResource): Promise { + const program = new Program({ + rootDirectory, + constructFileSuffix: '.check', + specFileSuffix: '.spec', + language: 'typescript', + }) + const codegen = new MaintenanceWindowCodegen(program) + const context = new Context() + + codegen.prepare('maintenance-window', resource, context) + codegen.gencode('maintenance-window', resource, context) + await program.realize() + + const [filePath] = program.paths + if (filePath === undefined) { + throw new Error('Codegen did not register a generated file') + } + return readFile(filePath, 'utf8') + } + + const baseResource: MaintenanceWindowResource = { + name: 'Weekly database maintenance', + tags: ['database'], + startsAt: '2030-01-01T09:00:00.000Z', + endsAt: '2030-01-01T10:00:00.000Z', + } + + it('emits the timezone and the pause and silence scope', async () => { + const source = await generate({ + ...baseResource, + timezone: 'Europe/Berlin', + pauseAllChecks: true, + silenceAlertsTags: ['api'], + silenceAllAlerts: true, + }) + + expect(source).toContain(`timezone: 'Europe/Berlin'`) + expect(source).toContain(`pauseAllChecks: true`) + expect(source).toContain(`silenceAlertsTags: [`) + expect(source).toContain(`silenceAllAlerts: true`) + }) + + it('omits unset and default values', async () => { + const source = await generate({ + ...baseResource, + timezone: null, + pauseAllChecks: false, + silenceAlertsTags: [], + silenceAllAlerts: false, + }) + + expect(source).toContain(`tags: [`) + expect(source).not.toContain('timezone') + expect(source).not.toContain('pauseAllChecks') + expect(source).not.toContain('silenceAlertsTags') + expect(source).not.toContain('silenceAllAlerts') + }) + + it('omits an empty tag list', async () => { + const source = await generate({ ...baseResource, tags: [], pauseAllChecks: true }) + + expect(source).not.toContain('tags') + expect(source).toContain(`pauseAllChecks: true`) + }) +}) diff --git a/packages/cli/src/constructs/__tests__/maintenance-window.spec.ts b/packages/cli/src/constructs/__tests__/maintenance-window.spec.ts new file mode 100644 index 000000000..da16551a0 --- /dev/null +++ b/packages/cli/src/constructs/__tests__/maintenance-window.spec.ts @@ -0,0 +1,119 @@ +import { beforeEach, describe, expect, it } from 'vitest' + +import { Diagnostics, KNOWN_TIME_ZONES, MaintenanceWindow, MaintenanceWindowProps } from '../index.js' +import { Project } from '../project.js' +import { Session } from '../session.js' + +const baseProps: MaintenanceWindowProps = { + name: 'Weekly database maintenance', + tags: ['database'], + startsAt: new Date('2030-01-01T09:00:00.000Z'), + endsAt: new Date('2030-01-01T10:00:00.000Z'), + repeatInterval: 1, + repeatUnit: 'WEEK', +} + +async function validate (props: MaintenanceWindowProps): Promise { + const window = new MaintenanceWindow('maintenance-window', props) + const diagnostics = new Diagnostics() + await window.validate(diagnostics) + return diagnostics +} + +describe('MaintenanceWindow', () => { + beforeEach(() => { + Session.project = new Project('project-id', { + name: 'Test Project', + repoUrl: 'https://github.com/checkly/checkly-cli', + }) + }) + + describe('synthesize', () => { + it('sends a null timezone when none is set, so a removed timezone resets to UTC', () => { + const window = new MaintenanceWindow('maintenance-window', baseProps) + expect(window.synthesize()).toHaveProperty('timezone', null) + }) + + it('sends the configured timezone', () => { + const window = new MaintenanceWindow('maintenance-window', { ...baseProps, timezone: 'Europe/Berlin' }) + expect(window.synthesize()).toHaveProperty('timezone', 'Europe/Berlin') + }) + + it('sends the pause and silence scope', () => { + const window = new MaintenanceWindow('maintenance-window', { + ...baseProps, + tags: undefined, + pauseAllChecks: true, + silenceAlertsTags: ['api'], + silenceAllAlerts: false, + }) + expect(window.synthesize()).toMatchObject({ + pauseAllChecks: true, + silenceAlertsTags: ['api'], + silenceAllAlerts: false, + }) + }) + }) + + describe('validate', () => { + it.each(['America/New_York', 'UTC', 'Europe/Kyiv', 'Asia/Calcutta', 'Etc/GMT+0', 'Etc/GMT-0'])('accepts %s', async timezone => { + const diagnostics = await validate({ ...baseProps, timezone }) + expect(diagnostics.isFatal()).toBe(false) + }) + + it.each(['+05:00', '-0300', 'Etc/GMT+5', 'Etc/GMT-14', 'Not/AZone'])('rejects %s', async timezone => { + const diagnostics = await validate({ ...baseProps, timezone }) + expect(diagnostics.isFatal()).toBe(true) + expect(diagnostics.observations).toEqual(expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining('"timezone" must be a named IANA time zone'), + }), + ])) + }) + + it('does not require a timezone', async () => { + const diagnostics = await validate(baseProps) + expect(diagnostics.isFatal()).toBe(false) + }) + + it('warns when the window neither pauses checks nor silences alerts', async () => { + const diagnostics = await validate({ ...baseProps, tags: undefined }) + expect(diagnostics.isFatal()).toBe(false) + expect(diagnostics.observations).toEqual(expect.arrayContaining([ + expect.objectContaining({ title: 'Maintenance window affects no checks' }), + ])) + }) + + it.each>([ + { tags: ['database'] }, + { tags: undefined, pauseAllChecks: true }, + { tags: undefined, silenceAlertsTags: ['api'] }, + { tags: undefined, silenceAllAlerts: true }, + ])('does not warn when a scope is set: %o', async scope => { + const diagnostics = await validate({ ...baseProps, ...scope }) + expect(diagnostics.observations).toEqual([]) + }) + }) + + describe('KNOWN_TIME_ZONES', () => { + // validate() trusts listed names without consulting Intl, so the list itself must be well formed. + // Whether the running Node recognizes each name is not asserted: older Node releases bundle older + // tzdata and legitimately lack recently added zones. + it('is a sorted, duplicate-free list of named zones', () => { + expect([...KNOWN_TIME_ZONES].sort()).toEqual(KNOWN_TIME_ZONES) + expect(new Set(KNOWN_TIME_ZONES).size).toBe(KNOWN_TIME_ZONES.length) + expect(KNOWN_TIME_ZONES).toContain('UTC') + const malformed = KNOWN_TIME_ZONES.filter(timeZone => + timeZone !== 'UTC' && !/^[A-Z][A-Za-z]+(\/[A-Z][A-Za-z0-9_+-]*){1,2}$/.test(timeZone)) + expect(malformed).toEqual([]) + }) + + it('contains no names that validate() would reject', async () => { + const diagnostics = new Diagnostics() + for (const timezone of KNOWN_TIME_ZONES) { + await new MaintenanceWindow(`maintenance-window-${timezone}`, { ...baseProps, timezone }).validate(diagnostics) + } + expect(diagnostics.isFatal()).toBe(false) + }) + }) +}) diff --git a/packages/cli/src/constructs/index.ts b/packages/cli/src/constructs/index.ts index f4622814d..90a06f725 100644 --- a/packages/cli/src/constructs/index.ts +++ b/packages/cli/src/constructs/index.ts @@ -20,6 +20,7 @@ export * from './http-request.js' export * from './query-param.js' export * from './frequency.js' export * from './maintenance-window.js' +export { KNOWN_TIME_ZONES, type KnownTimeZone, type TimeZone } from './time-zone.js' export * from './alert-channel.js' export * from './private-location.js' export * from './private-location-check-assignment.js' diff --git a/packages/cli/src/constructs/maintenance-window-codegen.ts b/packages/cli/src/constructs/maintenance-window-codegen.ts index a79d38ff1..2d4790612 100644 --- a/packages/cli/src/constructs/maintenance-window-codegen.ts +++ b/packages/cli/src/constructs/maintenance-window-codegen.ts @@ -9,6 +9,10 @@ export interface MaintenanceWindowResource { repeatInterval?: number | null repeatUnit?: string repeatEndsAt?: string + timezone?: string | null + pauseAllChecks?: boolean + silenceAlertsTags?: Array + silenceAllAlerts?: boolean } const construct = 'MaintenanceWindow' @@ -33,11 +37,13 @@ export class MaintenanceWindowCodegen extends Codegen builder.object(builder => { builder.string('name', resource.name) - builder.array('tags', builder => { - for (const tag of resource.tags) { - builder.string(tag) - } - }) + if (resource.tags?.length) { + builder.array('tags', builder => { + for (const tag of resource.tags) { + builder.string(tag) + } + }) + } builder.expr('startsAt', ident('Date'), builder => { builder.new(builder => { @@ -67,6 +73,29 @@ export class MaintenanceWindowCodegen extends Codegen }) }) } + + if (resource.timezone) { + builder.string('timezone', resource.timezone) + } + + // The default value for pauseAllChecks is false, only include if true. + if (resource.pauseAllChecks !== undefined && resource.pauseAllChecks) { + builder.boolean('pauseAllChecks', resource.pauseAllChecks) + } + + if (resource.silenceAlertsTags?.length) { + const silenceAlertsTags = resource.silenceAlertsTags + builder.array('silenceAlertsTags', builder => { + for (const tag of silenceAlertsTags) { + builder.string(tag) + } + }) + } + + // The default value for silenceAllAlerts is false, only include if true. + if (resource.silenceAllAlerts !== undefined && resource.silenceAllAlerts) { + builder.boolean('silenceAllAlerts', resource.silenceAllAlerts) + } }) }) })) diff --git a/packages/cli/src/constructs/maintenance-window.ts b/packages/cli/src/constructs/maintenance-window.ts index 87ffa4f4b..ce1b64cbd 100644 --- a/packages/cli/src/constructs/maintenance-window.ts +++ b/packages/cli/src/constructs/maintenance-window.ts @@ -1,5 +1,8 @@ import { Construct } from './construct.js' +import { InvalidPropertyValueDiagnostic } from './construct-diagnostics.js' +import { Diagnostics, WarningDiagnostic } from './diagnostics.js' import { Session } from './session.js' +import { isValidTimeZone, TimeZone } from './time-zone.js' export type MaintenanceWindowRepeatUnit = 'DAY' | 'WEEK' | 'MONTH' @@ -9,9 +12,10 @@ export interface MaintenanceWindowProps { */ name: string /** - * A list of one or more tags that filter which checks are affected by the maintenance window. + * Tags that select which checks are paused during the maintenance window. + * A window must set at least one of `tags`, `pauseAllChecks`, `silenceAlertsTags` or `silenceAllAlerts`. */ - tags: Array + tags?: Array /** * The start date and time of the maintenance window in ISO 8601 format, "YYYY-MM-DDTHH:mm:ss.sssZ" as returned by * `new Date()` @@ -34,6 +38,30 @@ export interface MaintenanceWindowProps { * The end date and time when the maintenance window should stop repeating. */ repeatEndsAt?: Date + /** + * The named IANA time zone used to schedule recurring occurrences, e.g. `'America/New_York'`. + * Occurrences keep the same local time across daylight-saving changes. + * + * `startsAt` and `endsAt` remain absolute instants; the time zone does not reinterpret them. + * UTC offsets such as `'+05:00'` or `'Etc/GMT+5'` are not accepted. When omitted, the window is + * scheduled in UTC, and removing a previously set time zone resets the window to UTC on the next + * deploy. The time zone cannot be changed while a maintenance is active. + */ + timezone?: TimeZone + /** + * When true, checks are paused for every check in the account, regardless of `tags`. + */ + pauseAllChecks?: boolean + /** + * A list of tags that filter which checks have their alerts silenced. Ignored when + * `silenceAllAlerts` is true. + */ + silenceAlertsTags?: Array + /** + * When true, alerts are silenced for every check in the account, overriding + * `silenceAlertsTags`. + */ + silenceAllAlerts?: boolean } /** @@ -45,12 +73,16 @@ export interface MaintenanceWindowProps { */ export class MaintenanceWindow extends Construct { name: string - tags: Array + tags?: Array startsAt: Date endsAt: Date repeatInterval?: number repeatUnit?: MaintenanceWindowRepeatUnit repeatEndsAt?: Date + timezone?: TimeZone + pauseAllChecks?: boolean + silenceAlertsTags?: Array + silenceAllAlerts?: boolean static readonly __checklyType = 'maintenance-window' @@ -71,6 +103,10 @@ export class MaintenanceWindow extends Construct { this.repeatInterval = props.repeatInterval this.repeatUnit = props.repeatUnit this.repeatEndsAt = props.repeatEndsAt + this.timezone = props.timezone + this.pauseAllChecks = props.pauseAllChecks + this.silenceAlertsTags = props.silenceAlertsTags + this.silenceAllAlerts = props.silenceAllAlerts Session.registerConstruct(this) } @@ -78,6 +114,32 @@ export class MaintenanceWindow extends Construct { return `MaintenanceWindow:${this.logicalId}` } + async validate (diagnostics: Diagnostics): Promise { + await super.validate(diagnostics) + + if (this.timezone !== undefined && !isValidTimeZone(this.timezone)) { + diagnostics.add(new InvalidPropertyValueDiagnostic( + 'timezone', + new Error( + `"timezone" must be a named IANA time zone such as "America/New_York", got "${this.timezone}".` + + ` UTC offsets such as "+05:00" or "Etc/GMT+5" are not supported.`, + ), + )) + } + + const pausesChecks = this.pauseAllChecks || !!this.tags?.length + const silencesAlerts = this.silenceAllAlerts || !!this.silenceAlertsTags?.length + if (!pausesChecks && !silencesAlerts) { + diagnostics.add(new WarningDiagnostic({ + title: 'Maintenance window affects no checks', + message: + `Maintenance window "${this.logicalId}" neither pauses checks nor silences alerts. ` + + `Set "tags" or "pauseAllChecks" to pause checks, or "silenceAlertsTags" or "silenceAllAlerts" ` + + `to silence alerts.`, + })) + } + } + synthesize (): any | null { return { name: this.name, @@ -87,6 +149,11 @@ export class MaintenanceWindow extends Construct { repeatInterval: this.repeatInterval, repeatUnit: this.repeatUnit, repeatEndsAt: this.repeatEndsAt, + // An omitted timezone keeps the stored value on update, so null is sent to reset it to UTC. + timezone: this.timezone ?? null, + pauseAllChecks: this.pauseAllChecks, + silenceAlertsTags: this.silenceAlertsTags, + silenceAllAlerts: this.silenceAllAlerts, } } } diff --git a/packages/cli/src/constructs/time-zone.ts b/packages/cli/src/constructs/time-zone.ts new file mode 100644 index 000000000..ac7ca3c59 --- /dev/null +++ b/packages/cli/src/constructs/time-zone.ts @@ -0,0 +1,469 @@ +/** + * Canonical IANA time zone names, taken from the `zone.tab` file of tzdata 2026c plus `UTC`. + * + * The list uses current IANA names (e.g. `Europe/Kyiv`, `Asia/Kolkata`) rather than the legacy + * CLDR identifiers that `Intl.supportedValuesOf('timeZone')` returns (e.g. `Europe/Kiev`), because + * the Checkly API stores the current names. Refresh it from a newer tzdata release when zones are added: + * take the third column of `zone.tab`, add `UTC`, sort, and update the release named above. + */ +export const KNOWN_TIME_ZONES = [ + 'Africa/Abidjan', + 'Africa/Accra', + 'Africa/Addis_Ababa', + 'Africa/Algiers', + 'Africa/Asmara', + 'Africa/Bamako', + 'Africa/Bangui', + 'Africa/Banjul', + 'Africa/Bissau', + 'Africa/Blantyre', + 'Africa/Brazzaville', + 'Africa/Bujumbura', + 'Africa/Cairo', + 'Africa/Casablanca', + 'Africa/Ceuta', + 'Africa/Conakry', + 'Africa/Dakar', + 'Africa/Dar_es_Salaam', + 'Africa/Djibouti', + 'Africa/Douala', + 'Africa/El_Aaiun', + 'Africa/Freetown', + 'Africa/Gaborone', + 'Africa/Harare', + 'Africa/Johannesburg', + 'Africa/Juba', + 'Africa/Kampala', + 'Africa/Khartoum', + 'Africa/Kigali', + 'Africa/Kinshasa', + 'Africa/Lagos', + 'Africa/Libreville', + 'Africa/Lome', + 'Africa/Luanda', + 'Africa/Lubumbashi', + 'Africa/Lusaka', + 'Africa/Malabo', + 'Africa/Maputo', + 'Africa/Maseru', + 'Africa/Mbabane', + 'Africa/Mogadishu', + 'Africa/Monrovia', + 'Africa/Nairobi', + 'Africa/Ndjamena', + 'Africa/Niamey', + 'Africa/Nouakchott', + 'Africa/Ouagadougou', + 'Africa/Porto-Novo', + 'Africa/Sao_Tome', + 'Africa/Tripoli', + 'Africa/Tunis', + 'Africa/Windhoek', + 'America/Adak', + 'America/Anchorage', + 'America/Anguilla', + 'America/Antigua', + 'America/Araguaina', + 'America/Argentina/Buenos_Aires', + 'America/Argentina/Catamarca', + 'America/Argentina/Cordoba', + 'America/Argentina/Jujuy', + 'America/Argentina/La_Rioja', + 'America/Argentina/Mendoza', + 'America/Argentina/Rio_Gallegos', + 'America/Argentina/Salta', + 'America/Argentina/San_Juan', + 'America/Argentina/San_Luis', + 'America/Argentina/Tucuman', + 'America/Argentina/Ushuaia', + 'America/Aruba', + 'America/Asuncion', + 'America/Atikokan', + 'America/Bahia', + 'America/Bahia_Banderas', + 'America/Barbados', + 'America/Belem', + 'America/Belize', + 'America/Blanc-Sablon', + 'America/Boa_Vista', + 'America/Bogota', + 'America/Boise', + 'America/Cambridge_Bay', + 'America/Campo_Grande', + 'America/Cancun', + 'America/Caracas', + 'America/Cayenne', + 'America/Cayman', + 'America/Chicago', + 'America/Chihuahua', + 'America/Ciudad_Juarez', + 'America/Costa_Rica', + 'America/Coyhaique', + 'America/Creston', + 'America/Cuiaba', + 'America/Curacao', + 'America/Danmarkshavn', + 'America/Dawson', + 'America/Dawson_Creek', + 'America/Denver', + 'America/Detroit', + 'America/Dominica', + 'America/Edmonton', + 'America/Eirunepe', + 'America/El_Salvador', + 'America/Fort_Nelson', + 'America/Fortaleza', + 'America/Glace_Bay', + 'America/Goose_Bay', + 'America/Grand_Turk', + 'America/Grenada', + 'America/Guadeloupe', + 'America/Guatemala', + 'America/Guayaquil', + 'America/Guyana', + 'America/Halifax', + 'America/Havana', + 'America/Hermosillo', + 'America/Indiana/Indianapolis', + 'America/Indiana/Knox', + 'America/Indiana/Marengo', + 'America/Indiana/Petersburg', + 'America/Indiana/Tell_City', + 'America/Indiana/Vevay', + 'America/Indiana/Vincennes', + 'America/Indiana/Winamac', + 'America/Inuvik', + 'America/Iqaluit', + 'America/Jamaica', + 'America/Juneau', + 'America/Kentucky/Louisville', + 'America/Kentucky/Monticello', + 'America/Kralendijk', + 'America/La_Paz', + 'America/Lima', + 'America/Los_Angeles', + 'America/Lower_Princes', + 'America/Maceio', + 'America/Managua', + 'America/Manaus', + 'America/Marigot', + 'America/Martinique', + 'America/Matamoros', + 'America/Mazatlan', + 'America/Menominee', + 'America/Merida', + 'America/Metlakatla', + 'America/Mexico_City', + 'America/Miquelon', + 'America/Moncton', + 'America/Monterrey', + 'America/Montevideo', + 'America/Montserrat', + 'America/Nassau', + 'America/New_York', + 'America/Nome', + 'America/Noronha', + 'America/North_Dakota/Beulah', + 'America/North_Dakota/Center', + 'America/North_Dakota/New_Salem', + 'America/Nuuk', + 'America/Ojinaga', + 'America/Panama', + 'America/Paramaribo', + 'America/Phoenix', + 'America/Port-au-Prince', + 'America/Port_of_Spain', + 'America/Porto_Velho', + 'America/Puerto_Rico', + 'America/Punta_Arenas', + 'America/Rankin_Inlet', + 'America/Recife', + 'America/Regina', + 'America/Resolute', + 'America/Rio_Branco', + 'America/Santarem', + 'America/Santiago', + 'America/Santo_Domingo', + 'America/Sao_Paulo', + 'America/Scoresbysund', + 'America/Sitka', + 'America/St_Barthelemy', + 'America/St_Johns', + 'America/St_Kitts', + 'America/St_Lucia', + 'America/St_Thomas', + 'America/St_Vincent', + 'America/Swift_Current', + 'America/Tegucigalpa', + 'America/Thule', + 'America/Tijuana', + 'America/Toronto', + 'America/Tortola', + 'America/Vancouver', + 'America/Whitehorse', + 'America/Winnipeg', + 'America/Yakutat', + 'Antarctica/Casey', + 'Antarctica/Davis', + 'Antarctica/DumontDUrville', + 'Antarctica/Macquarie', + 'Antarctica/Mawson', + 'Antarctica/McMurdo', + 'Antarctica/Palmer', + 'Antarctica/Rothera', + 'Antarctica/Syowa', + 'Antarctica/Troll', + 'Antarctica/Vostok', + 'Arctic/Longyearbyen', + 'Asia/Aden', + 'Asia/Almaty', + 'Asia/Amman', + 'Asia/Anadyr', + 'Asia/Aqtau', + 'Asia/Aqtobe', + 'Asia/Ashgabat', + 'Asia/Atyrau', + 'Asia/Baghdad', + 'Asia/Bahrain', + 'Asia/Baku', + 'Asia/Bangkok', + 'Asia/Barnaul', + 'Asia/Beirut', + 'Asia/Bishkek', + 'Asia/Brunei', + 'Asia/Chita', + 'Asia/Colombo', + 'Asia/Damascus', + 'Asia/Dhaka', + 'Asia/Dili', + 'Asia/Dubai', + 'Asia/Dushanbe', + 'Asia/Famagusta', + 'Asia/Gaza', + 'Asia/Hebron', + 'Asia/Ho_Chi_Minh', + 'Asia/Hong_Kong', + 'Asia/Hovd', + 'Asia/Irkutsk', + 'Asia/Jakarta', + 'Asia/Jayapura', + 'Asia/Jerusalem', + 'Asia/Kabul', + 'Asia/Kamchatka', + 'Asia/Karachi', + 'Asia/Kathmandu', + 'Asia/Khandyga', + 'Asia/Kolkata', + 'Asia/Krasnoyarsk', + 'Asia/Kuala_Lumpur', + 'Asia/Kuching', + 'Asia/Kuwait', + 'Asia/Macau', + 'Asia/Magadan', + 'Asia/Makassar', + 'Asia/Manila', + 'Asia/Muscat', + 'Asia/Nicosia', + 'Asia/Novokuznetsk', + 'Asia/Novosibirsk', + 'Asia/Omsk', + 'Asia/Oral', + 'Asia/Phnom_Penh', + 'Asia/Pontianak', + 'Asia/Pyongyang', + 'Asia/Qatar', + 'Asia/Qostanay', + 'Asia/Qyzylorda', + 'Asia/Riyadh', + 'Asia/Sakhalin', + 'Asia/Samarkand', + 'Asia/Seoul', + 'Asia/Shanghai', + 'Asia/Singapore', + 'Asia/Srednekolymsk', + 'Asia/Taipei', + 'Asia/Tashkent', + 'Asia/Tbilisi', + 'Asia/Tehran', + 'Asia/Thimphu', + 'Asia/Tokyo', + 'Asia/Tomsk', + 'Asia/Ulaanbaatar', + 'Asia/Urumqi', + 'Asia/Ust-Nera', + 'Asia/Vientiane', + 'Asia/Vladivostok', + 'Asia/Yakutsk', + 'Asia/Yangon', + 'Asia/Yekaterinburg', + 'Asia/Yerevan', + 'Atlantic/Azores', + 'Atlantic/Bermuda', + 'Atlantic/Canary', + 'Atlantic/Cape_Verde', + 'Atlantic/Faroe', + 'Atlantic/Madeira', + 'Atlantic/Reykjavik', + 'Atlantic/South_Georgia', + 'Atlantic/St_Helena', + 'Atlantic/Stanley', + 'Australia/Adelaide', + 'Australia/Brisbane', + 'Australia/Broken_Hill', + 'Australia/Darwin', + 'Australia/Eucla', + 'Australia/Hobart', + 'Australia/Lindeman', + 'Australia/Lord_Howe', + 'Australia/Melbourne', + 'Australia/Perth', + 'Australia/Sydney', + 'Europe/Amsterdam', + 'Europe/Andorra', + 'Europe/Astrakhan', + 'Europe/Athens', + 'Europe/Belgrade', + 'Europe/Berlin', + 'Europe/Bratislava', + 'Europe/Brussels', + 'Europe/Bucharest', + 'Europe/Budapest', + 'Europe/Busingen', + 'Europe/Chisinau', + 'Europe/Copenhagen', + 'Europe/Dublin', + 'Europe/Gibraltar', + 'Europe/Guernsey', + 'Europe/Helsinki', + 'Europe/Isle_of_Man', + 'Europe/Istanbul', + 'Europe/Jersey', + 'Europe/Kaliningrad', + 'Europe/Kirov', + 'Europe/Kyiv', + 'Europe/Lisbon', + 'Europe/Ljubljana', + 'Europe/London', + 'Europe/Luxembourg', + 'Europe/Madrid', + 'Europe/Malta', + 'Europe/Mariehamn', + 'Europe/Minsk', + 'Europe/Monaco', + 'Europe/Moscow', + 'Europe/Oslo', + 'Europe/Paris', + 'Europe/Podgorica', + 'Europe/Prague', + 'Europe/Riga', + 'Europe/Rome', + 'Europe/Samara', + 'Europe/San_Marino', + 'Europe/Sarajevo', + 'Europe/Saratov', + 'Europe/Simferopol', + 'Europe/Skopje', + 'Europe/Sofia', + 'Europe/Stockholm', + 'Europe/Tallinn', + 'Europe/Tirane', + 'Europe/Ulyanovsk', + 'Europe/Vaduz', + 'Europe/Vatican', + 'Europe/Vienna', + 'Europe/Vilnius', + 'Europe/Volgograd', + 'Europe/Warsaw', + 'Europe/Zagreb', + 'Europe/Zurich', + 'Indian/Antananarivo', + 'Indian/Chagos', + 'Indian/Christmas', + 'Indian/Cocos', + 'Indian/Comoro', + 'Indian/Kerguelen', + 'Indian/Mahe', + 'Indian/Maldives', + 'Indian/Mauritius', + 'Indian/Mayotte', + 'Indian/Reunion', + 'Pacific/Apia', + 'Pacific/Auckland', + 'Pacific/Bougainville', + 'Pacific/Chatham', + 'Pacific/Chuuk', + 'Pacific/Easter', + 'Pacific/Efate', + 'Pacific/Fakaofo', + 'Pacific/Fiji', + 'Pacific/Funafuti', + 'Pacific/Galapagos', + 'Pacific/Gambier', + 'Pacific/Guadalcanal', + 'Pacific/Guam', + 'Pacific/Honolulu', + 'Pacific/Kanton', + 'Pacific/Kiritimati', + 'Pacific/Kosrae', + 'Pacific/Kwajalein', + 'Pacific/Majuro', + 'Pacific/Marquesas', + 'Pacific/Midway', + 'Pacific/Nauru', + 'Pacific/Niue', + 'Pacific/Norfolk', + 'Pacific/Noumea', + 'Pacific/Pago_Pago', + 'Pacific/Palau', + 'Pacific/Pitcairn', + 'Pacific/Pohnpei', + 'Pacific/Port_Moresby', + 'Pacific/Rarotonga', + 'Pacific/Saipan', + 'Pacific/Tahiti', + 'Pacific/Tarawa', + 'Pacific/Tongatapu', + 'Pacific/Wake', + 'Pacific/Wallis', + 'UTC', +] as const + +/** + * A canonical IANA time zone name. + */ +export type KnownTimeZone = typeof KNOWN_TIME_ZONES[number] + +/** + * A named IANA time zone, e.g. `'America/New_York'`. + * + * Known names are suggested by editors, but any string is accepted so that zones added in newer + * tzdata releases can be used before this list is refreshed. + */ +export type TimeZone = KnownTimeZone | (string & {}) + +// UTC offsets are not IANA zone names and the API rejects them: "+05:00" style identifiers and the +// fixed-offset Etc/GMT±N zones, which have no daylight-saving rules. Etc/GMT+0 and Etc/GMT-0 are +// aliases of UTC and are accepted. +const FIXED_OFFSET_TIME_ZONE_PATTERN = /^[+-]|^Etc\/GMT[+-]0*[1-9]\d?$/i + +const knownTimeZones: ReadonlySet = new Set(KNOWN_TIME_ZONES) + +/** + * Whether a value is a named time zone the Checkly API accepts. UTC offsets are rejected. + */ +export function isValidTimeZone (timeZone: string): boolean { + if (FIXED_OFFSET_TIME_ZONE_PATTERN.test(timeZone)) { + return false + } + // Listed names skip the Intl check: older Node releases bundle older tzdata and would reject newer + // zones that the list offers. The API remains the final authority either way. + if (knownTimeZones.has(timeZone)) { + return true + } + try { + new Intl.DateTimeFormat('en-US', { timeZone }) + return true + } catch { + return false + } +}