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,10 @@
1
+ version: 2
2
+ updates:
3
+ - package-ecosystem: "npm"
4
+ directory: "/" # Location of package.json
5
+ schedule:
6
+ interval: "daily" # Check every day
7
+ open-pull-requests-limit: 10 # Max 10 open PRs
8
+ labels:
9
+ - "dependencies" # Attach this label
10
+ versioning-strategy: "auto" # Allow patch, minor & major bumps
@@ -0,0 +1,13 @@
1
+ name: CI
2
+ on: [push, pull_request]
3
+ jobs:
4
+ build-and-test:
5
+ runs-on: ubuntu-latest
6
+ steps:
7
+ - uses: actions/checkout@v3
8
+ - uses: actions/setup-node@v3
9
+ with:
10
+ node-version: '20'
11
+ - run: npm ci
12
+ - run: npm run build
13
+ - run: npm test
@@ -0,0 +1,30 @@
1
+ name: "Auto-merge Dependabot updates"
2
+
3
+ on:
4
+ pull_request_target:
5
+ types:
6
+ - opened
7
+ - labeled
8
+ - unlabeled
9
+ - synchronize
10
+ - ready_for_review
11
+ branches:
12
+ - main
13
+
14
+ permissions:
15
+ pull-requests: write
16
+ contents: write
17
+
18
+ jobs:
19
+ automerge:
20
+ if: >
21
+ github.actor == 'dependabot[bot]' &&
22
+ contains(github.event.pull_request.labels.*.name, 'dependencies')
23
+ runs-on: ubuntu-latest
24
+
25
+ steps:
26
+ - name: Enable auto-merge when CI passes
27
+ uses: peter-evans/enable-pull-request-automerge@v2
28
+ with:
29
+ pull-request-number: ${{ github.event.pull_request.number }}
30
+ merge-method: squash
@@ -0,0 +1,45 @@
1
+ name: Publish
2
+
3
+ on:
4
+ schedule:
5
+ - cron: '0 2 * * *' # daily at 02:00 UTC
6
+ workflow_dispatch:
7
+
8
+ jobs:
9
+ release:
10
+ runs-on: ubuntu-latest
11
+ permissions:
12
+ contents: write
13
+
14
+ steps:
15
+ - name: Check out code
16
+ uses: actions/checkout@v3
17
+ with:
18
+ fetch-depth: 0
19
+ persist-credentials: true
20
+
21
+ - name: Write .npmrc for registry auth
22
+ run: |
23
+ # Public npm
24
+ echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > ~/.npmrc
25
+ # GitHub
26
+ echo "@elipeF:registry=https://npm.pkg.github.com" >> ~/.npmrc
27
+ echo "//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN}" >> ~/.npmrc
28
+ env:
29
+ NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
30
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
31
+
32
+ - name: Install dependencies
33
+ run: npm ci
34
+
35
+ - name: Build
36
+ run: npm run build
37
+
38
+ - name: Test
39
+ run: npm test
40
+
41
+ - name: Run semantic-release
42
+ env:
43
+ NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
44
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
45
+ run: npx semantic-release
@@ -0,0 +1,36 @@
1
+ name: Update IATA→Timezone & Geo Mapping
2
+
3
+ on:
4
+ schedule:
5
+ - cron: '0 0 * * *' # every day at 00:00 UTC
6
+ workflow_dispatch:
7
+
8
+ jobs:
9
+ refresh-mapping:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - name: Checkout repo
13
+ uses: actions/checkout@v3
14
+ - name: Setup Node.js
15
+ uses: actions/setup-node@v3
16
+ with:
17
+ node-version: '20'
18
+ - name: Install dependencies
19
+ run: npm ci
20
+ - name: Generate updated mapping
21
+ run: npm run update:mapping
22
+ - name: Build
23
+ run: npm run build
24
+ - name: Test
25
+ run: npm test
26
+ - name: Commit & push if changed
27
+ run: |
28
+ git config user.name "github-actions[bot]"
29
+ git config user.email "github-actions[bot]@users.noreply.github.com"
30
+ git add src/mapping/*
31
+ if ! git diff --cached --quiet; then
32
+ git commit -m "chore: daily update of mapping files"
33
+ git push
34
+ else
35
+ echo "No changes in mapping files"
36
+ fi
package/.nvmrc ADDED
@@ -0,0 +1 @@
1
+ 20
@@ -0,0 +1,27 @@
1
+ {
2
+ "branches": ["main"],
3
+ "repositoryUrl": "https://github.com/elipeF/airport-utils.git",
4
+ "plugins": [
5
+ [
6
+ "@semantic-release/commit-analyzer",
7
+ {
8
+ "preset": "conventionalcommits",
9
+ "releaseRules": [
10
+ { "type": "chore", "release": "patch" },
11
+ { "type": "docs", "release": "patch" },
12
+ { "type": "style", "release": "patch" },
13
+ { "type": "refactor", "release": "patch" },
14
+ { "type": "perf", "release": "patch" },
15
+ { "type": "test", "release": "patch" },
16
+ { "type": "merge", "release": "patch" }
17
+ ],
18
+ "parserOpts": {
19
+ "noteKeywords": ["BREAKING CHANGE", "BREAKING CHANGES"]
20
+ }
21
+ }
22
+ ],
23
+ "@semantic-release/release-notes-generator",
24
+ "@semantic-release/npm",
25
+ "@semantic-release/github"
26
+ ]
27
+ }
package/README.md ADDED
@@ -0,0 +1,123 @@
1
+ # airport-utils
2
+
3
+ Convert local ISO 8601 timestamps to UTC using airport IATA codes, with airport geo-data.
4
+
5
+ ## Features
6
+
7
+ - **Local → UTC** conversion only (ISO 8601 in, ISO 8601 UTC out)
8
+ - Built-in IATA→IANA timezone mapping (OPTD)
9
+ - Built-in airport geo-data: latitude, longitude, name, city, country
10
+ - TypeScript support, Node 20+
11
+ - Synchronous API with custom error classes
12
+ - Day.js (UTC & Timezone plugins) under the hood
13
+ - Daily auto-updated mapping via GitHub Actions
14
+ - Jest tests with 100% coverage
15
+ - Automated releases via semantic-release
16
+
17
+ ## Installation
18
+
19
+ ```bash
20
+ npm install airport-utils
21
+ ```
22
+
23
+ ## Usage
24
+
25
+ ```ts
26
+ import {
27
+ convertToUTC,
28
+ convertLocalToUTCByZone,
29
+ getAirportInfo,
30
+ UnknownAirportError,
31
+ InvalidTimestampError,
32
+ UnknownTimezoneError
33
+ } from 'airport-utils';
34
+
35
+ // Convert local time to UTC
36
+ try {
37
+ const utc = convertToUTC('2025-05-02T14:30', 'JFK');
38
+ console.log(utc); // "2025-05-02T18:30:00Z"
39
+ } catch (err) {
40
+ // handle UnknownAirportError or InvalidTimestampError
41
+ }
42
+
43
+ // Convert local time by zone
44
+ try {
45
+ const utc2 = convertLocalToUTCByZone('2025-05-02T14:30:00', 'Europe/London');
46
+ console.log(utc2); // "2025-05-02T13:30:00Z"
47
+ } catch (err) {
48
+ // handle UnknownTimezoneError or InvalidTimestampError
49
+ }
50
+
51
+ // Get full airport info
52
+ try {
53
+ const info = getAirportInfo('JFK');
54
+ console.log(info);
55
+ // {
56
+ // timezone: 'America/New_York',
57
+ // latitude: 40.6413,
58
+ // longitude: -73.7781,
59
+ // name: 'John F. Kennedy International Airport',
60
+ // city: 'New York',
61
+ // country: 'US'
62
+ // }
63
+ } catch (err) {
64
+ // handle UnknownAirportError
65
+ }
66
+ ```
67
+
68
+ ### API
69
+
70
+ ```ts
71
+ export function convertToUTC(
72
+ localIso: string,
73
+ iata: string
74
+ ): string;
75
+
76
+ export function convertLocalToUTCByZone(
77
+ localIso: string,
78
+ timeZone: string
79
+ ): string;
80
+
81
+ export function getAirportInfo(iata: string): {
82
+ timezone: string;
83
+ latitude: number;
84
+ longitude: number;
85
+ name: string;
86
+ city: string;
87
+ country: string;
88
+ };
89
+
90
+ export class UnknownAirportError extends Error {}
91
+ export class InvalidTimestampError extends Error {}
92
+ export class UnknownTimezoneError extends Error {}
93
+ ```
94
+
95
+ ## Updating Mappings
96
+
97
+ ```bash
98
+ npm run update:mapping
99
+ ```
100
+
101
+ Runs `scripts/generateMapping.ts` to fetch OPTD CSV and regenerate:
102
+ - `src/mapping/timezones.ts`
103
+ - `src/mapping/geo.ts`
104
+
105
+ ## Development
106
+
107
+ ```bash
108
+ npm ci
109
+ npm run build
110
+ npm test
111
+ npm run update:mapping
112
+ ```
113
+
114
+ ## CI & Release
115
+
116
+ - **ci.yml**: build & test on push/PR
117
+ - **update-mapping.yml**: daily at 00:00 UTC, updates mapping, builds & tests, auto-commit
118
+ - **publish.yml**: daily at 02:00 UTC, builds, tests, and runs semantic-release
119
+ - Semantic-release uses default commit-analyzer rules and publishes to npm via `NPM_TOKEN`
120
+
121
+ ## License
122
+
123
+ MIT
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,78 @@
1
+ #!/usr/bin/env node
2
+ import fetch from 'node-fetch';
3
+ import fs from 'fs';
4
+ import path from 'path';
5
+ async function generateMapping() {
6
+ const url = 'https://raw.githubusercontent.com/opentraveldata/opentraveldata/master/opentraveldata/optd_por_public.csv';
7
+ const res = await fetch(url);
8
+ if (!res.ok)
9
+ throw new Error(`Fetch failed: ${res.statusText}`);
10
+ const text = await res.text();
11
+ const lines = text.split('\n').filter(l => l.trim());
12
+ const header = lines[0].split('^');
13
+ const idx = {
14
+ iata: header.indexOf('iata_code'),
15
+ tz: header.indexOf('timezone'),
16
+ lat: header.indexOf('latitude'),
17
+ lon: header.indexOf('longitude'),
18
+ name: header.indexOf('name'),
19
+ city: header.indexOf('city_name_list'),
20
+ country: header.indexOf('country_code')
21
+ };
22
+ if (Object.values(idx).some(i => i < 0)) {
23
+ throw new Error('Missing required OPTD columns');
24
+ }
25
+ const timezonesMap = {};
26
+ const geoMap = {};
27
+ for (let i = 1; i < lines.length; i++) {
28
+ const cols = lines[i].split('^');
29
+ const code = cols[idx.iata];
30
+ if (!code || code.length !== 3)
31
+ continue;
32
+ const tz = cols[idx.tz];
33
+ if (tz)
34
+ timezonesMap[code] = tz;
35
+ const lat = parseFloat(cols[idx.lat]);
36
+ const lon = parseFloat(cols[idx.lon]);
37
+ const name = cols[idx.name];
38
+ const city = cols[idx.city].split(',')[0].trim();
39
+ const country = cols[idx.country];
40
+ if (!isNaN(lat) && !isNaN(lon) && name && city && country) {
41
+ geoMap[code] = { latitude: lat, longitude: lon, name, city, country };
42
+ }
43
+ }
44
+ const sortedCodes = Object.keys(timezonesMap).sort();
45
+ const sortedTz = Object.fromEntries(sortedCodes.map(c => [c, timezonesMap[c]]));
46
+ const sortedGeo = Object.fromEntries(sortedCodes.filter(c => geoMap[c]).map(c => [c, geoMap[c]]));
47
+ const dir = path.resolve(path.dirname(import.meta.url.replace('file://', '')), '../src/mapping');
48
+ fs.mkdirSync(dir, { recursive: true });
49
+ // Write TypeScript modules
50
+ const tzTs = [
51
+ '// generated — do not edit',
52
+ 'export const timezones: Record<string, string> = ',
53
+ JSON.stringify(sortedTz, null, 2) + ';',
54
+ ].join('\n');
55
+ fs.writeFileSync(path.join(dir, 'timezones.ts'), tzTs + '\n');
56
+ const geoEntries = Object.entries(sortedGeo)
57
+ .map(([code, g]) => ` "${code}": ${JSON.stringify(g)}`)
58
+ .join(',\n');
59
+ const geoTs = [
60
+ '// generated — do not edit',
61
+ 'export interface GeoEntry {',
62
+ ' latitude: number;',
63
+ ' longitude: number;',
64
+ ' name: string;',
65
+ ' city: string;',
66
+ ' country: string;',
67
+ '}', '',
68
+ 'export const geo: Record<string, GeoEntry> = {',
69
+ geoEntries,
70
+ '};',
71
+ ].join('\n');
72
+ fs.writeFileSync(path.join(dir, 'geo.ts'), geoTs + '\n');
73
+ console.log(`✅ Mappings: ${sortedCodes.length} timezones, ${Object.keys(sortedGeo).length} geo entries`);
74
+ }
75
+ generateMapping().catch(err => {
76
+ console.error(err);
77
+ process.exit(1);
78
+ });
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Convert local ISO 8601 (YYYY-MM-DDTHH:mm) at an airport into UTC ISO string.
3
+ * @throws UnknownAirportError | InvalidTimestampError
4
+ */
5
+ export declare function convertToUTC(localIso: string, iata: string): string;
6
+ /**
7
+ * Convert local ISO 8601 string in any IANA timezone to UTC ISO string.
8
+ * @throws UnknownTimezoneError | InvalidTimestampError
9
+ */
10
+ export declare function convertLocalToUTCByZone(localIso: string, timeZone: string): string;
@@ -0,0 +1,50 @@
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 { UnknownAirportError, InvalidTimestampError, UnknownTimezoneError } from './errors';
6
+ // Initialize plugins
7
+ dayjs.extend(utc);
8
+ dayjs.extend(timezone);
9
+ /**
10
+ * Convert local ISO 8601 (YYYY-MM-DDTHH:mm) at an airport into UTC ISO string.
11
+ * @throws UnknownAirportError | InvalidTimestampError
12
+ */
13
+ export function convertToUTC(localIso, iata) {
14
+ const tz = timezones[iata];
15
+ if (!tz)
16
+ throw new UnknownAirportError(iata);
17
+ // 1) Pre-validate timestamp
18
+ const localDt = dayjs(localIso);
19
+ if (!localDt.isValid())
20
+ throw new InvalidTimestampError(localIso);
21
+ // 2) Then apply timezone conversion
22
+ let dt;
23
+ try {
24
+ dt = dayjs.tz(localIso, tz);
25
+ }
26
+ catch {
27
+ // Shouldn't happen for valid tz, but just in case:
28
+ throw new InvalidTimestampError(localIso);
29
+ }
30
+ return dt.utc().format(); // "YYYY-MM-DDTHH:mm:ssZ"
31
+ }
32
+ /**
33
+ * Convert local ISO 8601 string in any IANA timezone to UTC ISO string.
34
+ * @throws UnknownTimezoneError | InvalidTimestampError
35
+ */
36
+ export function convertLocalToUTCByZone(localIso, timeZone) {
37
+ // 1) Validate timestamp first
38
+ const localDt = dayjs(localIso);
39
+ if (!localDt.isValid())
40
+ throw new InvalidTimestampError(localIso);
41
+ // 2) Apply timezone, catching only invalid timezone errors
42
+ let dt;
43
+ try {
44
+ dt = dayjs.tz(localIso, timeZone);
45
+ }
46
+ catch {
47
+ throw new UnknownTimezoneError(timeZone);
48
+ }
49
+ return dt.utc().format();
50
+ }
@@ -0,0 +1,9 @@
1
+ export declare class UnknownAirportError extends Error {
2
+ constructor(iata: string);
3
+ }
4
+ export declare class InvalidTimestampError extends Error {
5
+ constructor(ts: string);
6
+ }
7
+ export declare class UnknownTimezoneError extends Error {
8
+ constructor(tz: string);
9
+ }
@@ -0,0 +1,18 @@
1
+ export class UnknownAirportError extends Error {
2
+ constructor(iata) {
3
+ super(`Unknown airport IATA code: ${iata}`);
4
+ this.name = 'UnknownAirportError';
5
+ }
6
+ }
7
+ export class InvalidTimestampError extends Error {
8
+ constructor(ts) {
9
+ super(`Invalid ISO 8601 timestamp: ${ts}`);
10
+ this.name = 'InvalidTimestampError';
11
+ }
12
+ }
13
+ export class UnknownTimezoneError extends Error {
14
+ constructor(tz) {
15
+ super(`Unknown timezone: ${tz}`);
16
+ this.name = 'UnknownTimezoneError';
17
+ }
18
+ }
@@ -0,0 +1,3 @@
1
+ export { convertToUTC, convertLocalToUTCByZone } from './converter';
2
+ export { getAirportInfo, AirportInfo } from './info';
3
+ export { UnknownAirportError, InvalidTimestampError, UnknownTimezoneError } from './errors';
@@ -0,0 +1,3 @@
1
+ export { convertToUTC, convertLocalToUTCByZone } from './converter';
2
+ export { getAirportInfo } from './info';
3
+ export { UnknownAirportError, InvalidTimestampError, UnknownTimezoneError } from './errors';
@@ -0,0 +1,10 @@
1
+ export interface AirportInfo {
2
+ timezone: string;
3
+ latitude: number;
4
+ longitude: number;
5
+ name: string;
6
+ city: string;
7
+ country: string;
8
+ }
9
+ /** @throws UnknownAirportError */
10
+ export declare function getAirportInfo(iata: string): AirportInfo;
@@ -0,0 +1,11 @@
1
+ import { timezones } from './mapping/timezones';
2
+ import { geo } from './mapping/geo';
3
+ import { UnknownAirportError } from './errors';
4
+ /** @throws UnknownAirportError */
5
+ export function getAirportInfo(iata) {
6
+ const tz = timezones[iata];
7
+ const g = geo[iata];
8
+ if (!tz || !g)
9
+ throw new UnknownAirportError(iata);
10
+ return { timezone: tz, ...g };
11
+ }
@@ -0,0 +1,8 @@
1
+ export interface GeoEntry {
2
+ latitude: number;
3
+ longitude: number;
4
+ name: string;
5
+ city: string;
6
+ country: string;
7
+ }
8
+ export declare const geo: Record<string, GeoEntry>;