├── .eslintrc.json ├── .github ├── matchers │ ├── eslint.json │ └── tap.json └── workflows │ └── quality.yaml ├── .gitignore ├── README.md ├── docs └── index.html ├── package-lock.json ├── package.json └── src ├── data ├── crypto.js ├── crypto.test.js ├── date.js ├── date.test.js ├── main.js ├── main.test.js ├── routeStation.js ├── stations.js ├── stations.test.js ├── trains.js ├── trains.test.data.js └── trains.test.js └── site ├── build.js └── templates ├── index.handlebars ├── partials ├── end.handlebars └── start.handlebars └── train.handlebars /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["airbnb-base", "prettier"], 3 | "rules": { 4 | // Node.js requires that import statements include a file extension in order 5 | // to properly resolve modules, similarly to how it works in browser 6 | // environments. In fact, the only places it doesn't work that way is with 7 | // precompilers like Babel, etc. 8 | "import/extensions": ["error", "always"], 9 | 10 | // This rule says that a file with a single export should only export 11 | // default, a rule I generally disagree with being in the linter because 12 | // what to export is contextual. There's not a good hard rule. 13 | "import/prefer-default-export": [0], 14 | 15 | // In general, I agree with this rule, but treating parameters as immutable 16 | // is a bridge too far for me. 17 | "no-param-reassign": ["error", { "props":false }], 18 | 19 | // This rule is copied from the Airbnb config, but we remove the prohibition 20 | // on ForOf statements because they are natively supported in Node.js. The 21 | // remaining prohibitions are still good, though, so we don't want to just 22 | // completely disable the rule. 23 | "no-restricted-syntax": [ 24 | "error", 25 | { 26 | "selector": "ForInStatement", 27 | "message": 28 | "for..in loops iterate over the entire prototype chain, which is virtually never what you want. Use Object.{keys,values,entries}, and iterate over the resulting array." 29 | }, 30 | { 31 | "selector": "LabeledStatement", 32 | "message": 33 | "Labels are a form of GOTO; using them makes code confusing and hard to maintain and understand." 34 | }, 35 | { 36 | "selector": "WithStatement", 37 | "message": 38 | "`with` is disallowed in strict mode because it makes code impossible to predict and optimize." 39 | } 40 | ] 41 | }, 42 | "env": { 43 | "es2023": true, 44 | "node": true 45 | }, 46 | "parserOptions": { "ecmaVersion": 2023 } 47 | } 48 | -------------------------------------------------------------------------------- /.github/matchers/eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "problemMatcher": [ 3 | { 4 | "owner": "eslint-stylish", 5 | "pattern": [ 6 | { 7 | "regexp": "^([^\\s].*)$", 8 | "file": 1 9 | }, 10 | { 11 | "regexp": "^\\s+(\\d+):(\\d+)\\s+(error|warning|info)\\s+(.*)\\s\\s+(.*)$", 12 | "line": 1, 13 | "column": 2, 14 | "severity": 3, 15 | "message": 4, 16 | "code": 5, 17 | "loop": true 18 | } 19 | ] 20 | } 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /.github/matchers/tap.json: -------------------------------------------------------------------------------- 1 | { 2 | "problemMatcher": [ 3 | { 4 | "owner": "tap", 5 | "pattern": [ 6 | { 7 | "regexp": "^\\s*not ok \\d+ - (.*)", 8 | "message": 1 9 | }, 10 | { 11 | "regexp": "^\\s*---" 12 | }, 13 | { 14 | "regexp": "^\\s*at:" 15 | }, 16 | { 17 | "regexp": "^\\s*line:\\s*(\\d+)", 18 | "line": 1 19 | }, 20 | { 21 | "regexp": "^\\s*column:\\s*(\\d+)", 22 | "column": 1 23 | }, 24 | { 25 | "regexp": "^\\s*file:\\s*(.*)", 26 | "file": 1 27 | } 28 | ] 29 | } 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /.github/workflows/quality.yaml: -------------------------------------------------------------------------------- 1 | name: code quality 2 | 3 | on: [pull_request] 4 | 5 | jobs: 6 | lint: 7 | name: lint 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v3 11 | 12 | - uses: actions/setup-node@v3 13 | with: 14 | node-version: 18 15 | cache: npm 16 | 17 | - name: install dependencies 18 | run: npm ci 19 | 20 | - name: add eslint output matcher 21 | run: echo "::add-matcher::${{ github.workspace }}/.github/matchers/eslint.json" 22 | 23 | - name: lint 24 | run: npm run test:lint 25 | 26 | test: 27 | name: unit test 28 | runs-on: ubuntu-latest 29 | steps: 30 | - uses: actions/checkout@v3 31 | 32 | - uses: actions/setup-node@v3 33 | with: 34 | node-version: 18 35 | cache: npm 36 | 37 | - name: install dependencies 38 | run: npm ci 39 | 40 | - name: add eslint output matcher 41 | run: echo "::add-matcher::${{ github.workspace }}/.github/matchers/tap.json" 42 | 43 | - name: run tests 44 | run: npm run test:test 45 | 46 | - name: store coverage output 47 | uses: actions/upload-artifact@v3 48 | with: 49 | name: coverage 50 | path: .tap 51 | retention-days: 1 52 | 53 | # She just gon' disable coverage reporting for right now because I don't want 54 | # to look at any more lcov reporters. 55 | # coverage: 56 | # name: code coverage 57 | # runs-on: ubuntu-latest 58 | # needs: [test] 59 | # steps: 60 | # - name: install lcov 61 | # uses: hrishikesh-kadam/setup-lcov@v1 62 | 63 | # - name: get coverage output 64 | # uses: actions/download-artifact@v3 65 | # with: 66 | # name: coverage 67 | 68 | # - name: Report code coverage 69 | # uses: zgosalvez/github-actions-report-lcov@v3 70 | # with: 71 | # coverage-files: ${{ github.workspace }}/.tap/report/lcov.info 72 | # minimum-coverage: 90 73 | # github-token: ${{ secrets.GITHUB_TOKEN }} 74 | # update-comment: true 75 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | _site 3 | .env 4 | .tap 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tools for getting Amtrak data 2 | 3 | Amtrak currently doesn't have an easily-accessible API. This tool will use the 4 | same APIs as 5 | [Amtrak's Track Your Train Map](https://www.amtrak.com/track-your-train.html) to 6 | extract information about trains that are currently active as well as those that 7 | are only planned. The result is a set of JSON files representing the current 8 | state of the map. 9 | 10 | The tool can also build a static website from the data. A version of that site 11 | is live at [mgwalker.github.io/amtrak-api/](https://mgwalker.github.io/amtrak-api/). 12 | 13 | ## Usage 14 | 15 | To get the latest data and build the site, run: 16 | 17 | ``` 18 | npm run site 19 | ``` 20 | 21 | If you only want to fetch the latest data, run: 22 | 23 | ``` 24 | npm run update 25 | ``` 26 | 27 | ## The data 28 | 29 | ### Stations 30 | 31 | Path: `_site/stations.json` 32 | 33 | An array of station objects, listing all of the stations identified on the 34 | Amtrak map. The station objects look like this: 35 | 36 | ``` 37 | { 38 | code: . . string - three-letter station identifier 39 | name: . . string - the name of the station 40 | address1: string - street address of the station 41 | address2: string 42 | city: . string 43 | state: . string 44 | zip: . . string 45 | lat: . . float - geographic coordinates of the station 46 | lon: . . float 47 | _raw: . . object - the raw data from Amtrak 48 | } 49 | ``` 50 | 51 | ### Routes 52 | 53 | Path: `_site/routes.json` 54 | 55 | An array of route objects, each one representing a whole Amtrak route. The 56 | route object includes an array of trains, each representing one of the trains 57 | that's currently running on that route. Then, each train has a list of stations 58 | representing each station along the route. Here's what those look like: 59 | 60 | #### Route: 61 | 62 | ``` 63 | { 64 | route: string - name of the route 65 | trains: [ ] - list of trains currently running on this route (see below) 66 | } 67 | 68 | ``` 69 | 70 | #### Train: 71 | 72 | ``` 73 | { 74 | id: int - the train's internal Amtrak ID (not useful) 75 | heading: char(2) - train's current cardinal or ordinal direction 76 | number: int - the train number 77 | route: string - the name of the route 78 | stations: [ ] - list of stations this train stops at, sorted from 79 | starting station to ending station (see below) 80 | _raw: object - the raw data from Amtrak 81 | } 82 | ``` 83 | 84 | #### Station: 85 | 86 | ``` 87 | { 88 | code: . . string - the station's 3-letter code 89 | bus: . . bool - whether this station also has bus service, I think 90 | status: . string - the train's status relative to this station 91 | timezone: string - IANA timezone where the train currently is 92 | 93 | arrivalActual: . . Arrival and departure times for this train at this 94 | arrivalEstimated: . station. All arrivals are null if this is the first 95 | arrivalScheduled: . station. Likewise departure for the last station. 96 | departureActual: . Estimated times sometimes go away after a train 97 | departureEstimated: actually arrives or departs. Values are ISO 8601 98 | departureScheduled: date/time strings in UTC. 99 | 100 | station: station object, as described in the Stations part of the README. 101 | This has the station name, address, etc. 102 | } 103 | ``` 104 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 15 | 21 | 76 | 77 | 78 |
79 |
83 |

This site is archived

84 |

85 | This website previously built itself automatically from Amtrak data, 86 | updated about every 20 minutes. However, the Amtrak data source is 87 | remarkably flaky and for a hobby project, it isn't worth the amount of 88 | naggy emails I get from the automatic build process whenver Amtrak 89 | delivers bad data. 90 |

91 | 92 |

93 | The source code used to generate this site – and the data files that 94 | it hosted – is 95 | available for free on GitHub. Please feel free to grab it and do with it as you please! If you're 98 | curious why I built this thing in the first place, I wrote 99 | a blog post about it in 2023. 102 |

103 |
104 |
105 | 106 | 107 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@mgwalker/amtrak-api", 3 | "license": "ISC", 4 | "type": "module", 5 | "devDependencies": { 6 | "eslint": "^8.53.0", 7 | "eslint-config-airbnb-base": "^15.0.0", 8 | "eslint-config-prettier": "^9.0.0", 9 | "eslint-plugin-import": "^2.29.0", 10 | "prettier": "^3.0.3", 11 | "sinon": "^17.0.1", 12 | "tap": "^18.5.7" 13 | }, 14 | "prettier": {}, 15 | "dependencies": { 16 | "dayjs": "^1.11.10", 17 | "dotenv": "^16.3.1", 18 | "handlebars": "^4.7.8" 19 | }, 20 | "scripts": { 21 | "site": "npm run update && node src/site/build.js", 22 | "update": "rm -rf _site && node src/data/main.js", 23 | "test:lint": "eslint 'src/**/*.js'", 24 | "test:test": "tap 'src/**/*.test.js' --coverage-report lcov --allow-incomplete-coverage", 25 | "test": "npm run test:lint && npm run test:test" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/data/crypto.js: -------------------------------------------------------------------------------- 1 | import { createDecipheriv, pbkdf2Sync } from "node:crypto"; 2 | 3 | let cryptoInitializers = false; 4 | 5 | // The public key (used to encrypt private key passwords), key derivation salt, 6 | // and AES initialization vectors are all provided by the API. Fetch them. 7 | export const getCryptoInitializers = async (fetch = global.fetch) => { 8 | if (cryptoInitializers === false) { 9 | // First, the index of the public key is the sum of all zoom levels for all 10 | // routes, so let's get that real quick. 11 | const masterZoom = await fetch( 12 | "https://maps.amtrak.com/rttl/js/RoutesList.json", 13 | ) 14 | .then((r) => r.json()) 15 | .then((list) => 16 | list.reduce((sum, { ZoomLevel }) => sum + (ZoomLevel ?? 0), 0), 17 | ); 18 | 19 | // Then fetch the data containing our values. 20 | const cryptoData = await fetch( 21 | "https://maps.amtrak.com/rttl/js/RoutesList.v.json", 22 | ).then((r) => r.json()); 23 | 24 | // And pull them out. 25 | cryptoInitializers = { 26 | PUBLIC_KEY: cryptoData.arr[masterZoom], 27 | // The salt and IV indices are equal to the length of any given value in the 28 | // array. So if salt[0] is 8 bytes long, then our value is at salt[8]. Etc. 29 | CRYPTO_SALT: Buffer.from(cryptoData.s[cryptoData.s[0].length], "hex"), 30 | CRYPTO_IV: Buffer.from(cryptoData.v[cryptoData.v[0].length], "hex"), 31 | }; 32 | } 33 | return cryptoInitializers; 34 | }; 35 | 36 | // The "private key" embedded in each response is really more of a password used 37 | // to derive a key. Anyway, it's 64 bytes long. Base64 decoded and padded, it 38 | // comes out to 88 bytes. And that's where this number comes from. 39 | const MASTER_SEGMENT = 88; 40 | 41 | export const decrypt = async (data, keyDerivationPassword) => { 42 | const { PUBLIC_KEY, CRYPTO_SALT, CRYPTO_IV } = await getCryptoInitializers(); 43 | 44 | // The content is base64 encoded, so decode that to binary first. 45 | const ciphertext = Buffer.from(data, "base64"); 46 | 47 | // The actual key is derived from the derivation password using the salt from 48 | // the API and PBKDF2 with SHA1, with 1,000 iterations and a 16-byte output. 49 | const key = pbkdf2Sync( 50 | keyDerivationPassword ?? PUBLIC_KEY, 51 | CRYPTO_SALT, 52 | 1_000, 53 | 16, 54 | "sha1", 55 | ); 56 | 57 | // It's encrypted with AES-128-CBC using the generated key above and the 58 | // hardcoded initialization vector. 59 | const decipher = createDecipheriv("aes-128-cbc", key, CRYPTO_IV); 60 | 61 | // The Node library works in chunks, so we'll get some stuff out as soon as we 62 | // update the decipher, and we have to get the rest out by calling .final(). 63 | // The result is a string either way, so just join the array of results at the 64 | // end and be happy. 65 | const text = [decipher.update(ciphertext, "binary", "utf-8")]; 66 | text.push(decipher.final("utf-8")); 67 | return text.join(""); 68 | }; 69 | 70 | export const parse = async (data) => { 71 | // The encrypted data is at the beginning. The last 88 bytes are the base64 72 | // encoded private key password. Slice those two out. 73 | const ciphertext = data.slice(0, -MASTER_SEGMENT); 74 | const privateKeyCipher = data.slice(-MASTER_SEGMENT); 75 | 76 | // The private key password is encrypted with the public key provided by the 77 | // API. It's a pipe-delimited string, but only the first segment is useful. 78 | // We can toss out the rest. 79 | const [privateKey] = await decrypt(privateKeyCipher).then((keyFragments) => 80 | keyFragments.split("|"), 81 | ); 82 | 83 | // The actual data is encrypted with the private key. The result is always 84 | // JSON (for our purposes), so go ahead and parse that. 85 | const plaintext = await decrypt(ciphertext, privateKey); 86 | try { 87 | return JSON.parse(plaintext); 88 | } catch (e) { 89 | return null; 90 | } 91 | }; 92 | -------------------------------------------------------------------------------- /src/data/crypto.test.js: -------------------------------------------------------------------------------- 1 | import sinon from "sinon"; 2 | import tap from "tap"; 3 | 4 | import { getCryptoInitializers, parse } from "./crypto.js"; 5 | 6 | tap.test("crypto utilities", async (cryptoTests) => { 7 | const fetch = sinon.stub(); 8 | 9 | cryptoTests.beforeEach(() => { 10 | fetch.reset(); 11 | 12 | fetch.withArgs("https://maps.amtrak.com/rttl/js/RoutesList.json").resolves({ 13 | json: async () => [], 14 | }); 15 | 16 | const s = "12345678"; 17 | const v = "12345678901234567890123456789012"; 18 | 19 | fetch 20 | .withArgs("https://maps.amtrak.com/rttl/js/RoutesList.v.json") 21 | .resolves({ 22 | json: async () => ({ 23 | arr: ["0b1d2897-640a-4c64-a1d8-b54f453a7ad7"], 24 | s: [s, s, s, s, s, s, s, s, "deadbeef"], 25 | // prettier-ignore 26 | v: [ 27 | v, v, v, v, v, v, v, v, v, v, 28 | v, v, v, v, v, v, v, v, v, v, 29 | v, v, v, v, v, v, v, v, v, v, 30 | v, v, 31 | "7e117a1e7e117a1e7e117a1e7e117a1e", 32 | ], 33 | }), 34 | }); 35 | }); 36 | 37 | cryptoTests.test("fetching the keys and stuff", async (test) => { 38 | const { 39 | PUBLIC_KEY: key, 40 | CRYPTO_SALT: salt, 41 | CRYPTO_IV: iv, 42 | } = await getCryptoInitializers(fetch); 43 | 44 | test.same(key, "0b1d2897-640a-4c64-a1d8-b54f453a7ad7"); 45 | test.same(salt.toString("hex"), "deadbeef"); 46 | test.same(iv.toString("hex"), "7e117a1e7e117a1e7e117a1e7e117a1e"); 47 | }); 48 | 49 | cryptoTests.test("parses an encrypted string", async (test) => { 50 | // This is an encrypted string of the type we'd get from Amtrak. The private 51 | // key is "private_key" and the payload is a JSON string that (see the test) 52 | // is a message for the emperor. 53 | // 54 | // See the bottom of this test file for commented code where I built up the 55 | // test ciphertext. 56 | const parsedMessage = await parse( 57 | "9REEYi/JXW52zpVxlbzDP/zQ2NxJE8ykzOdkuiiLn8U0PskEpazNwyQIpmOBlJthSQeY8NhCd9gldfh7C/CscgnbFUD7IHkKaK4fnwB6tyY1C5vh4yZ8rUj5NmPMHCM9G2d/zqKvBZw3iXZFjg18Jw==", 58 | ); 59 | 60 | test.matchOnly(parsedMessage, { a: "message", for: "the emperor" }); 61 | }); 62 | }); 63 | 64 | /* 65 | import { createDecipheriv, createCipheriv, pbkdf2Sync } from "node:crypto"; 66 | 67 | const PUBLIC_KEY = "0b1d2897-640a-4c64-a1d8-b54f453a7ad7"; 68 | const CRYPTO_SALT = Buffer.from("deadbeef", "hex"); 69 | const CRYPTO_IV = Buffer.from("7e117a1e7e117a1e7e117a1e7e117a1e", "hex"); 70 | 71 | // Encryption method. It's basically a reverse of the decrypt method in 72 | // the crypto.js utility module 73 | const enc = async (str, PK = PUBLIC_KEY) => 74 | new Promise((resolve) => { 75 | const key = pbkdf2Sync(PK, CRYPTO_SALT, 1_000, 16, "sha1"); 76 | const cipher = createCipheriv("aes-128-cbc", key, CRYPTO_IV); 77 | cipher.setEncoding("base64"); 78 | 79 | const ciphertext = []; 80 | cipher.on("data", (chunk) => { 81 | if (chunk) { 82 | ciphertext.push(chunk); 83 | } 84 | }); 85 | cipher.on("end", () => { 86 | resolve(ciphertext.join("")); 87 | }); 88 | 89 | cipher.write(str); 90 | cipher.end(); 91 | }); 92 | 93 | const makeCipherBlob = async () => { 94 | // The actual private key is derived from the password, which is the first 95 | // part of this string, "private_key". But this extra stuff is tacked on to 96 | // private key when it's encrypted, ostensibly to make it harder to decrypt. 97 | const privateKey = 98 | "private_key|date string or something|time sneaky sneaky haha"; 99 | 100 | // Encrypt the private key with the global public key 101 | const cipherkey = await enc(privateKey); 102 | 103 | // The binary length needs to be 64 and the base64-encoded length needs to be 104 | // 88. I output these here so I could fiddle with the padding stuff above to 105 | // get the right output lengths. 106 | console.log(cipherkey.length, Buffer.from(cipherkey, "base64").length); 107 | 108 | // Here's the actual message. 109 | const plaintext = JSON.stringify({ a: "message", for: "the emperor" }); 110 | 111 | // Encrypt it with "private_key," not with the encrypted or derived keys. 112 | const ciphertext = await enc(plaintext, "private_key"); 113 | 114 | // Smoosh them together and display them. This is what we can use for tests. 115 | console.log(`${ciphertext}${cipherkey}`); 116 | }; 117 | 118 | await makeCipherBlob(); 119 | */ 120 | -------------------------------------------------------------------------------- /src/data/date.js: -------------------------------------------------------------------------------- 1 | import customParseFormat from "dayjs/plugin/customParseFormat.js"; 2 | import dayjs from "dayjs"; 3 | import timezone from "dayjs/plugin/timezone.js"; 4 | import utc from "dayjs/plugin/utc.js"; 5 | 6 | dayjs.extend(customParseFormat); 7 | dayjs.extend(utc); 8 | dayjs.extend(timezone); 9 | 10 | export const getTimezoneFromCharacter = (tzIn) => { 11 | // The API returns timezones as single characters. I've only encountered the 12 | // four primary continental US timezones. The continental part makes sense 13 | // because these are trains, after all, but this doesn't account for all the 14 | // quirks with states and/or municipalities opting out of daylight savings 15 | // time. 16 | switch (tzIn.toUpperCase()) { 17 | case "P": 18 | return "America/Los_Angeles"; 19 | case "M": 20 | return "America/Denver"; 21 | case "C": 22 | return "America/Chicago"; 23 | case "E": 24 | default: 25 | return "America/New_York"; 26 | } 27 | }; 28 | 29 | export const parseDate = (uglyDate, tz = "America/New_York") => { 30 | if (!uglyDate) { 31 | return null; 32 | } 33 | 34 | // DayJS can parse the ugly dates if we tell it what the format is. Give it a 35 | // timezone and then turn that puppy into an ISO8601 UTC string. 36 | return dayjs.tz(uglyDate, "MM/DD/YYYY hh:mm:ss", tz).toISOString(); 37 | }; 38 | -------------------------------------------------------------------------------- /src/data/date.test.js: -------------------------------------------------------------------------------- 1 | import tap from "tap"; 2 | 3 | import { getTimezoneFromCharacter, parseDate } from "./date.js"; 4 | 5 | tap.test("date utilities", async (dateTests) => { 6 | dateTests.test("get timezone from a character", async (tzCharTests) => { 7 | tzCharTests.test("east coast", async (test) => { 8 | const actual = getTimezoneFromCharacter("e"); 9 | test.same(actual, "America/New_York"); 10 | }); 11 | 12 | tzCharTests.test("center coast", async (test) => { 13 | const actual = getTimezoneFromCharacter("c"); 14 | test.same(actual, "America/Chicago"); 15 | }); 16 | 17 | tzCharTests.test("mountain coast", async (test) => { 18 | const actual = getTimezoneFromCharacter("m"); 19 | test.same(actual, "America/Denver"); 20 | }); 21 | 22 | tzCharTests.test("left coast", async (test) => { 23 | const actual = getTimezoneFromCharacter("p"); 24 | test.same(actual, "America/Los_Angeles"); 25 | }); 26 | 27 | tzCharTests.test("unknown coast", async (test) => { 28 | const actual = getTimezoneFromCharacter("q"); 29 | test.same(actual, "America/New_York"); 30 | }); 31 | }); 32 | 33 | dateTests.test( 34 | "parse ugly Amtrak date into ISO8601 strings", 35 | async (parseTests) => { 36 | parseTests.test("handles falsey dates", async (test) => { 37 | const actual = parseDate(false); 38 | test.same(actual, null); 39 | }); 40 | 41 | parseTests.test("defaults to NY timezone", async (test) => { 42 | const actual = parseDate("02/03/2017 3:47:00"); 43 | test.same(actual, "2017-02-03T08:47:00.000Z"); 44 | }); 45 | 46 | parseTests.test("uses the optionally provided timezone", async (test) => { 47 | const actual = parseDate("02/03/2017 3:47:00", "America/Denver"); 48 | test.same(actual, "2017-02-03T10:47:00.000Z"); 49 | }); 50 | }, 51 | ); 52 | }); 53 | -------------------------------------------------------------------------------- /src/data/main.js: -------------------------------------------------------------------------------- 1 | import defaultFs from "node:fs/promises"; 2 | import { getStations as defaultGetStations } from "./stations.js"; 3 | import { getTrains as defaultGetTrains } from "./trains.js"; 4 | 5 | const slugify = (str) => 6 | str 7 | .toLowerCase() 8 | .trim() 9 | .replace(/[\s_]/g, "-") 10 | .replace(/[^a-z0-9-_]/g, "") 11 | .replace(/-{2,}/g, "-"); 12 | 13 | export const main = async ({ 14 | fs = defaultFs, 15 | getStations = defaultGetStations, 16 | getTrains = defaultGetTrains, 17 | } = {}) => { 18 | const stations = await getStations(); 19 | const trains = await getTrains(stations ?? []); 20 | 21 | await fs.mkdir(`_site/routes`, { recursive: true }); 22 | 23 | if (stations) { 24 | await fs.writeFile("_site/stations.json", JSON.stringify(stations)); 25 | } 26 | if (trains) { 27 | await fs.writeFile("_site/trains.json", JSON.stringify(trains)); 28 | 29 | const routes = Array.from(new Set(trains.map(({ route }) => route))) 30 | .map((routeName) => ({ 31 | route: routeName, 32 | trains: trains.filter(({ route }) => route === routeName), 33 | })) 34 | .sort(({ route: a }, { route: b }) => { 35 | if (a > b) { 36 | return 1; 37 | } 38 | if (a < b) { 39 | return -1; 40 | } 41 | return 0; 42 | }); 43 | 44 | await fs.writeFile("_site/routes.json", JSON.stringify(routes)); 45 | 46 | for await (const route of routes) { 47 | await fs.writeFile( 48 | `_site/routes/${slugify(route.route)}.json`, 49 | JSON.stringify(route), 50 | ); 51 | } 52 | } 53 | }; 54 | 55 | const isMainModule = import.meta.url.endsWith(process.argv[1]); 56 | if (isMainModule) { 57 | main(); 58 | } 59 | -------------------------------------------------------------------------------- /src/data/main.test.js: -------------------------------------------------------------------------------- 1 | import sinon from "sinon"; 2 | import tap from "tap"; 3 | 4 | import { main } from "./main.js"; 5 | 6 | const sandbox = sinon.createSandbox(); 7 | 8 | tap.test("main module", async (t) => { 9 | const fs = { mkdir: sandbox.spy(), writeFile: sandbox.spy() }; 10 | const getStations = sandbox.stub(); 11 | const getTrains = sandbox.stub(); 12 | 13 | t.beforeEach(async () => {}); 14 | 15 | t.afterEach(() => { 16 | sandbox.restore(); 17 | }); 18 | 19 | t.test("main works", async () => { 20 | const stations = [ 21 | { code: "station 1", name: "Moopville Station" }, 22 | { code: "station 2", name: "Mooptropolis" }, 23 | ]; 24 | getStations.resolves(stations); 25 | 26 | const trains = [ 27 | { id: 1, number: 1, route: "Route 1" }, 28 | { id: 2, number: 2, route: "Route 1" }, 29 | { id: 4, number: 3, route: "Route 1" }, 30 | { id: 5, number: 20, route: "Route 3" }, 31 | { id: 8, number: 30, route: "Route 4" }, 32 | { id: 6, number: 10, route: "Route 2" }, 33 | { id: 7, number: 11, route: "Route 2" }, 34 | ]; 35 | getTrains.resolves(trains); 36 | 37 | const routes = [ 38 | { 39 | route: "Route 1", 40 | trains: [ 41 | { id: 1, number: 1, route: "Route 1" }, 42 | { id: 2, number: 2, route: "Route 1" }, 43 | { id: 4, number: 3, route: "Route 1" }, 44 | ], 45 | }, 46 | { 47 | route: "Route 2", 48 | trains: [ 49 | { id: 6, number: 10, route: "Route 2" }, 50 | { id: 7, number: 11, route: "Route 2" }, 51 | ], 52 | }, 53 | { route: "Route 3", trains: [{ id: 5, number: 20, route: "Route 3" }] }, 54 | { route: "Route 4", trains: [{ id: 8, number: 30, route: "Route 4" }] }, 55 | ]; 56 | 57 | await main({ fs, getStations, getTrains }); 58 | 59 | t.ok(fs.mkdir.calledWith("_site/routes", { recursive: true })); 60 | t.ok( 61 | fs.writeFile.calledWith("_site/stations.json", JSON.stringify(stations)), 62 | ); 63 | t.ok(fs.writeFile.calledWith("_site/trains.json", JSON.stringify(trains))); 64 | t.ok(fs.writeFile.calledWith("_site/routes.json", JSON.stringify(routes))); 65 | 66 | t.ok( 67 | fs.writeFile.calledWith( 68 | "_site/routes/route-1.json", 69 | JSON.stringify(routes[0]), 70 | ), 71 | ); 72 | 73 | t.ok( 74 | fs.writeFile.calledWith( 75 | "_site/routes/route-2.json", 76 | JSON.stringify(routes[1]), 77 | ), 78 | ); 79 | 80 | t.ok( 81 | fs.writeFile.calledWith( 82 | "_site/routes/route-3.json", 83 | JSON.stringify(routes[2]), 84 | ), 85 | ); 86 | 87 | t.ok( 88 | fs.writeFile.calledWith( 89 | "_site/routes/route-4.json", 90 | JSON.stringify(routes[3]), 91 | ), 92 | ); 93 | }); 94 | }); 95 | -------------------------------------------------------------------------------- /src/data/routeStation.js: -------------------------------------------------------------------------------- 1 | import { getTimezoneFromCharacter, parseDate } from "./date.js"; 2 | 3 | export const parseRouteStation = (routeStation, first = false) => { 4 | const timezone = getTimezoneFromCharacter(routeStation.tz); 5 | 6 | const station = { 7 | code: routeStation.code, 8 | bus: routeStation.bus, 9 | arrivalActual: null, 10 | arrivalEstimated: null, 11 | arrivalScheduled: null, 12 | departureActual: null, 13 | departureEstimated: null, 14 | departureScheduled: null, 15 | status: null, 16 | timezone, 17 | }; 18 | 19 | if (first && !routeStation.postdep) { 20 | // If this is the first station and the train has not yet departed, then 21 | // this station is scheduled. We only have scheduled and estimated departure 22 | // times available to us. 23 | station.status = "scheduled"; 24 | station.departureEstimated = parseDate(routeStation.estdep, timezone); 25 | station.departureScheduled = parseDate(routeStation.schdep, timezone); 26 | } else if (routeStation.postdep) { 27 | // If the train has departed this station... 28 | station.status = "departed"; 29 | 30 | if (first) { 31 | // ...and this is the first station, we only have scheduled and actual 32 | // departure times available, as the train kinda didn't arrive. It was 33 | // just... there. Spooky train. 34 | station.departureActual = parseDate(routeStation.postdep, timezone); 35 | station.departureScheduled = parseDate(routeStation.schdep, timezone); 36 | } else { 37 | // ...otherwise we can capture the scheduled and actual arrival and 38 | // depature times for this station. 39 | station.arrivalActual = parseDate(routeStation.postarr, timezone); 40 | station.arrivalScheduled = parseDate(routeStation.scharr, timezone); 41 | station.departureActual = parseDate(routeStation.postdep, timezone); 42 | station.departureScheduled = parseDate(routeStation.schdep, timezone); 43 | } 44 | } else if (routeStation.postarr) { 45 | // If the has not departed but HAS arrived, then the train is currently 46 | // sitting at the station. We can know when it was supposed to arrive, when 47 | // it did, when it is scheduled to leave, and when they think it'll actually 48 | // depart. 49 | station.status = "arrived"; 50 | station.arrivalActual = parseDate(routeStation.postarr, timezone); 51 | station.arrivalScheduled = parseDate(routeStation.scharr, timezone); 52 | station.departureEstimated = parseDate(routeStation.estdep, timezone); 53 | station.departureScheduled = parseDate(routeStation.schdep, timezone); 54 | } else { 55 | // If the train has neither departed nor arrived, then it must be on its 56 | // way. We only have scheduled and estimated times to work with. 57 | station.status = "enroute"; 58 | station.arrivalEstimated = parseDate(routeStation.estarr, timezone); 59 | station.arrivalScheduled = parseDate(routeStation.scharr, timezone); 60 | station.departureEstimated = parseDate(routeStation.estdep, timezone); 61 | station.departureScheduled = parseDate(routeStation.schdep, timezone); 62 | } 63 | 64 | return station; 65 | }; 66 | -------------------------------------------------------------------------------- /src/data/stations.js: -------------------------------------------------------------------------------- 1 | import { parse } from "./crypto.js"; 2 | 3 | export const getStations = async ({ 4 | cryptoParse = parse, 5 | fetch = global.fetch, 6 | } = {}) => { 7 | // Fetch the raw data. 8 | const rawData = await fetch( 9 | "https://maps.amtrak.com/services/MapDataService/stations/trainStations", 10 | ).then((response) => response.text()); 11 | 12 | // Decrypt it. 13 | const stations = await cryptoParse(rawData); 14 | 15 | // Sometimes Amtrak tells the GitHub action that it's not allowed to access 16 | // the site. Perhaps they're shutting this API down because they're not happy 17 | // that someone has made their data accessible since they're not willing to 18 | // do it, despite being publicly funded. 19 | if (stations?.StationsDataResponse?.error) { 20 | console.log(stations.StationsDataResponse.error.message); 21 | return []; 22 | } 23 | 24 | // Map it into a little bit cleaner structure, and keep the original raw data 25 | return stations?.StationsDataResponse?.features?.map( 26 | ({ properties: station }) => ({ 27 | code: station.Code, 28 | name: station.StationName, 29 | address1: station.Address1, 30 | address2: station.Address2, 31 | city: station.City, 32 | state: station.State, 33 | lat: station.lat, 34 | lon: station.lon, 35 | zip: station.Zipcode, 36 | _raw: station, 37 | }), 38 | ); 39 | }; 40 | -------------------------------------------------------------------------------- /src/data/stations.test.js: -------------------------------------------------------------------------------- 1 | import sinon from "sinon"; 2 | import tap from "tap"; 3 | 4 | import { getStations } from "./stations.js"; 5 | 6 | tap.test("stations fetcher", async (stationTests) => { 7 | const fetch = sinon.stub(); 8 | 9 | stationTests.beforeEach(() => { 10 | fetch.reset(); 11 | 12 | fetch.withArgs("https://maps.amtrak.com/rttl/js/RoutesList.json").resolves({ 13 | json: async () => [], 14 | }); 15 | 16 | const s = "12345678"; 17 | const v = "12345678901234567890123456789012"; 18 | 19 | fetch 20 | .withArgs("https://maps.amtrak.com/rttl/js/RoutesList.v.json") 21 | .resolves({ 22 | json: async () => ({ 23 | arr: ["0b1d2897-640a-4c64-a1d8-b54f453a7ad7"], 24 | s: [s, s, s, s, s, s, s, s, "deadbeef"], 25 | // prettier-ignore 26 | v: [ 27 | v, v, v, v, v, v, v, v, v, v, 28 | v, v, v, v, v, v, v, v, v, v, 29 | v, v, v, v, v, v, v, v, v, v, 30 | v, v, 31 | "7e117a1e7e117a1e7e117a1e7e117a1e", 32 | ], 33 | }), 34 | }); 35 | }); 36 | 37 | stationTests.test("maps stations into our lingo", async (test) => { 38 | fetch 39 | .withArgs( 40 | "https://maps.amtrak.com/services/MapDataService/stations/trainStations", 41 | ) 42 | .resolves({ 43 | text: async () => "this is some text", 44 | }); 45 | 46 | const cryptoParse = sinon.stub(); 47 | cryptoParse.resolves({ 48 | StationsDataResponse: { 49 | features: [ 50 | { 51 | properties: { 52 | Code: "STA1", 53 | StationName: "Station 1", 54 | Address1: "Street 1", 55 | Address2: "Unit 1", 56 | City: "Cityville", 57 | State: "FR", 58 | lat: 31, 59 | lon: -31, 60 | Zipcode: 11111, 61 | }, 62 | }, 63 | { 64 | properties: { 65 | Code: "STA2", 66 | StationName: "Station Two", 67 | Address1: "Street 2", 68 | Address2: "Apt 2", 69 | City: "Metropolis", 70 | State: "BQ", 71 | lat: 32, 72 | lon: -32, 73 | Zipcode: 22222, 74 | }, 75 | }, 76 | { 77 | properties: { 78 | Code: "STA3", 79 | StationName: "Third Station", 80 | Address1: "Street 3", 81 | Address2: "Loft #3", 82 | City: "Gotham City", 83 | State: "OP", 84 | lat: 33, 85 | lon: -33, 86 | Zipcode: 33333, 87 | }, 88 | }, 89 | ], 90 | }, 91 | }); 92 | 93 | const out = await getStations({ fetch, cryptoParse }); 94 | test.same(out, [ 95 | { 96 | code: "STA1", 97 | name: "Station 1", 98 | address1: "Street 1", 99 | address2: "Unit 1", 100 | city: "Cityville", 101 | state: "FR", 102 | lat: 31, 103 | lon: -31, 104 | zip: 11111, 105 | _raw: { 106 | Code: "STA1", 107 | StationName: "Station 1", 108 | Address1: "Street 1", 109 | Address2: "Unit 1", 110 | City: "Cityville", 111 | State: "FR", 112 | lat: 31, 113 | lon: -31, 114 | Zipcode: 11111, 115 | }, 116 | }, 117 | { 118 | code: "STA2", 119 | name: "Station Two", 120 | address1: "Street 2", 121 | address2: "Apt 2", 122 | city: "Metropolis", 123 | state: "BQ", 124 | lat: 32, 125 | lon: -32, 126 | zip: 22222, 127 | _raw: { 128 | Code: "STA2", 129 | StationName: "Station Two", 130 | Address1: "Street 2", 131 | Address2: "Apt 2", 132 | City: "Metropolis", 133 | State: "BQ", 134 | lat: 32, 135 | lon: -32, 136 | Zipcode: 22222, 137 | }, 138 | }, 139 | { 140 | code: "STA3", 141 | name: "Third Station", 142 | address1: "Street 3", 143 | address2: "Loft #3", 144 | city: "Gotham City", 145 | state: "OP", 146 | lat: 33, 147 | lon: -33, 148 | zip: 33333, 149 | _raw: { 150 | Code: "STA3", 151 | StationName: "Third Station", 152 | Address1: "Street 3", 153 | Address2: "Loft #3", 154 | City: "Gotham City", 155 | State: "OP", 156 | lat: 33, 157 | lon: -33, 158 | Zipcode: 33333, 159 | }, 160 | }, 161 | ]); 162 | }); 163 | }); 164 | -------------------------------------------------------------------------------- /src/data/trains.js: -------------------------------------------------------------------------------- 1 | import { parse } from "./crypto.js"; 2 | import { parseRouteStation } from "./routeStation.js"; 3 | 4 | export const getTrains = async ( 5 | allStationMetadata, 6 | { fetch = global.fetch, cryptoParse = parse } = {}, 7 | ) => { 8 | // Fetch the train data. This is an encrypted blob. 9 | const rawData = await fetch( 10 | "https://maps.amtrak.com/services/MapDataService/trains/getTrainsData", 11 | ).then((response) => response.text()); 12 | 13 | // Decrypt all in one swoop. 14 | const trains = await cryptoParse(rawData); 15 | 16 | // Now clean up the train data. 17 | return ( 18 | trains.features 19 | .map(({ properties: train }) => { 20 | // The train has a bunch of properties Station1 through Station41. If a 21 | // train visits fewer stations, the remainder are just null. It seems 22 | // fragile to just assume there will always be the same number of these 23 | // properties, so instead, find all property keys that match, filter out the 24 | // ones with no data, and sort by number. Now they're in visit order. 25 | // Good work, team! 26 | const stations = Object.keys(train) 27 | .filter((key) => /^Station\d{1,2}$/.test(key)) 28 | .filter((key) => !!train[key]) 29 | .sort((a, b) => { 30 | const numA = +a.replace(/\D/g, ""); 31 | const numB = +b.replace(/\D/g, ""); 32 | return numA - numB; 33 | }) 34 | // Now we can clean up the information for each station, too. 35 | .map((stationKey, stationNumber) => { 36 | // So the station metadata is string-encoded JSON. Which I guess means 37 | // it was double encoded, since the containing object was also string- 38 | // encoded JSON. Shrugging-person-made-of-symbols. 39 | const stationData = JSON.parse(train[stationKey]); 40 | return { 41 | // This function takes care of figuring out whether the train is 42 | // scheduled, arrived, enroute, or departed. It also turns all the 43 | // timestamps into proper ISO8601 UTC timestamps, from what the API 44 | // provides (local timestamps, but not quite ISO8601, and with the 45 | // timezone provided as a single letter). 46 | // 47 | // This function needs to know if this is the first station in the 48 | // list because the first station is handled a little differently from 49 | // the rest. So that's the second argument. 50 | ...parseRouteStation(stationData, stationNumber === 0), 51 | 52 | // From the list of all stations provided, we can find this station 53 | // and add its metadata too. 54 | station: allStationMetadata.find( 55 | (value) => value.code === stationData.code, 56 | ), 57 | 58 | // And finally, keep the raw data. 59 | _raw: stationData, 60 | }; 61 | }) 62 | // And clear out this CBN station. No idea what it is, but there's no 63 | // associated CBN station metadata, and I believe I saw this somewhere on 64 | // the Amtrak site as well. 65 | .filter(({ code }) => code !== "CBN"); 66 | 67 | // If the first station is scheduled, all stations are scheduled, regardless 68 | // of the prior logic. So go ahead and fix that up. 69 | if (stations.length > 0 && stations[0].status === "scheduled") { 70 | stations.forEach((station) => { 71 | station.status = "scheduled"; 72 | }); 73 | } 74 | 75 | // If any station is in "arrived" status, then there can't be any enroute 76 | // stations because the train isn't enroute anywhere. 77 | if (stations.some(({ status }) => status === "arrived")) { 78 | stations 79 | .filter(({ status }) => status === "enroute") 80 | .forEach((station) => { 81 | station.status = "scheduled"; 82 | }); 83 | } 84 | 85 | // Also, there is only one enroute station. Any stations downtrack from that 86 | // are just scheduled. 87 | const enrouteIndex = stations.findIndex( 88 | ({ status }) => status === "enroute", 89 | ); 90 | if (enrouteIndex >= 0) { 91 | stations.slice(enrouteIndex + 1).forEach((station) => { 92 | station.status = "scheduled"; 93 | }); 94 | } 95 | 96 | // Now that we've turned all those weird StationDD properties into a single 97 | // sorted parsed array, we can delete them. 98 | Object.keys(train) 99 | .filter((key) => /^Station\d{1,2}$/.test(key)) 100 | .forEach((key) => delete train[key]); 101 | 102 | // And build our cleaned up train object. Also keep the raw data. 103 | const newTrain = { 104 | id: train.ID, 105 | heading: train.Heading, 106 | number: +train.TrainNum, 107 | route: train.RouteName, 108 | stations, 109 | _raw: train, 110 | }; 111 | 112 | return newTrain; 113 | }) 114 | // Sometimes there are trains with no stations. This seems to be common when 115 | // one of the listed trains is predeparture. 116 | .filter(({ stations }) => stations.length > 0) 117 | ); 118 | }; 119 | -------------------------------------------------------------------------------- /src/data/trains.test.data.js: -------------------------------------------------------------------------------- 1 | export const scheduled = { 2 | features: [ 3 | { 4 | properties: { 5 | ID: "train 1", 6 | Heading: "nw", 7 | TrainNum: "37", 8 | RouteName: "Big Route", 9 | Station3: JSON.stringify({ 10 | code: "STA3", 11 | bus: false, 12 | tz: "P", 13 | scharr: "10/10/2020 08:00:00", 14 | schdep: "10/10/2020 08:10:00", 15 | }), 16 | Station1: JSON.stringify({ 17 | code: "STA1", 18 | bus: false, 19 | tz: "E", 20 | estdep: "10/10/2020 07:00:00", 21 | schdep: "10/10/2020 06:30:00", 22 | }), 23 | Station2: JSON.stringify({ 24 | code: "STA2", 25 | bus: false, 26 | tz: "M", 27 | scharr: "10/10/2020 07:15:00", 28 | schdep: "10/10/2020 07:20:00", 29 | }), 30 | }, 31 | }, 32 | ], 33 | }; 34 | 35 | export const enrouteArrived = { 36 | features: [ 37 | { 38 | properties: { 39 | ID: "train 2", 40 | Heading: "ns", 41 | TrainNum: "42", 42 | RouteName: "Choo Choo Route", 43 | Station2: JSON.stringify({ 44 | code: "STA2", 45 | bus: false, 46 | tz: "M", 47 | postarr: "10/10/2020 13:45:00", 48 | postdep: "10/10/2020 13:48:00", 49 | }), 50 | Station4: JSON.stringify({ 51 | code: "STA4", 52 | bus: false, 53 | tz: "M", 54 | scharr: "10/10/2020 18:15:00", 55 | }), 56 | Station3: JSON.stringify({ 57 | code: "STA3", 58 | bus: false, 59 | tz: "E", 60 | postarr: "10/10/2020 14:19:00", 61 | }), 62 | Station1: JSON.stringify({ 63 | code: "STA1", 64 | bus: false, 65 | tz: "C", 66 | postdep: "10/10/2020 13:25:00", 67 | }), 68 | }, 69 | }, 70 | ], 71 | }; 72 | 73 | export const enroute = { 74 | features: [ 75 | { 76 | properties: { 77 | ID: "train 2", 78 | Heading: "ns", 79 | TrainNum: "42", 80 | RouteName: "Choo Choo Route", 81 | Station2: JSON.stringify({ 82 | code: "STA2", 83 | bus: false, 84 | tz: "M", 85 | postarr: "10/10/2020 13:45:00", 86 | postdep: "10/10/2020 13:48:00", 87 | }), 88 | Station4: JSON.stringify({ 89 | code: "STA4", 90 | bus: false, 91 | tz: "M", 92 | scharr: "10/10/2020 18:15:00", 93 | }), 94 | Station3: JSON.stringify({ 95 | code: "STA3", 96 | bus: false, 97 | tz: "E", 98 | postarr: "10/10/2020 14:19:00", 99 | postdep: "10/10/2020 14:23:00", 100 | }), 101 | Station1: JSON.stringify({ 102 | code: "STA1", 103 | bus: false, 104 | tz: "C", 105 | postdep: "10/10/2020 13:25:00", 106 | }), 107 | Station5: JSON.stringify({ 108 | code: "STA5", 109 | bus: false, 110 | tz: "M", 111 | scharr: "10/10/2020 18:15:00", 112 | }), 113 | }, 114 | }, 115 | ], 116 | }; 117 | -------------------------------------------------------------------------------- /src/data/trains.test.js: -------------------------------------------------------------------------------- 1 | import sinon from "sinon"; 2 | import tap from "tap"; 3 | import { enroute, enrouteArrived, scheduled } from "./trains.test.data.js"; 4 | 5 | import { getTrains } from "./trains.js"; 6 | 7 | tap.test("trains fetcher", async (trainTests) => { 8 | const fetch = sinon.stub(); 9 | const cryptoParse = sinon.stub(); 10 | 11 | trainTests.beforeEach(() => { 12 | fetch.reset(); 13 | cryptoParse.reset(); 14 | 15 | fetch.withArgs("https://maps.amtrak.com/rttl/js/RoutesList.json").resolves({ 16 | json: async () => [], 17 | }); 18 | 19 | const s = "12345678"; 20 | const v = "12345678901234567890123456789012"; 21 | 22 | fetch 23 | .withArgs("https://maps.amtrak.com/rttl/js/RoutesList.v.json") 24 | .resolves({ 25 | json: async () => ({ 26 | arr: ["0b1d2897-640a-4c64-a1d8-b54f453a7ad7"], 27 | s: [s, s, s, s, s, s, s, s, "deadbeef"], 28 | // prettier-ignore 29 | v: [ 30 | v, v, v, v, v, v, v, v, v, v, 31 | v, v, v, v, v, v, v, v, v, v, 32 | v, v, v, v, v, v, v, v, v, v, 33 | v, v, 34 | "7e117a1e7e117a1e7e117a1e7e117a1e", 35 | ], 36 | }), 37 | }); 38 | 39 | fetch 40 | .withArgs( 41 | "https://maps.amtrak.com/services/MapDataService/trains/getTrainsData", 42 | ) 43 | .resolves({ 44 | text: async () => "this is some text", 45 | }); 46 | }); 47 | 48 | trainTests.test("a predeparture train", async (test) => { 49 | cryptoParse.resolves({ 50 | features: [ 51 | { 52 | properties: { 53 | ID: "bad train", 54 | TrainNum: "333", 55 | Station4: null, 56 | Station2: null, 57 | Station1: null, 58 | Station3: null, 59 | }, 60 | }, 61 | ], 62 | }); 63 | 64 | const out = await getTrains([], { fetch, cryptoParse }); 65 | test.same(out, []); 66 | }); 67 | 68 | trainTests.test("a scheduled train", async (test) => { 69 | cryptoParse.resolves(scheduled); 70 | 71 | const out = await getTrains( 72 | [ 73 | { code: "STA1", station: "station 1 metadata" }, 74 | { code: "STA2", station: "station 2 metadata" }, 75 | { code: "STA3", station: "station 3 metadata" }, 76 | ], 77 | { fetch, cryptoParse }, 78 | ); 79 | 80 | test.same(out, [ 81 | { 82 | id: "train 1", 83 | heading: "nw", 84 | number: 37, 85 | route: "Big Route", 86 | stations: [ 87 | { 88 | code: "STA1", 89 | bus: false, 90 | arrivalActual: null, 91 | arrivalEstimated: null, 92 | arrivalScheduled: null, 93 | departureActual: null, 94 | departureEstimated: "2020-10-10T11:00:00.000Z", 95 | departureScheduled: "2020-10-10T10:30:00.000Z", 96 | status: "scheduled", 97 | timezone: "America/New_York", 98 | station: { 99 | code: "STA1", 100 | station: "station 1 metadata", 101 | }, 102 | _raw: { 103 | code: "STA1", 104 | bus: false, 105 | tz: "E", 106 | estdep: "10/10/2020 07:00:00", 107 | schdep: "10/10/2020 06:30:00", 108 | }, 109 | }, 110 | { 111 | code: "STA2", 112 | bus: false, 113 | arrivalActual: null, 114 | arrivalEstimated: null, 115 | arrivalScheduled: "2020-10-10T13:15:00.000Z", 116 | departureActual: null, 117 | departureEstimated: null, 118 | departureScheduled: "2020-10-10T13:20:00.000Z", 119 | status: "scheduled", 120 | timezone: "America/Denver", 121 | station: { 122 | code: "STA2", 123 | station: "station 2 metadata", 124 | }, 125 | _raw: { 126 | code: "STA2", 127 | bus: false, 128 | tz: "M", 129 | scharr: "10/10/2020 07:15:00", 130 | schdep: "10/10/2020 07:20:00", 131 | }, 132 | }, 133 | { 134 | code: "STA3", 135 | bus: false, 136 | arrivalActual: null, 137 | arrivalEstimated: null, 138 | arrivalScheduled: "2020-10-10T15:00:00.000Z", 139 | departureActual: null, 140 | departureEstimated: null, 141 | departureScheduled: "2020-10-10T15:10:00.000Z", 142 | status: "scheduled", 143 | timezone: "America/Los_Angeles", 144 | station: { 145 | code: "STA3", 146 | station: "station 3 metadata", 147 | }, 148 | _raw: { 149 | code: "STA3", 150 | bus: false, 151 | tz: "P", 152 | scharr: "10/10/2020 08:00:00", 153 | schdep: "10/10/2020 08:10:00", 154 | }, 155 | }, 156 | ], 157 | _raw: { 158 | ID: "train 1", 159 | Heading: "nw", 160 | TrainNum: "37", 161 | RouteName: "Big Route", 162 | }, 163 | }, 164 | ]); 165 | }); 166 | 167 | trainTests.test( 168 | "a train that has begun its route but is currently sitting at a station", 169 | async (test) => { 170 | cryptoParse.resolves(enrouteArrived); 171 | 172 | const out = await getTrains( 173 | [ 174 | { code: "STA1", station: "station 1 metadata" }, 175 | { code: "STA2", station: "station 2 metadata" }, 176 | { code: "STA3", station: "station 3 metadata" }, 177 | { code: "STA4", station: "station 4 metadata" }, 178 | ], 179 | { fetch, cryptoParse }, 180 | ); 181 | 182 | test.same(out, [ 183 | { 184 | id: "train 2", 185 | heading: "ns", 186 | number: 42, 187 | route: "Choo Choo Route", 188 | stations: [ 189 | { 190 | code: "STA1", 191 | bus: false, 192 | arrivalActual: null, 193 | arrivalEstimated: null, 194 | arrivalScheduled: null, 195 | departureActual: "2020-10-10T18:25:00.000Z", 196 | departureEstimated: null, 197 | departureScheduled: null, 198 | status: "departed", 199 | timezone: "America/Chicago", 200 | station: { 201 | code: "STA1", 202 | station: "station 1 metadata", 203 | }, 204 | _raw: { 205 | code: "STA1", 206 | bus: false, 207 | tz: "C", 208 | postdep: "10/10/2020 13:25:00", 209 | }, 210 | }, 211 | { 212 | code: "STA2", 213 | bus: false, 214 | arrivalActual: "2020-10-10T19:45:00.000Z", 215 | arrivalEstimated: null, 216 | arrivalScheduled: null, 217 | departureActual: "2020-10-10T19:48:00.000Z", 218 | departureEstimated: null, 219 | departureScheduled: null, 220 | status: "departed", 221 | timezone: "America/Denver", 222 | station: { 223 | code: "STA2", 224 | station: "station 2 metadata", 225 | }, 226 | _raw: { 227 | code: "STA2", 228 | bus: false, 229 | tz: "M", 230 | postarr: "10/10/2020 13:45:00", 231 | postdep: "10/10/2020 13:48:00", 232 | }, 233 | }, 234 | { 235 | code: "STA3", 236 | bus: false, 237 | arrivalActual: "2020-10-10T18:19:00.000Z", 238 | arrivalEstimated: null, 239 | arrivalScheduled: null, 240 | departureActual: null, 241 | departureEstimated: null, 242 | departureScheduled: null, 243 | status: "arrived", 244 | timezone: "America/New_York", 245 | station: { 246 | code: "STA3", 247 | station: "station 3 metadata", 248 | }, 249 | _raw: { 250 | code: "STA3", 251 | bus: false, 252 | tz: "E", 253 | postarr: "10/10/2020 14:19:00", 254 | }, 255 | }, 256 | { 257 | code: "STA4", 258 | bus: false, 259 | arrivalActual: null, 260 | arrivalEstimated: null, 261 | arrivalScheduled: "2020-10-11T00:15:00.000Z", 262 | departureActual: null, 263 | departureEstimated: null, 264 | departureScheduled: null, 265 | status: "scheduled", 266 | timezone: "America/Denver", 267 | station: { 268 | code: "STA4", 269 | station: "station 4 metadata", 270 | }, 271 | _raw: { 272 | code: "STA4", 273 | bus: false, 274 | tz: "M", 275 | scharr: "10/10/2020 18:15:00", 276 | }, 277 | }, 278 | ], 279 | _raw: { 280 | ID: "train 2", 281 | Heading: "ns", 282 | TrainNum: "42", 283 | RouteName: "Choo Choo Route", 284 | }, 285 | }, 286 | ]); 287 | }, 288 | ); 289 | 290 | trainTests.test( 291 | "a train that has begun its route and is currently between stations", 292 | async (test) => { 293 | cryptoParse.resolves(enroute); 294 | 295 | const out = await getTrains( 296 | [ 297 | { code: "STA1", station: "station 1 metadata" }, 298 | { code: "STA2", station: "station 2 metadata" }, 299 | { code: "STA3", station: "station 3 metadata" }, 300 | { code: "STA4", station: "station 4 metadata" }, 301 | { code: "STA5", station: "station 5 metadata" }, 302 | ], 303 | { fetch, cryptoParse }, 304 | ); 305 | 306 | test.same(out, [ 307 | { 308 | id: "train 2", 309 | heading: "ns", 310 | number: 42, 311 | route: "Choo Choo Route", 312 | stations: [ 313 | { 314 | code: "STA1", 315 | bus: false, 316 | arrivalActual: null, 317 | arrivalEstimated: null, 318 | arrivalScheduled: null, 319 | departureActual: "2020-10-10T18:25:00.000Z", 320 | departureEstimated: null, 321 | departureScheduled: null, 322 | status: "departed", 323 | timezone: "America/Chicago", 324 | station: { 325 | code: "STA1", 326 | station: "station 1 metadata", 327 | }, 328 | _raw: { 329 | code: "STA1", 330 | bus: false, 331 | tz: "C", 332 | postdep: "10/10/2020 13:25:00", 333 | }, 334 | }, 335 | { 336 | code: "STA2", 337 | bus: false, 338 | arrivalActual: "2020-10-10T19:45:00.000Z", 339 | arrivalEstimated: null, 340 | arrivalScheduled: null, 341 | departureActual: "2020-10-10T19:48:00.000Z", 342 | departureEstimated: null, 343 | departureScheduled: null, 344 | status: "departed", 345 | timezone: "America/Denver", 346 | station: { 347 | code: "STA2", 348 | station: "station 2 metadata", 349 | }, 350 | _raw: { 351 | code: "STA2", 352 | bus: false, 353 | tz: "M", 354 | postarr: "10/10/2020 13:45:00", 355 | postdep: "10/10/2020 13:48:00", 356 | }, 357 | }, 358 | { 359 | code: "STA3", 360 | bus: false, 361 | arrivalActual: "2020-10-10T18:19:00.000Z", 362 | arrivalEstimated: null, 363 | arrivalScheduled: null, 364 | departureActual: "2020-10-10T18:23:00.000Z", 365 | departureEstimated: null, 366 | departureScheduled: null, 367 | status: "departed", 368 | timezone: "America/New_York", 369 | station: { 370 | code: "STA3", 371 | station: "station 3 metadata", 372 | }, 373 | _raw: { 374 | code: "STA3", 375 | bus: false, 376 | tz: "E", 377 | postarr: "10/10/2020 14:19:00", 378 | postdep: "10/10/2020 14:23:00", 379 | }, 380 | }, 381 | { 382 | code: "STA4", 383 | bus: false, 384 | arrivalActual: null, 385 | arrivalEstimated: null, 386 | arrivalScheduled: "2020-10-11T00:15:00.000Z", 387 | departureActual: null, 388 | departureEstimated: null, 389 | departureScheduled: null, 390 | status: "enroute", 391 | timezone: "America/Denver", 392 | station: { 393 | code: "STA4", 394 | station: "station 4 metadata", 395 | }, 396 | _raw: { 397 | code: "STA4", 398 | bus: false, 399 | tz: "M", 400 | scharr: "10/10/2020 18:15:00", 401 | }, 402 | }, 403 | { 404 | code: "STA5", 405 | bus: false, 406 | arrivalActual: null, 407 | arrivalEstimated: null, 408 | arrivalScheduled: "2020-10-11T00:15:00.000Z", 409 | departureActual: null, 410 | departureEstimated: null, 411 | departureScheduled: null, 412 | status: "scheduled", 413 | timezone: "America/Denver", 414 | station: { 415 | code: "STA5", 416 | station: "station 5 metadata", 417 | }, 418 | _raw: { 419 | code: "STA5", 420 | bus: false, 421 | tz: "M", 422 | scharr: "10/10/2020 18:15:00", 423 | }, 424 | }, 425 | ], 426 | _raw: { 427 | ID: "train 2", 428 | Heading: "ns", 429 | TrainNum: "42", 430 | RouteName: "Choo Choo Route", 431 | }, 432 | }, 433 | ]); 434 | }, 435 | ); 436 | }); 437 | -------------------------------------------------------------------------------- /src/site/build.js: -------------------------------------------------------------------------------- 1 | import dotenv from "dotenv"; 2 | import fs from "node:fs/promises"; 3 | import path from "node:path"; 4 | import handlebars from "handlebars"; 5 | import dayjs from "dayjs"; 6 | import duration from "dayjs/plugin/duration.js"; 7 | import relativeTime from "dayjs/plugin/relativeTime.js"; 8 | import timezone from "dayjs/plugin/timezone.js"; 9 | import utc from "dayjs/plugin/utc.js"; 10 | 11 | dayjs.extend(duration); 12 | dayjs.extend(relativeTime); 13 | dayjs.extend(utc); 14 | dayjs.extend(timezone); 15 | 16 | dotenv.config(); 17 | 18 | const slugify = (str) => 19 | str 20 | .toLowerCase() 21 | .trim() 22 | .replace(/[\s_]/g, "-") 23 | .replace(/[^a-z0-9-_]/g, "") 24 | .replace(/-{2,}/g, "-"); 25 | 26 | const stationDelay = (station) => { 27 | const delay = { arrival: false, departure: false }; 28 | 29 | if ( 30 | station.arrivalScheduled && 31 | (station.arrivalActual || station.arrivalEstimated) 32 | ) { 33 | // The train can't have a delayed arrival if there's not a scheduled arrival 34 | // or at least one of actual or estimated arrival. 35 | const scheduled = dayjs(station.arrivalScheduled).tz(station.timezone); 36 | const actual = dayjs(station.arrivalActual ?? station.arrivalEstimated).tz( 37 | station.timezone, 38 | ); 39 | 40 | const delayTime = dayjs.duration(actual.diff(scheduled)); 41 | if (delayTime.asMinutes() > 10) { 42 | delay.arrival = delayTime.humanize(); 43 | } 44 | } 45 | 46 | if ( 47 | station.departureScheduled && 48 | (station.departureActual || station.departureEstimated) 49 | ) { 50 | // Same kinda logic for departure delays. :) 51 | const scheduled = dayjs(station.departureScheduled).tz(station.timezone); 52 | const actual = dayjs( 53 | station.departureActual ?? station.departureEstimated, 54 | ).tz(station.timezone); 55 | 56 | const delayTime = dayjs.duration(actual.diff(scheduled)); 57 | if (delayTime.asMinutes() > 10) { 58 | delay.departure = delayTime.humanize(); 59 | } 60 | } 61 | 62 | return delay; 63 | }; 64 | 65 | const dayAndTime = (str, tz) => { 66 | const ts = dayjs(str).tz(tz); 67 | return `${ts.format("dddd")} at ${ts.format("h:mm A")}`; 68 | }; 69 | 70 | const BASE_URL = process.env.BASE_URL ?? "/amtrak-api"; 71 | 72 | const TAG_BG_COLORS = new Map([ 73 | ["scheduled", "bg-base-lighter"], 74 | ["departed", "bg-primary-dark"], 75 | ["enroute", "bg-green"], 76 | ["on time", "bg-green"], 77 | ["arrived", "bg-gold"], 78 | ["delayed", "bg-secondary-dark"], 79 | ]); 80 | 81 | const TAG_TEXT_COLORS = new Map([ 82 | ["scheduled", "text-black"], 83 | ["departed", "text-white"], 84 | ["enroute", "text-white"], 85 | ["on time", "text-white"], 86 | ["arrived", "text-black"], 87 | ["delayed", "text-white"], 88 | ]); 89 | 90 | const STATION_COLORS = new Map([ 91 | ["arrived", "bg-gold"], 92 | ["enroute", "bg-green"], 93 | ["departed", "bg-primary-dark"], 94 | ["scheduled", "bg-base-light"], 95 | ]); 96 | 97 | // Get the route data 98 | const routes = JSON.parse( 99 | await fs.readFile("_site/routes.json", { encoding: "utf-8" }), 100 | ).map((routeInfo) => { 101 | const routeUrl = `${BASE_URL}/${slugify(routeInfo.route)}`; 102 | const routePath = `_site/${slugify(routeInfo.route)}`; 103 | const route = { 104 | name: routeInfo.route, 105 | filepath: routePath, 106 | url: routeUrl, 107 | }; 108 | 109 | route.trains = routeInfo.trains.map((trainInfo) => { 110 | const train = { 111 | route: route.name, 112 | number: trainInfo.number, 113 | filepath: `${routePath}/${trainInfo.number}-${trainInfo.id}.html`, 114 | url: `${routeUrl}/${trainInfo.number}-${trainInfo.id}.html`, 115 | info: "", 116 | status: "scheduled", 117 | trackColor: "bg-base-light", 118 | }; 119 | 120 | train.stations = trainInfo.stations.map((stationInfo) => { 121 | const station = { 122 | name: stationInfo.station?.name ?? stationInfo.code, 123 | code: stationInfo.code, 124 | status: stationInfo.status, 125 | 126 | // Are our arrival and departure times known or expected? We know the 127 | // arrival time if the train has arrived or departed, and we know the 128 | // departure time if the train has departed. In all other cases, we're 129 | // just hoping for the best. 130 | arrivalKnown: 131 | stationInfo.status === "arrived" || stationInfo.status === "departed", 132 | departureKnown: stationInfo.status === "departed", 133 | 134 | delay: stationDelay(stationInfo), 135 | 136 | tag: { 137 | text: stationInfo.status, 138 | bg: TAG_BG_COLORS.get(stationInfo.status), 139 | color: TAG_TEXT_COLORS.get(stationInfo.status), 140 | }, 141 | 142 | dotColor: STATION_COLORS.get(stationInfo.status), 143 | spacerColors: { before: "", after: "" }, 144 | 145 | arrival: (() => { 146 | const t = 147 | stationInfo.arrivalActual ?? 148 | stationInfo.arrivalEstimated ?? 149 | stationInfo.arrivalScheduled ?? 150 | false; 151 | if (t) { 152 | return dayAndTime(t, stationInfo.timezone); 153 | } 154 | return false; 155 | })(), 156 | departure: (() => { 157 | const t = 158 | stationInfo.departureActual ?? 159 | stationInfo.departureEstimated ?? 160 | stationInfo.departureScheduled ?? 161 | false; 162 | if (t) { 163 | return dayAndTime(t, stationInfo.timezone); 164 | } 165 | return false; 166 | })(), 167 | }; 168 | 169 | if (station.status === "enroute" || station.status === "scheduled") { 170 | station.info = []; 171 | if (station.delay.arrival || station.delay.departure) { 172 | station.info.push("This train is running behind schedule."); 173 | if (station.delay.arrival) { 174 | station.info.push( 175 | `It will arrive about ${station.delay.arrival} late`, 176 | ); 177 | } 178 | 179 | if (station.delay.arrival && station.delay.departure) { 180 | station.info.push(" and "); 181 | } 182 | 183 | if (station.delay.departure) { 184 | if (!station.delay.arrival) { 185 | station.info.push("It "); 186 | } 187 | station.info.push( 188 | ` will depart about ${station.delay.departure} late.`, 189 | ); 190 | } 191 | } 192 | station.info = station.info.join(" "); 193 | } 194 | 195 | return station; 196 | }); 197 | 198 | train.stations.forEach((station, i) => { 199 | if (station.status === "departed") { 200 | station.spacerColors.after = "bg-primary-dark"; 201 | } 202 | if (i > 0) { 203 | station.spacerColors.before = train.stations[i - 1].spacerColors.after; 204 | } 205 | }); 206 | 207 | if (train.stations[0].status !== "scheduled") { 208 | train.status = "on time"; 209 | } 210 | if (train.stations.slice(-1).pop().status === "arrived") { 211 | train.status = "arrived"; 212 | } 213 | train.tag = { 214 | text: train.status, 215 | bg: TAG_BG_COLORS.get(train.status), 216 | color: TAG_TEXT_COLORS.get(train.status), 217 | }; 218 | if (train.status !== "scheduled") { 219 | train.trackColor = "bg-primary-light"; 220 | } 221 | 222 | const first = train.stations[0]; 223 | const final = train.stations.slice(-1).pop(); 224 | 225 | if (train.status === "scheduled") { 226 | train.info = `Scheduled to depart ${first.name} on ${first.departure} and arrive at ${final.name} on ${final.arrival}.`; 227 | } else { 228 | const { previous, next } = (() => { 229 | const index = train.stations.findLastIndex( 230 | ({ status }) => status === "departed" || status === "arrived", 231 | ); 232 | if (index > 0) { 233 | return { 234 | previous: train.stations[index], 235 | next: train.stations[index + 1], 236 | }; 237 | } 238 | return { 239 | previous: first, 240 | next: final, 241 | }; 242 | })(); 243 | 244 | if (previous?.delay.departure || next?.delay.arrival) { 245 | train.tag.text = "delayed"; 246 | train.tag.bg = TAG_BG_COLORS.get("delayed"); 247 | train.tag.color = TAG_TEXT_COLORS.get("delayed"); 248 | } 249 | 250 | const info = [ 251 | previous.status === "arrived" ? "Arrived at " : "Departed from ", 252 | previous.name, 253 | ]; 254 | if (previous.status === "arrived" && previous.delay.arrival) { 255 | info.push(" about ", previous.delay.arrival, " late"); 256 | } 257 | if (previous.status === "departed" && previous.delay.departure) { 258 | info.push(" about ", previous.delay.departure, " late"); 259 | } 260 | 261 | info.push(" on ", previous.arrival); 262 | 263 | if (previous.status === "departed" && next) { 264 | info.push(" and is scheduled to arrive at ", next.name); 265 | if (next.delay.arrival) { 266 | info.push(" about ", next.delay.arrival, " late"); 267 | } 268 | info.push(" on ", next.arrival); 269 | } else if (next) { 270 | info.push(". Expected to depart"); 271 | if (previous.delay.departure) { 272 | info.push(" about ", previous.delay.departure, " late"); 273 | } 274 | info.push(" on ", previous.departure, " and arrive at ", next.name); 275 | if (next.delay.arrival) { 276 | info.push(" about ", next.delay.arrival, " late"); 277 | } 278 | info.push(" on ", next.arrival); 279 | } 280 | info.push("."); 281 | 282 | train.info = info.join(""); 283 | } 284 | 285 | train.from = train.stations[0].name; 286 | train.to = train.stations.slice(-1).pop().name; 287 | 288 | return train; 289 | }); 290 | 291 | return route; 292 | }); 293 | 294 | // Register all our handlebar partials so they're available in our templates. 295 | await fs 296 | .readdir("src/site/templates/partials") 297 | .then((files) => 298 | files 299 | .filter((file) => file.endsWith(".handlebars")) 300 | .map(async (file) => { 301 | const name = path.basename(file, ".handlebars"); 302 | const template = await fs.readFile( 303 | path.join("src/site/templates/partials", file), 304 | { encoding: "utf-8" }, 305 | ); 306 | handlebars.registerPartial(name, template); 307 | }), 308 | ) 309 | .then((promises) => Promise.all(promises)); 310 | 311 | // Load our two template files. One is for index pages (front page and route 312 | // pages), and the other is for a single train. 313 | const indexTemplate = await fs 314 | .readFile("src/site/templates/index.handlebars", { 315 | encoding: "utf-8", 316 | }) 317 | .then((template) => handlebars.compile(template)); 318 | const trainTemplate = await fs 319 | .readFile("src/site/templates/train.handlebars", { 320 | encoding: "utf-8", 321 | }) 322 | .then((template) => handlebars.compile(template)); 323 | 324 | // Build the front page 325 | const indexPage = indexTemplate({ 326 | index: true, 327 | routes, 328 | }); 329 | await fs.writeFile("_site/index.html", indexPage); 330 | 331 | // Build all the route pages. 332 | const routePages = routes.map(async (route) => { 333 | // Make a directory for the route 334 | await fs.mkdir(route.filepath, { recursive: true }); 335 | 336 | // Build the route index page and write it out. 337 | const routePage = indexTemplate({ routes: [route] }); 338 | await fs.writeFile(path.join(route.filepath, "index.html"), routePage); 339 | 340 | // Now create pages for each train on the route. 341 | const trainPages = route.trains.map(async (train) => { 342 | const trainPage = trainTemplate(train); 343 | await fs.writeFile(path.join(train.filepath), trainPage); 344 | }); 345 | await Promise.all(trainPages); 346 | }); 347 | 348 | await Promise.all(routePages); 349 | -------------------------------------------------------------------------------- /src/site/templates/index.handlebars: -------------------------------------------------------------------------------- 1 | {{> start . }} 2 | 3 | {{#routes}} 4 | 5 |

6 | {{#if ../index }}{{/if}}{{ name }}{{#if ../index }}{{/if}} 7 |

8 | 9 | {{#each trains}} 10 | 11 |

12 | Train #{{ number }} 13 | {{ tag.text }} 14 |
15 | from {{ from }} to {{ to }} 16 |

17 | 18 | 19 | 20 | {{#each stations }} 21 | 22 | 23 | 24 | {{# unless @last }} 25 | 26 | {{/ unless }} 27 | {{/ each }} 28 | 29 | {{ info }} 30 |
31 | {{/each}} 32 | 33 |
34 | {{/routes}} 35 | 36 | {{> end }} -------------------------------------------------------------------------------- /src/site/templates/partials/end.handlebars: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/site/templates/partials/start.handlebars: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 63 | 64 | 65 | 66 | {{#if index}} 67 |
68 |
69 |

Download the data

70 |

71 | This website is built automatically from Amtrak data, updated about every 72 | 20 minutes. If you want to download the data yourself, it's available! 73 |

78 |

79 |
80 |
{{/if}} 81 | 82 |
83 |
84 |
-------------------------------------------------------------------------------- /src/site/templates/train.handlebars: -------------------------------------------------------------------------------- 1 | {{> start }} 2 | 3 |

{{ route }} Train #{{ number }}

4 | 5 |
6 | {{#each stations }} 7 |
8 |
9 | 10 | {{# unless @first }} 11 | 12 | {{/ unless }} 13 | 14 | 15 | 16 | {{# unless @last }} 17 | 18 | {{/ unless }} 19 | 20 |
21 |
22 |

{{ name }} ({{ code }})

23 |
24 | {{ tag.text }} 25 |
26 |
    27 | {{#if arrival }} 28 |
  • 29 | 30 | {{#if arrivalKnown }}Arrived{{/if}}{{#unless arrivalKnown }}Expected to arrive{{/unless}} 31 | 32 | on {{ arrival }} 33 |
  • 34 | {{/if}} 35 | {{#if departure }} 36 |
  • 37 | 38 | {{#if departureKnown }}Departed{{/if}}{{#unless departureKnown }}Expected to depart{{/unless}} 39 | 40 | on {{ departure }} 41 |
  • 42 | {{/if}} 43 |
44 | {{#if info }}

{{ info }}

{{/if}} 45 |
46 |
47 | {{/ each }} 48 |
49 | 50 | {{> end }} --------------------------------------------------------------------------------