diff --git a/.eslintrc.yml b/.eslintrc.yml index 1272a16..36d5b59 100644 --- a/.eslintrc.yml +++ b/.eslintrc.yml @@ -2,10 +2,15 @@ extends: - eslint:recommended - airbnb-base - prettier + - plugin:@typescript-eslint/recommended +parser: "@typescript-eslint/parser" +plugins: + - "@typescript-eslint" overrides: [] parserOptions: ecmaVersion: latest sourceType: module rules: no-unexpected-multiline: off + import/no-unresolved: off import/extensions: off diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 9bee743..2f8e677 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -15,6 +15,8 @@ jobs: run: | npm ci npx playwright install --with-deps + - name: Typecheck + run: npm run check - name: Lint run: npm run lint - name: Run tests diff --git a/.gitignore b/.gitignore index afc0b91..a2454bd 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ /parking-lots-update.geojson /city-update.geojson +*.tsbuildinfo diff --git a/package-lock.json b/package-lock.json index dc58730..60c8345 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,13 +19,19 @@ "@parcel/compressor-gzip": "^2.10.3", "@parcel/transformer-sass": "^2.10.3", "@playwright/test": "^1.34.3", + "@types/geojson": "^7946.0.14", + "@types/leaflet": "^1.9.8", + "@types/node": "^20.11.20", + "@typescript-eslint/eslint-plugin": "^7.0.2", "eslint": "^8.37.0", "eslint-config-airbnb-base": "^15.0.0", "eslint-config-prettier": "^8.8.0", "http-server": "^14.1.1", "parcel": "^2.10.3", "playwright": "^1.34.3", - "prettier": "^2.8.7" + "prettier": "^2.8.7", + "ts-node": "^10.9.2", + "typescript": "^5.3.3" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -231,6 +237,18 @@ "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", @@ -353,6 +371,31 @@ "integrity": "sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw==", "dev": true }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.4.15", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", + "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, "node_modules/@lezer/common": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.2.1.tgz", @@ -2337,6 +2380,42 @@ "node": ">=10.13.0" } }, + "node_modules/@tsconfig/node10": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz", + "integrity": "sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA==", + "dev": true + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true + }, + "node_modules/@types/geojson": { + "version": "7946.0.14", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.14.tgz", + "integrity": "sha512-WCfD5Ht3ZesJUsONdhvm84dmzWOiOzOAqOncN0++w0lBw1o8OuDNJF2McvvCef/yBqb/HYRahp1BYtODFQ8bRg==", + "dev": true + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true + }, "node_modules/@types/json5": { "version": "0.0.29", "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", @@ -2344,6 +2423,245 @@ "dev": true, "peer": true }, + "node_modules/@types/leaflet": { + "version": "1.9.8", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.8.tgz", + "integrity": "sha512-EXdsL4EhoUtGm2GC2ZYtXn+Fzc6pluVgagvo2VC1RHWToLGlTRwVYoDpqS/7QXa01rmDyBjJk3Catpf60VMkwg==", + "dev": true, + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/node": { + "version": "20.11.20", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.20.tgz", + "integrity": "sha512-7/rR21OS+fq8IyHTgtLkDK949uzsa6n8BkziAKtPVpugIkO6D+/ooXMvzXxDnZrmtXVfjb1bKQafYpb8s89LOg==", + "dev": true, + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/semver": { + "version": "7.5.7", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.7.tgz", + "integrity": "sha512-/wdoPq1QqkSj9/QOeKkFquEuPzQbHTWAMPH/PaUMB+JuR31lXhlWXRZ52IpfDYVlDOUBvX09uBrPwxGT1hjNBg==", + "dev": true + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.0.2.tgz", + "integrity": "sha512-/XtVZJtbaphtdrWjr+CJclaCVGPtOdBpFEnvtNf/jRV0IiEemRrL0qABex/nEt8isYcnFacm3nPHYQwL+Wb7qg==", + "dev": true, + "dependencies": { + "@eslint-community/regexpp": "^4.5.1", + "@typescript-eslint/scope-manager": "7.0.2", + "@typescript-eslint/type-utils": "7.0.2", + "@typescript-eslint/utils": "7.0.2", + "@typescript-eslint/visitor-keys": "7.0.2", + "debug": "^4.3.4", + "graphemer": "^1.4.0", + "ignore": "^5.2.4", + "natural-compare": "^1.4.0", + "semver": "^7.5.4", + "ts-api-utils": "^1.0.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^7.0.0", + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.0.2.tgz", + "integrity": "sha512-GdwfDglCxSmU+QTS9vhz2Sop46ebNCXpPPvsByK7hu0rFGRHL+AusKQJ7SoN+LbLh6APFpQwHKmDSwN35Z700Q==", + "dev": true, + "peer": true, + "dependencies": { + "@typescript-eslint/scope-manager": "7.0.2", + "@typescript-eslint/types": "7.0.2", + "@typescript-eslint/typescript-estree": "7.0.2", + "@typescript-eslint/visitor-keys": "7.0.2", + "debug": "^4.3.4" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.0.2.tgz", + "integrity": "sha512-l6sa2jF3h+qgN2qUMjVR3uCNGjWw4ahGfzIYsCtFrQJCjhbrDPdiihYT8FnnqFwsWX+20hK592yX9I2rxKTP4g==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "7.0.2", + "@typescript-eslint/visitor-keys": "7.0.2" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.0.2.tgz", + "integrity": "sha512-IKKDcFsKAYlk8Rs4wiFfEwJTQlHcdn8CLwLaxwd6zb8HNiMcQIFX9sWax2k4Cjj7l7mGS5N1zl7RCHOVwHq2VQ==", + "dev": true, + "dependencies": { + "@typescript-eslint/typescript-estree": "7.0.2", + "@typescript-eslint/utils": "7.0.2", + "debug": "^4.3.4", + "ts-api-utils": "^1.0.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/types": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.0.2.tgz", + "integrity": "sha512-ZzcCQHj4JaXFjdOql6adYV4B/oFOFjPOC9XYwCaZFRvqN8Llfvv4gSxrkQkd2u4Ci62i2c6W6gkDwQJDaRc4nA==", + "dev": true, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.0.2.tgz", + "integrity": "sha512-3AMc8khTcELFWcKcPc0xiLviEvvfzATpdPj/DXuOGIdQIIFybf4DMT1vKRbuAEOFMwhWt7NFLXRkbjsvKZQyvw==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "7.0.2", + "@typescript-eslint/visitor-keys": "7.0.2", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "minimatch": "9.0.3", + "semver": "^7.5.4", + "ts-api-utils": "^1.0.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", + "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.0.2.tgz", + "integrity": "sha512-PZPIONBIB/X684bhT1XlrkjNZJIEevwkKDsdwfiu1WeqBxYEEdIgVDgm8/bbKHVu+6YOpeRqcfImTdImx/4Bsw==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@types/json-schema": "^7.0.12", + "@types/semver": "^7.5.0", + "@typescript-eslint/scope-manager": "7.0.2", + "@typescript-eslint/types": "7.0.2", + "@typescript-eslint/typescript-estree": "7.0.2", + "semver": "^7.5.4" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.0.2.tgz", + "integrity": "sha512-8Y+YiBmqPighbm5xA2k4wKTxRzx9EkBu7Rlw+WHqMvRJ3RPz/BMBO9b2ru0LUNmXg120PHUXD5+SWFy2R8DqlQ==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "7.0.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, "node_modules/@ungap/structured-clone": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", @@ -2376,6 +2694,15 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/acorn-walk": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.2.tgz", + "integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==", + "dev": true, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -2428,6 +2755,12 @@ "node": ">= 8" } }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -2470,6 +2803,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/array.prototype.filter": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/array.prototype.filter/-/array.prototype.filter-1.0.3.tgz", @@ -2897,6 +3239,12 @@ } } }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true + }, "node_modules/cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -3132,6 +3480,27 @@ "node": ">=0.10" } }, + "node_modules/diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "dev": true, + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -3692,6 +4061,34 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "dev": true }, + "node_modules/fast-glob": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", + "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -3972,6 +4369,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/gopd": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", @@ -4917,6 +5334,12 @@ "node": ">=10" } }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true + }, "node_modules/mdn-data": { "version": "2.0.30", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz", @@ -4925,6 +5348,15 @@ "optional": true, "peer": true }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, "node_modules/micromatch": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", @@ -5867,6 +6299,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -6083,6 +6524,61 @@ "node": ">=8.0" } }, + "node_modules/ts-api-utils": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.2.1.tgz", + "integrity": "sha512-RIYA36cJn2WiH9Hy77hdF9r7oEwxAtB/TS9/S4Qd90Ap4z5FSiin5zEiTL44OII1Y3IIlEvxwxFUVgrHSZ/UpA==", + "dev": true, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "typescript": ">=4.2.0" + } + }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, "node_modules/tsconfig-paths": { "version": "3.15.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", @@ -6204,6 +6700,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/typescript": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", + "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/unbox-primitive": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", @@ -6219,6 +6728,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true + }, "node_modules/union": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/union/-/union-0.5.0.tgz", @@ -6283,6 +6798,12 @@ "node": ">= 4" } }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true + }, "node_modules/weak-lru-cache": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/weak-lru-cache/-/weak-lru-cache-1.2.2.tgz", @@ -6361,6 +6882,15 @@ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 4c8191d..3894b33 100644 --- a/package.json +++ b/package.json @@ -7,12 +7,13 @@ "start": "parcel", "build": "rm -rf dist; parcel build --detailed-report", "test": "playwright test", + "check": "tsc --noEmit", "fmt": "prettier --write .", "fix": "prettier --write scripts/ src/ tests/; eslint --fix scripts/ src/ tests/", "lint": "prettier --check . && eslint scripts/ src/ tests/", - "add-city": "node scripts/add-city.js", - "update-city-boundaries": "node scripts/update-city-boundaries.js", - "update-lots": "node scripts/update-lots.js", + "add-city": "ts-node-esm scripts/add-city.ts", + "update-city-boundaries": "ts-node-esm scripts/update-city-boundaries.ts", + "update-lots": "ts-node-esm scripts/update-lots.ts", "serve-dist": "cd dist; http-server", "test-dist": "PORT=8080 playwright test" }, @@ -28,13 +29,19 @@ "@parcel/compressor-gzip": "^2.10.3", "@parcel/transformer-sass": "^2.10.3", "@playwright/test": "^1.34.3", + "@types/geojson": "^7946.0.14", + "@types/leaflet": "^1.9.8", + "@types/node": "^20.11.20", + "@typescript-eslint/eslint-plugin": "^7.0.2", "eslint": "^8.37.0", "eslint-config-airbnb-base": "^15.0.0", "eslint-config-prettier": "^8.8.0", "http-server": "^14.1.1", "parcel": "^2.10.3", "playwright": "^1.34.3", - "prettier": "^2.8.7" + "prettier": "^2.8.7", + "ts-node": "^10.9.2", + "typescript": "^5.3.3" }, "targets": { "default": { diff --git a/scripts/add-city.js b/scripts/add-city.ts similarity index 69% rename from scripts/add-city.js rename to scripts/add-city.ts index f5c1a88..8f6f4ef 100644 --- a/scripts/add-city.js +++ b/scripts/add-city.ts @@ -1,14 +1,20 @@ -/* eslint-disable no-console */ import fs from "fs/promises"; import { Ok, Err, + OrError, determineArgs, updateCoordinates, updateParkingLots, -} from "./base.js"; + valueOrExit, + exitOnError, +} from "./base.ts"; +import { CityId } from "../src/js/types.ts"; -const addScoreCard = async (cityId, cityName) => { +const addScoreCard: ( + cityId: CityId, + cityName: string +) => Promise> = async (cityId, cityName) => { const newEntry = { name: cityName, percentage: "FILL ME IN, e.g. 23%", @@ -21,20 +27,21 @@ const addScoreCard = async (cityId, cityName) => { }; const originalFilePath = "data/score-cards.json"; - let originalData; + let originalData: Record>; try { const rawOriginalData = await fs.readFile(originalFilePath, "utf8"); originalData = JSON.parse(rawOriginalData); - } catch (err) { + } catch (err: unknown) { + const { message } = err as Error; return Err( - `Issue reading the score card file path ${originalFilePath}: ${err.message}` + `Issue reading the score card file path ${originalFilePath}: ${message}` ); } originalData[cityId] = newEntry; const sortedKeys = Object.keys(originalData).sort(); - const sortedData = {}; + const sortedData: Record> = {}; sortedKeys.forEach((key) => { sortedData[key] = originalData[key]; }); @@ -45,11 +52,10 @@ const addScoreCard = async (cityId, cityName) => { const main = async () => { const args = determineArgs("add-city", process.argv.slice(2)); - if (args.error) { - console.error("Argument error:", args.error); - process.exit(1); - } - const { cityName, cityId } = args.value; + const { cityName, cityId } = valueOrExit( + args, + (msg) => `Argument error: ${msg}` + ); const boundariesResult = await updateCoordinates( "add-city", @@ -58,10 +64,7 @@ const main = async () => { "data/city-boundaries.geojson", "city-update.geojson" ); - if (boundariesResult.error) { - console.error("Error:", boundariesResult.error); - process.exit(1); - } + exitOnError(boundariesResult, (msg) => `Error: ${msg}`); const lotsResult = await updateParkingLots( cityId, @@ -69,16 +72,11 @@ const main = async () => { "parking-lots-update.geojson", `data/parking-lots/${cityId}.geojson` ); - if (lotsResult.error) { - console.error("Error:", lotsResult.error); - process.exit(1); - } + exitOnError(lotsResult, (msg) => `Error ${msg}`); const scoreCardResult = await addScoreCard(cityId, cityName); - if (scoreCardResult.error) { - console.error("Error:", scoreCardResult.error); - process.exit(1); - } + exitOnError(scoreCardResult, (msg) => `Error ${msg}`); + /* eslint-disable-next-line no-console */ console.log( `Almost done! Now, fill in the score card values in data/score-cards.json. Then, run 'npm run fmt'. Then, 'npm start' and see if the site is what you expect. diff --git a/scripts/base.js b/scripts/base.ts similarity index 67% rename from scripts/base.js rename to scripts/base.ts index 2015f5f..47f2655 100644 --- a/scripts/base.js +++ b/scripts/base.ts @@ -1,10 +1,40 @@ import fs from "fs/promises"; -import { parseCityIdFromJson } from "../src/js/cityId.js"; +import { + FeatureCollection, + GeoJsonProperties, + Polygon, + Feature, +} from "geojson"; +import { parseCityIdFromJson } from "../src/js/cityId.ts"; +import { CityId } from "../src/js/types"; + +type ok = { value: T }; +type error = { error: string }; +type OrError = ok | error; // Rather than using `try/catch`, we return either `Ok` or `Err`. // This emulates Rust's `Result` type. -const Ok = (value) => ({ value }); -const Err = (error) => ({ error }); +const Ok = (value?: T): OrError => ({ value } as ok); +const Err = (err: string): OrError => ({ error: err } as error); + +export const valueOrExit = ( + res: OrError, + f?: (msg: string) => string +): T => { + if ("error" in res) { + // eslint-disable-next-line no-console + console.error(f ? f(res.error) : res.error); + process.exit(1); + } + return res.value; +}; + +export const exitOnError = ( + res: OrError, + f?: (msg: string) => string +): void => { + valueOrExit(res, f); +}; /** * Determine the city name and city ID. @@ -13,7 +43,13 @@ const Err = (error) => ({ error }); * @param list[string] processArgv - all argv after the first two elements. * @return either an `error` or `value` object. */ -const determineArgs = (scriptCommand, processArgv) => { +const determineArgs: ( + scriptCommand: string, + processArgv: string[] +) => OrError<{ cityName: string; cityId: CityId }> = ( + scriptCommand, + processArgv +) => { if (processArgv.length !== 1) { return Err( `Must provide exactly one argument (the city/state name). For example, @@ -38,19 +74,20 @@ const determineArgs = (scriptCommand, processArgv) => { instructions, which you should log. */ const updateCoordinates = async ( - scriptCommand, - cityId, - addCity, - originalFilePath, - updateFilePath + scriptCommand: string, + cityId: CityId, + addCity: boolean, + originalFilePath: string, + updateFilePath: string ) => { - let newData; + let newData: FeatureCollection; try { const rawNewData = await fs.readFile(updateFilePath, "utf8"); newData = JSON.parse(rawNewData); - } catch (err) { + } catch (err: unknown) { + const { message } = err as Error; return Err( - `Issue reading the update file path ${updateFilePath}: ${err.message}` + `Issue reading the update file path ${updateFilePath}: ${message}` ); } @@ -60,16 +97,18 @@ const updateCoordinates = async ( ); } - const newCoordinates = newData.features[0].geometry.coordinates; - const newGeometryType = newData.features[0].geometry.type; + const polygon = newData.features[0].geometry; + const newCoordinates = polygon.coordinates; + const newGeometryType = polygon.type; - let originalData; + let originalData: FeatureCollection; try { const rawOriginalData = await fs.readFile(originalFilePath, "utf8"); originalData = JSON.parse(rawOriginalData); - } catch (err) { + } catch (err: unknown) { + const { message } = err as Error; return Err( - `Issue reading the original data file path ${originalFilePath}: ${err.message}` + `Issue reading the original data file path ${originalFilePath}: ${message}` ); } @@ -78,11 +117,11 @@ const updateCoordinates = async ( type: "Feature", properties: { id: cityId }, geometry: { type: newGeometryType, coordinates: newCoordinates }, - }; + } as Feature; originalData.features.push(newEntry); } else { const cityOriginalData = originalData.features.find( - (feature) => feature.properties.id === cityId + (feature) => feature?.properties?.id === cityId ); if (!cityOriginalData) { return Err( @@ -94,10 +133,10 @@ const updateCoordinates = async ( // Make sure the data is still sorted. originalData.features.sort((a, b) => { - if (a.properties.id < b.properties.id) { + if (a.properties?.id < b.properties?.id) { return -1; } - if (a.properties.id > b.properties.id) { + if (a.properties?.id > b.properties?.id) { return 1; } return 0; @@ -118,18 +157,19 @@ const updateCoordinates = async ( instructions, which you should log. */ const updateParkingLots = async ( - cityId, - addCity, - originalFilePath, - updateFilePath + cityId: CityId, + addCity: boolean, + originalFilePath: string, + updateFilePath: string ) => { let newData; try { const rawNewData = await fs.readFile(originalFilePath, "utf8"); newData = JSON.parse(rawNewData); - } catch (err) { + } catch (err: unknown) { + const { message } = err as Error; return Err( - `Issue reading the update file path parking-lots-update.geojson: ${err.message}` + `Issue reading the update file path parking-lots-update.geojson: ${message}` ); } @@ -147,9 +187,10 @@ const updateParkingLots = async ( try { const rawOriginalData = await fs.readFile(updateFilePath, "utf8"); originalData = JSON.parse(rawOriginalData); - } catch (err) { + } catch (err: unknown) { + const { message } = err as Error; return Err( - `Issue reading the original data file path ${updateFilePath}: ${err.message}` + `Issue reading the original data file path ${updateFilePath}: ${message}` ); } originalData.geometry.coordinates = newCoordinates; @@ -167,4 +208,13 @@ const updateParkingLots = async ( return Ok("File updated successfully!"); }; -export { Ok, Err, determineArgs, updateCoordinates, updateParkingLots }; +export { + Ok, + Err, + determineArgs, + updateCoordinates, + updateParkingLots, + OrError, + error, + ok, +}; diff --git a/scripts/update-city-boundaries.js b/scripts/update-city-boundaries.js deleted file mode 100644 index 9645446..0000000 --- a/scripts/update-city-boundaries.js +++ /dev/null @@ -1,31 +0,0 @@ -/* eslint-disable no-console */ -import { determineArgs, updateCoordinates } from "./base.js"; - -const main = async () => { - const args = determineArgs("update-city-boundaries", process.argv.slice(2)); - if (args.error) { - console.error("Argument error:", args.error); - process.exit(1); - } - - const { cityId } = args.value; - const result = await updateCoordinates( - "update-city-boundaries", - cityId, - false, - "data/city-boundaries.geojson", - "city-update.geojson" - ); - - if (result.error) { - console.error("Error:", result.error); - process.exit(1); - } - console.log( - `${result.value} Now, run 'npm run fmt'. Then, 'npm start' and - see if the site is what you expect. - ` - ); -}; - -main(); diff --git a/scripts/update-city-boundaries.ts b/scripts/update-city-boundaries.ts new file mode 100644 index 0000000..53a44cf --- /dev/null +++ b/scripts/update-city-boundaries.ts @@ -0,0 +1,23 @@ +import { determineArgs, valueOrExit, updateCoordinates } from "./base.js"; + +const main = async () => { + const args = determineArgs("update-city-boundaries", process.argv.slice(2)); + const { cityId } = valueOrExit(args, (msg) => `Argument error: ${msg}`); + const result = await updateCoordinates( + "update-city-boundaries", + cityId, + false, + "data/city-boundaries.geojson", + "city-update.geojson" + ); + + const value = valueOrExit(result, (msg) => `Error: ${msg}`); + /* eslint-disable-next-line no-console */ + console.log( + `${value} Now, run 'npm run fmt'. Then, 'npm start' and + see if the site is what you expect. + ` + ); +}; + +main(); diff --git a/scripts/update-lots.js b/scripts/update-lots.js deleted file mode 100644 index 5419659..0000000 --- a/scripts/update-lots.js +++ /dev/null @@ -1,30 +0,0 @@ -/* eslint-disable no-console */ -import { determineArgs, updateParkingLots } from "./base.js"; - -const main = async () => { - const args = determineArgs("update-lots", process.argv.slice(2)); - if (args.error) { - console.error("Argument error:", args.error); - process.exit(1); - } - - const { cityId } = args.value; - const result = await updateParkingLots( - cityId, - false, - "parking-lots-update.geojson", - `data/parking-lots/${cityId}.geojson` - ); - - if (result.error) { - console.error("Error:", result.error); - process.exit(1); - } - console.log( - `${result.value} Now, run 'npm run fmt'. Then, 'npm start' and - see if the site is what you expect. - ` - ); -}; - -main(); diff --git a/scripts/update-lots.ts b/scripts/update-lots.ts new file mode 100644 index 0000000..e1f8a6b --- /dev/null +++ b/scripts/update-lots.ts @@ -0,0 +1,22 @@ +import { valueOrExit, determineArgs, updateParkingLots } from "./base.js"; + +const main = async () => { + const args = determineArgs("update-lots", process.argv.slice(2)); + const { cityId } = valueOrExit(args, (msg) => `Argument error: ${msg}`); + const result = await updateParkingLots( + cityId, + false, + "parking-lots-update.geojson", + `data/parking-lots/${cityId}.geojson` + ); + + const value = valueOrExit(result, (msg) => `Error: ${msg}`); + /* eslint-disable-next-line no-console */ + console.log( + `${value} Now, run 'npm run fmt'. Then, 'npm start' and + see if the site is what you expect. + ` + ); +}; + +main(); diff --git a/src/css/_header.scss b/src/css/_header.scss index 69a46bf..60b7568 100644 --- a/src/css/_header.scss +++ b/src/css/_header.scss @@ -71,6 +71,10 @@ header { height: 40px; } +.choices__input { + font-size: 16px; +} + .choices__list--dropdown, .choices__list[aria-expanded] { z-index: 1001; diff --git a/src/js/about.js b/src/js/about.js deleted file mode 100644 index b085916..0000000 --- a/src/js/about.js +++ /dev/null @@ -1,33 +0,0 @@ -/* global document, window */ - -/** - * Set up event listeners to open and close the about popup. - */ -const setUpAbout = () => { - const aboutElement = document.querySelector(".about-text-popup"); - const infoButton = document.querySelector(".header-about-icon"); - infoButton.addEventListener("click", () => { - aboutElement.style.display = - aboutElement.style.display !== "block" ? "block" : "none"; - }); - - // closes window on clicks outside the info popup - window.addEventListener("click", (event) => { - if ( - !infoButton.contains(event.target) && - aboutElement.style.display === "block" && - !aboutElement.contains(event.target) - ) { - aboutElement.style.display = "none"; - infoButton.classList.toggle("active"); - } - }); - - // Note that the close element will only render when the about text popup is rendered. - // So, it only ever makes sense for a click to close. - document.querySelector(".about-close").addEventListener("click", () => { - aboutElement.style.display = "none"; - }); -}; - -export default setUpAbout; diff --git a/src/js/about.ts b/src/js/about.ts new file mode 100644 index 0000000..5ae5a52 --- /dev/null +++ b/src/js/about.ts @@ -0,0 +1,41 @@ +/* global document, window */ + +/** + * Set up event listeners to open and close the about popup. + */ +const setUpAbout = () => { + const aboutElement: HTMLElement | null = + document.querySelector(".about-text-popup"); + const infoButton = document.querySelector(".header-about-icon"); + if (infoButton && aboutElement) { + infoButton.addEventListener("click", () => { + aboutElement.style.display = + aboutElement.style.display !== "block" ? "block" : "none"; + }); + + // closes window on clicks outside the info popup + window.addEventListener("click", (event: MouseEvent) => { + const clickTarget = event?.target as Element; + if ( + !infoButton.contains(clickTarget) && + aboutElement.style.display === "block" && + !aboutElement.contains(clickTarget) + ) { + aboutElement.style.display = "none"; + infoButton.classList.toggle("active"); + } + }); + + // Note that the close element will only render when the about text popup is rendered. + // So, it only ever makes sense for a click to close. + const aboutClose: HTMLAnchorElement | null = + document.querySelector(".about-close"); + if (aboutClose) { + aboutClose.addEventListener("click", () => { + aboutElement.style.display = "none"; + }); + } + } +}; + +export default setUpAbout; diff --git a/src/js/cityId.js b/src/js/cityId.ts similarity index 82% rename from src/js/cityId.js rename to src/js/cityId.ts index f1898bd..650fd65 100644 --- a/src/js/cityId.js +++ b/src/js/cityId.ts @@ -1,10 +1,12 @@ +import { CityId } from "./types"; + /** * Extract the city ID from the URL's `#`, if present. * * @param string windowUrl: The `window.location.href` global * @return string: Returns e.g. `st.-louis-mo` if present, else the empty string */ -const extractCityIdFromUrl = (windowUrl) => +const extractCityIdFromUrl = (windowUrl: string) => windowUrl.indexOf("#parking-reform-map=") === -1 ? "" : windowUrl.split("#")[1].split("=")[1].toLowerCase(); @@ -15,7 +17,7 @@ const extractCityIdFromUrl = (windowUrl) => * @param string jsonCityName: the `Name` property from JSON, e.g. `"My City, AZ"` * @return string: the city ID, e.g. `st.-louis-mo`. */ -const parseCityIdFromJson = (jsonCityName) => +const parseCityIdFromJson = (jsonCityName: string) => jsonCityName.toLowerCase().replace(/ /g, "-").replace(/,/g, ""); /** @@ -25,7 +27,7 @@ const parseCityIdFromJson = (jsonCityName) => * @param string cityId: e.g. `st.-louis-mo` * @return string: the URL to share */ -const determineShareUrl = (windowUrl, cityId) => { +const determineShareUrl = (windowUrl: string, cityId: CityId) => { const [baseUrl] = windowUrl.split("#"); return `${baseUrl}#parking-reform-map=${cityId}`; }; diff --git a/src/js/declarations.td.ts b/src/js/declarations.td.ts new file mode 100644 index 0000000..63abad6 --- /dev/null +++ b/src/js/declarations.td.ts @@ -0,0 +1,16 @@ +declare module "~/data/city-boundaries.geojson" { + import { FeatureCollection, Polygon, GeoJsonProperties } from "geojson"; + + const value: FeatureCollection; + export default value; +} + +declare module "~/data/score-cards.json" { + const value: import("./types").ScoreCardsDetails; + export default value; +} + +declare module "~/data/parking-lots/*" { + const value: unknown; + export default value; +} diff --git a/src/js/dropdown.js b/src/js/dropdown.ts similarity index 85% rename from src/js/dropdown.js rename to src/js/dropdown.ts index 058bbde..8e9620b 100644 --- a/src/js/dropdown.js +++ b/src/js/dropdown.ts @@ -1,6 +1,7 @@ import Choices from "choices.js"; import "choices.js/public/assets/styles/choices.css"; import scoreCardsData from "../../data/score-cards.json"; +import { CityId } from "./types"; export const DROPDOWN = new Choices("#city-choice", { allowHTML: false, @@ -8,7 +9,7 @@ export const DROPDOWN = new Choices("#city-choice", { searchEnabled: true, }); -const setUpDropdown = (initialCityId, fallBackCityId) => { +const setUpDropdown = (initialCityId: CityId, fallBackCityId: CityId) => { const cities = Object.entries(scoreCardsData).map(([id, { name }]) => ({ value: id, label: name, diff --git a/src/js/fontAwesome.js b/src/js/fontAwesome.ts similarity index 100% rename from src/js/fontAwesome.js rename to src/js/fontAwesome.ts diff --git a/src/js/setUpSite.js b/src/js/setUpSite.ts similarity index 64% rename from src/js/setUpSite.js rename to src/js/setUpSite.ts index ded3fc8..291666f 100644 --- a/src/js/setUpSite.js +++ b/src/js/setUpSite.ts @@ -1,15 +1,31 @@ /* global document, window */ -import { Control, Map, Popup, TileLayer, geoJSON } from "leaflet"; +import { + Control, + ImageOverlay, + Map, + Popup, + TileLayer, + geoJSON, + GeoJSON, +} from "leaflet"; +import { Feature, GeoJsonProperties, Geometry } from "geojson"; import "leaflet/dist/leaflet.css"; - +import { CityId, ScoreCard, ScoreCards, ScoreCardDetails } from "./types"; import { extractCityIdFromUrl } from "./cityId"; import setUpIcons from "./fontAwesome"; -import scoreCardsData from "../../data/score-cards.json"; import setUpAbout from "./about"; import setUpShareUrlClickListener from "./share"; import setUpDropdown, { DROPDOWN } from "./dropdown"; +import cityBoundaries from "~/data/city-boundaries.geojson"; +import scoreCardsDetails from "~/data/score-cards.json"; + +interface ParkingLotModules { + [key: string]: () => Promise>; +} -const parkingLots = import("../../data/parking-lots/*"); // eslint-disable-line +const parkingLotsModules = import( + "~/data/parking-lots/*" +) as unknown as ParkingLotModules; const MAX_ZOOM = 18; const BASE_LAYERS = { @@ -22,7 +38,6 @@ const BASE_LAYERS = { subdomains: "abcd", minZoom: 0, maxZoom: MAX_ZOOM, - ext: "png", } ), "Google Maps": new TileLayer( @@ -66,7 +81,7 @@ const createMap = () => { ); new Control.Layers(BASE_LAYERS).addTo(map); - map.createPane("fixed", document.getElementById("map")); + map.createPane("fixed", document.getElementById("map") || undefined); return map; }; @@ -76,7 +91,7 @@ const createMap = () => { * @param scoreCardEntry: An entry from score-cards.json. * @returns string: The HTML represented as a string. */ -const generateScorecard = (scoreCardEntry) => { +const generateScorecard = (scoreCardEntry: ScoreCardDetails) => { const { name, cityType, @@ -119,12 +134,15 @@ const generateScorecard = (scoreCardEntry) => { * @param cityId: E.g. `columbus-oh`. * @param parkingLayer: GeoJSON layer with parking lot data */ -const loadParkingLot = async (cityId, parkingLayer) => { +const loadParkingLot: ( + cityId: CityId, + parkingLayer: GeoJSON +) => Promise = async (cityId, parkingLayer) => { const alreadyLoaded = parkingLayer .getLayers() - .find((city) => city.feature.properties.id === cityId); + .find((city: GeoJsonProperties) => city?.feature.properties.id === cityId); if (!alreadyLoaded) { - parkingLayer.addData(await parkingLots[`${cityId}.geojson`]()); + parkingLayer.addData(await parkingLotsModules[`${cityId}.geojson`]()); parkingLayer.bringToBack(); // Ensures city boundary is on top") } }; @@ -135,7 +153,10 @@ const loadParkingLot = async (cityId, parkingLayer) => { * @param map: The Leaflet map instance. * @param layer: The Leaflet layer with the city boundaries to snap to. */ -const snapToCity = async (map, layer) => { +const snapToCity: (map: Map, layer: ImageOverlay) => void = async ( + map, + layer +) => { map.fitBounds(layer.getBounds()); }; @@ -146,9 +167,12 @@ const snapToCity = async (map, layer) => { * @param cityProperties: An object with a `layout` key (Leaflet value) and keys * representing the score card properties stored in `score-cards.json`. */ -const setScorecard = (cityId, cityProperties) => { - const { layer } = cityProperties; - const scorecard = generateScorecard(cityProperties); +const setScorecard: (cityId: CityId, cityProperties: ScoreCard) => void = ( + cityId, + cityProperties +) => { + const { layer, details } = cityProperties; + const scorecard = generateScorecard(details); setUpShareUrlClickListener(cityId); const popup = new Popup({ pane: "fixed", @@ -166,15 +190,19 @@ const setScorecard = (cityId, cityProperties) => { * @param cities: Dictionary of cities with layer and scorecard info. * @param parkingLayer: GeoJSON layer with parking lot data */ -const setUpAutoScorecard = async (map, cities, parkingLayer) => { +const setUpAutoScorecard: ( + map: Map, + cities: ScoreCards, + parkingLayer: GeoJSON +) => Promise = async (map, cities, parkingLayer) => { map.on("moveend", async () => { - let centralCityDistance = null; + let centralCityDistance: number | null = null; let centralCity; Object.entries(cities).forEach((city) => { - const [cityName, details] = city; - const bounds = details.layer.getBounds(); + const [cityName, scoreCard] = city; + const bounds = scoreCard.layer.getBounds(); - if (map.getBounds().intersects(bounds)) { + if (bounds && map.getBounds().intersects(bounds)) { const diff = map.getBounds().getCenter().distanceTo(bounds.getCenter()); loadParkingLot(cityName, parkingLayer); // Load parking lot data on any city in view if (centralCityDistance == null || diff < centralCityDistance) { @@ -194,18 +222,23 @@ const setUpAutoScorecard = async (map, cities, parkingLayer) => { * Load the cities from GeoJson and set up an event listener to change cities when the user * toggles the city selection. */ -const setUpCitiesLayer = async (map, parkingLayer) => { - const cities = {}; - const cityBoundariesData = await import("../../data/city-boundaries.geojson"); - const allBoundaries = geoJSON(cityBoundariesData, { +const setUpCitiesLayer: ( + map: Map, + parkingLayer: GeoJSON +) => Promise = async (map, parkingLayer) => { + const cities: ScoreCards = {}; + const allBoundaries = geoJSON(cityBoundaries, { style() { return STYLES.cities; }, - onEachFeature(feature, layer) { + onEachFeature(feature, layer: ImageOverlay) { const cityId = feature.properties.id; - cities[cityId] = { layer, ...scoreCardsData[cityId] }; + cities[cityId] = { + layer, + details: scoreCardsDetails[cityId], + } as ScoreCard; layer.on("add", () => { - layer.getElement().setAttribute("id", cityId); + layer.getElement()?.setAttribute("id", cityId); }); }, }); @@ -214,26 +247,36 @@ const setUpCitiesLayer = async (map, parkingLayer) => { // Set up map to update when city selection changes. const cityToggleElement = document.getElementById("city-choice"); - cityToggleElement.addEventListener("change", async () => { - const cityId = cityToggleElement.value; - snapToCity(map, cities[cityId].layer); - }); + if (cityToggleElement instanceof HTMLSelectElement) { + cityToggleElement.addEventListener("change", async () => { + const cityId = cityToggleElement.value; + const { layer } = cities[cityId]; + if (layer) { + snapToCity(map, layer); + } + }); - // Set up map to update when user clicks within a city's boundary - allBoundaries.addEventListener("click", (e) => { - const currentZoom = map.getZoom(); - if (currentZoom > 7) { - const cityId = e.sourceTarget.feature.properties.id; - cityToggleElement.value = cityId; - snapToCity(map, cities[cityId].layer); - } - }); + // Set up map to update when user clicks within a city's boundary + allBoundaries.addEventListener("click", (e) => { + const currentZoom = map.getZoom(); + if (currentZoom > 7) { + const cityId = e.sourceTarget.feature.properties.id; + cityToggleElement.value = cityId; + const { layer } = cities[cityId]; + if (layer) { + snapToCity(map, layer); + } + } + }); - // Load initial city. - const cityId = cityToggleElement.value; - setUpAutoScorecard(map, cities, parkingLayer); - snapToCity(map, cities[cityId].layer); - setScorecard(cityId, cities[cityId]); + // Load initial city. + const cityId = cityToggleElement.value; + setUpAutoScorecard(map, cities, parkingLayer); + snapToCity(map, cities[cityId].layer); + setScorecard(cityId, cities[cityId]); + } else { + throw new Error("#city-choice is not a select element"); + } }; /** @@ -242,8 +285,10 @@ const setUpCitiesLayer = async (map, parkingLayer) => { * * @param map: The Leaflet map instance. */ -const setUpParkingLotsLayer = async (map) => { - const parkingLayer = geoJSON(null, { +const setUpParkingLotsLayer: ( + map: Map +) => Promise> = async (map) => { + const parkingLayer = geoJSON(undefined, { style() { return STYLES.parkingLots; }, @@ -251,18 +296,24 @@ const setUpParkingLotsLayer = async (map) => { // If `#lots-toggle` is in the URL, we show buttons to toggle parking lots. if (window.location.href.indexOf("#lots-toggle") !== -1) { - document.querySelector("#lots-toggle").style.display = "block"; - document.querySelector("#lots-toggle-off").addEventListener("click", () => { - parkingLayer.removeFrom(map); - }); - document.querySelector("#lots-toggle-on").addEventListener("click", () => { + const toggle: HTMLAnchorElement | null = + document.querySelector("#lots-toggle"); + if (toggle) { + toggle.style.display = "block"; + } + document + .querySelector("#lots-toggle-off") + ?.addEventListener("click", () => { + parkingLayer.removeFrom(map); + }); + document.querySelector("#lots-toggle-on")?.addEventListener("click", () => { parkingLayer.addTo(map); }); } return parkingLayer; }; -const setUpSite = async () => { +const setUpSite: () => Promise = async () => { setUpIcons(); const initialCityId = extractCityIdFromUrl(window.location.href); diff --git a/src/js/share.js b/src/js/share.js deleted file mode 100644 index 6ca89e7..0000000 --- a/src/js/share.js +++ /dev/null @@ -1,51 +0,0 @@ -/* global document, navigator, window */ -import { determineShareUrl } from "./cityId"; -/** - * Copy `value` to the user's clipboard - * - * @param string value - */ -const copyToClipboard = async (value) => { - try { - await navigator.clipboard.writeText(value); - } catch (err) { - // eslint-disable-next-line no-console - console.error("Failed to write to clipboard: ", err); - } -}; - -/** - * Toggle share link icon briefly to show user an indicator - * - * @param {HTMLAnchorElement} shareIcon - */ -const switchIcons = (shareIcon) => { - const linkIcon = shareIcon.querySelector("svg.share-link-icon"); - const checkIcon = shareIcon.querySelector("svg.share-check-icon"); - linkIcon.style.display = "none"; - checkIcon.style.display = "inline-block"; - setTimeout(() => { - linkIcon.style.display = "inline-block"; - checkIcon.style.display = "none"; - }, 1000); -}; - -/** - * Add an event listener for the share button to copy the link to the clipboard. - * - * @param string cityId: e.g. `st.-louis-mo` - */ -const setUpShareUrlClickListener = (cityId) => { - // We put the event listener on `map` because it is never erased, unlike the copy button - // being recreated every time the score card changes. This is called "event delegation". - document.querySelector("#map").addEventListener("click", async (event) => { - const targetElement = event.target.closest("div.url-copy-button > a"); - if (targetElement) { - const shareUrl = determineShareUrl(window.location.href, cityId); - await copyToClipboard(shareUrl); - switchIcons(targetElement); - } - }); -}; - -export default setUpShareUrlClickListener; diff --git a/src/js/share.ts b/src/js/share.ts new file mode 100644 index 0000000..3bfb232 --- /dev/null +++ b/src/js/share.ts @@ -0,0 +1,66 @@ +/* global document, navigator, window */ +import { CityId } from "./types"; +import { determineShareUrl } from "./cityId"; +/** + * Copy `value` to the user's clipboard + * + * @param string value + */ +const copyToClipboard = async (value: string) => { + try { + await navigator.clipboard.writeText(value); + } catch (err) { + // eslint-disable-next-line no-console + console.error("Failed to write to clipboard: ", err); + } +}; + +/** + * Toggle share link icon briefly to show user an indicator + * + * @param {HTMLAnchorElement} shareIcon + */ +const switchIcons = (shareIcon: HTMLAnchorElement) => { + const linkIcon: HTMLAnchorElement | null = shareIcon.querySelector( + "svg.share-link-icon" + ); + const checkIcon: HTMLAnchorElement | null = shareIcon.querySelector( + "svg.share-check-icon" + ); + if (linkIcon && checkIcon) { + linkIcon.style.display = "none"; + checkIcon.style.display = "inline-block"; + setTimeout(() => { + linkIcon.style.display = "inline-block"; + checkIcon.style.display = "none"; + }, 1000); + } +}; + +/** + * Add an event listener for the share button to copy the link to the clipboard. + * + * @param string cityId: e.g. `st.-louis-mo` + */ +const setUpShareUrlClickListener = (cityId: CityId) => { + // We put the event listener on `map` because it is never erased, unlike the copy button + // being recreated every time the score card changes. This is called "event delegation". + const mapElement: HTMLElement | null = document.querySelector("#map"); + if (mapElement) { + mapElement.addEventListener("click", async (event: MouseEvent | null) => { + const copyButton = event?.target as Element; + if (copyButton) { + const targetElement: HTMLAnchorElement | null = copyButton.closest( + "div.url-copy-button > a" + ); + if (targetElement) { + const shareUrl = determineShareUrl(window.location.href, cityId); + await copyToClipboard(shareUrl); + switchIcons(targetElement); + } + } + }); + } +}; + +export default setUpShareUrlClickListener; diff --git a/src/js/types.ts b/src/js/types.ts new file mode 100644 index 0000000..bc7c6c8 --- /dev/null +++ b/src/js/types.ts @@ -0,0 +1,28 @@ +import { Feature, Geometry } from "geojson"; +import { ImageOverlay } from "leaflet"; + +export type CityId = string; + +export interface ScoreCardDetails { + name: string; + percentage: string; + cityType: string; + population: string; + urbanizedAreaPopulation: string; + parkingScore: string; + reforms: string; + url: string; +} + +export type ScoreCardsDetails = Record; + +export interface ScoreCard { + details: ScoreCardDetails; + layer: ImageOverlay; +} + +export type ScoreCards = Record; + +export interface ParkingLotModules { + [key: string]: () => Promise>; +} diff --git a/tests/app/about.test.js b/tests/app/about.test.ts similarity index 100% rename from tests/app/about.test.js rename to tests/app/about.test.ts diff --git a/tests/app/cityId.test.js b/tests/app/cityId.test.ts similarity index 100% rename from tests/app/cityId.test.js rename to tests/app/cityId.test.ts diff --git a/tests/app/setUpSite.test.js b/tests/app/setUpSite.test.ts similarity index 81% rename from tests/app/setUpSite.test.js rename to tests/app/setUpSite.test.ts index 4c3d672..8b6d156 100644 --- a/tests/app/setUpSite.test.js +++ b/tests/app/setUpSite.test.ts @@ -1,9 +1,9 @@ /* global document, navigator */ import fs from "fs"; -import { expect, test } from "@playwright/test"; +import { expect, test, Page } from "@playwright/test"; test("no console errors and warnings", async ({ page }) => { - const errors = []; + const errors: string[] = []; page.on("console", (message) => { if (message.type() === "error" || message.type() === "warn") { errors.push(message.text()); @@ -15,15 +15,15 @@ test("no console errors and warnings", async ({ page }) => { }); test("every city is in the toggle", async ({ page }) => { - const rawData = fs.readFileSync("data/score-cards.json"); - const data = JSON.parse(rawData); + const rawData: Buffer = fs.readFileSync("data/score-cards.json"); + const data: JSON = JSON.parse(rawData.toString()); const expectedCities = Object.values(data).map((scoreCard) => scoreCard.name); await page.goto("/"); await page.waitForSelector(".choices"); const toggleValues = await page.$$eval(".choices__item--choice", (elements) => - Array.from(elements.map((opt) => opt.textContent.trim())) + Array.from(elements.map((opt) => opt.textContent?.trim())) ); toggleValues.sort(); @@ -32,8 +32,8 @@ test("every city is in the toggle", async ({ page }) => { }); test("correctly load the city score card", async ({ page }) => { - const rawData = fs.readFileSync("data/score-cards.json"); - const albanyExpected = JSON.parse(rawData)["albany-ny"]; + const rawData: Buffer = fs.readFileSync("data/score-cards.json"); + const albanyExpected = JSON.parse(rawData.toString())["albany-ny"]; let albanyLoaded = false; page.route("**/*", (route) => { const requestUrl = route.request().url(); @@ -57,7 +57,9 @@ test("correctly load the city score card", async ({ page }) => { }); const [content, cityToggleValue] = await page.evaluate(() => { - const cityToggle = document.querySelector("#city-choice").value; + const cityChoice: HTMLSelectElement | null = + document.querySelector("#city-choice"); + const cityToggle = cityChoice?.value; const detailsTitles = Array.from( document.querySelectorAll(".leaflet-popup-content .details-title") @@ -66,9 +68,11 @@ test("correctly load the city score card", async ({ page }) => { document.querySelectorAll(".leaflet-popup-content .details-value") ).map((el) => el.textContent); - const details = {}; + const details: Record = {}; detailsTitles.forEach((title, index) => { - details[title] = detailsValues[index]; + if (title) { + details[title] = detailsValues[index]; + } }); return [details, cityToggle]; }); @@ -128,10 +132,13 @@ test.describe("the share feature", () => { await page.waitForSelector(".leaflet-popup-content .title"); const [scoreCardTitle, cityToggleValue] = await page.evaluate(() => { - const title = document.querySelector( + const titlePopup: HTMLElement | null = document.querySelector( ".leaflet-popup-content .title" - ).textContent; - const cityToggle = document.querySelector("#city-choice").value; + ); + const title = titlePopup?.textContent; + const cityChoice: HTMLSelectElement | null = + document.querySelector("#city-choice"); + const cityToggle = cityChoice?.value; return [title, cityToggle]; }); @@ -147,10 +154,14 @@ test.describe("the share feature", () => { // Wait a second to make sure the site is fully loaded. await page.waitForTimeout(1000); const [scoreCardTitle, cityToggleValue] = await page.evaluate(() => { - const title = document.querySelector( + const titlePopup: HTMLAnchorElement | null = document.querySelector( ".leaflet-popup-content .title" - ).textContent; - const cityToggle = document.querySelector("#city-choice").value; + ); + + const title = titlePopup?.textContent; + const cityChoiceSelector: HTMLSelectElement | null = + document.querySelector("#city-choice"); + const cityToggle = cityChoiceSelector?.value; return [title, cityToggle]; }); @@ -159,7 +170,7 @@ test.describe("the share feature", () => { }); }); -const dragMap = async (page, distance) => { +const dragMap = async (page: Page, distance: number) => { await page.waitForTimeout(1000); await page.mouse.move(600, 500); await page.mouse.down(); @@ -191,7 +202,7 @@ test.describe("auto-focus city", () => { // Drag map to bring Birmingham into view. await dragMap(page, 200); // Click on Birmingham boundary. - const city = await page.locator("#birmingham-al"); + const city = page.locator("#birmingham-al"); await city.click({ force: true }); // Wait a second to make sure the site is fully loaded. @@ -200,8 +211,8 @@ test.describe("auto-focus city", () => { const newScorecard = await page .locator(".leaflet-popup-content .title") .evaluate((node) => node.textContent); - await expect(newScorecard).toEqual("Birmingham, AL"); - await expect(city).toBeVisible(); + expect(newScorecard).toEqual("Birmingham, AL"); + expect(await page.isVisible("#birmingham-al")).toBe(true); }); test("clicking on city boundary wide view", async ({ page }) => { await page.goto(""); @@ -223,7 +234,7 @@ test.describe("auto-focus city", () => { const scorecard = await page .locator(".leaflet-popup-content .title") .evaluate((node) => node.textContent); - await expect(scorecard).toEqual("Atlanta, GA"); + expect(scorecard).toEqual("Atlanta, GA"); }); }); @@ -242,14 +253,14 @@ test("scorecard pulls up city closest to center", async ({ page }) => { await page.waitForSelector(".choices"); const [scoreCardTitle, cityToggleValue] = await page.evaluate(() => { - const title = document.querySelector( - ".leaflet-popup-content .title" - ).textContent; - const cityToggle = document.querySelector("#city-choice").value; - return [title, cityToggle]; + const titlePopup = document.querySelector(".leaflet-popup-content .title"); + const title = titlePopup?.textContent; + const cityChoice: HTMLSelectElement | null = + document.querySelector("#city-choice"); + return [title, cityChoice?.value]; }); - await expect(scoreCardTitle).toEqual("Birmingham, AL"); - await expect(cityToggleValue).toEqual("birmingham-al"); + expect(scoreCardTitle).toEqual("Birmingham, AL"); + expect(cityToggleValue).toEqual("birmingham-al"); }); test("map only loads parking lots for visible cities", async ({ page }) => { diff --git a/tests/scripts/base.test.js b/tests/scripts/base.test.ts similarity index 77% rename from tests/scripts/base.test.js rename to tests/scripts/base.test.ts index fd1bf68..b6eddca 100644 --- a/tests/scripts/base.test.js +++ b/tests/scripts/base.test.ts @@ -1,34 +1,53 @@ import fs from "fs/promises"; import { expect, test } from "@playwright/test"; +import { Feature, Polygon, FeatureCollection } from "geojson"; import { + OrError, determineArgs, updateCoordinates, updateParkingLots, + valueOrExit, + error, } from "../../scripts/base"; +import { CityId } from "../../src/js/types"; + +const expectError = (res: OrError) => { + expect("error" in res).toBe(true); + return expect((res as error).error); +}; + +const expectOk = (res: OrError) => { + if ("error" in res) { + throw new Error(`Expected ok: ${res.error}`); + } +}; test.describe("determineArgs()", () => { test("returns the city name and ID", () => { const result = determineArgs("my-script", ["My City"]); - expect(result.value).toEqual({ + expect(valueOrExit(result)).toEqual({ cityName: "My City", cityId: "my-city", }); }); test("requires exactly 1 argument", () => { - let result = determineArgs("my-script", []); - expect(result.error).toContain("exactly one argument"); + let result: OrError<{ cityName: string; cityId: string }> = determineArgs( + "my-script", + [] + ); + expectError(result).toContain("exactly one argument"); result = determineArgs("my-script", ["My City", "--bad"]); - expect(result.error).toContain("exactly one argument"); + expectError(result).toContain("exactly one argument"); result = determineArgs("my-script", ["My City", "AZ"]); - expect(result.error).toContain("exactly one argument"); + expectError(result).toContain("exactly one argument"); }); }); test.describe("updateCoordinates()", () => { - let originalData; + let originalData: string; const originalFilePath = "tests/scripts/data/original-data.geojson"; const validUpdateFilePath = "tests/scripts/data/valid-update.geojson"; @@ -53,20 +72,19 @@ test.describe("updateCoordinates()", () => { originalFilePath, updateFilePath ); - expect(result.error).toBeUndefined(); - expect(result.value).toBeDefined(); + expectOk(result); const rawUpdateData = await fs.readFile(updateFilePath, "utf8"); const updateData = JSON.parse(rawUpdateData); const updatedCoordinates = updateData.features[0].geometry.coordinates; const rawResultData = await fs.readFile(originalFilePath, "utf8"); - const resultData = JSON.parse(rawResultData); + const resultData: FeatureCollection = JSON.parse(rawResultData); const cityTargetData = resultData.features.find( - (feature) => feature.properties.id === cityId + (feature) => feature?.properties?.id === cityId ); - expect(cityTargetData.geometry.coordinates).toEqual(updatedCoordinates); + expect(cityTargetData?.geometry.coordinates).toEqual(updatedCoordinates); }); test("adds a new city when add is set", async () => { @@ -80,8 +98,7 @@ test.describe("updateCoordinates()", () => { originalFilePath, updateFilePath ); - expect(result.error).toBeUndefined(); - expect(result.value).toBeDefined(); + expectOk(result); const rawUpdatedData = await fs.readFile(updateFilePath, "utf8"); const updatedData = JSON.parse(rawUpdatedData); @@ -91,12 +108,12 @@ test.describe("updateCoordinates()", () => { const resultData = JSON.parse(rawResultData); const resultCityIds = resultData.features.map( - (feature) => feature.properties.id + (feature: Feature) => feature.properties?.id ); expect(resultCityIds).toEqual(["honolulu-hi", cityId, "shoup-ville-az"]); const cityTargetData = resultData.features.find( - (feature) => feature.properties.id === cityId + (feature: Feature) => feature.properties?.id === cityId ); expect(cityTargetData.properties).toEqual({ id: cityId, @@ -113,7 +130,7 @@ test.describe("updateCoordinates()", () => { originalFilePath, validUpdateFilePath ); - expect(result.error).toContain("To add a new city,"); + expectError(result).toContain("To add a new city,"); }); test("validates the update file has exactly one `feature`", async () => { @@ -124,7 +141,7 @@ test.describe("updateCoordinates()", () => { originalFilePath, "tests/scripts/data/too-many-updates.geojson" ); - expect(result.error).toContain("expects exactly one entry in `features`"); + expectError(result).toContain("expects exactly one entry in `features`"); result = await updateCoordinates( "my-script", @@ -133,7 +150,7 @@ test.describe("updateCoordinates()", () => { originalFilePath, "tests/scripts/data/empty-update.geojson" ); - expect(result.error).toContain("expects exactly one entry in `features`"); + expectError(result).toContain("expects exactly one entry in `features`"); }); test("errors gracefully if update file not found", async () => { @@ -144,7 +161,7 @@ test.describe("updateCoordinates()", () => { originalFilePath, "tests/scripts/data/does-not-exist" ); - expect(result.error).toContain("tests/scripts/data/does-not-exist"); + expectError(result).toContain("tests/scripts/data/does-not-exist"); }); test("errors gracefully if original data file not found", async () => { @@ -155,12 +172,12 @@ test.describe("updateCoordinates()", () => { "tests/scripts/data/does-not-exist", validUpdateFilePath ); - expect(result.error).toContain("tests/scripts/data/does-not-exist"); + expectError(result).toContain("tests/scripts/data/does-not-exist"); }); }); test.describe("updateParkingLots()", () => { - let originalData; + let originalData: string; const parkingLotData = "tests/scripts/data/parking-lot-data.geojson"; const addDataPath = "tests/scripts/data/new-parking-lot.geojson"; @@ -174,7 +191,7 @@ test.describe("updateParkingLots()", () => { await fs.writeFile(parkingLotData, originalData); }); - const expectUpdatedFile = async (cityId, updateFilePath) => { + const expectUpdatedFile = async (cityId: CityId, updateFilePath: string) => { const rawUpdatedData = await fs.readFile(updateFilePath, "utf8"); const updatedData = JSON.parse(rawUpdatedData); const updatedCoordinates = updatedData.geometry.coordinates; @@ -198,8 +215,7 @@ test.describe("updateParkingLots()", () => { parkingLotData, addDataPath ); - expect(result.error).toBeUndefined(); - expect(result.value).toBeDefined(); + expectOk(result); await expectUpdatedFile(cityId, addDataPath); @@ -217,8 +233,7 @@ test.describe("updateParkingLots()", () => { parkingLotData, existingDataPath ); - expect(result.error).toBeUndefined(); - expect(result.value).toBeDefined(); + expectOk(result); await expectUpdatedFile(cityId, existingDataPath); await fs.writeFile(existingDataPath, existingData); diff --git a/tests/scripts/dataFolder.test.js b/tests/scripts/dataFolder.test.ts similarity index 92% rename from tests/scripts/dataFolder.test.js rename to tests/scripts/dataFolder.test.ts index 4f42df8..3386080 100644 --- a/tests/scripts/dataFolder.test.js +++ b/tests/scripts/dataFolder.test.ts @@ -1,7 +1,7 @@ import fs from "fs/promises"; import { expect, test } from "@playwright/test"; -const assertSortedGeojson = async (filePath) => { +const assertSortedGeojson = async (filePath: string) => { const rawData = await fs.readFile(filePath, "utf8"); const data = JSON.parse(rawData); const sortedFeatures = [...data.features].sort((a, b) => diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..9679a2f --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "noEmit": true, + "allowImportingTsExtensions": true, + "incremental": true /* Save .tsbuildinfo files to allow for incremental compilation of projects. */, + "target": "es2020" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, + "module": "es2022" /* Specify what module code is generated. */, + "moduleResolution": "node" /* Specify how TypeScript looks up a file from a given module specifier. */, + "resolveJsonModule": true /* Enable importing .json files. */, + "allowSyntheticDefaultImports": true /* Allow 'import x from y' when a module doesn't have a default export. */, + "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */, + "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, + "strict": true /* Enable all strict type-checking options. */, + "noImplicitAny": true /* Enable error reporting for expressions and declarations with an implied 'any' type. */, + "strictNullChecks": true /* When type checking, take into account 'null' and 'undefined'. */, + "strictFunctionTypes": true /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */, + "strictBindCallApply": true /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */, + "strictPropertyInitialization": true /* Check for class properties that are declared but not set in the constructor. */, + "noImplicitThis": true /* Enable error reporting when 'this' is given the type 'any'. */, + "alwaysStrict": true /* Ensure 'use strict' is always emitted. */, + "noUnusedLocals": true /* Enable error reporting when local variables aren't read. */, + "noUnusedParameters": true /* Raise an error when a function parameter isn't read. */, + "exactOptionalPropertyTypes": true /* Interpret optional property types as written, rather than adding 'undefined'. */, + "noImplicitReturns": true /* Enable error reporting for codepaths that do not explicitly return in a function. */, + "noFallthroughCasesInSwitch": true /* Enable error reporting for fallthrough cases in switch statements. */, + "skipLibCheck": true /* Skip type checking all .d.ts files. */ + } +}