@yext/phonenumber-util 0.3.0 → 0.4.1

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.
@@ -0,0 +1,37 @@
1
+ name: Publish to NPM
2
+
3
+ permissions:
4
+ contents: read
5
+ id-token: write
6
+
7
+ on:
8
+ workflow_dispatch:
9
+
10
+ jobs:
11
+ publish:
12
+ environment: Release
13
+ name: Publish to NPM
14
+ runs-on: ubuntu-latest
15
+
16
+ steps:
17
+ - name: Checkout code
18
+ uses: actions/checkout@v4
19
+
20
+ - name: Setup Node.js
21
+ uses: actions/setup-node@v4
22
+ with:
23
+ node-version: '24'
24
+ cache: 'npm'
25
+ registry-url: 'https://registry.npmjs.org'
26
+
27
+ - name: Install dependencies
28
+ run: npm ci
29
+
30
+ - name: Run tests
31
+ run: npm test
32
+
33
+ - name: Run linting
34
+ run: npm run lint
35
+
36
+ - name: Publish to NPM
37
+ run: npm publish --provenance --access public
@@ -1,5 +1,8 @@
1
1
  name: Run pull request checks
2
2
 
3
+ permissions:
4
+ contents: read
5
+
3
6
  on:
4
7
  pull_request:
5
8
  branches: ["main"]
@@ -12,6 +15,9 @@ jobs:
12
15
  steps:
13
16
  - uses: actions/checkout@v4
14
17
  - uses: actions/setup-node@v4
18
+ with:
19
+ node-version: '18'
20
+ cache: 'npm'
15
21
  - run: npm ci
16
22
  - run: npm run lint
17
23
  - run: npm test
package/.nvmrc CHANGED
@@ -1 +1 @@
1
- 22
1
+ 24
package/CODEOWNERS CHANGED
@@ -1,2 +1,2 @@
1
1
  # The following aliases have approval permissions in this repo:
2
- * @imbrianj @mi4l @DerekWW @jperezhearsay @Bearbar00008
2
+ * @imbrianj @mi4l @DerekWW @jperezhearsay @Bearbar00008 @jocruz-yext
package/README.md CHANGED
@@ -39,6 +39,85 @@ npm test
39
39
  npx vitest run --coverage
40
40
  ```
41
41
 
42
+ #### Release Process
43
+
44
+ This package is automatically published to NPM via GitHub Actions when a new version tag is pushed.
45
+
46
+ To publish a new version:
47
+
48
+ 1. **Bump the version** using one of these commands:
49
+
50
+ ```bash
51
+ npm run version:patch # For bug fixes (0.4.0 → 0.4.1)
52
+ npm run version:minor # For new features (0.4.0 → 0.5.0)
53
+ npm run version:major # For breaking changes (0.4.0 → 1.0.0)
54
+ ```
55
+
56
+ 2. **Push the changes and tags**:
57
+
58
+ ```bash
59
+ npm run release
60
+ ```
61
+
62
+ This will run tests, linting, and push both commits and tags to GitHub.
63
+
64
+ 3. **GitHub Actions will automatically**:
65
+ - Run tests and linting
66
+ - Publish to NPM with provenance
67
+ - Use OIDC authentication for secure publishing
68
+
69
+ ### ⚠️ **SECURITY WARNING**
70
+
71
+ **The `rawNumber` field contains UNSANITIZED user input and poses XSS risks if displayed in web applications.**
72
+
73
+ #### 🚨 **NEVER do this:**
74
+
75
+ ```javascript
76
+ // DANGEROUS - Direct HTML insertion
77
+ document.getElementById('phone').innerHTML = result.rawNumber;
78
+
79
+ // DANGEROUS - Template literal insertion
80
+ element.innerHTML = `Call ${result.rawNumber}`;
81
+
82
+ // DANGEROUS - React without escaping
83
+ return <div>{result.rawNumber}</div>;
84
+ ```
85
+
86
+ #### ✅ **Safe approaches:**
87
+
88
+ ```javascript
89
+ // SAFE - Use formatted version for display
90
+ document.getElementById('phone').textContent = result.formattedNumber;
91
+
92
+ // SAFE - Use rawNumber for string replacement/processing
93
+ const updatedText = originalText.replace(
94
+ result.rawNumber,
95
+ result.formattedNumber,
96
+ );
97
+
98
+ // SAFE - Escape before HTML insertion
99
+ document.getElementById('phone').innerHTML = escapeHtml(result.rawNumber);
100
+
101
+ // SAFE - React with proper text content
102
+ return <div>{result.formattedNumber}</div>;
103
+ ```
104
+
105
+ #### **Why `rawNumber` exists:**
106
+
107
+ The `rawNumber` field enables powerful text replacement functionality:
108
+
109
+ ```javascript
110
+ const text = 'Call me at (310) 555-1234 anytime!';
111
+ const numbers = findNumbersInString(text);
112
+ let updatedText = text;
113
+
114
+ numbers.forEach((phone) => {
115
+ // Replace original with formatted version
116
+ updatedText = updatedText.replace(phone.rawNumber, phone.formattedNumber);
117
+ });
118
+ // Result: "Call me at (310) 555-1234 anytime!" → formatted consistently
119
+ ```
120
+
42
121
  ### Warning
43
122
 
44
123
  The returned object will include a `rawNumber` value. This value is the return of the exact value passed to the function. No sanitization occurs with this value. If you reference this number, ensure you sanitize it _BEFORE_ passing to this function.
package/package.json CHANGED
@@ -1,9 +1,12 @@
1
1
  {
2
2
  "name": "@yext/phonenumber-util",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "author": "bajohnson@hearsaycorp.com",
5
5
  "license": "BSD-3-Clause",
6
6
  "description": "Utility for extracting and validating phone numbers",
7
+ "publishConfig": {
8
+ "access": "public"
9
+ },
7
10
  "type": "module",
8
11
  "main": "src",
9
12
  "exports": {
@@ -22,19 +25,23 @@
22
25
  "test": "TZ=America/Los_Angeles vitest",
23
26
  "build": "node -e \"console.log('No build script for vanilla JS')\"",
24
27
  "prepare": "husky",
25
- "make-badges": "istanbul-badges-readme"
28
+ "make-badges": "istanbul-badges-readme",
29
+ "version:patch": "npm version patch",
30
+ "version:minor": "npm version minor",
31
+ "version:major": "npm version major",
32
+ "release": "npm run test && npm run lint && git push && git push --tags"
26
33
  },
27
34
  "devDependencies": {
28
- "@eslint/js": "^9.28.0",
29
- "@vitest/coverage-v8": "^3.2.3",
30
- "eslint": "^9.28.0",
31
- "generate-license-file": "4.0.0",
32
- "globals": "^16.2.0",
35
+ "@eslint/js": "^9.38.0",
36
+ "@vitest/coverage-v8": "^4.0.2",
37
+ "eslint": "^9.38.0",
38
+ "generate-license-file": "^4.1.0",
39
+ "globals": "^16.4.0",
33
40
  "husky": "^9.1.7",
34
41
  "istanbul-badges-readme": "^1.9.0",
35
- "lint-staged": "^16.1.0",
36
- "prettier": "^3.5.3",
37
- "vitest": "^3.2.3"
42
+ "lint-staged": "^16.2.6",
43
+ "prettier": "^3.6.2",
44
+ "vitest": "^4.0.2"
38
45
  },
39
46
  "eslintConfig": {},
40
47
  "lint-staged": {
@@ -433,6 +433,11 @@ describe('Phone number pretty formatting', () => {
433
433
  regionCode: '47',
434
434
  format: findPhoneFormat({ regionCode: '47', e164: '+471740876543' }),
435
435
  },
436
+ egyptStringFormat: {
437
+ e164: '+201012345678',
438
+ regionCode: '20',
439
+ format: findPhoneFormat({ regionCode: '20', e164: '+201012345678' }),
440
+ },
436
441
  };
437
442
 
438
443
  expect(formatPhoneNumber(testNumbers.nullCase)).toBe(null);
@@ -443,5 +448,68 @@ describe('Phone number pretty formatting', () => {
443
448
  expect(formatPhoneNumber(testNumbers.norwayUnexpected)).toBe(
444
449
  '+47174087654',
445
450
  );
451
+ // Egypt has a string format (not array), this tests the else if (formatRaw)
452
+ expect(formatPhoneNumber(testNumbers.egyptStringFormat)).toBe(
453
+ '+20 101 234 5678',
454
+ );
455
+ });
456
+ });
457
+
458
+ describe('E.164 formatting edge cases', () => {
459
+ describe('formatPhoneNumberForE164 with missing required fields', () => {
460
+ it('should format US numbers with separate area code correctly', () => {
461
+ expect(
462
+ formatPhoneNumberForE164({
463
+ regionCode: '1',
464
+ areaCode: '310',
465
+ localNumber: '3496200',
466
+ }),
467
+ ).toBe('+13103496200');
468
+ });
469
+
470
+ it('should format international numbers without area code correctly', () => {
471
+ expect(
472
+ formatPhoneNumberForE164({
473
+ regionCode: '44',
474
+ areaCode: null,
475
+ localNumber: '2079460958',
476
+ }),
477
+ ).toBe('+442079460958');
478
+ });
479
+ });
480
+
481
+ describe('International number parsing with unknown region codes', () => {
482
+ it('should handle international numbers that do not match any known region code in the loop', () => {
483
+ // Test a number with + prefix and valid length but an unrecognized region code
484
+ // The parser should try all region code lengths (1, 2, 3 digits) but find no match in PHONE_FORMATS
485
+ const phoneParts = getPhoneParts('+00012345678');
486
+ // Should not have regionCode since +000, +00, or +0 are not valid region codes
487
+ expect(phoneParts.regionCode).toBeNull();
488
+ expect(phoneParts.localNumber).toBeNull();
489
+ });
490
+
491
+ it('should handle numbers that skip the international parsing else-if block entirely', () => {
492
+ // Test numbers with + prefix that exceed the maximum length for international number parsing
493
+ // The parser checks strippedPhoneNumber.length <= 14, so 16 chars (15 digits + plus sign) is too long
494
+ // This ensures the else-if branch is not entered, leaving regionCode and localNumber as null
495
+ const longNumber = getPhoneParts('+123456789012345'); // 15 digits, 16 chars total
496
+ expect(longNumber.regionCode).toBeNull();
497
+ expect(longNumber.localNumber).toBeNull();
498
+ });
499
+ });
500
+
501
+ describe('findNumbersInString with multiple phone numbers', () => {
502
+ it('should extract multiple valid phone numbers from a single string', () => {
503
+ const text = 'Call me at +1 (310) 349-6200 or +44 20 7946 0958.';
504
+ const results = findNumbersInString(text);
505
+ expect(results.length).toBe(2);
506
+ expect(results[0].e164).toBe('+13103496200');
507
+ expect(results[1].e164).toBe('+442079460958');
508
+ });
509
+
510
+ it('should return an empty array when no valid phone numbers are present', () => {
511
+ const text = 'No phone numbers here!';
512
+ expect(findNumbersInString(text)).toEqual([]);
513
+ });
446
514
  });
447
515
  });
@@ -5,7 +5,12 @@ import {
5
5
  findTimeDetails,
6
6
  findTimeFromAreaCode,
7
7
  findRegionFromRegionCode,
8
+ findAllNumbersInfoInString,
8
9
  } from '../geo.js';
10
+ import { AREA_CODE_LIST } from '../areaCodeList.js';
11
+ import { AREA_CODES, REGION_CODES } from '../phoneCodes.js';
12
+ import { PHONE_FORMATS } from '../phoneFormats.js';
13
+
9
14
  import { describe, it, expect } from 'vitest';
10
15
 
11
16
  const invalidPhone = {
@@ -184,6 +189,268 @@ const canadianPhone = {
184
189
  },
185
190
  };
186
191
 
192
+ const longStringWithNumbers = `
193
+ It was a cold, rainy evening when Sophie stumbled upon the weathered journal in the attic of her late grandfather's home. The journal's leather cover was cracked, its pages yellowed with time. Inside, however, was a web of mystery that would unravel Sophie's life over the coming weeks. The most intriguing part? Each entry ended with a phone number-a mix of US, Canadian, and international numbers-written in her grandfather's meticulous hand.
194
+ The first number, 617-555-0134, was scribbled below a cryptic entry about "the Boston job." Sophie's heart raced as she dialed the Massachusetts number. A gruff voice answered on the third ring. "This is Detective Harris. Who's calling?" Sophie's voice trembled as she explained the journal. Harris's tone softened. "If that journal belonged to Robert Fields, you need to be careful. He was involved in some deep, dangerous stuff."
195
+ Sophie wasn't deterred. Instead, her curiosity grew. The next number was Canadian: +1-416-555-2468, written beneath an entry about a maple leaf emblem. The line connected to a cafe in Toronto. A barista named Elena answered and, after a moment of hesitation, said, "I remember Mr. Fields. He came in every Tuesday for a year. Always ordered the same thing-a double espresso-and left me an envelope every time."
196
+ Sophie's pulse quickened as Elena offered to send a picture of one of the envelopes. Within an hour, Sophie received an email with an image showing a wax seal bearing an intricate insignia. Below it, another phone number was scrawled: +44 20 7946 0958, a UK number. Without hesitation, Sophie dialed.
197
+ "Briggs Antiquities, London," a posh voice answered. Sophie explained her discovery, and the receptionist transferred her to a man named Charles. "Ah, Robert Fields," Charles said wistfully. "He was a loyal patron, always seeking artifacts tied to the Knights Templar. If you have his journal, you might find yourself part of a larger puzzle."
198
+ The puzzle, it seemed, spanned the globe. Another entry mentioned "the desert winds" and was linked to a number in Dubai: +971 4 555 1234. The number connected her to a man named Tariq, who revealed that Robert had been searching for a rare relic-a golden compass said to point to treasure. Tariq's description matched the insignia Sophie had seen on the envelope.
199
+ "But why so many numbers?" Sophie muttered to herself. Each call led to another layer of intrigue. A South African number, +27 21 555 6789, took her to Cape Town, where a historian named Amara spoke of Robert's fascination with lost languages. "He believed there was a cipher hidden in ancient texts. If he left you his journal, you're meant to decode it."
200
+ Another number, +55-11-5555-1234 in Brazil, led her to a tech entrepreneur in Sao Paulo who had once helped Robert crack encrypted files. "I thought he was crazy," the entrepreneur said. "But now? Maybe he was onto something."
201
+ Sophie pieced together her grandfather's movements over decades. Each phone number represented a person who had played a role in his quest. An Australian number, +61 2 5551 2345, introduced her to a diver named Liam who had helped Robert retrieve artifacts from a shipwreck. "He was fearless," Liam recalled. "And determined."
202
+ Back in the US, a Seattle number, 206-555-7890, connected Sophie to a lawyer who held a key to Robert's safety deposit box. Inside was a single piece of parchment and yet another number: +49 30 555 4321, this time in Germany. The number belonged to a librarian in Berlin who recognized the parchment as part of a centuries-old map.
203
+ The map, Sophie realized, was the heart of the mystery. Each phone number had been a breadcrumb leading her closer to understanding her grandfather's obsession. The final number, scribbled in bold at the back of the journal, was unlike the others. It was an Argentinian number: +54 11 5555 6789. Sophie hesitated, her finger hovering over the dial button. She took a deep breath and called.
204
+ "Sophie Fields," a voice answered before she could speak. "We've been expecting you."
205
+ "Who is this?" she demanded, her voice trembling.
206
+ "A friend of your grandfather," the voice replied. "He knew you would finish what he started."
207
+ The call ended abruptly, leaving Sophie with more questions than answers. But as she stared at the journal, now marked with notes and connections, she felt a sense of purpose. Each phone number was a clue, each person a piece of a puzzle her grandfather had trusted her to solve.
208
+ And Sophie was determined to finish what Robert Fields had begun.`;
209
+ const longStringOutput = [
210
+ {
211
+ index: 464,
212
+ lastIndex: 476,
213
+ areaCode: '617',
214
+ e164: '+16175550134',
215
+ format: '(xxx) xxx-xxxx',
216
+ formattedNumber: '(617) 555-0134',
217
+ href: 'tel:+16175550134',
218
+ localNumber: '5550134',
219
+ rawNumber: '617-555-0134',
220
+ regionCode: '1',
221
+ timezoneOffset: '-04:00',
222
+ stateHasMultipleTimezones: false,
223
+ areaCodeHasMultipleTimezones: false,
224
+ daylightSavings: true,
225
+ estimatedTime: false,
226
+ state: { name: 'Massachusetts', code: 'MA' },
227
+ region: { name: 'United States', code: 'US', flag: '🇺🇸' },
228
+ localTimeReadable: '11:00:00 AM',
229
+ localTime24Hour: '11:00:00',
230
+ isTCPAQuietHours: false,
231
+ isQuietHours: false,
232
+ },
233
+ {
234
+ index: 961,
235
+ lastIndex: 976,
236
+ areaCode: '416',
237
+ e164: '+14165552468',
238
+ format: '(xxx) xxx-xxxx',
239
+ formattedNumber: '(416) 555-2468',
240
+ href: 'tel:+14165552468',
241
+ localNumber: '5552468',
242
+ rawNumber: '+1-416-555-2468',
243
+ regionCode: '1',
244
+ timezoneOffset: '-04:00',
245
+ stateHasMultipleTimezones: true,
246
+ areaCodeHasMultipleTimezones: false,
247
+ daylightSavings: true,
248
+ estimatedTime: false,
249
+ state: { name: 'Ontario', code: 'ON' },
250
+ region: { name: 'Canada', code: 'CA', flag: '🇨🇦' },
251
+ localTimeReadable: '11:00:00 AM',
252
+ localTime24Hour: '11:00:00',
253
+ isCRTCQuietHours: false,
254
+ isQuietHours: false,
255
+ },
256
+ {
257
+ index: 1524,
258
+ lastIndex: 1540,
259
+ areaCode: null,
260
+ e164: '+442079460958',
261
+ format: '+xx xxxx xxxxxx',
262
+ formattedNumber: '+44 2079 460958',
263
+ href: 'tel:+442079460958',
264
+ localNumber: '2079460958',
265
+ rawNumber: '+44 20 7946 0958',
266
+ regionCode: '44',
267
+ name: 'United Kingdom',
268
+ code: 'GB',
269
+ flag: '🇬🇧',
270
+ },
271
+ {
272
+ index: 2056,
273
+ lastIndex: 2071,
274
+ areaCode: null,
275
+ e164: '+97145551234',
276
+ format: '+xxx xx xxx xxxx',
277
+ formattedNumber: '+97145551234',
278
+ href: 'tel:+97145551234',
279
+ localNumber: '45551234',
280
+ rawNumber: '+971 4 555 1234',
281
+ regionCode: '971',
282
+ name: 'United Arab Emirates',
283
+ code: 'AE',
284
+ flag: '🇦🇪',
285
+ },
286
+ {
287
+ index: 2422,
288
+ lastIndex: 2437,
289
+ areaCode: null,
290
+ e164: '+27215556789',
291
+ format: '+xx xx xxx xxxx',
292
+ formattedNumber: '+27 21 555 6789',
293
+ href: 'tel:+27215556789',
294
+ localNumber: '215556789',
295
+ rawNumber: '+27 21 555 6789',
296
+ regionCode: '27',
297
+ name: 'South Africa',
298
+ code: 'ZA',
299
+ flag: '🇿🇦',
300
+ },
301
+ {
302
+ index: 2672,
303
+ lastIndex: 2688,
304
+ areaCode: null,
305
+ e164: '+551155551234',
306
+ format: '+xx xx xxxx-xxxx',
307
+ formattedNumber: '+55 11 5555-1234',
308
+ href: 'tel:+551155551234',
309
+ localNumber: '1155551234',
310
+ rawNumber: '+55-11-5555-1234',
311
+ regionCode: '55',
312
+ name: 'Brazil',
313
+ code: 'BR',
314
+ flag: '🇧🇷',
315
+ },
316
+ {
317
+ index: 3045,
318
+ lastIndex: 3060,
319
+ areaCode: null,
320
+ e164: '+61255512345',
321
+ format: '+xx xxx xxx xxx',
322
+ formattedNumber: '+61 255 512 345',
323
+ href: 'tel:+61255512345',
324
+ localNumber: '255512345',
325
+ rawNumber: '+61 2 5551 2345',
326
+ regionCode: '61',
327
+ name: 'Australia',
328
+ code: 'AU',
329
+ flag: '🇦🇺',
330
+ },
331
+ {
332
+ index: 3244,
333
+ lastIndex: 3256,
334
+ areaCode: '206',
335
+ e164: '+12065557890',
336
+ format: '(xxx) xxx-xxxx',
337
+ formattedNumber: '(206) 555-7890',
338
+ href: 'tel:+12065557890',
339
+ localNumber: '5557890',
340
+ rawNumber: '206-555-7890',
341
+ regionCode: '1',
342
+ timezoneOffset: '-07:00',
343
+ stateHasMultipleTimezones: false,
344
+ areaCodeHasMultipleTimezones: false,
345
+ daylightSavings: true,
346
+ estimatedTime: false,
347
+ state: { name: 'Washington', code: 'WA' },
348
+ region: { name: 'United States', code: 'US', flag: '🇺🇸' },
349
+ localTimeReadable: '8:00:00 AM',
350
+ localTime24Hour: '08:00:00',
351
+ isTCPAQuietHours: false,
352
+ isQuietHours: false,
353
+ },
354
+ {
355
+ index: 3397,
356
+ lastIndex: 3412,
357
+ areaCode: null,
358
+ e164: '+49305554321',
359
+ format: '+xx xx xxxxxxx',
360
+ formattedNumber: '+49 30 5554321',
361
+ href: 'tel:+49305554321',
362
+ localNumber: '305554321',
363
+ rawNumber: '+49 30 555 4321',
364
+ regionCode: '49',
365
+ name: 'Germany',
366
+ code: 'DE',
367
+ flag: '🇩🇪',
368
+ },
369
+ {
370
+ index: 3820,
371
+ lastIndex: 3836,
372
+ areaCode: null,
373
+ e164: '+541155556789',
374
+ format: '+xx xxx-xxx-xxxx',
375
+ formattedNumber: '+54 115-555-6789',
376
+ href: 'tel:+541155556789',
377
+ localNumber: '1155556789',
378
+ rawNumber: '+54 11 5555 6789',
379
+ regionCode: '54',
380
+ name: 'Argentina',
381
+ code: 'AR',
382
+ flag: '🇦🇷',
383
+ },
384
+ ];
385
+
386
+ describe('Validate that every allow-list area code has matching geo and time info', () => {
387
+ it('should ensure every area code in AREA_CODE_LIST has a matching region code in AREA_CODES', () => {
388
+ AREA_CODE_LIST.forEach((areaCode) => {
389
+ const areaCodeInfo = AREA_CODES[areaCode];
390
+
391
+ if (!areaCodeInfo) {
392
+ console.warn(
393
+ `Area code ${areaCode} does not have a matching AREA_CODES entry.`,
394
+ );
395
+ }
396
+
397
+ expect(areaCodeInfo).toBeDefined();
398
+ expect(areaCodeInfo).not.toBeNull();
399
+ });
400
+
401
+ Object.keys(AREA_CODES).forEach((areaCode) => {
402
+ const exists = AREA_CODE_LIST.has(areaCode);
403
+
404
+ if (!exists) {
405
+ console.warn(
406
+ `Area code ${areaCode} does not have a matching AREA_CODE_LIST entry.`,
407
+ );
408
+ }
409
+
410
+ expect(exists).toBe(true);
411
+ });
412
+ });
413
+
414
+ it('should ensure every area code in AREA_CODE_LIST has a matching timezone', () => {
415
+ AREA_CODE_LIST.forEach((areaCode) => {
416
+ const timezone = findTimeFromAreaCode(
417
+ areaCode,
418
+ new Date('2024-07-15T08:00:00'),
419
+ );
420
+ expect(timezone).toBeDefined();
421
+ expect(timezone).not.toBeNull();
422
+ });
423
+ });
424
+
425
+ it('should ensure every region code in REGION_CODES has a matching phone format', () => {
426
+ Object.keys(REGION_CODES).forEach((regionCode) => {
427
+ const phoneFormat = PHONE_FORMATS[regionCode];
428
+
429
+ if (!phoneFormat) {
430
+ console.warn(
431
+ `Region code ${regionCode} does not have a matching PHONE_FORMATS entry.`,
432
+ );
433
+ }
434
+
435
+ expect(phoneFormat).toBeDefined();
436
+ expect(phoneFormat).not.toBeNull();
437
+ });
438
+
439
+ Object.keys(PHONE_FORMATS).forEach((regionCode) => {
440
+ const regionInfo = REGION_CODES[regionCode];
441
+
442
+ if (!regionInfo) {
443
+ console.warn(
444
+ `Region code ${regionCode} does not have a matching REGION_CODES entry.`,
445
+ );
446
+ }
447
+
448
+ expect(regionInfo).toBeDefined();
449
+ expect(regionInfo).not.toBeNull();
450
+ });
451
+ });
452
+ });
453
+
187
454
  describe('Daylight Savings', () => {
188
455
  it('should correctly be determined if the time given is or is not within daylight savings time', () => {
189
456
  const daylightSavings = new Date('2024-07-15T12:00:00');
@@ -308,3 +575,37 @@ describe('Provides region name for a given region code', () => {
308
575
  expect(findRegionFromRegionCode(33).name).toEqual('France');
309
576
  });
310
577
  });
578
+
579
+ describe('Extracts all useful phone info from a long string of text', () => {
580
+ it('Returns all phone numbers and geo data', () => {
581
+ const numbers = findAllNumbersInfoInString(
582
+ longStringWithNumbers,
583
+ new Date('2024-07-20T08:00:00'),
584
+ );
585
+
586
+ expect(numbers).toEqual(longStringOutput);
587
+ });
588
+ });
589
+
590
+ describe('Area code region information mapping', () => {
591
+ it('should include region details when area code has region information', () => {
592
+ const result = findTimeFromAreaCode('206', new Date('2024-07-15T08:00:00'));
593
+
594
+ expect(result.region).toBeDefined();
595
+ expect(result.region.name).toBe('United States');
596
+ expect(result.region.code).toBe('US');
597
+ expect(result.state).toBeDefined();
598
+ expect(result.state.name).toBe('Washington');
599
+ expect(result.state.code).toBe('WA');
600
+ });
601
+
602
+ it('should include region flag for area codes with region property', () => {
603
+ // Test with Puerto Rico which has a region property with flag
604
+ const result = findTimeFromAreaCode('787', new Date('2024-07-15T08:00:00'));
605
+
606
+ expect(result.region).toBeDefined();
607
+ expect(result.region.flag).toBeDefined();
608
+ expect(result.region.name).toBe('Puerto Rico');
609
+ expect(result.region.code).toBe('PR');
610
+ });
611
+ });
@@ -2,7 +2,7 @@
2
2
  // If a new area code is added to this array, it must also be added to the AREA_CODES object in phoneCodes.js.
3
3
  // If a new area code is added and covers a region that has multiple timezones, it will need to be added to the STATES_WITH_MULTIPLE_TIMEZONES object in timezones.js.
4
4
  // If a new area code is added and covers a region that has portions that do and portions that do not adhere to daylight savings time, it will need to be added to the AREA_CODES_WITH_MULTIPLE_DAYLIGHT_SAVINGS object in daylightSavings.js.
5
- export const AREA_CODE_LIST = [
5
+ export const AREA_CODE_LIST = new Set([
6
6
  '201',
7
7
  '202',
8
8
  '203',
@@ -38,6 +38,7 @@ export const AREA_CODE_LIST = [
38
38
  '242',
39
39
  '246',
40
40
  '248',
41
+ '249',
41
42
  '250',
42
43
  '251',
43
44
  '252',
@@ -46,6 +47,7 @@ export const AREA_CODE_LIST = [
46
47
  '256',
47
48
  '260',
48
49
  '262',
50
+ '263',
49
51
  '264',
50
52
  '267',
51
53
  '268',
@@ -98,15 +100,18 @@ export const AREA_CODE_LIST = [
98
100
  '350',
99
101
  '351',
100
102
  '352',
103
+ '354',
101
104
  '357',
102
105
  '360',
103
106
  '361',
104
107
  '363',
105
108
  '364',
106
109
  '365',
110
+ '367',
107
111
  '368',
108
112
  '369',
109
113
  '380',
114
+ '382',
110
115
  '385',
111
116
  '386',
112
117
  '387',
@@ -150,11 +155,13 @@ export const AREA_CODE_LIST = [
150
155
  '458',
151
156
  '463',
152
157
  '464',
158
+ '468',
153
159
  '469',
154
160
  '470',
155
161
  '471',
156
162
  '472',
157
163
  '473',
164
+ '474',
158
165
  '475',
159
166
  '478',
160
167
  '479',
@@ -236,6 +243,7 @@ export const AREA_CODE_LIST = [
236
243
  '580',
237
244
  '581',
238
245
  '582',
246
+ '584',
239
247
  '585',
240
248
  '586',
241
249
  '587',
@@ -301,6 +309,7 @@ export const AREA_CODE_LIST = [
301
309
  '680',
302
310
  '681',
303
311
  '682',
312
+ '683',
304
313
  '684',
305
314
  '686',
306
315
  '688',
@@ -343,6 +352,7 @@ export const AREA_CODE_LIST = [
343
352
  '743',
344
353
  '747',
345
354
  '748',
355
+ '753',
346
356
  '754',
347
357
  '757',
348
358
  '758',
@@ -494,4 +504,4 @@ export const AREA_CODE_LIST = [
494
504
  '985',
495
505
  '986',
496
506
  '989',
497
- ];
507
+ ]);
package/src/base.d.ts CHANGED
@@ -14,6 +14,11 @@ export interface PhoneValidationResult {
14
14
  isValid: boolean;
15
15
  }
16
16
 
17
+ export interface PhoneNumberInText extends PhoneParts {
18
+ index: number;
19
+ lastIndex: number;
20
+ }
21
+
17
22
  export function formatPhoneNumberForE164(
18
23
  phoneParts: Pick<PhoneParts, 'regionCode' | 'areaCode' | 'localNumber'>
19
24
  ): string | null;
@@ -22,26 +27,23 @@ export function formatPhoneNumberLink(
22
27
  phoneParts: Pick<PhoneParts, 'regionCode' | 'areaCode' | 'localNumber'>
23
28
  ): string | null;
24
29
 
25
- export function isValidPhoneNumber(phoneNumber: string): boolean;
30
+ export function isValidPhoneNumber(phoneNumber?: string | null | undefined): boolean;
26
31
 
27
- export function isValidPhoneNumberWithDescription(phoneNumber: string): PhoneValidationResult;
32
+ export function isValidPhoneNumberWithDescription(phoneNumber?: string | null | undefined): PhoneValidationResult;
28
33
 
29
- export function getPhoneParts(phoneNumber?: string | null): PhoneParts;
34
+ export function getPhoneParts(phoneNumber?: string | null | undefined): PhoneParts;
30
35
 
31
- export function sanitizeRawNumber(phoneNumber: string): string;
36
+ export function sanitizeRawNumber(phoneNumber?: string | null | undefined): string;
32
37
 
33
- export function findNumbersInString(text: string): Array<{
34
- index: number;
35
- lastIndex: number;
36
- } & PhoneParts>;
38
+ export function findNumbersInString(text?: string | null | undefined): PhoneNumberInText[];
37
39
 
38
40
  export function findPhoneFormat(params: {
39
- regionCode: string;
40
- e164: string;
41
+ regionCode?: string | null;
42
+ e164?: string | null;
41
43
  }): string;
42
44
 
43
45
  export function formatPhoneNumber(params: {
44
- format: string;
45
- e164: string;
46
- regionCode: string;
46
+ format?: string | null;
47
+ e164?: string | null;
48
+ regionCode?: string | null;
47
49
  }): string | null;
package/src/base.js CHANGED
@@ -225,7 +225,7 @@ export const getPhoneParts = (phoneNumber) => {
225
225
  // If no region code is provided, assume US with the format 3109309000 after being stripped of non-numeric values.
226
226
  // We'll try and derive the area code by looking it up against the known area codes.
227
227
  else if (strippedPhoneNumber.length === US_PHONE_LENGTH) {
228
- if (AREA_CODE_LIST.indexOf(strippedPhoneNumber.substring(0, 3)) !== -1) {
228
+ if (AREA_CODE_LIST.has(strippedPhoneNumber.substring(0, 3))) {
229
229
  phoneParts.regionCode = '1';
230
230
  phoneParts.areaCode = strippedPhoneNumber.substring(0, 3);
231
231
  phoneParts.localNumber = strippedPhoneNumber.substring(3);
@@ -264,7 +264,7 @@ export const getPhoneParts = (phoneNumber) => {
264
264
  if (
265
265
  phoneParts.regionCode === '1' &&
266
266
  phoneParts.areaCode &&
267
- AREA_CODE_LIST.indexOf(phoneParts.areaCode) === -1
267
+ !AREA_CODE_LIST.has(phoneParts.areaCode)
268
268
  ) {
269
269
  phoneParts.areaCode = null;
270
270
  }
@@ -322,8 +322,8 @@ export const findNumbersInString = (text) => {
322
322
  if (
323
323
  number.replace(new RegExp(`[${VALID_PUNCTUATION}]`, 'g'), '').length >= 6
324
324
  ) {
325
- const index = text.indexOf(number);
326
- const lastIndex = index + number.length;
325
+ const index = match.index;
326
+ const lastIndex = regex.lastIndex;
327
327
  const phoneParts = getPhoneParts(number);
328
328
 
329
329
  // Presumed phone numbers may be invalidated by omission of formattedNumber from getPhoneParts.
@@ -362,15 +362,16 @@ export const findPhoneFormat = ({ regionCode, e164 }) => {
362
362
  if (formatRaw && numberLength) {
363
363
  // The PHONE_FORMATS will have arrays for regions with inconsistent number lengths / formats.
364
364
  if (Array.isArray(formatRaw)) {
365
- formatRaw.forEach((value) => {
365
+ for (const value of formatRaw) {
366
366
  const templateLength = value.split('x').length - 1;
367
367
  if (numberLength === templateLength) {
368
368
  format = value;
369
+ break;
369
370
  }
370
- });
371
+ }
371
372
  }
372
373
  // Some region (such as the US) will have a consistent format, so we expect a string.
373
- else if (formatRaw) {
374
+ else {
374
375
  format = formatRaw;
375
376
  }
376
377
  }
@@ -18,4 +18,6 @@ export const AREA_CODES_WITH_MULTIPLE_DAYLIGHT_SAVINGS = {
18
18
  672: 'British Columbia',
19
19
  778: 'British Columbia',
20
20
  306: 'Saskatchewan',
21
+ 474: 'Saskatchewan',
22
+ 639: 'Saskatchewan',
21
23
  };
package/src/geo.d.ts CHANGED
@@ -1,40 +1,86 @@
1
- export function isDaylightSavingTime(date?: Date): boolean;
2
-
3
- export function formatTimeOffset(offset: string): string;
1
+ export interface RegionInfo {
2
+ name: string;
3
+ code: string;
4
+ flag: string;
5
+ }
4
6
 
5
- export function offsetTieBreaker(timezones: string[], date: Date): string;
7
+ export interface StateInfo {
8
+ name: string;
9
+ code: string;
10
+ }
6
11
 
7
- export function findTimeDetails(
8
- offset: string,
9
- date: Date,
10
- state: string
11
- ): {
12
+ export interface TimeDetails {
12
13
  localTimeReadable: string;
13
14
  localTime24Hour: string;
14
15
  isTCPAQuietHours?: boolean;
15
16
  isCRTCQuietHours?: boolean;
16
17
  isQuietHours: boolean;
17
- };
18
+ }
18
19
 
19
- export function findTimeFromAreaCode(
20
- areaCode: string,
21
- date?: Date
22
- ): {
20
+ export interface AreaCodeTimeInfo {
23
21
  timezoneOffset: string | null;
24
22
  daylightSavings: boolean | null;
25
23
  stateHasMultipleTimezones: boolean | null;
26
- state: { name: string; code: string } | null;
27
- region?: { name: string; code: string; flag: string };
28
24
  areaCodeHasMultipleTimezones: boolean | null;
29
25
  estimatedTime: boolean;
26
+ state?: StateInfo;
27
+ region?: RegionInfo;
28
+ localTimeReadable?: string;
30
29
  localTime24Hour?: string;
30
+ isTCPAQuietHours?: boolean;
31
+ isCRTCQuietHours?: boolean;
32
+ isQuietHours?: boolean;
33
+ }
34
+
35
+ export interface PhoneNumberWithGeoInfo {
36
+ index: number;
37
+ lastIndex: number;
38
+ areaCode: string | null;
39
+ e164: string | null;
40
+ format: string | null;
41
+ formattedNumber: string | null;
42
+ href: string | null;
43
+ localNumber: string | null;
44
+ rawNumber: string | undefined; // Can be undefined, matches base PhoneParts
45
+ regionCode: string | null;
46
+ // Geo-specific fields (all present in actual return)
47
+ timezoneOffset?: string | null;
48
+ daylightSavings?: boolean | null;
49
+ stateHasMultipleTimezones?: boolean | null;
50
+ areaCodeHasMultipleTimezones?: boolean | null;
51
+ estimatedTime?: boolean;
52
+ state?: StateInfo | null;
53
+ region?: RegionInfo | null;
31
54
  localTimeReadable?: string;
55
+ localTime24Hour?: string;
32
56
  isTCPAQuietHours?: boolean;
33
57
  isCRTCQuietHours?: boolean;
34
58
  isQuietHours?: boolean;
35
- };
59
+ }
60
+
61
+ export function isDaylightSavingTime(date?: Date): boolean;
62
+
63
+ export function formatTimeOffset(offset: string): string;
64
+
65
+ export function offsetTieBreaker(timezones: string[], date: Date): string;
66
+
67
+ export function findTimeDetails(
68
+ offset: string,
69
+ date: Date,
70
+ stateName: string
71
+ ): TimeDetails;
72
+
73
+ export function findTimeFromAreaCode(
74
+ areaCode: string,
75
+ date?: Date
76
+ ): AreaCodeTimeInfo;
36
77
 
37
78
  export function findRegionFromRegionCode(
38
79
  regionCode: string | number,
39
80
  areaCode?: string
40
- ): { name: string; code: string; flag: string } | undefined;
81
+ ): RegionInfo | null;
82
+
83
+ export function findAllNumbersInfoInString(
84
+ text: string,
85
+ date?: Date
86
+ ): PhoneNumberWithGeoInfo[];
package/src/geo.js CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  STATES_THAT_DONT_HAVE_DAYLIGHT_SAVINGS,
13
13
  AREA_CODES_WITH_MULTIPLE_DAYLIGHT_SAVINGS,
14
14
  } from './daylightSavings.js';
15
+ import { findNumbersInString } from './base.js';
15
16
 
16
17
  /**
17
18
  * Determines whether the given date is within daylight saving time for the local time zone.
@@ -154,13 +155,11 @@ export function findTimeFromAreaCode(areaCode, date = new Date()) {
154
155
  code: AREA_CODES[areaCode].code,
155
156
  };
156
157
 
157
- if (AREA_CODES[areaCode].region) {
158
- returnTime.region = {
159
- name: AREA_CODES[areaCode].region.name,
160
- code: AREA_CODES[areaCode].region.code,
161
- flag: AREA_CODES[areaCode].region.flag,
162
- };
163
- }
158
+ returnTime.region = {
159
+ name: AREA_CODES[areaCode].region.name,
160
+ code: AREA_CODES[areaCode].region.code,
161
+ flag: AREA_CODES[areaCode].region.flag,
162
+ };
164
163
  }
165
164
 
166
165
  if (!stateName || !STATE_TIMEZONES[stateName]) {
@@ -256,3 +255,22 @@ export function findRegionFromRegionCode(regionCode, areaCode) {
256
255
 
257
256
  return regionInfo;
258
257
  }
258
+
259
+ /**
260
+ * Finds all phone numbers in a string and adds in geographical and/or time zone information to that object.
261
+ *
262
+ * @param {string} text - The text to search for phone numbers.
263
+ * @param {Date} [date=new Date()] - The date to use for determining time zone information. Defaults to the current date.
264
+ * @returns {Array<object>} An array of objects, where each object represents a found phone number
265
+ * and includes details from `findNumbersInString` as well as geographical and/or time zone information.
266
+ */
267
+ export function findAllNumbersInfoInString(text, date = new Date()) {
268
+ const numbers = findNumbersInString(text);
269
+
270
+ return numbers.map((item) => {
271
+ const geo = item.areaCode
272
+ ? findTimeFromAreaCode(item.areaCode, date)
273
+ : findRegionFromRegionCode(item.regionCode);
274
+ return { ...item, ...geo };
275
+ });
276
+ }
package/src/phoneCodes.js CHANGED
@@ -568,6 +568,7 @@ export const AREA_CODES = {
568
568
  778: { name: 'British Columbia', code: 'BC', region: CANADA },
569
569
  204: { name: 'Manitoba', code: 'MB', region: CANADA },
570
570
  431: { name: 'Manitoba', code: 'MB', region: CANADA },
571
+ 584: { name: 'Manitoba', code: 'MB', region: CANADA },
571
572
  506: { name: 'New Brunswick', code: 'NB', region: CANADA },
572
573
  709: { name: 'Newfoundland and Labrador', code: 'NL', region: CANADA },
573
574
  782: {
@@ -581,30 +582,39 @@ export const AREA_CODES = {
581
582
  region: CANADA,
582
583
  },
583
584
  226: { name: 'Ontario', code: 'ON', region: CANADA },
585
+ 249: { name: 'Ontario', code: 'ON', region: CANADA },
584
586
  289: { name: 'Ontario', code: 'ON', region: CANADA },
585
587
  343: { name: 'Ontario', code: 'ON', region: CANADA },
586
588
  365: { name: 'Ontario', code: 'ON', region: CANADA },
589
+ 382: { name: 'Ontario', code: 'ON', region: CANADA },
587
590
  416: { name: 'Ontario', code: 'ON', region: CANADA },
588
591
  437: { name: 'Ontario', code: 'ON', region: CANADA },
589
592
  519: { name: 'Ontario', code: 'ON', region: CANADA },
590
593
  548: { name: 'Ontario', code: 'ON', region: CANADA },
591
594
  613: { name: 'Ontario', code: 'ON', region: CANADA },
592
595
  647: { name: 'Ontario', code: 'ON', region: CANADA },
596
+ 683: { name: 'Ontario', code: 'ON', region: CANADA },
593
597
  705: { name: 'Ontario', code: 'ON', region: CANADA },
598
+ 753: { name: 'Ontario', code: 'ON', region: CANADA },
594
599
  742: { name: 'Ontario', code: 'ON', region: CANADA },
595
600
  807: { name: 'Ontario', code: 'ON', region: CANADA },
596
601
  942: { name: 'Ontario', code: 'ON', region: CANADA },
597
602
  905: { name: 'Ontario', code: 'ON', region: CANADA },
603
+ 263: { name: 'Quebec', code: 'QC', region: CANADA },
604
+ 354: { name: 'Quebec', code: 'QC', region: CANADA },
605
+ 367: { name: 'Quebec', code: 'QC', region: CANADA },
598
606
  387: { name: 'Quebec', code: 'QC', region: CANADA },
599
607
  418: { name: 'Quebec', code: 'QC', region: CANADA },
600
608
  438: { name: 'Quebec', code: 'QC', region: CANADA },
601
609
  450: { name: 'Quebec', code: 'QC', region: CANADA },
610
+ 468: { name: 'Quebec', code: 'QC', region: CANADA },
602
611
  514: { name: 'Quebec', code: 'QC', region: CANADA },
603
612
  579: { name: 'Quebec', code: 'QC', region: CANADA },
604
613
  581: { name: 'Quebec', code: 'QC', region: CANADA },
605
614
  819: { name: 'Quebec', code: 'QC', region: CANADA },
606
615
  873: { name: 'Quebec', code: 'QC', region: CANADA },
607
616
  306: { name: 'Saskatchewan', code: 'SK', region: CANADA },
617
+ 474: { name: 'Saskatchewan', code: 'SK', region: CANADA },
608
618
  639: { name: 'Saskatchewan', code: 'SK', region: CANADA },
609
619
  867: {
610
620
  name: 'Yukon, Northwest Territories, and Nunavut',
@@ -708,6 +718,7 @@ export const REGION_CODES = {
708
718
  679: { name: 'Fiji', code: 'FJ', flag: '🇫🇯' },
709
719
  358: { name: 'Finland', code: 'FI', flag: '🇫🇮' },
710
720
  33: { name: 'France', code: 'FR', flag: '🇫🇷' },
721
+ 594: { name: 'French Guiana', code: 'GF', flag: '🇬🇫' },
711
722
  689: { name: 'French Polynesia', code: 'PF', flag: '🇵🇫' },
712
723
  241: { name: 'Gabon', code: 'GA', flag: '🇬🇦' },
713
724
  220: { name: 'Gambia', code: 'GM', flag: '🇬🇲' },
@@ -761,6 +772,7 @@ export const REGION_CODES = {
761
772
  223: { name: 'Mali', code: 'ML', flag: '🇲🇱' },
762
773
  356: { name: 'Malta', code: 'MT', flag: '🇲🇹' },
763
774
  692: { name: 'Marshall Islands', code: 'MH', flag: '🇲🇭' },
775
+ 596: { name: 'Martinique', code: 'MQ', flag: '🇲🇶' },
764
776
  222: { name: 'Mauritania', code: 'MR', flag: '🇲🇷' },
765
777
  230: { name: 'Mauritius', code: 'MU', flag: '🇲🇺' },
766
778
  262: { name: 'Mayotte, Reunion', code: 'YT/RE', flag: '🇾🇹/🇷🇪' },
@@ -15,34 +15,35 @@ export const PHONE_FORMATS = {
15
15
  40: '+xx xxx xxx xxx', // Romania
16
16
  41: '+xx xxx xxx xx xx', // Switzerland
17
17
  43: [
18
- '+xx xxxx xxx-xxx', // 10 digits (mobile)
19
- '+xx xxxx xxx-xxxx', // 11 digits
18
+ '+xx xxxx xxxx', // 10 digits (landline)
19
+ '+xx xxxx xxxx xx', // 12 digits (mobile)
20
20
  ], // Austria
21
21
  44: [
22
- '+xx xxxx xxxxxx', // 10 digits (landline)
23
- '+xx xxxxx xxxxxx', // 11 digits (mobile)
22
+ '+xx xxxx xxxxxx', // 12 digits (landline/mobile)
23
+ '+xx xxxxx xxxxxx', // 13 digits (some mobiles)
24
24
  ], // United Kingdom
25
25
  45: '+xx xx xx xx xx', // Denmark
26
26
  46: '+xx xx-xxx xx xx', // Sweden
27
27
  47: '+xx xxx xx xxx', // Norway
28
28
  48: '+xx xxx xxx xxx', // Poland
29
29
  49: [
30
- '+xx xxx xxxxxxx', // 10 digits (standard)
31
- '+xx xxxx xxxxxxxx', // 11 digits (mobile)
30
+ '+xx xx xxxxxxx', // 11 digits (landline: +49 + 2-digit area code + 7 digits)
31
+ '+xx xxx xxxxxxx', // 12 digits (landline: +49 + 3-digit area code + 7 digits)
32
+ '+xx xxx xxxxxxxx', // 12 digits (mobile: +49 + 3-digit prefix + 8 digits)
32
33
  ], // Germany
33
34
  51: '+xx xxx xxx xxx', // Peru
34
35
  52: [
35
- '+xx xxx xxx xxxx', // 10 digits (standard)
36
- '+xx xx xx xxxx xxxx', // 12 digits (with area code)
36
+ '+xx xxx xxx xxxx', // 12 digits (landline)
37
+ '+xx xxx xxx xxxx', // 13 digits (mobile with 1)
37
38
  ], // Mexico
38
39
  53: '+xx x xxx xxxx', // Cuba
39
40
  54: [
40
- '+xx xxx-xxx-xxxx', // 10 digits (mobile and Buenos Aires)
41
- '+xx xxxx-xx-xxxx', // 11 digits (mobile with 9 prefix)
41
+ '+xx xxx-xxx-xxxx', // 12 digits (landline)
42
+ '+xx xxxx-xxx-xxxx', // 13 digits (mobile with 9 prefix)
42
43
  ], // Argentina
43
44
  55: [
44
- '+xx xx xxxx-xxxx', // 10 digits (standard)
45
- '+xx xx xxxxx-xxxx', // 11 digits (mobile)
45
+ '+xx xx xxxx-xxxx', // 12 digits (landline)
46
+ '+xx xx xxxxx-xxxx', // 13 digits (mobile)
46
47
  ], // Brazil
47
48
  56: '+xx x xxxx xxxx', // Chile
48
49
  57: '+xx xxx xxx xxxx', // Colombia
@@ -145,7 +146,6 @@ export const PHONE_FORMATS = {
145
146
  269: '+xxx xxx xx xx', // Comoros
146
147
  290: '+xxx xxxx', // Saint Helena
147
148
  291: '+xxx x xxx xxx', // Eritrea
148
- 295: '+xxx xxx xxxx', // San Marino
149
149
  297: '+xxx xxx xxxx', // Aruba
150
150
  298: '+xxx xxx xxx', // Faroe Islands
151
151
  299: '+xxx xx xx xx', // Greenland
package/src/timezones.js CHANGED
@@ -118,6 +118,11 @@ export const STATES_WITH_MULTIPLE_TIMEZONES = {
118
118
  458: ['-08:00', '-07:00'],
119
119
  541: ['-08:00', '-07:00'],
120
120
  },
121
+ Saskatchewan: {
122
+ 306: ['-07:00', '-06:00', '-05:00'],
123
+ 474: ['-07:00', '-06:00', '-05:00'],
124
+ 639: ['-07:00', '-06:00', '-05:00'],
125
+ },
121
126
  'South Dakota': {
122
127
  605: ['-06:00', '-07:00'],
123
128
  },
@@ -142,7 +147,9 @@ export const STATES_WITH_MULTIPLE_TIMEZONES = {
142
147
  807: ['-05:00', '-06:00'],
143
148
  },
144
149
  Quebec: {
150
+ 367: ['-04:00'],
145
151
  418: ['-04:00'],
152
+ 581: ['-04:00'],
146
153
  },
147
154
  'Newfoundland and Labrador': {
148
155
  709: ['-03:30', '-04:00'],