From cab81e1f95c8479b88718237fa372bf73f02327a Mon Sep 17 00:00:00 2001 From: David Wheatley Date: Fri, 9 Feb 2024 17:40:36 +0000 Subject: [PATCH 1/4] feat: rtt import --- .gitignore | 2 + README.md | 13 +++ functions/api/get-service-rtt.ts | 182 +++++++++++++++++++++++++++++++ functions/index.d.ts | 3 + 4 files changed, 200 insertions(+) create mode 100644 functions/api/get-service-rtt.ts diff --git a/.gitignore b/.gitignore index 792ae4f01..1290509db 100644 --- a/.gitignore +++ b/.gitignore @@ -88,3 +88,5 @@ static/temp/ .wrangler/ ~$*.xlsx + +.dev.vars diff --git a/README.md b/README.md index 2ba1de2bf..623c2a461 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,19 @@ You'll be able to access the website at [http://local.davw.network:8787](http:// will always resolve to your local machine, and is used to ensure that the website works correctly with the audio files and backend API during local development. +#### Additional steps + +Some features require additional work in order to test locally. + +##### Realtime Trains importing + +You'll need to create a `.dev.vars` file at the root of the repository with your [RTT API](https://api.rtt.io/) username and password: + +```bash +RTT_API_USERNAME=rttapi_username +RTT_API_PASSWORD=your_password +``` + ### Website contributions This site is created with the React Framework using Gatsby. If you're not familiar with React or Gatsby, you may want to research them before diff --git a/functions/api/get-service-rtt.ts b/functions/api/get-service-rtt.ts new file mode 100644 index 000000000..3747301ae --- /dev/null +++ b/functions/api/get-service-rtt.ts @@ -0,0 +1,182 @@ +import TiplocToStation from './tiploc_to_station.json' + +interface RttError { + error: string +} + +export interface RttResponse { + serviceUid: string + runDate: string + serviceType: string + isPassenger: boolean + trainIdentity: string + powerType: string + trainClass: string + atocCode: string + atocName: string + performanceMonitored: boolean + origin: Origin[] + destination: Destination[] + locations: Location[] +} + +interface Origin { + tiploc: string + description: string + workingTime: string + publicTime: string + crs?: string +} + +interface Destination { + tiploc: string + description: string + workingTime: string + publicTime: string + crs?: string +} + +type LocationDisplayAs = 'CALL' | 'PASS' | 'ORIGIN' | 'DESTINATION' | 'STARTS' | 'TERMINATES' | 'CANCELLED_CALL' | 'CANCELLED_PASS' + +interface Location { + tiploc: string + crs?: string + description: string + origin: Origin[] + destination: Destination[] + isCall: boolean + isPublicCall: boolean + platform?: string + gbttBookedArrival?: string + gbttBookedArrivalNextDay?: true + gbttBookedDeparture?: string + gbttBookedDepartureNextDay?: true + realtimeDeparture?: string + realtimeDepartureNextDay?: true + realtimeArrival?: string + realtimeArrivalNextDay?: true + path?: string + associations?: Association[] + displayAs: LocationDisplayAs + realtimeActivated?: true + realtimeGbttArrivalLateness?: number + realtimeGbttDepartureLateness?: number +} + +interface Association { + type: AssociationType + associatedUid: string + associatedRunDate: string + service?: RttResponse +} + +type AssociationType = 'divide' | 'join' + +async function fetchRttService( + serviceUid: string, + runDate: string, + username: string, + password: string, + followAssociations: boolean = true, +): Promise { + // YYYY-MM-DD + const year = runDate.slice(0, 4) + const month = runDate.slice(5, 7) + const day = runDate.slice(8, 10) + + const req = await fetch(`https://api.rtt.io/api/v1/json/service/${serviceUid}/${year}/${month}/${day}`, { + headers: { + Accept: 'application/json', + Authorization: `Basic ${btoa(`${username}:${password}`)}`, + 'User-Agent': 'railannouncements.co.uk', + Server: 'cloudflare pages function', + }, + }) + if (!req.ok) { + throw new Error(`Failed to fetch RTT service: ${req.status} ${req.statusText}`) + } + const response: RttResponse | RttError = await req.json() + + if ('error' in response) { + throw new Error(response.error) + } + + // Fill in CRS data + response.origin.forEach(origin => { + const station = TiplocToStation[origin.tiploc] + if (station) { + origin.crs = station.crs + } + }) + response.destination.forEach(destination => { + const station = TiplocToStation[destination.tiploc] + if (station) { + destination.crs = station.crs + } + }) + response.locations ??= [] + response.locations.forEach(location => { + location.origin.forEach(origin => { + const station = TiplocToStation[origin.tiploc] + if (station) { + origin.crs = station.crs + } + }) + location.destination.forEach(destination => { + const station = TiplocToStation[destination.tiploc] + if (station) { + destination.crs = station.crs + } + }) + }) + + if (followAssociations) { + await Promise.all( + response.locations + .flatMap(location => { + return location.associations?.map((association): Promise => { + return (async () => { + association.service = await fetchRttService(association.associatedUid, association.associatedRunDate, username, password, false) + })() + }) + }) + .filter(Boolean), + ) + } + + return response +} + +export const onRequest: PagesFunction = async ({ request, env }) => { + const { searchParams } = new URL(request.url) + + try { + const uid = searchParams.get('uid') + const date = searchParams.get('date') + + console.log(uid, date) + + if (!uid) { + return Response.json({ error: true, message: 'Missing uid' }) + } + if (!date) { + return Response.json({ error: true, message: 'Missing date' }) + } + if (!date.match(/^\d{4}-\d{2}-\d{2}$/)) { + return Response.json({ error: true, message: 'Invalid date' }) + } + + const username = env.RTT_API_USERNAME + const password = env.RTT_API_PASSWORD + + const json = await fetchRttService(uid, date, username, password) + + return Response.json(json) + } catch (ex) { + if (ex instanceof Error && ex.message) { + return Response.json({ error: true, message: ex.message }) + } else { + return Response.json({ error: true, message: 'Unknown error' }) + } + } +} diff --git a/functions/index.d.ts b/functions/index.d.ts index 5dc2a670d..a78f0ece8 100644 --- a/functions/index.d.ts +++ b/functions/index.d.ts @@ -1,3 +1,6 @@ interface Env { DB: D1Database + + RTT_API_USERNAME: string + RTT_API_PASSWORD: string } From 9e3d684e63825cc8e5ab31a1efc7264875f0721d Mon Sep 17 00:00:00 2001 From: David Wheatley Date: Sun, 22 Sep 2024 01:17:12 +0100 Subject: [PATCH 2/4] feat: implement rtt importing on frontend --- package.json | 1 + src/announcement-data/AnnouncementSystem.ts | 7 + .../systems/stations/AmeyPhil.tsx | 54 ++ src/components/AnnouncementPanel.tsx | 1 + src/components/ImportStateFromRtt.tsx | 464 ++++++++++++++++++ .../PanelPanes/CustomAnnouncementPane.tsx | 16 + src/data/RttUtils.ts | 180 +++++++ src/styles/baseline.less | 4 + src/styles/main.less | 1 + src/styles/text-input.less | 18 + yarn.lock | 10 + 11 files changed, 756 insertions(+) create mode 100644 src/components/ImportStateFromRtt.tsx create mode 100644 src/data/RttUtils.ts create mode 100644 src/styles/text-input.less diff --git a/package.json b/package.json index b9c477426..dac5f8877 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "recoil-persist": "^5.1.0", "recoil-persistence": "^0.3.0-beta.1", "uk-railway-stations": "^1.6.0", + "use-html-dialog": "^0.2.1", "uuid": "^9.0.1" }, "devDependencies": { diff --git a/src/announcement-data/AnnouncementSystem.ts b/src/announcement-data/AnnouncementSystem.ts index 85e87e5ec..fe355d596 100644 --- a/src/announcement-data/AnnouncementSystem.ts +++ b/src/announcement-data/AnnouncementSystem.ts @@ -3,6 +3,7 @@ import Crunker from '../helpers/crunker' import type { ICustomAnnouncementPaneProps } from '@components/PanelPanes/CustomAnnouncementPane' import type { ICustomButtonPaneProps } from '@components/PanelPanes/CustomButtonPane' import type React from 'react' +import { RttResponse } from '../../functions/api/get-service-rtt' export interface IPlayOptions { delayStart: number @@ -90,12 +91,18 @@ export interface CustomAnnouncementTab { | 'deletePersonalPreset' | 'system' | 'defaultState' + | 'importStateFromRttService' > | ICustomButtonPaneProps /** * Merged with any personal preset to allow migration if new features are added. */ defaultState: Record + importStateFromRttService?: ( + rttService: RttResponse, + fromLocationIndex: number, + existingOptions: Record, + ) => Record } export type CustomAnnouncementButton = { diff --git a/src/announcement-data/systems/stations/AmeyPhil.tsx b/src/announcement-data/systems/stations/AmeyPhil.tsx index 7722c80a0..ff3b30d03 100644 --- a/src/announcement-data/systems/stations/AmeyPhil.tsx +++ b/src/announcement-data/systems/stations/AmeyPhil.tsx @@ -10,6 +10,9 @@ import { AudioItem, CustomAnnouncementButton, CustomAnnouncementTab } from '../. import DelayCodeMapping from './DarwinDelayCodes_Male1.json' import NamedServices from './named-services.json' +import type { RttResponse } from '../../../../functions/api/get-service-rtt' +import { RttUtils } from '@data/RttUtils' + export type ChimeType = 'three' | 'four' | 'none' export type FirstClassLocation = 'none' | 'front' | 'middle' | 'rear' @@ -5585,6 +5588,7 @@ export default class AmeyPhil extends StationAnnouncementSystem { nextTrain: { name: 'Next train', component: CustomAnnouncementPane, + importStateFromRttService: this.nextTrainOptionsFromRtt.bind(this), defaultState: { chime: 'three', platform: this.PLATFORMS[1], @@ -6354,4 +6358,54 @@ export default class AmeyPhil extends StationAnnouncementSystem { }, } as CustomAnnouncementTab, } + + /** + * @param rttService RTT service data + * @param fromStation The station from which to interpret data from + * @param existingOptions The existing options to copy other settings from (e.g., chime) + * @returns The options to use for the next train announcement + */ + private nextTrainOptionsFromRtt( + rttService: RttResponse, + fromLocationIndex: number, + existingOptions: INextTrainAnnouncementOptions, + ): INextTrainAnnouncementOptions { + const originLocation = rttService.locations[fromLocationIndex] + + const callingPoints = RttUtils.getCallingPoints(rttService, fromLocationIndex) + const destinationLocations = originLocation.destination.filter(d => { + if (!d.crs) { + console.warn('Destination location has no CRS', d) + return false + } + return true + }) + + const h = originLocation.gbttBookedDeparture!!.substring(0, 2) + const m = originLocation.gbttBookedDeparture!!.substring(2, 4) + + return { + chime: existingOptions.chime, + announceShortPlatformsAfterSplit: existingOptions.announceShortPlatformsAfterSplit, + coaches: existingOptions.coaches, + firstClassLocation: existingOptions.firstClassLocation, + + hour: h === '00' ? '00 - midnight' : h, + min: m === '00' ? '00 - hundred-hours' : m, + isDelayed: RttUtils.getIsDelayedDeparture(rttService, fromLocationIndex), + platform: originLocation.platform || existingOptions.platform, + callingAt: callingPoints, + vias: [], + notCallingAtStations: RttUtils.getCancelledCallingPoints(rttService, fromLocationIndex), + terminatingStationCode: destinationLocations.map(d => d.crs!!)[0], + toc: this.processTocForLiveTrains( + rttService.atocName, + rttService.atocCode, + originLocation.crs!!, + destinationLocations.map(d => d.crs!!)[0], + false, + rttService.serviceUid, + ).toLowerCase(), + } + } } diff --git a/src/components/AnnouncementPanel.tsx b/src/components/AnnouncementPanel.tsx index be9a58484..ce06d19ee 100644 --- a/src/components/AnnouncementPanel.tsx +++ b/src/components/AnnouncementPanel.tsx @@ -50,6 +50,7 @@ function AnnouncementPanel({ system }: IProps) { getPersonalPresets={getPersonalPresets} deletePersonalPreset={deletePersonalPreset} defaultState={JSON.stringify(opts.defaultState)} + importStateFromRttService={opts.importStateFromRttService} /> ) diff --git a/src/components/ImportStateFromRtt.tsx b/src/components/ImportStateFromRtt.tsx new file mode 100644 index 000000000..3e3ae06d1 --- /dev/null +++ b/src/components/ImportStateFromRtt.tsx @@ -0,0 +1,464 @@ +import React, { useState } from 'react' + +import ImportIcon from 'mdi-react/ApplicationImportIcon' +import { useHtmlDialog } from 'use-html-dialog' +import { captureException } from '@sentry/gatsby' + +import type { RttResponse } from '../../functions/api/get-service-rtt' +import LoadingSpinner from './LoadingSpinner' +import { RttUtils } from '@data/RttUtils' +import { Dayjs } from 'dayjs' + +interface IImportStateFromRttProps { + importStateFromRttService: (rttService: RttResponse, fromLocationIndex: number) => void + disabled: boolean +} + +function latenessString(lateness: number | null) { + if (lateness === null) return '' + if (lateness === 0) return 'on time' + if (lateness > 0) return `${lateness}L` + return `${Math.abs(lateness)}E` +} + +export default function ImportStateFromRtt({ importStateFromRttService, disabled }: IImportStateFromRttProps) { + const { showModal, props, close } = useHtmlDialog({ resetStyles: true, closeOnOutsideClick: false }) + const [rttUri, setRttUri] = useState('') + const [importing, setImporting] = useState(false) + const [errors, setErrors] = useState([]) + const [serviceData, setServiceData] = useState(null) + const [selectedOriginIndex, setSelectedOriginIndex] = useState(null) + + const closeAndReset = () => { + setImporting(false) + setErrors([]) + setServiceData(null) + setRttUri('') + setSelectedOriginIndex(null) + close() + } + + const importService = async () => { + setImporting(true) + setErrors([]) + setSelectedOriginIndex(null) + + let uid, date + + // https://www.realtimetrains.co.uk/service/gb-nr:X16032/2024-09-21/detailed + try { + const uri = new URL(rttUri) + if (uri.host !== 'www.realtimetrains.co.uk' && uri.host !== 'realtimetrains.co.uk') { + setErrors(['Invalid Realtime Trains URL (wrong host)']) + setImporting(false) + return + } + + const uriPath = uri.pathname.split('/') + if (uriPath.length < 4) { + setErrors(['Invalid Realtime Trains URL (too few path parameters)']) + setImporting(false) + return + } + + if (uriPath[1] !== 'service') { + setErrors(['Invalid Realtime Trains URL (wrong path)']) + setImporting(false) + return + } + + if (uriPath[2].startsWith('gb-nr:')) { + uid = uriPath[2].substring(6) + } else { + setErrors([`Only National Rail services can be imported at this time (tried to import: ${uriPath[2]})`]) + setImporting(false) + return + } + + if (!uid.match(/^[A-Z][0-9]{5}$/)) { + setErrors([`Invalid Realtime Trains URL (invalid UID: ${uid})`]) + setImporting(false) + return + } + + date = uriPath[3] + if (new Date(date).toString() === 'Invalid Date') { + setErrors([`Invalid Realtime Trains URL (invalid date: ${date})`]) + setImporting(false) + return + } + } catch (e) { + console.error(e) + captureException(e, { data: { rttUri } }) + setErrors(['Invalid Realtime Trains URL (error)']) + setImporting(false) + return + } + + const params = new URLSearchParams({ + uid, + date, + }) + try { + const resp = await fetch( + process.env.NODE_ENV === 'development' + ? `http://local.davw.network:8787/api/get-service-rtt?${params}` + : `/api/get-service-rtt?${params}`, + ) + + if (!resp.ok) { + setErrors([`Failed to fetch Realtime Trains service (HTTP ${resp.status} ${resp.statusText})`]) + setImporting(false) + return + } + + const data: { error: true; message: string } | RttResponse = await resp.json() + if ('error' in data) { + setErrors([`API error: ${data.message}`]) + setImporting(false) + return + } + + console.log(data) + setServiceData(data) + setImporting(false) + } catch (e) { + console.error(e) + captureException(e, { data: { rttUri } }) + + if (e instanceof Error) { + setErrors([`Failed to fetch Realtime Trains service (${e.message})`]) + } else { + setErrors(['Failed to fetch Realtime Trains service (unknown error)']) + } + setImporting(false) + return + } + } + + const selectOriginLocation = (index: number) => { + importStateFromRttService(serviceData!, index) + closeAndReset() + } + + return ( + <> + + + +
+
+

+ {!serviceData && 'Import service from Realtime Trains'} + {serviceData && 'Select your origin location'} +

+
+ +
+
+

+ This feature is currently in beta. If you encounter issues, please report them on{' '} + + this GitHub tracking issue + + . +

+
+ +
+
+ {errors.length > 0 && ( +
+ {errors.map((error, i) => ( +

+ {error} +

+ ))} +
+ )} + + {!serviceData && ( + + )} + + {serviceData && + (() => { + const locations = RttUtils.getEligibleLocations(serviceData) + + if (locations.length === 0) { + return ( + <> +
+

There are no public call locations for this service.

+

Please try again with another service URL.

+ +
+ + ) + } + + return ( +
    + {locations.map((location, i) => { + console.log(location) + + let schedArr: Dayjs | null = null + try { + schedArr = RttUtils.getScheduledArrivalTime(serviceData, i) + } catch (e) { + console.warn(e) + } + + let realArr: Dayjs | null = null + try { + realArr = RttUtils.getRealtimeArrivalTime(serviceData, i) + } catch (e) { + console.warn(e) + } + + let schedDep: Dayjs | null = null + try { + schedDep = RttUtils.getScheduledDepartureTime(serviceData, i) + } catch (e) { + console.warn(e) + } + + let realDep: Dayjs | null = null + try { + realDep = RttUtils.getRealtimeDepartureTime(serviceData, i) + } catch (e) { + console.warn(e) + } + + return ( +
  • + +
  • + ) + })} +
+ ) + })()} +
+ {importing && ( +
+ +
+ )} +
+
+ +
+ + +
+
+
+ + ) +} diff --git a/src/components/PanelPanes/CustomAnnouncementPane.tsx b/src/components/PanelPanes/CustomAnnouncementPane.tsx index 63f8521c1..e9c395bc8 100644 --- a/src/components/PanelPanes/CustomAnnouncementPane.tsx +++ b/src/components/PanelPanes/CustomAnnouncementPane.tsx @@ -29,6 +29,8 @@ import clsx from 'clsx' import type { OptionsExplanation } from '@announcement-data/AnnouncementSystem' import type { IPersonalPresetObject } from '@data/db' import type AnnouncementSystem from '@announcement-data/AnnouncementSystem' +import { RttResponse } from '../../../functions/api/get-service-rtt' +import ImportStateFromRtt from '@components/ImportStateFromRtt' export interface ICustomAnnouncementPreset> { name: string @@ -48,6 +50,9 @@ export interface ICustomAnnouncementPaneProps { deletePersonalPreset: (systemId: string, tabId: string, presetId: string) => Promise system: typeof AnnouncementSystem defaultState: string + importStateFromRttService: + | null + | ((rttService: RttResponse, fromLocationIndex: number, existingOptions: Record) => Record) } function CustomAnnouncementPane({ @@ -63,6 +68,7 @@ function CustomAnnouncementPane({ getPersonalPresets, deletePersonalPreset, defaultState: _defaultState, + importStateFromRttService = null, }: ICustomAnnouncementPaneProps) { const { enqueueSnackbar } = useSnackbar() const defaultState = JSON.parse(_defaultState) @@ -423,6 +429,16 @@ function CustomAnnouncementPane({ >

Options

+ {importStateFromRttService !== null && ( + { + const state = importStateFromRttService(...args, allTabStates[tabId] || {}) + setAllTabStates(prevState => ({ ...(prevState || {}), [tabId]: { ...defaultState, ...state } })) + }} + /> + )} + {Object.keys(options).length === 0 &&

No options available

} <> diff --git a/src/data/RttUtils.ts b/src/data/RttUtils.ts new file mode 100644 index 000000000..bc7674730 --- /dev/null +++ b/src/data/RttUtils.ts @@ -0,0 +1,180 @@ +import { CallingAtPoint } from '@components/CallingAtSelector' +import { RttResponse } from '../../functions/api/get-service-rtt' +import { stationItemCompleter } from '@helpers/crsToStationItemMapper' + +import dayjs from 'dayjs' +import dayjsTz from 'dayjs/plugin/timezone' +import dayjsUtc from 'dayjs/plugin/utc' + +dayjs.extend(dayjsUtc) +dayjs.extend(dayjsTz) + +interface CallingAtPointWithRttDetail extends CallingAtPoint { + rttPlatform: string | null + arrLateness: number | null + depLateness: number | null +} + +const eligibleLocationsSymbol = Symbol('eligibleLocations') + +export class RttUtils { + static getCallingPoints(rttService: RttResponse, fromLocationIndex: number): CallingAtPointWithRttDetail[] { + if (fromLocationIndex === rttService.locations.length - 1) return [] + + return this.getEligibleLocationsInternal(rttService) + .slice(fromLocationIndex + 1) + .filter(l => { + if (!l.isPublicCall || l.displayAs === 'CANCELLED_CALL') return false + if (!l.crs) { + console.warn(`Location ${l.tiploc} has no CRS code`) + return false + } + return true + }) + .map(l => ({ + ...stationItemCompleter(l.crs!), + rttPlatform: l.platform ?? null, + arrLateness: l.realtimeGbttArrivalLateness ?? null, + depLateness: l.realtimeGbttDepartureLateness ?? null, + })) + } + + static getEligibleLocations(rttService: RttResponse): CallingAtPointWithRttDetail[] { + rttService[eligibleLocationsSymbol] ??= this.getEligibleLocationsInternal(rttService).map(l => ({ + ...stationItemCompleter(l.crs!), + rttPlatform: l.platform ?? null, + arrLateness: l.realtimeGbttArrivalLateness ?? null, + depLateness: l.realtimeGbttDepartureLateness ?? null, + })) + + return rttService[eligibleLocationsSymbol] + } + + private static getEligibleLocationsInternal(rttService: RttResponse) { + return rttService.locations.filter(l => { + if (!l.isPublicCall) return false + if (!l.crs) { + console.warn(`Location ${l.tiploc} has no CRS code`) + return false + } + return true + }) + } + + static getCancelledCallingPoints(rttService: RttResponse, fromLocationIndex: number): CallingAtPoint[] { + if (fromLocationIndex === rttService.locations.length - 1) return [] + + return this.getEligibleLocationsInternal(rttService) + .slice(fromLocationIndex + 1) + .filter(l => { + if (!l.isPublicCall || l.displayAs !== 'CANCELLED_CALL') return false + if (!l.crs) { + console.warn(`Location ${l.tiploc} has no CRS code`) + return false + } + return true + }) + .map(l => l.crs!) + .map(stationItemCompleter) + } + + static getScheduledDepartureTime(rttService: RttResponse, locationIndex: number): dayjs.Dayjs { + const loc = this.getEligibleLocationsInternal(rttService)[locationIndex] + if (!loc.gbttBookedDeparture) { + throw new Error(`Location ${loc.tiploc} has no scheduled departure time`) + } + + const date = dayjs + .tz(rttService.runDate, 'Europe/London') + .set('hour', parseInt(loc.gbttBookedDeparture.substring(0, 2))) + .set('minute', parseInt(loc.gbttBookedDeparture.substring(2, 4))) + .set('second', 0) + .set('millisecond', 0) + .add(loc.gbttBookedDepartureNextDay ? 1 : 0, 'day') + + return date + } + + static getScheduledArrivalTime(rttService: RttResponse, locationIndex: number): dayjs.Dayjs { + const loc = this.getEligibleLocationsInternal(rttService)[locationIndex] + if (!loc.gbttBookedArrival) { + throw new Error(`Location ${loc.tiploc} has no scheduled arrival time`) + } + + const date = dayjs + .tz(rttService.runDate, 'Europe/London') + .set('hour', parseInt(loc.gbttBookedArrival.substring(0, 2))) + .set('minute', parseInt(loc.gbttBookedArrival.substring(2, 4))) + .set('second', 0) + .set('millisecond', 0) + .add(loc.gbttBookedArrivalNextDay ? 1 : 0, 'day') + + return date + } + + static getRealtimeDepartureTime(rttService: RttResponse, locationIndex: number): dayjs.Dayjs { + const loc = this.getEligibleLocationsInternal(rttService)[locationIndex] + if (!loc.realtimeActivated) { + return this.getScheduledDepartureTime(rttService, locationIndex) + } + if (!loc.realtimeDeparture) { + throw new Error(`Location ${loc.tiploc} has no realtime departure time`) + } + + const date = dayjs + .tz(rttService.runDate, 'Europe/London') + .set('hour', parseInt(loc.realtimeDeparture.substring(0, 2))) + .set('minute', parseInt(loc.realtimeDeparture.substring(2, 4))) + .set('second', 0) + .set('millisecond', 0) + .add(loc.realtimeDepartureNextDay ? 1 : 0, 'day') + + return date + } + + static getRealtimeArrivalTime(rttService: RttResponse, locationIndex: number): dayjs.Dayjs { + const loc = this.getEligibleLocationsInternal(rttService)[locationIndex] + if (!loc.realtimeActivated) { + return this.getScheduledArrivalTime(rttService, locationIndex) + } + if (!loc.realtimeArrival) { + throw new Error(`Location ${loc.tiploc} has no realtime arrival time`) + } + + const date = dayjs + .tz(rttService.runDate, 'Europe/London') + .set('hour', parseInt(loc.realtimeArrival.substring(0, 2))) + .set('minute', parseInt(loc.realtimeArrival.substring(2, 4))) + .set('second', 0) + .set('millisecond', 0) + .add(loc.realtimeArrivalNextDay ? 1 : 0, 'day') + + return date + } + + static getIsDelayedDeparture(rttService: RttResponse, locationIndex: number): boolean { + const loc = this.getEligibleLocationsInternal(rttService)[locationIndex] + if (!loc.realtimeActivated) { + return false + } + + const minsDiff = + loc.realtimeGbttDepartureLateness ?? + this.getRealtimeDepartureTime(rttService, locationIndex).diff(this.getScheduledDepartureTime(rttService, locationIndex), 'minutes') + + return minsDiff >= 4 + } + + static getIsDelayedArrival(rttService: RttResponse, locationIndex: number): boolean { + const loc = this.getEligibleLocationsInternal(rttService)[locationIndex] + if (!loc.realtimeActivated) { + return false + } + + const minsDiff = + loc.realtimeGbttArrivalLateness ?? + this.getScheduledArrivalTime(rttService, locationIndex).diff(this.getRealtimeArrivalTime(rttService, locationIndex), 'minutes') + + return minsDiff >= 4 + } +} diff --git a/src/styles/baseline.less b/src/styles/baseline.less index ecfa68d84..6dc7fbeb7 100644 --- a/src/styles/baseline.less +++ b/src/styles/baseline.less @@ -174,3 +174,7 @@ b, strong { font-weight: bold; } + +body:has(dialog[open]) { + overflow: hidden; +} diff --git a/src/styles/main.less b/src/styles/main.less index 657abbf26..424226ed2 100644 --- a/src/styles/main.less +++ b/src/styles/main.less @@ -121,6 +121,7 @@ h3 { } @import './checkbox.less'; +@import './text-input.less'; .react-tabs__tab-panel:not(.react-tabs__tab-panel--selected) { display: none; diff --git a/src/styles/text-input.less b/src/styles/text-input.less new file mode 100644 index 000000000..368fc789f --- /dev/null +++ b/src/styles/text-input.less @@ -0,0 +1,18 @@ +input[type='text'].textInput { + display: block; + width: 100%; + padding: 0.5em; + margin: 0.5em 0; + font-size: 0.9em; + line-height: 1; + font-family: 'Rail Alphabet 2'; + box-shadow: 0 0 0 2px var(--primary-blue); + border: none; + border-radius: 6px; + transition: box-shadow 0.2s; + + &:focus { + outline: none; + box-shadow: 0 0 0 4px var(--primary-red); + } +} diff --git a/yarn.lock b/yarn.lock index 362815442..ceda43caa 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13328,6 +13328,7 @@ __metadata: recoil-persistence: "npm:^0.3.0-beta.1" typescript: "npm:^5.5.3" uk-railway-stations: "npm:^1.6.0" + use-html-dialog: "npm:^0.2.1" uuid: "npm:^9.0.1" wrangler: "npm:^3.64.0" languageName: unknown @@ -15825,6 +15826,15 @@ __metadata: languageName: node linkType: hard +"use-html-dialog@npm:^0.2.1": + version: 0.2.1 + resolution: "use-html-dialog@npm:0.2.1" + peerDependencies: + react: ">=16.8.0" + checksum: 10/ec6d33a74fdefb06e5e28d2d2d96906b1346b2f7505f46230238f511f8e669e068123b7fc84aa540fd3290c7ab8f70a5373aef6512bc835e619486b1a4395012 + languageName: node + linkType: hard + "use-isomorphic-layout-effect@npm:^1.1.2": version: 1.1.2 resolution: "use-isomorphic-layout-effect@npm:1.1.2" From b0b79b8a64cc702c5cf052168d81e0bd0f727ecd Mon Sep 17 00:00:00 2001 From: David Wheatley Date: Sun, 22 Sep 2024 01:22:57 +0100 Subject: [PATCH 3/4] chore: clarify error state with search url --- src/components/ImportStateFromRtt.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/components/ImportStateFromRtt.tsx b/src/components/ImportStateFromRtt.tsx index 3e3ae06d1..70b34cdb1 100644 --- a/src/components/ImportStateFromRtt.tsx +++ b/src/components/ImportStateFromRtt.tsx @@ -62,7 +62,11 @@ export default function ImportStateFromRtt({ importStateFromRttService, disabled } if (uriPath[1] !== 'service') { - setErrors(['Invalid Realtime Trains URL (wrong path)']) + if (uriPath[1] === 'search') { + setErrors(['This looks like an RTT search link. You need to provide the URL for a particular service into this tool.']) + } else { + setErrors(['Invalid Realtime Trains URL (wrong path)']) + } setImporting(false) return } @@ -242,7 +246,7 @@ export default function ImportStateFromRtt({ importStateFromRttService, disabled {!serviceData && (