Build TFI Live G2 deployable app
This commit is contained in:
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.evenhub-config/
|
||||||
|
.realgit/
|
||||||
|
*.ehpk
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
103
README.md
Normal file
103
README.md
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
# TFI Live G2
|
||||||
|
|
||||||
|
Even Realities G2 app for live Transport for Ireland departures.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Favourite stops
|
||||||
|
- Nearby stops using phone geolocation
|
||||||
|
- Typed stop/route search
|
||||||
|
- G2 microphone voice search through a configured STT endpoint
|
||||||
|
- Stop departure board for bus, Luas/tram, rail, DART, and other GTFS modes
|
||||||
|
- G2 touch controls with a mirrored phone UI
|
||||||
|
- Even SDK local storage with browser storage fallback
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Create `.env.local` when using live data:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
NTA_API_KEY=your_nta_key
|
||||||
|
NTA_TRIP_UPDATES_TARGET=https://api.nationaltransport.ie/gtfsr/v2/TripUpdates?format=json
|
||||||
|
VITE_TFI_API_BASE=https://your-worker.example
|
||||||
|
VITE_TFI_STOPS_URL=https://your-host.example/stops.json
|
||||||
|
VITE_STT_API_BASE=https://your-stt.example
|
||||||
|
VITE_STT_API_KEY=optional_stt_key
|
||||||
|
```
|
||||||
|
|
||||||
|
For real sideloaded/packed use, deploy a small proxy for NTA requests and set
|
||||||
|
`VITE_TFI_API_BASE` to it. The included Cloudflare Worker at `worker/nta-proxy.js`
|
||||||
|
keeps the NTA key server-side, caches the GTFS-R feed briefly, and returns
|
||||||
|
per-stop departures from `/departures?stopId=...&stopCode=...`. Configure Worker
|
||||||
|
secrets/vars:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
NTA_API_KEY=your_nta_key
|
||||||
|
NTA_TRIP_UPDATES_TARGET=https://api.nationaltransport.ie/gtfsr/v2/TripUpdates?format=json
|
||||||
|
STOPS_URL=https://your-host.example/stops.json
|
||||||
|
```
|
||||||
|
|
||||||
|
`VITE_TFI_STOPS_URL` or `${VITE_TFI_API_BASE}/stops` should return either an
|
||||||
|
array of stops or `{ "stops": [...] }`.
|
||||||
|
Each stop can use camelCase or GTFS-style fields:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"stop_id": "822GA00345",
|
||||||
|
"stop_code": "345",
|
||||||
|
"stop_name": "OConnell Street",
|
||||||
|
"stop_lat": 53.3498,
|
||||||
|
"stop_lon": -6.2603,
|
||||||
|
"modes": ["bus"],
|
||||||
|
"routes": ["1", "11", "13"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The NTA key is intentionally not prefixed with `VITE_`; Vite keeps it server-side
|
||||||
|
and proxies `/api/nta/trip-updates` with the required subscription headers during
|
||||||
|
local/tunnel development. The Vite proxy is dev-only, so production builds should
|
||||||
|
use `VITE_TFI_API_BASE`. If live env vars are missing or calls fail, the app
|
||||||
|
falls back to demo stops/departures so the G2 flow remains testable.
|
||||||
|
|
||||||
|
## Build a stop index
|
||||||
|
|
||||||
|
If you have an extracted static GTFS folder containing `stops.txt`,
|
||||||
|
`routes.txt`, `trips.txt`, and `stop_times.txt`, build the compact stop index:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build:stops -- /path/to/extracted-gtfs public/stops.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Then serve `public/stops.json` or upload it and point `VITE_TFI_STOPS_URL` /
|
||||||
|
Worker `STOPS_URL` at that JSON file.
|
||||||
|
|
||||||
|
If you deploy the Worker on a custom domain instead of `*.workers.dev`, add that
|
||||||
|
domain to `app.json` under `permissions[0].whitelist` before packing.
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Generate a sideload QR:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
XDG_CONFIG_HOME=.evenhub-config npx evenhub qr --url "https://YOUR_TUNNEL_OR_HOST"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Glasses controls
|
||||||
|
|
||||||
|
- Single press: select stop or refresh departures
|
||||||
|
- Swipe up/down: move selection
|
||||||
|
- Double press: back; from the main menu it opens the system exit dialog
|
||||||
|
|
||||||
|
## Package
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
npm run pack
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates `tfi-live-g2.ehpk`.
|
||||||
30
app.json
Normal file
30
app.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"package_id": "com.matissjurevics.tfiliveg2",
|
||||||
|
"edition": "202601",
|
||||||
|
"name": "TFI Live G2",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"min_app_version": "2.0.0",
|
||||||
|
"min_sdk_version": "0.0.10",
|
||||||
|
"entrypoint": "index.html",
|
||||||
|
"permissions": [
|
||||||
|
{
|
||||||
|
"name": "network",
|
||||||
|
"desc": "Fetches live TFI/NTA departures and sends short voice search clips to the configured speech-to-text service.",
|
||||||
|
"whitelist": [
|
||||||
|
"https://developer.nationaltransport.ie",
|
||||||
|
"https://api.nationaltransport.ie",
|
||||||
|
"https://www.transportforireland.ie",
|
||||||
|
"https://*.workers.dev"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "location",
|
||||||
|
"desc": "Finds nearby stops from the paired phone location."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "g2-microphone",
|
||||||
|
"desc": "Captures short G2 microphone clips for voice stop search."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"supported_languages": ["en"]
|
||||||
|
}
|
||||||
12
index.html
Normal file
12
index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>TFI Live G2</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main id="app"></main>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
2589
package-lock.json
generated
Normal file
2589
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
37
package.json
Normal file
37
package.json
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"name": "tfi-live-g2",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --host 0.0.0.0 --port 5173",
|
||||||
|
"build": "tsc --noEmit && vite build",
|
||||||
|
"test": "node scripts/smoke-test.mjs",
|
||||||
|
"start": "node server.mjs",
|
||||||
|
"preview": "vite preview --host 0.0.0.0 --port 4173",
|
||||||
|
"pack": "evenhub pack app.json dist -o tfi-live-g2.ehpk",
|
||||||
|
"qr": "XDG_CONFIG_HOME=.evenhub-config evenhub qr --url http://localhost:5173",
|
||||||
|
"sim": "evenhub-simulator http://localhost:5173",
|
||||||
|
"build:stops": "node scripts/build-stop-index.mjs"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@evenrealities/even_hub_sdk": "^0.0.10",
|
||||||
|
"@evenrealities/pretext": "^0.1.4",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"even-toolkit": "^1.7.2",
|
||||||
|
"react": "^19.2.6",
|
||||||
|
"react-dom": "^19.2.6",
|
||||||
|
"react-router": "^7.16.0",
|
||||||
|
"tailwind-merge": "^3.6.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@evenrealities/evenhub-cli": "^0.1.13",
|
||||||
|
"@evenrealities/evenhub-simulator": "^0.7.2",
|
||||||
|
"@types/node": "^22.15.29",
|
||||||
|
"@types/react": "^19.2.15",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"typescript": "^5.8.3",
|
||||||
|
"vite": "^6.3.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
public/stops.json
Normal file
1
public/stops.json
Normal file
File diff suppressed because one or more lines are too long
131
scripts/build-stop-index.mjs
Normal file
131
scripts/build-stop-index.mjs
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import { createReadStream, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||||
|
import { dirname, join } from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { createInterface } from 'node:readline'
|
||||||
|
|
||||||
|
export async function buildStopIndex(gtfsDir, outputPath = 'public/stops.json') {
|
||||||
|
const stops = readCsv(join(gtfsDir, 'stops.txt'))
|
||||||
|
const routes = readCsv(join(gtfsDir, 'routes.txt'))
|
||||||
|
const trips = readCsv(join(gtfsDir, 'trips.txt'))
|
||||||
|
const routeById = new Map(routes.map((route) => [route.route_id, route]))
|
||||||
|
const tripRouteById = new Map(trips.map((trip) => [trip.trip_id, trip.route_id]))
|
||||||
|
const routesByStop = new Map()
|
||||||
|
const modesByStop = new Map()
|
||||||
|
|
||||||
|
await eachCsvRow(join(gtfsDir, 'stop_times.txt'), (row) => {
|
||||||
|
const stopId = row.stop_id
|
||||||
|
const routeId = tripRouteById.get(row.trip_id)
|
||||||
|
if (!stopId || !routeId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const route = routeById.get(routeId)
|
||||||
|
const routeName = route?.route_short_name || route?.route_long_name || routeId
|
||||||
|
pushUnique(routesByStop, stopId, routeName)
|
||||||
|
pushUnique(modesByStop, stopId, modeFromRouteType(route?.route_type))
|
||||||
|
})
|
||||||
|
|
||||||
|
const index = stops
|
||||||
|
.map((stop) => ({
|
||||||
|
id: stop.stop_id,
|
||||||
|
code: stop.stop_code || stop.stop_id,
|
||||||
|
name: stop.stop_name,
|
||||||
|
lat: Number(stop.stop_lat),
|
||||||
|
lon: Number(stop.stop_lon),
|
||||||
|
modes: modesByStop.get(stop.stop_id) || ['unknown'],
|
||||||
|
routes: routesByStop.get(stop.stop_id) || [],
|
||||||
|
}))
|
||||||
|
.filter((stop) => stop.id && stop.name && Number.isFinite(stop.lat) && Number.isFinite(stop.lon))
|
||||||
|
|
||||||
|
mkdirSync(dirname(outputPath), { recursive: true })
|
||||||
|
writeFileSync(outputPath, `${JSON.stringify({ stops: index })}\n`)
|
||||||
|
return index.length
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
||||||
|
const [gtfsDir, outputPath = 'public/stops.json'] = process.argv.slice(2)
|
||||||
|
|
||||||
|
if (!gtfsDir) {
|
||||||
|
console.error('Usage: node scripts/build-stop-index.mjs <extracted-gtfs-dir> [output.json]')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = await buildStopIndex(gtfsDir, outputPath)
|
||||||
|
console.log(`Wrote ${count} stops to ${outputPath}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCsv(path) {
|
||||||
|
const [headerLine, ...lines] = readFileSync(path, 'utf8').trim().split(/\r?\n/)
|
||||||
|
const headers = parseCsvLine(headerLine)
|
||||||
|
return lines.map((line) => rowFromValues(headers, parseCsvLine(line)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function eachCsvRow(path, callback) {
|
||||||
|
const reader = createInterface({
|
||||||
|
input: createReadStream(path),
|
||||||
|
crlfDelay: Infinity,
|
||||||
|
})
|
||||||
|
|
||||||
|
let headers = []
|
||||||
|
for await (const line of reader) {
|
||||||
|
if (!headers.length) {
|
||||||
|
headers = parseCsvLine(line)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
callback(rowFromValues(headers, parseCsvLine(line)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowFromValues(headers, values) {
|
||||||
|
return Object.fromEntries(headers.map((header, index) => [header, values[index] ?? '']))
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCsvLine(line) {
|
||||||
|
const values = []
|
||||||
|
let value = ''
|
||||||
|
let quoted = false
|
||||||
|
|
||||||
|
for (let index = 0; index < line.length; index += 1) {
|
||||||
|
const char = line[index]
|
||||||
|
const next = line[index + 1]
|
||||||
|
|
||||||
|
if (char === '"' && quoted && next === '"') {
|
||||||
|
value += '"'
|
||||||
|
index += 1
|
||||||
|
} else if (char === '"') {
|
||||||
|
quoted = !quoted
|
||||||
|
} else if (char === ',' && !quoted) {
|
||||||
|
values.push(value)
|
||||||
|
value = ''
|
||||||
|
} else {
|
||||||
|
value += char
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
values.push(value)
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushUnique(map, key, value) {
|
||||||
|
if (!value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const values = map.get(key) || []
|
||||||
|
if (!values.includes(value)) {
|
||||||
|
values.push(value)
|
||||||
|
map.set(key, values)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function modeFromRouteType(routeType) {
|
||||||
|
switch (String(routeType)) {
|
||||||
|
case '0':
|
||||||
|
return 'tram'
|
||||||
|
case '2':
|
||||||
|
return 'rail'
|
||||||
|
case '3':
|
||||||
|
return 'bus'
|
||||||
|
default:
|
||||||
|
return 'unknown'
|
||||||
|
}
|
||||||
|
}
|
||||||
135
scripts/smoke-test.mjs
Normal file
135
scripts/smoke-test.mjs
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { buildStopIndex } from './build-stop-index.mjs'
|
||||||
|
import worker from '../worker/nta-proxy.js'
|
||||||
|
|
||||||
|
const tempDir = mkdtempSync(join(tmpdir(), 'tfi-live-g2-'))
|
||||||
|
|
||||||
|
try {
|
||||||
|
await testStopIndexBuilder()
|
||||||
|
await testWorkerDepartures()
|
||||||
|
testToolkitMigration()
|
||||||
|
testStopsJson()
|
||||||
|
testNoLocalSecretBundled()
|
||||||
|
console.log('Smoke tests passed')
|
||||||
|
} finally {
|
||||||
|
rmSync(tempDir, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testStopIndexBuilder() {
|
||||||
|
const gtfsDir = join(tempDir, 'gtfs')
|
||||||
|
mkdirSync(gtfsDir, { recursive: true })
|
||||||
|
writeFileSync(join(gtfsDir, 'stops.txt'), 'stop_id,stop_code,stop_name,stop_lat,stop_lon\nS1,100,Main Street,53.1,-6.1\n')
|
||||||
|
writeFileSync(join(gtfsDir, 'routes.txt'), 'route_id,route_short_name,route_long_name,route_type\nR1,46A,Route 46A,3\n')
|
||||||
|
writeFileSync(join(gtfsDir, 'trips.txt'), 'route_id,service_id,trip_id\nR1,W,T1\n')
|
||||||
|
writeFileSync(join(gtfsDir, 'stop_times.txt'), 'trip_id,arrival_time,departure_time,stop_id,stop_sequence\nT1,10:00:00,10:00:00,S1,1\n')
|
||||||
|
|
||||||
|
const outputPath = join(tempDir, 'stops.json')
|
||||||
|
await buildStopIndex(gtfsDir, outputPath)
|
||||||
|
const payload = JSON.parse(readFileSync(outputPath, 'utf8'))
|
||||||
|
assert(payload.stops?.[0]?.routes?.[0] === '46A', 'stop index should include route short name')
|
||||||
|
assert(payload.stops?.[0]?.modes?.[0] === 'bus', 'stop index should infer bus mode')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testWorkerDepartures() {
|
||||||
|
const nowSeconds = Math.floor(Date.now() / 1000)
|
||||||
|
const feed = {
|
||||||
|
entity: [
|
||||||
|
{
|
||||||
|
id: 'fresh',
|
||||||
|
tripUpdate: {
|
||||||
|
trip: { routeId: '46A', tripHeadsign: 'Dun Laoghaire' },
|
||||||
|
stopTimeUpdate: [{ stopId: 'S1', departure: { time: nowSeconds + 300 } }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'stale',
|
||||||
|
tripUpdate: {
|
||||||
|
trip: { routeId: '46A', tripHeadsign: 'Past' },
|
||||||
|
stopTimeUpdate: [{ stopId: 'S1', departure: { time: nowSeconds - 600 } }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheStore = new Map()
|
||||||
|
globalThis.caches = {
|
||||||
|
default: {
|
||||||
|
match: async (request) => cacheStore.get(request.url),
|
||||||
|
put: async (request, response) => {
|
||||||
|
cacheStore.set(request.url, response)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
globalThis.fetch = async () => Response.json(feed)
|
||||||
|
|
||||||
|
const response = await worker.fetch(
|
||||||
|
new Request('https://proxy.example/departures?stopId=S1'),
|
||||||
|
{ NTA_API_KEY: 'test' },
|
||||||
|
{ waitUntil: () => undefined },
|
||||||
|
)
|
||||||
|
assert(response.ok, 'worker departures response should be ok')
|
||||||
|
const payload = await response.json()
|
||||||
|
assert(payload.departures.length === 1, 'worker should filter stale departures')
|
||||||
|
assert(payload.departures[0].route === '46A', 'worker should preserve route id')
|
||||||
|
}
|
||||||
|
|
||||||
|
function testNoLocalSecretBundled() {
|
||||||
|
const key = process.env.NTA_API_KEY
|
||||||
|
if (!key) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const matches = findFilesContaining(key, ['dist', 'src', 'worker', 'scripts', 'README.md', 'app.json'])
|
||||||
|
assert(matches.length === 0, `local NTA_API_KEY must not be bundled or documented verbatim: ${matches.join(', ')}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function testStopsJson() {
|
||||||
|
const payload = JSON.parse(readFileSync('public/stops.json', 'utf8'))
|
||||||
|
assert(payload.stops?.length > 10_000, 'public/stops.json should contain the generated TFI stop index')
|
||||||
|
assert(payload.stops.some((stop) => stop.name && stop.lat && stop.lon), 'stops should include names and coordinates')
|
||||||
|
}
|
||||||
|
|
||||||
|
function testToolkitMigration() {
|
||||||
|
const source = readFileSync('src/main.ts', 'utf8')
|
||||||
|
assert(source.includes("from 'even-toolkit/bridge'"), 'app should use Even toolkit bridge')
|
||||||
|
assert(source.includes("from 'even-toolkit/glass-display-builders'"), 'glasses lists should use toolkit display builders')
|
||||||
|
assert(source.includes("from 'even-toolkit/web/button'"), 'phone UI should use Even toolkit Button')
|
||||||
|
assert(source.includes("from 'even-toolkit/web/list-item'"), 'phone lists should use Even toolkit ListItem')
|
||||||
|
assert(source.includes("from 'even-toolkit/web/stat-grid'"), 'phone status grid should use Even toolkit StatGrid')
|
||||||
|
assert(source.includes("from 'react-dom/client'"), 'phone UI should be rendered through React toolkit components')
|
||||||
|
assert(!source.includes('createStartUpPageContainer'), 'app should not render glasses UI through raw startup containers')
|
||||||
|
assert(!source.includes('new TextContainerUpgrade'), 'app should not update glasses UI through raw text upgrades')
|
||||||
|
assert(!source.includes('document.createElement'), 'phone UI should not use hand-built DOM rendering')
|
||||||
|
assert(!source.includes('innerHTML'), 'phone UI should not render raw HTML strings')
|
||||||
|
}
|
||||||
|
|
||||||
|
function findFilesContaining(needle, paths) {
|
||||||
|
const matches = []
|
||||||
|
for (const path of paths) {
|
||||||
|
collectMatches(path, needle, matches)
|
||||||
|
}
|
||||||
|
return matches
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectMatches(path, needle, matches) {
|
||||||
|
const stats = statSync(path)
|
||||||
|
if (stats.isDirectory()) {
|
||||||
|
for (const entry of readdirSync(path)) {
|
||||||
|
collectMatches(join(path, entry), needle, matches)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = readFileSync(path, 'utf8')
|
||||||
|
if (content.includes(needle)) {
|
||||||
|
matches.push(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assert(condition, message) {
|
||||||
|
if (!condition) {
|
||||||
|
throw new Error(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
188
server.mjs
Normal file
188
server.mjs
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
import { createReadStream, existsSync, statSync } from 'node:fs'
|
||||||
|
import { extname, join, normalize, resolve } from 'node:path'
|
||||||
|
import { createServer } from 'node:http'
|
||||||
|
|
||||||
|
const root = resolve('dist')
|
||||||
|
const port = Number(process.env.PORT ?? 3000)
|
||||||
|
const tripUpdatesUrl =
|
||||||
|
process.env.NTA_TRIP_UPDATES_TARGET ??
|
||||||
|
'https://api.nationaltransport.ie/gtfsr/v2/TripUpdates?format=json'
|
||||||
|
const cacheMs = Number(process.env.NTA_CACHE_MS ?? 25_000)
|
||||||
|
|
||||||
|
let feedCache = null
|
||||||
|
|
||||||
|
createServer(async (request, response) => {
|
||||||
|
try {
|
||||||
|
const url = new URL(request.url ?? '/', `http://${request.headers.host ?? 'localhost'}`)
|
||||||
|
|
||||||
|
if (request.method === 'OPTIONS') {
|
||||||
|
send(response, 204, '', corsHeaders())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.method !== 'GET') {
|
||||||
|
sendJson(response, 405, { error: 'Method not allowed' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === '/health') {
|
||||||
|
sendJson(response, 200, { ok: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === '/departures') {
|
||||||
|
await handleDepartures(url, response)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await serveStatic(url.pathname, response)
|
||||||
|
} catch (error) {
|
||||||
|
sendJson(response, 500, { error: error instanceof Error ? error.message : 'Unknown error' })
|
||||||
|
}
|
||||||
|
}).listen(port, '0.0.0.0', () => {
|
||||||
|
console.log(`TFI Live G2 listening on ${port}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
async function handleDepartures(url, response) {
|
||||||
|
const stopId = url.searchParams.get('stopId') ?? ''
|
||||||
|
const stopCode = url.searchParams.get('stopCode') ?? ''
|
||||||
|
if (!stopId && !stopCode) {
|
||||||
|
sendJson(response, 400, { departures: [], error: 'stopId or stopCode is required' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiKey = process.env.NTA_API_KEY
|
||||||
|
if (!apiKey) {
|
||||||
|
sendJson(response, 503, { departures: [], error: 'NTA_API_KEY is not configured' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const feed = await getTripUpdateFeed(apiKey)
|
||||||
|
sendJson(response, 200, {
|
||||||
|
departures: parseTripUpdates(feed, stopId, stopCode),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getTripUpdateFeed(apiKey) {
|
||||||
|
if (feedCache && Date.now() - feedCache.updatedAt < cacheMs) {
|
||||||
|
return feedCache.payload
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(tripUpdatesUrl, {
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
'Ocp-Apim-Subscription-Key': apiKey,
|
||||||
|
'x-api-key': apiKey,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`NTA returned ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await response.json()
|
||||||
|
feedCache = { payload, updatedAt: Date.now() }
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTripUpdates(payload, stopId, stopCode) {
|
||||||
|
const entities = Array.isArray(payload?.entity) ? payload.entity : []
|
||||||
|
const departures = []
|
||||||
|
|
||||||
|
for (const entity of entities) {
|
||||||
|
const tripUpdate = entity.tripUpdate || entity.trip_update || {}
|
||||||
|
const trip = tripUpdate.trip || {}
|
||||||
|
const route = trip.routeId || trip.route_id || 'Route'
|
||||||
|
const headsign = trip.tripHeadsign || trip.trip_headsign || 'Destination'
|
||||||
|
const updates = tripUpdate.stopTimeUpdate || tripUpdate.stop_time_update || []
|
||||||
|
|
||||||
|
for (const update of updates) {
|
||||||
|
const updateStopId = update.stopId || update.stop_id
|
||||||
|
if (updateStopId !== stopId && updateStopId !== stopCode) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const departure = update.departure || update.arrival || {}
|
||||||
|
const epoch = Number(departure.time)
|
||||||
|
if (!epoch) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const millis = epoch < 10_000_000_000 ? epoch * 1000 : epoch
|
||||||
|
if (millis < Date.now() - 120_000) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
departures.push({
|
||||||
|
id: `${entity.id || route}-${millis}`,
|
||||||
|
route,
|
||||||
|
destination: headsign,
|
||||||
|
dueMinutes: Math.max(0, Math.round((millis - Date.now()) / 60_000)),
|
||||||
|
scheduledTime: new Date(millis).toLocaleTimeString('en-IE', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
}),
|
||||||
|
realtime: true,
|
||||||
|
status: update.scheduleRelationship || update.schedule_relationship || undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return departures.sort((a, b) => a.dueMinutes - b.dueMinutes).slice(0, 40)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function serveStatic(pathname, response) {
|
||||||
|
const cleanPath = normalize(decodeURIComponent(pathname)).replace(/^(\.\.[/\\])+/, '')
|
||||||
|
let filePath = join(root, cleanPath === '/' ? 'index.html' : cleanPath)
|
||||||
|
if (!filePath.startsWith(root)) {
|
||||||
|
send(response, 403, 'Forbidden')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existsSync(filePath) || statSync(filePath).isDirectory()) {
|
||||||
|
filePath = join(root, 'index.html')
|
||||||
|
}
|
||||||
|
|
||||||
|
response.writeHead(200, {
|
||||||
|
'Content-Type': contentType(filePath),
|
||||||
|
...corsHeaders(),
|
||||||
|
})
|
||||||
|
createReadStream(filePath).pipe(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendJson(response, status, body) {
|
||||||
|
send(response, status, JSON.stringify(body), {
|
||||||
|
'Content-Type': 'application/json; charset=utf-8',
|
||||||
|
...corsHeaders(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function send(response, status, body, headers = {}) {
|
||||||
|
response.writeHead(status, headers)
|
||||||
|
response.end(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
function corsHeaders() {
|
||||||
|
return {
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||||
|
'Access-Control-Allow-Headers': 'Accept, Content-Type',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function contentType(filePath) {
|
||||||
|
switch (extname(filePath)) {
|
||||||
|
case '.html':
|
||||||
|
return 'text/html; charset=utf-8'
|
||||||
|
case '.js':
|
||||||
|
return 'text/javascript; charset=utf-8'
|
||||||
|
case '.css':
|
||||||
|
return 'text/css; charset=utf-8'
|
||||||
|
case '.json':
|
||||||
|
return 'application/json; charset=utf-8'
|
||||||
|
case '.svg':
|
||||||
|
return 'image/svg+xml'
|
||||||
|
default:
|
||||||
|
return 'application/octet-stream'
|
||||||
|
}
|
||||||
|
}
|
||||||
1524
src/main.ts
Normal file
1524
src/main.ts
Normal file
File diff suppressed because it is too large
Load Diff
284
src/style.css
Normal file
284
src/style.css
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
:root {
|
||||||
|
color: var(--color-text);
|
||||||
|
background: var(--color-bg);
|
||||||
|
font-family:
|
||||||
|
var(--font-body), ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
|
||||||
|
"Segoe UI", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-width: 320px;
|
||||||
|
min-height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
background: var(--color-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input {
|
||||||
|
border-radius: var(--radius-default);
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
min-height: 44px;
|
||||||
|
border: 1px solid var(--color-border-light);
|
||||||
|
color: var(--color-text);
|
||||||
|
background: var(--color-surface);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover,
|
||||||
|
button.active {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
color: var(--color-bg);
|
||||||
|
background: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 46px;
|
||||||
|
border: 1px solid var(--color-border-light);
|
||||||
|
color: var(--color-text);
|
||||||
|
background: var(--color-input-bg);
|
||||||
|
padding: 0 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
width: min(100%, 1120px);
|
||||||
|
min-height: 100vh;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar,
|
||||||
|
.panel-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow,
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: var(--color-accent-warning);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 800;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: clamp(1.8rem, 5vw, 3.4rem);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border: 1px solid var(--color-accent);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
color: var(--color-accent);
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchbar,
|
||||||
|
.tabs,
|
||||||
|
.status-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchbar {
|
||||||
|
grid-template-columns: 1fr 92px 92px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(280px, 0.95fr) minmax(360px, 1.35fr);
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 16px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
min-height: 0;
|
||||||
|
max-height: calc(100vh - 286px);
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-default);
|
||||||
|
background: var(--color-surface);
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stop-list,
|
||||||
|
.departure-list {
|
||||||
|
max-height: calc(100vh - 370px);
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
margin: 14px 0 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stop-list li,
|
||||||
|
.departure-list li {
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
padding: 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stop-list button {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
border: 0;
|
||||||
|
color: inherit;
|
||||||
|
background: transparent;
|
||||||
|
padding: 0;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stop-list li.selected,
|
||||||
|
.departure-list li.selected {
|
||||||
|
border-left: 4px solid var(--color-accent);
|
||||||
|
background: var(--color-accent-alpha);
|
||||||
|
padding-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stop-title,
|
||||||
|
.dep-route {
|
||||||
|
display: block;
|
||||||
|
color: var(--color-text-highlight);
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stop-subtitle,
|
||||||
|
.dep-meta,
|
||||||
|
#stopMeta,
|
||||||
|
.message {
|
||||||
|
color: var(--color-text-dim);
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-chip {
|
||||||
|
border: 1px solid var(--color-border-light);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 2px 7px;
|
||||||
|
color: var(--color-accent-warning);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dep-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 74px 1fr 70px;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dep-time {
|
||||||
|
color: var(--color-accent-warning);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-grid {
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-grid div {
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-default);
|
||||||
|
background: var(--color-surface);
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-grid span {
|
||||||
|
display: block;
|
||||||
|
color: var(--color-accent-warning);
|
||||||
|
font-weight: 900;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-grid small {
|
||||||
|
color: var(--color-text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
margin-top: 8px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 820px) {
|
||||||
|
#app {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar,
|
||||||
|
.panel-head {
|
||||||
|
align-items: start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout,
|
||||||
|
.searchbar,
|
||||||
|
.tabs,
|
||||||
|
.status-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
max-height: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout {
|
||||||
|
grid-template-rows: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stop-list,
|
||||||
|
.departure-list {
|
||||||
|
max-height: calc((100vh - 410px) / 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
src/vite-env.d.ts
vendored
Normal file
14
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_TFI_API_BASE?: string
|
||||||
|
readonly VITE_NTA_TRIP_UPDATES_URL?: string
|
||||||
|
readonly VITE_TFI_STOPS_URL?: string
|
||||||
|
readonly VITE_STT_API_BASE?: string
|
||||||
|
readonly VITE_STT_API_KEY?: string
|
||||||
|
readonly PROD: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv
|
||||||
|
}
|
||||||
19
tsconfig.json
Normal file
19
tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"allowJs": false,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
33
vite.config.ts
Normal file
33
vite.config.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { defineConfig, loadEnv } from 'vite'
|
||||||
|
|
||||||
|
export default defineConfig(({ mode }) => {
|
||||||
|
const env = loadEnv(mode, process.cwd(), '')
|
||||||
|
const ntaUrl = new URL(
|
||||||
|
env.NTA_TRIP_UPDATES_TARGET ??
|
||||||
|
'https://api.nationaltransport.ie/gtfsr/v2/TripUpdates?format=json',
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
server: {
|
||||||
|
host: '0.0.0.0',
|
||||||
|
port: 5173,
|
||||||
|
allowedHosts: true,
|
||||||
|
proxy: {
|
||||||
|
'/api/nta/trip-updates': {
|
||||||
|
target: ntaUrl.origin,
|
||||||
|
changeOrigin: true,
|
||||||
|
rewrite: () => `${ntaUrl.pathname}${ntaUrl.search}`,
|
||||||
|
configure(proxy) {
|
||||||
|
proxy.on('proxyReq', (proxyReq) => {
|
||||||
|
if (env.NTA_API_KEY) {
|
||||||
|
proxyReq.setHeader('Ocp-Apim-Subscription-Key', env.NTA_API_KEY)
|
||||||
|
proxyReq.setHeader('x-api-key', env.NTA_API_KEY)
|
||||||
|
}
|
||||||
|
proxyReq.setHeader('Accept', 'application/json')
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
161
worker/nta-proxy.js
Normal file
161
worker/nta-proxy.js
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
const DEFAULT_TRIP_UPDATES_URL = 'https://api.nationaltransport.ie/gtfsr/v2/TripUpdates?format=json'
|
||||||
|
const CACHE_SECONDS = 25
|
||||||
|
|
||||||
|
export default {
|
||||||
|
async fetch(request, env, ctx) {
|
||||||
|
const url = new URL(request.url)
|
||||||
|
|
||||||
|
if (request.method === 'OPTIONS') {
|
||||||
|
return new Response(null, { headers: corsHeaders() })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (url.pathname === '/health') {
|
||||||
|
return json({ ok: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === '/stops') {
|
||||||
|
return fetchStops(env)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === '/departures') {
|
||||||
|
return fetchDepartures(url, env, ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({ error: 'Not found' }, 404)
|
||||||
|
} catch (error) {
|
||||||
|
return json({ error: error instanceof Error ? error.message : 'Unknown error' }, 500)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchStops(env) {
|
||||||
|
if (!env.STOPS_URL) {
|
||||||
|
return json({ stops: [], error: 'STOPS_URL is not configured' }, 503)
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(env.STOPS_URL, {
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
cf: { cacheTtl: 3600, cacheEverything: true },
|
||||||
|
})
|
||||||
|
return withCors(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchDepartures(url, env, ctx) {
|
||||||
|
const stopId = url.searchParams.get('stopId') ?? ''
|
||||||
|
const stopCode = url.searchParams.get('stopCode') ?? ''
|
||||||
|
if (!stopId && !stopCode) {
|
||||||
|
return json({ departures: [], error: 'stopId or stopCode is required' }, 400)
|
||||||
|
}
|
||||||
|
if (!env.NTA_API_KEY) {
|
||||||
|
return json({ departures: [], error: 'NTA_API_KEY is not configured' }, 503)
|
||||||
|
}
|
||||||
|
|
||||||
|
const feed = await getTripUpdateFeed(env, ctx)
|
||||||
|
return json({
|
||||||
|
departures: parseTripUpdates(feed, stopId, stopCode),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getTripUpdateFeed(env, ctx) {
|
||||||
|
const endpoint = env.NTA_TRIP_UPDATES_TARGET || DEFAULT_TRIP_UPDATES_URL
|
||||||
|
const cache = caches.default
|
||||||
|
const cacheKey = new Request(endpoint)
|
||||||
|
const cached = await cache.match(cacheKey)
|
||||||
|
if (cached) {
|
||||||
|
return cached.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(endpoint, {
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
'Ocp-Apim-Subscription-Key': env.NTA_API_KEY,
|
||||||
|
'x-api-key': env.NTA_API_KEY,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`NTA returned ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await response.json()
|
||||||
|
const cacheResponse = json(payload, 200, {
|
||||||
|
'Cache-Control': `public, max-age=${CACHE_SECONDS}`,
|
||||||
|
})
|
||||||
|
ctx.waitUntil(cache.put(cacheKey, cacheResponse.clone()))
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTripUpdates(payload, stopId, stopCode) {
|
||||||
|
const entities = Array.isArray(payload?.entity) ? payload.entity : []
|
||||||
|
const departures = []
|
||||||
|
|
||||||
|
for (const entity of entities) {
|
||||||
|
const tripUpdate = entity.tripUpdate || entity.trip_update || {}
|
||||||
|
const trip = tripUpdate.trip || {}
|
||||||
|
const route = trip.routeId || trip.route_id || 'Route'
|
||||||
|
const headsign = trip.tripHeadsign || trip.trip_headsign || 'Destination'
|
||||||
|
const updates = tripUpdate.stopTimeUpdate || tripUpdate.stop_time_update || []
|
||||||
|
|
||||||
|
for (const update of updates) {
|
||||||
|
const updateStopId = update.stopId || update.stop_id
|
||||||
|
if (updateStopId !== stopId && updateStopId !== stopCode) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const departure = update.departure || update.arrival || {}
|
||||||
|
const epoch = Number(departure.time)
|
||||||
|
if (!epoch) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const millis = epoch < 10_000_000_000 ? epoch * 1000 : epoch
|
||||||
|
if (millis < Date.now() - 120_000) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
departures.push({
|
||||||
|
id: `${entity.id || route}-${millis}`,
|
||||||
|
route,
|
||||||
|
destination: headsign,
|
||||||
|
dueMinutes: Math.max(0, Math.round((millis - Date.now()) / 60_000)),
|
||||||
|
scheduledTime: new Date(millis).toLocaleTimeString('en-IE', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
}),
|
||||||
|
realtime: true,
|
||||||
|
status: update.scheduleRelationship || update.schedule_relationship || undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return departures.sort((a, b) => a.dueMinutes - b.dueMinutes).slice(0, 40)
|
||||||
|
}
|
||||||
|
|
||||||
|
function json(body, status = 200, headers = {}) {
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
status,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json; charset=utf-8',
|
||||||
|
...corsHeaders(),
|
||||||
|
...headers,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function withCors(response) {
|
||||||
|
const headers = new Headers(response.headers)
|
||||||
|
Object.entries(corsHeaders()).forEach(([key, value]) => headers.set(key, value))
|
||||||
|
return new Response(response.body, {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
headers,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function corsHeaders() {
|
||||||
|
return {
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||||
|
'Access-Control-Allow-Headers': 'Accept, Content-Type',
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user