airport-utils 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.
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,56 @@
1
+ import dayjs from 'dayjs';
2
+ import { convertToUTC, convertLocalToUTCByZone } from '../src/converter';
3
+ import { UnknownAirportError, InvalidTimestampError, UnknownTimezoneError } from '../src/errors';
4
+ import { timezones } from '../src/mapping/timezones';
5
+ // Dynamically find the first 3-letter code not in our mapping
6
+ function getInvalidIata() {
7
+ const existing = new Set(Object.keys(timezones));
8
+ for (let a = 65; a <= 90; a++) {
9
+ for (let b = 65; b <= 90; b++) {
10
+ for (let c = 65; c <= 90; c++) {
11
+ const code = String.fromCharCode(a, b, c);
12
+ if (!existing.has(code))
13
+ return code;
14
+ }
15
+ }
16
+ }
17
+ throw new Error('All 3-letter codes are taken?!');
18
+ }
19
+ const invalidIata = getInvalidIata();
20
+ describe('convertToUTC (Day.js)', () => {
21
+ it('converts JFK local time (UTC–4 in May) correctly', () => {
22
+ expect(convertToUTC('2025-05-02T14:30', 'JFK'))
23
+ .toBe('2025-05-02T18:30:00Z');
24
+ });
25
+ it('throws UnknownAirportError for bad IATA', () => {
26
+ expect(() => convertToUTC('2025-05-02T14:30', invalidIata))
27
+ .toThrow(UnknownAirportError);
28
+ });
29
+ it('throws InvalidTimestampError for malformed timestamp', () => {
30
+ expect(() => convertToUTC('invalid-format', 'JFK'))
31
+ .toThrow(InvalidTimestampError);
32
+ });
33
+ it('throws InvalidTimestampError if dayjs.tz unexpectedly throws', () => {
34
+ const orig = dayjs.tz;
35
+ dayjs.tz = () => { throw new Error(); };
36
+ try {
37
+ expect(() => convertToUTC('2025-05-02T14:30', 'JFK'))
38
+ .toThrow(InvalidTimestampError);
39
+ }
40
+ finally {
41
+ dayjs.tz = orig;
42
+ }
43
+ });
44
+ });
45
+ describe('convertLocalToUTCByZone', () => {
46
+ it('converts London local time to UTC', () => {
47
+ expect(convertLocalToUTCByZone('2025-05-02T14:30:00', 'Europe/London'))
48
+ .toBe('2025-05-02T13:30:00Z');
49
+ });
50
+ it('throws UnknownTimezoneError for invalid tz', () => {
51
+ expect(() => convertLocalToUTCByZone('2025-05-02T14:30:00', 'Invalid/Zone')).toThrow(UnknownTimezoneError);
52
+ });
53
+ it('throws InvalidTimestampError for malformed timestamp', () => {
54
+ expect(() => convertLocalToUTCByZone('bad-format', 'Europe/London')).toThrow(InvalidTimestampError);
55
+ });
56
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,34 @@
1
+ import { getAirportInfo } from '../src/info';
2
+ import { UnknownAirportError } from '../src/errors';
3
+ import { timezones } from '../src/mapping/timezones';
4
+ import { geo } from '../src/mapping/geo';
5
+ // Dynamically find the first 3-letter code not in our mapping
6
+ function getInvalidIata() {
7
+ const existing = new Set(Object.keys(timezones));
8
+ for (let a = 65; a <= 90; a++) {
9
+ for (let b = 65; b <= 90; b++) {
10
+ for (let c = 65; c <= 90; c++) {
11
+ const code = String.fromCharCode(a, b, c);
12
+ if (!existing.has(code))
13
+ return code;
14
+ }
15
+ }
16
+ }
17
+ throw new Error('All 3-letter codes are taken?!');
18
+ }
19
+ const invalidIata = getInvalidIata();
20
+ describe('getAirportInfo', () => {
21
+ const validCodes = Object.keys(timezones).filter(i => geo[i]);
22
+ const sample = validCodes.length > 0 ? validCodes[0] : 'JFK';
23
+ it('returns full info for a valid IATA', () => {
24
+ const info = getAirportInfo(sample);
25
+ expect(info).toEqual({
26
+ timezone: timezones[sample],
27
+ ...geo[sample]
28
+ });
29
+ });
30
+ it('throws UnknownAirportError for missing IATA', () => {
31
+ expect(() => getAirportInfo(invalidIata))
32
+ .toThrow(UnknownAirportError);
33
+ });
34
+ });
package/jest.config.js ADDED
@@ -0,0 +1,11 @@
1
+ export default {
2
+ preset: 'ts-jest',
3
+ testEnvironment: 'node',
4
+ roots: ['<rootDir>/tests'],
5
+ collectCoverage: true,
6
+ coverageThreshold: {
7
+ global: { branches: 100, functions: 100, lines: 100, statements: 100 }
8
+ },
9
+ moduleFileExtensions: ['ts', 'js', 'json'],
10
+ transform: { '^.+\\.ts$': 'ts-jest' }
11
+ };
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "airport-utils",
3
+ "version": "1.0.0",
4
+ "description": "Convert local ISO 8601 timestamps to UTC using airport IATA codes, with airport geo-data",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "type": "module",
8
+ "scripts": {
9
+ "build": "tsc",
10
+ "test": "jest --coverage",
11
+ "prepublishOnly": "npm run build",
12
+ "update:mapping": "node --loader ts-node/esm scripts/generateMapping.ts"
13
+ },
14
+ "keywords": [
15
+ "amadeus",
16
+ "timezone",
17
+ "iata",
18
+ "iso8601",
19
+ "utc",
20
+ "airport",
21
+ "geo"
22
+ ],
23
+ "license": "MIT",
24
+ "dependencies": {
25
+ "dayjs": "^1.11.7"
26
+ },
27
+ "devDependencies": {
28
+ "@semantic-release/commit-analyzer": "^13.0.1",
29
+ "@semantic-release/github": "^11.0.2",
30
+ "@semantic-release/npm": "^12.0.1",
31
+ "@semantic-release/release-notes-generator": "^14.0.3",
32
+ "@types/jest": "^29.0.0",
33
+ "@types/node-fetch": "^2.6.12",
34
+ "conventional-changelog-conventionalcommits": "^8.0.0",
35
+ "jest": "^29.0.0",
36
+ "node-fetch": "^3.3.2",
37
+ "semantic-release": "^24.2.3",
38
+ "ts-jest": "^29.0.0",
39
+ "ts-node": "^10.9.1",
40
+ "typescript": "^5.0.0"
41
+ }
42
+ }
@@ -0,0 +1,95 @@
1
+ #!/usr/bin/env node
2
+ import fetch from 'node-fetch';
3
+ import fs from 'fs';
4
+ import path from 'path';
5
+
6
+ async function generateMapping() {
7
+ const url =
8
+ 'https://raw.githubusercontent.com/opentraveldata/opentraveldata/master/opentraveldata/optd_por_public.csv';
9
+ const res = await fetch(url);
10
+ if (!res.ok) throw new Error(`Fetch failed: ${res.statusText}`);
11
+ const text = await res.text();
12
+
13
+ const lines = text.split('\n').filter(l => l.trim());
14
+ const header = lines[0].split('^');
15
+ const idx = {
16
+ iata: header.indexOf('iata_code'),
17
+ tz: header.indexOf('timezone'),
18
+ lat: header.indexOf('latitude'),
19
+ lon: header.indexOf('longitude'),
20
+ name: header.indexOf('name'),
21
+ city: header.indexOf('city_name_list'),
22
+ country: header.indexOf('country_code')
23
+ };
24
+ if (Object.values(idx).some(i => i < 0)) {
25
+ throw new Error('Missing required OPTD columns');
26
+ }
27
+
28
+ const timezonesMap: Record<string, string> = {};
29
+ const geoMap: Record<string, {
30
+ latitude: number;
31
+ longitude: number;
32
+ name: string;
33
+ city: string;
34
+ country: string;
35
+ }> = {};
36
+
37
+ for (let i = 1; i < lines.length; i++) {
38
+ const cols = lines[i].split('^');
39
+ const code = cols[idx.iata];
40
+ if (!code || code.length !== 3) continue;
41
+ const tz = cols[idx.tz];
42
+ if (tz) timezonesMap[code] = tz;
43
+
44
+ const lat = parseFloat(cols[idx.lat]);
45
+ const lon = parseFloat(cols[idx.lon]);
46
+ const name = cols[idx.name];
47
+ const city = cols[idx.city].split(',')[0].trim();
48
+ const country = cols[idx.country];
49
+ if (!isNaN(lat) && !isNaN(lon) && name && city && country) {
50
+ geoMap[code] = { latitude: lat, longitude: lon, name, city, country };
51
+ }
52
+ }
53
+
54
+ const sortedCodes = Object.keys(timezonesMap).sort();
55
+ const sortedTz = Object.fromEntries(sortedCodes.map(c => [c, timezonesMap[c]]));
56
+ const sortedGeo = Object.fromEntries(
57
+ sortedCodes.filter(c => geoMap[c]).map(c => [c, geoMap[c]])
58
+ );
59
+
60
+ const dir = path.resolve(path.dirname(import.meta.url.replace('file://', '')), '../src/mapping');
61
+ fs.mkdirSync(dir, { recursive: true });
62
+
63
+ // Write TypeScript modules
64
+ const tzTs = [
65
+ '// generated — do not edit',
66
+ 'export const timezones: Record<string, string> = ',
67
+ JSON.stringify(sortedTz, null, 2) + ';',
68
+ ].join('\n');
69
+ fs.writeFileSync(path.join(dir, 'timezones.ts'), tzTs + '\n');
70
+
71
+ const geoEntries = Object.entries(sortedGeo)
72
+ .map(([code, g]) => ` "${code}": ${JSON.stringify(g)}`)
73
+ .join(',\n');
74
+ const geoTs = [
75
+ '// generated — do not edit',
76
+ 'export interface GeoEntry {',
77
+ ' latitude: number;',
78
+ ' longitude: number;',
79
+ ' name: string;',
80
+ ' city: string;',
81
+ ' country: string;',
82
+ '}', '',
83
+ 'export const geo: Record<string, GeoEntry> = {',
84
+ geoEntries,
85
+ '};',
86
+ ].join('\n');
87
+ fs.writeFileSync(path.join(dir, 'geo.ts'), geoTs + '\n');
88
+
89
+ console.log(`✅ Mappings: ${sortedCodes.length} timezones, ${Object.keys(sortedGeo).length} geo entries`);
90
+ }
91
+
92
+ generateMapping().catch(err => {
93
+ console.error(err);
94
+ process.exit(1);
95
+ });
@@ -0,0 +1,60 @@
1
+ import dayjs from 'dayjs';
2
+ import utc from 'dayjs/plugin/utc';
3
+ import timezone from 'dayjs/plugin/timezone';
4
+ import { timezones } from './mapping/timezones';
5
+ import {
6
+ UnknownAirportError,
7
+ InvalidTimestampError,
8
+ UnknownTimezoneError
9
+ } from './errors';
10
+
11
+ // Initialize plugins
12
+ dayjs.extend(utc);
13
+ dayjs.extend(timezone);
14
+
15
+ /**
16
+ * Convert local ISO 8601 (YYYY-MM-DDTHH:mm) at an airport into UTC ISO string.
17
+ * @throws UnknownAirportError | InvalidTimestampError
18
+ */
19
+ export function convertToUTC(localIso: string, iata: string): string {
20
+ const tz = timezones[iata];
21
+ if (!tz) throw new UnknownAirportError(iata);
22
+
23
+ // 1) Pre-validate timestamp
24
+ const localDt = dayjs(localIso, /* no format, ISO parse */);
25
+ if (!localDt.isValid()) throw new InvalidTimestampError(localIso);
26
+
27
+ // 2) Then apply timezone conversion
28
+ let dt;
29
+ try {
30
+ dt = dayjs.tz(localIso, tz);
31
+ } catch {
32
+ // Shouldn't happen for valid tz, but just in case:
33
+ throw new InvalidTimestampError(localIso);
34
+ }
35
+
36
+ return dt.utc().format(); // "YYYY-MM-DDTHH:mm:ssZ"
37
+ }
38
+
39
+ /**
40
+ * Convert local ISO 8601 string in any IANA timezone to UTC ISO string.
41
+ * @throws UnknownTimezoneError | InvalidTimestampError
42
+ */
43
+ export function convertLocalToUTCByZone(
44
+ localIso: string,
45
+ timeZone: string
46
+ ): string {
47
+ // 1) Validate timestamp first
48
+ const localDt = dayjs(localIso);
49
+ if (!localDt.isValid()) throw new InvalidTimestampError(localIso);
50
+
51
+ // 2) Apply timezone, catching only invalid timezone errors
52
+ let dt;
53
+ try {
54
+ dt = dayjs.tz(localIso, timeZone);
55
+ } catch {
56
+ throw new UnknownTimezoneError(timeZone);
57
+ }
58
+
59
+ return dt.utc().format();
60
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,18 @@
1
+ export class UnknownAirportError extends Error {
2
+ constructor(iata: string) {
3
+ super(`Unknown airport IATA code: ${iata}`);
4
+ this.name = 'UnknownAirportError';
5
+ }
6
+ }
7
+ export class InvalidTimestampError extends Error {
8
+ constructor(ts: string) {
9
+ super(`Invalid ISO 8601 timestamp: ${ts}`);
10
+ this.name = 'InvalidTimestampError';
11
+ }
12
+ }
13
+ export class UnknownTimezoneError extends Error {
14
+ constructor(tz: string) {
15
+ super(`Unknown timezone: ${tz}`);
16
+ this.name = 'UnknownTimezoneError';
17
+ }
18
+ }
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export { convertToUTC, convertLocalToUTCByZone } from './converter';
2
+ export { getAirportInfo, AirportInfo } from './info';
3
+ export { UnknownAirportError, InvalidTimestampError, UnknownTimezoneError } from './errors';
package/src/info.ts ADDED
@@ -0,0 +1,20 @@
1
+ import { timezones } from './mapping/timezones';
2
+ import { geo, GeoEntry } from './mapping/geo';
3
+ import { UnknownAirportError } from './errors';
4
+
5
+ export interface AirportInfo {
6
+ timezone: string;
7
+ latitude: number;
8
+ longitude: number;
9
+ name: string;
10
+ city: string;
11
+ country: string;
12
+ }
13
+
14
+ /** @throws UnknownAirportError */
15
+ export function getAirportInfo(iata: string): AirportInfo {
16
+ const tz = timezones[iata];
17
+ const g = geo[iata];
18
+ if (!tz || !g) throw new UnknownAirportError(iata);
19
+ return { timezone: tz, ...g };
20
+ }