From 20c97de94da65d97117644734fa5aa69a07644af Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Mon, 10 Nov 2025 20:20:39 -0800 Subject: [PATCH] Unify the new ETA functionality across all shuttle repositories --- .../shuttle/RedisShuttleRepository.ts | 25 +--- .../shuttle/ShuttleGetterRepository.ts | 31 ++++ .../shuttle/ShuttleGetterSetterRepository.ts | 23 ++- .../UnoptimizedInMemoryShuttleRepository.ts | 141 +++++++++++++++++- 4 files changed, 197 insertions(+), 23 deletions(-) diff --git a/src/repositories/shuttle/RedisShuttleRepository.ts b/src/repositories/shuttle/RedisShuttleRepository.ts index 6e8ea18..98a3fcb 100644 --- a/src/repositories/shuttle/RedisShuttleRepository.ts +++ b/src/repositories/shuttle/RedisShuttleRepository.ts @@ -8,24 +8,11 @@ import { ShuttleRepositoryEventListener, ShuttleRepositoryEventName, ShuttleRepositoryEventPayloads, + ShuttleStopArrival, + ShuttleTravelTimeDataIdentifier, + ShuttleTravelTimeDateFilterArguments, } from "./ShuttleGetterRepository"; -export interface ShuttleStopArrival { - stopId: string; - timestamp: Date; -} - -export interface ShuttleTravelTimeDataIdentifier { - routeId: string; - fromStopId: string; - toStopId: string; -} - -export interface ShuttleTravelTimeDateFilterArguments { - from: Date; - to: Date; -} - export class RedisShuttleRepository extends EventEmitter implements ShuttleGetterSetterRepository { protected redisClient; @@ -644,12 +631,16 @@ export class RedisShuttleRepository extends EventEmitter implements ShuttleGette await this.redisClient.hSet(key, this.createRedisHashFromOrderedStop(orderedStop)); } - public async addOrUpdateEta(eta: IEta): Promise { + private async addOrUpdateEta(eta: IEta): Promise { const key = this.createEtaKey(eta.shuttleId, eta.stopId); await this.redisClient.hSet(key, this.createRedisHashFromEta(eta)); this.emit(ShuttleRepositoryEvent.ETA_UPDATED, eta); } + public async addOrUpdateEtaFromExternalSource(eta: IEta): Promise { + await this.addOrUpdateEta(eta); + } + // Remove methods public async removeRouteIfExists(routeId: string): Promise { const route = await this.getRouteById(routeId); diff --git a/src/repositories/shuttle/ShuttleGetterRepository.ts b/src/repositories/shuttle/ShuttleGetterRepository.ts index 86f3ec5..c93ce5d 100644 --- a/src/repositories/shuttle/ShuttleGetterRepository.ts +++ b/src/repositories/shuttle/ShuttleGetterRepository.ts @@ -22,6 +22,22 @@ export type ShuttleRepositoryEventListener payload: ShuttleRepositoryEventPayloads[T], ) => void; +export interface ShuttleStopArrival { + stopId: string; + timestamp: Date; +} + +export interface ShuttleTravelTimeDataIdentifier { + routeId: string; + fromStopId: string; + toStopId: string; +} + +export interface ShuttleTravelTimeDateFilterArguments { + from: Date; + to: Date; +} + /** * Shuttle getter repository to be linked to a system. */ @@ -61,4 +77,19 @@ export interface ShuttleGetterRepository extends EventEmitter { * @param routeId */ getOrderedStopsByRouteId(routeId: string): Promise; + + /** + * Get the last stop arrival for a shuttle. + * Returns undefined if no last stop arrival has been recorded. + * @param shuttleId + */ + getShuttleLastStopArrival(shuttleId: string): Promise; + + /** + * Check if a shuttle has arrived at a stop within the given delta. + * Returns the stop if the shuttle is at a stop, otherwise undefined. + * @param shuttle + * @param delta - The coordinate delta tolerance (default 0.001) + */ + getArrivedStopIfExists(shuttle: IShuttle, delta?: number): Promise; } diff --git a/src/repositories/shuttle/ShuttleGetterSetterRepository.ts b/src/repositories/shuttle/ShuttleGetterSetterRepository.ts index 24da9e1..c61d0a2 100644 --- a/src/repositories/shuttle/ShuttleGetterSetterRepository.ts +++ b/src/repositories/shuttle/ShuttleGetterSetterRepository.ts @@ -1,7 +1,7 @@ // If types match closely, we can use TypeScript "casting" // to convert from data repo to GraphQL schema -import { ShuttleGetterRepository } from "./ShuttleGetterRepository"; +import { ShuttleGetterRepository, ShuttleTravelTimeDataIdentifier, ShuttleTravelTimeDateFilterArguments } from "./ShuttleGetterRepository"; import { IEta, IOrderedStop, IRoute, IShuttle, IStop } from "../../entities/ShuttleRepositoryEntities"; /** @@ -13,10 +13,16 @@ import { IEta, IOrderedStop, IRoute, IShuttle, IStop } from "../../entities/Shut export interface ShuttleGetterSetterRepository extends ShuttleGetterRepository { // Setter methods addOrUpdateRoute(route: IRoute): Promise; - addOrUpdateShuttle(shuttle: IShuttle): Promise; + addOrUpdateShuttle(shuttle: IShuttle, travelTimeTimestamp?: number, referenceCurrentTime?: Date): Promise; addOrUpdateStop(stop: IStop): Promise; addOrUpdateOrderedStop(orderedStop: IOrderedStop): Promise; - addOrUpdateEta(eta: IEta): Promise; + + /** + * Add or update an ETA from an external source (e.g., API or test data). + * This bypasses the internal ETA calculation based on shuttle movements. + * Use this for loading ETAs from external APIs or setting test data. + */ + addOrUpdateEtaFromExternalSource(eta: IEta): Promise; removeRouteIfExists(routeId: string): Promise; removeShuttleIfExists(shuttleId: string): Promise; @@ -29,4 +35,15 @@ export interface ShuttleGetterSetterRepository extends ShuttleGetterRepository { clearStopData(): Promise; clearOrderedStopData(): Promise; clearEtaData(): Promise; + + /** + * Get average travel time between two stops based on historical data. + * Returns undefined if no data exists for the specified time range. + * @param identifier - The route and stop IDs to query + * @param dateFilter - The date range to filter data + */ + getAverageTravelTimeSeconds( + identifier: ShuttleTravelTimeDataIdentifier, + dateFilter: ShuttleTravelTimeDateFilterArguments + ): Promise; } diff --git a/src/repositories/shuttle/UnoptimizedInMemoryShuttleRepository.ts b/src/repositories/shuttle/UnoptimizedInMemoryShuttleRepository.ts index 0d1c88c..c0eedd8 100644 --- a/src/repositories/shuttle/UnoptimizedInMemoryShuttleRepository.ts +++ b/src/repositories/shuttle/UnoptimizedInMemoryShuttleRepository.ts @@ -1,12 +1,15 @@ import EventEmitter from "node:events"; import { ShuttleGetterSetterRepository } from "./ShuttleGetterSetterRepository"; -import { IEta, IOrderedStop, IRoute, IShuttle, IStop } from "../../entities/ShuttleRepositoryEntities"; +import { IEta, IOrderedStop, IRoute, IShuttle, IStop, shuttleHasArrivedAtStop } from "../../entities/ShuttleRepositoryEntities"; import { IEntityWithId } from "../../entities/SharedEntities"; import { ShuttleRepositoryEvent, ShuttleRepositoryEventListener, ShuttleRepositoryEventName, ShuttleRepositoryEventPayloads, + ShuttleStopArrival, + ShuttleTravelTimeDataIdentifier, + ShuttleTravelTimeDateFilterArguments, } from "./ShuttleGetterRepository"; /** @@ -70,6 +73,8 @@ export class UnoptimizedInMemoryShuttleRepository private shuttles: IShuttle[] = []; private etas: IEta[] = []; private orderedStops: IOrderedStop[] = []; + private shuttleLastStopArrivals: Map = new Map(); + private travelTimeData: Map> = new Map(); public async getStops(): Promise { return this.stops; @@ -144,13 +149,20 @@ export class UnoptimizedInMemoryShuttleRepository } } - public async addOrUpdateShuttle(shuttle: IShuttle): Promise { + public async addOrUpdateShuttle( + shuttle: IShuttle, + travelTimeTimestamp = Date.now(), + referenceCurrentTime = new Date(), + ): Promise { const index = this.shuttles.findIndex((s) => s.id === shuttle.id); if (index !== -1) { this.shuttles[index] = shuttle; } else { this.shuttles.push(shuttle); } + + await this.updateLastStopArrivalAndTravelTimeDataPoints(shuttle, travelTimeTimestamp); + await this.updateEtasBasedOnHistoricalData(shuttle, referenceCurrentTime); } public async addOrUpdateStop(stop: IStop): Promise { @@ -171,7 +183,7 @@ export class UnoptimizedInMemoryShuttleRepository } } - public async addOrUpdateEta(eta: IEta): Promise { + private async addOrUpdateEta(eta: IEta): Promise { const index = this.etas.findIndex((e) => e.stopId === eta.stopId && e.shuttleId === eta.shuttleId); if (index !== -1) { this.etas[index] = eta; @@ -181,6 +193,129 @@ export class UnoptimizedInMemoryShuttleRepository this.emit(ShuttleRepositoryEvent.ETA_UPDATED, eta); } + public async addOrUpdateEtaFromExternalSource(eta: IEta): Promise { + await this.addOrUpdateEta(eta); + } + + private async updateEtasBasedOnHistoricalData( + shuttle: IShuttle, + referenceCurrentTime: Date = new Date(), + ) { + const oneWeekAgo = new Date(referenceCurrentTime.getTime() - (60 * 60 * 24 * 7 * 1000)); + + const lastStopArrival = await this.getShuttleLastStopArrival(shuttle.id); + if (lastStopArrival == undefined) return; + + const lastOrderedStop = await this.getOrderedStopByRouteAndStopId(shuttle.routeId, lastStopArrival.stopId); + const nextStop = lastOrderedStop?.nextStop; + if (nextStop == null) return; + + const travelTimeSeconds = await this.getAverageTravelTimeSeconds({ + routeId: shuttle.routeId, + fromStopId: lastStopArrival.stopId, + toStopId: nextStop.stopId, + }, { + from: oneWeekAgo, + to: new Date(oneWeekAgo.getTime() + (60 * 60 * 1000)) + }); + if (travelTimeSeconds == undefined) return; + + const elapsedTimeMs = referenceCurrentTime.getTime() - lastStopArrival.timestamp.getTime(); + const predictedTimeSeconds = travelTimeSeconds - (elapsedTimeMs / 1000); + + await this.addOrUpdateEta({ + secondsRemaining: predictedTimeSeconds, + shuttleId: shuttle.id, + stopId: nextStop.stopId, + systemId: nextStop.systemId, + updatedTime: new Date(), + }); + } + + private async updateLastStopArrivalAndTravelTimeDataPoints( + shuttle: IShuttle, + travelTimeTimestamp = Date.now(), + ) { + const arrivedStop = await this.getArrivedStopIfExists(shuttle); + + if (arrivedStop != undefined) { + const lastStopTimestamp = await this.getShuttleLastStopArrival(shuttle.id); + if (lastStopTimestamp != undefined) { + const routeId = shuttle.routeId; + const fromStopId = lastStopTimestamp.stopId; + const toStopId = arrivedStop.id; + + const travelTimeSeconds = (travelTimeTimestamp - lastStopTimestamp.timestamp.getTime()) / 1000; + await this.addTravelTimeDataPoint({ routeId, fromStopId, toStopId }, travelTimeSeconds, travelTimeTimestamp); + } + + await this.updateShuttleLastStopArrival(shuttle.id, { + stopId: arrivedStop.id, + timestamp: new Date(travelTimeTimestamp), + }); + } + } + + private async addTravelTimeDataPoint( + { routeId, fromStopId, toStopId }: ShuttleTravelTimeDataIdentifier, + travelTimeSeconds: number, + timestamp = Date.now(), + ): Promise { + const key = `${routeId}:${fromStopId}:${toStopId}`; + const dataPoints = this.travelTimeData.get(key) || []; + dataPoints.push({ timestamp, seconds: travelTimeSeconds }); + this.travelTimeData.set(key, dataPoints); + } + + private async updateShuttleLastStopArrival(shuttleId: string, lastStopArrival: ShuttleStopArrival) { + this.shuttleLastStopArrivals.set(shuttleId, lastStopArrival); + } + + public async getAverageTravelTimeSeconds( + { routeId, fromStopId, toStopId }: ShuttleTravelTimeDataIdentifier, + { from, to }: ShuttleTravelTimeDateFilterArguments, + ): Promise { + const key = `${routeId}:${fromStopId}:${toStopId}`; + const dataPoints = this.travelTimeData.get(key); + + if (!dataPoints || dataPoints.length === 0) { + return undefined; + } + + const fromTimestamp = from.getTime(); + const toTimestamp = to.getTime(); + + const filteredPoints = dataPoints.filter( + (point) => point.timestamp >= fromTimestamp && point.timestamp <= toTimestamp + ); + + if (filteredPoints.length === 0) { + return undefined; + } + + const sum = filteredPoints.reduce((acc, point) => acc + point.seconds, 0); + return sum / filteredPoints.length; + } + + public async getArrivedStopIfExists( + shuttle: IShuttle, + delta = 0.001, + ): Promise { + const orderedStops = await this.getOrderedStopsByRouteId(shuttle.routeId); + + for (const orderedStop of orderedStops) { + const stop = await this.getStopById(orderedStop.stopId); + if (stop != null && shuttleHasArrivedAtStop(shuttle, stop, delta)) { + return stop; + } + } + return undefined; + } + + public async getShuttleLastStopArrival(shuttleId: string): Promise { + return this.shuttleLastStopArrivals.get(shuttleId); + } + private async removeEntityByMatcherIfExists(callback: (value: T) => boolean, arrayToSearchIn: T[]) { const index = arrayToSearchIn.findIndex(callback); if (index > -1) {