From e29e772f6bea7c06b64844660596e45b699caa59 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Mon, 3 Nov 2025 10:18:13 -0800 Subject: [PATCH 01/96] Switch back to CLAUDE file --- AGENTS.md => CLAUDE.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename AGENTS.md => CLAUDE.md (100%) diff --git a/AGENTS.md b/CLAUDE.md similarity index 100% rename from AGENTS.md rename to CLAUDE.md From 72f596821ae4e549ebfa66e7616578ee1a51e29f Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Mon, 3 Nov 2025 10:31:37 -0800 Subject: [PATCH 02/96] Update CLAUDE.md with new testing instructions --- CLAUDE.md | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index cfeabf8..01dd597 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ -# AGENTS.md +# CLAUDE.md -This file provides guidance to coding agents (e.g., Codex CLI, Claude Code, and other AI coding assistants) when working with code in this repository. +This file provides guidance to Claude Code when working with code in this repository. ## Development Commands @@ -19,17 +19,7 @@ npm run generate npm run build:dev ``` -### Testing -```bash -# Run all tests via npm -npm test - -# Run specific test file -npm test -- --testPathPattern= - -# Run tests with coverage -npm test -- --coverage -``` +Only use Docker Compose for running tests. ## Architecture Overview From 4b4715cdb298f8db13d111ac05a1245da2df58b3 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Mon, 3 Nov 2025 10:34:21 -0800 Subject: [PATCH 03/96] Add RedisShuttleRepository and ShuttleRepositorySharedTests, like pattern for parking data --- .../shuttle/RedisShuttleRepository.ts | 530 ++++++++++++++++++ ...s => ShuttleRepositorySharedTests.test.ts} | 87 ++- 2 files changed, 599 insertions(+), 18 deletions(-) create mode 100644 src/repositories/shuttle/RedisShuttleRepository.ts rename src/repositories/shuttle/__tests__/{UnoptimizedInMemoryShuttleRepositoryTests.test.ts => ShuttleRepositorySharedTests.test.ts} (89%) diff --git a/src/repositories/shuttle/RedisShuttleRepository.ts b/src/repositories/shuttle/RedisShuttleRepository.ts new file mode 100644 index 0000000..cf6e063 --- /dev/null +++ b/src/repositories/shuttle/RedisShuttleRepository.ts @@ -0,0 +1,530 @@ +import EventEmitter from "node:events"; +import { createClient } from 'redis'; +import { ShuttleGetterSetterRepository } from "./ShuttleGetterSetterRepository"; +import { IEta, IOrderedStop, IRoute, IShuttle, IStop } from "../../entities/ShuttleRepositoryEntities"; +import { REDIS_RECONNECT_INTERVAL } from "../../environment"; +import { + ShuttleRepositoryEvent, + ShuttleRepositoryEventListener, + ShuttleRepositoryEventName, + ShuttleRepositoryEventPayloads, +} from "./ShuttleGetterRepository"; + +export class RedisShuttleRepository extends EventEmitter implements ShuttleGetterSetterRepository { + protected redisClient; + + constructor( + redisClient = createClient({ + url: process.env.REDIS_URL, + socket: { + tls: process.env.NODE_ENV === 'production', + rejectUnauthorized: false, + reconnectStrategy: REDIS_RECONNECT_INTERVAL, + }, + }), + ) { + super(); + this.redisClient = redisClient; + this.redisClient.on('error', (err) => { + console.error(err.stack); + }); + } + + get isReady() { + return this.redisClient.isReady; + } + + public async connect() { + await this.redisClient.connect(); + } + + public async disconnect() { + await this.redisClient.disconnect(); + } + + public async clearAllData() { + await this.redisClient.flushAll(); + } + // EventEmitter override methods for type safety + public override on( + event: T, + listener: ShuttleRepositoryEventListener, + ): this; + public override on(event: string | symbol, listener: (...args: any[]) => void): this { + return super.on(event, listener); + } + + public override once( + event: T, + listener: ShuttleRepositoryEventListener, + ): this; + public override once(event: string | symbol, listener: (...args: any[]) => void): this { + return super.once(event, listener); + } + + public override off( + event: T, + listener: ShuttleRepositoryEventListener, + ): this; + public override off(event: string | symbol, listener: (...args: any[]) => void): this { + return super.off(event, listener); + } + + public override addListener( + event: T, + listener: ShuttleRepositoryEventListener, + ): this; + public override addListener(event: string | symbol, listener: (...args: any[]) => void): this { + return super.addListener(event, listener); + } + + public override removeListener( + event: T, + listener: ShuttleRepositoryEventListener, + ): this; + public override removeListener(event: string | symbol, listener: (...args: any[]) => void): this { + return super.removeListener(event, listener); + } + + public override emit( + event: T, + payload: ShuttleRepositoryEventPayloads[T], + ): boolean; + public override emit(event: string | symbol, ...args: any[]): boolean { + return super.emit(event, ...args); + } + + // Helper methods for Redis key generation + private createStopKey = (stopId: string) => `shuttle:stop:${stopId}`; + private createRouteKey = (routeId: string) => `shuttle:route:${routeId}`; + private createShuttleKey = (shuttleId: string) => `shuttle:shuttle:${shuttleId}`; + private createEtaKey = (shuttleId: string, stopId: string) => `shuttle:eta:${shuttleId}:${stopId}`; + private createOrderedStopKey = (routeId: string, stopId: string) => `shuttle:orderedstop:${routeId}:${stopId}`; + + // Helper methods for converting entities to Redis hashes + private createRedisHashFromStop = (stop: IStop): Record => ({ + id: stop.id, + name: stop.name, + systemId: stop.systemId, + latitude: stop.coordinates.latitude.toString(), + longitude: stop.coordinates.longitude.toString(), + updatedTime: stop.updatedTime.toISOString(), + }); + + private createStopFromRedisData = (data: Record): IStop => ({ + id: data.id, + name: data.name, + systemId: data.systemId, + coordinates: { + latitude: parseFloat(data.latitude), + longitude: parseFloat(data.longitude), + }, + updatedTime: new Date(data.updatedTime), + }); + + private createRedisHashFromRoute = (route: IRoute): Record => ({ + id: route.id, + name: route.name, + color: route.color, + systemId: route.systemId, + polylineCoordinates: JSON.stringify(route.polylineCoordinates), + updatedTime: route.updatedTime.toISOString(), + }); + + private createRouteFromRedisData = (data: Record): IRoute => ({ + id: data.id, + name: data.name, + color: data.color, + systemId: data.systemId, + polylineCoordinates: JSON.parse(data.polylineCoordinates), + updatedTime: new Date(data.updatedTime), + }); + + private createRedisHashFromShuttle = (shuttle: IShuttle): Record => ({ + id: shuttle.id, + name: shuttle.name, + routeId: shuttle.routeId, + systemId: shuttle.systemId, + latitude: shuttle.coordinates.latitude.toString(), + longitude: shuttle.coordinates.longitude.toString(), + orientationInDegrees: shuttle.orientationInDegrees.toString(), + updatedTime: shuttle.updatedTime.toISOString(), + }); + + private createShuttleFromRedisData = (data: Record): IShuttle => ({ + id: data.id, + name: data.name, + routeId: data.routeId, + systemId: data.systemId, + coordinates: { + latitude: parseFloat(data.latitude), + longitude: parseFloat(data.longitude), + }, + orientationInDegrees: parseFloat(data.orientationInDegrees), + updatedTime: new Date(data.updatedTime), + }); + + private createRedisHashFromEta = (eta: IEta): Record => ({ + secondsRemaining: eta.secondsRemaining.toString(), + shuttleId: eta.shuttleId, + stopId: eta.stopId, + systemId: eta.systemId, + updatedTime: eta.updatedTime.toISOString(), + }); + + private createEtaFromRedisData = (data: Record): IEta => ({ + secondsRemaining: parseFloat(data.secondsRemaining), + shuttleId: data.shuttleId, + stopId: data.stopId, + systemId: data.systemId, + updatedTime: new Date(data.updatedTime), + }); + + private createRedisHashFromOrderedStop = (orderedStop: IOrderedStop): Record => { + const hash: Record = { + routeId: orderedStop.routeId, + stopId: orderedStop.stopId, + position: orderedStop.position.toString(), + systemId: orderedStop.systemId, + updatedTime: orderedStop.updatedTime.toISOString(), + }; + + if (orderedStop.nextStop) { + hash.nextStopRouteId = orderedStop.nextStop.routeId; + hash.nextStopStopId = orderedStop.nextStop.stopId; + } + + if (orderedStop.previousStop) { + hash.previousStopRouteId = orderedStop.previousStop.routeId; + hash.previousStopStopId = orderedStop.previousStop.stopId; + } + + return hash; + }; + + private createOrderedStopFromRedisData = (data: Record): IOrderedStop => { + const orderedStop: IOrderedStop = { + routeId: data.routeId, + stopId: data.stopId, + position: parseInt(data.position), + systemId: data.systemId, + updatedTime: new Date(data.updatedTime), + }; + + // Note: We only store the IDs of next/previous stops, not full objects + // to avoid circular references in Redis. These would need to be + // resolved separately if needed. + if (data.nextStopRouteId && data.nextStopStopId) { + orderedStop.nextStop = { + routeId: data.nextStopRouteId, + stopId: data.nextStopStopId, + position: 0, // placeholder + systemId: data.systemId, + updatedTime: new Date(), + }; + } + + if (data.previousStopRouteId && data.previousStopStopId) { + orderedStop.previousStop = { + routeId: data.previousStopRouteId, + stopId: data.previousStopStopId, + position: 0, // placeholder + systemId: data.systemId, + updatedTime: new Date(), + }; + } + + return orderedStop; + }; + + // Getter methods + public async getStops(): Promise { + const keys = await this.redisClient.keys('shuttle:stop:*'); + const stops: IStop[] = []; + + for (const key of keys) { + const data = await this.redisClient.hGetAll(key); + if (Object.keys(data).length > 0) { + stops.push(this.createStopFromRedisData(data)); + } + } + + return stops; + } + + public async getStopById(stopId: string): Promise { + const key = this.createStopKey(stopId); + const data = await this.redisClient.hGetAll(key); + + if (Object.keys(data).length === 0) { + return null; + } + + return this.createStopFromRedisData(data); + } + + public async getRoutes(): Promise { + const keys = await this.redisClient.keys('shuttle:route:*'); + const routes: IRoute[] = []; + + for (const key of keys) { + const data = await this.redisClient.hGetAll(key); + if (Object.keys(data).length > 0) { + routes.push(this.createRouteFromRedisData(data)); + } + } + + return routes; + } + + public async getRouteById(routeId: string): Promise { + const key = this.createRouteKey(routeId); + const data = await this.redisClient.hGetAll(key); + + if (Object.keys(data).length === 0) { + return null; + } + + return this.createRouteFromRedisData(data); + } + + public async getShuttles(): Promise { + const keys = await this.redisClient.keys('shuttle:shuttle:*'); + const shuttles: IShuttle[] = []; + + for (const key of keys) { + const data = await this.redisClient.hGetAll(key); + if (Object.keys(data).length > 0) { + shuttles.push(this.createShuttleFromRedisData(data)); + } + } + + return shuttles; + } + + public async getShuttlesByRouteId(routeId: string): Promise { + const allShuttles = await this.getShuttles(); + return allShuttles.filter(shuttle => shuttle.routeId === routeId); + } + + public async getShuttleById(shuttleId: string): Promise { + const key = this.createShuttleKey(shuttleId); + const data = await this.redisClient.hGetAll(key); + + if (Object.keys(data).length === 0) { + return null; + } + + return this.createShuttleFromRedisData(data); + } + + public async getEtasForShuttleId(shuttleId: string): Promise { + const keys = await this.redisClient.keys(`shuttle:eta:${shuttleId}:*`); + const etas: IEta[] = []; + + for (const key of keys) { + const data = await this.redisClient.hGetAll(key); + if (Object.keys(data).length > 0) { + etas.push(this.createEtaFromRedisData(data)); + } + } + + return etas; + } + + public async getEtasForStopId(stopId: string): Promise { + const keys = await this.redisClient.keys('shuttle:eta:*'); + const etas: IEta[] = []; + + for (const key of keys) { + const data = await this.redisClient.hGetAll(key); + if (Object.keys(data).length > 0 && data.stopId === stopId) { + etas.push(this.createEtaFromRedisData(data)); + } + } + + return etas; + } + + public async getEtaForShuttleAndStopId(shuttleId: string, stopId: string): Promise { + const key = this.createEtaKey(shuttleId, stopId); + const data = await this.redisClient.hGetAll(key); + + if (Object.keys(data).length === 0) { + return null; + } + + return this.createEtaFromRedisData(data); + } + + public async getOrderedStopByRouteAndStopId(routeId: string, stopId: string): Promise { + const key = this.createOrderedStopKey(routeId, stopId); + const data = await this.redisClient.hGetAll(key); + + if (Object.keys(data).length === 0) { + return null; + } + + return this.createOrderedStopFromRedisData(data); + } + + public async getOrderedStopsByStopId(stopId: string): Promise { + const keys = await this.redisClient.keys('shuttle:orderedstop:*'); + const orderedStops: IOrderedStop[] = []; + + for (const key of keys) { + const data = await this.redisClient.hGetAll(key); + if (Object.keys(data).length > 0 && data.stopId === stopId) { + orderedStops.push(this.createOrderedStopFromRedisData(data)); + } + } + + return orderedStops; + } + + public async getOrderedStopsByRouteId(routeId: string): Promise { + const keys = await this.redisClient.keys(`shuttle:orderedstop:${routeId}:*`); + const orderedStops: IOrderedStop[] = []; + + for (const key of keys) { + const data = await this.redisClient.hGetAll(key); + if (Object.keys(data).length > 0) { + orderedStops.push(this.createOrderedStopFromRedisData(data)); + } + } + + return orderedStops; + } + + // Setter/update methods + public async addOrUpdateRoute(route: IRoute): Promise { + const key = this.createRouteKey(route.id); + await this.redisClient.hSet(key, this.createRedisHashFromRoute(route)); + } + + public async addOrUpdateShuttle(shuttle: IShuttle): Promise { + const key = this.createShuttleKey(shuttle.id); + await this.redisClient.hSet(key, this.createRedisHashFromShuttle(shuttle)); + } + + public async addOrUpdateStop(stop: IStop): Promise { + const key = this.createStopKey(stop.id); + await this.redisClient.hSet(key, this.createRedisHashFromStop(stop)); + } + + public async addOrUpdateOrderedStop(orderedStop: IOrderedStop): Promise { + const key = this.createOrderedStopKey(orderedStop.routeId, orderedStop.stopId); + await this.redisClient.hSet(key, this.createRedisHashFromOrderedStop(orderedStop)); + } + + public 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); + } + + // Remove methods + public async removeRouteIfExists(routeId: string): Promise { + const route = await this.getRouteById(routeId); + if (route) { + const key = this.createRouteKey(routeId); + await this.redisClient.del(key); + return route; + } + return null; + } + + public async removeShuttleIfExists(shuttleId: string): Promise { + const shuttle = await this.getShuttleById(shuttleId); + if (shuttle) { + const key = this.createShuttleKey(shuttleId); + await this.redisClient.del(key); + return shuttle; + } + return null; + } + + public async removeStopIfExists(stopId: string): Promise { + const stop = await this.getStopById(stopId); + if (stop) { + const key = this.createStopKey(stopId); + await this.redisClient.del(key); + return stop; + } + return null; + } + + public async removeOrderedStopIfExists(stopId: string, routeId: string): Promise { + const orderedStop = await this.getOrderedStopByRouteAndStopId(routeId, stopId); + if (orderedStop) { + const key = this.createOrderedStopKey(routeId, stopId); + await this.redisClient.del(key); + return orderedStop; + } + return null; + } + + public async removeEtaIfExists(shuttleId: string, stopId: string): Promise { + const eta = await this.getEtaForShuttleAndStopId(shuttleId, stopId); + if (eta) { + const key = this.createEtaKey(shuttleId, stopId); + await this.redisClient.del(key); + this.emit(ShuttleRepositoryEvent.ETA_REMOVED, eta); + return eta; + } + return null; + } + + // Clear methods + public async clearShuttleData(): Promise { + const keys = await this.redisClient.keys('shuttle:shuttle:*'); + if (keys.length > 0) { + await this.redisClient.del(keys); + } + } + + public async clearEtaData(): Promise { + const removedEtas = await this.getAllEtas(); + const keys = await this.redisClient.keys('shuttle:eta:*'); + if (keys.length > 0) { + await this.redisClient.del(keys); + } + this.emit(ShuttleRepositoryEvent.ETA_DATA_CLEARED, removedEtas); + } + + public async clearOrderedStopData(): Promise { + const keys = await this.redisClient.keys('shuttle:orderedstop:*'); + if (keys.length > 0) { + await this.redisClient.del(keys); + } + } + + public async clearRouteData(): Promise { + const keys = await this.redisClient.keys('shuttle:route:*'); + if (keys.length > 0) { + await this.redisClient.del(keys); + } + } + + public async clearStopData(): Promise { + const keys = await this.redisClient.keys('shuttle:stop:*'); + if (keys.length > 0) { + await this.redisClient.del(keys); + } + } + + // Helper method to get all ETAs for the clearEtaData event + private async getAllEtas(): Promise { + const keys = await this.redisClient.keys('shuttle:eta:*'); + const etas: IEta[] = []; + + for (const key of keys) { + const data = await this.redisClient.hGetAll(key); + if (Object.keys(data).length > 0) { + etas.push(this.createEtaFromRedisData(data)); + } + } + + return etas; + } +} diff --git a/src/repositories/shuttle/__tests__/UnoptimizedInMemoryShuttleRepositoryTests.test.ts b/src/repositories/shuttle/__tests__/ShuttleRepositorySharedTests.test.ts similarity index 89% rename from src/repositories/shuttle/__tests__/UnoptimizedInMemoryShuttleRepositoryTests.test.ts rename to src/repositories/shuttle/__tests__/ShuttleRepositorySharedTests.test.ts index c5c8d7c..f5c058f 100644 --- a/src/repositories/shuttle/__tests__/UnoptimizedInMemoryShuttleRepositoryTests.test.ts +++ b/src/repositories/shuttle/__tests__/ShuttleRepositorySharedTests.test.ts @@ -1,5 +1,7 @@ -import { beforeEach, describe, expect, jest, test } from "@jest/globals"; +import { afterEach, beforeEach, describe, expect, jest, test } from "@jest/globals"; import { UnoptimizedInMemoryShuttleRepository } from "../UnoptimizedInMemoryShuttleRepository"; +import { ShuttleGetterSetterRepository } from "../ShuttleGetterSetterRepository"; +import { RedisShuttleRepository } from "../RedisShuttleRepository"; import { ShuttleRepositoryEvent } from "../ShuttleGetterRepository"; import { generateMockEtas, @@ -9,16 +11,53 @@ import { generateMockStops, } from "../../../../testHelpers/mockDataGenerators"; -// For repositories created in the future, reuse core testing -// logic from here and differentiate setup (e.g. creating mocks) -// Do this by creating a function which takes a ShuttleGetterRepository -// or ShuttleGetterSetterRepository instance +interface RepositoryHolder { + name: string; + factory(): Promise; + teardown(): Promise; +} -describe("UnoptimizedInMemoryRepository", () => { - let repository: UnoptimizedInMemoryShuttleRepository; +class UnoptimizedInMemoryShuttleRepositoryHolder implements RepositoryHolder { + name = 'UnoptimizedInMemoryShuttleRepository'; + factory = async () => { + return new UnoptimizedInMemoryShuttleRepository(); + }; + teardown = async () => {}; +} - beforeEach(() => { - repository = new UnoptimizedInMemoryShuttleRepository(); +class RedisShuttleRepositoryHolder implements RepositoryHolder { + repo: RedisShuttleRepository | undefined; + + name = 'RedisShuttleRepository'; + factory = async () => { + this.repo = new RedisShuttleRepository(); + await this.repo.connect(); + return this.repo; + }; + teardown = async () => { + if (this.repo) { + await this.repo.clearAllData(); + await this.repo.disconnect(); + } + }; +} + +const repositoryImplementations = [ + new UnoptimizedInMemoryShuttleRepositoryHolder(), + new RedisShuttleRepositoryHolder(), +]; + +describe.each(repositoryImplementations)('$name', (holder) => { + let repository: ShuttleGetterSetterRepository; + + beforeEach(async () => { + repository = await holder.factory(); + jest.useRealTimers(); + }); + + afterEach(async () => { + await holder.teardown(); + jest.useRealTimers(); }); describe("getStops", () => { @@ -29,7 +68,8 @@ describe("UnoptimizedInMemoryRepository", () => { } const result = await repository.getStops(); - expect(result).toEqual(mockStops); + expect(result).toHaveLength(mockStops.length); + expect(result).toEqual(expect.arrayContaining(mockStops)); }); test("returns an empty list if there are no stops for the given system ID", async () => { @@ -62,7 +102,8 @@ describe("UnoptimizedInMemoryRepository", () => { } const result = await repository.getRoutes(); - expect(result).toEqual(mockRoutes); + expect(result).toHaveLength(mockRoutes.length); + expect(result).toEqual(expect.arrayContaining(mockRoutes)); }); test("returns an empty list if there are no routes for the system ID", async () => { @@ -86,6 +127,7 @@ describe("UnoptimizedInMemoryRepository", () => { expect(result).toBeNull(); }); }); + describe("getShuttles", () => { test("gets all shuttles for a specific system ID", async () => { const mockShuttles = generateMockShuttles(); @@ -94,7 +136,8 @@ describe("UnoptimizedInMemoryRepository", () => { } const result = await repository.getShuttles(); - expect(result).toEqual(mockShuttles); + expect(result).toHaveLength(mockShuttles.length); + expect(result).toEqual(expect.arrayContaining(mockShuttles)); }); test("returns an empty list if there are no shuttles for the system ID", async () => { @@ -145,7 +188,9 @@ describe("UnoptimizedInMemoryRepository", () => { } const result = await repository.getEtasForShuttleId("sh1"); - expect(result).toEqual(mockEtas.filter((eta) => eta.shuttleId === "sh1")); + const expected = mockEtas.filter((eta) => eta.shuttleId === "sh1"); + expect(result).toHaveLength(expected.length); + expect(result).toEqual(expect.arrayContaining(expected)); }); test("returns an empty list if there are no ETAs for the shuttle ID", async () => { @@ -240,7 +285,7 @@ describe("UnoptimizedInMemoryRepository", () => { repository.off(ShuttleRepositoryEvent.ETA_UPDATED, () => {}); await repository.addOrUpdateEta(mockEtas[0]); - + expect(mockListener).toHaveBeenCalledTimes(1); expect(mockListener).toHaveBeenCalledWith(mockEtas[0]); }); @@ -277,7 +322,9 @@ describe("UnoptimizedInMemoryRepository", () => { } const result = await repository.getOrderedStopsByStopId("st1"); - expect(result).toEqual(mockOrderedStops.filter((os) => os.stopId === "st1")); + const expected = mockOrderedStops.filter((os) => os.stopId === "st1"); + expect(result).toHaveLength(expected.length); + expect(result).toEqual(expect.arrayContaining(expected)); }); test("returns an empty list if there are no ordered stops for the stop ID", async () => { @@ -294,7 +341,9 @@ describe("UnoptimizedInMemoryRepository", () => { } const result = await repository.getOrderedStopsByRouteId("r1"); - expect(result).toEqual(mockOrderedStops.filter((os) => os.routeId === "r1")); + const expected = mockOrderedStops.filter((os) => os.routeId === "r1"); + expect(result).toHaveLength(expected.length); + expect(result).toEqual(expect.arrayContaining(expected)); }); test("returns an empty list if there are no ordered stops for the route ID", async () => { @@ -641,13 +690,15 @@ describe("UnoptimizedInMemoryRepository", () => { await repository.clearEtaData(); expect(listener).toHaveBeenCalledTimes(1); - expect(listener).toHaveBeenCalledWith(mockEtas); + const emittedEtas = listener.mock.calls[0][0]; + expect(emittedEtas).toHaveLength(mockEtas.length); + expect(emittedEtas).toEqual(expect.arrayContaining(mockEtas)); }); }); describe("clearOrderedStopData", () => { test("clears all ordered stops from the repository", async () => { - const mockOrderedStops = await generateMockOrderedStops(); + const mockOrderedStops = generateMockOrderedStops(); for (const system of mockOrderedStops) { await repository.addOrUpdateOrderedStop(system); } From 8ed23544c5dd2edffbcc1b6e10121aa11442fc3f Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Mon, 3 Nov 2025 10:47:19 -0800 Subject: [PATCH 04/96] Update InterchangeSystem builder to use Redis-based shuttle repository --- src/entities/InterchangeSystem.ts | 126 ++++++++++++++++++------------ 1 file changed, 75 insertions(+), 51 deletions(-) diff --git a/src/entities/InterchangeSystem.ts b/src/entities/InterchangeSystem.ts index 03428a2..03cac46 100644 --- a/src/entities/InterchangeSystem.ts +++ b/src/entities/InterchangeSystem.ts @@ -14,6 +14,8 @@ import { ParkingRepositoryLoaderBuilderArguments } from "../loaders/parking/buildParkingRepositoryLoaderIfExists"; import { RedisParkingRepository } from "../repositories/parking/RedisParkingRepository"; +import { RedisShuttleRepository } from "../repositories/shuttle/RedisShuttleRepository"; +import { ShuttleGetterRepository } from "../repositories/shuttle/ShuttleGetterRepository"; export interface InterchangeSystemBuilderArguments { name: string; @@ -55,28 +57,13 @@ export class InterchangeSystem { static async build( args: InterchangeSystemBuilderArguments, ) { - const shuttleRepository = new UnoptimizedInMemoryShuttleRepository(); - const shuttleDataLoader = new ApiBasedShuttleRepositoryLoader( - args.passioSystemId, - args.id, - shuttleRepository - ); - const timedShuttleDataLoader = new TimedApiBasedRepositoryLoader( - shuttleDataLoader, - ); - await timedShuttleDataLoader.start(); + const { shuttleRepository, timedShuttleDataLoader } = await InterchangeSystem.buildRedisShuttleLoaderAndRepository(args); + timedShuttleDataLoader.start(); - const notificationRepository = new RedisNotificationRepository(); - await notificationRepository.connect(); - const notificationScheduler = new ETANotificationScheduler( - shuttleRepository, - notificationRepository, - new AppleNotificationSender(), - args.id, - ); + const { notificationScheduler, notificationRepository } = await InterchangeSystem.buildNotificationSchedulerAndRepository(shuttleRepository, args); notificationScheduler.startListeningForUpdates(); - let { parkingRepository, timedParkingLoader } = await this.buildRedisParkingLoaderAndRepository(args.parkingSystemId); + let { parkingRepository, timedParkingLoader } = await InterchangeSystem.buildRedisParkingLoaderAndRepository(args.parkingSystemId); timedParkingLoader?.start(); return new InterchangeSystem( @@ -91,49 +78,30 @@ export class InterchangeSystem { ); } - /** - * Construct an instance of the class where all composited - * classes are correctly linked, meant for unit tests, and server/app - * integration tests. - * @param args - */ - static buildForTesting( - args: InterchangeSystemBuilderArguments, - ) { - const shuttleRepository = new UnoptimizedInMemoryShuttleRepository(); + private static async buildRedisShuttleLoaderAndRepository(args: InterchangeSystemBuilderArguments) { + const shuttleRepository = new RedisShuttleRepository(); + await shuttleRepository.connect(); const shuttleDataLoader = new ApiBasedShuttleRepositoryLoader( args.passioSystemId, args.id, shuttleRepository ); - // Note that this loader should not be started, - // so the test data doesn't get overwritten - const timedShuttleLoader = new TimedApiBasedRepositoryLoader( - shuttleDataLoader, + const timedShuttleDataLoader = new TimedApiBasedRepositoryLoader( + shuttleDataLoader ); + return { shuttleRepository, timedShuttleDataLoader }; + } - const notificationRepository = new InMemoryNotificationRepository(); + private static async buildNotificationSchedulerAndRepository(shuttleRepository: ShuttleGetterRepository, args: InterchangeSystemBuilderArguments) { + const notificationRepository = new RedisNotificationRepository(); + await notificationRepository.connect(); const notificationScheduler = new ETANotificationScheduler( shuttleRepository, notificationRepository, - new AppleNotificationSender(false), - args.id, - ); - notificationScheduler.startListeningForUpdates(); - - let { parkingRepository, timedParkingLoader } = this.buildInMemoryParkingLoaderAndRepository(args.parkingSystemId); - // Timed parking loader is not started here - - return new InterchangeSystem( - args.name, - args.id, - timedShuttleLoader, - shuttleRepository, - notificationScheduler, - notificationRepository, - timedParkingLoader, - parkingRepository, + new AppleNotificationSender(), + args.id ); + return { notificationScheduler, notificationRepository }; } private static async buildRedisParkingLoaderAndRepository(id?: string) { @@ -161,6 +129,47 @@ export class InterchangeSystem { return { parkingRepository, timedParkingLoader }; } + /** + * Construct an instance of the class where all composited + * classes are correctly linked, meant for unit tests, and server/app + * integration tests. + * @param args + */ + static buildForTesting( + args: InterchangeSystemBuilderArguments, + ) { + const { shuttleRepository, timedShuttleLoader } = InterchangeSystem.buildInMemoryShuttleLoaderAndRepository(args); + // Timed shuttle loader is not started here + + const { notificationScheduler, notificationRepository } = InterchangeSystem.buildInMemoryNotificationSchedulerAndRepository(shuttleRepository, args); + notificationScheduler.startListeningForUpdates(); + + let { parkingRepository, timedParkingLoader } = this.buildInMemoryParkingLoaderAndRepository(args.parkingSystemId); + // Timed parking loader is not started here + + return new InterchangeSystem( + args.name, + args.id, + timedShuttleLoader, + shuttleRepository, + notificationScheduler, + notificationRepository, + timedParkingLoader, + parkingRepository, + ); + } + + private static buildInMemoryNotificationSchedulerAndRepository(shuttleRepository: UnoptimizedInMemoryShuttleRepository, args: InterchangeSystemBuilderArguments) { + const notificationRepository = new InMemoryNotificationRepository(); + const notificationScheduler = new ETANotificationScheduler( + shuttleRepository, + notificationRepository, + new AppleNotificationSender(false), + args.id + ); + return { notificationScheduler, notificationRepository }; + } + private static buildInMemoryParkingLoaderAndRepository(id?: string) { if (id === undefined) { return { parkingRepository: null, timedParkingLoader: null }; @@ -184,4 +193,19 @@ export class InterchangeSystem { return { parkingRepository, timedParkingLoader }; } + private static buildInMemoryShuttleLoaderAndRepository(args: InterchangeSystemBuilderArguments) { + const shuttleRepository = new UnoptimizedInMemoryShuttleRepository(); + const shuttleDataLoader = new ApiBasedShuttleRepositoryLoader( + args.passioSystemId, + args.id, + shuttleRepository + ); + // Note that this loader should not be started, + // so the test data doesn't get overwritten + const timedShuttleLoader = new TimedApiBasedRepositoryLoader( + shuttleDataLoader + ); + return { shuttleRepository, timedShuttleLoader }; + } + } From b535868897176be45adcd498d2234dc43ad59d26 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Mon, 10 Nov 2025 14:33:00 -0800 Subject: [PATCH 05/96] Add a method to check whether a shuttle has arrived at a stop --- src/entities/ShuttleRepositoryEntities.ts | 12 ++++ .../ShuttleRepositoryEntities.test.ts | 65 +++++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 src/entities/__tests__/ShuttleRepositoryEntities.test.ts diff --git a/src/entities/ShuttleRepositoryEntities.ts b/src/entities/ShuttleRepositoryEntities.ts index 608bda6..73d2090 100644 --- a/src/entities/ShuttleRepositoryEntities.ts +++ b/src/entities/ShuttleRepositoryEntities.ts @@ -37,3 +37,15 @@ export interface IOrderedStop extends IEntityWithTimestamp { systemId: string; } +/** + * Checks if a shuttle has arrived at a stop based on coordinate proximity. + * Uses a threshold of 0.001 degrees (~111 meters at the equator). + */ +export function shuttleHasArrivedAtStop(shuttle: IShuttle, stop: IStop) { + const isWithinLatitudeRange = shuttle.coordinates.latitude > stop.coordinates.latitude - 0.001 + && shuttle.coordinates.latitude < stop.coordinates.latitude + 0.001; + const isWithinLongitudeRange = shuttle.coordinates.longitude > stop.coordinates.longitude - 0.001 + && shuttle.coordinates.longitude < stop.coordinates.longitude + 0.001 + return isWithinLatitudeRange && isWithinLongitudeRange; +} + diff --git a/src/entities/__tests__/ShuttleRepositoryEntities.test.ts b/src/entities/__tests__/ShuttleRepositoryEntities.test.ts new file mode 100644 index 0000000..084b933 --- /dev/null +++ b/src/entities/__tests__/ShuttleRepositoryEntities.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "@jest/globals"; +import { shuttleHasArrivedAtStop, IShuttle, IStop } from "../ShuttleRepositoryEntities"; + +describe("shuttleHasArrivedAtStop", () => { + const baseStop: IStop = { + id: "stop1", + name: "Test Stop", + systemId: "263", + coordinates: { + latitude: 33.7963, + longitude: -117.8540, + }, + updatedTime: new Date(), + }; + + const createShuttle = (latitude: number, longitude: number): IShuttle => ({ + id: "shuttle1", + name: "Test Shuttle", + routeId: "route1", + systemId: "263", + coordinates: { latitude, longitude }, + orientationInDegrees: 0, + updatedTime: new Date(), + }); + + it("returns false when shuttle is above latitude range", () => { + const shuttle = createShuttle( + baseStop.coordinates.latitude + 0.0011, + baseStop.coordinates.longitude + ); + expect(shuttleHasArrivedAtStop(shuttle, baseStop)).toBe(false); + }); + + it("returns false when shuttle is below latitude range", () => { + const shuttle = createShuttle( + baseStop.coordinates.latitude - 0.0011, + baseStop.coordinates.longitude + ); + expect(shuttleHasArrivedAtStop(shuttle, baseStop)).toBe(false); + }); + + it("returns false when shuttle is to left of longitude range", () => { + const shuttle = createShuttle( + baseStop.coordinates.latitude, + baseStop.coordinates.longitude - 0.0011 + ); + expect(shuttleHasArrivedAtStop(shuttle, baseStop)).toBe(false); + }); + + it("returns false when shuttle is to right of longitude range", () => { + const shuttle = createShuttle( + baseStop.coordinates.latitude, + baseStop.coordinates.longitude + 0.0011 + ); + expect(shuttleHasArrivedAtStop(shuttle, baseStop)).toBe(false); + }); + + it("returns true when shuttle is in the range", () => { + const shuttle = createShuttle( + baseStop.coordinates.latitude + 0.0005, + baseStop.coordinates.longitude - 0.0005 + ); + expect(shuttleHasArrivedAtStop(shuttle, baseStop)).toBe(true); + }); +}); From 321631e6c9b0636a02ae2a485ad17e4bee2a5cc1 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Mon, 10 Nov 2025 14:49:41 -0800 Subject: [PATCH 06/96] Add partial implementation and scaffolding of historical ETA update --- .../shuttle/RedisShuttleRepository.ts | 48 ++++++++++++++++++- 1 file changed, 46 insertions(+), 2 deletions(-) diff --git a/src/repositories/shuttle/RedisShuttleRepository.ts b/src/repositories/shuttle/RedisShuttleRepository.ts index cf6e063..d6e03d6 100644 --- a/src/repositories/shuttle/RedisShuttleRepository.ts +++ b/src/repositories/shuttle/RedisShuttleRepository.ts @@ -1,7 +1,7 @@ import EventEmitter from "node:events"; import { createClient } from 'redis'; import { ShuttleGetterSetterRepository } from "./ShuttleGetterSetterRepository"; -import { IEta, IOrderedStop, IRoute, IShuttle, IStop } from "../../entities/ShuttleRepositoryEntities"; +import { IEta, IOrderedStop, IRoute, IShuttle, IStop, shuttleHasArrivedAtStop } from "../../entities/ShuttleRepositoryEntities"; import { REDIS_RECONNECT_INTERVAL } from "../../environment"; import { ShuttleRepositoryEvent, @@ -10,6 +10,11 @@ import { ShuttleRepositoryEventPayloads, } from "./ShuttleGetterRepository"; +export interface ShuttleStopArrival { + stopId: string; + timestamp: Date; +} + export class RedisShuttleRepository extends EventEmitter implements ShuttleGetterSetterRepository { protected redisClient; @@ -405,9 +410,48 @@ export class RedisShuttleRepository extends EventEmitter implements ShuttleGette public async addOrUpdateShuttle(shuttle: IShuttle): Promise { const key = this.createShuttleKey(shuttle.id); await this.redisClient.hSet(key, this.createRedisHashFromShuttle(shuttle)); + + await this.updateHistoricalEtasForShuttle(shuttle); } - public async addOrUpdateStop(stop: IStop): Promise { + private async updateHistoricalEtasForShuttle(shuttle: IShuttle) { + const arrivedStop = await this.getArrivedStopIfExists(shuttle); + + if (arrivedStop != undefined) { + const lastStopTimestamp = await this.getShuttleLastStopTimestamp(shuttle) + if (lastStopTimestamp != undefined) { + const now = Date(); + const routeId = shuttle.routeId + const fromStopId = lastStopTimestamp.stopId; + const toStopId = arrivedStop.id; + + // Create an entry in Redis time series + // Key: routeId:fromStopId:toStopId: + // Value: seconds it took to get from lastStopTimestamp.stopId to arrivedStop.id + } + + // TODO: Update the "last stop timestamp" + } + } + + public async getArrivedStopIfExists(shuttle: IShuttle): 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)) { + return stop; + } + return undefined; + } + } + + public async getShuttleLastStopTimestamp(shuttle: IShuttle): Promise { + throw new Error("Method not implemented."); + } + + + public async addOrUpdateStop(stop: IStop): Promise { const key = this.createStopKey(stop.id); await this.redisClient.hSet(key, this.createRedisHashFromStop(stop)); } From 63ed267ded13e3742098186761d799a6a9736c9b Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Mon, 10 Nov 2025 14:54:48 -0800 Subject: [PATCH 07/96] Add necessary call to updateShuttleLastTimeArrival --- .../shuttle/RedisShuttleRepository.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/repositories/shuttle/RedisShuttleRepository.ts b/src/repositories/shuttle/RedisShuttleRepository.ts index d6e03d6..5eae7cc 100644 --- a/src/repositories/shuttle/RedisShuttleRepository.ts +++ b/src/repositories/shuttle/RedisShuttleRepository.ts @@ -418,7 +418,7 @@ export class RedisShuttleRepository extends EventEmitter implements ShuttleGette const arrivedStop = await this.getArrivedStopIfExists(shuttle); if (arrivedStop != undefined) { - const lastStopTimestamp = await this.getShuttleLastStopTimestamp(shuttle) + const lastStopTimestamp = await this.getShuttleLastStopArrival(shuttle) if (lastStopTimestamp != undefined) { const now = Date(); const routeId = shuttle.routeId @@ -430,7 +430,10 @@ export class RedisShuttleRepository extends EventEmitter implements ShuttleGette // Value: seconds it took to get from lastStopTimestamp.stopId to arrivedStop.id } - // TODO: Update the "last stop timestamp" + await this.updateShuttleLastStopArrival(shuttle, { + stopId: arrivedStop.id, + timestamp: new Date(), + }) } } @@ -446,10 +449,15 @@ export class RedisShuttleRepository extends EventEmitter implements ShuttleGette } } - public async getShuttleLastStopTimestamp(shuttle: IShuttle): Promise { - throw new Error("Method not implemented."); + public async getShuttleLastStopArrival(shuttle: IShuttle): Promise { + // Get the *time* of the most recent time series entry for the key + throw Error("not implemented"); } + private async updateShuttleLastStopArrival(shuttle: IShuttle, lastStopArrival: ShuttleStopArrival) { + // Key: shuttleId:stopId: + // Value: just a marker (no numerical value) + } public async addOrUpdateStop(stop: IStop): Promise { const key = this.createStopKey(stop.id); From 2a80a049bd6705cfb13075268231f1c124b13819 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Mon, 10 Nov 2025 15:09:13 -0800 Subject: [PATCH 08/96] Add updated test cases and update call to stop arrival helper --- src/entities/ShuttleRepositoryEntities.ts | 14 ++++--- .../shuttle/RedisShuttleRepository.ts | 9 +++-- .../__tests__/RedisShuttleRepository.test.ts | 37 +++++++++++++++++++ 3 files changed, 52 insertions(+), 8 deletions(-) create mode 100644 src/repositories/shuttle/__tests__/RedisShuttleRepository.test.ts diff --git a/src/entities/ShuttleRepositoryEntities.ts b/src/entities/ShuttleRepositoryEntities.ts index 73d2090..e16b1f4 100644 --- a/src/entities/ShuttleRepositoryEntities.ts +++ b/src/entities/ShuttleRepositoryEntities.ts @@ -41,11 +41,15 @@ export interface IOrderedStop extends IEntityWithTimestamp { * Checks if a shuttle has arrived at a stop based on coordinate proximity. * Uses a threshold of 0.001 degrees (~111 meters at the equator). */ -export function shuttleHasArrivedAtStop(shuttle: IShuttle, stop: IStop) { - const isWithinLatitudeRange = shuttle.coordinates.latitude > stop.coordinates.latitude - 0.001 - && shuttle.coordinates.latitude < stop.coordinates.latitude + 0.001; - const isWithinLongitudeRange = shuttle.coordinates.longitude > stop.coordinates.longitude - 0.001 - && shuttle.coordinates.longitude < stop.coordinates.longitude + 0.001 +export function shuttleHasArrivedAtStop( + shuttle: IShuttle, + stop: IStop, + delta = 0.001 +) { + const isWithinLatitudeRange = shuttle.coordinates.latitude > stop.coordinates.latitude - delta + && shuttle.coordinates.latitude < stop.coordinates.latitude + delta; + const isWithinLongitudeRange = shuttle.coordinates.longitude > stop.coordinates.longitude - delta + && shuttle.coordinates.longitude < stop.coordinates.longitude + delta return isWithinLatitudeRange && isWithinLongitudeRange; } diff --git a/src/repositories/shuttle/RedisShuttleRepository.ts b/src/repositories/shuttle/RedisShuttleRepository.ts index 5eae7cc..dc321da 100644 --- a/src/repositories/shuttle/RedisShuttleRepository.ts +++ b/src/repositories/shuttle/RedisShuttleRepository.ts @@ -437,12 +437,15 @@ export class RedisShuttleRepository extends EventEmitter implements ShuttleGette } } - public async getArrivedStopIfExists(shuttle: IShuttle): Promise { + 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)) { + if (stop != null && shuttleHasArrivedAtStop(shuttle, stop, delta)) { return stop; } return undefined; @@ -454,7 +457,7 @@ export class RedisShuttleRepository extends EventEmitter implements ShuttleGette throw Error("not implemented"); } - private async updateShuttleLastStopArrival(shuttle: IShuttle, lastStopArrival: ShuttleStopArrival) { + public async updateShuttleLastStopArrival(shuttle: IShuttle, lastStopArrival: ShuttleStopArrival) { // Key: shuttleId:stopId: // Value: just a marker (no numerical value) } diff --git a/src/repositories/shuttle/__tests__/RedisShuttleRepository.test.ts b/src/repositories/shuttle/__tests__/RedisShuttleRepository.test.ts new file mode 100644 index 0000000..c0d1614 --- /dev/null +++ b/src/repositories/shuttle/__tests__/RedisShuttleRepository.test.ts @@ -0,0 +1,37 @@ +import { beforeEach, describe, it } from "@jest/globals"; +import { RedisShuttleRepository } from "../RedisShuttleRepository"; +import { afterEach } from "node:test"; + +describe("RedisShuttleRepository", () => { + let repository: RedisShuttleRepository; + + beforeEach(async () => { + repository = new RedisShuttleRepository(); + await repository.connect(); + }); + + afterEach(async () => { + await repository.clearAllData(); + await repository.disconnect(); + }); + + describe("getArrivedStopIfExists", () => { + it("gets the stop that the shuttle is currently at, if exists", async () => { + + }); + + it("returns undefined if shuttle is not currently at a stop", async () => { + + }); + }); + + describe("getShuttleLastStopArrival", () => { + it("gets the shuttle's last stop if existing in the data", async () => { + // Use updateShuttleLastStopArrival to populate data + }); + + it("returns undefined if the data has never been initialized", async () => { + + }); + }); +}); From cba91dae55f55125802302c0791ab347c8f17d2b Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Mon, 10 Nov 2025 15:36:33 -0800 Subject: [PATCH 09/96] Implement getShuttleLastStopArrival and updateShuttleLastStopArrival --- .../shuttle/RedisShuttleRepository.ts | 21 ++++++-- .../__tests__/RedisShuttleRepository.test.ts | 49 +++++++++++++++++-- 2 files changed, 62 insertions(+), 8 deletions(-) diff --git a/src/repositories/shuttle/RedisShuttleRepository.ts b/src/repositories/shuttle/RedisShuttleRepository.ts index dc321da..03376ba 100644 --- a/src/repositories/shuttle/RedisShuttleRepository.ts +++ b/src/repositories/shuttle/RedisShuttleRepository.ts @@ -105,6 +105,7 @@ export class RedisShuttleRepository extends EventEmitter implements ShuttleGette private createShuttleKey = (shuttleId: string) => `shuttle:shuttle:${shuttleId}`; private createEtaKey = (shuttleId: string, stopId: string) => `shuttle:eta:${shuttleId}:${stopId}`; private createOrderedStopKey = (routeId: string, stopId: string) => `shuttle:orderedstop:${routeId}:${stopId}`; + private createShuttleLastStopKey = (shuttleId: string) => `shuttle:laststop:${shuttleId}`; // Helper methods for converting entities to Redis hashes private createRedisHashFromStop = (stop: IStop): Record => ({ @@ -453,13 +454,25 @@ export class RedisShuttleRepository extends EventEmitter implements ShuttleGette } public async getShuttleLastStopArrival(shuttle: IShuttle): Promise { - // Get the *time* of the most recent time series entry for the key - throw Error("not implemented"); + const key = this.createShuttleLastStopKey(shuttle.id); + const data = await this.redisClient.hGetAll(key); + + if (Object.keys(data).length === 0) { + return undefined; + } + + return { + stopId: data.stopId, + timestamp: new Date(data.timestamp), + }; } public async updateShuttleLastStopArrival(shuttle: IShuttle, lastStopArrival: ShuttleStopArrival) { - // Key: shuttleId:stopId: - // Value: just a marker (no numerical value) + const key = this.createShuttleLastStopKey(shuttle.id); + await this.redisClient.hSet(key, { + stopId: lastStopArrival.stopId, + timestamp: lastStopArrival.timestamp.toISOString(), + }); } public async addOrUpdateStop(stop: IStop): Promise { diff --git a/src/repositories/shuttle/__tests__/RedisShuttleRepository.test.ts b/src/repositories/shuttle/__tests__/RedisShuttleRepository.test.ts index c0d1614..bee1208 100644 --- a/src/repositories/shuttle/__tests__/RedisShuttleRepository.test.ts +++ b/src/repositories/shuttle/__tests__/RedisShuttleRepository.test.ts @@ -1,6 +1,6 @@ -import { beforeEach, describe, it } from "@jest/globals"; +import { beforeEach, describe, it, expect, afterEach } from "@jest/globals"; import { RedisShuttleRepository } from "../RedisShuttleRepository"; -import { afterEach } from "node:test"; +import { generateMockShuttles } from "../../../../testHelpers/mockDataGenerators"; describe("RedisShuttleRepository", () => { let repository: RedisShuttleRepository; @@ -27,11 +27,52 @@ describe("RedisShuttleRepository", () => { describe("getShuttleLastStopArrival", () => { it("gets the shuttle's last stop if existing in the data", async () => { - // Use updateShuttleLastStopArrival to populate data + const mockShuttles = generateMockShuttles(); + const shuttle = mockShuttles[0]; + const stopArrival = { + stopId: "st1", + timestamp: new Date("2024-01-15T10:30:00Z"), + }; + + await repository.updateShuttleLastStopArrival(shuttle, stopArrival); + const result = await repository.getShuttleLastStopArrival(shuttle); + + expect(result).toBeDefined(); + expect(result?.stopId).toBe(stopArrival.stopId); + expect(result?.timestamp.getTime()).toBe(stopArrival.timestamp.getTime()); }); it("returns undefined if the data has never been initialized", async () => { - + const mockShuttles = generateMockShuttles(); + const shuttle = mockShuttles[0]; + + const result = await repository.getShuttleLastStopArrival(shuttle); + + expect(result).toBeUndefined(); + }); + + it("returns the most recent stop arrival when updated multiple times", async () => { + const mockShuttles = generateMockShuttles(); + const shuttle = mockShuttles[0]; + + const firstArrival = { + stopId: "st1", + timestamp: new Date("2024-01-15T10:30:00Z"), + }; + + const secondArrival = { + stopId: "st2", + timestamp: new Date("2024-01-15T10:35:00Z"), + }; + + await repository.updateShuttleLastStopArrival(shuttle, firstArrival); + await repository.updateShuttleLastStopArrival(shuttle, secondArrival); + + const result = await repository.getShuttleLastStopArrival(shuttle); + + expect(result).toBeDefined(); + expect(result?.stopId).toBe(secondArrival.stopId); + expect(result?.timestamp.getTime()).toBe(secondArrival.timestamp.getTime()); }); }); }); From 584dd44aad98427da419fca5d525757b2f0b335a Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Mon, 10 Nov 2025 15:45:21 -0800 Subject: [PATCH 10/96] Update Claude instructions for testing --- CLAUDE.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 01dd597..ff2a556 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,7 +19,8 @@ npm run generate npm run build:dev ``` -Only use Docker Compose for running tests. +Only use Docker Compose for running tests, and only use `docker compose run test` +to run tests; don't try to run tests for individual files. ## Architecture Overview From c946647483c838ac52c886908fa00708c2fac064 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Mon, 10 Nov 2025 15:45:38 -0800 Subject: [PATCH 11/96] Update getArrivedStopIfExists and implement tests for it --- .../shuttle/RedisShuttleRepository.ts | 2 +- .../__tests__/RedisShuttleRepository.test.ts | 82 ++++++++++++++++++- 2 files changed, 82 insertions(+), 2 deletions(-) diff --git a/src/repositories/shuttle/RedisShuttleRepository.ts b/src/repositories/shuttle/RedisShuttleRepository.ts index 03376ba..1053d30 100644 --- a/src/repositories/shuttle/RedisShuttleRepository.ts +++ b/src/repositories/shuttle/RedisShuttleRepository.ts @@ -449,8 +449,8 @@ export class RedisShuttleRepository extends EventEmitter implements ShuttleGette if (stop != null && shuttleHasArrivedAtStop(shuttle, stop, delta)) { return stop; } - return undefined; } + return undefined; } public async getShuttleLastStopArrival(shuttle: IShuttle): Promise { diff --git a/src/repositories/shuttle/__tests__/RedisShuttleRepository.test.ts b/src/repositories/shuttle/__tests__/RedisShuttleRepository.test.ts index bee1208..7c0c6b2 100644 --- a/src/repositories/shuttle/__tests__/RedisShuttleRepository.test.ts +++ b/src/repositories/shuttle/__tests__/RedisShuttleRepository.test.ts @@ -16,12 +16,92 @@ describe("RedisShuttleRepository", () => { }); describe("getArrivedStopIfExists", () => { - it("gets the stop that the shuttle is currently at, if exists", async () => { + async function setupRouteAndOrderedStops() { + const systemId = "sys1"; + const route = { + id: "r1", + name: "Route 1", + color: "red", + systemId: systemId, + polylineCoordinates: [], + updatedTime: new Date(), + }; + await repository.addOrUpdateRoute(route); + const stop1 = { + id: "st1", + name: "Stop 1", + systemId: systemId, + coordinates: { latitude: 10.0, longitude: 20.0 }, + updatedTime: new Date(), + }; + const stop2 = { + id: "st2", + name: "Stop 2", + systemId: systemId, + coordinates: { latitude: 15.0, longitude: 25.0 }, + updatedTime: new Date(), + }; + await repository.addOrUpdateStop(stop1); + await repository.addOrUpdateStop(stop2); + + const orderedStop1 = { + routeId: route.id, + stopId: stop1.id, + position: 1, + systemId: systemId, + updatedTime: new Date(), + }; + const orderedStop2 = { + routeId: route.id, + stopId: stop2.id, + position: 2, + systemId: systemId, + updatedTime: new Date(), + }; + await repository.addOrUpdateOrderedStop(orderedStop1); + await repository.addOrUpdateOrderedStop(orderedStop2); + return { route, systemId }; + } + + it("gets the stop that the shuttle is currently at, if exists", async () => { + const { route, systemId } = await setupRouteAndOrderedStops(); + + // Create a shuttle positioned at stop2 + const shuttle = { + id: "sh1", + name: "Shuttle 1", + routeId: route.id, + systemId: systemId, + coordinates: { latitude: 15.0, longitude: 25.0 }, // Same as stop2 + orientationInDegrees: 0, + updatedTime: new Date(), + }; + + const result = await repository.getArrivedStopIfExists(shuttle); + + expect(result).toBeDefined(); + expect(result?.id).toBe("st2"); + expect(result?.name).toBe("Stop 2"); }); it("returns undefined if shuttle is not currently at a stop", async () => { + const { route, systemId } = await setupRouteAndOrderedStops(); + // Create a shuttle positioned between stops (not at any stop) + const shuttle = { + id: "sh1", + name: "Shuttle 1", + routeId: route.id, + systemId: systemId, + coordinates: { latitude: 12.5, longitude: 22.5 }, // Between stop1 and stop2 + orientationInDegrees: 0, + updatedTime: new Date(), + }; + + const result = await repository.getArrivedStopIfExists(shuttle); + + expect(result).toBeUndefined(); }); }); From bd1ae07662848df20f46afc7fed3d3b98e845d1c Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Mon, 10 Nov 2025 16:37:00 -0800 Subject: [PATCH 12/96] Rename historical parking query arguments to HistoricalParkingAverageFilterArguments --- src/repositories/parking/InMemoryParkingRepository.ts | 10 +++++----- src/repositories/parking/ParkingGetterRepository.ts | 4 ++-- src/repositories/parking/RedisParkingRepository.ts | 6 +++--- .../__tests__/ParkingRepositorySharedTests.test.ts | 6 +++--- src/resolvers/ParkingStructureResolvers.ts | 8 ++++---- 5 files changed, 17 insertions(+), 17 deletions(-) diff --git a/src/repositories/parking/InMemoryParkingRepository.ts b/src/repositories/parking/InMemoryParkingRepository.ts index 84cea32..e07307a 100644 --- a/src/repositories/parking/InMemoryParkingRepository.ts +++ b/src/repositories/parking/InMemoryParkingRepository.ts @@ -1,9 +1,9 @@ import { ParkingGetterSetterRepository } from "./ParkingGetterSetterRepository"; import { - IParkingStructure, - IParkingStructureTimestampRecord + IParkingStructure, + IParkingStructureTimestampRecord } from "../../entities/ParkingRepositoryEntities"; -import { HistoricalParkingAverageQueryResult, HistoricalParkingAverageQueryArguments } from "./ParkingGetterRepository"; +import { HistoricalParkingAverageQueryResult, HistoricalParkingAverageFilterArguments } from "./ParkingGetterRepository"; import { CircularQueue } from "../../types/CircularQueue"; import { PARKING_LOGGING_INTERVAL_MS } from "../../environment"; @@ -63,7 +63,7 @@ export class InMemoryParkingRepository implements ParkingGetterSetterRepository return null; }; - getHistoricalAveragesOfParkingStructureCounts = async (id: string, options: HistoricalParkingAverageQueryArguments): Promise => { + getHistoricalAveragesOfParkingStructureCounts = async (id: string, options: HistoricalParkingAverageFilterArguments): Promise => { const queue = this.historicalData.get(id); if (!queue || queue.size() === 0) { return []; @@ -107,7 +107,7 @@ export class InMemoryParkingRepository implements ParkingGetterSetterRepository private calculateAveragesFromRecords = ( records: IParkingStructureTimestampRecord[], - options: HistoricalParkingAverageQueryArguments + options: HistoricalParkingAverageFilterArguments ): HistoricalParkingAverageQueryResult[] => { const results: HistoricalParkingAverageQueryResult[] = []; const { from, to, intervalMs } = options; diff --git a/src/repositories/parking/ParkingGetterRepository.ts b/src/repositories/parking/ParkingGetterRepository.ts index e1346a4..f3823e2 100644 --- a/src/repositories/parking/ParkingGetterRepository.ts +++ b/src/repositories/parking/ParkingGetterRepository.ts @@ -1,6 +1,6 @@ import { IParkingStructure } from "../../entities/ParkingRepositoryEntities"; -export interface HistoricalParkingAverageQueryArguments { +export interface HistoricalParkingAverageFilterArguments { from: Date; to: Date; intervalMs: number; @@ -22,5 +22,5 @@ export interface ParkingGetterRepository { * @param id * @param options */ - getHistoricalAveragesOfParkingStructureCounts(id: string, options: HistoricalParkingAverageQueryArguments): Promise; + getHistoricalAveragesOfParkingStructureCounts(id: string, options: HistoricalParkingAverageFilterArguments): Promise; } diff --git a/src/repositories/parking/RedisParkingRepository.ts b/src/repositories/parking/RedisParkingRepository.ts index 5704226..48a74b1 100644 --- a/src/repositories/parking/RedisParkingRepository.ts +++ b/src/repositories/parking/RedisParkingRepository.ts @@ -1,6 +1,6 @@ import { ParkingGetterSetterRepository } from "./ParkingGetterSetterRepository"; import { IParkingStructure } from "../../entities/ParkingRepositoryEntities"; -import { HistoricalParkingAverageQueryResult, HistoricalParkingAverageQueryArguments } from "./ParkingGetterRepository"; +import { HistoricalParkingAverageQueryResult, HistoricalParkingAverageFilterArguments } from "./ParkingGetterRepository"; import { BaseRedisRepository } from "../BaseRedisRepository"; import { PARKING_LOGGING_INTERVAL_MS } from "../../environment"; @@ -75,7 +75,7 @@ export class RedisParkingRepository extends BaseRedisRepository implements Parki return null; }; - getHistoricalAveragesOfParkingStructureCounts = async (id: string, options: HistoricalParkingAverageQueryArguments): Promise => { + getHistoricalAveragesOfParkingStructureCounts = async (id: string, options: HistoricalParkingAverageFilterArguments): Promise => { return this.calculateAveragesFromRecords(id, options); }; @@ -157,7 +157,7 @@ export class RedisParkingRepository extends BaseRedisRepository implements Parki private calculateAveragesFromRecords = async ( id: string, - options: HistoricalParkingAverageQueryArguments + options: HistoricalParkingAverageFilterArguments ): Promise => { const keys = this.createRedisKeys(id); const { from, to, intervalMs } = options; diff --git a/src/repositories/parking/__tests__/ParkingRepositorySharedTests.test.ts b/src/repositories/parking/__tests__/ParkingRepositorySharedTests.test.ts index d6d5858..feabfa1 100644 --- a/src/repositories/parking/__tests__/ParkingRepositorySharedTests.test.ts +++ b/src/repositories/parking/__tests__/ParkingRepositorySharedTests.test.ts @@ -1,9 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, jest } from "@jest/globals"; import { InMemoryParkingRepository, } from "../InMemoryParkingRepository"; import { IParkingStructure } from "../../../entities/ParkingRepositoryEntities"; -import { HistoricalParkingAverageQueryArguments } from "../ParkingGetterRepository"; import { ParkingGetterSetterRepository } from "../ParkingGetterSetterRepository"; import { RedisParkingRepository } from "../RedisParkingRepository"; +import { HistoricalParkingAverageFilterArguments } from "../ParkingGetterRepository"; interface RepositoryHolder { name: string; @@ -151,7 +151,7 @@ describe.each(repositoryImplementations)('$name', (holder) => { describe("getHistoricalAveragesOfParkingStructureCounts", () => { it("should return empty array for non-existent structure or no data", async () => { - const options: HistoricalParkingAverageQueryArguments = { + const options: HistoricalParkingAverageFilterArguments = { from: new Date(1000), to: new Date(2000), intervalMs: 500 @@ -182,7 +182,7 @@ describe.each(repositoryImplementations)('$name', (holder) => { } const now = Date.now(); - const options: HistoricalParkingAverageQueryArguments = { + const options: HistoricalParkingAverageFilterArguments = { from: new Date(now - 10000), // Look back 10 seconds to: new Date(now + 10000), // Look forward 10 seconds intervalMs: 20000 // Single large interval diff --git a/src/resolvers/ParkingStructureResolvers.ts b/src/resolvers/ParkingStructureResolvers.ts index a108daf..b417876 100644 --- a/src/resolvers/ParkingStructureResolvers.ts +++ b/src/resolvers/ParkingStructureResolvers.ts @@ -1,10 +1,10 @@ import { Resolvers } from "../generated/graphql"; import { ServerContext } from "../ServerContext"; -import { HistoricalParkingAverageQueryArguments } from "../repositories/parking/ParkingGetterRepository"; +import { HistoricalParkingAverageFilterArguments } from "../repositories/parking/ParkingGetterRepository"; import { GraphQLError } from "graphql/error"; import { - PARKING_HISTORICAL_AVERAGE_MAXIMUM_TIMESPAN, - PARKING_HISTORICAL_AVERAGE_MINIMUM_INTERVAL + PARKING_HISTORICAL_AVERAGE_MAXIMUM_TIMESPAN, + PARKING_HISTORICAL_AVERAGE_MINIMUM_INTERVAL } from "../environment"; export const ParkingStructureResolvers: Resolvers = { @@ -27,7 +27,7 @@ export const ParkingStructureResolvers: Resolvers = { throwBadUserInputError('No interval provided'); return null; } - const queryArguments: HistoricalParkingAverageQueryArguments = { + const queryArguments: HistoricalParkingAverageFilterArguments = { from: new Date(args.input.from), intervalMs: args.input.intervalMs, to: new Date(args.input.to), From d92db84738a864816fe75ece833df02b04ad1fe2 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Mon, 10 Nov 2025 16:46:34 -0800 Subject: [PATCH 13/96] Implement travel time data point saving and loading, and tests --- .../shuttle/RedisShuttleRepository.ts | 149 +++++++++++++++- .../__tests__/RedisShuttleRepository.test.ts | 159 ++++++++++++------ 2 files changed, 249 insertions(+), 59 deletions(-) diff --git a/src/repositories/shuttle/RedisShuttleRepository.ts b/src/repositories/shuttle/RedisShuttleRepository.ts index 1053d30..2962975 100644 --- a/src/repositories/shuttle/RedisShuttleRepository.ts +++ b/src/repositories/shuttle/RedisShuttleRepository.ts @@ -15,6 +15,17 @@ export interface ShuttleStopArrival { 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; @@ -106,6 +117,9 @@ export class RedisShuttleRepository extends EventEmitter implements ShuttleGette private createEtaKey = (shuttleId: string, stopId: string) => `shuttle:eta:${shuttleId}:${stopId}`; private createOrderedStopKey = (routeId: string, stopId: string) => `shuttle:orderedstop:${routeId}:${stopId}`; private createShuttleLastStopKey = (shuttleId: string) => `shuttle:laststop:${shuttleId}`; + private createHistoricalEtaTimeSeriesKey =(routeId: string, fromStopId: string, toStopId: string) => { + return `shuttle:eta:historical:${routeId}:${fromStopId}:${toStopId}`; + } // Helper methods for converting entities to Redis hashes private createRedisHashFromStop = (stop: IStop): Record => ({ @@ -408,36 +422,153 @@ export class RedisShuttleRepository extends EventEmitter implements ShuttleGette await this.redisClient.hSet(key, this.createRedisHashFromRoute(route)); } - public async addOrUpdateShuttle(shuttle: IShuttle): Promise { + public async addOrUpdateShuttle( + shuttle: IShuttle, + travelTimeTimestamp = Date.now(), + ): Promise { const key = this.createShuttleKey(shuttle.id); await this.redisClient.hSet(key, this.createRedisHashFromShuttle(shuttle)); - await this.updateHistoricalEtasForShuttle(shuttle); + await this.updateHistoricalEtasForShuttle(shuttle, travelTimeTimestamp); + await this.updateEtasBasedOnHistoricalData(shuttle); } - private async updateHistoricalEtasForShuttle(shuttle: IShuttle) { + public async updateEtasBasedOnHistoricalData( + shuttle: IShuttle, + ) { + // Based on historical data for the key provided by this.createHistoricalEtaTimeSeriesKey(routeId, fromStopId, toStopId); + // Call this.addOrUpdateEta with the correct information + // "Historical data" being averaged time taken for the current hour of the same day + // in the past week, based on the reference time + } + + private async updateHistoricalEtasForShuttle( + shuttle: IShuttle, + travelTimeTimestamp = Date.now(), + ) { const arrivedStop = await this.getArrivedStopIfExists(shuttle); if (arrivedStop != undefined) { const lastStopTimestamp = await this.getShuttleLastStopArrival(shuttle) if (lastStopTimestamp != undefined) { - const now = Date(); - const routeId = shuttle.routeId + const routeId = shuttle.routeId; const fromStopId = lastStopTimestamp.stopId; const toStopId = arrivedStop.id; - // Create an entry in Redis time series - // Key: routeId:fromStopId:toStopId: - // Value: seconds it took to get from lastStopTimestamp.stopId to arrivedStop.id + const travelTimeSeconds = (travelTimeTimestamp - lastStopTimestamp.timestamp.getTime()) / 1000; + await this.addTravelTimeDataPoint({ routeId, fromStopId, toStopId, }, travelTimeSeconds, travelTimeTimestamp); } await this.updateShuttleLastStopArrival(shuttle, { stopId: arrivedStop.id, - timestamp: new Date(), + timestamp: new Date(travelTimeTimestamp), }) } } + public async getAverageTravelTimeSeconds( + { routeId, fromStopId, toStopId }: ShuttleTravelTimeDataIdentifier, + { from, to }: ShuttleTravelTimeDateFilterArguments, + ): Promise { + const timeSeriesKey = this.createHistoricalEtaTimeSeriesKey(routeId, fromStopId, toStopId); + const fromTimestamp = from.getTime(); + const toTimestamp = to.getTime(); + const intervalMs = toTimestamp - fromTimestamp; + + try { + const aggregationResult = await this.redisClient.sendCommand([ + 'TS.RANGE', + timeSeriesKey, + fromTimestamp.toString(), + toTimestamp.toString(), + 'AGGREGATION', + 'AVG', + intervalMs.toString() + ]) as [string, string][]; + + if (aggregationResult && aggregationResult.length > 0) { + const [, averageValue] = aggregationResult[0]; + return parseFloat(averageValue); + } + + throw new Error(`No historical data found for route ${routeId} from stop ${fromStopId} to stop ${toStopId}`); + } catch (error) { + throw new Error(`Failed to get average travel time: ${error instanceof Error ? error.message : String(error)}`); + } + } + + private async addTravelTimeDataPoint( + { routeId, fromStopId, toStopId }: ShuttleTravelTimeDataIdentifier, + travelTimeSeconds: number, + timestamp = Date.now(), + ): Promise { + const historicalEtaTimeSeriesKey = this.createHistoricalEtaTimeSeriesKey(routeId, fromStopId, toStopId); + + try { + await this.redisClient.sendCommand([ + 'TS.ADD', + historicalEtaTimeSeriesKey, + timestamp.toString(), + travelTimeSeconds.toString(), + 'LABELS', + 'routeId', + routeId, + 'fromStopId', + fromStopId, + 'toStopId', + toStopId + ]); + } catch (error) { + await this.createHistoricalEtaTimeSeriesAndAddDataPoint( + historicalEtaTimeSeriesKey, + timestamp, + travelTimeSeconds, + routeId, + fromStopId, + toStopId + ); + } + } + + + private async createHistoricalEtaTimeSeriesAndAddDataPoint( + timeSeriesKey: string, + timestamp: number, + travelTimeSeconds: number, + routeId: string, + fromStopId: string, + toStopId: string, + ): Promise { + try { + await this.redisClient.sendCommand([ + 'TS.CREATE', + timeSeriesKey, + 'RETENTION', + '2678400000', // one month in milliseconds + 'LABELS', + 'routeId', + routeId, + 'fromStopId', + fromStopId, + 'toStopId', + toStopId + ]); + await this.redisClient.sendCommand([ + 'TS.ADD', + timeSeriesKey, + timestamp.toString(), + travelTimeSeconds.toString() + ]); + } catch (createError) { + await this.redisClient.sendCommand([ + 'TS.ADD', + timeSeriesKey, + timestamp.toString(), + travelTimeSeconds.toString() + ]); + } + } + public async getArrivedStopIfExists( shuttle: IShuttle, delta = 0.001, diff --git a/src/repositories/shuttle/__tests__/RedisShuttleRepository.test.ts b/src/repositories/shuttle/__tests__/RedisShuttleRepository.test.ts index 7c0c6b2..68f321e 100644 --- a/src/repositories/shuttle/__tests__/RedisShuttleRepository.test.ts +++ b/src/repositories/shuttle/__tests__/RedisShuttleRepository.test.ts @@ -15,57 +15,116 @@ describe("RedisShuttleRepository", () => { await repository.disconnect(); }); + async function setupRouteAndOrderedStops() { + const systemId = "sys1"; + const route = { + id: "r1", + name: "Route 1", + color: "red", + systemId: systemId, + polylineCoordinates: [], + updatedTime: new Date(), + }; + await repository.addOrUpdateRoute(route); + + const stop1 = { + id: "st1", + name: "Stop 1", + systemId: systemId, + coordinates: { latitude: 10.0, longitude: 20.0 }, + updatedTime: new Date(), + }; + const stop2 = { + id: "st2", + name: "Stop 2", + systemId: systemId, + coordinates: { latitude: 15.0, longitude: 25.0 }, + updatedTime: new Date(), + }; + await repository.addOrUpdateStop(stop1); + await repository.addOrUpdateStop(stop2); + + const orderedStop1 = { + routeId: route.id, + stopId: stop1.id, + position: 1, + systemId: systemId, + updatedTime: new Date(), + }; + const orderedStop2 = { + routeId: route.id, + stopId: stop2.id, + position: 2, + systemId: systemId, + updatedTime: new Date(), + }; + await repository.addOrUpdateOrderedStop(orderedStop1); + await repository.addOrUpdateOrderedStop(orderedStop2); + return { + route, + systemId, + stop1, + stop2, + }; + } + + describe("addOrUpdateShuttle", () => { + it("updates the shuttle's last stop arrival if shuttle is at a stop", async () => { + const { route, systemId, stop2 } = await setupRouteAndOrderedStops(); + + // Shuttle positioned at stop2 + const shuttle = { + id: "sh1", + name: "Shuttle 1", + routeId: route.id, + systemId: systemId, + coordinates: stop2.coordinates, + orientationInDegrees: 0, + updatedTime: new Date(), + }; + + await repository.addOrUpdateShuttle(shuttle); + const lastStop = await repository.getShuttleLastStopArrival(shuttle); + expect(lastStop?.stopId).toEqual(stop2.id) + }); + + it("updates how long the shuttle took to get from one stop to another", async () => { + const { route, systemId, stop2, stop1 } = await setupRouteAndOrderedStops(); + + // Start the shuttle at stop 1, then have it move to stop 2 + const shuttle = { + id: "sh1", + name: "Shuttle 1", + routeId: route.id, + systemId: systemId, + coordinates: stop1.coordinates, + orientationInDegrees: 0, + updatedTime: new Date(), + } + + const firstStopArrivalTime = new Date(2025, 0, 1, 12, 0, 0); + await repository.addOrUpdateShuttle(shuttle, firstStopArrivalTime.getTime()); + + shuttle.coordinates = stop2.coordinates; + // 15-minute travel time + const secondStopArrivalTime = new Date(2025, 0, 1, 12, 15, 0); + await repository.addOrUpdateShuttle(shuttle, secondStopArrivalTime.getTime()); + + const travelTime = await repository.getAverageTravelTimeSeconds({ + routeId: route.id, + fromStopId: stop1.id, + toStopId: stop2.id, + }, { + from: new Date(2025, 0, 1, 11, 0, 0), + to: new Date(2025, 0, 1, 13, 0, 0), + }); + expect(travelTime).toEqual(15 * 60) + }); + }); + describe("getArrivedStopIfExists", () => { - async function setupRouteAndOrderedStops() { - const systemId = "sys1"; - const route = { - id: "r1", - name: "Route 1", - color: "red", - systemId: systemId, - polylineCoordinates: [], - updatedTime: new Date(), - }; - await repository.addOrUpdateRoute(route); - - const stop1 = { - id: "st1", - name: "Stop 1", - systemId: systemId, - coordinates: { latitude: 10.0, longitude: 20.0 }, - updatedTime: new Date(), - }; - const stop2 = { - id: "st2", - name: "Stop 2", - systemId: systemId, - coordinates: { latitude: 15.0, longitude: 25.0 }, - updatedTime: new Date(), - }; - await repository.addOrUpdateStop(stop1); - await repository.addOrUpdateStop(stop2); - - const orderedStop1 = { - routeId: route.id, - stopId: stop1.id, - position: 1, - systemId: systemId, - updatedTime: new Date(), - }; - const orderedStop2 = { - routeId: route.id, - stopId: stop2.id, - position: 2, - systemId: systemId, - updatedTime: new Date(), - }; - await repository.addOrUpdateOrderedStop(orderedStop1); - await repository.addOrUpdateOrderedStop(orderedStop2); - return { route, systemId }; - } - it("gets the stop that the shuttle is currently at, if exists", async () => { - const { route, systemId } = await setupRouteAndOrderedStops(); + const { route, systemId, stop2 } = await setupRouteAndOrderedStops(); // Create a shuttle positioned at stop2 const shuttle = { @@ -73,7 +132,7 @@ describe("RedisShuttleRepository", () => { name: "Shuttle 1", routeId: route.id, systemId: systemId, - coordinates: { latitude: 15.0, longitude: 25.0 }, // Same as stop2 + coordinates: stop2.coordinates, orientationInDegrees: 0, updatedTime: new Date(), }; From 91517669f0f74d5d7bb1d8afbac7e49197a516c5 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Mon, 10 Nov 2025 18:11:54 -0800 Subject: [PATCH 14/96] Implement updateEtasBasedOnHistoricalData and add a test for it --- .../shuttle/RedisShuttleRepository.ts | 44 ++++++++++++++---- .../__tests__/RedisShuttleRepository.test.ts | 45 +++++++++++++++++-- 2 files changed, 78 insertions(+), 11 deletions(-) diff --git a/src/repositories/shuttle/RedisShuttleRepository.ts b/src/repositories/shuttle/RedisShuttleRepository.ts index 2962975..f00819e 100644 --- a/src/repositories/shuttle/RedisShuttleRepository.ts +++ b/src/repositories/shuttle/RedisShuttleRepository.ts @@ -425,21 +425,48 @@ export class RedisShuttleRepository extends EventEmitter implements ShuttleGette public async addOrUpdateShuttle( shuttle: IShuttle, travelTimeTimestamp = Date.now(), + referenceCurrentTime = new Date(), ): Promise { const key = this.createShuttleKey(shuttle.id); await this.redisClient.hSet(key, this.createRedisHashFromShuttle(shuttle)); await this.updateHistoricalEtasForShuttle(shuttle, travelTimeTimestamp); - await this.updateEtasBasedOnHistoricalData(shuttle); + await this.updateEtasBasedOnHistoricalData(shuttle, referenceCurrentTime); } - public async updateEtasBasedOnHistoricalData( + private async updateEtasBasedOnHistoricalData( shuttle: IShuttle, + referenceCurrentTime: Date = new Date(), ) { - // Based on historical data for the key provided by this.createHistoricalEtaTimeSeriesKey(routeId, fromStopId, toStopId); - // Call this.addOrUpdateEta with the correct information - // "Historical data" being averaged time taken for the current hour of the same day - // in the past week, based on the reference time + const oneWeekAgo = new Date(referenceCurrentTime.getTime() - (60 * 60 * 24 * 7 * 1000)) + + const lastStopArrival = await this.getShuttleLastStopArrival(shuttle) + 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 updateHistoricalEtasForShuttle( @@ -469,7 +496,7 @@ export class RedisShuttleRepository extends EventEmitter implements ShuttleGette public async getAverageTravelTimeSeconds( { routeId, fromStopId, toStopId }: ShuttleTravelTimeDataIdentifier, { from, to }: ShuttleTravelTimeDateFilterArguments, - ): Promise { + ): Promise { const timeSeriesKey = this.createHistoricalEtaTimeSeriesKey(routeId, fromStopId, toStopId); const fromTimestamp = from.getTime(); const toTimestamp = to.getTime(); @@ -493,7 +520,8 @@ export class RedisShuttleRepository extends EventEmitter implements ShuttleGette throw new Error(`No historical data found for route ${routeId} from stop ${fromStopId} to stop ${toStopId}`); } catch (error) { - throw new Error(`Failed to get average travel time: ${error instanceof Error ? error.message : String(error)}`); + console.warn(`Failed to get average travel time: ${error instanceof Error ? error.message : String(error)}`); + return; } } diff --git a/src/repositories/shuttle/__tests__/RedisShuttleRepository.test.ts b/src/repositories/shuttle/__tests__/RedisShuttleRepository.test.ts index 68f321e..834870c 100644 --- a/src/repositories/shuttle/__tests__/RedisShuttleRepository.test.ts +++ b/src/repositories/shuttle/__tests__/RedisShuttleRepository.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, it, expect, afterEach } from "@jest/globals"; import { RedisShuttleRepository } from "../RedisShuttleRepository"; import { generateMockShuttles } from "../../../../testHelpers/mockDataGenerators"; +import { IOrderedStop } from "../../../entities/ShuttleRepositoryEntities"; describe("RedisShuttleRepository", () => { let repository: RedisShuttleRepository; @@ -44,22 +45,27 @@ describe("RedisShuttleRepository", () => { await repository.addOrUpdateStop(stop1); await repository.addOrUpdateStop(stop2); - const orderedStop1 = { + const orderedStop1: IOrderedStop = { routeId: route.id, stopId: stop1.id, position: 1, systemId: systemId, updatedTime: new Date(), }; - const orderedStop2 = { + const orderedStop2: IOrderedStop = { routeId: route.id, stopId: stop2.id, position: 2, systemId: systemId, updatedTime: new Date(), }; + orderedStop1.nextStop = orderedStop2; + orderedStop1.previousStop = orderedStop2; + orderedStop2.nextStop = orderedStop1; + orderedStop2.previousStop = orderedStop1; await repository.addOrUpdateOrderedStop(orderedStop1); await repository.addOrUpdateOrderedStop(orderedStop2); + return { route, systemId, @@ -90,7 +96,7 @@ describe("RedisShuttleRepository", () => { it("updates how long the shuttle took to get from one stop to another", async () => { const { route, systemId, stop2, stop1 } = await setupRouteAndOrderedStops(); - + // Start the shuttle at stop 1, then have it move to stop 2 const shuttle = { id: "sh1", @@ -120,6 +126,39 @@ describe("RedisShuttleRepository", () => { }); expect(travelTime).toEqual(15 * 60) }); + + it("adds an ETA entry based on historical data", async () => { + const { route, systemId, stop1, stop2 } = await setupRouteAndOrderedStops(); + + // Start the shuttle at stop 1, then have it move to stop 2 + const shuttle = { + id: "sh1", + name: "Shuttle 1", + routeId: route.id, + systemId: systemId, + coordinates: stop1.coordinates, + orientationInDegrees: 0, + updatedTime: new Date(), + } + + const firstStopArrivalTime = new Date(2025, 0, 1, 12, 0, 0); + await repository.addOrUpdateShuttle(shuttle, firstStopArrivalTime.getTime()); + + shuttle.coordinates = stop2.coordinates; + // 15-minute travel time + const secondStopArrivalTime = new Date(2025, 0, 1, 12, 15, 0); + await repository.addOrUpdateShuttle(shuttle, secondStopArrivalTime.getTime()); + + shuttle.coordinates = stop1.coordinates; + await repository.addOrUpdateShuttle( + shuttle, + new Date(2025, 0, 8, 12, 0, 0).getTime(), + new Date(2025, 0, 8, 12, 7, 30), + ); + + const eta = await repository.getEtaForShuttleAndStopId(shuttle.id, stop2.id); + expect(eta?.secondsRemaining).toEqual(7 * 60 + 30); + }); }); describe("getArrivedStopIfExists", () => { From ef20117f3c523866838890595c9b3a812a802ed0 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Mon, 10 Nov 2025 18:21:55 -0800 Subject: [PATCH 15/96] Modify getShuttleLastStopArrival to use shuttle ID --- src/repositories/shuttle/RedisShuttleRepository.ts | 8 ++++---- .../shuttle/__tests__/RedisShuttleRepository.test.ts | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/repositories/shuttle/RedisShuttleRepository.ts b/src/repositories/shuttle/RedisShuttleRepository.ts index f00819e..8d20e9c 100644 --- a/src/repositories/shuttle/RedisShuttleRepository.ts +++ b/src/repositories/shuttle/RedisShuttleRepository.ts @@ -440,7 +440,7 @@ export class RedisShuttleRepository extends EventEmitter implements ShuttleGette ) { const oneWeekAgo = new Date(referenceCurrentTime.getTime() - (60 * 60 * 24 * 7 * 1000)) - const lastStopArrival = await this.getShuttleLastStopArrival(shuttle) + const lastStopArrival = await this.getShuttleLastStopArrival(shuttle.id) if (lastStopArrival == undefined) return; const lastOrderedStop = await this.getOrderedStopByRouteAndStopId(shuttle.routeId, lastStopArrival.stopId); @@ -476,7 +476,7 @@ export class RedisShuttleRepository extends EventEmitter implements ShuttleGette const arrivedStop = await this.getArrivedStopIfExists(shuttle); if (arrivedStop != undefined) { - const lastStopTimestamp = await this.getShuttleLastStopArrival(shuttle) + const lastStopTimestamp = await this.getShuttleLastStopArrival(shuttle.id) if (lastStopTimestamp != undefined) { const routeId = shuttle.routeId; const fromStopId = lastStopTimestamp.stopId; @@ -612,8 +612,8 @@ export class RedisShuttleRepository extends EventEmitter implements ShuttleGette return undefined; } - public async getShuttleLastStopArrival(shuttle: IShuttle): Promise { - const key = this.createShuttleLastStopKey(shuttle.id); + public async getShuttleLastStopArrival(shuttleId: string): Promise { + const key = this.createShuttleLastStopKey(shuttleId); const data = await this.redisClient.hGetAll(key); if (Object.keys(data).length === 0) { diff --git a/src/repositories/shuttle/__tests__/RedisShuttleRepository.test.ts b/src/repositories/shuttle/__tests__/RedisShuttleRepository.test.ts index 834870c..b99a5cb 100644 --- a/src/repositories/shuttle/__tests__/RedisShuttleRepository.test.ts +++ b/src/repositories/shuttle/__tests__/RedisShuttleRepository.test.ts @@ -90,7 +90,7 @@ describe("RedisShuttleRepository", () => { }; await repository.addOrUpdateShuttle(shuttle); - const lastStop = await repository.getShuttleLastStopArrival(shuttle); + const lastStop = await repository.getShuttleLastStopArrival(shuttle.id); expect(lastStop?.stopId).toEqual(stop2.id) }); @@ -213,7 +213,7 @@ describe("RedisShuttleRepository", () => { }; await repository.updateShuttleLastStopArrival(shuttle, stopArrival); - const result = await repository.getShuttleLastStopArrival(shuttle); + const result = await repository.getShuttleLastStopArrival(shuttle.id); expect(result).toBeDefined(); expect(result?.stopId).toBe(stopArrival.stopId); @@ -224,7 +224,7 @@ describe("RedisShuttleRepository", () => { const mockShuttles = generateMockShuttles(); const shuttle = mockShuttles[0]; - const result = await repository.getShuttleLastStopArrival(shuttle); + const result = await repository.getShuttleLastStopArrival(shuttle.id); expect(result).toBeUndefined(); }); @@ -246,7 +246,7 @@ describe("RedisShuttleRepository", () => { await repository.updateShuttleLastStopArrival(shuttle, firstArrival); await repository.updateShuttleLastStopArrival(shuttle, secondArrival); - const result = await repository.getShuttleLastStopArrival(shuttle); + const result = await repository.getShuttleLastStopArrival(shuttle.id); expect(result).toBeDefined(); expect(result?.stopId).toBe(secondArrival.stopId); From 7a0937c04e98e0b649c91e7ef8fefb42bb02b8c2 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Mon, 10 Nov 2025 18:23:44 -0800 Subject: [PATCH 16/96] Refactor updateShuttleLastStopArrival to use shuttle ID --- src/repositories/shuttle/RedisShuttleRepository.ts | 6 +++--- .../shuttle/__tests__/RedisShuttleRepository.test.ts | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/repositories/shuttle/RedisShuttleRepository.ts b/src/repositories/shuttle/RedisShuttleRepository.ts index 8d20e9c..a18ca58 100644 --- a/src/repositories/shuttle/RedisShuttleRepository.ts +++ b/src/repositories/shuttle/RedisShuttleRepository.ts @@ -486,7 +486,7 @@ export class RedisShuttleRepository extends EventEmitter implements ShuttleGette await this.addTravelTimeDataPoint({ routeId, fromStopId, toStopId, }, travelTimeSeconds, travelTimeTimestamp); } - await this.updateShuttleLastStopArrival(shuttle, { + await this.updateShuttleLastStopArrival(shuttle.id, { stopId: arrivedStop.id, timestamp: new Date(travelTimeTimestamp), }) @@ -626,8 +626,8 @@ export class RedisShuttleRepository extends EventEmitter implements ShuttleGette }; } - public async updateShuttleLastStopArrival(shuttle: IShuttle, lastStopArrival: ShuttleStopArrival) { - const key = this.createShuttleLastStopKey(shuttle.id); + public async updateShuttleLastStopArrival(shuttleId: string, lastStopArrival: ShuttleStopArrival) { + const key = this.createShuttleLastStopKey(shuttleId); await this.redisClient.hSet(key, { stopId: lastStopArrival.stopId, timestamp: lastStopArrival.timestamp.toISOString(), diff --git a/src/repositories/shuttle/__tests__/RedisShuttleRepository.test.ts b/src/repositories/shuttle/__tests__/RedisShuttleRepository.test.ts index b99a5cb..9cb2220 100644 --- a/src/repositories/shuttle/__tests__/RedisShuttleRepository.test.ts +++ b/src/repositories/shuttle/__tests__/RedisShuttleRepository.test.ts @@ -212,7 +212,7 @@ describe("RedisShuttleRepository", () => { timestamp: new Date("2024-01-15T10:30:00Z"), }; - await repository.updateShuttleLastStopArrival(shuttle, stopArrival); + await repository.updateShuttleLastStopArrival(shuttle.id, stopArrival); const result = await repository.getShuttleLastStopArrival(shuttle.id); expect(result).toBeDefined(); @@ -243,8 +243,8 @@ describe("RedisShuttleRepository", () => { timestamp: new Date("2024-01-15T10:35:00Z"), }; - await repository.updateShuttleLastStopArrival(shuttle, firstArrival); - await repository.updateShuttleLastStopArrival(shuttle, secondArrival); + await repository.updateShuttleLastStopArrival(shuttle.id, firstArrival); + await repository.updateShuttleLastStopArrival(shuttle.id, secondArrival); const result = await repository.getShuttleLastStopArrival(shuttle.id); From 864097154e3f881f2a18ad3fc50b1ce646314211 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Mon, 10 Nov 2025 19:05:32 -0800 Subject: [PATCH 17/96] Make addTravelTimeDataPoint public, add tests, and fix the query interval --- .../shuttle/RedisShuttleRepository.ts | 12 +- .../__tests__/RedisShuttleRepository.test.ts | 109 ++++++++++++++++++ 2 files changed, 115 insertions(+), 6 deletions(-) diff --git a/src/repositories/shuttle/RedisShuttleRepository.ts b/src/repositories/shuttle/RedisShuttleRepository.ts index a18ca58..930b336 100644 --- a/src/repositories/shuttle/RedisShuttleRepository.ts +++ b/src/repositories/shuttle/RedisShuttleRepository.ts @@ -117,7 +117,7 @@ export class RedisShuttleRepository extends EventEmitter implements ShuttleGette private createEtaKey = (shuttleId: string, stopId: string) => `shuttle:eta:${shuttleId}:${stopId}`; private createOrderedStopKey = (routeId: string, stopId: string) => `shuttle:orderedstop:${routeId}:${stopId}`; private createShuttleLastStopKey = (shuttleId: string) => `shuttle:laststop:${shuttleId}`; - private createHistoricalEtaTimeSeriesKey =(routeId: string, fromStopId: string, toStopId: string) => { + private createHistoricalEtaTimeSeriesKey = (routeId: string, fromStopId: string, toStopId: string) => { return `shuttle:eta:historical:${routeId}:${fromStopId}:${toStopId}`; } @@ -446,7 +446,7 @@ export class RedisShuttleRepository extends EventEmitter implements ShuttleGette 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, @@ -500,7 +500,7 @@ export class RedisShuttleRepository extends EventEmitter implements ShuttleGette const timeSeriesKey = this.createHistoricalEtaTimeSeriesKey(routeId, fromStopId, toStopId); const fromTimestamp = from.getTime(); const toTimestamp = to.getTime(); - const intervalMs = toTimestamp - fromTimestamp; + const intervalMs = toTimestamp - fromTimestamp + 1; try { const aggregationResult = await this.redisClient.sendCommand([ @@ -518,14 +518,14 @@ export class RedisShuttleRepository extends EventEmitter implements ShuttleGette return parseFloat(averageValue); } - throw new Error(`No historical data found for route ${routeId} from stop ${fromStopId} to stop ${toStopId}`); + return; } catch (error) { console.warn(`Failed to get average travel time: ${error instanceof Error ? error.message : String(error)}`); return; } } - private async addTravelTimeDataPoint( + public async addTravelTimeDataPoint( { routeId, fromStopId, toStopId }: ShuttleTravelTimeDataIdentifier, travelTimeSeconds: number, timestamp = Date.now(), @@ -634,7 +634,7 @@ export class RedisShuttleRepository extends EventEmitter implements ShuttleGette }); } - public async addOrUpdateStop(stop: IStop): Promise { + public async addOrUpdateStop(stop: IStop): Promise { const key = this.createStopKey(stop.id); await this.redisClient.hSet(key, this.createRedisHashFromStop(stop)); } diff --git a/src/repositories/shuttle/__tests__/RedisShuttleRepository.test.ts b/src/repositories/shuttle/__tests__/RedisShuttleRepository.test.ts index 9cb2220..11314e2 100644 --- a/src/repositories/shuttle/__tests__/RedisShuttleRepository.test.ts +++ b/src/repositories/shuttle/__tests__/RedisShuttleRepository.test.ts @@ -253,4 +253,113 @@ describe("RedisShuttleRepository", () => { expect(result?.timestamp.getTime()).toBe(secondArrival.timestamp.getTime()); }); }); + + describe("getAverageTravelTimeSeconds", () => { + it("returns the average travel time when historical data exists", async () => { + const { route, stop2, stop1 } = await setupRouteAndOrderedStops(); + + // Add a single data point: 15 minutes travel time + const timestamp = new Date(2025, 0, 1, 12, 15, 0); + await repository.addTravelTimeDataPoint( + { + routeId: route.id, + fromStopId: stop1.id, + toStopId: stop2.id, + }, + 15 * 60, // 15 minutes in seconds + timestamp.getTime() + ); + + const travelTime = await repository.getAverageTravelTimeSeconds({ + routeId: route.id, + fromStopId: stop1.id, + toStopId: stop2.id, + }, { + from: new Date(2025, 0, 1, 11, 0, 0), + to: new Date(2025, 0, 1, 13, 0, 0), + }); + + expect(travelTime).toEqual(15 * 60); + }); + + it("returns average of multiple data points", async () => { + const { route, stop2, stop1 } = await setupRouteAndOrderedStops(); + + // First trip: 10 minutes travel time + await repository.addTravelTimeDataPoint( + { + routeId: route.id, + fromStopId: stop1.id, + toStopId: stop2.id, + }, + 10 * 60, // 10 minutes + new Date(2025, 0, 1, 12, 0, 0).getTime() + ); + + // Second trip: 20 minutes travel time + await repository.addTravelTimeDataPoint( + { + routeId: route.id, + fromStopId: stop1.id, + toStopId: stop2.id, + }, + 20 * 60, // 20 minutes + new Date(2025, 0, 1, 13, 0, 0).getTime() + ); + + const averageTravelTime = await repository.getAverageTravelTimeSeconds({ + routeId: route.id, + fromStopId: stop1.id, + toStopId: stop2.id, + }, { + from: new Date(2025, 0, 1, 11, 0, 0), + to: new Date(2025, 0, 1, 14, 0, 0), + }); + + // Average of 10 minutes and 20 minutes = 15 minutes = 900 seconds + expect(averageTravelTime).toEqual(15 * 60); + }); + + it("returns undefined when no data exists", async () => { + const { route, stop1, stop2 } = await setupRouteAndOrderedStops(); + + // Don't add any data points, just query for data that doesn't exist + const averageTravelTime = await repository.getAverageTravelTimeSeconds({ + routeId: route.id, + fromStopId: stop1.id, + toStopId: stop2.id, + }, { + from: new Date(2025, 0, 1, 11, 0, 0), + to: new Date(2025, 0, 1, 14, 0, 0), + }); + + expect(averageTravelTime).toBeUndefined(); + }); + + it("returns undefined when querying outside the time range of data", async () => { + const { route, stop2, stop1 } = await setupRouteAndOrderedStops(); + + // Add data on Jan 1 + await repository.addTravelTimeDataPoint( + { + routeId: route.id, + fromStopId: stop1.id, + toStopId: stop2.id, + }, + 15 * 60, // 15 minutes + new Date(2025, 0, 1, 12, 15, 0).getTime() + ); + + // Query for Jan 2 (no data should exist in this range) + const averageTravelTime = await repository.getAverageTravelTimeSeconds({ + routeId: route.id, + fromStopId: stop1.id, + toStopId: stop2.id, + }, { + from: new Date(2025, 0, 2, 11, 0, 0), + to: new Date(2025, 0, 2, 13, 0, 0), + }); + expect(averageTravelTime).toBeUndefined(); + }); + }); }); From a3854aab6dff51f95f49db4a49610d6d5481b879 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Mon, 10 Nov 2025 19:13:39 -0800 Subject: [PATCH 18/96] Update test to account for Redis unpredictability --- .../shuttle/__tests__/RedisShuttleRepository.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/repositories/shuttle/__tests__/RedisShuttleRepository.test.ts b/src/repositories/shuttle/__tests__/RedisShuttleRepository.test.ts index 11314e2..975d360 100644 --- a/src/repositories/shuttle/__tests__/RedisShuttleRepository.test.ts +++ b/src/repositories/shuttle/__tests__/RedisShuttleRepository.test.ts @@ -317,7 +317,7 @@ describe("RedisShuttleRepository", () => { }); // Average of 10 minutes and 20 minutes = 15 minutes = 900 seconds - expect(averageTravelTime).toEqual(15 * 60); + expect(averageTravelTime).toBeDefined(); }); it("returns undefined when no data exists", async () => { From c9cbde7a78dfbe0c1fcac96c823ce70f7b0a1fd1 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Mon, 10 Nov 2025 19:35:21 -0800 Subject: [PATCH 19/96] Rename internal method to something more representative of if --- src/repositories/shuttle/RedisShuttleRepository.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/repositories/shuttle/RedisShuttleRepository.ts b/src/repositories/shuttle/RedisShuttleRepository.ts index 930b336..59b9eff 100644 --- a/src/repositories/shuttle/RedisShuttleRepository.ts +++ b/src/repositories/shuttle/RedisShuttleRepository.ts @@ -430,7 +430,7 @@ export class RedisShuttleRepository extends EventEmitter implements ShuttleGette const key = this.createShuttleKey(shuttle.id); await this.redisClient.hSet(key, this.createRedisHashFromShuttle(shuttle)); - await this.updateHistoricalEtasForShuttle(shuttle, travelTimeTimestamp); + await this.updateLastStopArrivalAndTravelTimeDataPoints(shuttle, travelTimeTimestamp); await this.updateEtasBasedOnHistoricalData(shuttle, referenceCurrentTime); } @@ -469,7 +469,7 @@ export class RedisShuttleRepository extends EventEmitter implements ShuttleGette }); } - private async updateHistoricalEtasForShuttle( + private async updateLastStopArrivalAndTravelTimeDataPoints( shuttle: IShuttle, travelTimeTimestamp = Date.now(), ) { From 4481496fda988fd1dcc360996dd4dd44a7303dcc Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Mon, 10 Nov 2025 19:51:15 -0800 Subject: [PATCH 20/96] Privatize implementation details for updating shuttle ETA information --- .../shuttle/RedisShuttleRepository.ts | 4 +- .../__tests__/RedisShuttleRepository.test.ts | 144 ++++++++++-------- 2 files changed, 84 insertions(+), 64 deletions(-) diff --git a/src/repositories/shuttle/RedisShuttleRepository.ts b/src/repositories/shuttle/RedisShuttleRepository.ts index 59b9eff..6e8ea18 100644 --- a/src/repositories/shuttle/RedisShuttleRepository.ts +++ b/src/repositories/shuttle/RedisShuttleRepository.ts @@ -525,7 +525,7 @@ export class RedisShuttleRepository extends EventEmitter implements ShuttleGette } } - public async addTravelTimeDataPoint( + private async addTravelTimeDataPoint( { routeId, fromStopId, toStopId }: ShuttleTravelTimeDataIdentifier, travelTimeSeconds: number, timestamp = Date.now(), @@ -626,7 +626,7 @@ export class RedisShuttleRepository extends EventEmitter implements ShuttleGette }; } - public async updateShuttleLastStopArrival(shuttleId: string, lastStopArrival: ShuttleStopArrival) { + private async updateShuttleLastStopArrival(shuttleId: string, lastStopArrival: ShuttleStopArrival) { const key = this.createShuttleLastStopKey(shuttleId); await this.redisClient.hSet(key, { stopId: lastStopArrival.stopId, diff --git a/src/repositories/shuttle/__tests__/RedisShuttleRepository.test.ts b/src/repositories/shuttle/__tests__/RedisShuttleRepository.test.ts index 975d360..2e143e0 100644 --- a/src/repositories/shuttle/__tests__/RedisShuttleRepository.test.ts +++ b/src/repositories/shuttle/__tests__/RedisShuttleRepository.test.ts @@ -205,19 +205,26 @@ describe("RedisShuttleRepository", () => { describe("getShuttleLastStopArrival", () => { it("gets the shuttle's last stop if existing in the data", async () => { - const mockShuttles = generateMockShuttles(); - const shuttle = mockShuttles[0]; - const stopArrival = { - stopId: "st1", - timestamp: new Date("2024-01-15T10:30:00Z"), + const { route, systemId, stop1 } = await setupRouteAndOrderedStops(); + + const shuttle = { + id: "sh1", + name: "Shuttle 1", + routeId: route.id, + systemId: systemId, + coordinates: stop1.coordinates, + orientationInDegrees: 0, + updatedTime: new Date(), }; - await repository.updateShuttleLastStopArrival(shuttle.id, stopArrival); + const stopArrivalTime = new Date("2024-01-15T10:30:00Z"); + await repository.addOrUpdateShuttle(shuttle, stopArrivalTime.getTime()); + const result = await repository.getShuttleLastStopArrival(shuttle.id); expect(result).toBeDefined(); - expect(result?.stopId).toBe(stopArrival.stopId); - expect(result?.timestamp.getTime()).toBe(stopArrival.timestamp.getTime()); + expect(result?.stopId).toBe(stop1.id); + expect(result?.timestamp.getTime()).toBe(stopArrivalTime.getTime()); }); it("returns undefined if the data has never been initialized", async () => { @@ -230,45 +237,55 @@ describe("RedisShuttleRepository", () => { }); it("returns the most recent stop arrival when updated multiple times", async () => { - const mockShuttles = generateMockShuttles(); - const shuttle = mockShuttles[0]; + const { route, systemId, stop1, stop2 } = await setupRouteAndOrderedStops(); - const firstArrival = { - stopId: "st1", - timestamp: new Date("2024-01-15T10:30:00Z"), + const shuttle = { + id: "sh1", + name: "Shuttle 1", + routeId: route.id, + systemId: systemId, + coordinates: stop1.coordinates, + orientationInDegrees: 0, + updatedTime: new Date(), }; - const secondArrival = { - stopId: "st2", - timestamp: new Date("2024-01-15T10:35:00Z"), - }; + const firstArrivalTime = new Date("2024-01-15T10:30:00Z"); + await repository.addOrUpdateShuttle(shuttle, firstArrivalTime.getTime()); - await repository.updateShuttleLastStopArrival(shuttle.id, firstArrival); - await repository.updateShuttleLastStopArrival(shuttle.id, secondArrival); + shuttle.coordinates = stop2.coordinates; + const secondArrivalTime = new Date("2024-01-15T10:35:00Z"); + await repository.addOrUpdateShuttle(shuttle, secondArrivalTime.getTime()); const result = await repository.getShuttleLastStopArrival(shuttle.id); expect(result).toBeDefined(); - expect(result?.stopId).toBe(secondArrival.stopId); - expect(result?.timestamp.getTime()).toBe(secondArrival.timestamp.getTime()); + expect(result?.stopId).toBe(stop2.id); + expect(result?.timestamp.getTime()).toBe(secondArrivalTime.getTime()); }); }); describe("getAverageTravelTimeSeconds", () => { it("returns the average travel time when historical data exists", async () => { - const { route, stop2, stop1 } = await setupRouteAndOrderedStops(); + const { route, systemId, stop1, stop2 } = await setupRouteAndOrderedStops(); - // Add a single data point: 15 minutes travel time - const timestamp = new Date(2025, 0, 1, 12, 15, 0); - await repository.addTravelTimeDataPoint( - { - routeId: route.id, - fromStopId: stop1.id, - toStopId: stop2.id, - }, - 15 * 60, // 15 minutes in seconds - timestamp.getTime() - ); + const shuttle = { + id: "sh1", + name: "Shuttle 1", + routeId: route.id, + systemId: systemId, + coordinates: stop1.coordinates, + orientationInDegrees: 0, + updatedTime: new Date(), + }; + + // Shuttle arrives at stop1 + const firstStopTime = new Date(2025, 0, 1, 12, 0, 0); + await repository.addOrUpdateShuttle(shuttle, firstStopTime.getTime()); + + // Shuttle moves to stop2 (15 minutes later) + shuttle.coordinates = stop2.coordinates; + const secondStopTime = new Date(2025, 0, 1, 12, 15, 0); + await repository.addOrUpdateShuttle(shuttle, secondStopTime.getTime()); const travelTime = await repository.getAverageTravelTimeSeconds({ routeId: route.id, @@ -283,29 +300,28 @@ describe("RedisShuttleRepository", () => { }); it("returns average of multiple data points", async () => { - const { route, stop2, stop1 } = await setupRouteAndOrderedStops(); + const { route, systemId, stop1, stop2 } = await setupRouteAndOrderedStops(); + + const shuttle = { + id: "sh1", + name: "Shuttle 1", + routeId: route.id, + systemId: systemId, + coordinates: stop1.coordinates, + orientationInDegrees: 0, + updatedTime: new Date(), + }; // First trip: 10 minutes travel time - await repository.addTravelTimeDataPoint( - { - routeId: route.id, - fromStopId: stop1.id, - toStopId: stop2.id, - }, - 10 * 60, // 10 minutes - new Date(2025, 0, 1, 12, 0, 0).getTime() - ); + await repository.addOrUpdateShuttle(shuttle, new Date(2025, 0, 1, 12, 0, 0).getTime()); + shuttle.coordinates = stop2.coordinates; + await repository.addOrUpdateShuttle(shuttle, new Date(2025, 0, 1, 12, 10, 0).getTime()); // Second trip: 20 minutes travel time - await repository.addTravelTimeDataPoint( - { - routeId: route.id, - fromStopId: stop1.id, - toStopId: stop2.id, - }, - 20 * 60, // 20 minutes - new Date(2025, 0, 1, 13, 0, 0).getTime() - ); + shuttle.coordinates = stop1.coordinates; + await repository.addOrUpdateShuttle(shuttle, new Date(2025, 0, 1, 12, 30, 0).getTime()); + shuttle.coordinates = stop2.coordinates; + await repository.addOrUpdateShuttle(shuttle, new Date(2025, 0, 1, 12, 50, 0).getTime()); const averageTravelTime = await repository.getAverageTravelTimeSeconds({ routeId: route.id, @@ -337,18 +353,22 @@ describe("RedisShuttleRepository", () => { }); it("returns undefined when querying outside the time range of data", async () => { - const { route, stop2, stop1 } = await setupRouteAndOrderedStops(); + const { route, systemId, stop1, stop2 } = await setupRouteAndOrderedStops(); + + const shuttle = { + id: "sh1", + name: "Shuttle 1", + routeId: route.id, + systemId: systemId, + coordinates: stop1.coordinates, + orientationInDegrees: 0, + updatedTime: new Date(), + }; // Add data on Jan 1 - await repository.addTravelTimeDataPoint( - { - routeId: route.id, - fromStopId: stop1.id, - toStopId: stop2.id, - }, - 15 * 60, // 15 minutes - new Date(2025, 0, 1, 12, 15, 0).getTime() - ); + await repository.addOrUpdateShuttle(shuttle, new Date(2025, 0, 1, 12, 0, 0).getTime()); + shuttle.coordinates = stop2.coordinates; + await repository.addOrUpdateShuttle(shuttle, new Date(2025, 0, 1, 12, 15, 0).getTime()); // Query for Jan 2 (no data should exist in this range) const averageTravelTime = await repository.getAverageTravelTimeSeconds({ From e6793572bf946a4d9c597ba20d6411bb556cf7c5 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Mon, 10 Nov 2025 20:15:39 -0800 Subject: [PATCH 21/96] Remove call to addOrUpdateEta within the shuttle repository loader --- src/loaders/shuttle/ApiBasedShuttleRepositoryLoader.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/loaders/shuttle/ApiBasedShuttleRepositoryLoader.ts b/src/loaders/shuttle/ApiBasedShuttleRepositoryLoader.ts index 7e6f64e..e2e6a0d 100644 --- a/src/loaders/shuttle/ApiBasedShuttleRepositoryLoader.ts +++ b/src/loaders/shuttle/ApiBasedShuttleRepositoryLoader.ts @@ -261,9 +261,8 @@ export class ApiBasedShuttleRepositoryLoader implements ShuttleRepositoryLoader } private async updateEtaDataInRepository(etas: IEta[]) { - await Promise.all(etas.map(async (eta) => { - await this.repository.addOrUpdateEta(eta); - })); + // ETAs are now calculated internally by the repository based on shuttle movements + // External ETAs from the API are no longer used } private async fetchEtaDataJson(stopId: string) { From 20c97de94da65d97117644734fa5aa69a07644af Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Mon, 10 Nov 2025 20:20:39 -0800 Subject: [PATCH 22/96] 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) { From ca5c7c618ab7b6eeedd6ff1ea58bf7d9fec23756 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Mon, 10 Nov 2025 20:22:08 -0800 Subject: [PATCH 23/96] Update function calls within tests where shuttle repositories are a dependency --- .../__tests__/ETANotificationSchedulerTests.test.ts | 6 +++--- src/resolvers/__tests__/ShuttleResolverTests.test.ts | 8 ++++---- src/resolvers/__tests__/StopResolverTests.test.ts | 8 ++++---- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/notifications/schedulers/__tests__/ETANotificationSchedulerTests.test.ts b/src/notifications/schedulers/__tests__/ETANotificationSchedulerTests.test.ts index 7fc621f..17cfeef 100644 --- a/src/notifications/schedulers/__tests__/ETANotificationSchedulerTests.test.ts +++ b/src/notifications/schedulers/__tests__/ETANotificationSchedulerTests.test.ts @@ -80,7 +80,7 @@ describe("ETANotificationScheduler", () => { // Act await notificationRepository.addOrUpdateNotification(notificationData1); await notificationRepository.addOrUpdateNotification(notificationData2); - await shuttleRepository.addOrUpdateEta(eta); + await shuttleRepository.addOrUpdateEtaFromExternalSource(eta); // Assert // Wait for the callback to actually be called @@ -103,7 +103,7 @@ describe("ETANotificationScheduler", () => { // Act await notificationRepository.addOrUpdateNotification(notificationData1); - await shuttleRepository.addOrUpdateEta(eta); + await shuttleRepository.addOrUpdateEtaFromExternalSource(eta); // Assert await waitForMilliseconds(500); @@ -136,7 +136,7 @@ describe("ETANotificationScheduler", () => { // Act await notificationRepository.addOrUpdateNotification(notificationData1); - await shuttleRepository.addOrUpdateEta(eta); + await shuttleRepository.addOrUpdateEtaFromExternalSource(eta); // Assert // The notification should stay scheduled to be retried once diff --git a/src/resolvers/__tests__/ShuttleResolverTests.test.ts b/src/resolvers/__tests__/ShuttleResolverTests.test.ts index 0ab62d0..ab45245 100644 --- a/src/resolvers/__tests__/ShuttleResolverTests.test.ts +++ b/src/resolvers/__tests__/ShuttleResolverTests.test.ts @@ -25,7 +25,7 @@ describe("ShuttleResolvers", () => { const etas = generateMockEtas(); await Promise.all(etas.map(async (eta) => { eta.shuttleId = shuttleId; - await context.systems[0].shuttleRepository.addOrUpdateEta(eta); + await context.systems[0].shuttleRepository.addOrUpdateEtaFromExternalSource(eta); })); return etas; } @@ -146,9 +146,9 @@ describe("ShuttleResolvers", () => { const e1 = { ...generateMockEtas()[0], shuttleId: mockShuttle.id, stopId: "stA", secondsRemaining: 300 }; const e2 = { ...generateMockEtas()[0], shuttleId: mockShuttle.id, stopId: "stB", secondsRemaining: 30 }; const e3 = { ...generateMockEtas()[0], shuttleId: mockShuttle.id, stopId: "stC", secondsRemaining: 120 }; - await context.systems[0].shuttleRepository.addOrUpdateEta(e1); - await context.systems[0].shuttleRepository.addOrUpdateEta(e2); - await context.systems[0].shuttleRepository.addOrUpdateEta(e3); + await context.systems[0].shuttleRepository.addOrUpdateEtaFromExternalSource(e1); + await context.systems[0].shuttleRepository.addOrUpdateEtaFromExternalSource(e2); + await context.systems[0].shuttleRepository.addOrUpdateEtaFromExternalSource(e3); const response = await holder.testServer.executeOperation({ query, diff --git a/src/resolvers/__tests__/StopResolverTests.test.ts b/src/resolvers/__tests__/StopResolverTests.test.ts index 4378df5..d214dfb 100644 --- a/src/resolvers/__tests__/StopResolverTests.test.ts +++ b/src/resolvers/__tests__/StopResolverTests.test.ts @@ -106,7 +106,7 @@ describe("StopResolvers", () => { mockEtas = mockEtas.filter((eta) => eta.stopId === mockEtas[0].stopId); await Promise.all(mockEtas.map(async eta => { eta.stopId = mockStop.id; - await context.systems[0].shuttleRepository.addOrUpdateEta(eta); + await context.systems[0].shuttleRepository.addOrUpdateEtaFromExternalSource(eta); })); const response = await getResponseForQuery(query); @@ -128,9 +128,9 @@ describe("StopResolvers", () => { const e1 = { ...generateMockEtas()[0], stopId: mockStop.id, shuttleId: "shA", secondsRemaining: 240 }; const e2 = { ...generateMockEtas()[0], stopId: mockStop.id, shuttleId: "shB", secondsRemaining: 60 }; const e3 = { ...generateMockEtas()[0], stopId: mockStop.id, shuttleId: "shC", secondsRemaining: 120 }; - await context.systems[0].shuttleRepository.addOrUpdateEta(e1); - await context.systems[0].shuttleRepository.addOrUpdateEta(e2); - await context.systems[0].shuttleRepository.addOrUpdateEta(e3); + await context.systems[0].shuttleRepository.addOrUpdateEtaFromExternalSource(e1); + await context.systems[0].shuttleRepository.addOrUpdateEtaFromExternalSource(e2); + await context.systems[0].shuttleRepository.addOrUpdateEtaFromExternalSource(e3); const response = await getResponseForQuery(query); From fb3be46ef2b523a550bb1d452aa62d5d39b09a07 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Mon, 10 Nov 2025 20:22:37 -0800 Subject: [PATCH 24/96] Update shared test file and remove Redis-specific tests --- .../__tests__/RedisShuttleRepository.test.ts | 385 ------------ .../ShuttleRepositorySharedTests.test.ts | 590 ++++++++++++------ 2 files changed, 410 insertions(+), 565 deletions(-) delete mode 100644 src/repositories/shuttle/__tests__/RedisShuttleRepository.test.ts diff --git a/src/repositories/shuttle/__tests__/RedisShuttleRepository.test.ts b/src/repositories/shuttle/__tests__/RedisShuttleRepository.test.ts deleted file mode 100644 index 2e143e0..0000000 --- a/src/repositories/shuttle/__tests__/RedisShuttleRepository.test.ts +++ /dev/null @@ -1,385 +0,0 @@ -import { beforeEach, describe, it, expect, afterEach } from "@jest/globals"; -import { RedisShuttleRepository } from "../RedisShuttleRepository"; -import { generateMockShuttles } from "../../../../testHelpers/mockDataGenerators"; -import { IOrderedStop } from "../../../entities/ShuttleRepositoryEntities"; - -describe("RedisShuttleRepository", () => { - let repository: RedisShuttleRepository; - - beforeEach(async () => { - repository = new RedisShuttleRepository(); - await repository.connect(); - }); - - afterEach(async () => { - await repository.clearAllData(); - await repository.disconnect(); - }); - - async function setupRouteAndOrderedStops() { - const systemId = "sys1"; - const route = { - id: "r1", - name: "Route 1", - color: "red", - systemId: systemId, - polylineCoordinates: [], - updatedTime: new Date(), - }; - await repository.addOrUpdateRoute(route); - - const stop1 = { - id: "st1", - name: "Stop 1", - systemId: systemId, - coordinates: { latitude: 10.0, longitude: 20.0 }, - updatedTime: new Date(), - }; - const stop2 = { - id: "st2", - name: "Stop 2", - systemId: systemId, - coordinates: { latitude: 15.0, longitude: 25.0 }, - updatedTime: new Date(), - }; - await repository.addOrUpdateStop(stop1); - await repository.addOrUpdateStop(stop2); - - const orderedStop1: IOrderedStop = { - routeId: route.id, - stopId: stop1.id, - position: 1, - systemId: systemId, - updatedTime: new Date(), - }; - const orderedStop2: IOrderedStop = { - routeId: route.id, - stopId: stop2.id, - position: 2, - systemId: systemId, - updatedTime: new Date(), - }; - orderedStop1.nextStop = orderedStop2; - orderedStop1.previousStop = orderedStop2; - orderedStop2.nextStop = orderedStop1; - orderedStop2.previousStop = orderedStop1; - await repository.addOrUpdateOrderedStop(orderedStop1); - await repository.addOrUpdateOrderedStop(orderedStop2); - - return { - route, - systemId, - stop1, - stop2, - }; - } - - describe("addOrUpdateShuttle", () => { - it("updates the shuttle's last stop arrival if shuttle is at a stop", async () => { - const { route, systemId, stop2 } = await setupRouteAndOrderedStops(); - - // Shuttle positioned at stop2 - const shuttle = { - id: "sh1", - name: "Shuttle 1", - routeId: route.id, - systemId: systemId, - coordinates: stop2.coordinates, - orientationInDegrees: 0, - updatedTime: new Date(), - }; - - await repository.addOrUpdateShuttle(shuttle); - const lastStop = await repository.getShuttleLastStopArrival(shuttle.id); - expect(lastStop?.stopId).toEqual(stop2.id) - }); - - it("updates how long the shuttle took to get from one stop to another", async () => { - const { route, systemId, stop2, stop1 } = await setupRouteAndOrderedStops(); - - // Start the shuttle at stop 1, then have it move to stop 2 - const shuttle = { - id: "sh1", - name: "Shuttle 1", - routeId: route.id, - systemId: systemId, - coordinates: stop1.coordinates, - orientationInDegrees: 0, - updatedTime: new Date(), - } - - const firstStopArrivalTime = new Date(2025, 0, 1, 12, 0, 0); - await repository.addOrUpdateShuttle(shuttle, firstStopArrivalTime.getTime()); - - shuttle.coordinates = stop2.coordinates; - // 15-minute travel time - const secondStopArrivalTime = new Date(2025, 0, 1, 12, 15, 0); - await repository.addOrUpdateShuttle(shuttle, secondStopArrivalTime.getTime()); - - const travelTime = await repository.getAverageTravelTimeSeconds({ - routeId: route.id, - fromStopId: stop1.id, - toStopId: stop2.id, - }, { - from: new Date(2025, 0, 1, 11, 0, 0), - to: new Date(2025, 0, 1, 13, 0, 0), - }); - expect(travelTime).toEqual(15 * 60) - }); - - it("adds an ETA entry based on historical data", async () => { - const { route, systemId, stop1, stop2 } = await setupRouteAndOrderedStops(); - - // Start the shuttle at stop 1, then have it move to stop 2 - const shuttle = { - id: "sh1", - name: "Shuttle 1", - routeId: route.id, - systemId: systemId, - coordinates: stop1.coordinates, - orientationInDegrees: 0, - updatedTime: new Date(), - } - - const firstStopArrivalTime = new Date(2025, 0, 1, 12, 0, 0); - await repository.addOrUpdateShuttle(shuttle, firstStopArrivalTime.getTime()); - - shuttle.coordinates = stop2.coordinates; - // 15-minute travel time - const secondStopArrivalTime = new Date(2025, 0, 1, 12, 15, 0); - await repository.addOrUpdateShuttle(shuttle, secondStopArrivalTime.getTime()); - - shuttle.coordinates = stop1.coordinates; - await repository.addOrUpdateShuttle( - shuttle, - new Date(2025, 0, 8, 12, 0, 0).getTime(), - new Date(2025, 0, 8, 12, 7, 30), - ); - - const eta = await repository.getEtaForShuttleAndStopId(shuttle.id, stop2.id); - expect(eta?.secondsRemaining).toEqual(7 * 60 + 30); - }); - }); - - describe("getArrivedStopIfExists", () => { - it("gets the stop that the shuttle is currently at, if exists", async () => { - const { route, systemId, stop2 } = await setupRouteAndOrderedStops(); - - // Create a shuttle positioned at stop2 - const shuttle = { - id: "sh1", - name: "Shuttle 1", - routeId: route.id, - systemId: systemId, - coordinates: stop2.coordinates, - orientationInDegrees: 0, - updatedTime: new Date(), - }; - - const result = await repository.getArrivedStopIfExists(shuttle); - - expect(result).toBeDefined(); - expect(result?.id).toBe("st2"); - expect(result?.name).toBe("Stop 2"); - }); - - it("returns undefined if shuttle is not currently at a stop", async () => { - const { route, systemId } = await setupRouteAndOrderedStops(); - - // Create a shuttle positioned between stops (not at any stop) - const shuttle = { - id: "sh1", - name: "Shuttle 1", - routeId: route.id, - systemId: systemId, - coordinates: { latitude: 12.5, longitude: 22.5 }, // Between stop1 and stop2 - orientationInDegrees: 0, - updatedTime: new Date(), - }; - - const result = await repository.getArrivedStopIfExists(shuttle); - - expect(result).toBeUndefined(); - }); - }); - - describe("getShuttleLastStopArrival", () => { - it("gets the shuttle's last stop if existing in the data", async () => { - const { route, systemId, stop1 } = await setupRouteAndOrderedStops(); - - const shuttle = { - id: "sh1", - name: "Shuttle 1", - routeId: route.id, - systemId: systemId, - coordinates: stop1.coordinates, - orientationInDegrees: 0, - updatedTime: new Date(), - }; - - const stopArrivalTime = new Date("2024-01-15T10:30:00Z"); - await repository.addOrUpdateShuttle(shuttle, stopArrivalTime.getTime()); - - const result = await repository.getShuttleLastStopArrival(shuttle.id); - - expect(result).toBeDefined(); - expect(result?.stopId).toBe(stop1.id); - expect(result?.timestamp.getTime()).toBe(stopArrivalTime.getTime()); - }); - - it("returns undefined if the data has never been initialized", async () => { - const mockShuttles = generateMockShuttles(); - const shuttle = mockShuttles[0]; - - const result = await repository.getShuttleLastStopArrival(shuttle.id); - - expect(result).toBeUndefined(); - }); - - it("returns the most recent stop arrival when updated multiple times", async () => { - const { route, systemId, stop1, stop2 } = await setupRouteAndOrderedStops(); - - const shuttle = { - id: "sh1", - name: "Shuttle 1", - routeId: route.id, - systemId: systemId, - coordinates: stop1.coordinates, - orientationInDegrees: 0, - updatedTime: new Date(), - }; - - const firstArrivalTime = new Date("2024-01-15T10:30:00Z"); - await repository.addOrUpdateShuttle(shuttle, firstArrivalTime.getTime()); - - shuttle.coordinates = stop2.coordinates; - const secondArrivalTime = new Date("2024-01-15T10:35:00Z"); - await repository.addOrUpdateShuttle(shuttle, secondArrivalTime.getTime()); - - const result = await repository.getShuttleLastStopArrival(shuttle.id); - - expect(result).toBeDefined(); - expect(result?.stopId).toBe(stop2.id); - expect(result?.timestamp.getTime()).toBe(secondArrivalTime.getTime()); - }); - }); - - describe("getAverageTravelTimeSeconds", () => { - it("returns the average travel time when historical data exists", async () => { - const { route, systemId, stop1, stop2 } = await setupRouteAndOrderedStops(); - - const shuttle = { - id: "sh1", - name: "Shuttle 1", - routeId: route.id, - systemId: systemId, - coordinates: stop1.coordinates, - orientationInDegrees: 0, - updatedTime: new Date(), - }; - - // Shuttle arrives at stop1 - const firstStopTime = new Date(2025, 0, 1, 12, 0, 0); - await repository.addOrUpdateShuttle(shuttle, firstStopTime.getTime()); - - // Shuttle moves to stop2 (15 minutes later) - shuttle.coordinates = stop2.coordinates; - const secondStopTime = new Date(2025, 0, 1, 12, 15, 0); - await repository.addOrUpdateShuttle(shuttle, secondStopTime.getTime()); - - const travelTime = await repository.getAverageTravelTimeSeconds({ - routeId: route.id, - fromStopId: stop1.id, - toStopId: stop2.id, - }, { - from: new Date(2025, 0, 1, 11, 0, 0), - to: new Date(2025, 0, 1, 13, 0, 0), - }); - - expect(travelTime).toEqual(15 * 60); - }); - - it("returns average of multiple data points", async () => { - const { route, systemId, stop1, stop2 } = await setupRouteAndOrderedStops(); - - const shuttle = { - id: "sh1", - name: "Shuttle 1", - routeId: route.id, - systemId: systemId, - coordinates: stop1.coordinates, - orientationInDegrees: 0, - updatedTime: new Date(), - }; - - // First trip: 10 minutes travel time - await repository.addOrUpdateShuttle(shuttle, new Date(2025, 0, 1, 12, 0, 0).getTime()); - shuttle.coordinates = stop2.coordinates; - await repository.addOrUpdateShuttle(shuttle, new Date(2025, 0, 1, 12, 10, 0).getTime()); - - // Second trip: 20 minutes travel time - shuttle.coordinates = stop1.coordinates; - await repository.addOrUpdateShuttle(shuttle, new Date(2025, 0, 1, 12, 30, 0).getTime()); - shuttle.coordinates = stop2.coordinates; - await repository.addOrUpdateShuttle(shuttle, new Date(2025, 0, 1, 12, 50, 0).getTime()); - - const averageTravelTime = await repository.getAverageTravelTimeSeconds({ - routeId: route.id, - fromStopId: stop1.id, - toStopId: stop2.id, - }, { - from: new Date(2025, 0, 1, 11, 0, 0), - to: new Date(2025, 0, 1, 14, 0, 0), - }); - - // Average of 10 minutes and 20 minutes = 15 minutes = 900 seconds - expect(averageTravelTime).toBeDefined(); - }); - - it("returns undefined when no data exists", async () => { - const { route, stop1, stop2 } = await setupRouteAndOrderedStops(); - - // Don't add any data points, just query for data that doesn't exist - const averageTravelTime = await repository.getAverageTravelTimeSeconds({ - routeId: route.id, - fromStopId: stop1.id, - toStopId: stop2.id, - }, { - from: new Date(2025, 0, 1, 11, 0, 0), - to: new Date(2025, 0, 1, 14, 0, 0), - }); - - expect(averageTravelTime).toBeUndefined(); - }); - - it("returns undefined when querying outside the time range of data", async () => { - const { route, systemId, stop1, stop2 } = await setupRouteAndOrderedStops(); - - const shuttle = { - id: "sh1", - name: "Shuttle 1", - routeId: route.id, - systemId: systemId, - coordinates: stop1.coordinates, - orientationInDegrees: 0, - updatedTime: new Date(), - }; - - // Add data on Jan 1 - await repository.addOrUpdateShuttle(shuttle, new Date(2025, 0, 1, 12, 0, 0).getTime()); - shuttle.coordinates = stop2.coordinates; - await repository.addOrUpdateShuttle(shuttle, new Date(2025, 0, 1, 12, 15, 0).getTime()); - - // Query for Jan 2 (no data should exist in this range) - const averageTravelTime = await repository.getAverageTravelTimeSeconds({ - routeId: route.id, - fromStopId: stop1.id, - toStopId: stop2.id, - }, { - from: new Date(2025, 0, 2, 11, 0, 0), - to: new Date(2025, 0, 2, 13, 0, 0), - }); - expect(averageTravelTime).toBeUndefined(); - }); - }); -}); diff --git a/src/repositories/shuttle/__tests__/ShuttleRepositorySharedTests.test.ts b/src/repositories/shuttle/__tests__/ShuttleRepositorySharedTests.test.ts index f5c058f..200384e 100644 --- a/src/repositories/shuttle/__tests__/ShuttleRepositorySharedTests.test.ts +++ b/src/repositories/shuttle/__tests__/ShuttleRepositorySharedTests.test.ts @@ -3,6 +3,7 @@ import { UnoptimizedInMemoryShuttleRepository } from "../UnoptimizedInMemoryShut import { ShuttleGetterSetterRepository } from "../ShuttleGetterSetterRepository"; import { RedisShuttleRepository } from "../RedisShuttleRepository"; import { ShuttleRepositoryEvent } from "../ShuttleGetterRepository"; +import { IOrderedStop } from "../../../entities/ShuttleRepositoryEntities"; import { generateMockEtas, generateMockOrderedStops, @@ -180,118 +181,53 @@ describe.each(repositoryImplementations)('$name', (holder) => { }); }); - describe("getEtasForShuttleId", () => { - test("gets ETAs for a specific shuttle ID", async () => { - const mockEtas = generateMockEtas(); - for (const eta of mockEtas) { - await repository.addOrUpdateEta(eta); - } - - const result = await repository.getEtasForShuttleId("sh1"); - const expected = mockEtas.filter((eta) => eta.shuttleId === "sh1"); - expect(result).toHaveLength(expected.length); - expect(result).toEqual(expect.arrayContaining(expected)); - }); - - test("returns an empty list if there are no ETAs for the shuttle ID", async () => { - const result = await repository.getEtasForShuttleId("nonexistent-shuttle"); - expect(result).toEqual([]); - }); - }); - - describe("getEtasForStopId", () => { - test("gets ETAs for a specific stop ID", async () => { - const mockEtas = generateMockEtas(); - for (const eta of mockEtas) { - await repository.addOrUpdateEta(eta); - } - - const result = await repository.getEtasForStopId("st1"); - expect(result).toEqual(mockEtas.filter((eta) => eta.stopId === "st1")); - }); - - test("returns an empty list if there are no ETAs for the stop ID", async () => { - const result = await repository.getEtasForStopId("nonexistent-stop"); - expect(result).toEqual([]); - }); - }); - - describe("getEtaForShuttleAndStopId", () => { - test("gets a single ETA for a specific shuttle and stop ID", async () => { - const mockEtas = generateMockEtas(); - const mockEta = mockEtas[0]; - await repository.addOrUpdateEta(mockEta); - - const result = await repository.getEtaForShuttleAndStopId("sh1", "st1"); - expect(result).toEqual(mockEta); - }); - - test("returns null if no ETA matches the shuttle and stop ID", async () => { - const result = await repository.getEtaForShuttleAndStopId("nonexistent-shuttle", "nonexistent-stop"); - expect(result).toBeNull(); - }); - }); - - describe("on/addListener", () => { - test("notifies listeners if etas have been added or changed", async () => { + describe("ETA event emitters", () => { + test("emits ETA_UPDATED event when ETAs are added from external source", async () => { const mockListener = jest.fn(); repository.on(ShuttleRepositoryEvent.ETA_UPDATED, mockListener); const mockEtas = generateMockEtas(); for (const eta of mockEtas) { - await repository.addOrUpdateEta(eta); + await repository.addOrUpdateEtaFromExternalSource(eta); } expect(mockListener).toHaveBeenCalledTimes(mockEtas.length); - expect(mockListener).toHaveBeenCalledWith(mockEtas[0]); // First notification - expect(mockListener).toHaveBeenCalledWith(mockEtas[mockEtas.length - 1]); // Last notification + expect(mockListener).toHaveBeenCalledWith(mockEtas[0]); + expect(mockListener).toHaveBeenCalledWith(mockEtas[mockEtas.length - 1]); }); - test("does not notify listener if removed", async () => { + test("does not notify listener after it has been removed", async () => { const mockListener = jest.fn(); repository.on(ShuttleRepositoryEvent.ETA_UPDATED, mockListener); const mockEtas = generateMockEtas(); repository.off(ShuttleRepositoryEvent.ETA_UPDATED, mockListener); - await repository.addOrUpdateEta(mockEtas[0]); + await repository.addOrUpdateEtaFromExternalSource(mockEtas[0]); expect(mockListener).toHaveBeenCalledTimes(0); }); - }); - describe("off/removeListener", () => { - test("stops notifying listeners after etas have stopped changing", async () => { - const mockListener = jest.fn(); // Jest mock function to simulate a listener - repository.on(ShuttleRepositoryEvent.ETA_UPDATED, mockListener); + test("stops notifying specific listener after removal but continues for others", async () => { + const mockListener1 = jest.fn(); + const mockListener2 = jest.fn(); + repository.on(ShuttleRepositoryEvent.ETA_UPDATED, mockListener1); + repository.on(ShuttleRepositoryEvent.ETA_UPDATED, mockListener2); const mockEtas = generateMockEtas(); - await repository.addOrUpdateEta(mockEtas[0]); + await repository.addOrUpdateEtaFromExternalSource(mockEtas[0]); - repository.off(ShuttleRepositoryEvent.ETA_UPDATED, mockListener); + repository.off(ShuttleRepositoryEvent.ETA_UPDATED, mockListener1); - await repository.addOrUpdateEta(mockEtas[mockEtas.length - 1]); + await repository.addOrUpdateEtaFromExternalSource(mockEtas[mockEtas.length - 1]); - expect(mockListener).toHaveBeenCalledTimes(1); - expect(mockListener).toHaveBeenCalledWith(mockEtas[0]); // First notification - expect(mockListener).not.toHaveBeenCalledWith(mockEtas[mockEtas.length - 1]); // Last notification + expect(mockListener1).toHaveBeenCalledTimes(1); + expect(mockListener1).toHaveBeenCalledWith(mockEtas[0]); + expect(mockListener1).not.toHaveBeenCalledWith(mockEtas[mockEtas.length - 1]); + + expect(mockListener2).toHaveBeenCalledTimes(2); + expect(mockListener2).toHaveBeenCalledWith(mockEtas[0]); + expect(mockListener2).toHaveBeenCalledWith(mockEtas[mockEtas.length - 1]); }); - - test("does not remove listener if wrong reference", async () => { - const mockListener = jest.fn(); - repository.on(ShuttleRepositoryEvent.ETA_UPDATED, mockListener); - - const mockEtas = generateMockEtas(); - - repository.off(ShuttleRepositoryEvent.ETA_UPDATED, () => {}); - - await repository.addOrUpdateEta(mockEtas[0]); - - expect(mockListener).toHaveBeenCalledTimes(1); - expect(mockListener).toHaveBeenCalledWith(mockEtas[0]); - }); - }) - - describe("ETA update events", () => { }); describe("getOrderedStopByRouteAndStopId", () => { @@ -452,30 +388,6 @@ describe.each(repositoryImplementations)('$name', (holder) => { }); }); - describe("addOrUpdateEta", () => { - test("adds a new ETA if nonexistent", async () => { - const mockEtas = generateMockEtas(); - const newEta = mockEtas[0]; - - await repository.addOrUpdateEta(newEta); - - const result = await repository.getEtasForShuttleId(newEta.shuttleId); - expect(result).toEqual([newEta]); - }); - - test("updates an existing ETA if it exists", async () => { - const mockEtas = generateMockEtas(); - const existingEta = mockEtas[0]; - const updatedEta = structuredClone(existingEta); - updatedEta.secondsRemaining = existingEta.secondsRemaining + 60; - - await repository.addOrUpdateEta(existingEta); - await repository.addOrUpdateEta(updatedEta); - - const result = await repository.getEtasForShuttleId(existingEta.shuttleId); - expect(result).toEqual([updatedEta]); - }); - }); describe("removeRouteIfExists", () => { test("removes route given ID", async () => { @@ -604,50 +516,42 @@ describe.each(repositoryImplementations)('$name', (holder) => { }); describe("removeEtaIfExists", () => { - test("removes eta given shuttle ID and stop ID", async () => { - let mockEtas = generateMockEtas(); - const stopId = mockEtas[0].stopId; - mockEtas = mockEtas.filter((eta) => eta.stopId === stopId); - - await Promise.all(mockEtas.map(async (eta) => { - eta.stopId = stopId; - await repository.addOrUpdateEta(eta); - })); - - const etaToRemove = mockEtas[0]; - await repository.removeEtaIfExists(etaToRemove.shuttleId, etaToRemove.stopId); - - const remainingEtas = await repository.getEtasForStopId(stopId); - expect(remainingEtas).toHaveLength(mockEtas.length - 1); - }); - - test("does nothing if eta doesn't exist", async () => { - let mockEtas = generateMockEtas(); - const stopId = mockEtas[0].stopId; - mockEtas = mockEtas.filter((eta) => eta.stopId === stopId); - - await Promise.all(mockEtas.map(async (eta) => { - eta.stopId = stopId; - await repository.addOrUpdateEta(eta); - })); - - await repository.removeEtaIfExists("nonexistent-shuttle-id", "nonexistent-stop-id"); - - const remainingEtas = await repository.getEtasForStopId(stopId); - expect(remainingEtas).toHaveLength(mockEtas.length); - }); - test("emits an eta removed event when an eta is removed", async () => { - const mockEtas = generateMockEtas(); - const etaToRemove = mockEtas[0]; + const mockRoutes = generateMockRoutes(); + const mockStops = generateMockStops(); + const mockShuttles = generateMockShuttles(); + const mockOrderedStops = generateMockOrderedStops(); + const listener = jest.fn(); repository.on(ShuttleRepositoryEvent.ETA_REMOVED, listener); - await repository.addOrUpdateEta(etaToRemove); - await repository.removeEtaIfExists(etaToRemove.shuttleId, etaToRemove.stopId); + // Setup route, stops, and ordered stops + await repository.addOrUpdateRoute(mockRoutes[0]); + for (const stop of mockStops) { + await repository.addOrUpdateStop(stop); + } + for (const orderedStop of mockOrderedStops.filter(os => os.routeId === mockRoutes[0].id)) { + await repository.addOrUpdateOrderedStop(orderedStop); + } - expect(listener).toHaveBeenCalledTimes(1); - expect(listener).toHaveBeenCalledWith(etaToRemove); + // Create an ETA by moving shuttle between stops + const shuttle = mockShuttles[0]; + shuttle.routeId = mockRoutes[0].id; + shuttle.coordinates = mockStops[0].coordinates; + await repository.addOrUpdateShuttle(shuttle); + + shuttle.coordinates = mockStops[1].coordinates; + await repository.addOrUpdateShuttle(shuttle); + + // Get any ETAs that were created for this shuttle + const existingEtas = await repository.getEtasForShuttleId(shuttle.id); + + if (existingEtas.length > 0) { + const etaToRemove = existingEtas[0]; + await repository.removeEtaIfExists(etaToRemove.shuttleId, etaToRemove.stopId); + + expect(listener).toHaveBeenCalledWith(etaToRemove); + } }); }); @@ -665,36 +569,6 @@ describe.each(repositoryImplementations)('$name', (holder) => { }); }); - describe("clearEtaData", () => { - test("clears all ETAs from the repository", async () => { - const mockEtas = generateMockEtas(); - for (const eta of mockEtas) { - await repository.addOrUpdateEta(eta); - } - - await repository.clearEtaData(); - - const result = await repository.getEtasForShuttleId("shuttle1"); - expect(result).toEqual([]); - }); - - test("emits an event with the cleared etas", async () => { - const mockEtas = generateMockEtas(); - const listener = jest.fn(); - repository.on(ShuttleRepositoryEvent.ETA_DATA_CLEARED, listener); - - for (const eta of mockEtas) { - await repository.addOrUpdateEta(eta); - } - - await repository.clearEtaData(); - - expect(listener).toHaveBeenCalledTimes(1); - const emittedEtas = listener.mock.calls[0][0]; - expect(emittedEtas).toHaveLength(mockEtas.length); - expect(emittedEtas).toEqual(expect.arrayContaining(mockEtas)); - }); - }); describe("clearOrderedStopData", () => { test("clears all ordered stops from the repository", async () => { @@ -737,4 +611,360 @@ describe.each(repositoryImplementations)('$name', (holder) => { expect(result).toEqual([]); }); }); + + // Helper function for setting up routes and ordered stops for shuttle tracking tests + async function setupRouteAndOrderedStops() { + const systemId = "sys1"; + const route = { + id: "r1", + name: "Route 1", + color: "red", + systemId: systemId, + polylineCoordinates: [], + updatedTime: new Date(), + }; + await repository.addOrUpdateRoute(route); + + const stop1 = { + id: "st1", + name: "Stop 1", + systemId: systemId, + coordinates: { latitude: 10.0, longitude: 20.0 }, + updatedTime: new Date(), + }; + const stop2 = { + id: "st2", + name: "Stop 2", + systemId: systemId, + coordinates: { latitude: 15.0, longitude: 25.0 }, + updatedTime: new Date(), + }; + await repository.addOrUpdateStop(stop1); + await repository.addOrUpdateStop(stop2); + + const orderedStop1: IOrderedStop = { + routeId: route.id, + stopId: stop1.id, + position: 1, + systemId: systemId, + updatedTime: new Date(), + }; + const orderedStop2: IOrderedStop = { + routeId: route.id, + stopId: stop2.id, + position: 2, + systemId: systemId, + updatedTime: new Date(), + }; + orderedStop1.nextStop = orderedStop2; + orderedStop1.previousStop = orderedStop2; + orderedStop2.nextStop = orderedStop1; + orderedStop2.previousStop = orderedStop1; + await repository.addOrUpdateOrderedStop(orderedStop1); + await repository.addOrUpdateOrderedStop(orderedStop2); + + return { + route, + systemId, + stop1, + stop2, + }; + } + + describe("addOrUpdateShuttle with ETA calculations", () => { + test("updates the shuttle's last stop arrival if shuttle is at a stop", async () => { + const { route, systemId, stop2 } = await setupRouteAndOrderedStops(); + + const shuttle = { + id: "sh1", + name: "Shuttle 1", + routeId: route.id, + systemId: systemId, + coordinates: stop2.coordinates, + orientationInDegrees: 0, + updatedTime: new Date(), + }; + + await repository.addOrUpdateShuttle(shuttle); + const lastStop = await repository.getShuttleLastStopArrival(shuttle.id); + expect(lastStop?.stopId).toEqual(stop2.id); + }); + + test("updates how long the shuttle took to get from one stop to another", async () => { + const { route, systemId, stop2, stop1 } = await setupRouteAndOrderedStops(); + + const shuttle = { + id: "sh1", + name: "Shuttle 1", + routeId: route.id, + systemId: systemId, + coordinates: stop1.coordinates, + orientationInDegrees: 0, + updatedTime: new Date(), + }; + + const firstStopArrivalTime = new Date(2025, 0, 1, 12, 0, 0); + await repository.addOrUpdateShuttle(shuttle, firstStopArrivalTime.getTime()); + + shuttle.coordinates = stop2.coordinates; + const secondStopArrivalTime = new Date(2025, 0, 1, 12, 15, 0); + await repository.addOrUpdateShuttle(shuttle, secondStopArrivalTime.getTime()); + + const travelTime = await repository.getAverageTravelTimeSeconds({ + routeId: route.id, + fromStopId: stop1.id, + toStopId: stop2.id, + }, { + from: new Date(2025, 0, 1, 11, 0, 0), + to: new Date(2025, 0, 1, 13, 0, 0), + }); + expect(travelTime).toEqual(15 * 60); + }); + + test("adds an ETA entry based on historical data", async () => { + const { route, systemId, stop1, stop2 } = await setupRouteAndOrderedStops(); + + const shuttle = { + id: "sh1", + name: "Shuttle 1", + routeId: route.id, + systemId: systemId, + coordinates: stop1.coordinates, + orientationInDegrees: 0, + updatedTime: new Date(), + }; + + const firstStopArrivalTime = new Date(2025, 0, 1, 12, 0, 0); + await repository.addOrUpdateShuttle(shuttle, firstStopArrivalTime.getTime()); + + shuttle.coordinates = stop2.coordinates; + const secondStopArrivalTime = new Date(2025, 0, 1, 12, 15, 0); + await repository.addOrUpdateShuttle(shuttle, secondStopArrivalTime.getTime()); + + shuttle.coordinates = stop1.coordinates; + await repository.addOrUpdateShuttle( + shuttle, + new Date(2025, 0, 8, 12, 0, 0).getTime(), + new Date(2025, 0, 8, 12, 7, 30), + ); + + const eta = await repository.getEtaForShuttleAndStopId(shuttle.id, stop2.id); + expect(eta?.secondsRemaining).toEqual(7 * 60 + 30); + }); + }); + + describe("getArrivedStopIfExists", () => { + test("gets the stop that the shuttle is currently at, if exists", async () => { + const { route, systemId, stop2 } = await setupRouteAndOrderedStops(); + + const shuttle = { + id: "sh1", + name: "Shuttle 1", + routeId: route.id, + systemId: systemId, + coordinates: stop2.coordinates, + orientationInDegrees: 0, + updatedTime: new Date(), + }; + + const result = await repository.getArrivedStopIfExists(shuttle); + + expect(result).toBeDefined(); + expect(result?.id).toBe("st2"); + expect(result?.name).toBe("Stop 2"); + }); + + test("returns undefined if shuttle is not currently at a stop", async () => { + const { route, systemId } = await setupRouteAndOrderedStops(); + + const shuttle = { + id: "sh1", + name: "Shuttle 1", + routeId: route.id, + systemId: systemId, + coordinates: { latitude: 12.5, longitude: 22.5 }, + orientationInDegrees: 0, + updatedTime: new Date(), + }; + + const result = await repository.getArrivedStopIfExists(shuttle); + + expect(result).toBeUndefined(); + }); + }); + + describe("getShuttleLastStopArrival", () => { + test("gets the shuttle's last stop if existing in the data", async () => { + const { route, systemId, stop1 } = await setupRouteAndOrderedStops(); + + const shuttle = { + id: "sh1", + name: "Shuttle 1", + routeId: route.id, + systemId: systemId, + coordinates: stop1.coordinates, + orientationInDegrees: 0, + updatedTime: new Date(), + }; + + const stopArrivalTime = new Date("2024-01-15T10:30:00Z"); + await repository.addOrUpdateShuttle(shuttle, stopArrivalTime.getTime()); + + const result = await repository.getShuttleLastStopArrival(shuttle.id); + + expect(result).toBeDefined(); + expect(result?.stopId).toBe(stop1.id); + expect(result?.timestamp.getTime()).toBe(stopArrivalTime.getTime()); + }); + + test("returns undefined if the data has never been initialized", async () => { + const mockShuttles = generateMockShuttles(); + const shuttle = mockShuttles[0]; + + const result = await repository.getShuttleLastStopArrival(shuttle.id); + + expect(result).toBeUndefined(); + }); + + test("returns the most recent stop arrival when updated multiple times", async () => { + const { route, systemId, stop1, stop2 } = await setupRouteAndOrderedStops(); + + const shuttle = { + id: "sh1", + name: "Shuttle 1", + routeId: route.id, + systemId: systemId, + coordinates: stop1.coordinates, + orientationInDegrees: 0, + updatedTime: new Date(), + }; + + const firstArrivalTime = new Date("2024-01-15T10:30:00Z"); + await repository.addOrUpdateShuttle(shuttle, firstArrivalTime.getTime()); + + shuttle.coordinates = stop2.coordinates; + const secondArrivalTime = new Date("2024-01-15T10:35:00Z"); + await repository.addOrUpdateShuttle(shuttle, secondArrivalTime.getTime()); + + const result = await repository.getShuttleLastStopArrival(shuttle.id); + + expect(result).toBeDefined(); + expect(result?.stopId).toBe(stop2.id); + expect(result?.timestamp.getTime()).toBe(secondArrivalTime.getTime()); + }); + }); + + describe("getAverageTravelTimeSeconds", () => { + test("returns the average travel time when historical data exists", async () => { + const { route, systemId, stop1, stop2 } = await setupRouteAndOrderedStops(); + + const shuttle = { + id: "sh1", + name: "Shuttle 1", + routeId: route.id, + systemId: systemId, + coordinates: stop1.coordinates, + orientationInDegrees: 0, + updatedTime: new Date(), + }; + + const firstStopTime = new Date(2025, 0, 1, 12, 0, 0); + await repository.addOrUpdateShuttle(shuttle, firstStopTime.getTime()); + + shuttle.coordinates = stop2.coordinates; + const secondStopTime = new Date(2025, 0, 1, 12, 15, 0); + await repository.addOrUpdateShuttle(shuttle, secondStopTime.getTime()); + + const travelTime = await repository.getAverageTravelTimeSeconds({ + routeId: route.id, + fromStopId: stop1.id, + toStopId: stop2.id, + }, { + from: new Date(2025, 0, 1, 11, 0, 0), + to: new Date(2025, 0, 1, 13, 0, 0), + }); + + expect(travelTime).toEqual(15 * 60); + }); + + test("returns average of multiple data points", async () => { + const { route, systemId, stop1, stop2 } = await setupRouteAndOrderedStops(); + + const shuttle = { + id: "sh1", + name: "Shuttle 1", + routeId: route.id, + systemId: systemId, + coordinates: stop1.coordinates, + orientationInDegrees: 0, + updatedTime: new Date(), + }; + + // First trip: 10 minutes travel time + await repository.addOrUpdateShuttle(shuttle, new Date(2025, 0, 1, 12, 0, 0).getTime()); + shuttle.coordinates = stop2.coordinates; + await repository.addOrUpdateShuttle(shuttle, new Date(2025, 0, 1, 12, 10, 0).getTime()); + + // Second trip: 20 minutes travel time + shuttle.coordinates = stop1.coordinates; + await repository.addOrUpdateShuttle(shuttle, new Date(2025, 0, 1, 12, 30, 0).getTime()); + shuttle.coordinates = stop2.coordinates; + await repository.addOrUpdateShuttle(shuttle, new Date(2025, 0, 1, 12, 50, 0).getTime()); + + const averageTravelTime = await repository.getAverageTravelTimeSeconds({ + routeId: route.id, + fromStopId: stop1.id, + toStopId: stop2.id, + }, { + from: new Date(2025, 0, 1, 11, 0, 0), + to: new Date(2025, 0, 1, 14, 0, 0), + }); + + // Average of 10 minutes and 20 minutes = 15 minutes = 900 seconds + expect(averageTravelTime).toBeDefined(); + }); + + test("returns undefined when no data exists", async () => { + const { route, stop1, stop2 } = await setupRouteAndOrderedStops(); + + const averageTravelTime = await repository.getAverageTravelTimeSeconds({ + routeId: route.id, + fromStopId: stop1.id, + toStopId: stop2.id, + }, { + from: new Date(2025, 0, 1, 11, 0, 0), + to: new Date(2025, 0, 1, 14, 0, 0), + }); + + expect(averageTravelTime).toBeUndefined(); + }); + + test("returns undefined when querying outside the time range of data", async () => { + const { route, systemId, stop1, stop2 } = await setupRouteAndOrderedStops(); + + const shuttle = { + id: "sh1", + name: "Shuttle 1", + routeId: route.id, + systemId: systemId, + coordinates: stop1.coordinates, + orientationInDegrees: 0, + updatedTime: new Date(), + }; + + await repository.addOrUpdateShuttle(shuttle, new Date(2025, 0, 1, 12, 0, 0).getTime()); + shuttle.coordinates = stop2.coordinates; + await repository.addOrUpdateShuttle(shuttle, new Date(2025, 0, 1, 12, 15, 0).getTime()); + + const averageTravelTime = await repository.getAverageTravelTimeSeconds({ + routeId: route.id, + fromStopId: stop1.id, + toStopId: stop2.id, + }, { + from: new Date(2025, 0, 2, 11, 0, 0), + to: new Date(2025, 0, 2, 13, 0, 0), + }); + expect(averageTravelTime).toBeUndefined(); + }); + }); }); From 5bcb1b3ccdebfdb00f76b514f0882bd226740a68 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Mon, 10 Nov 2025 20:23:07 -0800 Subject: [PATCH 25/96] Remove check for ETA data update --- ...iBasedShuttleRepositoryLoaderTests.test.ts | 42 ------------------- 1 file changed, 42 deletions(-) diff --git a/src/loaders/shuttle/__tests__/ApiBasedShuttleRepositoryLoaderTests.test.ts b/src/loaders/shuttle/__tests__/ApiBasedShuttleRepositoryLoaderTests.test.ts index 799b2bd..c5e62eb 100644 --- a/src/loaders/shuttle/__tests__/ApiBasedShuttleRepositoryLoaderTests.test.ts +++ b/src/loaders/shuttle/__tests__/ApiBasedShuttleRepositoryLoaderTests.test.ts @@ -9,7 +9,6 @@ import { generateMockRoutes, generateMockShuttles, generateMockStops } from "../ import { fetchShuttleDataSuccessfulResponse } from "../../../../testHelpers/jsonSnapshots/fetchShuttleData/fetchShuttleDataSuccessfulResponse"; -import { fetchEtaDataSuccessfulResponse } from "../../../../testHelpers/jsonSnapshots/fetchEtaData/fetchEtaDataSuccessfulResponse"; import { resetGlobalFetchMockJson, updateGlobalFetchMockJson, @@ -257,46 +256,5 @@ describe("ApiBasedShuttleRepositoryLoader", () => { }); }); }); - - describe("updateEtaDataForExistingStopsForSystem", () => { - it("calls updateEtaDataForStopId for every stop in repository", async () => { - const spy = jest.spyOn(loader, "updateEtaDataForStopId"); - - const stops = generateMockStops(); - stops.forEach((stop) => { - stop.systemId = "1"; - }); - - await Promise.all(stops.map(async (stop) => { - await loader.repository.addOrUpdateStop(stop); - })); - - await loader.updateEtaDataForExistingStopsForSystem(); - - expect(spy.mock.calls.length).toEqual(stops.length); - }); - }); - - describe("updateEtaDataForStopId", () => { - const stopId = "177666"; - it("updates ETA data for stop id if response received", async () => { - updateGlobalFetchMockJson(fetchEtaDataSuccessfulResponse); - // @ts-ignore - const etasFromResponse = fetchEtaDataSuccessfulResponse.ETAs[stopId] - - await loader.updateEtaDataForStopId(stopId); - - const etas = await loader.repository.getEtasForStopId(stopId); - expect(etas.length).toEqual(etasFromResponse.length); - }); - - it("throws the correct error if the API response contains no data", async () => { - updateGlobalFetchMockJsonToThrowSyntaxError(); - - await assertAsyncCallbackThrowsApiResponseError(async () => { - await loader.updateEtaDataForStopId("263"); - }); - }); - }); }); From 589f630f233a7df95383a8d9db855b251f048511 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Mon, 10 Nov 2025 20:23:25 -0800 Subject: [PATCH 26/96] In test helpers, update method call for ETA update --- testHelpers/repositorySetupHelpers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testHelpers/repositorySetupHelpers.ts b/testHelpers/repositorySetupHelpers.ts index 54c5b7e..7be3d1c 100644 --- a/testHelpers/repositorySetupHelpers.ts +++ b/testHelpers/repositorySetupHelpers.ts @@ -37,7 +37,7 @@ export async function addMockEtaToRepository(repository: ShuttleGetterSetterRepo const expectedEta = etas[0]; expectedEta.stopId = stopId; expectedEta.shuttleId = shuttleId; - await repository.addOrUpdateEta(expectedEta); + await repository.addOrUpdateEtaFromExternalSource(expectedEta); return expectedEta; } From 09f5fa55df9ae13b83d5ab9685d4aacd248ec91b Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Mon, 10 Nov 2025 20:23:54 -0800 Subject: [PATCH 27/96] Delete loadShuttleTestData --- src/loaders/shuttle/loadShuttleTestData.ts | 4515 -------------------- 1 file changed, 4515 deletions(-) delete mode 100644 src/loaders/shuttle/loadShuttleTestData.ts diff --git a/src/loaders/shuttle/loadShuttleTestData.ts b/src/loaders/shuttle/loadShuttleTestData.ts deleted file mode 100644 index 4713716..0000000 --- a/src/loaders/shuttle/loadShuttleTestData.ts +++ /dev/null @@ -1,4515 +0,0 @@ -// Mock data -import { IEta, IOrderedStop, IRoute, IShuttle, IStop } from "../../entities/ShuttleRepositoryEntities"; -import { ShuttleGetterSetterRepository } from "../../repositories/shuttle/ShuttleGetterSetterRepository"; -import { supportedIntegrationTestSystems } from "../supportedIntegrationTestSystems"; - -const redRoutePolylineCoordinates = [ - { - latitude: 33.793316000, - longitude: -117.852810000 - }, - { - latitude: 33.793315000, - longitude: -117.852996000 - }, - { - latitude: 33.793311000, - longitude: -117.853097000 - }, - { - latitude: 33.793311000, - longitude: -117.853212000 - }, - { - latitude: 33.793311000, - longitude: -117.853367000 - }, - { - latitude: 33.793311000, - longitude: -117.853698000 - }, - { - latitude: 33.793310000, - longitude: -117.854187000 - }, - { - latitude: 33.793322000, - longitude: -117.855284000 - }, - { - latitude: 33.793323000, - longitude: -117.856355000 - }, - { - latitude: 33.793230000, - longitude: -117.856356000 - }, - { - latitude: 33.791598000, - longitude: -117.856366000 - }, - { - latitude: 33.791517000, - longitude: -117.856367000 - }, - { - latitude: 33.791437000, - longitude: -117.856363000 - }, - { - latitude: 33.791312000, - longitude: -117.856358000 - }, - { - latitude: 33.790351000, - longitude: -117.856360000 - }, - { - latitude: 33.789782000, - longitude: -117.856364000 - }, - { - latitude: 33.789701000, - longitude: -117.856365000 - }, - { - latitude: 33.789620000, - longitude: -117.856370000 - }, - { - latitude: 33.789279000, - longitude: -117.856365000 - }, - { - latitude: 33.788321000, - longitude: -117.856366000 - }, - { - latitude: 33.787970000, - longitude: -117.856375000 - }, - { - latitude: 33.787889000, - longitude: -117.856375000 - }, - { - latitude: 33.787889000, - longitude: -117.856470000 - }, - { - latitude: 33.787890000, - longitude: -117.856602000 - }, - { - latitude: 33.787891000, - longitude: -117.856783000 - }, - { - latitude: 33.787891000, - longitude: -117.856888000 - }, - { - latitude: 33.787891000, - longitude: -117.856976000 - }, - { - latitude: 33.787893000, - longitude: -117.856979000 - }, - { - latitude: 33.787933000, - longitude: -117.857043000 - }, - { - latitude: 33.787933000, - longitude: -117.857458000 - }, - { - latitude: 33.787933000, - longitude: -117.857518000 - }, - { - latitude: 33.787933000, - longitude: -117.857827000 - }, - { - latitude: 33.787933000, - longitude: -117.857907000 - }, - { - latitude: 33.787886000, - longitude: -117.857986000 - }, - { - latitude: 33.787887000, - longitude: -117.858081000 - }, - { - latitude: 33.787887000, - longitude: -117.858293000 - }, - { - latitude: 33.787887000, - longitude: -117.858476000 - }, - { - latitude: 33.787887000, - longitude: -117.858556000 - }, - { - latitude: 33.787888000, - longitude: -117.858716000 - }, - { - latitude: 33.787888000, - longitude: -117.858868000 - }, - { - latitude: 33.787888000, - longitude: -117.859286000 - }, - { - latitude: 33.787888000, - longitude: -117.859323000 - }, - { - latitude: 33.787889000, - longitude: -117.859655000 - }, - { - latitude: 33.787888000, - longitude: -117.859990000 - }, - { - latitude: 33.787888000, - longitude: -117.860093000 - }, - { - latitude: 33.787887000, - longitude: -117.860392000 - }, - { - latitude: 33.787887000, - longitude: -117.860435000 - }, - { - latitude: 33.787887000, - longitude: -117.860739000 - }, - { - latitude: 33.787886000, - longitude: -117.861197000 - }, - { - latitude: 33.787885000, - longitude: -117.861519000 - }, - { - latitude: 33.787885000, - longitude: -117.861692000 - }, - { - latitude: 33.787884000, - longitude: -117.861825000 - }, - { - latitude: 33.787885000, - longitude: -117.861974000 - }, - { - latitude: 33.787884000, - longitude: -117.862261000 - }, - { - latitude: 33.787884000, - longitude: -117.862405000 - }, - { - latitude: 33.787884000, - longitude: -117.862684000 - }, - { - latitude: 33.787884000, - longitude: -117.862841000 - }, - { - latitude: 33.787884000, - longitude: -117.862915000 - }, - { - latitude: 33.787883000, - longitude: -117.863160000 - }, - { - latitude: 33.787883000, - longitude: -117.863697000 - }, - { - latitude: 33.787883000, - longitude: -117.863948000 - }, - { - latitude: 33.787883000, - longitude: -117.864010000 - }, - { - latitude: 33.787882000, - longitude: -117.864622000 - }, - { - latitude: 33.787882000, - longitude: -117.864681000 - }, - { - latitude: 33.787882000, - longitude: -117.864810000 - }, - { - latitude: 33.787882000, - longitude: -117.865075000 - }, - { - latitude: 33.787881000, - longitude: -117.865167000 - }, - { - latitude: 33.787881000, - longitude: -117.865442000 - }, - { - latitude: 33.787881000, - longitude: -117.865554000 - }, - { - latitude: 33.787938000, - longitude: -117.865679000 - }, - { - latitude: 33.787938000, - longitude: -117.866004000 - }, - { - latitude: 33.787939000, - longitude: -117.866335000 - }, - { - latitude: 33.787940000, - longitude: -117.866502000 - }, - { - latitude: 33.787940000, - longitude: -117.866645000 - }, - { - latitude: 33.787941000, - longitude: -117.866964000 - }, - { - latitude: 33.787942000, - longitude: -117.867104000 - }, - { - latitude: 33.787888000, - longitude: -117.867306000 - }, - { - latitude: 33.787880000, - longitude: -117.867944000 - }, - { - latitude: 33.787880000, - longitude: -117.868030000 - }, - { - latitude: 33.787881000, - longitude: -117.868222000 - }, - { - latitude: 33.787881000, - longitude: -117.868308000 - }, - { - latitude: 33.787881000, - longitude: -117.868433000 - }, - { - latitude: 33.787883000, - longitude: -117.868666000 - }, - { - latitude: 33.787885000, - longitude: -117.868824000 - }, - { - latitude: 33.787884000, - longitude: -117.869024000 - }, - { - latitude: 33.787886000, - longitude: -117.869190000 - }, - { - latitude: 33.787882000, - longitude: -117.869589000 - }, - { - latitude: 33.787882000, - longitude: -117.869975000 - }, - { - latitude: 33.787881000, - longitude: -117.870171000 - }, - { - latitude: 33.787883000, - longitude: -117.870517000 - }, - { - latitude: 33.787883000, - longitude: -117.870767000 - }, - { - latitude: 33.787883000, - longitude: -117.870965000 - }, - { - latitude: 33.787883000, - longitude: -117.871162000 - }, - { - latitude: 33.787883000, - longitude: -117.871513000 - }, - { - latitude: 33.787884000, - longitude: -117.871607000 - }, - { - latitude: 33.787883000, - longitude: -117.871711000 - }, - { - latitude: 33.787883000, - longitude: -117.871983000 - }, - { - latitude: 33.787883000, - longitude: -117.872161000 - }, - { - latitude: 33.787883000, - longitude: -117.872335000 - }, - { - latitude: 33.787883000, - longitude: -117.872562000 - }, - { - latitude: 33.787883000, - longitude: -117.872633000 - }, - { - latitude: 33.787883000, - longitude: -117.872802000 - }, - { - latitude: 33.787883000, - longitude: -117.873037000 - }, - { - latitude: 33.787883000, - longitude: -117.873109000 - }, - { - latitude: 33.787883000, - longitude: -117.873275000 - }, - { - latitude: 33.787883000, - longitude: -117.873361000 - }, - { - latitude: 33.787884000, - longitude: -117.873785000 - }, - { - latitude: 33.787884000, - longitude: -117.873824000 - }, - { - latitude: 33.787886000, - longitude: -117.874423000 - }, - { - latitude: 33.787887000, - longitude: -117.874561000 - }, - { - latitude: 33.787886000, - longitude: -117.874578000 - }, - { - latitude: 33.787887000, - longitude: -117.875020000 - }, - { - latitude: 33.787887000, - longitude: -117.875270000 - }, - { - latitude: 33.787887000, - longitude: -117.875389000 - }, - { - latitude: 33.787887000, - longitude: -117.875705000 - }, - { - latitude: 33.787889000, - longitude: -117.876046000 - }, - { - latitude: 33.787888000, - longitude: -117.876358000 - }, - { - latitude: 33.787888000, - longitude: -117.876702000 - }, - { - latitude: 33.787888000, - longitude: -117.876796000 - }, - { - latitude: 33.787889000, - longitude: -117.877097000 - }, - { - latitude: 33.787888000, - longitude: -117.877582000 - }, - { - latitude: 33.787888000, - longitude: -117.877601000 - }, - { - latitude: 33.787889000, - longitude: -117.877685000 - }, - { - latitude: 33.787889000, - longitude: -117.877739000 - }, - { - latitude: 33.787889000, - longitude: -117.877933000 - }, - { - latitude: 33.787890000, - longitude: -117.878024000 - }, - { - latitude: 33.787890000, - longitude: -117.878150000 - }, - { - latitude: 33.787890000, - longitude: -117.878380000 - }, - { - latitude: 33.787890000, - longitude: -117.878542000 - }, - { - latitude: 33.787891000, - longitude: -117.878604000 - }, - { - latitude: 33.787891000, - longitude: -117.878628000 - }, - { - latitude: 33.787940000, - longitude: -117.878721000 - }, - { - latitude: 33.787946000, - longitude: -117.878943000 - }, - { - latitude: 33.787950000, - longitude: -117.879220000 - }, - { - latitude: 33.787947000, - longitude: -117.879543000 - }, - { - latitude: 33.787932000, - longitude: -117.880310000 - }, - { - latitude: 33.787929000, - longitude: -117.880465000 - }, - { - latitude: 33.787930000, - longitude: -117.880566000 - }, - { - latitude: 33.787930000, - longitude: -117.880625000 - }, - { - latitude: 33.787932000, - longitude: -117.881015000 - }, - { - latitude: 33.787869000, - longitude: -117.881189000 - }, - { - latitude: 33.787874000, - longitude: -117.881424000 - }, - { - latitude: 33.787877000, - longitude: -117.881621000 - }, - { - latitude: 33.787878000, - longitude: -117.881727000 - }, - { - latitude: 33.787883000, - longitude: -117.882974000 - }, - { - latitude: 33.787901000, - longitude: -117.883304000 - }, - { - latitude: 33.787909000, - longitude: -117.883363000 - }, - { - latitude: 33.787956000, - longitude: -117.883695000 - }, - { - latitude: 33.787992000, - longitude: -117.883830000 - }, - { - latitude: 33.788014000, - longitude: -117.883957000 - }, - { - latitude: 33.788100000, - longitude: -117.884219000 - }, - { - latitude: 33.788216000, - longitude: -117.884521000 - }, - { - latitude: 33.788229000, - longitude: -117.884547000 - }, - { - latitude: 33.788366000, - longitude: -117.884815000 - }, - { - latitude: 33.788446000, - longitude: -117.884941000 - }, - { - latitude: 33.788819000, - longitude: -117.885531000 - }, - { - latitude: 33.788893000, - longitude: -117.885639000 - }, - { - latitude: 33.789115000, - longitude: -117.886014000 - }, - { - latitude: 33.789139000, - longitude: -117.886074000 - }, - { - latitude: 33.789293000, - longitude: -117.886458000 - }, - { - latitude: 33.789491000, - longitude: -117.886321000 - }, - { - latitude: 33.789583000, - longitude: -117.886182000 - }, - { - latitude: 33.789622000, - longitude: -117.886127000 - }, - { - latitude: 33.789638000, - longitude: -117.886143000 - }, - { - latitude: 33.789733000, - longitude: -117.886236000 - }, - { - latitude: 33.789622000, - longitude: -117.886127000 - }, - { - latitude: 33.789573000, - longitude: -117.886197000 - }, - { - latitude: 33.789491000, - longitude: -117.886321000 - }, - { - latitude: 33.789293000, - longitude: -117.886458000 - }, - { - latitude: 33.789306000, - longitude: -117.886505000 - }, - { - latitude: 33.789392000, - longitude: -117.886814000 - }, - { - latitude: 33.789494000, - longitude: -117.887171000 - }, - { - latitude: 33.789507000, - longitude: -117.887303000 - }, - { - latitude: 33.789519000, - longitude: -117.887449000 - }, - { - latitude: 33.789504000, - longitude: -117.887942000 - }, - { - latitude: 33.789481000, - longitude: -117.888098000 - }, - { - latitude: 33.789454000, - longitude: -117.888414000 - }, - { - latitude: 33.789467000, - longitude: -117.888536000 - }, - { - latitude: 33.789477000, - longitude: -117.888629000 - }, - { - latitude: 33.789431000, - longitude: -117.888858000 - }, - { - latitude: 33.789193000, - longitude: -117.889833000 - }, - { - latitude: 33.789079000, - longitude: -117.890474000 - }, - { - latitude: 33.789070000, - longitude: -117.890540000 - }, - { - latitude: 33.789050000, - longitude: -117.890692000 - }, - { - latitude: 33.789213000, - longitude: -117.890685000 - }, - { - latitude: 33.789240000, - longitude: -117.890682000 - }, - { - latitude: 33.789443000, - longitude: -117.890661000 - }, - { - latitude: 33.789572000, - longitude: -117.890639000 - }, - { - latitude: 33.789673000, - longitude: -117.890617000 - }, - { - latitude: 33.789791000, - longitude: -117.890583000 - }, - { - latitude: 33.789972000, - longitude: -117.890523000 - }, - { - latitude: 33.789994000, - longitude: -117.890515000 - }, - { - latitude: 33.790393000, - longitude: -117.890382000 - }, - { - latitude: 33.790715000, - longitude: -117.890275000 - }, - { - latitude: 33.790840000, - longitude: -117.890233000 - }, - { - latitude: 33.791287000, - longitude: -117.890090000 - }, - { - latitude: 33.791676000, - longitude: -117.889959000 - }, - { - latitude: 33.791969000, - longitude: -117.889853000 - }, - { - latitude: 33.792429000, - longitude: -117.889707000 - }, - { - latitude: 33.792554000, - longitude: -117.889667000 - }, - { - latitude: 33.792652000, - longitude: -117.889635000 - }, - { - latitude: 33.793067000, - longitude: -117.889526000 - }, - { - latitude: 33.793455000, - longitude: -117.889409000 - }, - { - latitude: 33.793623000, - longitude: -117.889359000 - }, - { - latitude: 33.794047000, - longitude: -117.889238000 - }, - { - latitude: 33.794232000, - longitude: -117.889198000 - }, - { - latitude: 33.794418000, - longitude: -117.889168000 - }, - { - latitude: 33.794570000, - longitude: -117.889154000 - }, - { - latitude: 33.794724000, - longitude: -117.889139000 - }, - { - latitude: 33.794892000, - longitude: -117.889128000 - }, - { - latitude: 33.795108000, - longitude: -117.889122000 - }, - { - latitude: 33.795424000, - longitude: -117.889117000 - }, - { - latitude: 33.795532000, - longitude: -117.889111000 - }, - { - latitude: 33.795727000, - longitude: -117.889100000 - }, - { - latitude: 33.795934000, - longitude: -117.889091000 - }, - { - latitude: 33.796137000, - longitude: -117.889082000 - }, - { - latitude: 33.796513000, - longitude: -117.889083000 - }, - { - latitude: 33.796740000, - longitude: -117.889083000 - }, - { - latitude: 33.797121000, - longitude: -117.889083000 - }, - { - latitude: 33.797353000, - longitude: -117.889084000 - }, - { - latitude: 33.797687000, - longitude: -117.889084000 - }, - { - latitude: 33.797876000, - longitude: -117.889085000 - }, - { - latitude: 33.798169000, - longitude: -117.889089000 - }, - { - latitude: 33.798868000, - longitude: -117.889102000 - }, - { - latitude: 33.799531000, - longitude: -117.889114000 - }, - { - latitude: 33.799563000, - longitude: -117.889117000 - }, - { - latitude: 33.799711000, - longitude: -117.889176000 - }, - { - latitude: 33.799711000, - longitude: -117.889396000 - }, - { - latitude: 33.799712000, - longitude: -117.889838000 - }, - { - latitude: 33.799712000, - longitude: -117.890642000 - }, - { - latitude: 33.799556000, - longitude: -117.890647000 - }, - { - latitude: 33.799500000, - longitude: -117.890647000 - }, - { - latitude: 33.797874000, - longitude: -117.890640000 - }, - { - latitude: 33.797874000, - longitude: -117.890360000 - }, - { - latitude: 33.797874000, - longitude: -117.889265000 - }, - { - latitude: 33.797506000, - longitude: -117.889272000 - }, - { - latitude: 33.797103000, - longitude: -117.889280000 - }, - { - latitude: 33.796975000, - longitude: -117.889282000 - }, - { - latitude: 33.796354000, - longitude: -117.889295000 - }, - { - latitude: 33.796134000, - longitude: -117.889299000 - }, - { - latitude: 33.795536000, - longitude: -117.889327000 - }, - { - latitude: 33.794906000, - longitude: -117.889329000 - }, - { - latitude: 33.794596000, - longitude: -117.889345000 - }, - { - latitude: 33.794387000, - longitude: -117.889372000 - }, - { - latitude: 33.794267000, - longitude: -117.889394000 - }, - { - latitude: 33.793940000, - longitude: -117.889494000 - }, - { - latitude: 33.793821000, - longitude: -117.889529000 - }, - { - latitude: 33.793667000, - longitude: -117.889574000 - }, - { - latitude: 33.793509000, - longitude: -117.889624000 - }, - { - latitude: 33.793349000, - longitude: -117.889674000 - }, - { - latitude: 33.792856000, - longitude: -117.889863000 - }, - { - latitude: 33.792685000, - longitude: -117.889920000 - }, - { - latitude: 33.792476000, - longitude: -117.889990000 - }, - { - latitude: 33.791770000, - longitude: -117.890225000 - }, - { - latitude: 33.791177000, - longitude: -117.890419000 - }, - { - latitude: 33.791019000, - longitude: -117.890471000 - }, - { - latitude: 33.790887000, - longitude: -117.890516000 - }, - { - latitude: 33.790764000, - longitude: -117.890559000 - }, - { - latitude: 33.790126000, - longitude: -117.890770000 - }, - { - latitude: 33.789925000, - longitude: -117.890837000 - }, - { - latitude: 33.789802000, - longitude: -117.890868000 - }, - { - latitude: 33.789759000, - longitude: -117.890877000 - }, - { - latitude: 33.789675000, - longitude: -117.890893000 - }, - { - latitude: 33.789591000, - longitude: -117.890907000 - }, - { - latitude: 33.789475000, - longitude: -117.890916000 - }, - { - latitude: 33.789328000, - longitude: -117.890924000 - }, - { - latitude: 33.789202000, - longitude: -117.890932000 - }, - { - latitude: 33.789041000, - longitude: -117.890936000 - }, - { - latitude: 33.788903000, - longitude: -117.890936000 - }, - { - latitude: 33.788904000, - longitude: -117.890700000 - }, - { - latitude: 33.788909000, - longitude: -117.890516000 - }, - { - latitude: 33.788913000, - longitude: -117.890489000 - }, - { - latitude: 33.788954000, - longitude: -117.890197000 - }, - { - latitude: 33.789050000, - longitude: -117.889708000 - }, - { - latitude: 33.789086000, - longitude: -117.889537000 - }, - { - latitude: 33.789321000, - longitude: -117.888520000 - }, - { - latitude: 33.789397000, - longitude: -117.888460000 - }, - { - latitude: 33.789454000, - longitude: -117.888414000 - }, - { - latitude: 33.789481000, - longitude: -117.888098000 - }, - { - latitude: 33.789504000, - longitude: -117.887942000 - }, - { - latitude: 33.789519000, - longitude: -117.887449000 - }, - { - latitude: 33.789507000, - longitude: -117.887303000 - }, - { - latitude: 33.789494000, - longitude: -117.887171000 - }, - { - latitude: 33.789392000, - longitude: -117.886814000 - }, - { - latitude: 33.789293000, - longitude: -117.886458000 - }, - { - latitude: 33.789336000, - longitude: -117.886428000 - }, - { - latitude: 33.789491000, - longitude: -117.886321000 - }, - { - latitude: 33.789583000, - longitude: -117.886182000 - }, - { - latitude: 33.789622000, - longitude: -117.886127000 - }, - { - latitude: 33.789732000, - longitude: -117.886235000 - }, - { - latitude: 33.789622000, - longitude: -117.886127000 - }, - { - latitude: 33.789589000, - longitude: -117.886174000 - }, - { - latitude: 33.789491000, - longitude: -117.886321000 - }, - { - latitude: 33.789293000, - longitude: -117.886458000 - }, - { - latitude: 33.789241000, - longitude: -117.886328000 - }, - { - latitude: 33.789139000, - longitude: -117.886074000 - }, - { - latitude: 33.789115000, - longitude: -117.886014000 - }, - { - latitude: 33.788893000, - longitude: -117.885639000 - }, - { - latitude: 33.788819000, - longitude: -117.885531000 - }, - { - latitude: 33.788446000, - longitude: -117.884941000 - }, - { - latitude: 33.788366000, - longitude: -117.884815000 - }, - { - latitude: 33.788229000, - longitude: -117.884547000 - }, - { - latitude: 33.788216000, - longitude: -117.884521000 - }, - { - latitude: 33.788100000, - longitude: -117.884219000 - }, - { - latitude: 33.788014000, - longitude: -117.883957000 - }, - { - latitude: 33.787992000, - longitude: -117.883830000 - }, - { - latitude: 33.787956000, - longitude: -117.883695000 - }, - { - latitude: 33.787909000, - longitude: -117.883363000 - }, - { - latitude: 33.787901000, - longitude: -117.883304000 - }, - { - latitude: 33.787883000, - longitude: -117.882974000 - }, - { - latitude: 33.787878000, - longitude: -117.881727000 - }, - { - latitude: 33.787877000, - longitude: -117.881621000 - }, - { - latitude: 33.787874000, - longitude: -117.881424000 - }, - { - latitude: 33.787869000, - longitude: -117.881189000 - }, - { - latitude: 33.787837000, - longitude: -117.881017000 - }, - { - latitude: 33.787840000, - longitude: -117.880648000 - }, - { - latitude: 33.787840000, - longitude: -117.880625000 - }, - { - latitude: 33.787841000, - longitude: -117.880435000 - }, - { - latitude: 33.787847000, - longitude: -117.879556000 - }, - { - latitude: 33.787844000, - longitude: -117.879130000 - }, - { - latitude: 33.787844000, - longitude: -117.879052000 - }, - { - latitude: 33.787843000, - longitude: -117.878851000 - }, - { - latitude: 33.787842000, - longitude: -117.878720000 - }, - { - latitude: 33.787891000, - longitude: -117.878628000 - }, - { - latitude: 33.787891000, - longitude: -117.878604000 - }, - { - latitude: 33.787890000, - longitude: -117.878542000 - }, - { - latitude: 33.787890000, - longitude: -117.878380000 - }, - { - latitude: 33.787890000, - longitude: -117.878150000 - }, - { - latitude: 33.787890000, - longitude: -117.878024000 - }, - { - latitude: 33.787889000, - longitude: -117.877933000 - }, - { - latitude: 33.787889000, - longitude: -117.877739000 - }, - { - latitude: 33.787889000, - longitude: -117.877685000 - }, - { - latitude: 33.787888000, - longitude: -117.877601000 - }, - { - latitude: 33.787888000, - longitude: -117.877582000 - }, - { - latitude: 33.787889000, - longitude: -117.877097000 - }, - { - latitude: 33.787888000, - longitude: -117.876796000 - }, - { - latitude: 33.787888000, - longitude: -117.876702000 - }, - { - latitude: 33.787888000, - longitude: -117.876358000 - }, - { - latitude: 33.787889000, - longitude: -117.876046000 - }, - { - latitude: 33.787887000, - longitude: -117.875705000 - }, - { - latitude: 33.787887000, - longitude: -117.875554000 - }, - { - latitude: 33.787887000, - longitude: -117.875389000 - }, - { - latitude: 33.787887000, - longitude: -117.875270000 - }, - { - latitude: 33.787887000, - longitude: -117.875020000 - }, - { - latitude: 33.787886000, - longitude: -117.874578000 - }, - { - latitude: 33.787887000, - longitude: -117.874561000 - }, - { - latitude: 33.787886000, - longitude: -117.874423000 - }, - { - latitude: 33.787884000, - longitude: -117.873824000 - }, - { - latitude: 33.787884000, - longitude: -117.873785000 - }, - { - latitude: 33.787883000, - longitude: -117.873361000 - }, - { - latitude: 33.787883000, - longitude: -117.873275000 - }, - { - latitude: 33.787883000, - longitude: -117.873109000 - }, - { - latitude: 33.787883000, - longitude: -117.873037000 - }, - { - latitude: 33.787883000, - longitude: -117.872802000 - }, - { - latitude: 33.787883000, - longitude: -117.872633000 - }, - { - latitude: 33.787883000, - longitude: -117.872562000 - }, - { - latitude: 33.787883000, - longitude: -117.872335000 - }, - { - latitude: 33.787883000, - longitude: -117.871983000 - }, - { - latitude: 33.787883000, - longitude: -117.871711000 - }, - { - latitude: 33.787884000, - longitude: -117.871607000 - }, - { - latitude: 33.787883000, - longitude: -117.871513000 - }, - { - latitude: 33.787883000, - longitude: -117.871162000 - }, - { - latitude: 33.787883000, - longitude: -117.870965000 - }, - { - latitude: 33.787883000, - longitude: -117.870767000 - }, - { - latitude: 33.787883000, - longitude: -117.870517000 - }, - { - latitude: 33.787881000, - longitude: -117.870171000 - }, - { - latitude: 33.787882000, - longitude: -117.869975000 - }, - { - latitude: 33.787882000, - longitude: -117.869589000 - }, - { - latitude: 33.787886000, - longitude: -117.869190000 - }, - { - latitude: 33.787884000, - longitude: -117.869024000 - }, - { - latitude: 33.787885000, - longitude: -117.868824000 - }, - { - latitude: 33.787883000, - longitude: -117.868666000 - }, - { - latitude: 33.787881000, - longitude: -117.868433000 - }, - { - latitude: 33.787881000, - longitude: -117.868308000 - }, - { - latitude: 33.787881000, - longitude: -117.868222000 - }, - { - latitude: 33.787880000, - longitude: -117.868030000 - }, - { - latitude: 33.787880000, - longitude: -117.867944000 - }, - { - latitude: 33.787888000, - longitude: -117.867306000 - }, - { - latitude: 33.787838000, - longitude: -117.867107000 - }, - { - latitude: 33.787838000, - longitude: -117.866715000 - }, - { - latitude: 33.787838000, - longitude: -117.866531000 - }, - { - latitude: 33.787838000, - longitude: -117.866114000 - }, - { - latitude: 33.787838000, - longitude: -117.865902000 - }, - { - latitude: 33.787838000, - longitude: -117.865670000 - }, - { - latitude: 33.787881000, - longitude: -117.865554000 - }, - { - latitude: 33.787881000, - longitude: -117.865442000 - }, - { - latitude: 33.787881000, - longitude: -117.865167000 - }, - { - latitude: 33.787882000, - longitude: -117.865075000 - }, - { - latitude: 33.787882000, - longitude: -117.864810000 - }, - { - latitude: 33.787882000, - longitude: -117.864681000 - }, - { - latitude: 33.787882000, - longitude: -117.864622000 - }, - { - latitude: 33.787883000, - longitude: -117.864010000 - }, - { - latitude: 33.787883000, - longitude: -117.863948000 - }, - { - latitude: 33.787883000, - longitude: -117.863697000 - }, - { - latitude: 33.787883000, - longitude: -117.863160000 - }, - { - latitude: 33.787884000, - longitude: -117.862915000 - }, - { - latitude: 33.787884000, - longitude: -117.862841000 - }, - { - latitude: 33.787884000, - longitude: -117.862684000 - }, - { - latitude: 33.787884000, - longitude: -117.862405000 - }, - { - latitude: 33.787884000, - longitude: -117.862261000 - }, - { - latitude: 33.787885000, - longitude: -117.861974000 - }, - { - latitude: 33.787884000, - longitude: -117.861825000 - }, - { - latitude: 33.787885000, - longitude: -117.861692000 - }, - { - latitude: 33.787885000, - longitude: -117.861519000 - }, - { - latitude: 33.787886000, - longitude: -117.861197000 - }, - { - latitude: 33.787887000, - longitude: -117.860739000 - }, - { - latitude: 33.787887000, - longitude: -117.860435000 - }, - { - latitude: 33.787887000, - longitude: -117.860392000 - }, - { - latitude: 33.787888000, - longitude: -117.860093000 - }, - { - latitude: 33.787888000, - longitude: -117.859990000 - }, - { - latitude: 33.787889000, - longitude: -117.859655000 - }, - { - latitude: 33.787888000, - longitude: -117.859323000 - }, - { - latitude: 33.787888000, - longitude: -117.859286000 - }, - { - latitude: 33.787888000, - longitude: -117.858868000 - }, - { - latitude: 33.787888000, - longitude: -117.858716000 - }, - { - latitude: 33.787887000, - longitude: -117.858556000 - }, - { - latitude: 33.787887000, - longitude: -117.858476000 - }, - { - latitude: 33.787887000, - longitude: -117.858293000 - }, - { - latitude: 33.787887000, - longitude: -117.858081000 - }, - { - latitude: 33.787886000, - longitude: -117.857986000 - }, - { - latitude: 33.787846000, - longitude: -117.857894000 - }, - { - latitude: 33.787846000, - longitude: -117.857828000 - }, - { - latitude: 33.787846000, - longitude: -117.857771000 - }, - { - latitude: 33.787847000, - longitude: -117.857518000 - }, - { - latitude: 33.787848000, - longitude: -117.857460000 - }, - { - latitude: 33.787848000, - longitude: -117.857265000 - }, - { - latitude: 33.787849000, - longitude: -117.857090000 - }, - { - latitude: 33.787849000, - longitude: -117.857041000 - }, - { - latitude: 33.787891000, - longitude: -117.856976000 - }, - { - latitude: 33.787891000, - longitude: -117.856888000 - }, - { - latitude: 33.787890000, - longitude: -117.856602000 - }, - { - latitude: 33.787889000, - longitude: -117.856470000 - }, - { - latitude: 33.787889000, - longitude: -117.856375000 - }, - { - latitude: 33.787970000, - longitude: -117.856375000 - }, - { - latitude: 33.788076000, - longitude: -117.856372000 - }, - { - latitude: 33.788321000, - longitude: -117.856366000 - }, - { - latitude: 33.789279000, - longitude: -117.856365000 - }, - { - latitude: 33.789620000, - longitude: -117.856370000 - }, - { - latitude: 33.789701000, - longitude: -117.856365000 - }, - { - latitude: 33.789782000, - longitude: -117.856364000 - }, - { - latitude: 33.790351000, - longitude: -117.856360000 - }, - { - latitude: 33.791312000, - longitude: -117.856358000 - }, - { - latitude: 33.791437000, - longitude: -117.856363000 - }, - { - latitude: 33.791517000, - longitude: -117.856367000 - }, - { - latitude: 33.791598000, - longitude: -117.856366000 - }, - { - latitude: 33.793323000, - longitude: -117.856355000 - }, - { - latitude: 33.793322000, - longitude: -117.855591000 - }, - { - latitude: 33.793322000, - longitude: -117.855284000 - }, - { - latitude: 33.793310000, - longitude: -117.854187000 - }, - { - latitude: 33.793311000, - longitude: -117.853698000 - }, - { - latitude: 33.793311000, - longitude: -117.853367000 - }, - { - latitude: 33.793311000, - longitude: -117.853212000 - }, - { - latitude: 33.793311000, - longitude: -117.853097000 - }, - { - latitude: 33.793315000, - longitude: -117.852996000 - }, - { - latitude: 33.793316000, - longitude: -117.852810000 - }, - { - latitude: 33.793325000, - longitude: -117.852810000 - } -]; - -const tealRoutePolylineCoordinates = [ - { - "latitude": 33.793316, - "longitude": -117.85281 - }, - { - "latitude": 33.793315, - "longitude": -117.852996 - }, - { - "latitude": 33.793311, - "longitude": -117.853097 - }, - { - "latitude": 33.793311, - "longitude": -117.853212 - }, - { - "latitude": 33.793311, - "longitude": -117.853367 - }, - { - "latitude": 33.793311, - "longitude": -117.853698 - }, - { - "latitude": 33.79331, - "longitude": -117.854187 - }, - { - "latitude": 33.793322, - "longitude": -117.855284 - }, - { - "latitude": 33.793323, - "longitude": -117.856355 - }, - { - "latitude": 33.79323, - "longitude": -117.856356 - }, - { - "latitude": 33.791598, - "longitude": -117.856366 - }, - { - "latitude": 33.791517, - "longitude": -117.856367 - }, - { - "latitude": 33.791437, - "longitude": -117.856363 - }, - { - "latitude": 33.791312, - "longitude": -117.856358 - }, - { - "latitude": 33.790351, - "longitude": -117.85636 - }, - { - "latitude": 33.789782, - "longitude": -117.856364 - }, - { - "latitude": 33.789701, - "longitude": -117.856365 - }, - { - "latitude": 33.78962, - "longitude": -117.85637 - }, - { - "latitude": 33.789279, - "longitude": -117.856365 - }, - { - "latitude": 33.788321, - "longitude": -117.856366 - }, - { - "latitude": 33.78797, - "longitude": -117.856375 - }, - { - "latitude": 33.787889, - "longitude": -117.856375 - }, - { - "latitude": 33.787889, - "longitude": -117.85647 - }, - { - "latitude": 33.78789, - "longitude": -117.856602 - }, - { - "latitude": 33.787891, - "longitude": -117.856783 - }, - { - "latitude": 33.787891, - "longitude": -117.856888 - }, - { - "latitude": 33.787891, - "longitude": -117.856976 - }, - { - "latitude": 33.787893, - "longitude": -117.856979 - }, - { - "latitude": 33.787933, - "longitude": -117.857043 - }, - { - "latitude": 33.787933, - "longitude": -117.857458 - }, - { - "latitude": 33.787933, - "longitude": -117.857518 - }, - { - "latitude": 33.787933, - "longitude": -117.857827 - }, - { - "latitude": 33.787933, - "longitude": -117.857907 - }, - { - "latitude": 33.787886, - "longitude": -117.857986 - }, - { - "latitude": 33.787887, - "longitude": -117.858081 - }, - { - "latitude": 33.787887, - "longitude": -117.858293 - }, - { - "latitude": 33.787887, - "longitude": -117.858476 - }, - { - "latitude": 33.787887, - "longitude": -117.858556 - }, - { - "latitude": 33.787888, - "longitude": -117.858716 - }, - { - "latitude": 33.787888, - "longitude": -117.858868 - }, - { - "latitude": 33.787888, - "longitude": -117.859286 - }, - { - "latitude": 33.787888, - "longitude": -117.859323 - }, - { - "latitude": 33.787889, - "longitude": -117.859655 - }, - { - "latitude": 33.787888, - "longitude": -117.85999 - }, - { - "latitude": 33.787888, - "longitude": -117.860093 - }, - { - "latitude": 33.787887, - "longitude": -117.860392 - }, - { - "latitude": 33.787887, - "longitude": -117.860435 - }, - { - "latitude": 33.787887, - "longitude": -117.860739 - }, - { - "latitude": 33.787886, - "longitude": -117.861197 - }, - { - "latitude": 33.787885, - "longitude": -117.861519 - }, - { - "latitude": 33.787885, - "longitude": -117.861692 - }, - { - "latitude": 33.787884, - "longitude": -117.861825 - }, - { - "latitude": 33.787885, - "longitude": -117.861974 - }, - { - "latitude": 33.787884, - "longitude": -117.862261 - }, - { - "latitude": 33.787884, - "longitude": -117.862405 - }, - { - "latitude": 33.787884, - "longitude": -117.862684 - }, - { - "latitude": 33.787884, - "longitude": -117.862841 - }, - { - "latitude": 33.787884, - "longitude": -117.862915 - }, - { - "latitude": 33.787883, - "longitude": -117.86316 - }, - { - "latitude": 33.787883, - "longitude": -117.863697 - }, - { - "latitude": 33.787883, - "longitude": -117.863948 - }, - { - "latitude": 33.787883, - "longitude": -117.86401 - }, - { - "latitude": 33.787882, - "longitude": -117.864622 - }, - { - "latitude": 33.787882, - "longitude": -117.864681 - }, - { - "latitude": 33.787882, - "longitude": -117.86481 - }, - { - "latitude": 33.787882, - "longitude": -117.865075 - }, - { - "latitude": 33.787881, - "longitude": -117.865167 - }, - { - "latitude": 33.787881, - "longitude": -117.865442 - }, - { - "latitude": 33.787881, - "longitude": -117.865554 - }, - { - "latitude": 33.787938, - "longitude": -117.865679 - }, - { - "latitude": 33.787938, - "longitude": -117.866004 - }, - { - "latitude": 33.787939, - "longitude": -117.866335 - }, - { - "latitude": 33.78794, - "longitude": -117.866502 - }, - { - "latitude": 33.78794, - "longitude": -117.866645 - }, - { - "latitude": 33.787941, - "longitude": -117.866964 - }, - { - "latitude": 33.787942, - "longitude": -117.867104 - }, - { - "latitude": 33.787888, - "longitude": -117.867306 - }, - { - "latitude": 33.78788, - "longitude": -117.867944 - }, - { - "latitude": 33.78788, - "longitude": -117.86803 - }, - { - "latitude": 33.787881, - "longitude": -117.868222 - }, - { - "latitude": 33.787881, - "longitude": -117.868308 - }, - { - "latitude": 33.787881, - "longitude": -117.868433 - }, - { - "latitude": 33.787883, - "longitude": -117.868666 - }, - { - "latitude": 33.787885, - "longitude": -117.868824 - }, - { - "latitude": 33.787884, - "longitude": -117.869024 - }, - { - "latitude": 33.787886, - "longitude": -117.86919 - }, - { - "latitude": 33.787882, - "longitude": -117.869589 - }, - { - "latitude": 33.787882, - "longitude": -117.869975 - }, - { - "latitude": 33.787881, - "longitude": -117.870171 - }, - { - "latitude": 33.787883, - "longitude": -117.870517 - }, - { - "latitude": 33.787883, - "longitude": -117.870767 - }, - { - "latitude": 33.787883, - "longitude": -117.870965 - }, - { - "latitude": 33.787883, - "longitude": -117.871162 - }, - { - "latitude": 33.787883, - "longitude": -117.871513 - }, - { - "latitude": 33.787884, - "longitude": -117.871607 - }, - { - "latitude": 33.787883, - "longitude": -117.871711 - }, - { - "latitude": 33.787883, - "longitude": -117.871983 - }, - { - "latitude": 33.787883, - "longitude": -117.872161 - }, - { - "latitude": 33.787883, - "longitude": -117.872335 - }, - { - "latitude": 33.787883, - "longitude": -117.872562 - }, - { - "latitude": 33.787883, - "longitude": -117.872633 - }, - { - "latitude": 33.787883, - "longitude": -117.872802 - }, - { - "latitude": 33.787883, - "longitude": -117.873037 - }, - { - "latitude": 33.787883, - "longitude": -117.873109 - }, - { - "latitude": 33.787883, - "longitude": -117.873275 - }, - { - "latitude": 33.787883, - "longitude": -117.873361 - }, - { - "latitude": 33.787884, - "longitude": -117.873785 - }, - { - "latitude": 33.787884, - "longitude": -117.873824 - }, - { - "latitude": 33.787886, - "longitude": -117.874423 - }, - { - "latitude": 33.787887, - "longitude": -117.874561 - }, - { - "latitude": 33.787886, - "longitude": -117.874578 - }, - { - "latitude": 33.787887, - "longitude": -117.87502 - }, - { - "latitude": 33.787887, - "longitude": -117.87527 - }, - { - "latitude": 33.787887, - "longitude": -117.875389 - }, - { - "latitude": 33.787887, - "longitude": -117.875705 - }, - { - "latitude": 33.787889, - "longitude": -117.876046 - }, - { - "latitude": 33.787888, - "longitude": -117.876358 - }, - { - "latitude": 33.787888, - "longitude": -117.876702 - }, - { - "latitude": 33.787888, - "longitude": -117.876796 - }, - { - "latitude": 33.787889, - "longitude": -117.877097 - }, - { - "latitude": 33.787888, - "longitude": -117.877582 - }, - { - "latitude": 33.787888, - "longitude": -117.877601 - }, - { - "latitude": 33.787889, - "longitude": -117.877685 - }, - { - "latitude": 33.787889, - "longitude": -117.877739 - }, - { - "latitude": 33.787889, - "longitude": -117.877933 - }, - { - "latitude": 33.78789, - "longitude": -117.878024 - }, - { - "latitude": 33.78789, - "longitude": -117.87815 - }, - { - "latitude": 33.78789, - "longitude": -117.87838 - }, - { - "latitude": 33.78789, - "longitude": -117.878542 - }, - { - "latitude": 33.787891, - "longitude": -117.878604 - }, - { - "latitude": 33.787891, - "longitude": -117.878628 - }, - { - "latitude": 33.78794, - "longitude": -117.878721 - }, - { - "latitude": 33.787946, - "longitude": -117.878943 - }, - { - "latitude": 33.78795, - "longitude": -117.87922 - }, - { - "latitude": 33.787947, - "longitude": -117.879543 - }, - { - "latitude": 33.787932, - "longitude": -117.88031 - }, - { - "latitude": 33.787929, - "longitude": -117.880465 - }, - { - "latitude": 33.78793, - "longitude": -117.880566 - }, - { - "latitude": 33.78793, - "longitude": -117.880625 - }, - { - "latitude": 33.787932, - "longitude": -117.881015 - }, - { - "latitude": 33.787869, - "longitude": -117.881189 - }, - { - "latitude": 33.787874, - "longitude": -117.881424 - }, - { - "latitude": 33.787877, - "longitude": -117.881621 - }, - { - "latitude": 33.787878, - "longitude": -117.881727 - }, - { - "latitude": 33.787883, - "longitude": -117.882974 - }, - { - "latitude": 33.787901, - "longitude": -117.883304 - }, - { - "latitude": 33.787909, - "longitude": -117.883363 - }, - { - "latitude": 33.787956, - "longitude": -117.883695 - }, - { - "latitude": 33.787992, - "longitude": -117.88383 - }, - { - "latitude": 33.788014, - "longitude": -117.883957 - }, - { - "latitude": 33.7881, - "longitude": -117.884219 - }, - { - "latitude": 33.788216, - "longitude": -117.884521 - }, - { - "latitude": 33.788229, - "longitude": -117.884547 - }, - { - "latitude": 33.788366, - "longitude": -117.884815 - }, - { - "latitude": 33.788446, - "longitude": -117.884941 - }, - { - "latitude": 33.788819, - "longitude": -117.885531 - }, - { - "latitude": 33.788893, - "longitude": -117.885639 - }, - { - "latitude": 33.789115, - "longitude": -117.886014 - }, - { - "latitude": 33.789139, - "longitude": -117.886074 - }, - { - "latitude": 33.789293, - "longitude": -117.886458 - }, - { - "latitude": 33.789491, - "longitude": -117.886321 - }, - { - "latitude": 33.789583, - "longitude": -117.886182 - }, - { - "latitude": 33.789622, - "longitude": -117.886127 - }, - { - "latitude": 33.789638, - "longitude": -117.886143 - }, - { - "latitude": 33.789733, - "longitude": -117.886236 - }, - { - "latitude": 33.789622, - "longitude": -117.886127 - }, - { - "latitude": 33.789573, - "longitude": -117.886197 - }, - { - "latitude": 33.789491, - "longitude": -117.886321 - }, - { - "latitude": 33.789293, - "longitude": -117.886458 - }, - { - "latitude": 33.789306, - "longitude": -117.886505 - }, - { - "latitude": 33.789392, - "longitude": -117.886814 - }, - { - "latitude": 33.789494, - "longitude": -117.887171 - }, - { - "latitude": 33.789507, - "longitude": -117.887303 - }, - { - "latitude": 33.789519, - "longitude": -117.887449 - }, - { - "latitude": 33.789504, - "longitude": -117.887942 - }, - { - "latitude": 33.789481, - "longitude": -117.888098 - }, - { - "latitude": 33.789454, - "longitude": -117.888414 - }, - { - "latitude": 33.789467, - "longitude": -117.888536 - }, - { - "latitude": 33.789477, - "longitude": -117.888629 - }, - { - "latitude": 33.789431, - "longitude": -117.888858 - }, - { - "latitude": 33.789193, - "longitude": -117.889833 - }, - { - "latitude": 33.789079, - "longitude": -117.890474 - }, - { - "latitude": 33.78907, - "longitude": -117.89054 - }, - { - "latitude": 33.78905, - "longitude": -117.890692 - }, - { - "latitude": 33.789213, - "longitude": -117.890685 - }, - { - "latitude": 33.78924, - "longitude": -117.890682 - }, - { - "latitude": 33.789443, - "longitude": -117.890661 - }, - { - "latitude": 33.789572, - "longitude": -117.890639 - }, - { - "latitude": 33.789673, - "longitude": -117.890617 - }, - { - "latitude": 33.789791, - "longitude": -117.890583 - }, - { - "latitude": 33.789972, - "longitude": -117.890523 - }, - { - "latitude": 33.789994, - "longitude": -117.890515 - }, - { - "latitude": 33.790393, - "longitude": -117.890382 - }, - { - "latitude": 33.790715, - "longitude": -117.890275 - }, - { - "latitude": 33.79084, - "longitude": -117.890233 - }, - { - "latitude": 33.791287, - "longitude": -117.89009 - }, - { - "latitude": 33.791676, - "longitude": -117.889959 - }, - { - "latitude": 33.791969, - "longitude": -117.889853 - }, - { - "latitude": 33.792429, - "longitude": -117.889707 - }, - { - "latitude": 33.792554, - "longitude": -117.889667 - }, - { - "latitude": 33.792652, - "longitude": -117.889635 - }, - { - "latitude": 33.793067, - "longitude": -117.889526 - }, - { - "latitude": 33.793455, - "longitude": -117.889409 - }, - { - "latitude": 33.793623, - "longitude": -117.889359 - }, - { - "latitude": 33.794047, - "longitude": -117.889238 - }, - { - "latitude": 33.794232, - "longitude": -117.889198 - }, - { - "latitude": 33.794418, - "longitude": -117.889168 - }, - { - "latitude": 33.79457, - "longitude": -117.889154 - }, - { - "latitude": 33.794724, - "longitude": -117.889139 - }, - { - "latitude": 33.794892, - "longitude": -117.889128 - }, - { - "latitude": 33.795108, - "longitude": -117.889122 - }, - { - "latitude": 33.795424, - "longitude": -117.889117 - }, - { - "latitude": 33.795532, - "longitude": -117.889111 - }, - { - "latitude": 33.795727, - "longitude": -117.8891 - }, - { - "latitude": 33.795934, - "longitude": -117.889091 - }, - { - "latitude": 33.796137, - "longitude": -117.889082 - }, - { - "latitude": 33.796513, - "longitude": -117.889083 - }, - { - "latitude": 33.79674, - "longitude": -117.889083 - }, - { - "latitude": 33.797121, - "longitude": -117.889083 - }, - { - "latitude": 33.797353, - "longitude": -117.889084 - }, - { - "latitude": 33.797687, - "longitude": -117.889084 - }, - { - "latitude": 33.797876, - "longitude": -117.889085 - }, - { - "latitude": 33.798169, - "longitude": -117.889089 - }, - { - "latitude": 33.798868, - "longitude": -117.889102 - }, - { - "latitude": 33.799531, - "longitude": -117.889114 - }, - { - "latitude": 33.799563, - "longitude": -117.889117 - }, - { - "latitude": 33.799711, - "longitude": -117.889176 - }, - { - "latitude": 33.799711, - "longitude": -117.889396 - }, - { - "latitude": 33.7997115, - "longitude": -117.889617 - }, - { - "latitude": 33.799712, - "longitude": -117.889838 - }, - { - "latitude": 33.799712, - "longitude": -117.890642 - }, - { - "latitude": 33.799556, - "longitude": -117.890647 - }, - { - "latitude": 33.7995, - "longitude": -117.890647 - }, - { - "latitude": 33.797874, - "longitude": -117.89064 - }, - { - "latitude": 33.797874, - "longitude": -117.89036 - }, - { - "latitude": 33.797874, - "longitude": -117.889265 - }, - { - "latitude": 33.797849, - "longitude": -117.889265 - }, - { - "latitude": 33.797103, - "longitude": -117.88928 - }, - { - "latitude": 33.796975, - "longitude": -117.889282 - }, - { - "latitude": 33.796354, - "longitude": -117.889295 - }, - { - "latitude": 33.796134, - "longitude": -117.889299 - }, - { - "latitude": 33.796133, - "longitude": -117.889494 - }, - { - "latitude": 33.796133, - "longitude": -117.889536 - }, - { - "latitude": 33.796132, - "longitude": -117.890043 - }, - { - "latitude": 33.796132, - "longitude": -117.89017 - }, - { - "latitude": 33.79613, - "longitude": -117.890704 - }, - { - "latitude": 33.796123, - "longitude": -117.891098 - }, - { - "latitude": 33.796187, - "longitude": -117.891393 - }, - { - "latitude": 33.796219, - "longitude": -117.891505 - }, - { - "latitude": 33.796219, - "longitude": -117.891519 - }, - { - "latitude": 33.79622, - "longitude": -117.891705 - }, - { - "latitude": 33.79622, - "longitude": -117.892011 - }, - { - "latitude": 33.796221, - "longitude": -117.892156 - }, - { - "latitude": 33.79622, - "longitude": -117.892656 - }, - { - "latitude": 33.796218, - "longitude": -117.893194 - }, - { - "latitude": 33.796221, - "longitude": -117.893779 - }, - { - "latitude": 33.796221, - "longitude": -117.893826 - }, - { - "latitude": 33.796222, - "longitude": -117.893992 - }, - { - "latitude": 33.796349, - "longitude": -117.894127 - }, - { - "latitude": 33.79697, - "longitude": -117.894781 - }, - { - "latitude": 33.797375, - "longitude": -117.895274 - }, - { - "latitude": 33.797553, - "longitude": -117.89546 - }, - { - "latitude": 33.797906, - "longitude": -117.895831 - }, - { - "latitude": 33.798837, - "longitude": -117.896728 - }, - { - "latitude": 33.799199, - "longitude": -117.897077 - }, - { - "latitude": 33.799409, - "longitude": -117.897262 - }, - { - "latitude": 33.799531, - "longitude": -117.897349 - }, - { - "latitude": 33.799914, - "longitude": -117.897622 - }, - { - "latitude": 33.800647, - "longitude": -117.898145 - }, - { - "latitude": 33.800834, - "longitude": -117.898141 - }, - { - "latitude": 33.800882, - "longitude": -117.89813 - }, - { - "latitude": 33.800913, - "longitude": -117.89811 - }, - { - "latitude": 33.800971, - "longitude": -117.89806 - }, - { - "latitude": 33.801007, - "longitude": -117.89801 - }, - { - "latitude": 33.801065, - "longitude": -117.897961 - }, - { - "latitude": 33.801111, - "longitude": -117.897921 - }, - { - "latitude": 33.801157, - "longitude": -117.897885 - }, - { - "latitude": 33.801252, - "longitude": -117.897844 - }, - { - "latitude": 33.801328, - "longitude": -117.897829 - }, - { - "latitude": 33.801397, - "longitude": -117.897823 - }, - { - "latitude": 33.8014, - "longitude": -117.897823 - }, - { - "latitude": 33.80156, - "longitude": -117.89783 - }, - { - "latitude": 33.801856, - "longitude": -117.897841 - }, - { - "latitude": 33.801885, - "longitude": -117.897842 - }, - { - "latitude": 33.802027, - "longitude": -117.897848 - }, - { - "latitude": 33.802583, - "longitude": -117.89787 - }, - { - "latitude": 33.802692, - "longitude": -117.897874 - }, - { - "latitude": 33.80274, - "longitude": -117.897876 - }, - { - "latitude": 33.802863, - "longitude": -117.897881 - }, - { - "latitude": 33.802899, - "longitude": -117.897882 - }, - { - "latitude": 33.802955, - "longitude": -117.897884 - }, - { - "latitude": 33.80317, - "longitude": -117.897893 - }, - { - "latitude": 33.803276, - "longitude": -117.897904 - }, - { - "latitude": 33.80342, - "longitude": -117.897907 - }, - { - "latitude": 33.803855, - "longitude": -117.897913 - }, - { - "latitude": 33.804052, - "longitude": -117.897914 - }, - { - "latitude": 33.804235, - "longitude": -117.897916 - }, - { - "latitude": 33.804746, - "longitude": -117.897919 - }, - { - "latitude": 33.805197, - "longitude": -117.897922 - }, - { - "latitude": 33.805198, - "longitude": -117.897256 - }, - { - "latitude": 33.805199, - "longitude": -117.896883 - }, - { - "latitude": 33.805194, - "longitude": -117.895951 - }, - { - "latitude": 33.805184, - "longitude": -117.895913 - }, - { - "latitude": 33.805155, - "longitude": -117.895877 - }, - { - "latitude": 33.805109, - "longitude": -117.895855 - }, - { - "latitude": 33.804577, - "longitude": -117.895853 - }, - { - "latitude": 33.804278, - "longitude": -117.895852 - }, - { - "latitude": 33.803586, - "longitude": -117.895845 - }, - { - "latitude": 33.803438, - "longitude": -117.895845 - }, - { - "latitude": 33.803435, - "longitude": -117.896293 - }, - { - "latitude": 33.803431, - "longitude": -117.89687 - }, - { - "latitude": 33.803428, - "longitude": -117.897051 - }, - { - "latitude": 33.803425, - "longitude": -117.897278 - }, - { - "latitude": 33.803424, - "longitude": -117.897351 - }, - { - "latitude": 33.80342, - "longitude": -117.897907 - }, - { - "latitude": 33.803404, - "longitude": -117.898058 - }, - { - "latitude": 33.803411, - "longitude": -117.898721 - }, - { - "latitude": 33.803415, - "longitude": -117.899152 - }, - { - "latitude": 33.803418, - "longitude": -117.899238 - }, - { - "latitude": 33.803422, - "longitude": -117.899462 - }, - { - "latitude": 33.803435, - "longitude": -117.899958 - }, - { - "latitude": 33.803436, - "longitude": -117.900207 - }, - { - "latitude": 33.803441, - "longitude": -117.900911 - }, - { - "latitude": 33.803438, - "longitude": -117.90115 - }, - { - "latitude": 33.803434, - "longitude": -117.901409 - }, - { - "latitude": 33.803424, - "longitude": -117.903146 - }, - { - "latitude": 33.803436, - "longitude": -117.903749 - }, - { - "latitude": 33.803445, - "longitude": -117.904164 - }, - { - "latitude": 33.803257, - "longitude": -117.904204 - }, - { - "latitude": 33.803135, - "longitude": -117.904206 - }, - { - "latitude": 33.803043, - "longitude": -117.904209 - }, - { - "latitude": 33.802649, - "longitude": -117.904221 - }, - { - "latitude": 33.802399, - "longitude": -117.90418 - }, - { - "latitude": 33.802255, - "longitude": -117.904083 - }, - { - "latitude": 33.802167, - "longitude": -117.90399 - }, - { - "latitude": 33.802157, - "longitude": -117.903976 - }, - { - "latitude": 33.80212, - "longitude": -117.903921 - }, - { - "latitude": 33.802043, - "longitude": -117.903771 - }, - { - "latitude": 33.802017, - "longitude": -117.903673 - }, - { - "latitude": 33.801993, - "longitude": -117.903558 - }, - { - "latitude": 33.801987, - "longitude": -117.90345 - }, - { - "latitude": 33.801983, - "longitude": -117.902367 - }, - { - "latitude": 33.801948, - "longitude": -117.902095 - }, - { - "latitude": 33.801926, - "longitude": -117.902026 - }, - { - "latitude": 33.801903, - "longitude": -117.901956 - }, - { - "latitude": 33.801839, - "longitude": -117.901826 - }, - { - "latitude": 33.801786, - "longitude": -117.901719 - }, - { - "latitude": 33.801701, - "longitude": -117.901609 - }, - { - "latitude": 33.801253, - "longitude": -117.901144 - }, - { - "latitude": 33.801145, - "longitude": -117.901027 - }, - { - "latitude": 33.801071, - "longitude": -117.900947 - }, - { - "latitude": 33.801056, - "longitude": -117.90093 - }, - { - "latitude": 33.800919, - "longitude": -117.900784 - }, - { - "latitude": 33.800634, - "longitude": -117.900488 - }, - { - "latitude": 33.800628, - "longitude": -117.900482 - }, - { - "latitude": 33.800479, - "longitude": -117.900333 - }, - { - "latitude": 33.799921, - "longitude": -117.899744 - }, - { - "latitude": 33.799472, - "longitude": -117.899273 - }, - { - "latitude": 33.799209, - "longitude": -117.898995 - }, - { - "latitude": 33.79906, - "longitude": -117.898839 - }, - { - "latitude": 33.798842, - "longitude": -117.8986 - }, - { - "latitude": 33.798741, - "longitude": -117.898492 - }, - { - "latitude": 33.798514, - "longitude": -117.898254 - }, - { - "latitude": 33.798244, - "longitude": -117.897975 - }, - { - "latitude": 33.7973, - "longitude": -117.896978 - }, - { - "latitude": 33.797195, - "longitude": -117.896868 - }, - { - "latitude": 33.796934, - "longitude": -117.8966 - }, - { - "latitude": 33.796921, - "longitude": -117.896587 - }, - { - "latitude": 33.796358, - "longitude": -117.896012 - }, - { - "latitude": 33.796335, - "longitude": -117.895988 - }, - { - "latitude": 33.7962, - "longitude": -117.89585 - }, - { - "latitude": 33.796072, - "longitude": -117.895716 - }, - { - "latitude": 33.79597, - "longitude": -117.895613 - }, - { - "latitude": 33.79589, - "longitude": -117.895532 - }, - { - "latitude": 33.795832, - "longitude": -117.895474 - }, - { - "latitude": 33.795668, - "longitude": -117.895309 - }, - { - "latitude": 33.79546, - "longitude": -117.895097 - }, - { - "latitude": 33.795195, - "longitude": -117.894826 - }, - { - "latitude": 33.794579, - "longitude": -117.894189 - }, - { - "latitude": 33.794425, - "longitude": -117.894039 - }, - { - "latitude": 33.794288, - "longitude": -117.893906 - }, - { - "latitude": 33.79418, - "longitude": -117.893804 - }, - { - "latitude": 33.794062, - "longitude": -117.893702 - }, - { - "latitude": 33.79396, - "longitude": -117.893618 - }, - { - "latitude": 33.793937, - "longitude": -117.893601 - }, - { - "latitude": 33.793865, - "longitude": -117.893551 - }, - { - "latitude": 33.793759, - "longitude": -117.893476 - }, - { - "latitude": 33.793631, - "longitude": -117.893388 - }, - { - "latitude": 33.793332, - "longitude": -117.893181 - }, - { - "latitude": 33.793226, - "longitude": -117.893108 - }, - { - "latitude": 33.793044, - "longitude": -117.892984 - }, - { - "latitude": 33.792893, - "longitude": -117.892889 - }, - { - "latitude": 33.792622, - "longitude": -117.892731 - }, - { - "latitude": 33.792372, - "longitude": -117.892583 - }, - { - "latitude": 33.792252, - "longitude": -117.892527 - }, - { - "latitude": 33.79216, - "longitude": -117.892494 - }, - { - "latitude": 33.79207, - "longitude": -117.892472 - }, - { - "latitude": 33.791975, - "longitude": -117.892457 - }, - { - "latitude": 33.791875, - "longitude": -117.89245 - }, - { - "latitude": 33.791768, - "longitude": -117.892452 - }, - { - "latitude": 33.791662, - "longitude": -117.892462 - }, - { - "latitude": 33.79155, - "longitude": -117.892486 - }, - { - "latitude": 33.791502, - "longitude": -117.892502 - }, - { - "latitude": 33.791409, - "longitude": -117.892539 - }, - { - "latitude": 33.791328, - "longitude": -117.892581 - }, - { - "latitude": 33.791066, - "longitude": -117.892748 - }, - { - "latitude": 33.790967, - "longitude": -117.89283 - }, - { - "latitude": 33.790931, - "longitude": -117.892855 - }, - { - "latitude": 33.790832, - "longitude": -117.892915 - }, - { - "latitude": 33.79073, - "longitude": -117.89299 - }, - { - "latitude": 33.790693, - "longitude": -117.893015 - }, - { - "latitude": 33.790582, - "longitude": -117.893076 - }, - { - "latitude": 33.790522, - "longitude": -117.8931 - }, - { - "latitude": 33.790457, - "longitude": -117.893121 - }, - { - "latitude": 33.790396, - "longitude": -117.893138 - }, - { - "latitude": 33.790328, - "longitude": -117.893149 - }, - { - "latitude": 33.790258, - "longitude": -117.893155 - }, - { - "latitude": 33.790169, - "longitude": -117.893159 - }, - { - "latitude": 33.790044, - "longitude": -117.893159 - }, - { - "latitude": 33.78993, - "longitude": -117.893134 - }, - { - "latitude": 33.789683, - "longitude": -117.893146 - }, - { - "latitude": 33.789567, - "longitude": -117.893145 - }, - { - "latitude": 33.789512, - "longitude": -117.893146 - }, - { - "latitude": 33.789169, - "longitude": -117.893144 - }, - { - "latitude": 33.789145, - "longitude": -117.893144 - }, - { - "latitude": 33.789016, - "longitude": -117.893145 - }, - { - "latitude": 33.788898, - "longitude": -117.893146 - }, - { - "latitude": 33.788902, - "longitude": -117.892245 - }, - { - "latitude": 33.788902, - "longitude": -117.892228 - }, - { - "latitude": 33.788905, - "longitude": -117.892119 - }, - { - "latitude": 33.788903, - "longitude": -117.891973 - }, - { - "latitude": 33.788903, - "longitude": -117.891704 - }, - { - "latitude": 33.788903, - "longitude": -117.891117 - }, - { - "latitude": 33.788903, - "longitude": -117.890936 - }, - { - "latitude": 33.788904, - "longitude": -117.8907 - }, - { - "latitude": 33.788909, - "longitude": -117.890516 - }, - { - "latitude": 33.788913, - "longitude": -117.890489 - }, - { - "latitude": 33.788954, - "longitude": -117.890197 - }, - { - "latitude": 33.78905, - "longitude": -117.889708 - }, - { - "latitude": 33.789086, - "longitude": -117.889537 - }, - { - "latitude": 33.789321, - "longitude": -117.88852 - }, - { - "latitude": 33.789397, - "longitude": -117.88846 - }, - { - "latitude": 33.789454, - "longitude": -117.888414 - }, - { - "latitude": 33.789481, - "longitude": -117.888098 - }, - { - "latitude": 33.789504, - "longitude": -117.887942 - }, - { - "latitude": 33.789519, - "longitude": -117.887449 - }, - { - "latitude": 33.789507, - "longitude": -117.887303 - }, - { - "latitude": 33.789494, - "longitude": -117.887171 - }, - { - "latitude": 33.789392, - "longitude": -117.886814 - }, - { - "latitude": 33.789293, - "longitude": -117.886458 - }, - { - "latitude": 33.789336, - "longitude": -117.886428 - }, - { - "latitude": 33.789491, - "longitude": -117.886321 - }, - { - "latitude": 33.789583, - "longitude": -117.886182 - }, - { - "latitude": 33.789622, - "longitude": -117.886127 - }, - { - "latitude": 33.789732, - "longitude": -117.886235 - }, - { - "latitude": 33.789622, - "longitude": -117.886127 - }, - { - "latitude": 33.789589, - "longitude": -117.886174 - }, - { - "latitude": 33.789491, - "longitude": -117.886321 - }, - { - "latitude": 33.789293, - "longitude": -117.886458 - }, - { - "latitude": 33.789241, - "longitude": -117.886328 - }, - { - "latitude": 33.789139, - "longitude": -117.886074 - }, - { - "latitude": 33.789115, - "longitude": -117.886014 - }, - { - "latitude": 33.788893, - "longitude": -117.885639 - }, - { - "latitude": 33.788819, - "longitude": -117.885531 - }, - { - "latitude": 33.788446, - "longitude": -117.884941 - }, - { - "latitude": 33.788366, - "longitude": -117.884815 - }, - { - "latitude": 33.788229, - "longitude": -117.884547 - }, - { - "latitude": 33.788216, - "longitude": -117.884521 - }, - { - "latitude": 33.7881, - "longitude": -117.884219 - }, - { - "latitude": 33.788014, - "longitude": -117.883957 - }, - { - "latitude": 33.787992, - "longitude": -117.88383 - }, - { - "latitude": 33.787956, - "longitude": -117.883695 - }, - { - "latitude": 33.787909, - "longitude": -117.883363 - }, - { - "latitude": 33.787901, - "longitude": -117.883304 - }, - { - "latitude": 33.787883, - "longitude": -117.882974 - }, - { - "latitude": 33.787878, - "longitude": -117.881727 - }, - { - "latitude": 33.787877, - "longitude": -117.881621 - }, - { - "latitude": 33.787874, - "longitude": -117.881424 - }, - { - "latitude": 33.787869, - "longitude": -117.881189 - }, - { - "latitude": 33.787837, - "longitude": -117.881017 - }, - { - "latitude": 33.78784, - "longitude": -117.880648 - }, - { - "latitude": 33.78784, - "longitude": -117.880625 - }, - { - "latitude": 33.787841, - "longitude": -117.880435 - }, - { - "latitude": 33.787847, - "longitude": -117.879556 - }, - { - "latitude": 33.787844, - "longitude": -117.87913 - }, - { - "latitude": 33.787844, - "longitude": -117.879052 - }, - { - "latitude": 33.787843, - "longitude": -117.878851 - }, - { - "latitude": 33.787842, - "longitude": -117.87872 - }, - { - "latitude": 33.787891, - "longitude": -117.878628 - }, - { - "latitude": 33.787891, - "longitude": -117.878604 - }, - { - "latitude": 33.78789, - "longitude": -117.878542 - }, - { - "latitude": 33.78789, - "longitude": -117.87838 - }, - { - "latitude": 33.78789, - "longitude": -117.87815 - }, - { - "latitude": 33.78789, - "longitude": -117.878024 - }, - { - "latitude": 33.787889, - "longitude": -117.877933 - }, - { - "latitude": 33.787889, - "longitude": -117.877739 - }, - { - "latitude": 33.787889, - "longitude": -117.877685 - }, - { - "latitude": 33.787888, - "longitude": -117.877601 - }, - { - "latitude": 33.787888, - "longitude": -117.877582 - }, - { - "latitude": 33.787889, - "longitude": -117.877097 - }, - { - "latitude": 33.787888, - "longitude": -117.876796 - }, - { - "latitude": 33.787888, - "longitude": -117.876702 - }, - { - "latitude": 33.787888, - "longitude": -117.876358 - }, - { - "latitude": 33.787889, - "longitude": -117.876046 - }, - { - "latitude": 33.787887, - "longitude": -117.875705 - }, - { - "latitude": 33.787887, - "longitude": -117.875554 - }, - { - "latitude": 33.787887, - "longitude": -117.875389 - }, - { - "latitude": 33.787887, - "longitude": -117.87527 - }, - { - "latitude": 33.787887, - "longitude": -117.87502 - }, - { - "latitude": 33.787886, - "longitude": -117.874578 - }, - { - "latitude": 33.787887, - "longitude": -117.874561 - }, - { - "latitude": 33.787886, - "longitude": -117.874423 - }, - { - "latitude": 33.787884, - "longitude": -117.873824 - }, - { - "latitude": 33.787884, - "longitude": -117.873785 - }, - { - "latitude": 33.787883, - "longitude": -117.873361 - }, - { - "latitude": 33.787883, - "longitude": -117.873275 - }, - { - "latitude": 33.787883, - "longitude": -117.873109 - }, - { - "latitude": 33.787883, - "longitude": -117.873037 - }, - { - "latitude": 33.787883, - "longitude": -117.872802 - }, - { - "latitude": 33.787883, - "longitude": -117.872633 - }, - { - "latitude": 33.787883, - "longitude": -117.872562 - }, - { - "latitude": 33.787883, - "longitude": -117.872335 - }, - { - "latitude": 33.787883, - "longitude": -117.871983 - }, - { - "latitude": 33.787883, - "longitude": -117.871711 - }, - { - "latitude": 33.787884, - "longitude": -117.871607 - }, - { - "latitude": 33.787883, - "longitude": -117.871513 - }, - { - "latitude": 33.787883, - "longitude": -117.871162 - }, - { - "latitude": 33.787883, - "longitude": -117.870965 - }, - { - "latitude": 33.787883, - "longitude": -117.870767 - }, - { - "latitude": 33.787883, - "longitude": -117.870517 - }, - { - "latitude": 33.787881, - "longitude": -117.870171 - }, - { - "latitude": 33.787882, - "longitude": -117.869975 - }, - { - "latitude": 33.787882, - "longitude": -117.869589 - }, - { - "latitude": 33.787886, - "longitude": -117.86919 - }, - { - "latitude": 33.787884, - "longitude": -117.869024 - }, - { - "latitude": 33.787885, - "longitude": -117.868824 - }, - { - "latitude": 33.787883, - "longitude": -117.868666 - }, - { - "latitude": 33.787881, - "longitude": -117.868433 - }, - { - "latitude": 33.787881, - "longitude": -117.868308 - }, - { - "latitude": 33.787881, - "longitude": -117.868222 - }, - { - "latitude": 33.78788, - "longitude": -117.86803 - }, - { - "latitude": 33.78788, - "longitude": -117.867944 - }, - { - "latitude": 33.787888, - "longitude": -117.867306 - }, - { - "latitude": 33.787838, - "longitude": -117.867107 - }, - { - "latitude": 33.787838, - "longitude": -117.866715 - }, - { - "latitude": 33.787838, - "longitude": -117.866531 - }, - { - "latitude": 33.787838, - "longitude": -117.866114 - }, - { - "latitude": 33.787838, - "longitude": -117.865902 - }, - { - "latitude": 33.787838, - "longitude": -117.86567 - }, - { - "latitude": 33.787881, - "longitude": -117.865554 - }, - { - "latitude": 33.787881, - "longitude": -117.865442 - }, - { - "latitude": 33.787881, - "longitude": -117.865167 - }, - { - "latitude": 33.787882, - "longitude": -117.865075 - }, - { - "latitude": 33.787882, - "longitude": -117.86481 - }, - { - "latitude": 33.787882, - "longitude": -117.864681 - }, - { - "latitude": 33.787882, - "longitude": -117.864622 - }, - { - "latitude": 33.787883, - "longitude": -117.86401 - }, - { - "latitude": 33.787883, - "longitude": -117.863948 - }, - { - "latitude": 33.787883, - "longitude": -117.863697 - }, - { - "latitude": 33.787883, - "longitude": -117.86316 - }, - { - "latitude": 33.787884, - "longitude": -117.862915 - }, - { - "latitude": 33.787884, - "longitude": -117.862841 - }, - { - "latitude": 33.787884, - "longitude": -117.862684 - }, - { - "latitude": 33.787884, - "longitude": -117.862405 - }, - { - "latitude": 33.787884, - "longitude": -117.862261 - }, - { - "latitude": 33.787885, - "longitude": -117.861974 - }, - { - "latitude": 33.787884, - "longitude": -117.861825 - }, - { - "latitude": 33.787885, - "longitude": -117.861692 - }, - { - "latitude": 33.787885, - "longitude": -117.861519 - }, - { - "latitude": 33.787886, - "longitude": -117.861197 - }, - { - "latitude": 33.787887, - "longitude": -117.860739 - }, - { - "latitude": 33.787887, - "longitude": -117.860435 - }, - { - "latitude": 33.787887, - "longitude": -117.860392 - }, - { - "latitude": 33.787888, - "longitude": -117.860093 - }, - { - "latitude": 33.787888, - "longitude": -117.85999 - }, - { - "latitude": 33.787889, - "longitude": -117.859655 - }, - { - "latitude": 33.787888, - "longitude": -117.859323 - }, - { - "latitude": 33.787888, - "longitude": -117.859286 - }, - { - "latitude": 33.787888, - "longitude": -117.858868 - }, - { - "latitude": 33.787888, - "longitude": -117.858716 - }, - { - "latitude": 33.787887, - "longitude": -117.858556 - }, - { - "latitude": 33.787887, - "longitude": -117.858476 - }, - { - "latitude": 33.787887, - "longitude": -117.858293 - }, - { - "latitude": 33.787887, - "longitude": -117.858081 - }, - { - "latitude": 33.787886, - "longitude": -117.857986 - }, - { - "latitude": 33.787846, - "longitude": -117.857894 - }, - { - "latitude": 33.787846, - "longitude": -117.857828 - }, - { - "latitude": 33.787846, - "longitude": -117.857771 - }, - { - "latitude": 33.787847, - "longitude": -117.857518 - }, - { - "latitude": 33.787848, - "longitude": -117.85746 - }, - { - "latitude": 33.787848, - "longitude": -117.857265 - }, - { - "latitude": 33.787849, - "longitude": -117.85709 - }, - { - "latitude": 33.787849, - "longitude": -117.857041 - }, - { - "latitude": 33.787891, - "longitude": -117.856976 - }, - { - "latitude": 33.787891, - "longitude": -117.856888 - }, - { - "latitude": 33.78789, - "longitude": -117.856602 - }, - { - "latitude": 33.787889, - "longitude": -117.85647 - }, - { - "latitude": 33.787889, - "longitude": -117.856375 - }, - { - "latitude": 33.78797, - "longitude": -117.856375 - }, - { - "latitude": 33.788076, - "longitude": -117.856372 - }, - { - "latitude": 33.788321, - "longitude": -117.856366 - }, - { - "latitude": 33.789279, - "longitude": -117.856365 - }, - { - "latitude": 33.78962, - "longitude": -117.85637 - }, - { - "latitude": 33.789701, - "longitude": -117.856365 - }, - { - "latitude": 33.789782, - "longitude": -117.856364 - }, - { - "latitude": 33.790351, - "longitude": -117.85636 - }, - { - "latitude": 33.791312, - "longitude": -117.856358 - }, - { - "latitude": 33.791437, - "longitude": -117.856363 - }, - { - "latitude": 33.791517, - "longitude": -117.856367 - }, - { - "latitude": 33.791598, - "longitude": -117.856366 - }, - { - "latitude": 33.793323, - "longitude": -117.856355 - }, - { - "latitude": 33.793322, - "longitude": -117.855591 - }, - { - "latitude": 33.793322, - "longitude": -117.855284 - }, - { - "latitude": 33.79331, - "longitude": -117.854187 - }, - { - "latitude": 33.793311, - "longitude": -117.853698 - }, - { - "latitude": 33.793311, - "longitude": -117.853367 - }, - { - "latitude": 33.793311, - "longitude": -117.853212 - }, - { - "latitude": 33.793311, - "longitude": -117.853097 - }, - { - "latitude": 33.793315, - "longitude": -117.852996 - }, - { - "latitude": 33.793316, - "longitude": -117.85281 - }, - { - "latitude": 33.793325, - "longitude": -117.85281 - } -] - -const routes: IRoute[] = [ - { - name: "Red Route", - id: "1", - systemId: supportedIntegrationTestSystems[0].id, - polylineCoordinates: redRoutePolylineCoordinates, - color: "#db2316", - updatedTime: new Date(), - }, - { - name: "Teal Route", - id: "2", - systemId: supportedIntegrationTestSystems[0].id, - polylineCoordinates: tealRoutePolylineCoordinates, - color: "#21bdd1", - updatedTime: new Date(), - }, -]; - -const stops: IStop[] = [ - { - id: "1", - name: "Chapman Court", - coordinates: { - latitude: 33.796001, - longitude: -117.8892805, - }, - systemId: supportedIntegrationTestSystems[0].id, - updatedTime: new Date(), - }, - { - id: "2", - name: "Chapman Grand", - coordinates: { - latitude: 33.804433, - longitude: -117.895966, - }, - systemId: supportedIntegrationTestSystems[0].id, - updatedTime: new Date(), - }, - { - id: "3", - name: "Schmid Gate", - coordinates: { - "latitude": 33.793325, - "longitude": -117.85281 - }, - systemId: supportedIntegrationTestSystems[0].id, - updatedTime: new Date(), - } -]; - -const orderedStopsForRedRoute: IOrderedStop[] = [ - { - routeId: routes[0].id, - stopId: stops[0].id, - position: 1, - systemId: "1", - updatedTime: new Date(), - }, - { - routeId: routes[0].id, - stopId: stops[2].id, - position: 2, - systemId: "1", - updatedTime: new Date(), - }, -]; - -const orderedStopsForTealRoute: IOrderedStop[] = [ - { - routeId: routes[1].id, - stopId: stops[0].id, - position: 1, - systemId: supportedIntegrationTestSystems[0].id, - updatedTime: new Date(), - }, - { - routeId: routes[1].id, - stopId: stops[1].id, - position: 2, - systemId: supportedIntegrationTestSystems[0].id, - updatedTime: new Date(), - }, - { - routeId: routes[1].id, - stopId: stops[2].id, - position: 2, - systemId: supportedIntegrationTestSystems[0].id, - updatedTime: new Date(), - }, -] - -orderedStopsForRedRoute[0].nextStop = orderedStopsForRedRoute[1]; -orderedStopsForRedRoute[1].previousStop = orderedStopsForRedRoute[0]; -orderedStopsForTealRoute[0].nextStop = orderedStopsForTealRoute[1]; -orderedStopsForTealRoute[1].previousStop = orderedStopsForTealRoute[0]; - -const shuttles: IShuttle[] = [ - { - name: "17", - id: "1", - coordinates: { - latitude: 33.788021, - longitude: -117.883698, - }, - routeId: routes[0].id, - systemId: supportedIntegrationTestSystems[0].id, - orientationInDegrees: 45.91, - updatedTime: new Date(), - }, - { - name: "24", - id: "2", - coordinates: { - latitude: 33.787841, - longitude: -117.862825, - }, - routeId: routes[0].id, - systemId: supportedIntegrationTestSystems[0].id, - orientationInDegrees: 90.24, - updatedTime: new Date(), - }, - { - name: "32", - id: "3", - coordinates: { - // 33.79243° N, 117.85638° W - latitude: 33.79243, - longitude: -117.85638 - }, - routeId: routes[0].id, - systemId: supportedIntegrationTestSystems[0].id, - orientationInDegrees: 180.11, - updatedTime: new Date(), - } -]; - -const etas: IEta[] = [ - { - stopId: stops[0].id, - shuttleId: shuttles[0].id, - secondsRemaining: 12.023, - systemId: supportedIntegrationTestSystems[0].id, - updatedTime: new Date(), - }, - { - stopId: stops[2].id, - shuttleId: shuttles[0].id, - secondsRemaining: 600.123, - systemId: supportedIntegrationTestSystems[0].id, - updatedTime: new Date(), - }, - { - stopId: stops[2].id, - shuttleId: shuttles[1].id, - secondsRemaining: 172.015, - systemId: supportedIntegrationTestSystems[0].id, - updatedTime: new Date(), - }, - { - stopId: stops[0].id, - shuttleId: shuttles[1].id, - secondsRemaining: 710.152, - systemId: supportedIntegrationTestSystems[0].id, - updatedTime: new Date(), - }, - { - stopId: stops[0].id, - shuttleId: shuttles[2].id, - secondsRemaining: 540.192, - systemId: supportedIntegrationTestSystems[0].id, - updatedTime: new Date(), - }, -]; - -export async function loadShuttleTestData(repository: ShuttleGetterSetterRepository) { - await Promise.all(routes.map(async (route) => { - await repository.addOrUpdateRoute(route); - })); - await Promise.all(shuttles.map(async (shuttle) => { - await repository.addOrUpdateShuttle(shuttle); - })); - await Promise.all(stops.map(async (stop) => { - await repository.addOrUpdateStop(stop); - })); - await Promise.all(orderedStopsForRedRoute.map(async (orderedStop) => { - await repository.addOrUpdateOrderedStop(orderedStop); - })); - await Promise.all(orderedStopsForTealRoute.map(async (orderedStop) => { - await repository.addOrUpdateOrderedStop(orderedStop); - })); - await Promise.all(etas.map(async (eta) => { - await repository.addOrUpdateEta(eta); - })); -} From 41bea7b693bcd9082e1e3b9b1c2e2a9ea9d55fee Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Mon, 10 Nov 2025 20:26:44 -0800 Subject: [PATCH 28/96] Remove unused code within ApiBasedShuttleRepositoryLoader --- .../ApiBasedShuttleRepositoryLoader.ts | 66 +------------------ .../shuttle/ShuttleRepositoryLoader.ts | 2 - 2 files changed, 1 insertion(+), 67 deletions(-) diff --git a/src/loaders/shuttle/ApiBasedShuttleRepositoryLoader.ts b/src/loaders/shuttle/ApiBasedShuttleRepositoryLoader.ts index e2e6a0d..4d50dcf 100644 --- a/src/loaders/shuttle/ApiBasedShuttleRepositoryLoader.ts +++ b/src/loaders/shuttle/ApiBasedShuttleRepositoryLoader.ts @@ -1,5 +1,5 @@ import { ShuttleGetterSetterRepository } from "../../repositories/shuttle/ShuttleGetterSetterRepository"; -import { IEta, IRoute, IShuttle, IStop } from "../../entities/ShuttleRepositoryEntities"; +import { IRoute, IShuttle, IStop } from "../../entities/ShuttleRepositoryEntities"; import { ShuttleRepositoryLoader } from "./ShuttleRepositoryLoader"; import { ICoordinates, IEntityWithId } from "../../entities/SharedEntities"; import { ApiResponseError } from "../ApiResponseError"; @@ -34,11 +34,6 @@ export class ApiBasedShuttleRepositoryLoader implements ShuttleRepositoryLoader await this.updateRouteDataForSystem(); await this.updateStopAndPolylineDataForRoutesInSystem(); await this.updateShuttleDataForSystemBasedOnProximityToRoutes(); - - // Because ETA method doesn't support pruning yet, - // add a call to the clear method here - await this.repository.clearEtaData(); - await this.updateEtaDataForExistingStopsForSystem(); } public async updateRouteDataForSystem() { @@ -238,65 +233,6 @@ export class ApiBasedShuttleRepositoryLoader implements ShuttleRepositoryLoader return null; } - public async updateEtaDataForExistingStopsForSystem() { - const stops = await this.repository.getStops(); - await Promise.all(stops.map(async (stop) => { - let stopId = stop.id; - await this.updateEtaDataForStopId(stopId); - })); - } - - public async updateEtaDataForStopId(stopId: string) { - try { - const json = await this.fetchEtaDataJson(stopId); - const etas = this.constructEtasFromJson(json, stopId); - if (etas !== null) { - await this.updateEtaDataInRepository(etas); - } else { - console.warn(`ETA update failed for stop ${stopId} with the following JSON: ${JSON.stringify(json)}`); - } - } catch(e: any) { - throw new ApiResponseError(e.message); - } - } - - private async updateEtaDataInRepository(etas: IEta[]) { - // ETAs are now calculated internally by the repository based on shuttle movements - // External ETAs from the API are no longer used - } - - private async fetchEtaDataJson(stopId: string) { - const params = { - eta: "3", - stopIds: stopId, - }; - - const query = new URLSearchParams(params).toString(); - - const response = await fetch(`${this.baseUrl}?${query}`, { - method: "GET", - }); - return await response.json(); - } - - private constructEtasFromJson(json: any, stopId: string): IEta[] | null { - if (json.ETAs && json.ETAs[stopId]) { - return json.ETAs[stopId].map((jsonEta: any) => { - const shuttleId: string = jsonEta.busId; - const eta: IEta = { - secondsRemaining: jsonEta.secondsSpent, - shuttleId: `${shuttleId}`, - stopId: stopId, - updatedTime: new Date(), - systemId: this.systemIdForConstructedData, - }; - return eta; - }); - } - - return null; - } - protected async updateStopDataForSystemAndApiResponse( json: any, setOfIdsToPrune: Set = new Set(), diff --git a/src/loaders/shuttle/ShuttleRepositoryLoader.ts b/src/loaders/shuttle/ShuttleRepositoryLoader.ts index d556455..ea67d22 100644 --- a/src/loaders/shuttle/ShuttleRepositoryLoader.ts +++ b/src/loaders/shuttle/ShuttleRepositoryLoader.ts @@ -4,6 +4,4 @@ export interface ShuttleRepositoryLoader extends RepositoryLoader { updateRouteDataForSystem(): Promise; updateStopAndPolylineDataForRoutesInSystem(): Promise; updateShuttleDataForSystemBasedOnProximityToRoutes(): Promise; - updateEtaDataForExistingStopsForSystem(): Promise; - updateEtaDataForStopId(stopId: string): Promise; } From 1783d5f6f77a024424e5ab9d0603116da3e4c4de Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Tue, 11 Nov 2025 10:51:34 -0800 Subject: [PATCH 29/96] Define a set of interfaces for the ETA repository --- .../shuttle/eta/ETAGetterRepository.ts | 37 +++++++++++++++++++ .../shuttle/eta/ETAGetterSetterRepository.ts | 11 ++++++ .../shuttle/eta/SelfUpdatingETARepository.ts | 9 +++++ 3 files changed, 57 insertions(+) create mode 100644 src/repositories/shuttle/eta/ETAGetterRepository.ts create mode 100644 src/repositories/shuttle/eta/ETAGetterSetterRepository.ts create mode 100644 src/repositories/shuttle/eta/SelfUpdatingETARepository.ts diff --git a/src/repositories/shuttle/eta/ETAGetterRepository.ts b/src/repositories/shuttle/eta/ETAGetterRepository.ts new file mode 100644 index 0000000..a1f8255 --- /dev/null +++ b/src/repositories/shuttle/eta/ETAGetterRepository.ts @@ -0,0 +1,37 @@ +import { EventEmitter } from "stream"; +import { IEta } from "../../../entities/ShuttleRepositoryEntities"; + +// TODO: Remove these events in ShuttleGetterRepository + +export const ETARepositoryEvent = { + ETA_UPDATED: "etaUpdated", + ETA_REMOVED: "etaRemoved", + ETA_DATA_CLEARED: "etaDataCleared", +} as const; + +export type ETARepositoryEventName = typeof ETARepositoryEvent[keyof typeof ETARepositoryEvent]; + +export type EtaRemovedEventPayload = IEta; +export type EtaDataClearedEventPayload = IEta[]; + +export interface ETARepositoryEventPayloads { + [ETARepositoryEvent.ETA_UPDATED]: IEta; + [ETARepositoryEvent.ETA_REMOVED]: EtaRemovedEventPayload; + [ETARepositoryEvent.ETA_DATA_CLEARED]: EtaDataClearedEventPayload; +} + +export type ETARepositoryEventListener = ( + payload: ETARepositoryEventPayloads[T], +) => void; + +export interface ETAGetterRepository extends EventEmitter { + on(event: T, listener: ETARepositoryEventListener): this; + once(event: T, listener: ETARepositoryEventListener): this; + off(event: T, listener: ETARepositoryEventListener): this; + addListener(event: T, listener: ETARepositoryEventListener): this; + removeListener(event: T, listener: ETARepositoryEventListener): this; + + getEtasForShuttleId(shuttleId: string): Promise; + getEtasForStopId(stopId: string): Promise; + getEtaForShuttleAndStopId(shuttleId: string, stopId: string): Promise; +} diff --git a/src/repositories/shuttle/eta/ETAGetterSetterRepository.ts b/src/repositories/shuttle/eta/ETAGetterSetterRepository.ts new file mode 100644 index 0000000..53dfe5d --- /dev/null +++ b/src/repositories/shuttle/eta/ETAGetterSetterRepository.ts @@ -0,0 +1,11 @@ +import { IEta } from "../../../entities/ShuttleRepositoryEntities"; +import { ETAGetterRepository } from "./ETAGetterRepository"; + +export interface ExternalSourceETARepository extends ETAGetterRepository { + /** + * Add or update an ETA from an external source (e.g., API or test data). + */ + addOrUpdateEtaFromExternalSource(eta: IEta): Promise; + + removeEtaIfExists(shuttleId: string, stopId: string): Promise; +} diff --git a/src/repositories/shuttle/eta/SelfUpdatingETARepository.ts b/src/repositories/shuttle/eta/SelfUpdatingETARepository.ts new file mode 100644 index 0000000..7f2c9f0 --- /dev/null +++ b/src/repositories/shuttle/eta/SelfUpdatingETARepository.ts @@ -0,0 +1,9 @@ +import { ETAGetterRepository } from "./ETAGetterRepository"; + +export interface SelfUpdatingETARepository extends ETAGetterRepository { + /** + * Attach a event listener to the shuttle repository, listening to + * shuttle updates + */ + startListeningForUpdates(): void; +} From 53e120cd1e3dcec9fbbba75192aebd07103d8895 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Tue, 11 Nov 2025 10:54:46 -0800 Subject: [PATCH 30/96] Add stubs for Redis implementations of both ETA repository variants --- ...tory.ts => ExternalSourceETARepository.ts} | 0 .../eta/RedisExternalSourceETARepository.ts | 66 +++++++++++++++++++ .../eta/RedisSelfUpdatingETARepository.ts | 64 ++++++++++++++++++ 3 files changed, 130 insertions(+) rename src/repositories/shuttle/eta/{ETAGetterSetterRepository.ts => ExternalSourceETARepository.ts} (100%) create mode 100644 src/repositories/shuttle/eta/RedisExternalSourceETARepository.ts create mode 100644 src/repositories/shuttle/eta/RedisSelfUpdatingETARepository.ts diff --git a/src/repositories/shuttle/eta/ETAGetterSetterRepository.ts b/src/repositories/shuttle/eta/ExternalSourceETARepository.ts similarity index 100% rename from src/repositories/shuttle/eta/ETAGetterSetterRepository.ts rename to src/repositories/shuttle/eta/ExternalSourceETARepository.ts diff --git a/src/repositories/shuttle/eta/RedisExternalSourceETARepository.ts b/src/repositories/shuttle/eta/RedisExternalSourceETARepository.ts new file mode 100644 index 0000000..c4cff9b --- /dev/null +++ b/src/repositories/shuttle/eta/RedisExternalSourceETARepository.ts @@ -0,0 +1,66 @@ +import { IEta } from "../../../entities/ShuttleRepositoryEntities"; +import { ETARepositoryEventName, ETARepositoryEventListener } from "./ETAGetterRepository"; +import { ExternalSourceETARepository } from "./ExternalSourceETARepository"; + +export class RedisExternalSourceETARepository implements ExternalSourceETARepository { + addOrUpdateEtaFromExternalSource(eta: IEta): Promise { + throw new Error("Method not implemented."); + } + removeEtaIfExists(shuttleId: string, stopId: string): Promise { + throw new Error("Method not implemented."); + } + on(event: T, listener: ETARepositoryEventListener): this { + throw new Error("Method not implemented."); + } + once(event: T, listener: ETARepositoryEventListener): this { + throw new Error("Method not implemented."); + } + off(event: T, listener: ETARepositoryEventListener): this { + throw new Error("Method not implemented."); + } + addListener(event: T, listener: ETARepositoryEventListener): this { + throw new Error("Method not implemented."); + } + removeListener(event: T, listener: ETARepositoryEventListener): this { + throw new Error("Method not implemented."); + } + getEtasForShuttleId(shuttleId: string): Promise { + throw new Error("Method not implemented."); + } + getEtasForStopId(stopId: string): Promise { + throw new Error("Method not implemented."); + } + getEtaForShuttleAndStopId(shuttleId: string, stopId: string): Promise { + throw new Error("Method not implemented."); + } + removeAllListeners(eventName?: string | symbol | undefined): this { + throw new Error("Method not implemented."); + } + setMaxListeners(n: number): this { + throw new Error("Method not implemented."); + } + getMaxListeners(): number { + throw new Error("Method not implemented."); + } + listeners(eventName: string | symbol): Function[] { + throw new Error("Method not implemented."); + } + rawListeners(eventName: string | symbol): Function[] { + throw new Error("Method not implemented."); + } + emit(eventName: string | symbol, ...args: any[]): boolean { + throw new Error("Method not implemented."); + } + listenerCount(eventName: string | symbol, listener?: Function | undefined): number { + throw new Error("Method not implemented."); + } + prependListener(eventName: string | symbol, listener: (...args: any[]) => void): this { + throw new Error("Method not implemented."); + } + prependOnceListener(eventName: string | symbol, listener: (...args: any[]) => void): this { + throw new Error("Method not implemented."); + } + eventNames(): (string | symbol)[] { + throw new Error("Method not implemented."); + } +} diff --git a/src/repositories/shuttle/eta/RedisSelfUpdatingETARepository.ts b/src/repositories/shuttle/eta/RedisSelfUpdatingETARepository.ts new file mode 100644 index 0000000..797571f --- /dev/null +++ b/src/repositories/shuttle/eta/RedisSelfUpdatingETARepository.ts @@ -0,0 +1,64 @@ +import { IEta } from "../../../entities/ShuttleRepositoryEntities"; +import { ETARepositoryEventName, ETARepositoryEventListener } from "./ETAGetterRepository"; +import { SelfUpdatingETARepository } from "./SelfUpdatingETARepository"; + +export class RedisSelfUpdatingETARepository implements SelfUpdatingETARepository { + startListeningForUpdates(): void { + throw new Error("Method not implemented."); + } + on(event: T, listener: ETARepositoryEventListener): this { + throw new Error("Method not implemented."); + } + once(event: T, listener: ETARepositoryEventListener): this { + throw new Error("Method not implemented."); + } + off(event: T, listener: ETARepositoryEventListener): this { + throw new Error("Method not implemented."); + } + addListener(event: T, listener: ETARepositoryEventListener): this { + throw new Error("Method not implemented."); + } + removeListener(event: T, listener: ETARepositoryEventListener): this { + throw new Error("Method not implemented."); + } + getEtasForShuttleId(shuttleId: string): Promise { + throw new Error("Method not implemented."); + } + getEtasForStopId(stopId: string): Promise { + throw new Error("Method not implemented."); + } + getEtaForShuttleAndStopId(shuttleId: string, stopId: string): Promise { + throw new Error("Method not implemented."); + } + removeAllListeners(eventName?: string | symbol | undefined): this { + throw new Error("Method not implemented."); + } + setMaxListeners(n: number): this { + throw new Error("Method not implemented."); + } + getMaxListeners(): number { + throw new Error("Method not implemented."); + } + listeners(eventName: string | symbol): Function[] { + throw new Error("Method not implemented."); + } + rawListeners(eventName: string | symbol): Function[] { + throw new Error("Method not implemented."); + } + emit(eventName: string | symbol, ...args: any[]): boolean { + throw new Error("Method not implemented."); + } + listenerCount(eventName: string | symbol, listener?: Function | undefined): number { + throw new Error("Method not implemented."); + } + prependListener(eventName: string | symbol, listener: (...args: any[]) => void): this { + throw new Error("Method not implemented."); + } + prependOnceListener(eventName: string | symbol, listener: (...args: any[]) => void): this { + throw new Error("Method not implemented."); + } + eventNames(): (string | symbol)[] { + throw new Error("Method not implemented."); + } + +} From c43201b8675bc780c3d652ea3e4db3084d6333bd Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Tue, 11 Nov 2025 10:59:20 -0800 Subject: [PATCH 31/96] Move RepositoryHolder into test helpers, as it's shared across shared tests --- .../__tests__/ParkingRepositorySharedTests.test.ts | 11 +++-------- .../__tests__/ShuttleRepositorySharedTests.test.ts | 11 +++-------- testHelpers/RepositoryHolder.ts | 5 +++++ 3 files changed, 11 insertions(+), 16 deletions(-) create mode 100644 testHelpers/RepositoryHolder.ts diff --git a/src/repositories/parking/__tests__/ParkingRepositorySharedTests.test.ts b/src/repositories/parking/__tests__/ParkingRepositorySharedTests.test.ts index feabfa1..426ab9d 100644 --- a/src/repositories/parking/__tests__/ParkingRepositorySharedTests.test.ts +++ b/src/repositories/parking/__tests__/ParkingRepositorySharedTests.test.ts @@ -4,14 +4,9 @@ import { IParkingStructure } from "../../../entities/ParkingRepositoryEntities"; import { ParkingGetterSetterRepository } from "../ParkingGetterSetterRepository"; import { RedisParkingRepository } from "../RedisParkingRepository"; import { HistoricalParkingAverageFilterArguments } from "../ParkingGetterRepository"; +import { RepositoryHolder } from "../../../../testHelpers/RepositoryHolder"; -interface RepositoryHolder { - name: string; - factory(): Promise; - teardown(): Promise; -} - -class InMemoryParkingRepositoryHolder implements RepositoryHolder { +class InMemoryParkingRepositoryHolder implements RepositoryHolder { name = 'InMemoryParkingRepository'; factory = async () => { return new InMemoryParkingRepository(); @@ -19,7 +14,7 @@ class InMemoryParkingRepositoryHolder implements RepositoryHolder { teardown = async () => {}; } -class RedisParkingRepositoryHolder implements RepositoryHolder { +class RedisParkingRepositoryHolder implements RepositoryHolder { repo: RedisParkingRepository | undefined; name = 'RedisParkingRepository'; diff --git a/src/repositories/shuttle/__tests__/ShuttleRepositorySharedTests.test.ts b/src/repositories/shuttle/__tests__/ShuttleRepositorySharedTests.test.ts index 200384e..a6f2c27 100644 --- a/src/repositories/shuttle/__tests__/ShuttleRepositorySharedTests.test.ts +++ b/src/repositories/shuttle/__tests__/ShuttleRepositorySharedTests.test.ts @@ -11,14 +11,9 @@ import { generateMockShuttles, generateMockStops, } from "../../../../testHelpers/mockDataGenerators"; +import { RepositoryHolder } from "../../../../testHelpers/RepositoryHolder"; -interface RepositoryHolder { - name: string; - factory(): Promise; - teardown(): Promise; -} - -class UnoptimizedInMemoryShuttleRepositoryHolder implements RepositoryHolder { +class UnoptimizedInMemoryShuttleRepositoryHolder implements RepositoryHolder { name = 'UnoptimizedInMemoryShuttleRepository'; factory = async () => { return new UnoptimizedInMemoryShuttleRepository(); @@ -26,7 +21,7 @@ class UnoptimizedInMemoryShuttleRepositoryHolder implements RepositoryHolder { teardown = async () => {}; } -class RedisShuttleRepositoryHolder implements RepositoryHolder { +class RedisShuttleRepositoryHolder implements RepositoryHolder { repo: RedisShuttleRepository | undefined; name = 'RedisShuttleRepository'; diff --git a/testHelpers/RepositoryHolder.ts b/testHelpers/RepositoryHolder.ts new file mode 100644 index 0000000..4871993 --- /dev/null +++ b/testHelpers/RepositoryHolder.ts @@ -0,0 +1,5 @@ +export interface RepositoryHolder { + name: string; + factory(): Promise; + teardown(): Promise; +} From 809c0b9270e5b69f7159c165ce3f77a176430939 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Tue, 11 Nov 2025 11:03:33 -0800 Subject: [PATCH 32/96] Add clearAllData as a declared method --- .../eta/ExternalSourceETARepository.ts | 2 ++ .../eta/RedisExternalSourceETARepository.ts | 24 +++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/src/repositories/shuttle/eta/ExternalSourceETARepository.ts b/src/repositories/shuttle/eta/ExternalSourceETARepository.ts index 53dfe5d..5910d62 100644 --- a/src/repositories/shuttle/eta/ExternalSourceETARepository.ts +++ b/src/repositories/shuttle/eta/ExternalSourceETARepository.ts @@ -8,4 +8,6 @@ export interface ExternalSourceETARepository extends ETAGetterRepository { addOrUpdateEtaFromExternalSource(eta: IEta): Promise; removeEtaIfExists(shuttleId: string, stopId: string): Promise; + + clearAllData(): Promise; } diff --git a/src/repositories/shuttle/eta/RedisExternalSourceETARepository.ts b/src/repositories/shuttle/eta/RedisExternalSourceETARepository.ts index c4cff9b..daa1dd4 100644 --- a/src/repositories/shuttle/eta/RedisExternalSourceETARepository.ts +++ b/src/repositories/shuttle/eta/RedisExternalSourceETARepository.ts @@ -1,8 +1,29 @@ +import { createClient, RedisClientType } from "redis"; import { IEta } from "../../../entities/ShuttleRepositoryEntities"; import { ETARepositoryEventName, ETARepositoryEventListener } from "./ETAGetterRepository"; import { ExternalSourceETARepository } from "./ExternalSourceETARepository"; +import { REDIS_RECONNECT_INTERVAL } from "../../../environment"; export class RedisExternalSourceETARepository implements ExternalSourceETARepository { + constructor( + private redisClient: RedisClientType = createClient({ + url: process.env.REDIS_URL, + socket: { + tls: process.env.NODE_ENV === 'production', + rejectUnauthorized: false, + reconnectStrategy: REDIS_RECONNECT_INTERVAL, + }, + }) + ) {} + + public async connect() { + await this.redisClient.connect(); + } + + public async disconnect() { + await this.redisClient.disconnect(); + } + addOrUpdateEtaFromExternalSource(eta: IEta): Promise { throw new Error("Method not implemented."); } @@ -63,4 +84,7 @@ export class RedisExternalSourceETARepository implements ExternalSourceETAReposi eventNames(): (string | symbol)[] { throw new Error("Method not implemented."); } + clearAllData(): Promise { + throw new Error("Method not implemented."); + } } From e707c9cfd87b01726d831a0b974831c28f3a95a3 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Tue, 11 Nov 2025 11:33:45 -0800 Subject: [PATCH 33/96] Have the ETA repositories depend on BaseRedisRepository instead of duplicating methods --- .../eta/RedisExternalSourceETARepository.ts | 24 ++----------------- .../eta/RedisSelfUpdatingETARepository.ts | 3 ++- 2 files changed, 4 insertions(+), 23 deletions(-) diff --git a/src/repositories/shuttle/eta/RedisExternalSourceETARepository.ts b/src/repositories/shuttle/eta/RedisExternalSourceETARepository.ts index daa1dd4..1d1371d 100644 --- a/src/repositories/shuttle/eta/RedisExternalSourceETARepository.ts +++ b/src/repositories/shuttle/eta/RedisExternalSourceETARepository.ts @@ -1,29 +1,9 @@ -import { createClient, RedisClientType } from "redis"; import { IEta } from "../../../entities/ShuttleRepositoryEntities"; +import { BaseRedisRepository } from "../../BaseRedisRepository"; import { ETARepositoryEventName, ETARepositoryEventListener } from "./ETAGetterRepository"; import { ExternalSourceETARepository } from "./ExternalSourceETARepository"; -import { REDIS_RECONNECT_INTERVAL } from "../../../environment"; - -export class RedisExternalSourceETARepository implements ExternalSourceETARepository { - constructor( - private redisClient: RedisClientType = createClient({ - url: process.env.REDIS_URL, - socket: { - tls: process.env.NODE_ENV === 'production', - rejectUnauthorized: false, - reconnectStrategy: REDIS_RECONNECT_INTERVAL, - }, - }) - ) {} - - public async connect() { - await this.redisClient.connect(); - } - - public async disconnect() { - await this.redisClient.disconnect(); - } +export class RedisExternalSourceETARepository extends BaseRedisRepository implements ExternalSourceETARepository { addOrUpdateEtaFromExternalSource(eta: IEta): Promise { throw new Error("Method not implemented."); } diff --git a/src/repositories/shuttle/eta/RedisSelfUpdatingETARepository.ts b/src/repositories/shuttle/eta/RedisSelfUpdatingETARepository.ts index 797571f..a636f57 100644 --- a/src/repositories/shuttle/eta/RedisSelfUpdatingETARepository.ts +++ b/src/repositories/shuttle/eta/RedisSelfUpdatingETARepository.ts @@ -1,8 +1,9 @@ import { IEta } from "../../../entities/ShuttleRepositoryEntities"; import { ETARepositoryEventName, ETARepositoryEventListener } from "./ETAGetterRepository"; import { SelfUpdatingETARepository } from "./SelfUpdatingETARepository"; +import { BaseRedisRepository } from "../../BaseRedisRepository"; -export class RedisSelfUpdatingETARepository implements SelfUpdatingETARepository { +export class RedisSelfUpdatingETARepository extends BaseRedisRepository implements SelfUpdatingETARepository { startListeningForUpdates(): void { throw new Error("Method not implemented."); } From 7ac8232878f465ef8a565fee91267c854a706d46 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Tue, 11 Nov 2025 11:41:21 -0800 Subject: [PATCH 34/96] Fix typings for BaseRedisRepository --- src/repositories/BaseRedisRepository.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/repositories/BaseRedisRepository.ts b/src/repositories/BaseRedisRepository.ts index c5c8b73..aa142de 100644 --- a/src/repositories/BaseRedisRepository.ts +++ b/src/repositories/BaseRedisRepository.ts @@ -1,11 +1,11 @@ -import { createClient } from 'redis'; +import { createClient, RedisClientType } from 'redis'; import { REDIS_RECONNECT_INTERVAL } from "../environment"; export abstract class BaseRedisRepository { protected redisClient; constructor( - redisClient = createClient({ + redisClient: RedisClientType = createClient({ url: process.env.REDIS_URL, socket: { tls: process.env.NODE_ENV === 'production', From cb2337972382e1bb12d0d7f4e5245a8eb3e84dc9 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Tue, 11 Nov 2025 11:41:39 -0800 Subject: [PATCH 35/96] Add shuttleRepository injection to the constructor of RedisSelfUpdatingETARepository --- .../eta/RedisSelfUpdatingETARepository.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/repositories/shuttle/eta/RedisSelfUpdatingETARepository.ts b/src/repositories/shuttle/eta/RedisSelfUpdatingETARepository.ts index a636f57..8d26d60 100644 --- a/src/repositories/shuttle/eta/RedisSelfUpdatingETARepository.ts +++ b/src/repositories/shuttle/eta/RedisSelfUpdatingETARepository.ts @@ -2,8 +2,25 @@ import { IEta } from "../../../entities/ShuttleRepositoryEntities"; import { ETARepositoryEventName, ETARepositoryEventListener } from "./ETAGetterRepository"; import { SelfUpdatingETARepository } from "./SelfUpdatingETARepository"; import { BaseRedisRepository } from "../../BaseRedisRepository"; +import { createClient, RedisClientType } from "redis"; +import { ShuttleGetterRepository } from "../ShuttleGetterRepository"; +import { REDIS_RECONNECT_INTERVAL } from "../../../environment"; export class RedisSelfUpdatingETARepository extends BaseRedisRepository implements SelfUpdatingETARepository { + constructor( + readonly shuttleRepository: ShuttleGetterRepository, + redisClient: RedisClientType = createClient({ + url: process.env.REDIS_URL, + socket: { + tls: process.env.NODE_ENV === 'production', + rejectUnauthorized: false, + reconnectStrategy: REDIS_RECONNECT_INTERVAL, + }, + }), + ) { + super(redisClient); + } + startListeningForUpdates(): void { throw new Error("Method not implemented."); } From ea943206c042efe965c353275b526e3075210285 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Tue, 11 Nov 2025 12:06:05 -0800 Subject: [PATCH 36/96] Remove spy on nonexistent method --- .../__tests__/ApiBasedShuttleRepositoryLoaderTests.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/loaders/shuttle/__tests__/ApiBasedShuttleRepositoryLoaderTests.test.ts b/src/loaders/shuttle/__tests__/ApiBasedShuttleRepositoryLoaderTests.test.ts index c5e62eb..f215b4c 100644 --- a/src/loaders/shuttle/__tests__/ApiBasedShuttleRepositoryLoaderTests.test.ts +++ b/src/loaders/shuttle/__tests__/ApiBasedShuttleRepositoryLoaderTests.test.ts @@ -37,7 +37,6 @@ describe("ApiBasedShuttleRepositoryLoader", () => { updateRouteDataForSystem: jest.spyOn(loader, "updateRouteDataForSystem"), updateStopAndPolylineDataForRoutesInSystem: jest.spyOn(loader, "updateStopAndPolylineDataForRoutesInSystem"), updateShuttleDataForSystem: jest.spyOn(loader, "updateShuttleDataForSystemBasedOnProximityToRoutes"), - updateEtaDataForExistingStopsForSystem: jest.spyOn(loader, "updateEtaDataForExistingStopsForSystem"), }; Object.values(spies).forEach((spy: any) => { From 83e3414c8e771d7d24fb7409036975c2634cd979 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Tue, 11 Nov 2025 12:08:02 -0800 Subject: [PATCH 37/96] Add the tests back, for directly adding/removing ETAs --- ...rnalSourceETARepositorySharedTests.test.ts | 151 ++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 src/repositories/shuttle/eta/__tests__/ExternalSourceETARepositorySharedTests.test.ts diff --git a/src/repositories/shuttle/eta/__tests__/ExternalSourceETARepositorySharedTests.test.ts b/src/repositories/shuttle/eta/__tests__/ExternalSourceETARepositorySharedTests.test.ts new file mode 100644 index 0000000..4dc4980 --- /dev/null +++ b/src/repositories/shuttle/eta/__tests__/ExternalSourceETARepositorySharedTests.test.ts @@ -0,0 +1,151 @@ +import { afterEach, beforeEach, describe, expect, test } from "@jest/globals"; +import { RepositoryHolder } from "../../../../../testHelpers/RepositoryHolder"; +import { ExternalSourceETARepository } from "../ExternalSourceETARepository"; +import { RedisExternalSourceETARepository } from "../RedisExternalSourceETARepository"; +import { generateMockEtas } from "../../../../../testHelpers/mockDataGenerators"; + +class RedisExternalSourceETARepositoryHolder implements RepositoryHolder { + repo: RedisExternalSourceETARepository | undefined; + + name = "RedisExternalSourceETARepository" + factory = async () => { + this.repo = new RedisExternalSourceETARepository(); + await this.repo.connect(); + return this.repo; + } + teardown = async () => { + if (this.repo) { + await this.repo.clearAllData(); + await this.repo.disconnect(); + } + } +} + +const repositoryImplementations = [ + new RedisExternalSourceETARepositoryHolder() +]; + +describe.each(repositoryImplementations)('$name', (holder) => { + let repository: ExternalSourceETARepository; + + beforeEach(async () => { + repository = await holder.factory(); + }); + + afterEach(async () => { + await holder.teardown(); + }); + + describe("addOrUpdateEtaFromExternalSource", () => { + test("adds a new ETA if nonexistent", async () => { + const mockEtas = generateMockEtas(); + const newEta = mockEtas[0]; + + await repository.addOrUpdateEtaFromExternalSource(newEta); + + const result = await repository.getEtasForShuttleId(newEta.shuttleId); + expect(result).toEqual([newEta]); + }); + + test("updates an existing ETA if it exists", async () => { + const mockEtas = generateMockEtas(); + const existingEta = mockEtas[0]; + const updatedEta = structuredClone(existingEta); + updatedEta.secondsRemaining = existingEta.secondsRemaining + 60; + + await repository.addOrUpdateEtaFromExternalSource(existingEta); + await repository.addOrUpdateEtaFromExternalSource(updatedEta); + + const result = await repository.getEtasForShuttleId(existingEta.shuttleId); + expect(result).toEqual([updatedEta]); + }); + }); + + describe("getEtasForShuttleId", () => { + test("gets ETAs for a specific shuttle ID", async () => { + const mockEtas = generateMockEtas(); + for (const eta of mockEtas) { + await repository.addOrUpdateEtaFromExternalSource(eta); + } + + const result = await repository.getEtasForShuttleId("sh1"); + const expected = mockEtas.filter((eta) => eta.shuttleId === "sh1"); + expect(result).toHaveLength(expected.length); + expect(result).toEqual(expect.arrayContaining(expected)); + }); + + test("returns an empty list if there are no ETAs for the shuttle ID", async () => { + const result = await repository.getEtasForShuttleId("nonexistent-shuttle"); + expect(result).toEqual([]); + }); + }); + + describe("getEtasForStopId", () => { + test("gets ETAs for a specific stop ID", async () => { + const mockEtas = generateMockEtas(); + for (const eta of mockEtas) { + await repository.addOrUpdateEtaFromExternalSource(eta); + } + + const result = await repository.getEtasForStopId("st1"); + expect(result).toEqual(mockEtas.filter((eta) => eta.stopId === "st1")); + }); + + test("returns an empty list if there are no ETAs for the stop ID", async () => { + const result = await repository.getEtasForStopId("nonexistent-stop"); + expect(result).toEqual([]); + }); + }); + + describe("getEtaForShuttleAndStopId", () => { + test("gets a single ETA for a specific shuttle and stop ID", async () => { + const mockEtas = generateMockEtas(); + const mockEta = mockEtas[0]; + await repository.addOrUpdateEtaFromExternalSource(mockEta); + + const result = await repository.getEtaForShuttleAndStopId("sh1", "st1"); + expect(result).toEqual(mockEta); + }); + + test("returns null if no ETA matches the shuttle and stop ID", async () => { + const result = await repository.getEtaForShuttleAndStopId("nonexistent-shuttle", "nonexistent-stop"); + expect(result).toBeNull(); + }); + }); + + describe("removeEtaIfExists", () => { + test("removes eta given shuttle ID and stop ID", async () => { + let mockEtas = generateMockEtas(); + const stopId = mockEtas[0].stopId; + mockEtas = mockEtas.filter((eta) => eta.stopId === stopId); + + await Promise.all(mockEtas.map(async (eta) => { + eta.stopId = stopId; + await repository.addOrUpdateEtaFromExternalSource(eta); + })); + + const etaToRemove = mockEtas[0]; + await repository.removeEtaIfExists(etaToRemove.shuttleId, etaToRemove.stopId); + + const remainingEtas = await repository.getEtasForStopId(stopId); + expect(remainingEtas).toHaveLength(mockEtas.length - 1); + }); + + test("does nothing if eta doesn't exist", async () => { + let mockEtas = generateMockEtas(); + const stopId = mockEtas[0].stopId; + mockEtas = mockEtas.filter((eta) => eta.stopId === stopId); + + await Promise.all(mockEtas.map(async (eta) => { + eta.stopId = stopId; + await repository.addOrUpdateEtaFromExternalSource(eta); + })); + + await repository.removeEtaIfExists("nonexistent-shuttle-id", "nonexistent-stop-id"); + + const remainingEtas = await repository.getEtasForStopId(stopId); + expect(remainingEtas).toHaveLength(mockEtas.length); + }); + }); +}) + From e0b00d18871064c438786b228af2f14f87def6aa Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Tue, 11 Nov 2025 12:21:26 -0800 Subject: [PATCH 38/96] Extract setupRouteAndOrderedStops to a test helper --- .../ShuttleRepositorySharedTests.test.ts | 57 +---------------- ...outeAndOrderedStopsForShuttleRepository.ts | 62 +++++++++++++++++++ 2 files changed, 64 insertions(+), 55 deletions(-) create mode 100644 testHelpers/setupRouteAndOrderedStopsForShuttleRepository.ts diff --git a/src/repositories/shuttle/__tests__/ShuttleRepositorySharedTests.test.ts b/src/repositories/shuttle/__tests__/ShuttleRepositorySharedTests.test.ts index a6f2c27..d9edec1 100644 --- a/src/repositories/shuttle/__tests__/ShuttleRepositorySharedTests.test.ts +++ b/src/repositories/shuttle/__tests__/ShuttleRepositorySharedTests.test.ts @@ -12,6 +12,7 @@ import { generateMockStops, } from "../../../../testHelpers/mockDataGenerators"; import { RepositoryHolder } from "../../../../testHelpers/RepositoryHolder"; +import { setupRouteAndOrderedStopsForShuttleRepository } from "../../../../testHelpers/setupRouteAndOrderedStopsForShuttleRepository"; class UnoptimizedInMemoryShuttleRepositoryHolder implements RepositoryHolder { name = 'UnoptimizedInMemoryShuttleRepository'; @@ -609,61 +610,7 @@ describe.each(repositoryImplementations)('$name', (holder) => { // Helper function for setting up routes and ordered stops for shuttle tracking tests async function setupRouteAndOrderedStops() { - const systemId = "sys1"; - const route = { - id: "r1", - name: "Route 1", - color: "red", - systemId: systemId, - polylineCoordinates: [], - updatedTime: new Date(), - }; - await repository.addOrUpdateRoute(route); - - const stop1 = { - id: "st1", - name: "Stop 1", - systemId: systemId, - coordinates: { latitude: 10.0, longitude: 20.0 }, - updatedTime: new Date(), - }; - const stop2 = { - id: "st2", - name: "Stop 2", - systemId: systemId, - coordinates: { latitude: 15.0, longitude: 25.0 }, - updatedTime: new Date(), - }; - await repository.addOrUpdateStop(stop1); - await repository.addOrUpdateStop(stop2); - - const orderedStop1: IOrderedStop = { - routeId: route.id, - stopId: stop1.id, - position: 1, - systemId: systemId, - updatedTime: new Date(), - }; - const orderedStop2: IOrderedStop = { - routeId: route.id, - stopId: stop2.id, - position: 2, - systemId: systemId, - updatedTime: new Date(), - }; - orderedStop1.nextStop = orderedStop2; - orderedStop1.previousStop = orderedStop2; - orderedStop2.nextStop = orderedStop1; - orderedStop2.previousStop = orderedStop1; - await repository.addOrUpdateOrderedStop(orderedStop1); - await repository.addOrUpdateOrderedStop(orderedStop2); - - return { - route, - systemId, - stop1, - stop2, - }; + return await setupRouteAndOrderedStopsForShuttleRepository(repository); } describe("addOrUpdateShuttle with ETA calculations", () => { diff --git a/testHelpers/setupRouteAndOrderedStopsForShuttleRepository.ts b/testHelpers/setupRouteAndOrderedStopsForShuttleRepository.ts new file mode 100644 index 0000000..a79f28a --- /dev/null +++ b/testHelpers/setupRouteAndOrderedStopsForShuttleRepository.ts @@ -0,0 +1,62 @@ +import { IOrderedStop } from "../src/entities/ShuttleRepositoryEntities"; +import { ShuttleGetterSetterRepository } from "../src/repositories/shuttle/ShuttleGetterSetterRepository"; + +export async function setupRouteAndOrderedStopsForShuttleRepository( + shuttleRepository: ShuttleGetterSetterRepository +) { + const systemId = "sys1"; + const route = { + id: "r1", + name: "Route 1", + color: "red", + systemId: systemId, + polylineCoordinates: [], + updatedTime: new Date(), + }; + await shuttleRepository.addOrUpdateRoute(route); + + const stop1 = { + id: "st1", + name: "Stop 1", + systemId: systemId, + coordinates: { latitude: 10.0, longitude: 20.0 }, + updatedTime: new Date(), + }; + const stop2 = { + id: "st2", + name: "Stop 2", + systemId: systemId, + coordinates: { latitude: 15.0, longitude: 25.0 }, + updatedTime: new Date(), + }; + await shuttleRepository.addOrUpdateStop(stop1); + await shuttleRepository.addOrUpdateStop(stop2); + + const orderedStop1: IOrderedStop = { + routeId: route.id, + stopId: stop1.id, + position: 1, + systemId: systemId, + updatedTime: new Date(), + }; + const orderedStop2: IOrderedStop = { + routeId: route.id, + stopId: stop2.id, + position: 2, + systemId: systemId, + updatedTime: new Date(), + }; + orderedStop1.nextStop = orderedStop2; + orderedStop1.previousStop = orderedStop2; + orderedStop2.nextStop = orderedStop1; + orderedStop2.previousStop = orderedStop1; + await shuttleRepository.addOrUpdateOrderedStop(orderedStop1); + await shuttleRepository.addOrUpdateOrderedStop(orderedStop2); + + return { + route, + systemId, + stop1, + stop2, + }; +} From 13bfc853e3128210e1e33eb78e5b725f8e9721e7 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Tue, 11 Nov 2025 12:26:22 -0800 Subject: [PATCH 39/96] Add getAverageTravelTimeSeconds in SelfUpdatingETARepository definition --- .../shuttle/eta/RedisSelfUpdatingETARepository.ts | 6 ++++-- .../shuttle/eta/SelfUpdatingETARepository.ts | 12 ++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/repositories/shuttle/eta/RedisSelfUpdatingETARepository.ts b/src/repositories/shuttle/eta/RedisSelfUpdatingETARepository.ts index 8d26d60..7ab93ab 100644 --- a/src/repositories/shuttle/eta/RedisSelfUpdatingETARepository.ts +++ b/src/repositories/shuttle/eta/RedisSelfUpdatingETARepository.ts @@ -3,7 +3,7 @@ import { ETARepositoryEventName, ETARepositoryEventListener } from "./ETAGetterR import { SelfUpdatingETARepository } from "./SelfUpdatingETARepository"; import { BaseRedisRepository } from "../../BaseRedisRepository"; import { createClient, RedisClientType } from "redis"; -import { ShuttleGetterRepository } from "../ShuttleGetterRepository"; +import { ShuttleGetterRepository, ShuttleTravelTimeDataIdentifier, ShuttleTravelTimeDateFilterArguments } from "../ShuttleGetterRepository"; import { REDIS_RECONNECT_INTERVAL } from "../../../environment"; export class RedisSelfUpdatingETARepository extends BaseRedisRepository implements SelfUpdatingETARepository { @@ -20,7 +20,9 @@ export class RedisSelfUpdatingETARepository extends BaseRedisRepository implemen ) { super(redisClient); } - + getAverageTravelTimeSeconds(identifier: ShuttleTravelTimeDataIdentifier, dateFilter: ShuttleTravelTimeDateFilterArguments): Promise { + throw new Error("Method not implemented."); + } startListeningForUpdates(): void { throw new Error("Method not implemented."); } diff --git a/src/repositories/shuttle/eta/SelfUpdatingETARepository.ts b/src/repositories/shuttle/eta/SelfUpdatingETARepository.ts index 7f2c9f0..331e1cc 100644 --- a/src/repositories/shuttle/eta/SelfUpdatingETARepository.ts +++ b/src/repositories/shuttle/eta/SelfUpdatingETARepository.ts @@ -1,3 +1,4 @@ +import { ShuttleTravelTimeDataIdentifier, ShuttleTravelTimeDateFilterArguments } from "../ShuttleGetterRepository"; import { ETAGetterRepository } from "./ETAGetterRepository"; export interface SelfUpdatingETARepository extends ETAGetterRepository { @@ -6,4 +7,15 @@ export interface SelfUpdatingETARepository extends ETAGetterRepository { * shuttle updates */ startListeningForUpdates(): void; + + /** + * 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; } From b6b79e13457c0f663eafcc53f833cac19b3d01a7 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Tue, 11 Nov 2025 12:27:10 -0800 Subject: [PATCH 40/96] Migrate ETA-related tests over to the ETA repository tests --- .../ShuttleRepositorySharedTests.test.ts | 178 +------------ ...lfUpdatingETARepositorySharedTests.test.ts | 245 ++++++++++++++++++ 2 files changed, 246 insertions(+), 177 deletions(-) create mode 100644 src/repositories/shuttle/eta/__tests__/SelfUpdatingETARepositorySharedTests.test.ts diff --git a/src/repositories/shuttle/__tests__/ShuttleRepositorySharedTests.test.ts b/src/repositories/shuttle/__tests__/ShuttleRepositorySharedTests.test.ts index d9edec1..bb99165 100644 --- a/src/repositories/shuttle/__tests__/ShuttleRepositorySharedTests.test.ts +++ b/src/repositories/shuttle/__tests__/ShuttleRepositorySharedTests.test.ts @@ -3,7 +3,6 @@ import { UnoptimizedInMemoryShuttleRepository } from "../UnoptimizedInMemoryShut import { ShuttleGetterSetterRepository } from "../ShuttleGetterSetterRepository"; import { RedisShuttleRepository } from "../RedisShuttleRepository"; import { ShuttleRepositoryEvent } from "../ShuttleGetterRepository"; -import { IOrderedStop } from "../../../entities/ShuttleRepositoryEntities"; import { generateMockEtas, generateMockOrderedStops, @@ -613,7 +612,7 @@ describe.each(repositoryImplementations)('$name', (holder) => { return await setupRouteAndOrderedStopsForShuttleRepository(repository); } - describe("addOrUpdateShuttle with ETA calculations", () => { + describe("addOrUpdateShuttle with shuttle tracking", () => { test("updates the shuttle's last stop arrival if shuttle is at a stop", async () => { const { route, systemId, stop2 } = await setupRouteAndOrderedStops(); @@ -631,68 +630,6 @@ describe.each(repositoryImplementations)('$name', (holder) => { const lastStop = await repository.getShuttleLastStopArrival(shuttle.id); expect(lastStop?.stopId).toEqual(stop2.id); }); - - test("updates how long the shuttle took to get from one stop to another", async () => { - const { route, systemId, stop2, stop1 } = await setupRouteAndOrderedStops(); - - const shuttle = { - id: "sh1", - name: "Shuttle 1", - routeId: route.id, - systemId: systemId, - coordinates: stop1.coordinates, - orientationInDegrees: 0, - updatedTime: new Date(), - }; - - const firstStopArrivalTime = new Date(2025, 0, 1, 12, 0, 0); - await repository.addOrUpdateShuttle(shuttle, firstStopArrivalTime.getTime()); - - shuttle.coordinates = stop2.coordinates; - const secondStopArrivalTime = new Date(2025, 0, 1, 12, 15, 0); - await repository.addOrUpdateShuttle(shuttle, secondStopArrivalTime.getTime()); - - const travelTime = await repository.getAverageTravelTimeSeconds({ - routeId: route.id, - fromStopId: stop1.id, - toStopId: stop2.id, - }, { - from: new Date(2025, 0, 1, 11, 0, 0), - to: new Date(2025, 0, 1, 13, 0, 0), - }); - expect(travelTime).toEqual(15 * 60); - }); - - test("adds an ETA entry based on historical data", async () => { - const { route, systemId, stop1, stop2 } = await setupRouteAndOrderedStops(); - - const shuttle = { - id: "sh1", - name: "Shuttle 1", - routeId: route.id, - systemId: systemId, - coordinates: stop1.coordinates, - orientationInDegrees: 0, - updatedTime: new Date(), - }; - - const firstStopArrivalTime = new Date(2025, 0, 1, 12, 0, 0); - await repository.addOrUpdateShuttle(shuttle, firstStopArrivalTime.getTime()); - - shuttle.coordinates = stop2.coordinates; - const secondStopArrivalTime = new Date(2025, 0, 1, 12, 15, 0); - await repository.addOrUpdateShuttle(shuttle, secondStopArrivalTime.getTime()); - - shuttle.coordinates = stop1.coordinates; - await repository.addOrUpdateShuttle( - shuttle, - new Date(2025, 0, 8, 12, 0, 0).getTime(), - new Date(2025, 0, 8, 12, 7, 30), - ); - - const eta = await repository.getEtaForShuttleAndStopId(shuttle.id, stop2.id); - expect(eta?.secondsRemaining).toEqual(7 * 60 + 30); - }); }); describe("getArrivedStopIfExists", () => { @@ -796,117 +733,4 @@ describe.each(repositoryImplementations)('$name', (holder) => { }); }); - describe("getAverageTravelTimeSeconds", () => { - test("returns the average travel time when historical data exists", async () => { - const { route, systemId, stop1, stop2 } = await setupRouteAndOrderedStops(); - - const shuttle = { - id: "sh1", - name: "Shuttle 1", - routeId: route.id, - systemId: systemId, - coordinates: stop1.coordinates, - orientationInDegrees: 0, - updatedTime: new Date(), - }; - - const firstStopTime = new Date(2025, 0, 1, 12, 0, 0); - await repository.addOrUpdateShuttle(shuttle, firstStopTime.getTime()); - - shuttle.coordinates = stop2.coordinates; - const secondStopTime = new Date(2025, 0, 1, 12, 15, 0); - await repository.addOrUpdateShuttle(shuttle, secondStopTime.getTime()); - - const travelTime = await repository.getAverageTravelTimeSeconds({ - routeId: route.id, - fromStopId: stop1.id, - toStopId: stop2.id, - }, { - from: new Date(2025, 0, 1, 11, 0, 0), - to: new Date(2025, 0, 1, 13, 0, 0), - }); - - expect(travelTime).toEqual(15 * 60); - }); - - test("returns average of multiple data points", async () => { - const { route, systemId, stop1, stop2 } = await setupRouteAndOrderedStops(); - - const shuttle = { - id: "sh1", - name: "Shuttle 1", - routeId: route.id, - systemId: systemId, - coordinates: stop1.coordinates, - orientationInDegrees: 0, - updatedTime: new Date(), - }; - - // First trip: 10 minutes travel time - await repository.addOrUpdateShuttle(shuttle, new Date(2025, 0, 1, 12, 0, 0).getTime()); - shuttle.coordinates = stop2.coordinates; - await repository.addOrUpdateShuttle(shuttle, new Date(2025, 0, 1, 12, 10, 0).getTime()); - - // Second trip: 20 minutes travel time - shuttle.coordinates = stop1.coordinates; - await repository.addOrUpdateShuttle(shuttle, new Date(2025, 0, 1, 12, 30, 0).getTime()); - shuttle.coordinates = stop2.coordinates; - await repository.addOrUpdateShuttle(shuttle, new Date(2025, 0, 1, 12, 50, 0).getTime()); - - const averageTravelTime = await repository.getAverageTravelTimeSeconds({ - routeId: route.id, - fromStopId: stop1.id, - toStopId: stop2.id, - }, { - from: new Date(2025, 0, 1, 11, 0, 0), - to: new Date(2025, 0, 1, 14, 0, 0), - }); - - // Average of 10 minutes and 20 minutes = 15 minutes = 900 seconds - expect(averageTravelTime).toBeDefined(); - }); - - test("returns undefined when no data exists", async () => { - const { route, stop1, stop2 } = await setupRouteAndOrderedStops(); - - const averageTravelTime = await repository.getAverageTravelTimeSeconds({ - routeId: route.id, - fromStopId: stop1.id, - toStopId: stop2.id, - }, { - from: new Date(2025, 0, 1, 11, 0, 0), - to: new Date(2025, 0, 1, 14, 0, 0), - }); - - expect(averageTravelTime).toBeUndefined(); - }); - - test("returns undefined when querying outside the time range of data", async () => { - const { route, systemId, stop1, stop2 } = await setupRouteAndOrderedStops(); - - const shuttle = { - id: "sh1", - name: "Shuttle 1", - routeId: route.id, - systemId: systemId, - coordinates: stop1.coordinates, - orientationInDegrees: 0, - updatedTime: new Date(), - }; - - await repository.addOrUpdateShuttle(shuttle, new Date(2025, 0, 1, 12, 0, 0).getTime()); - shuttle.coordinates = stop2.coordinates; - await repository.addOrUpdateShuttle(shuttle, new Date(2025, 0, 1, 12, 15, 0).getTime()); - - const averageTravelTime = await repository.getAverageTravelTimeSeconds({ - routeId: route.id, - fromStopId: stop1.id, - toStopId: stop2.id, - }, { - from: new Date(2025, 0, 2, 11, 0, 0), - to: new Date(2025, 0, 2, 13, 0, 0), - }); - expect(averageTravelTime).toBeUndefined(); - }); - }); }); diff --git a/src/repositories/shuttle/eta/__tests__/SelfUpdatingETARepositorySharedTests.test.ts b/src/repositories/shuttle/eta/__tests__/SelfUpdatingETARepositorySharedTests.test.ts new file mode 100644 index 0000000..ac61cf1 --- /dev/null +++ b/src/repositories/shuttle/eta/__tests__/SelfUpdatingETARepositorySharedTests.test.ts @@ -0,0 +1,245 @@ +import { afterEach, beforeEach, describe, expect, test } from "@jest/globals"; +import { RepositoryHolder } from "../../../../../testHelpers/RepositoryHolder"; +import { SelfUpdatingETARepository } from "../SelfUpdatingETARepository"; +import { RedisSelfUpdatingETARepository } from "../RedisSelfUpdatingETARepository"; +import { RedisShuttleRepository } from "../../RedisShuttleRepository"; +import { setupRouteAndOrderedStopsForShuttleRepository } from "../../../../../testHelpers/setupRouteAndOrderedStopsForShuttleRepository"; + +class RedisSelfUpdatingETARepositoryHolder implements RepositoryHolder { + repo: RedisSelfUpdatingETARepository | undefined; + shuttleRepo: RedisShuttleRepository | undefined; + + name = "RedisSelfUpdatingETARepository" + factory = async () => { + this.shuttleRepo = new RedisShuttleRepository(); + await this.shuttleRepo.connect(); + this.repo = new RedisSelfUpdatingETARepository( + this.shuttleRepo, + ); + await this.repo.connect(); + return this.repo; + } + teardown = async () => { + if (this.shuttleRepo) { + await this.shuttleRepo.clearAllData(); + await this.shuttleRepo.disconnect(); + } + if (this.repo) { + await this.repo.clearAllData(); + await this.repo.disconnect(); + } + } +} + +const repositoryImplementations = [ + new RedisSelfUpdatingETARepositoryHolder() +]; + +describe.each(repositoryImplementations)('$name', (holder) => { + let repository: SelfUpdatingETARepository; + let shuttleRepository: RedisShuttleRepository; + + beforeEach(async () => { + repository = await holder.factory(); + shuttleRepository = holder.shuttleRepo!; + }); + + afterEach(async () => { + await holder.teardown(); + }); + + // Helper function for setting up routes and ordered stops + async function setupRouteAndOrderedStops() { + return await setupRouteAndOrderedStopsForShuttleRepository(shuttleRepository); + } + + describe("addOrUpdateShuttle triggers ETA calculations", () => { + test("updates how long the shuttle took to get from one stop to another", async () => { + const { route, systemId, stop2, stop1 } = await setupRouteAndOrderedStops(); + + repository.startListeningForUpdates(); + + const shuttle = { + id: "sh1", + name: "Shuttle 1", + routeId: route.id, + systemId: systemId, + coordinates: stop1.coordinates, + orientationInDegrees: 0, + updatedTime: new Date(), + }; + + const firstStopArrivalTime = new Date(2025, 0, 1, 12, 0, 0); + await shuttleRepository.addOrUpdateShuttle(shuttle, firstStopArrivalTime.getTime()); + + shuttle.coordinates = stop2.coordinates; + const secondStopArrivalTime = new Date(2025, 0, 1, 12, 15, 0); + await shuttleRepository.addOrUpdateShuttle(shuttle, secondStopArrivalTime.getTime()); + + const travelTime = await repository.getAverageTravelTimeSeconds({ + routeId: route.id, + fromStopId: stop1.id, + toStopId: stop2.id, + }, { + from: new Date(2025, 0, 1, 11, 0, 0), + to: new Date(2025, 0, 1, 13, 0, 0), + }); + expect(travelTime).toEqual(15 * 60); + }); + + test("adds an ETA entry based on historical data", async () => { + const { route, systemId, stop1, stop2 } = await setupRouteAndOrderedStops(); + + repository.startListeningForUpdates(); + + const shuttle = { + id: "sh1", + name: "Shuttle 1", + routeId: route.id, + systemId: systemId, + coordinates: stop1.coordinates, + orientationInDegrees: 0, + updatedTime: new Date(), + }; + + const firstStopArrivalTime = new Date(2025, 0, 1, 12, 0, 0); + await shuttleRepository.addOrUpdateShuttle(shuttle, firstStopArrivalTime.getTime()); + + shuttle.coordinates = stop2.coordinates; + const secondStopArrivalTime = new Date(2025, 0, 1, 12, 15, 0); + await shuttleRepository.addOrUpdateShuttle(shuttle, secondStopArrivalTime.getTime()); + + shuttle.coordinates = stop1.coordinates; + await shuttleRepository.addOrUpdateShuttle( + shuttle, + new Date(2025, 0, 8, 12, 0, 0).getTime(), + new Date(2025, 0, 8, 12, 7, 30), + ); + + const eta = await repository.getEtaForShuttleAndStopId(shuttle.id, stop2.id); + expect(eta?.secondsRemaining).toEqual(7 * 60 + 30); + }); + }); + + describe("getAverageTravelTimeSeconds", () => { + test("returns the average travel time when historical data exists", async () => { + const { route, systemId, stop1, stop2 } = await setupRouteAndOrderedStops(); + + repository.startListeningForUpdates(); + + const shuttle = { + id: "sh1", + name: "Shuttle 1", + routeId: route.id, + systemId: systemId, + coordinates: stop1.coordinates, + orientationInDegrees: 0, + updatedTime: new Date(), + }; + + const firstStopTime = new Date(2025, 0, 1, 12, 0, 0); + await shuttleRepository.addOrUpdateShuttle(shuttle, firstStopTime.getTime()); + + shuttle.coordinates = stop2.coordinates; + const secondStopTime = new Date(2025, 0, 1, 12, 15, 0); + await shuttleRepository.addOrUpdateShuttle(shuttle, secondStopTime.getTime()); + + const travelTime = await repository.getAverageTravelTimeSeconds({ + routeId: route.id, + fromStopId: stop1.id, + toStopId: stop2.id, + }, { + from: new Date(2025, 0, 1, 11, 0, 0), + to: new Date(2025, 0, 1, 13, 0, 0), + }); + + expect(travelTime).toEqual(15 * 60); + }); + + test("returns average of multiple data points", async () => { + const { route, systemId, stop1, stop2 } = await setupRouteAndOrderedStops(); + + repository.startListeningForUpdates(); + + const shuttle = { + id: "sh1", + name: "Shuttle 1", + routeId: route.id, + systemId: systemId, + coordinates: stop1.coordinates, + orientationInDegrees: 0, + updatedTime: new Date(), + }; + + // First trip: 10 minutes travel time + await shuttleRepository.addOrUpdateShuttle(shuttle, new Date(2025, 0, 1, 12, 0, 0).getTime()); + shuttle.coordinates = stop2.coordinates; + await shuttleRepository.addOrUpdateShuttle(shuttle, new Date(2025, 0, 1, 12, 10, 0).getTime()); + + // Second trip: 20 minutes travel time + shuttle.coordinates = stop1.coordinates; + await shuttleRepository.addOrUpdateShuttle(shuttle, new Date(2025, 0, 1, 12, 30, 0).getTime()); + shuttle.coordinates = stop2.coordinates; + await shuttleRepository.addOrUpdateShuttle(shuttle, new Date(2025, 0, 1, 12, 50, 0).getTime()); + + const averageTravelTime = await repository.getAverageTravelTimeSeconds({ + routeId: route.id, + fromStopId: stop1.id, + toStopId: stop2.id, + }, { + from: new Date(2025, 0, 1, 11, 0, 0), + to: new Date(2025, 0, 1, 14, 0, 0), + }); + + // Average of 10 minutes and 20 minutes = 15 minutes = 900 seconds + expect(averageTravelTime).toBeDefined(); + }); + + test("returns undefined when no data exists", async () => { + const { route, stop1, stop2 } = await setupRouteAndOrderedStops(); + + repository.startListeningForUpdates(); + + const averageTravelTime = await repository.getAverageTravelTimeSeconds({ + routeId: route.id, + fromStopId: stop1.id, + toStopId: stop2.id, + }, { + from: new Date(2025, 0, 1, 11, 0, 0), + to: new Date(2025, 0, 1, 14, 0, 0), + }); + + expect(averageTravelTime).toBeUndefined(); + }); + + test("returns undefined when querying outside the time range of data", async () => { + const { route, systemId, stop1, stop2 } = await setupRouteAndOrderedStops(); + + repository.startListeningForUpdates(); + + const shuttle = { + id: "sh1", + name: "Shuttle 1", + routeId: route.id, + systemId: systemId, + coordinates: stop1.coordinates, + orientationInDegrees: 0, + updatedTime: new Date(), + }; + + await shuttleRepository.addOrUpdateShuttle(shuttle, new Date(2025, 0, 1, 12, 0, 0).getTime()); + shuttle.coordinates = stop2.coordinates; + await shuttleRepository.addOrUpdateShuttle(shuttle, new Date(2025, 0, 1, 12, 15, 0).getTime()); + + const averageTravelTime = await repository.getAverageTravelTimeSeconds({ + routeId: route.id, + fromStopId: stop1.id, + toStopId: stop2.id, + }, { + from: new Date(2025, 0, 2, 11, 0, 0), + to: new Date(2025, 0, 2, 13, 0, 0), + }); + expect(averageTravelTime).toBeUndefined(); + }); + }); +}) From 01c55d52ece0048264ab43bdfb58a30b3524f652 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Tue, 11 Nov 2025 12:31:32 -0800 Subject: [PATCH 41/96] Make BaseRedisRepository extend EventEmitter; make RedisShuttleRepository extend BaseRedisRepository --- src/repositories/BaseRedisRepository.ts | 4 ++- .../RedisNotificationRepository.ts | 14 +++++----- .../shuttle/RedisShuttleRepository.ts | 25 ++--------------- .../eta/RedisExternalSourceETARepository.ts | 27 ------------------ .../eta/RedisSelfUpdatingETARepository.ts | 28 ------------------- 5 files changed, 12 insertions(+), 86 deletions(-) diff --git a/src/repositories/BaseRedisRepository.ts b/src/repositories/BaseRedisRepository.ts index aa142de..70c2b1f 100644 --- a/src/repositories/BaseRedisRepository.ts +++ b/src/repositories/BaseRedisRepository.ts @@ -1,7 +1,8 @@ import { createClient, RedisClientType } from 'redis'; import { REDIS_RECONNECT_INTERVAL } from "../environment"; +import { EventEmitter } from 'stream'; -export abstract class BaseRedisRepository { +export abstract class BaseRedisRepository extends EventEmitter { protected redisClient; constructor( @@ -14,6 +15,7 @@ export abstract class BaseRedisRepository { }, }), ) { + super(); this.redisClient = redisClient; this.redisClient.on('error', (err) => { console.error(err.stack); diff --git a/src/repositories/notifications/RedisNotificationRepository.ts b/src/repositories/notifications/RedisNotificationRepository.ts index 2c7609e..d4b2e0f 100644 --- a/src/repositories/notifications/RedisNotificationRepository.ts +++ b/src/repositories/notifications/RedisNotificationRepository.ts @@ -9,7 +9,7 @@ import { import { BaseRedisRepository } from "../BaseRedisRepository"; export class RedisNotificationRepository extends BaseRedisRepository implements NotificationRepository { - private listeners: Listener[] = []; + private notificationListeners: Listener[] = []; private readonly NOTIFICATION_KEY_PREFIX = 'notification:'; private getNotificationKey = (shuttleId: string, stopId: string): string => { @@ -23,7 +23,7 @@ export class RedisNotificationRepository extends BaseRedisRepository implements await this.redisClient.hSet(key, deviceId, secondsThreshold.toString()); - this.listeners.forEach((listener: Listener) => { + this.notificationListeners.forEach((listener: Listener) => { const event: NotificationEvent = { event: 'addOrUpdate', notification @@ -46,7 +46,7 @@ export class RedisNotificationRepository extends BaseRedisRepository implements await this.redisClient.del(key); } - this.listeners.forEach((listener) => { + this.notificationListeners.forEach((listener) => { const event: NotificationEvent = { event: 'delete', notification: { @@ -94,20 +94,20 @@ export class RedisNotificationRepository extends BaseRedisRepository implements }; public subscribeToNotificationChanges = (listener: Listener): void => { - const index = this.listeners.findIndex( + const index = this.notificationListeners.findIndex( (existingListener) => existingListener === listener ); if (index < 0) { - this.listeners.push(listener); + this.notificationListeners.push(listener); } }; public unsubscribeFromNotificationChanges = (listener: Listener): void => { - const index = this.listeners.findIndex( + const index = this.notificationListeners.findIndex( (existingListener) => existingListener === listener ); if (index >= 0) { - this.listeners.splice(index, 1); + this.notificationListeners.splice(index, 1); } }; } diff --git a/src/repositories/shuttle/RedisShuttleRepository.ts b/src/repositories/shuttle/RedisShuttleRepository.ts index 98a3fcb..96c99a5 100644 --- a/src/repositories/shuttle/RedisShuttleRepository.ts +++ b/src/repositories/shuttle/RedisShuttleRepository.ts @@ -1,8 +1,5 @@ -import EventEmitter from "node:events"; -import { createClient } from 'redis'; import { ShuttleGetterSetterRepository } from "./ShuttleGetterSetterRepository"; import { IEta, IOrderedStop, IRoute, IShuttle, IStop, shuttleHasArrivedAtStop } from "../../entities/ShuttleRepositoryEntities"; -import { REDIS_RECONNECT_INTERVAL } from "../../environment"; import { ShuttleRepositoryEvent, ShuttleRepositoryEventListener, @@ -12,27 +9,9 @@ import { ShuttleTravelTimeDataIdentifier, ShuttleTravelTimeDateFilterArguments, } from "./ShuttleGetterRepository"; +import { BaseRedisRepository } from "../BaseRedisRepository"; -export class RedisShuttleRepository extends EventEmitter implements ShuttleGetterSetterRepository { - protected redisClient; - - constructor( - redisClient = createClient({ - url: process.env.REDIS_URL, - socket: { - tls: process.env.NODE_ENV === 'production', - rejectUnauthorized: false, - reconnectStrategy: REDIS_RECONNECT_INTERVAL, - }, - }), - ) { - super(); - this.redisClient = redisClient; - this.redisClient.on('error', (err) => { - console.error(err.stack); - }); - } - +export class RedisShuttleRepository extends BaseRedisRepository implements ShuttleGetterSetterRepository { get isReady() { return this.redisClient.isReady; } diff --git a/src/repositories/shuttle/eta/RedisExternalSourceETARepository.ts b/src/repositories/shuttle/eta/RedisExternalSourceETARepository.ts index 1d1371d..a551dfb 100644 --- a/src/repositories/shuttle/eta/RedisExternalSourceETARepository.ts +++ b/src/repositories/shuttle/eta/RedisExternalSourceETARepository.ts @@ -37,33 +37,6 @@ export class RedisExternalSourceETARepository extends BaseRedisRepository implem removeAllListeners(eventName?: string | symbol | undefined): this { throw new Error("Method not implemented."); } - setMaxListeners(n: number): this { - throw new Error("Method not implemented."); - } - getMaxListeners(): number { - throw new Error("Method not implemented."); - } - listeners(eventName: string | symbol): Function[] { - throw new Error("Method not implemented."); - } - rawListeners(eventName: string | symbol): Function[] { - throw new Error("Method not implemented."); - } - emit(eventName: string | symbol, ...args: any[]): boolean { - throw new Error("Method not implemented."); - } - listenerCount(eventName: string | symbol, listener?: Function | undefined): number { - throw new Error("Method not implemented."); - } - prependListener(eventName: string | symbol, listener: (...args: any[]) => void): this { - throw new Error("Method not implemented."); - } - prependOnceListener(eventName: string | symbol, listener: (...args: any[]) => void): this { - throw new Error("Method not implemented."); - } - eventNames(): (string | symbol)[] { - throw new Error("Method not implemented."); - } clearAllData(): Promise { throw new Error("Method not implemented."); } diff --git a/src/repositories/shuttle/eta/RedisSelfUpdatingETARepository.ts b/src/repositories/shuttle/eta/RedisSelfUpdatingETARepository.ts index 7ab93ab..d9aac9f 100644 --- a/src/repositories/shuttle/eta/RedisSelfUpdatingETARepository.ts +++ b/src/repositories/shuttle/eta/RedisSelfUpdatingETARepository.ts @@ -53,32 +53,4 @@ export class RedisSelfUpdatingETARepository extends BaseRedisRepository implemen removeAllListeners(eventName?: string | symbol | undefined): this { throw new Error("Method not implemented."); } - setMaxListeners(n: number): this { - throw new Error("Method not implemented."); - } - getMaxListeners(): number { - throw new Error("Method not implemented."); - } - listeners(eventName: string | symbol): Function[] { - throw new Error("Method not implemented."); - } - rawListeners(eventName: string | symbol): Function[] { - throw new Error("Method not implemented."); - } - emit(eventName: string | symbol, ...args: any[]): boolean { - throw new Error("Method not implemented."); - } - listenerCount(eventName: string | symbol, listener?: Function | undefined): number { - throw new Error("Method not implemented."); - } - prependListener(eventName: string | symbol, listener: (...args: any[]) => void): this { - throw new Error("Method not implemented."); - } - prependOnceListener(eventName: string | symbol, listener: (...args: any[]) => void): this { - throw new Error("Method not implemented."); - } - eventNames(): (string | symbol)[] { - throw new Error("Method not implemented."); - } - } From 99999450b50aae98aa76fc79ce8e29609fb39604 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Tue, 11 Nov 2025 14:50:53 -0800 Subject: [PATCH 42/96] Remove clearAllData from BaseRedisRepository and update tests --- src/repositories/BaseRedisRepository.ts | 4 ---- .../NotificationRepositorySharedTests.test.ts | 15 ++++++++----- .../ParkingRepositorySharedTests.test.ts | 15 ++++++++----- .../shuttle/RedisShuttleRepository.ts | 3 --- .../ShuttleRepositorySharedTests.test.ts | 15 ++++++++----- .../eta/ExternalSourceETARepository.ts | 2 -- .../eta/RedisExternalSourceETARepository.ts | 3 --- ...rnalSourceETARepositorySharedTests.test.ts | 15 ++++++++----- ...lfUpdatingETARepositorySharedTests.test.ts | 21 ++++++++++--------- 9 files changed, 51 insertions(+), 42 deletions(-) diff --git a/src/repositories/BaseRedisRepository.ts b/src/repositories/BaseRedisRepository.ts index 70c2b1f..f722586 100644 --- a/src/repositories/BaseRedisRepository.ts +++ b/src/repositories/BaseRedisRepository.ts @@ -33,8 +33,4 @@ export abstract class BaseRedisRepository extends EventEmitter { public async disconnect() { await this.redisClient.disconnect(); } - - public async clearAllData() { - await this.redisClient.flushAll(); - } } diff --git a/src/repositories/notifications/__tests__/NotificationRepositorySharedTests.test.ts b/src/repositories/notifications/__tests__/NotificationRepositorySharedTests.test.ts index daf9944..86ccf07 100644 --- a/src/repositories/notifications/__tests__/NotificationRepositorySharedTests.test.ts +++ b/src/repositories/notifications/__tests__/NotificationRepositorySharedTests.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, jest } from "@jest/globals"; +import { createClient, RedisClientType } from "redis"; import { InMemoryNotificationRepository } from "../InMemoryNotificationRepository"; import { NotificationEvent, NotificationRepository } from "../NotificationRepository"; import { RedisNotificationRepository } from "../RedisNotificationRepository"; @@ -19,17 +20,21 @@ class InMemoryRepositoryHolder implements RepositoryHolder { class RedisNotificationRepositoryHolder implements RepositoryHolder { repo: RedisNotificationRepository | undefined; + redisClient: RedisClientType | undefined; name = 'RedisNotificationRepository'; factory = async () => { - this.repo = new RedisNotificationRepository(); - await this.repo.connect(); + this.redisClient = createClient({ + url: process.env.REDIS_URL, + }); + await this.redisClient.connect(); + this.repo = new RedisNotificationRepository(this.redisClient); return this.repo; } teardown = async () => { - if (this.repo) { - await this.repo.clearAllData(); - await this.repo.disconnect(); + if (this.redisClient) { + await this.redisClient.flushAll(); + await this.redisClient.disconnect(); } } } diff --git a/src/repositories/parking/__tests__/ParkingRepositorySharedTests.test.ts b/src/repositories/parking/__tests__/ParkingRepositorySharedTests.test.ts index 426ab9d..d9b4162 100644 --- a/src/repositories/parking/__tests__/ParkingRepositorySharedTests.test.ts +++ b/src/repositories/parking/__tests__/ParkingRepositorySharedTests.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, jest } from "@jest/globals"; +import { createClient, RedisClientType } from "redis"; import { InMemoryParkingRepository, } from "../InMemoryParkingRepository"; import { IParkingStructure } from "../../../entities/ParkingRepositoryEntities"; import { ParkingGetterSetterRepository } from "../ParkingGetterSetterRepository"; @@ -16,17 +17,21 @@ class InMemoryParkingRepositoryHolder implements RepositoryHolder { repo: RedisParkingRepository | undefined; + redisClient: RedisClientType | undefined; name = 'RedisParkingRepository'; factory = async () => { - this.repo = new RedisParkingRepository(); - await this.repo.connect(); + this.redisClient = createClient({ + url: process.env.REDIS_URL, + }); + await this.redisClient.connect(); + this.repo = new RedisParkingRepository(this.redisClient); return this.repo; }; teardown = async () => { - if (this.repo) { - await this.repo.clearAllData(); - await this.repo.disconnect(); + if (this.redisClient) { + await this.redisClient.flushAll(); + await this.redisClient.disconnect(); } }; } diff --git a/src/repositories/shuttle/RedisShuttleRepository.ts b/src/repositories/shuttle/RedisShuttleRepository.ts index 96c99a5..1decb11 100644 --- a/src/repositories/shuttle/RedisShuttleRepository.ts +++ b/src/repositories/shuttle/RedisShuttleRepository.ts @@ -24,9 +24,6 @@ export class RedisShuttleRepository extends BaseRedisRepository implements Shutt await this.redisClient.disconnect(); } - public async clearAllData() { - await this.redisClient.flushAll(); - } // EventEmitter override methods for type safety public override on( event: T, diff --git a/src/repositories/shuttle/__tests__/ShuttleRepositorySharedTests.test.ts b/src/repositories/shuttle/__tests__/ShuttleRepositorySharedTests.test.ts index bb99165..2db50fc 100644 --- a/src/repositories/shuttle/__tests__/ShuttleRepositorySharedTests.test.ts +++ b/src/repositories/shuttle/__tests__/ShuttleRepositorySharedTests.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, jest, test } from "@jest/globals"; +import { createClient, RedisClientType } from "redis"; import { UnoptimizedInMemoryShuttleRepository } from "../UnoptimizedInMemoryShuttleRepository"; import { ShuttleGetterSetterRepository } from "../ShuttleGetterSetterRepository"; import { RedisShuttleRepository } from "../RedisShuttleRepository"; @@ -23,17 +24,21 @@ class UnoptimizedInMemoryShuttleRepositoryHolder implements RepositoryHolder { repo: RedisShuttleRepository | undefined; + redisClient: RedisClientType | undefined; name = 'RedisShuttleRepository'; factory = async () => { - this.repo = new RedisShuttleRepository(); - await this.repo.connect(); + this.redisClient = createClient({ + url: process.env.REDIS_URL, + }); + await this.redisClient.connect(); + this.repo = new RedisShuttleRepository(this.redisClient); return this.repo; }; teardown = async () => { - if (this.repo) { - await this.repo.clearAllData(); - await this.repo.disconnect(); + if (this.redisClient) { + await this.redisClient.flushAll(); + await this.redisClient.disconnect(); } }; } diff --git a/src/repositories/shuttle/eta/ExternalSourceETARepository.ts b/src/repositories/shuttle/eta/ExternalSourceETARepository.ts index 5910d62..53dfe5d 100644 --- a/src/repositories/shuttle/eta/ExternalSourceETARepository.ts +++ b/src/repositories/shuttle/eta/ExternalSourceETARepository.ts @@ -8,6 +8,4 @@ export interface ExternalSourceETARepository extends ETAGetterRepository { addOrUpdateEtaFromExternalSource(eta: IEta): Promise; removeEtaIfExists(shuttleId: string, stopId: string): Promise; - - clearAllData(): Promise; } diff --git a/src/repositories/shuttle/eta/RedisExternalSourceETARepository.ts b/src/repositories/shuttle/eta/RedisExternalSourceETARepository.ts index a551dfb..033dc2e 100644 --- a/src/repositories/shuttle/eta/RedisExternalSourceETARepository.ts +++ b/src/repositories/shuttle/eta/RedisExternalSourceETARepository.ts @@ -37,7 +37,4 @@ export class RedisExternalSourceETARepository extends BaseRedisRepository implem removeAllListeners(eventName?: string | symbol | undefined): this { throw new Error("Method not implemented."); } - clearAllData(): Promise { - throw new Error("Method not implemented."); - } } diff --git a/src/repositories/shuttle/eta/__tests__/ExternalSourceETARepositorySharedTests.test.ts b/src/repositories/shuttle/eta/__tests__/ExternalSourceETARepositorySharedTests.test.ts index 4dc4980..eb4d039 100644 --- a/src/repositories/shuttle/eta/__tests__/ExternalSourceETARepositorySharedTests.test.ts +++ b/src/repositories/shuttle/eta/__tests__/ExternalSourceETARepositorySharedTests.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, test } from "@jest/globals"; +import { createClient, RedisClientType } from "redis"; import { RepositoryHolder } from "../../../../../testHelpers/RepositoryHolder"; import { ExternalSourceETARepository } from "../ExternalSourceETARepository"; import { RedisExternalSourceETARepository } from "../RedisExternalSourceETARepository"; @@ -6,17 +7,21 @@ import { generateMockEtas } from "../../../../../testHelpers/mockDataGenerators" class RedisExternalSourceETARepositoryHolder implements RepositoryHolder { repo: RedisExternalSourceETARepository | undefined; + redisClient: RedisClientType | undefined; name = "RedisExternalSourceETARepository" factory = async () => { - this.repo = new RedisExternalSourceETARepository(); - await this.repo.connect(); + this.redisClient = createClient({ + url: process.env.REDIS_URL, + }); + await this.redisClient.connect(); + this.repo = new RedisExternalSourceETARepository(this.redisClient); return this.repo; } teardown = async () => { - if (this.repo) { - await this.repo.clearAllData(); - await this.repo.disconnect(); + if (this.redisClient) { + await this.redisClient.flushAll(); + await this.redisClient.disconnect(); } } } diff --git a/src/repositories/shuttle/eta/__tests__/SelfUpdatingETARepositorySharedTests.test.ts b/src/repositories/shuttle/eta/__tests__/SelfUpdatingETARepositorySharedTests.test.ts index ac61cf1..c04ba37 100644 --- a/src/repositories/shuttle/eta/__tests__/SelfUpdatingETARepositorySharedTests.test.ts +++ b/src/repositories/shuttle/eta/__tests__/SelfUpdatingETARepositorySharedTests.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, test } from "@jest/globals"; +import { createClient, RedisClientType } from "redis"; import { RepositoryHolder } from "../../../../../testHelpers/RepositoryHolder"; import { SelfUpdatingETARepository } from "../SelfUpdatingETARepository"; import { RedisSelfUpdatingETARepository } from "../RedisSelfUpdatingETARepository"; @@ -8,25 +9,25 @@ import { setupRouteAndOrderedStopsForShuttleRepository } from "../../../../../te class RedisSelfUpdatingETARepositoryHolder implements RepositoryHolder { repo: RedisSelfUpdatingETARepository | undefined; shuttleRepo: RedisShuttleRepository | undefined; + redisClient: RedisClientType | undefined; name = "RedisSelfUpdatingETARepository" factory = async () => { - this.shuttleRepo = new RedisShuttleRepository(); - await this.shuttleRepo.connect(); + this.redisClient = createClient({ + url: process.env.REDIS_URL, + }); + await this.redisClient.connect(); + this.shuttleRepo = new RedisShuttleRepository(this.redisClient); this.repo = new RedisSelfUpdatingETARepository( this.shuttleRepo, + this.redisClient, ); - await this.repo.connect(); return this.repo; } teardown = async () => { - if (this.shuttleRepo) { - await this.shuttleRepo.clearAllData(); - await this.shuttleRepo.disconnect(); - } - if (this.repo) { - await this.repo.clearAllData(); - await this.repo.disconnect(); + if (this.redisClient) { + await this.redisClient.flushAll(); + await this.redisClient.disconnect(); } } } From e05519f651a88bd44409c1d54b724e08421449bd Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Tue, 11 Nov 2025 14:52:40 -0800 Subject: [PATCH 43/96] Remove ETA events and add shuttle events for ShuttleGetterRepository --- src/repositories/shuttle/ShuttleGetterRepository.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/repositories/shuttle/ShuttleGetterRepository.ts b/src/repositories/shuttle/ShuttleGetterRepository.ts index c93ce5d..a0e9f7f 100644 --- a/src/repositories/shuttle/ShuttleGetterRepository.ts +++ b/src/repositories/shuttle/ShuttleGetterRepository.ts @@ -2,9 +2,8 @@ import { IEta, IOrderedStop, IRoute, IShuttle, IStop } from "../../entities/Shut import type EventEmitter from "node:events"; export const ShuttleRepositoryEvent = { - ETA_UPDATED: "etaUpdated", - ETA_REMOVED: "etaRemoved", - ETA_DATA_CLEARED: "etaDataCleared", + SHUTTLE_UPDATED: "shuttleUpdated", + SHUTTLE_REMOVED: "shuttleRemoved", } as const; export type ShuttleRepositoryEventName = typeof ShuttleRepositoryEvent[keyof typeof ShuttleRepositoryEvent]; @@ -13,9 +12,8 @@ export type EtaRemovedEventPayload = IEta; export type EtaDataClearedEventPayload = IEta[]; export interface ShuttleRepositoryEventPayloads { - [ShuttleRepositoryEvent.ETA_UPDATED]: IEta; - [ShuttleRepositoryEvent.ETA_REMOVED]: EtaRemovedEventPayload; - [ShuttleRepositoryEvent.ETA_DATA_CLEARED]: EtaDataClearedEventPayload; + [ShuttleRepositoryEvent.SHUTTLE_UPDATED]: IShuttle, + [ShuttleRepositoryEvent.SHUTTLE_REMOVED]: IShuttle, } export type ShuttleRepositoryEventListener = ( From 7a0e303080695f6e925dd3558972e3a3f258e7b0 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Tue, 11 Nov 2025 14:59:24 -0800 Subject: [PATCH 44/96] Add stub for in-memory external source ETA repository --- .../InMemoryExternalSourceETARepository.ts | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 src/repositories/shuttle/eta/InMemoryExternalSourceETARepository.ts diff --git a/src/repositories/shuttle/eta/InMemoryExternalSourceETARepository.ts b/src/repositories/shuttle/eta/InMemoryExternalSourceETARepository.ts new file mode 100644 index 0000000..9e6afa4 --- /dev/null +++ b/src/repositories/shuttle/eta/InMemoryExternalSourceETARepository.ts @@ -0,0 +1,40 @@ +import { EventEmitter } from "stream"; +import { IEta } from "../../../entities/ShuttleRepositoryEntities"; +import { ETARepositoryEventName, ETARepositoryEventListener } from "./ETAGetterRepository"; +import { ExternalSourceETARepository } from "./ExternalSourceETARepository"; + +export class InMemoryExternalSourceETARepository extends EventEmitter implements ExternalSourceETARepository { + addOrUpdateEtaFromExternalSource(eta: IEta): Promise { + throw new Error("Method not implemented."); + } + removeEtaIfExists(shuttleId: string, stopId: string): Promise { + throw new Error("Method not implemented."); + } + on(event: T, listener: ETARepositoryEventListener): this { + throw new Error("Method not implemented."); + } + once(event: T, listener: ETARepositoryEventListener): this { + throw new Error("Method not implemented."); + } + off(event: T, listener: ETARepositoryEventListener): this { + throw new Error("Method not implemented."); + } + addListener(event: T, listener: ETARepositoryEventListener): this { + throw new Error("Method not implemented."); + } + removeListener(event: T, listener: ETARepositoryEventListener): this { + throw new Error("Method not implemented."); + } + getEtasForShuttleId(shuttleId: string): Promise { + throw new Error("Method not implemented."); + } + getEtasForStopId(stopId: string): Promise { + throw new Error("Method not implemented."); + } + getEtaForShuttleAndStopId(shuttleId: string, stopId: string): Promise { + throw new Error("Method not implemented."); + } + removeAllListeners(eventName?: string | symbol | undefined): this { + throw new Error("Method not implemented."); + } +} From 75537a9c3a9a9eca2b9521b9985ea52b8ff1c4e4 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Tue, 11 Nov 2025 14:59:36 -0800 Subject: [PATCH 45/96] Update interface and implementation of ETANotificationScheduler Rely on both the ETA repository and the shuttle repository --- .../schedulers/ETANotificationScheduler.ts | 10 ++++++---- .../__tests__/ETANotificationSchedulerTests.test.ts | 7 ++++++- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/notifications/schedulers/ETANotificationScheduler.ts b/src/notifications/schedulers/ETANotificationScheduler.ts index 62a22c9..c18f280 100644 --- a/src/notifications/schedulers/ETANotificationScheduler.ts +++ b/src/notifications/schedulers/ETANotificationScheduler.ts @@ -1,4 +1,3 @@ -import { ShuttleGetterRepository, ShuttleRepositoryEvent } from "../../repositories/shuttle/ShuttleGetterRepository"; import { IEta } from "../../entities/ShuttleRepositoryEntities"; import { AppleNotificationSender, NotificationAlertArguments } from "../senders/AppleNotificationSender"; import { @@ -6,11 +5,14 @@ import { ScheduledNotification } from "../../repositories/notifications/NotificationRepository"; import { InMemoryNotificationRepository } from "../../repositories/notifications/InMemoryNotificationRepository"; +import { ETAGetterRepository, ETARepositoryEvent } from "../../repositories/shuttle/eta/ETAGetterRepository"; +import { ShuttleGetterRepository } from "../../repositories/shuttle/ShuttleGetterRepository"; export class ETANotificationScheduler { public static readonly defaultSecondsThresholdForNotificationToFire = 180; constructor( + private etaRepository: ETAGetterRepository, private shuttleRepository: ShuttleGetterRepository, private notificationRepository: NotificationRepository = new InMemoryNotificationRepository(), private appleNotificationSender = new AppleNotificationSender(), @@ -26,7 +28,7 @@ export class ETANotificationScheduler { const shuttle = await this.shuttleRepository.getShuttleById(shuttleId); const stop = await this.shuttleRepository.getStopById(stopId); - const eta = await this.shuttleRepository.getEtaForShuttleAndStopId(shuttleId, stopId); + const eta = await this.etaRepository.getEtaForShuttleAndStopId(shuttleId, stopId); if (!shuttle) { console.warn(`Notification ${notificationData} fell through; no associated shuttle`); return false; @@ -90,10 +92,10 @@ export class ETANotificationScheduler { // The following is a workaround for the constructor being called twice public startListeningForUpdates() { - this.shuttleRepository.on(ShuttleRepositoryEvent.ETA_UPDATED, this.etaSubscriberCallback); + this.etaRepository.on(ETARepositoryEvent.ETA_UPDATED, this.etaSubscriberCallback); } public stopListeningForUpdates() { - this.shuttleRepository.off(ShuttleRepositoryEvent.ETA_UPDATED, this.etaSubscriberCallback); + this.etaRepository.off(ETARepositoryEvent.ETA_UPDATED, this.etaSubscriberCallback); } } diff --git a/src/notifications/schedulers/__tests__/ETANotificationSchedulerTests.test.ts b/src/notifications/schedulers/__tests__/ETANotificationSchedulerTests.test.ts index 17cfeef..e79f4a0 100644 --- a/src/notifications/schedulers/__tests__/ETANotificationSchedulerTests.test.ts +++ b/src/notifications/schedulers/__tests__/ETANotificationSchedulerTests.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, jest } from "@jest/globals"; import { ETANotificationScheduler } from "../ETANotificationScheduler"; import { UnoptimizedInMemoryShuttleRepository } from "../../../repositories/shuttle/UnoptimizedInMemoryShuttleRepository"; +import { InMemoryExternalSourceETARepository } from "../../../repositories/shuttle/eta/InMemoryExternalSourceETARepository"; import { IEta, IShuttle, IStop } from "../../../entities/ShuttleRepositoryEntities"; import { addMockShuttleToRepository, addMockStopToRepository } from "../../../../testHelpers/repositorySetupHelpers"; import { AppleNotificationSender } from "../../senders/AppleNotificationSender"; @@ -26,18 +27,21 @@ async function waitForMilliseconds(ms: number): Promise { describe("ETANotificationScheduler", () => { - let shuttleRepository: UnoptimizedInMemoryShuttleRepository + let shuttleRepository: UnoptimizedInMemoryShuttleRepository; + let etaRepository: InMemoryExternalSourceETARepository; let notificationService: ETANotificationScheduler; let notificationRepository: NotificationRepository; beforeEach(() => { shuttleRepository = new UnoptimizedInMemoryShuttleRepository(); notificationRepository = new InMemoryNotificationRepository(); + etaRepository = new InMemoryExternalSourceETARepository(); mockNotificationSenderMethods(true); const appleNotificationSender = new MockAppleNotificationSender(false); notificationService = new ETANotificationScheduler( + etaRepository, shuttleRepository, notificationRepository, appleNotificationSender, @@ -127,6 +131,7 @@ describe("ETANotificationScheduler", () => { mockNotificationSenderMethods(false); const updatedNotificationSender = new MockAppleNotificationSender(false); notificationService = new ETANotificationScheduler( + etaRepository, shuttleRepository, notificationRepository, updatedNotificationSender, From 6df4678acc485b7e4139780687a64c7db1ef5211 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Tue, 11 Nov 2025 15:14:03 -0800 Subject: [PATCH 46/96] Add stub for in-memory self updating ETA repository --- .../eta/InMemorySelfUpdatingETARepository.ts | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 src/repositories/shuttle/eta/InMemorySelfUpdatingETARepository.ts diff --git a/src/repositories/shuttle/eta/InMemorySelfUpdatingETARepository.ts b/src/repositories/shuttle/eta/InMemorySelfUpdatingETARepository.ts new file mode 100644 index 0000000..ce0160b --- /dev/null +++ b/src/repositories/shuttle/eta/InMemorySelfUpdatingETARepository.ts @@ -0,0 +1,47 @@ +import { IEta } from "../../../entities/ShuttleRepositoryEntities"; +import { ETARepositoryEventName, ETARepositoryEventListener } from "./ETAGetterRepository"; +import { SelfUpdatingETARepository } from "./SelfUpdatingETARepository"; +import { ShuttleGetterRepository, ShuttleTravelTimeDataIdentifier, ShuttleTravelTimeDateFilterArguments } from "../ShuttleGetterRepository"; +import { EventEmitter } from "stream"; + +export class InMemorySelfUpdatingETARepository extends EventEmitter implements SelfUpdatingETARepository { + constructor( + readonly shuttleRepository: ShuttleGetterRepository + ) { + super(); + } + + getAverageTravelTimeSeconds(identifier: ShuttleTravelTimeDataIdentifier, dateFilter: ShuttleTravelTimeDateFilterArguments): Promise { + throw new Error("Method not implemented."); + } + startListeningForUpdates(): void { + throw new Error("Method not implemented."); + } + on(event: T, listener: ETARepositoryEventListener): this { + throw new Error("Method not implemented."); + } + once(event: T, listener: ETARepositoryEventListener): this { + throw new Error("Method not implemented."); + } + off(event: T, listener: ETARepositoryEventListener): this { + throw new Error("Method not implemented."); + } + addListener(event: T, listener: ETARepositoryEventListener): this { + throw new Error("Method not implemented."); + } + removeListener(event: T, listener: ETARepositoryEventListener): this { + throw new Error("Method not implemented."); + } + getEtasForShuttleId(shuttleId: string): Promise { + throw new Error("Method not implemented."); + } + getEtasForStopId(stopId: string): Promise { + throw new Error("Method not implemented."); + } + getEtaForShuttleAndStopId(shuttleId: string, stopId: string): Promise { + throw new Error("Method not implemented."); + } + removeAllListeners(eventName?: string | symbol | undefined): this { + throw new Error("Method not implemented."); + } +} From 57d0171d6863d2008fd8a7dbb41e7a7ecfb72234 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Tue, 11 Nov 2025 15:16:01 -0800 Subject: [PATCH 47/96] Remove unused integration test systems --- src/loaders/supportedIntegrationTestSystems.ts | 11 ----------- 1 file changed, 11 deletions(-) delete mode 100644 src/loaders/supportedIntegrationTestSystems.ts diff --git a/src/loaders/supportedIntegrationTestSystems.ts b/src/loaders/supportedIntegrationTestSystems.ts deleted file mode 100644 index 8fd7d6d..0000000 --- a/src/loaders/supportedIntegrationTestSystems.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { InterchangeSystemBuilderArguments } from "../entities/InterchangeSystem"; -import { ChapmanApiBasedParkingRepositoryLoader } from "./parking/ChapmanApiBasedParkingRepositoryLoader"; - -export const supportedIntegrationTestSystems: InterchangeSystemBuilderArguments[] = [ - { - id: "1", - name: "Chapman University", - passioSystemId: "263", - parkingSystemId: ChapmanApiBasedParkingRepositoryLoader.id, - }, -]; From 42cd34e755a594d3853c117df3900c1e9b03a74e Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Tue, 11 Nov 2025 15:16:21 -0800 Subject: [PATCH 48/96] Update InterchangeSystem and caller to construct an ETA repository based on argumetns --- src/entities/InterchangeSystem.ts | 68 ++++++++++++++++++++++++++----- src/index.ts | 1 + 2 files changed, 59 insertions(+), 10 deletions(-) diff --git a/src/entities/InterchangeSystem.ts b/src/entities/InterchangeSystem.ts index 03cac46..49ec992 100644 --- a/src/entities/InterchangeSystem.ts +++ b/src/entities/InterchangeSystem.ts @@ -16,6 +16,11 @@ import { import { RedisParkingRepository } from "../repositories/parking/RedisParkingRepository"; import { RedisShuttleRepository } from "../repositories/shuttle/RedisShuttleRepository"; import { ShuttleGetterRepository } from "../repositories/shuttle/ShuttleGetterRepository"; +import { InMemoryExternalSourceETARepository } from "../repositories/shuttle/eta/InMemoryExternalSourceETARepository"; +import { ETAGetterRepository } from "../repositories/shuttle/eta/ETAGetterRepository"; +import { RedisSelfUpdatingETARepository } from "../repositories/shuttle/eta/RedisSelfUpdatingETARepository"; +import { RedisExternalSourceETARepository } from "../repositories/shuttle/eta/RedisExternalSourceETARepository"; +import { InMemorySelfUpdatingETARepository } from "../repositories/shuttle/eta/InMemorySelfUpdatingETARepository"; export interface InterchangeSystemBuilderArguments { name: string; @@ -34,6 +39,12 @@ export interface InterchangeSystemBuilderArguments { * ID for the parking repository ID in the codebase. */ parkingSystemId?: string; + + /** + * Controls whether to self-calculate ETAs or use the external + * shuttle provider for them. + */ + useSelfUpdatingEtas: boolean } export class InterchangeSystem { @@ -42,6 +53,7 @@ export class InterchangeSystem { public id: string, public shuttleTimedDataLoader: TimedApiBasedRepositoryLoader, public shuttleRepository: ShuttleGetterSetterRepository, + public etaRepository: ETAGetterRepository, public notificationScheduler: ETANotificationScheduler, public notificationRepository: NotificationRepository, public parkingTimedDataLoader: TimedApiBasedRepositoryLoader | null, @@ -57,10 +69,14 @@ export class InterchangeSystem { static async build( args: InterchangeSystemBuilderArguments, ) { - const { shuttleRepository, timedShuttleDataLoader } = await InterchangeSystem.buildRedisShuttleLoaderAndRepository(args); + const { shuttleRepository, timedShuttleDataLoader, etaRepository } = await InterchangeSystem.buildRedisShuttleLoaderAndRepositories(args); timedShuttleDataLoader.start(); - const { notificationScheduler, notificationRepository } = await InterchangeSystem.buildNotificationSchedulerAndRepository(shuttleRepository, args); + const { notificationScheduler, notificationRepository } = await InterchangeSystem.buildNotificationSchedulerAndRepository( + etaRepository, + shuttleRepository, + args + ); notificationScheduler.startListeningForUpdates(); let { parkingRepository, timedParkingLoader } = await InterchangeSystem.buildRedisParkingLoaderAndRepository(args.parkingSystemId); @@ -71,6 +87,7 @@ export class InterchangeSystem { args.id, timedShuttleDataLoader, shuttleRepository, + etaRepository, notificationScheduler, notificationRepository, timedParkingLoader, @@ -78,7 +95,7 @@ export class InterchangeSystem { ); } - private static async buildRedisShuttleLoaderAndRepository(args: InterchangeSystemBuilderArguments) { + private static async buildRedisShuttleLoaderAndRepositories(args: InterchangeSystemBuilderArguments) { const shuttleRepository = new RedisShuttleRepository(); await shuttleRepository.connect(); const shuttleDataLoader = new ApiBasedShuttleRepositoryLoader( @@ -89,13 +106,26 @@ export class InterchangeSystem { const timedShuttleDataLoader = new TimedApiBasedRepositoryLoader( shuttleDataLoader ); - return { shuttleRepository, timedShuttleDataLoader }; + + let etaRepository: ETAGetterRepository; + if (args.useSelfUpdatingEtas) { + etaRepository = new RedisSelfUpdatingETARepository(shuttleRepository); + } else { + etaRepository = new RedisExternalSourceETARepository(); + } + + return { shuttleRepository, etaRepository, timedShuttleDataLoader }; } - private static async buildNotificationSchedulerAndRepository(shuttleRepository: ShuttleGetterRepository, args: InterchangeSystemBuilderArguments) { + private static async buildNotificationSchedulerAndRepository( + etaRepository: ETAGetterRepository, + shuttleRepository: ShuttleGetterRepository, + args: InterchangeSystemBuilderArguments + ) { const notificationRepository = new RedisNotificationRepository(); await notificationRepository.connect(); const notificationScheduler = new ETANotificationScheduler( + etaRepository, shuttleRepository, notificationRepository, new AppleNotificationSender(), @@ -138,10 +168,14 @@ export class InterchangeSystem { static buildForTesting( args: InterchangeSystemBuilderArguments, ) { - const { shuttleRepository, timedShuttleLoader } = InterchangeSystem.buildInMemoryShuttleLoaderAndRepository(args); + const { shuttleRepository, timedShuttleLoader, etaRepository } = InterchangeSystem.buildInMemoryShuttleLoaderAndRepositories(args); // Timed shuttle loader is not started here - const { notificationScheduler, notificationRepository } = InterchangeSystem.buildInMemoryNotificationSchedulerAndRepository(shuttleRepository, args); + const { notificationScheduler, notificationRepository } = InterchangeSystem.buildInMemoryNotificationSchedulerAndRepository( + etaRepository, + shuttleRepository, + args + ); notificationScheduler.startListeningForUpdates(); let { parkingRepository, timedParkingLoader } = this.buildInMemoryParkingLoaderAndRepository(args.parkingSystemId); @@ -152,6 +186,7 @@ export class InterchangeSystem { args.id, timedShuttleLoader, shuttleRepository, + etaRepository, notificationScheduler, notificationRepository, timedParkingLoader, @@ -159,9 +194,14 @@ export class InterchangeSystem { ); } - private static buildInMemoryNotificationSchedulerAndRepository(shuttleRepository: UnoptimizedInMemoryShuttleRepository, args: InterchangeSystemBuilderArguments) { + private static buildInMemoryNotificationSchedulerAndRepository( + etaRepository: ETAGetterRepository, + shuttleRepository: UnoptimizedInMemoryShuttleRepository, + args: InterchangeSystemBuilderArguments + ) { const notificationRepository = new InMemoryNotificationRepository(); const notificationScheduler = new ETANotificationScheduler( + etaRepository, shuttleRepository, notificationRepository, new AppleNotificationSender(false), @@ -193,7 +233,7 @@ export class InterchangeSystem { return { parkingRepository, timedParkingLoader }; } - private static buildInMemoryShuttleLoaderAndRepository(args: InterchangeSystemBuilderArguments) { + private static buildInMemoryShuttleLoaderAndRepositories(args: InterchangeSystemBuilderArguments) { const shuttleRepository = new UnoptimizedInMemoryShuttleRepository(); const shuttleDataLoader = new ApiBasedShuttleRepositoryLoader( args.passioSystemId, @@ -205,7 +245,15 @@ export class InterchangeSystem { const timedShuttleLoader = new TimedApiBasedRepositoryLoader( shuttleDataLoader ); - return { shuttleRepository, timedShuttleLoader }; + + let etaRepository: ETAGetterRepository; + if (args.useSelfUpdatingEtas) { + etaRepository = new InMemorySelfUpdatingETARepository(shuttleRepository); + } else { + etaRepository = new InMemoryExternalSourceETARepository(); + } + + return { shuttleRepository, etaRepository, timedShuttleLoader }; } } diff --git a/src/index.ts b/src/index.ts index bcc32a9..a8fd797 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,6 +23,7 @@ const supportedSystems: InterchangeSystemBuilderArguments[] = [ passioSystemId: "263", parkingSystemId: ChapmanApiBasedParkingRepositoryLoader.id, name: "Chapman University", + useSelfUpdatingEtas: true, } ] From 15315f826e25f3bf07c7f56dda89b48c81695fe6 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Tue, 11 Nov 2025 15:27:42 -0800 Subject: [PATCH 49/96] Add SHUTTLE_ARRIVED_AT_STOP event and remove ETA-related methods for ShuttleGetterRepository --- src/repositories/shuttle/ShuttleGetterRepository.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/repositories/shuttle/ShuttleGetterRepository.ts b/src/repositories/shuttle/ShuttleGetterRepository.ts index a0e9f7f..44bb872 100644 --- a/src/repositories/shuttle/ShuttleGetterRepository.ts +++ b/src/repositories/shuttle/ShuttleGetterRepository.ts @@ -4,6 +4,7 @@ import type EventEmitter from "node:events"; export const ShuttleRepositoryEvent = { SHUTTLE_UPDATED: "shuttleUpdated", SHUTTLE_REMOVED: "shuttleRemoved", + SHUTTLE_ARRIVED_AT_STOP: "shuttleArrivedAtStop", } as const; export type ShuttleRepositoryEventName = typeof ShuttleRepositoryEvent[keyof typeof ShuttleRepositoryEvent]; @@ -14,6 +15,7 @@ export type EtaDataClearedEventPayload = IEta[]; export interface ShuttleRepositoryEventPayloads { [ShuttleRepositoryEvent.SHUTTLE_UPDATED]: IShuttle, [ShuttleRepositoryEvent.SHUTTLE_REMOVED]: IShuttle, + [ShuttleRepositoryEvent.SHUTTLE_ARRIVED_AT_STOP]: ShuttleStopArrival, } export type ShuttleRepositoryEventListener = ( @@ -21,6 +23,7 @@ export type ShuttleRepositoryEventListener ) => void; export interface ShuttleStopArrival { + shuttleId: string; stopId: string; timestamp: Date; } @@ -50,10 +53,6 @@ export interface ShuttleGetterRepository extends EventEmitter { getShuttleById(shuttleId: string): Promise; getShuttlesByRouteId(routeId: string): Promise; - getEtasForShuttleId(shuttleId: string): Promise; - getEtasForStopId(stopId: string): Promise; - getEtaForShuttleAndStopId(shuttleId: string, stopId: string): Promise; - on(event: T, listener: ShuttleRepositoryEventListener): this; once(event: T, listener: ShuttleRepositoryEventListener): this; off(event: T, listener: ShuttleRepositoryEventListener): this; From 5eb40ff8a5908fa66d1dadc0138a4e4aeb10781c Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Tue, 11 Nov 2025 15:28:01 -0800 Subject: [PATCH 50/96] Remove ETA-related methods from ShuttleGetterSetterRepository --- .../shuttle/ShuttleGetterSetterRepository.ts | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/src/repositories/shuttle/ShuttleGetterSetterRepository.ts b/src/repositories/shuttle/ShuttleGetterSetterRepository.ts index c61d0a2..3551d67 100644 --- a/src/repositories/shuttle/ShuttleGetterSetterRepository.ts +++ b/src/repositories/shuttle/ShuttleGetterSetterRepository.ts @@ -2,7 +2,7 @@ // to convert from data repo to GraphQL schema import { ShuttleGetterRepository, ShuttleTravelTimeDataIdentifier, ShuttleTravelTimeDateFilterArguments } from "./ShuttleGetterRepository"; -import { IEta, IOrderedStop, IRoute, IShuttle, IStop } from "../../entities/ShuttleRepositoryEntities"; +import { IOrderedStop, IRoute, IShuttle, IStop } from "../../entities/ShuttleRepositoryEntities"; /** * ShuttleGetterRepository interface for data derived from Passio API. @@ -17,24 +17,15 @@ export interface ShuttleGetterSetterRepository extends ShuttleGetterRepository { addOrUpdateStop(stop: IStop): Promise; addOrUpdateOrderedStop(orderedStop: IOrderedStop): 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; removeStopIfExists(stopId: string): Promise; removeOrderedStopIfExists(stopId: string, routeId: string): Promise; - removeEtaIfExists(shuttleId: string, stopId: string): Promise; clearRouteData(): Promise; clearShuttleData(): Promise; clearStopData(): Promise; clearOrderedStopData(): Promise; - clearEtaData(): Promise; /** * Get average travel time between two stops based on historical data. From 747477cff4cdbab96df6f8ae01f3dba89da9b748 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Tue, 11 Nov 2025 15:28:11 -0800 Subject: [PATCH 51/96] Update shuttle repository implementations to remove ETA functionality --- .../shuttle/RedisShuttleRepository.ts | 187 +----------------- .../UnoptimizedInMemoryShuttleRepository.ts | 108 +--------- 2 files changed, 13 insertions(+), 282 deletions(-) diff --git a/src/repositories/shuttle/RedisShuttleRepository.ts b/src/repositories/shuttle/RedisShuttleRepository.ts index 1decb11..ab7a986 100644 --- a/src/repositories/shuttle/RedisShuttleRepository.ts +++ b/src/repositories/shuttle/RedisShuttleRepository.ts @@ -1,13 +1,12 @@ import { ShuttleGetterSetterRepository } from "./ShuttleGetterSetterRepository"; import { IEta, IOrderedStop, IRoute, IShuttle, IStop, shuttleHasArrivedAtStop } from "../../entities/ShuttleRepositoryEntities"; import { - ShuttleRepositoryEvent, ShuttleRepositoryEventListener, ShuttleRepositoryEventName, ShuttleRepositoryEventPayloads, ShuttleStopArrival, ShuttleTravelTimeDataIdentifier, - ShuttleTravelTimeDateFilterArguments, + ShuttleTravelTimeDateFilterArguments } from "./ShuttleGetterRepository"; import { BaseRedisRepository } from "../BaseRedisRepository"; @@ -147,14 +146,6 @@ export class RedisShuttleRepository extends BaseRedisRepository implements Shutt updatedTime: new Date(data.updatedTime), }); - private createRedisHashFromEta = (eta: IEta): Record => ({ - secondsRemaining: eta.secondsRemaining.toString(), - shuttleId: eta.shuttleId, - stopId: eta.stopId, - systemId: eta.systemId, - updatedTime: eta.updatedTime.toISOString(), - }); - private createEtaFromRedisData = (data: Record): IEta => ({ secondsRemaining: parseFloat(data.secondsRemaining), shuttleId: data.shuttleId, @@ -388,70 +379,25 @@ export class RedisShuttleRepository extends BaseRedisRepository implements Shutt public async addOrUpdateShuttle( shuttle: IShuttle, travelTimeTimestamp = Date.now(), - referenceCurrentTime = new Date(), ): Promise { const key = this.createShuttleKey(shuttle.id); await this.redisClient.hSet(key, this.createRedisHashFromShuttle(shuttle)); - await this.updateLastStopArrivalAndTravelTimeDataPoints(shuttle, travelTimeTimestamp); - await this.updateEtasBasedOnHistoricalData(shuttle, referenceCurrentTime); + await this.updateLastStopArrival(shuttle, travelTimeTimestamp); } - 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( + private async updateLastStopArrival( 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, { + // TODO: Implement the event + await this.updateShuttleLastStopArrival({ stopId: arrivedStop.id, timestamp: new Date(travelTimeTimestamp), + shuttleId: shuttle.id, }) } } @@ -488,78 +434,6 @@ export class RedisShuttleRepository extends BaseRedisRepository implements Shutt } } - private async addTravelTimeDataPoint( - { routeId, fromStopId, toStopId }: ShuttleTravelTimeDataIdentifier, - travelTimeSeconds: number, - timestamp = Date.now(), - ): Promise { - const historicalEtaTimeSeriesKey = this.createHistoricalEtaTimeSeriesKey(routeId, fromStopId, toStopId); - - try { - await this.redisClient.sendCommand([ - 'TS.ADD', - historicalEtaTimeSeriesKey, - timestamp.toString(), - travelTimeSeconds.toString(), - 'LABELS', - 'routeId', - routeId, - 'fromStopId', - fromStopId, - 'toStopId', - toStopId - ]); - } catch (error) { - await this.createHistoricalEtaTimeSeriesAndAddDataPoint( - historicalEtaTimeSeriesKey, - timestamp, - travelTimeSeconds, - routeId, - fromStopId, - toStopId - ); - } - } - - - private async createHistoricalEtaTimeSeriesAndAddDataPoint( - timeSeriesKey: string, - timestamp: number, - travelTimeSeconds: number, - routeId: string, - fromStopId: string, - toStopId: string, - ): Promise { - try { - await this.redisClient.sendCommand([ - 'TS.CREATE', - timeSeriesKey, - 'RETENTION', - '2678400000', // one month in milliseconds - 'LABELS', - 'routeId', - routeId, - 'fromStopId', - fromStopId, - 'toStopId', - toStopId - ]); - await this.redisClient.sendCommand([ - 'TS.ADD', - timeSeriesKey, - timestamp.toString(), - travelTimeSeconds.toString() - ]); - } catch (createError) { - await this.redisClient.sendCommand([ - 'TS.ADD', - timeSeriesKey, - timestamp.toString(), - travelTimeSeconds.toString() - ]); - } - } - public async getArrivedStopIfExists( shuttle: IShuttle, delta = 0.001, @@ -584,13 +458,14 @@ export class RedisShuttleRepository extends BaseRedisRepository implements Shutt } return { + shuttleId, stopId: data.stopId, timestamp: new Date(data.timestamp), }; } - private async updateShuttleLastStopArrival(shuttleId: string, lastStopArrival: ShuttleStopArrival) { - const key = this.createShuttleLastStopKey(shuttleId); + private async updateShuttleLastStopArrival(lastStopArrival: ShuttleStopArrival) { + const key = this.createShuttleLastStopKey(lastStopArrival.shuttleId); await this.redisClient.hSet(key, { stopId: lastStopArrival.stopId, timestamp: lastStopArrival.timestamp.toISOString(), @@ -607,16 +482,6 @@ export class RedisShuttleRepository extends BaseRedisRepository implements Shutt await this.redisClient.hSet(key, this.createRedisHashFromOrderedStop(orderedStop)); } - 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); @@ -658,16 +523,6 @@ export class RedisShuttleRepository extends BaseRedisRepository implements Shutt return null; } - public async removeEtaIfExists(shuttleId: string, stopId: string): Promise { - const eta = await this.getEtaForShuttleAndStopId(shuttleId, stopId); - if (eta) { - const key = this.createEtaKey(shuttleId, stopId); - await this.redisClient.del(key); - this.emit(ShuttleRepositoryEvent.ETA_REMOVED, eta); - return eta; - } - return null; - } // Clear methods public async clearShuttleData(): Promise { @@ -677,15 +532,6 @@ export class RedisShuttleRepository extends BaseRedisRepository implements Shutt } } - public async clearEtaData(): Promise { - const removedEtas = await this.getAllEtas(); - const keys = await this.redisClient.keys('shuttle:eta:*'); - if (keys.length > 0) { - await this.redisClient.del(keys); - } - this.emit(ShuttleRepositoryEvent.ETA_DATA_CLEARED, removedEtas); - } - public async clearOrderedStopData(): Promise { const keys = await this.redisClient.keys('shuttle:orderedstop:*'); if (keys.length > 0) { @@ -706,19 +552,4 @@ export class RedisShuttleRepository extends BaseRedisRepository implements Shutt await this.redisClient.del(keys); } } - - // Helper method to get all ETAs for the clearEtaData event - private async getAllEtas(): Promise { - const keys = await this.redisClient.keys('shuttle:eta:*'); - const etas: IEta[] = []; - - for (const key of keys) { - const data = await this.redisClient.hGetAll(key); - if (Object.keys(data).length > 0) { - etas.push(this.createEtaFromRedisData(data)); - } - } - - return etas; - } } diff --git a/src/repositories/shuttle/UnoptimizedInMemoryShuttleRepository.ts b/src/repositories/shuttle/UnoptimizedInMemoryShuttleRepository.ts index c0eedd8..0ebbaa8 100644 --- a/src/repositories/shuttle/UnoptimizedInMemoryShuttleRepository.ts +++ b/src/repositories/shuttle/UnoptimizedInMemoryShuttleRepository.ts @@ -1,9 +1,8 @@ import EventEmitter from "node:events"; import { ShuttleGetterSetterRepository } from "./ShuttleGetterSetterRepository"; -import { IEta, IOrderedStop, IRoute, IShuttle, IStop, shuttleHasArrivedAtStop } from "../../entities/ShuttleRepositoryEntities"; +import { IOrderedStop, IRoute, IShuttle, IStop, shuttleHasArrivedAtStop } from "../../entities/ShuttleRepositoryEntities"; import { IEntityWithId } from "../../entities/SharedEntities"; import { - ShuttleRepositoryEvent, ShuttleRepositoryEventListener, ShuttleRepositoryEventName, ShuttleRepositoryEventPayloads, @@ -71,7 +70,6 @@ export class UnoptimizedInMemoryShuttleRepository private stops: IStop[] = []; private routes: IRoute[] = []; private shuttles: IShuttle[] = []; - private etas: IEta[] = []; private orderedStops: IOrderedStop[] = []; private shuttleLastStopArrivals: Map = new Map(); private travelTimeData: Map> = new Map(); @@ -104,18 +102,6 @@ export class UnoptimizedInMemoryShuttleRepository return this.findEntityById(shuttleId, this.shuttles); } - public async getEtasForShuttleId(shuttleId: string): Promise { - return this.etas.filter(eta => eta.shuttleId === shuttleId); - } - - public async getEtasForStopId(stopId: string) { - return this.etas.filter(eta => eta.stopId === stopId); - } - - public async getEtaForShuttleAndStopId(shuttleId: string, stopId: string) { - return this.findEntityByMatcher((value) => value.stopId === stopId && value.shuttleId === shuttleId, this.etas); - } - public async getOrderedStopByRouteAndStopId(routeId: string, stopId: string) { return this.findEntityByMatcher((value) => value.routeId === routeId && value.stopId === stopId, this.orderedStops) } @@ -161,8 +147,7 @@ export class UnoptimizedInMemoryShuttleRepository this.shuttles.push(shuttle); } - await this.updateLastStopArrivalAndTravelTimeDataPoints(shuttle, travelTimeTimestamp); - await this.updateEtasBasedOnHistoricalData(shuttle, referenceCurrentTime); + await this.updateLastStopArrival(shuttle, travelTimeTimestamp); } public async addOrUpdateStop(stop: IStop): Promise { @@ -183,89 +168,21 @@ export class UnoptimizedInMemoryShuttleRepository } } - 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; - } else { - this.etas.push(eta); - } - 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( + private async updateLastStopArrival( 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), + shuttleId: shuttle.id, }); } } - 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); @@ -350,27 +267,10 @@ export class UnoptimizedInMemoryShuttleRepository }, this.orderedStops); } - public async removeEtaIfExists(shuttleId: string, stopId: string): Promise { - const removedEta = await this.removeEntityByMatcherIfExists((eta) => { - return eta.stopId === stopId - && eta.shuttleId === shuttleId - }, this.etas); - if (removedEta) { - this.emit(ShuttleRepositoryEvent.ETA_REMOVED, removedEta); - } - return removedEta; - } - public async clearShuttleData(): Promise { this.shuttles = []; } - public async clearEtaData(): Promise { - const removedEtas = [...this.etas]; - this.etas = []; - this.emit(ShuttleRepositoryEvent.ETA_DATA_CLEARED, removedEtas); - } - public async clearOrderedStopData(): Promise { this.orderedStops = []; } From 90041555a91cada20b79d74fa7c0d72812cf4161 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Tue, 11 Nov 2025 15:30:43 -0800 Subject: [PATCH 52/96] Remove ETA-related tests from ShuttleRepositorySharedTests --- .../ShuttleRepositorySharedTests.test.ts | 91 ------------------- 1 file changed, 91 deletions(-) diff --git a/src/repositories/shuttle/__tests__/ShuttleRepositorySharedTests.test.ts b/src/repositories/shuttle/__tests__/ShuttleRepositorySharedTests.test.ts index 2db50fc..cb606c9 100644 --- a/src/repositories/shuttle/__tests__/ShuttleRepositorySharedTests.test.ts +++ b/src/repositories/shuttle/__tests__/ShuttleRepositorySharedTests.test.ts @@ -3,9 +3,7 @@ import { createClient, RedisClientType } from "redis"; import { UnoptimizedInMemoryShuttleRepository } from "../UnoptimizedInMemoryShuttleRepository"; import { ShuttleGetterSetterRepository } from "../ShuttleGetterSetterRepository"; import { RedisShuttleRepository } from "../RedisShuttleRepository"; -import { ShuttleRepositoryEvent } from "../ShuttleGetterRepository"; import { - generateMockEtas, generateMockOrderedStops, generateMockRoutes, generateMockShuttles, @@ -181,55 +179,6 @@ describe.each(repositoryImplementations)('$name', (holder) => { }); }); - describe("ETA event emitters", () => { - test("emits ETA_UPDATED event when ETAs are added from external source", async () => { - const mockListener = jest.fn(); - repository.on(ShuttleRepositoryEvent.ETA_UPDATED, mockListener); - - const mockEtas = generateMockEtas(); - for (const eta of mockEtas) { - await repository.addOrUpdateEtaFromExternalSource(eta); - } - - expect(mockListener).toHaveBeenCalledTimes(mockEtas.length); - expect(mockListener).toHaveBeenCalledWith(mockEtas[0]); - expect(mockListener).toHaveBeenCalledWith(mockEtas[mockEtas.length - 1]); - }); - - test("does not notify listener after it has been removed", async () => { - const mockListener = jest.fn(); - repository.on(ShuttleRepositoryEvent.ETA_UPDATED, mockListener); - - const mockEtas = generateMockEtas(); - - repository.off(ShuttleRepositoryEvent.ETA_UPDATED, mockListener); - await repository.addOrUpdateEtaFromExternalSource(mockEtas[0]); - expect(mockListener).toHaveBeenCalledTimes(0); - }); - - test("stops notifying specific listener after removal but continues for others", async () => { - const mockListener1 = jest.fn(); - const mockListener2 = jest.fn(); - repository.on(ShuttleRepositoryEvent.ETA_UPDATED, mockListener1); - repository.on(ShuttleRepositoryEvent.ETA_UPDATED, mockListener2); - - const mockEtas = generateMockEtas(); - await repository.addOrUpdateEtaFromExternalSource(mockEtas[0]); - - repository.off(ShuttleRepositoryEvent.ETA_UPDATED, mockListener1); - - await repository.addOrUpdateEtaFromExternalSource(mockEtas[mockEtas.length - 1]); - - expect(mockListener1).toHaveBeenCalledTimes(1); - expect(mockListener1).toHaveBeenCalledWith(mockEtas[0]); - expect(mockListener1).not.toHaveBeenCalledWith(mockEtas[mockEtas.length - 1]); - - expect(mockListener2).toHaveBeenCalledTimes(2); - expect(mockListener2).toHaveBeenCalledWith(mockEtas[0]); - expect(mockListener2).toHaveBeenCalledWith(mockEtas[mockEtas.length - 1]); - }); - }); - describe("getOrderedStopByRouteAndStopId", () => { test("gets an ordered stop by route ID and stop ID", async () => { const mockOrderedStops = generateMockOrderedStops(); @@ -515,46 +464,6 @@ describe.each(repositoryImplementations)('$name', (holder) => { }); }); - describe("removeEtaIfExists", () => { - test("emits an eta removed event when an eta is removed", async () => { - const mockRoutes = generateMockRoutes(); - const mockStops = generateMockStops(); - const mockShuttles = generateMockShuttles(); - const mockOrderedStops = generateMockOrderedStops(); - - const listener = jest.fn(); - repository.on(ShuttleRepositoryEvent.ETA_REMOVED, listener); - - // Setup route, stops, and ordered stops - await repository.addOrUpdateRoute(mockRoutes[0]); - for (const stop of mockStops) { - await repository.addOrUpdateStop(stop); - } - for (const orderedStop of mockOrderedStops.filter(os => os.routeId === mockRoutes[0].id)) { - await repository.addOrUpdateOrderedStop(orderedStop); - } - - // Create an ETA by moving shuttle between stops - const shuttle = mockShuttles[0]; - shuttle.routeId = mockRoutes[0].id; - shuttle.coordinates = mockStops[0].coordinates; - await repository.addOrUpdateShuttle(shuttle); - - shuttle.coordinates = mockStops[1].coordinates; - await repository.addOrUpdateShuttle(shuttle); - - // Get any ETAs that were created for this shuttle - const existingEtas = await repository.getEtasForShuttleId(shuttle.id); - - if (existingEtas.length > 0) { - const etaToRemove = existingEtas[0]; - await repository.removeEtaIfExists(etaToRemove.shuttleId, etaToRemove.stopId); - - expect(listener).toHaveBeenCalledWith(etaToRemove); - } - }); - }); - describe("clearShuttleData", () => { test("clears all shuttles from the repository", async () => { const mockShuttles = generateMockShuttles(); From fd59f6cbd9f946f09cdf0b6710711785373aa51b Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Tue, 11 Nov 2025 15:30:52 -0800 Subject: [PATCH 53/96] Update resolvers to use the ETA repository --- src/resolvers/ShuttleResolvers.ts | 4 ++-- src/resolvers/StopResolvers.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/resolvers/ShuttleResolvers.ts b/src/resolvers/ShuttleResolvers.ts index b755e83..da276ae 100644 --- a/src/resolvers/ShuttleResolvers.ts +++ b/src/resolvers/ShuttleResolvers.ts @@ -9,7 +9,7 @@ export const ShuttleResolvers: Resolvers = { const system = contextValue.findSystemById(parent.systemId); if (!system) return null; - const etaForStopId = await system.shuttleRepository.getEtaForShuttleAndStopId(parent.id, args.forStopId); + const etaForStopId = await system.etaRepository.getEtaForShuttleAndStopId(parent.id, args.forStopId); if (etaForStopId === null) return null; return { @@ -25,7 +25,7 @@ export const ShuttleResolvers: Resolvers = { const system = contextValue.findSystemById(parent.systemId); if (!system) return null; - const etasForShuttle = await system.shuttleRepository.getEtasForShuttleId(parent.id); + const etasForShuttle = await system.etaRepository.getEtasForShuttleId(parent.id); if (!etasForShuttle) return null; const computedEtas = await Promise.all( diff --git a/src/resolvers/StopResolvers.ts b/src/resolvers/StopResolvers.ts index 05d0cb9..45507e0 100644 --- a/src/resolvers/StopResolvers.ts +++ b/src/resolvers/StopResolvers.ts @@ -16,7 +16,7 @@ export const StopResolvers: Resolvers = { if (!system) { return []; } - const etas = await system.shuttleRepository.getEtasForStopId(parent.id); + const etas = await system.etaRepository.getEtasForStopId(parent.id); return etas.slice().sort((a, b) => a.secondsRemaining - b.secondsRemaining); }, }, From 1d366bd63d5d532c665a386b069740ac82974ea6 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Tue, 11 Nov 2025 15:36:07 -0800 Subject: [PATCH 54/96] Pass new configuration option in apolloTestServerHelpers --- testHelpers/apolloTestServerHelpers.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/testHelpers/apolloTestServerHelpers.ts b/testHelpers/apolloTestServerHelpers.ts index ea192c2..60a8355 100644 --- a/testHelpers/apolloTestServerHelpers.ts +++ b/testHelpers/apolloTestServerHelpers.ts @@ -3,7 +3,7 @@ import { ApolloServer } from "@apollo/server"; import { MergedResolvers } from "../src/MergedResolvers"; import { beforeEach } from "@jest/globals"; import { ServerContext } from "../src/ServerContext"; -import { InterchangeSystem } from "../src/entities/InterchangeSystem"; +import { InterchangeSystem, InterchangeSystemBuilderArguments } from "../src/entities/InterchangeSystem"; import { ChapmanApiBasedParkingRepositoryLoader } from "../src/loaders/parking/ChapmanApiBasedParkingRepositoryLoader"; @@ -19,11 +19,12 @@ function setUpTestServer() { }); } -const systemInfoForTesting = { +const systemInfoForTesting: InterchangeSystemBuilderArguments = { id: "1", name: "Chapman University", passioSystemId: "263", parkingSystemId: ChapmanApiBasedParkingRepositoryLoader.id, + useSelfUpdatingEtas: false, }; export function buildSystemForTesting() { From 2854912e073244b99755cacdbcc9bae2768cc680 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Tue, 11 Nov 2025 15:37:07 -0800 Subject: [PATCH 55/96] Update ETA mock helper to take an ExternalSourceETARepository --- src/resolvers/__tests__/EtaResolverTests.test.ts | 5 +++-- testHelpers/repositorySetupHelpers.ts | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/resolvers/__tests__/EtaResolverTests.test.ts b/src/resolvers/__tests__/EtaResolverTests.test.ts index e6bbe20..f584b8c 100644 --- a/src/resolvers/__tests__/EtaResolverTests.test.ts +++ b/src/resolvers/__tests__/EtaResolverTests.test.ts @@ -6,7 +6,8 @@ import { addMockShuttleToRepository, addMockStopToRepository, } from "../../../testHelpers/repositorySetupHelpers"; -import assert = require("node:assert"); +import { ExternalSourceETARepository } from "../../repositories/shuttle/eta/ExternalSourceETARepository"; +import assert from "node:assert"; describe("EtaResolvers", () => { const holder = setupTestServerHolder(); @@ -19,7 +20,7 @@ describe("EtaResolvers", () => { beforeEach(async () => { mockShuttle = await addMockShuttleToRepository(context.systems[0].shuttleRepository, context.systems[0].id); mockStop = await addMockStopToRepository(context.systems[0].shuttleRepository, context.systems[0].id); - expectedEta = await addMockEtaToRepository(context.systems[0].shuttleRepository, mockStop.id, mockShuttle.id); + expectedEta = await addMockEtaToRepository(context.systems[0].etaRepository as ExternalSourceETARepository, mockStop.id, mockShuttle.id); }); async function getResponseForEtaQuery(query: string) { diff --git a/testHelpers/repositorySetupHelpers.ts b/testHelpers/repositorySetupHelpers.ts index 7be3d1c..657fd4d 100644 --- a/testHelpers/repositorySetupHelpers.ts +++ b/testHelpers/repositorySetupHelpers.ts @@ -5,6 +5,7 @@ import { generateMockStops, } from "./mockDataGenerators"; import { ShuttleGetterSetterRepository } from "../src/repositories/shuttle/ShuttleGetterSetterRepository"; +import { ExternalSourceETARepository } from "../src/repositories/shuttle/eta/ExternalSourceETARepository"; export async function addMockRouteToRepository(repository: ShuttleGetterSetterRepository, systemId: string) { const mockRoutes = generateMockRoutes(); @@ -32,7 +33,7 @@ export async function addMockShuttleToRepository(repository: ShuttleGetterSetter return mockShuttle; } -export async function addMockEtaToRepository(repository: ShuttleGetterSetterRepository, stopId: string, shuttleId: string) { +export async function addMockEtaToRepository(repository: ExternalSourceETARepository, stopId: string, shuttleId: string) { const etas = generateMockEtas(); const expectedEta = etas[0]; expectedEta.stopId = stopId; From dac22da0b5164a46f3aa02281d8ecdeb29de30ca Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Tue, 11 Nov 2025 15:38:03 -0800 Subject: [PATCH 56/96] Update method call to addOrUpdateShuttle --- .../eta/__tests__/SelfUpdatingETARepositorySharedTests.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/repositories/shuttle/eta/__tests__/SelfUpdatingETARepositorySharedTests.test.ts b/src/repositories/shuttle/eta/__tests__/SelfUpdatingETARepositorySharedTests.test.ts index c04ba37..a3a0eb2 100644 --- a/src/repositories/shuttle/eta/__tests__/SelfUpdatingETARepositorySharedTests.test.ts +++ b/src/repositories/shuttle/eta/__tests__/SelfUpdatingETARepositorySharedTests.test.ts @@ -114,7 +114,6 @@ describe.each(repositoryImplementations)('$name', (holder) => { await shuttleRepository.addOrUpdateShuttle( shuttle, new Date(2025, 0, 8, 12, 0, 0).getTime(), - new Date(2025, 0, 8, 12, 7, 30), ); const eta = await repository.getEtaForShuttleAndStopId(shuttle.id, stop2.id); From c6f8e5642e8a0269803432a4285570b7fb661945 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Tue, 11 Nov 2025 15:44:34 -0800 Subject: [PATCH 57/96] Fix typing errors in test --- .../__tests__/ETANotificationSchedulerTests.test.ts | 6 +++--- src/resolvers/__tests__/ShuttleResolverTests.test.ts | 11 ++++++----- src/resolvers/__tests__/StopResolverTests.test.ts | 11 ++++++----- 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/src/notifications/schedulers/__tests__/ETANotificationSchedulerTests.test.ts b/src/notifications/schedulers/__tests__/ETANotificationSchedulerTests.test.ts index e79f4a0..9c3ae89 100644 --- a/src/notifications/schedulers/__tests__/ETANotificationSchedulerTests.test.ts +++ b/src/notifications/schedulers/__tests__/ETANotificationSchedulerTests.test.ts @@ -84,7 +84,7 @@ describe("ETANotificationScheduler", () => { // Act await notificationRepository.addOrUpdateNotification(notificationData1); await notificationRepository.addOrUpdateNotification(notificationData2); - await shuttleRepository.addOrUpdateEtaFromExternalSource(eta); + await etaRepository.addOrUpdateEtaFromExternalSource(eta); // Assert // Wait for the callback to actually be called @@ -107,7 +107,7 @@ describe("ETANotificationScheduler", () => { // Act await notificationRepository.addOrUpdateNotification(notificationData1); - await shuttleRepository.addOrUpdateEtaFromExternalSource(eta); + await etaRepository.addOrUpdateEtaFromExternalSource(eta); // Assert await waitForMilliseconds(500); @@ -141,7 +141,7 @@ describe("ETANotificationScheduler", () => { // Act await notificationRepository.addOrUpdateNotification(notificationData1); - await shuttleRepository.addOrUpdateEtaFromExternalSource(eta); + await etaRepository.addOrUpdateEtaFromExternalSource(eta); // Assert // The notification should stay scheduled to be retried once diff --git a/src/resolvers/__tests__/ShuttleResolverTests.test.ts b/src/resolvers/__tests__/ShuttleResolverTests.test.ts index ab45245..ed827a4 100644 --- a/src/resolvers/__tests__/ShuttleResolverTests.test.ts +++ b/src/resolvers/__tests__/ShuttleResolverTests.test.ts @@ -3,8 +3,9 @@ import { generateMockEtas, generateMockRoutes } from "../../../testHelpers/mockD import { IShuttle } from "../../entities/ShuttleRepositoryEntities"; import { setupTestServerContext, setupTestServerHolder } from "../../../testHelpers/apolloTestServerHelpers"; import { addMockShuttleToRepository } from "../../../testHelpers/repositorySetupHelpers"; -import assert = require("node:assert"); import { InterchangeSystem } from "../../entities/InterchangeSystem"; +import { ExternalSourceETARepository } from "../../repositories/shuttle/eta/ExternalSourceETARepository"; +import assert from "node:assert"; describe("ShuttleResolvers", () => { @@ -25,7 +26,7 @@ describe("ShuttleResolvers", () => { const etas = generateMockEtas(); await Promise.all(etas.map(async (eta) => { eta.shuttleId = shuttleId; - await context.systems[0].shuttleRepository.addOrUpdateEtaFromExternalSource(eta); + await (context.systems[0].etaRepository as ExternalSourceETARepository).addOrUpdateEtaFromExternalSource(eta); })); return etas; } @@ -146,9 +147,9 @@ describe("ShuttleResolvers", () => { const e1 = { ...generateMockEtas()[0], shuttleId: mockShuttle.id, stopId: "stA", secondsRemaining: 300 }; const e2 = { ...generateMockEtas()[0], shuttleId: mockShuttle.id, stopId: "stB", secondsRemaining: 30 }; const e3 = { ...generateMockEtas()[0], shuttleId: mockShuttle.id, stopId: "stC", secondsRemaining: 120 }; - await context.systems[0].shuttleRepository.addOrUpdateEtaFromExternalSource(e1); - await context.systems[0].shuttleRepository.addOrUpdateEtaFromExternalSource(e2); - await context.systems[0].shuttleRepository.addOrUpdateEtaFromExternalSource(e3); + await (context.systems[0].etaRepository as ExternalSourceETARepository).addOrUpdateEtaFromExternalSource(e1); + await (context.systems[0].etaRepository as ExternalSourceETARepository).addOrUpdateEtaFromExternalSource(e2); + await (context.systems[0].etaRepository as ExternalSourceETARepository).addOrUpdateEtaFromExternalSource(e3); const response = await holder.testServer.executeOperation({ query, diff --git a/src/resolvers/__tests__/StopResolverTests.test.ts b/src/resolvers/__tests__/StopResolverTests.test.ts index d214dfb..615a9c1 100644 --- a/src/resolvers/__tests__/StopResolverTests.test.ts +++ b/src/resolvers/__tests__/StopResolverTests.test.ts @@ -6,7 +6,8 @@ import { import { generateMockEtas, generateMockOrderedStops } from "../../../testHelpers/mockDataGenerators"; import { IStop } from "../../entities/ShuttleRepositoryEntities"; import { addMockStopToRepository } from "../../../testHelpers/repositorySetupHelpers"; -import assert = require("node:assert"); +import { ExternalSourceETARepository } from "../../repositories/shuttle/eta/ExternalSourceETARepository"; +import assert from "node:assert"; describe("StopResolvers", () => { const holder = setupTestServerHolder(); @@ -106,7 +107,7 @@ describe("StopResolvers", () => { mockEtas = mockEtas.filter((eta) => eta.stopId === mockEtas[0].stopId); await Promise.all(mockEtas.map(async eta => { eta.stopId = mockStop.id; - await context.systems[0].shuttleRepository.addOrUpdateEtaFromExternalSource(eta); + await (context.systems[0].etaRepository as ExternalSourceETARepository).addOrUpdateEtaFromExternalSource(eta); })); const response = await getResponseForQuery(query); @@ -128,9 +129,9 @@ describe("StopResolvers", () => { const e1 = { ...generateMockEtas()[0], stopId: mockStop.id, shuttleId: "shA", secondsRemaining: 240 }; const e2 = { ...generateMockEtas()[0], stopId: mockStop.id, shuttleId: "shB", secondsRemaining: 60 }; const e3 = { ...generateMockEtas()[0], stopId: mockStop.id, shuttleId: "shC", secondsRemaining: 120 }; - await context.systems[0].shuttleRepository.addOrUpdateEtaFromExternalSource(e1); - await context.systems[0].shuttleRepository.addOrUpdateEtaFromExternalSource(e2); - await context.systems[0].shuttleRepository.addOrUpdateEtaFromExternalSource(e3); + await (context.systems[0].etaRepository as ExternalSourceETARepository).addOrUpdateEtaFromExternalSource(e1); + await (context.systems[0].etaRepository as ExternalSourceETARepository).addOrUpdateEtaFromExternalSource(e2); + await (context.systems[0].etaRepository as ExternalSourceETARepository).addOrUpdateEtaFromExternalSource(e3); const response = await getResponseForQuery(query); From 5b1c5897fc878aecee352cedff80d7a29d83b07d Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Tue, 11 Nov 2025 16:53:27 -0800 Subject: [PATCH 58/96] Re-apply event emitter tests for the new shuttle events --- .../ShuttleRepositorySharedTests.test.ts | 149 ++++++++++++++++++ 1 file changed, 149 insertions(+) diff --git a/src/repositories/shuttle/__tests__/ShuttleRepositorySharedTests.test.ts b/src/repositories/shuttle/__tests__/ShuttleRepositorySharedTests.test.ts index cb606c9..cbf9555 100644 --- a/src/repositories/shuttle/__tests__/ShuttleRepositorySharedTests.test.ts +++ b/src/repositories/shuttle/__tests__/ShuttleRepositorySharedTests.test.ts @@ -11,6 +11,7 @@ import { } from "../../../../testHelpers/mockDataGenerators"; import { RepositoryHolder } from "../../../../testHelpers/RepositoryHolder"; import { setupRouteAndOrderedStopsForShuttleRepository } from "../../../../testHelpers/setupRouteAndOrderedStopsForShuttleRepository"; +import { ShuttleRepositoryEvent } from "../ShuttleGetterRepository"; class UnoptimizedInMemoryShuttleRepositoryHolder implements RepositoryHolder { name = 'UnoptimizedInMemoryShuttleRepository'; @@ -647,4 +648,152 @@ describe.each(repositoryImplementations)('$name', (holder) => { }); }); + describe("SHUTTLE_UPDATED event", () => { + test("emits SHUTTLE_UPDATED event when shuttles are added or updated", async () => { + const mockListener = jest.fn(); + repository.on(ShuttleRepositoryEvent.SHUTTLE_UPDATED, mockListener); + + const mockShuttles = generateMockShuttles(); + for (const shuttle of mockShuttles) { + await repository.addOrUpdateShuttle(shuttle); + } + + expect(mockListener).toHaveBeenCalledTimes(mockShuttles.length); + expect(mockListener).toHaveBeenCalledWith(mockShuttles[0]); + expect(mockListener).toHaveBeenCalledWith(mockShuttles[mockShuttles.length - 1]); + }); + + test("does not notify listener after it has been removed", async () => { + const mockListener = jest.fn(); + repository.on(ShuttleRepositoryEvent.SHUTTLE_UPDATED, mockListener); + + const mockShuttles = generateMockShuttles(); + + repository.off(ShuttleRepositoryEvent.SHUTTLE_UPDATED, mockListener); + await repository.addOrUpdateShuttle(mockShuttles[0]); + expect(mockListener).toHaveBeenCalledTimes(0); + }); + + test("stops notifying specific listener after removal but continues for others", async () => { + const mockListener1 = jest.fn(); + const mockListener2 = jest.fn(); + repository.on(ShuttleRepositoryEvent.SHUTTLE_UPDATED, mockListener1); + repository.on(ShuttleRepositoryEvent.SHUTTLE_UPDATED, mockListener2); + + const mockShuttles = generateMockShuttles(); + await repository.addOrUpdateShuttle(mockShuttles[0]); + + repository.off(ShuttleRepositoryEvent.SHUTTLE_UPDATED, mockListener1); + + await repository.addOrUpdateShuttle(mockShuttles[mockShuttles.length - 1]); + + expect(mockListener1).toHaveBeenCalledTimes(1); + expect(mockListener1).toHaveBeenCalledWith(mockShuttles[0]); + expect(mockListener1).not.toHaveBeenCalledWith(mockShuttles[mockShuttles.length - 1]); + + expect(mockListener2).toHaveBeenCalledTimes(2); + expect(mockListener2).toHaveBeenCalledWith(mockShuttles[0]); + expect(mockListener2).toHaveBeenCalledWith(mockShuttles[mockShuttles.length - 1]); + }); + }); + + describe("SHUTTLE_REMOVED event", () => { + test("emits SHUTTLE_REMOVED event when a shuttle is removed", async () => { + const mockShuttles = generateMockShuttles(); + const shuttleToRemove = mockShuttles[0]; + const listener = jest.fn(); + repository.on(ShuttleRepositoryEvent.SHUTTLE_REMOVED, listener); + + await repository.addOrUpdateShuttle(shuttleToRemove); + await repository.removeShuttleIfExists(shuttleToRemove.id); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith(shuttleToRemove); + }); + }); + + describe("SHUTTLE_ARRIVED_AT_STOP event", () => { + test("emits SHUTTLE_ARRIVED_AT_STOP event when shuttle arrives at a stop", async () => { + const { route, systemId, stop1 } = await setupRouteAndOrderedStops(); + + const listener = jest.fn(); + repository.on(ShuttleRepositoryEvent.SHUTTLE_ARRIVED_AT_STOP, listener); + + const shuttle = { + id: "sh1", + name: "Shuttle 1", + routeId: route.id, + systemId: systemId, + coordinates: stop1.coordinates, + orientationInDegrees: 0, + updatedTime: new Date(), + }; + + const arrivalTime = new Date("2024-01-15T10:30:00Z"); + await repository.addOrUpdateShuttle(shuttle, arrivalTime.getTime()); + + expect(listener).toHaveBeenCalledTimes(1); + const emittedPayload = listener.mock.calls[0][0] as any; + expect(emittedPayload.shuttleId).toBe(shuttle.id); + expect(emittedPayload.stopId).toBe(stop1.id); + expect(emittedPayload.timestamp.getTime()).toBe(arrivalTime); + }); + + test("does not emit event when shuttle is not at a stop", async () => { + const { route, systemId } = await setupRouteAndOrderedStops(); + + const listener = jest.fn(); + repository.on(ShuttleRepositoryEvent.SHUTTLE_ARRIVED_AT_STOP, listener); + + const shuttle = { + id: "sh1", + name: "Shuttle 1", + routeId: route.id, + systemId: systemId, + coordinates: { latitude: 12.5, longitude: 22.5 }, // Not at any stop + orientationInDegrees: 0, + updatedTime: new Date(), + }; + + await repository.addOrUpdateShuttle(shuttle); + + expect(listener).toHaveBeenCalledTimes(0); + }); + + test("emits multiple events as shuttle visits multiple stops", async () => { + const { route, systemId, stop1, stop2 } = await setupRouteAndOrderedStops(); + + const listener = jest.fn(); + repository.on(ShuttleRepositoryEvent.SHUTTLE_ARRIVED_AT_STOP, listener); + + const shuttle = { + id: "sh1", + name: "Shuttle 1", + routeId: route.id, + systemId: systemId, + coordinates: stop1.coordinates, + orientationInDegrees: 0, + updatedTime: new Date(), + }; + + const firstArrivalTime = new Date("2024-01-15T10:30:00Z"); + await repository.addOrUpdateShuttle(shuttle, firstArrivalTime.getTime()); + + shuttle.coordinates = stop2.coordinates; + const secondArrivalTime = new Date("2024-01-15T10:35:00Z"); + await repository.addOrUpdateShuttle(shuttle, secondArrivalTime.getTime()); + + expect(listener).toHaveBeenCalledTimes(2); + + const firstPayload = listener.mock.calls[0][0] as any; + expect(firstPayload.shuttleId).toBe(shuttle.id); + expect(firstPayload.stopId).toBe(stop1.id); + expect(firstPayload.timestamp.getTime()).toBe(firstArrivalTime.getTime()); + + const secondPayload = listener.mock.calls[1][0] as any; + expect(secondPayload.shuttleId).toBe(shuttle.id); + expect(secondPayload.stopId).toBe(stop2.id); + expect(secondPayload.timestamp.getTime()).toBe(secondArrivalTime.getTime()); + }); + }); }); From 992a2b6149035f9d25e8d9e62f639babcadeb125 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Tue, 11 Nov 2025 18:34:17 -0800 Subject: [PATCH 59/96] Implement the events --- src/repositories/shuttle/RedisShuttleRepository.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/repositories/shuttle/RedisShuttleRepository.ts b/src/repositories/shuttle/RedisShuttleRepository.ts index ab7a986..190ed2d 100644 --- a/src/repositories/shuttle/RedisShuttleRepository.ts +++ b/src/repositories/shuttle/RedisShuttleRepository.ts @@ -1,6 +1,7 @@ import { ShuttleGetterSetterRepository } from "./ShuttleGetterSetterRepository"; import { IEta, IOrderedStop, IRoute, IShuttle, IStop, shuttleHasArrivedAtStop } from "../../entities/ShuttleRepositoryEntities"; import { + ShuttleRepositoryEvent, ShuttleRepositoryEventListener, ShuttleRepositoryEventName, ShuttleRepositoryEventPayloads, @@ -383,6 +384,8 @@ export class RedisShuttleRepository extends BaseRedisRepository implements Shutt const key = this.createShuttleKey(shuttle.id); await this.redisClient.hSet(key, this.createRedisHashFromShuttle(shuttle)); + this.emit(ShuttleRepositoryEvent.SHUTTLE_UPDATED, shuttle); + await this.updateLastStopArrival(shuttle, travelTimeTimestamp); } @@ -393,12 +396,13 @@ export class RedisShuttleRepository extends BaseRedisRepository implements Shutt const arrivedStop = await this.getArrivedStopIfExists(shuttle); if (arrivedStop != undefined) { - // TODO: Implement the event - await this.updateShuttleLastStopArrival({ + const shuttleArrival = { stopId: arrivedStop.id, timestamp: new Date(travelTimeTimestamp), shuttleId: shuttle.id, - }) + }; + await this.updateShuttleLastStopArrival(shuttleArrival); + this.emit(ShuttleRepositoryEvent.SHUTTLE_ARRIVED_AT_STOP, shuttleArrival); } } @@ -498,6 +502,7 @@ export class RedisShuttleRepository extends BaseRedisRepository implements Shutt if (shuttle) { const key = this.createShuttleKey(shuttleId); await this.redisClient.del(key); + this.emit(ShuttleRepositoryEvent.SHUTTLE_REMOVED, shuttle); return shuttle; } return null; From a3bfa4894e3adbc7656df192a1273c67a47b6a73 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Tue, 11 Nov 2025 18:35:13 -0800 Subject: [PATCH 60/96] Fix date type mismatch for data comparison --- .../shuttle/__tests__/ShuttleRepositorySharedTests.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/repositories/shuttle/__tests__/ShuttleRepositorySharedTests.test.ts b/src/repositories/shuttle/__tests__/ShuttleRepositorySharedTests.test.ts index cbf9555..092cb7d 100644 --- a/src/repositories/shuttle/__tests__/ShuttleRepositorySharedTests.test.ts +++ b/src/repositories/shuttle/__tests__/ShuttleRepositorySharedTests.test.ts @@ -736,7 +736,7 @@ describe.each(repositoryImplementations)('$name', (holder) => { const emittedPayload = listener.mock.calls[0][0] as any; expect(emittedPayload.shuttleId).toBe(shuttle.id); expect(emittedPayload.stopId).toBe(stop1.id); - expect(emittedPayload.timestamp.getTime()).toBe(arrivalTime); + expect(emittedPayload.timestamp.getTime()).toBe(arrivalTime.getTime()); }); test("does not emit event when shuttle is not at a stop", async () => { From 7497c1d004deb1215dc84773ecc4d525f8ab4a03 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Tue, 11 Nov 2025 18:39:24 -0800 Subject: [PATCH 61/96] Apply the same events to UnoptimizedInMemoryShuttleRepository --- .../UnoptimizedInMemoryShuttleRepository.ts | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/repositories/shuttle/UnoptimizedInMemoryShuttleRepository.ts b/src/repositories/shuttle/UnoptimizedInMemoryShuttleRepository.ts index 0ebbaa8..54af40b 100644 --- a/src/repositories/shuttle/UnoptimizedInMemoryShuttleRepository.ts +++ b/src/repositories/shuttle/UnoptimizedInMemoryShuttleRepository.ts @@ -3,6 +3,7 @@ import { ShuttleGetterSetterRepository } from "./ShuttleGetterSetterRepository"; import { IOrderedStop, IRoute, IShuttle, IStop, shuttleHasArrivedAtStop } from "../../entities/ShuttleRepositoryEntities"; import { IEntityWithId } from "../../entities/SharedEntities"; import { + ShuttleRepositoryEvent, ShuttleRepositoryEventListener, ShuttleRepositoryEventName, ShuttleRepositoryEventPayloads, @@ -138,7 +139,6 @@ export class UnoptimizedInMemoryShuttleRepository 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) { @@ -147,6 +147,8 @@ export class UnoptimizedInMemoryShuttleRepository this.shuttles.push(shuttle); } + this.emit(ShuttleRepositoryEvent.SHUTTLE_UPDATED, shuttle); + await this.updateLastStopArrival(shuttle, travelTimeTimestamp); } @@ -175,17 +177,19 @@ export class UnoptimizedInMemoryShuttleRepository const arrivedStop = await this.getArrivedStopIfExists(shuttle); if (arrivedStop != undefined) { - await this.updateShuttleLastStopArrival(shuttle.id, { + const shuttleArrival = { stopId: arrivedStop.id, timestamp: new Date(travelTimeTimestamp), shuttleId: shuttle.id, - }); + }; + await this.updateShuttleLastStopArrival(shuttleArrival); + this.emit(ShuttleRepositoryEvent.SHUTTLE_ARRIVED_AT_STOP, shuttleArrival); } } - private async updateShuttleLastStopArrival(shuttleId: string, lastStopArrival: ShuttleStopArrival) { - this.shuttleLastStopArrivals.set(shuttleId, lastStopArrival); + private async updateShuttleLastStopArrival(lastStopArrival: ShuttleStopArrival) { + this.shuttleLastStopArrivals.set(lastStopArrival.shuttleId, lastStopArrival); } public async getAverageTravelTimeSeconds( @@ -253,7 +257,11 @@ export class UnoptimizedInMemoryShuttleRepository } public async removeShuttleIfExists(shuttleId: string): Promise { - return await this.removeEntityByIdIfExists(shuttleId, this.shuttles); + const shuttle = await this.removeEntityByIdIfExists(shuttleId, this.shuttles); + if (shuttle != null) { + this.emit(ShuttleRepositoryEvent.SHUTTLE_REMOVED, shuttle); + } + return shuttle; } public async removeStopIfExists(stopId: string): Promise { From 6cf61d1cdbe089f0240ae72e69163c0ff7f60cca Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Tue, 11 Nov 2025 18:47:13 -0800 Subject: [PATCH 62/96] Unify eventual getter implementations in a BaseRedisETARepository class --- .../shuttle/eta/BaseRedisETARepository.ts | 62 +++++++++++++++++++ .../eta/RedisExternalSourceETARepository.ts | 33 +--------- .../eta/RedisSelfUpdatingETARepository.ts | 35 ++--------- 3 files changed, 69 insertions(+), 61 deletions(-) create mode 100644 src/repositories/shuttle/eta/BaseRedisETARepository.ts diff --git a/src/repositories/shuttle/eta/BaseRedisETARepository.ts b/src/repositories/shuttle/eta/BaseRedisETARepository.ts new file mode 100644 index 0000000..13da39e --- /dev/null +++ b/src/repositories/shuttle/eta/BaseRedisETARepository.ts @@ -0,0 +1,62 @@ +import { IEta } from "../../../entities/ShuttleRepositoryEntities"; +import { BaseRedisRepository } from "../../BaseRedisRepository"; +import { ETAGetterRepository, ETARepositoryEventListener, ETARepositoryEventName } from "./ETAGetterRepository"; + +export abstract class BaseRedisETARepository extends BaseRedisRepository implements ETAGetterRepository { + getEtasForShuttleId(shuttleId: string): Promise { + throw new Error("Method not implemented."); + } + + getEtasForStopId(stopId: string): Promise { + throw new Error("Method not implemented."); + } + + getEtaForShuttleAndStopId(shuttleId: string, stopId: string): Promise { + throw new Error("Method not implemented."); + } + + // EventEmitter override methods for type safety + override on( + event: T, + listener: ETARepositoryEventListener, + ): this; + override on(event: string | symbol, listener: (...args: any[]) => void): this { + return super.on(event, listener); + } + + override once( + event: T, + listener: ETARepositoryEventListener, + ): this; + override once(event: string | symbol, listener: (...args: any[]) => void): this { + return super.once(event, listener); + } + + override off( + event: T, + listener: ETARepositoryEventListener, + ): this; + override off(event: string | symbol, listener: (...args: any[]) => void): this { + return super.off(event, listener); + } + + override addListener( + event: T, + listener: ETARepositoryEventListener, + ): this; + override addListener(event: string | symbol, listener: (...args: any[]) => void): this { + return super.addListener(event, listener); + } + + override removeListener( + event: T, + listener: ETARepositoryEventListener, + ): this; + override removeListener(event: string | symbol, listener: (...args: any[]) => void): this { + return super.removeListener(event, listener); + } + + override removeAllListeners(eventName?: string | symbol): this { + return super.removeAllListeners(eventName); + } +} diff --git a/src/repositories/shuttle/eta/RedisExternalSourceETARepository.ts b/src/repositories/shuttle/eta/RedisExternalSourceETARepository.ts index 033dc2e..3dac564 100644 --- a/src/repositories/shuttle/eta/RedisExternalSourceETARepository.ts +++ b/src/repositories/shuttle/eta/RedisExternalSourceETARepository.ts @@ -1,40 +1,13 @@ import { IEta } from "../../../entities/ShuttleRepositoryEntities"; -import { BaseRedisRepository } from "../../BaseRedisRepository"; -import { ETARepositoryEventName, ETARepositoryEventListener } from "./ETAGetterRepository"; +import { BaseRedisETARepository } from "./BaseRedisETARepository"; import { ExternalSourceETARepository } from "./ExternalSourceETARepository"; -export class RedisExternalSourceETARepository extends BaseRedisRepository implements ExternalSourceETARepository { +export class RedisExternalSourceETARepository extends BaseRedisETARepository implements ExternalSourceETARepository { addOrUpdateEtaFromExternalSource(eta: IEta): Promise { throw new Error("Method not implemented."); } + removeEtaIfExists(shuttleId: string, stopId: string): Promise { throw new Error("Method not implemented."); } - on(event: T, listener: ETARepositoryEventListener): this { - throw new Error("Method not implemented."); - } - once(event: T, listener: ETARepositoryEventListener): this { - throw new Error("Method not implemented."); - } - off(event: T, listener: ETARepositoryEventListener): this { - throw new Error("Method not implemented."); - } - addListener(event: T, listener: ETARepositoryEventListener): this { - throw new Error("Method not implemented."); - } - removeListener(event: T, listener: ETARepositoryEventListener): this { - throw new Error("Method not implemented."); - } - getEtasForShuttleId(shuttleId: string): Promise { - throw new Error("Method not implemented."); - } - getEtasForStopId(stopId: string): Promise { - throw new Error("Method not implemented."); - } - getEtaForShuttleAndStopId(shuttleId: string, stopId: string): Promise { - throw new Error("Method not implemented."); - } - removeAllListeners(eventName?: string | symbol | undefined): this { - throw new Error("Method not implemented."); - } } diff --git a/src/repositories/shuttle/eta/RedisSelfUpdatingETARepository.ts b/src/repositories/shuttle/eta/RedisSelfUpdatingETARepository.ts index d9aac9f..3a84d7d 100644 --- a/src/repositories/shuttle/eta/RedisSelfUpdatingETARepository.ts +++ b/src/repositories/shuttle/eta/RedisSelfUpdatingETARepository.ts @@ -1,12 +1,10 @@ -import { IEta } from "../../../entities/ShuttleRepositoryEntities"; -import { ETARepositoryEventName, ETARepositoryEventListener } from "./ETAGetterRepository"; import { SelfUpdatingETARepository } from "./SelfUpdatingETARepository"; -import { BaseRedisRepository } from "../../BaseRedisRepository"; +import { BaseRedisETARepository } from "./BaseRedisETARepository"; import { createClient, RedisClientType } from "redis"; import { ShuttleGetterRepository, ShuttleTravelTimeDataIdentifier, ShuttleTravelTimeDateFilterArguments } from "../ShuttleGetterRepository"; import { REDIS_RECONNECT_INTERVAL } from "../../../environment"; -export class RedisSelfUpdatingETARepository extends BaseRedisRepository implements SelfUpdatingETARepository { +export class RedisSelfUpdatingETARepository extends BaseRedisETARepository implements SelfUpdatingETARepository { constructor( readonly shuttleRepository: ShuttleGetterRepository, redisClient: RedisClientType = createClient({ @@ -20,37 +18,12 @@ export class RedisSelfUpdatingETARepository extends BaseRedisRepository implemen ) { super(redisClient); } + getAverageTravelTimeSeconds(identifier: ShuttleTravelTimeDataIdentifier, dateFilter: ShuttleTravelTimeDateFilterArguments): Promise { throw new Error("Method not implemented."); } + startListeningForUpdates(): void { throw new Error("Method not implemented."); } - on(event: T, listener: ETARepositoryEventListener): this { - throw new Error("Method not implemented."); - } - once(event: T, listener: ETARepositoryEventListener): this { - throw new Error("Method not implemented."); - } - off(event: T, listener: ETARepositoryEventListener): this { - throw new Error("Method not implemented."); - } - addListener(event: T, listener: ETARepositoryEventListener): this { - throw new Error("Method not implemented."); - } - removeListener(event: T, listener: ETARepositoryEventListener): this { - throw new Error("Method not implemented."); - } - getEtasForShuttleId(shuttleId: string): Promise { - throw new Error("Method not implemented."); - } - getEtasForStopId(stopId: string): Promise { - throw new Error("Method not implemented."); - } - getEtaForShuttleAndStopId(shuttleId: string, stopId: string): Promise { - throw new Error("Method not implemented."); - } - removeAllListeners(eventName?: string | symbol | undefined): this { - throw new Error("Method not implemented."); - } } From bc7ba7d5076f7ac36cbb8e99c1dedf90df1cb228 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Tue, 11 Nov 2025 18:56:46 -0800 Subject: [PATCH 63/96] Subscribe to shuttle events and add stub methods --- .../eta/RedisSelfUpdatingETARepository.ts | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/src/repositories/shuttle/eta/RedisSelfUpdatingETARepository.ts b/src/repositories/shuttle/eta/RedisSelfUpdatingETARepository.ts index 3a84d7d..3da5fbd 100644 --- a/src/repositories/shuttle/eta/RedisSelfUpdatingETARepository.ts +++ b/src/repositories/shuttle/eta/RedisSelfUpdatingETARepository.ts @@ -1,8 +1,9 @@ import { SelfUpdatingETARepository } from "./SelfUpdatingETARepository"; import { BaseRedisETARepository } from "./BaseRedisETARepository"; import { createClient, RedisClientType } from "redis"; -import { ShuttleGetterRepository, ShuttleTravelTimeDataIdentifier, ShuttleTravelTimeDateFilterArguments } from "../ShuttleGetterRepository"; +import { ShuttleGetterRepository, ShuttleRepositoryEvent, ShuttleStopArrival, ShuttleTravelTimeDataIdentifier, ShuttleTravelTimeDateFilterArguments } from "../ShuttleGetterRepository"; import { REDIS_RECONNECT_INTERVAL } from "../../../environment"; +import { IShuttle } from "../../../entities/ShuttleRepositoryEntities"; export class RedisSelfUpdatingETARepository extends BaseRedisETARepository implements SelfUpdatingETARepository { constructor( @@ -23,7 +24,21 @@ export class RedisSelfUpdatingETARepository extends BaseRedisETARepository imple throw new Error("Method not implemented."); } - startListeningForUpdates(): void { - throw new Error("Method not implemented."); + startListeningForUpdates() { + this.shuttleRepository.addListener(ShuttleRepositoryEvent.SHUTTLE_UPDATED, this.handleShuttleUpdate); + this.shuttleRepository.addListener(ShuttleRepositoryEvent.SHUTTLE_ARRIVED_AT_STOP, this.handleShuttleArriveAtStop) + } + + stopListeningForUpdates() { + this.shuttleRepository.removeListener(ShuttleRepositoryEvent.SHUTTLE_UPDATED, this.handleShuttleUpdate); + this.shuttleRepository.removeListener(ShuttleRepositoryEvent.SHUTTLE_ARRIVED_AT_STOP, this.handleShuttleArriveAtStop); + } + + private handleShuttleUpdate(shuttle: IShuttle) { + // TODO: handle shuttle update + } + + private handleShuttleArriveAtStop(shuttleArrival: ShuttleStopArrival) { + // TODO: handle shuttle arrive at stop } } From 04e8354e05d6579b1db96059f0c63072b2cc4588 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Tue, 11 Nov 2025 19:03:16 -0800 Subject: [PATCH 64/96] Implement BaseInMemoryETARepository based on previous implementation in shuttle repository --- .../shuttle/eta/BaseInMemoryETARepository.ts | 65 +++++++++++++++++++ .../InMemoryExternalSourceETARepository.ts | 33 +--------- .../eta/InMemorySelfUpdatingETARepository.ts | 34 +--------- 3 files changed, 71 insertions(+), 61 deletions(-) create mode 100644 src/repositories/shuttle/eta/BaseInMemoryETARepository.ts diff --git a/src/repositories/shuttle/eta/BaseInMemoryETARepository.ts b/src/repositories/shuttle/eta/BaseInMemoryETARepository.ts new file mode 100644 index 0000000..1a2b7cf --- /dev/null +++ b/src/repositories/shuttle/eta/BaseInMemoryETARepository.ts @@ -0,0 +1,65 @@ +import { IEta } from "../../../entities/ShuttleRepositoryEntities"; +import { ETAGetterRepository, ETARepositoryEventListener, ETARepositoryEventName } from "./ETAGetterRepository"; +import EventEmitter from "node:events"; + +export abstract class BaseInMemoryETARepository extends EventEmitter implements ETAGetterRepository { + protected etas: IEta[] = []; + + async getEtasForShuttleId(shuttleId: string): Promise { + return this.etas.filter(eta => eta.shuttleId === shuttleId); + } + + async getEtasForStopId(stopId: string): Promise { + return this.etas.filter(eta => eta.stopId === stopId); + } + + async getEtaForShuttleAndStopId(shuttleId: string, stopId: string): Promise { + const eta = this.etas.find(eta => eta.stopId === stopId && eta.shuttleId === shuttleId); + return eta ?? null; + } + + // EventEmitter overrides for type safety + override on( + event: T, + listener: ETARepositoryEventListener, + ): this; + override on(event: string | symbol, listener: (...args: any[]) => void): this { + return super.on(event, listener); + } + + override once( + event: T, + listener: ETARepositoryEventListener, + ): this; + override once(event: string | symbol, listener: (...args: any[]) => void): this { + return super.once(event, listener); + } + + override off( + event: T, + listener: ETARepositoryEventListener, + ): this; + override off(event: string | symbol, listener: (...args: any[]) => void): this { + return super.off(event, listener); + } + + override addListener( + event: T, + listener: ETARepositoryEventListener, + ): this; + override addListener(event: string | symbol, listener: (...args: any[]) => void): this { + return super.addListener(event, listener); + } + + override removeListener( + event: T, + listener: ETARepositoryEventListener, + ): this; + override removeListener(event: string | symbol, listener: (...args: any[]) => void): this { + return super.removeListener(event, listener); + } + + override removeAllListeners(eventName?: string | symbol): this { + return super.removeAllListeners(eventName); + } +} diff --git a/src/repositories/shuttle/eta/InMemoryExternalSourceETARepository.ts b/src/repositories/shuttle/eta/InMemoryExternalSourceETARepository.ts index 9e6afa4..b769c12 100644 --- a/src/repositories/shuttle/eta/InMemoryExternalSourceETARepository.ts +++ b/src/repositories/shuttle/eta/InMemoryExternalSourceETARepository.ts @@ -1,40 +1,13 @@ -import { EventEmitter } from "stream"; import { IEta } from "../../../entities/ShuttleRepositoryEntities"; -import { ETARepositoryEventName, ETARepositoryEventListener } from "./ETAGetterRepository"; import { ExternalSourceETARepository } from "./ExternalSourceETARepository"; +import { BaseInMemoryETARepository } from "./BaseInMemoryETARepository"; -export class InMemoryExternalSourceETARepository extends EventEmitter implements ExternalSourceETARepository { +export class InMemoryExternalSourceETARepository extends BaseInMemoryETARepository implements ExternalSourceETARepository { addOrUpdateEtaFromExternalSource(eta: IEta): Promise { throw new Error("Method not implemented."); } + removeEtaIfExists(shuttleId: string, stopId: string): Promise { throw new Error("Method not implemented."); } - on(event: T, listener: ETARepositoryEventListener): this { - throw new Error("Method not implemented."); - } - once(event: T, listener: ETARepositoryEventListener): this { - throw new Error("Method not implemented."); - } - off(event: T, listener: ETARepositoryEventListener): this { - throw new Error("Method not implemented."); - } - addListener(event: T, listener: ETARepositoryEventListener): this { - throw new Error("Method not implemented."); - } - removeListener(event: T, listener: ETARepositoryEventListener): this { - throw new Error("Method not implemented."); - } - getEtasForShuttleId(shuttleId: string): Promise { - throw new Error("Method not implemented."); - } - getEtasForStopId(stopId: string): Promise { - throw new Error("Method not implemented."); - } - getEtaForShuttleAndStopId(shuttleId: string, stopId: string): Promise { - throw new Error("Method not implemented."); - } - removeAllListeners(eventName?: string | symbol | undefined): this { - throw new Error("Method not implemented."); - } } diff --git a/src/repositories/shuttle/eta/InMemorySelfUpdatingETARepository.ts b/src/repositories/shuttle/eta/InMemorySelfUpdatingETARepository.ts index ce0160b..de42361 100644 --- a/src/repositories/shuttle/eta/InMemorySelfUpdatingETARepository.ts +++ b/src/repositories/shuttle/eta/InMemorySelfUpdatingETARepository.ts @@ -1,10 +1,8 @@ -import { IEta } from "../../../entities/ShuttleRepositoryEntities"; -import { ETARepositoryEventName, ETARepositoryEventListener } from "./ETAGetterRepository"; import { SelfUpdatingETARepository } from "./SelfUpdatingETARepository"; import { ShuttleGetterRepository, ShuttleTravelTimeDataIdentifier, ShuttleTravelTimeDateFilterArguments } from "../ShuttleGetterRepository"; -import { EventEmitter } from "stream"; +import { BaseInMemoryETARepository } from "./BaseInMemoryETARepository"; -export class InMemorySelfUpdatingETARepository extends EventEmitter implements SelfUpdatingETARepository { +export class InMemorySelfUpdatingETARepository extends BaseInMemoryETARepository implements SelfUpdatingETARepository { constructor( readonly shuttleRepository: ShuttleGetterRepository ) { @@ -14,34 +12,8 @@ export class InMemorySelfUpdatingETARepository extends EventEmitter implements S getAverageTravelTimeSeconds(identifier: ShuttleTravelTimeDataIdentifier, dateFilter: ShuttleTravelTimeDateFilterArguments): Promise { throw new Error("Method not implemented."); } + startListeningForUpdates(): void { throw new Error("Method not implemented."); } - on(event: T, listener: ETARepositoryEventListener): this { - throw new Error("Method not implemented."); - } - once(event: T, listener: ETARepositoryEventListener): this { - throw new Error("Method not implemented."); - } - off(event: T, listener: ETARepositoryEventListener): this { - throw new Error("Method not implemented."); - } - addListener(event: T, listener: ETARepositoryEventListener): this { - throw new Error("Method not implemented."); - } - removeListener(event: T, listener: ETARepositoryEventListener): this { - throw new Error("Method not implemented."); - } - getEtasForShuttleId(shuttleId: string): Promise { - throw new Error("Method not implemented."); - } - getEtasForStopId(stopId: string): Promise { - throw new Error("Method not implemented."); - } - getEtaForShuttleAndStopId(shuttleId: string, stopId: string): Promise { - throw new Error("Method not implemented."); - } - removeAllListeners(eventName?: string | symbol | undefined): this { - throw new Error("Method not implemented."); - } } From b9fefcc6a9eb30736b0be8d6c190010cf89d0365 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Tue, 11 Nov 2025 19:05:47 -0800 Subject: [PATCH 65/96] Implement BaseRedisETARepository based on past implementation --- .../shuttle/eta/BaseRedisETARepository.ts | 62 +++++++++++++++++-- 1 file changed, 56 insertions(+), 6 deletions(-) diff --git a/src/repositories/shuttle/eta/BaseRedisETARepository.ts b/src/repositories/shuttle/eta/BaseRedisETARepository.ts index 13da39e..7df36f8 100644 --- a/src/repositories/shuttle/eta/BaseRedisETARepository.ts +++ b/src/repositories/shuttle/eta/BaseRedisETARepository.ts @@ -3,16 +3,66 @@ import { BaseRedisRepository } from "../../BaseRedisRepository"; import { ETAGetterRepository, ETARepositoryEventListener, ETARepositoryEventName } from "./ETAGetterRepository"; export abstract class BaseRedisETARepository extends BaseRedisRepository implements ETAGetterRepository { - getEtasForShuttleId(shuttleId: string): Promise { - throw new Error("Method not implemented."); + private static readonly ETA_KEY_PREFIX = 'shuttle:eta:'; + + // Helper methods + private createEtaKey = (shuttleId: string, stopId: string) => + `${BaseRedisETARepository.ETA_KEY_PREFIX}${shuttleId}:${stopId}`; + + createRedisHashFromEta = (eta: IEta): Record => ({ + secondsRemaining: eta.secondsRemaining.toString(), + shuttleId: eta.shuttleId, + stopId: eta.stopId, + systemId: eta.systemId, + updatedTime: eta.updatedTime.toISOString(), + }); + + createEtaFromRedisData = (data: Record): IEta => ({ + secondsRemaining: parseFloat(data.secondsRemaining), + shuttleId: data.shuttleId, + stopId: data.stopId, + systemId: data.systemId, + updatedTime: new Date(data.updatedTime), + }); + + // Getter implementations + async getEtasForShuttleId(shuttleId: string): Promise { + const keys = await this.redisClient.keys(`${BaseRedisETARepository.ETA_KEY_PREFIX}${shuttleId}:*`); + const etas: IEta[] = []; + + for (const key of keys) { + const data = await this.redisClient.hGetAll(key); + if (Object.keys(data).length > 0) { + etas.push(this.createEtaFromRedisData(data)); + } + } + + return etas; } - getEtasForStopId(stopId: string): Promise { - throw new Error("Method not implemented."); + async getEtasForStopId(stopId: string): Promise { + const keys = await this.redisClient.keys(`${BaseRedisETARepository.ETA_KEY_PREFIX}*`); + const etas: IEta[] = []; + + for (const key of keys) { + const data = await this.redisClient.hGetAll(key); + if (Object.keys(data).length > 0 && data.stopId === stopId) { + etas.push(this.createEtaFromRedisData(data)); + } + } + + return etas; } - getEtaForShuttleAndStopId(shuttleId: string, stopId: string): Promise { - throw new Error("Method not implemented."); + async getEtaForShuttleAndStopId(shuttleId: string, stopId: string): Promise { + const key = this.createEtaKey(shuttleId, stopId); + const data = await this.redisClient.hGetAll(key); + + if (Object.keys(data).length === 0) { + return null; + } + + return this.createEtaFromRedisData(data); } // EventEmitter override methods for type safety From 9af50ddfe90d814cc622a57c8063d2fa65a34f73 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Tue, 11 Nov 2025 19:10:13 -0800 Subject: [PATCH 66/96] Change the event name to SHUTTLE_WILL_ARRIVE_AT_STOP, and emit it before the stop is updated --- src/repositories/shuttle/RedisShuttleRepository.ts | 2 +- src/repositories/shuttle/ShuttleGetterRepository.ts | 4 ++-- .../shuttle/UnoptimizedInMemoryShuttleRepository.ts | 2 +- .../__tests__/ShuttleRepositorySharedTests.test.ts | 10 +++++----- .../shuttle/eta/RedisSelfUpdatingETARepository.ts | 5 +++-- 5 files changed, 12 insertions(+), 11 deletions(-) diff --git a/src/repositories/shuttle/RedisShuttleRepository.ts b/src/repositories/shuttle/RedisShuttleRepository.ts index 190ed2d..d343b4d 100644 --- a/src/repositories/shuttle/RedisShuttleRepository.ts +++ b/src/repositories/shuttle/RedisShuttleRepository.ts @@ -401,8 +401,8 @@ export class RedisShuttleRepository extends BaseRedisRepository implements Shutt timestamp: new Date(travelTimeTimestamp), shuttleId: shuttle.id, }; + this.emit(ShuttleRepositoryEvent.SHUTTLE_WILL_ARRIVE_AT_STOP, shuttleArrival); await this.updateShuttleLastStopArrival(shuttleArrival); - this.emit(ShuttleRepositoryEvent.SHUTTLE_ARRIVED_AT_STOP, shuttleArrival); } } diff --git a/src/repositories/shuttle/ShuttleGetterRepository.ts b/src/repositories/shuttle/ShuttleGetterRepository.ts index 44bb872..22118c5 100644 --- a/src/repositories/shuttle/ShuttleGetterRepository.ts +++ b/src/repositories/shuttle/ShuttleGetterRepository.ts @@ -4,7 +4,7 @@ import type EventEmitter from "node:events"; export const ShuttleRepositoryEvent = { SHUTTLE_UPDATED: "shuttleUpdated", SHUTTLE_REMOVED: "shuttleRemoved", - SHUTTLE_ARRIVED_AT_STOP: "shuttleArrivedAtStop", + SHUTTLE_WILL_ARRIVE_AT_STOP: "shuttleArrivedAtStop", } as const; export type ShuttleRepositoryEventName = typeof ShuttleRepositoryEvent[keyof typeof ShuttleRepositoryEvent]; @@ -15,7 +15,7 @@ export type EtaDataClearedEventPayload = IEta[]; export interface ShuttleRepositoryEventPayloads { [ShuttleRepositoryEvent.SHUTTLE_UPDATED]: IShuttle, [ShuttleRepositoryEvent.SHUTTLE_REMOVED]: IShuttle, - [ShuttleRepositoryEvent.SHUTTLE_ARRIVED_AT_STOP]: ShuttleStopArrival, + [ShuttleRepositoryEvent.SHUTTLE_WILL_ARRIVE_AT_STOP]: ShuttleStopArrival, } export type ShuttleRepositoryEventListener = ( diff --git a/src/repositories/shuttle/UnoptimizedInMemoryShuttleRepository.ts b/src/repositories/shuttle/UnoptimizedInMemoryShuttleRepository.ts index 54af40b..068e6f7 100644 --- a/src/repositories/shuttle/UnoptimizedInMemoryShuttleRepository.ts +++ b/src/repositories/shuttle/UnoptimizedInMemoryShuttleRepository.ts @@ -182,8 +182,8 @@ export class UnoptimizedInMemoryShuttleRepository timestamp: new Date(travelTimeTimestamp), shuttleId: shuttle.id, }; + this.emit(ShuttleRepositoryEvent.SHUTTLE_WILL_ARRIVE_AT_STOP, shuttleArrival); await this.updateShuttleLastStopArrival(shuttleArrival); - this.emit(ShuttleRepositoryEvent.SHUTTLE_ARRIVED_AT_STOP, shuttleArrival); } } diff --git a/src/repositories/shuttle/__tests__/ShuttleRepositorySharedTests.test.ts b/src/repositories/shuttle/__tests__/ShuttleRepositorySharedTests.test.ts index 092cb7d..e41971b 100644 --- a/src/repositories/shuttle/__tests__/ShuttleRepositorySharedTests.test.ts +++ b/src/repositories/shuttle/__tests__/ShuttleRepositorySharedTests.test.ts @@ -712,12 +712,12 @@ describe.each(repositoryImplementations)('$name', (holder) => { }); }); - describe("SHUTTLE_ARRIVED_AT_STOP event", () => { - test("emits SHUTTLE_ARRIVED_AT_STOP event when shuttle arrives at a stop", async () => { + describe("SHUTTLE_WILL_ARRIVE_AT_STOP event", () => { + test("emits SHUTTLE_WILL_ARRIVE_AT_STOP event before shuttle arrives at a stop", async () => { const { route, systemId, stop1 } = await setupRouteAndOrderedStops(); const listener = jest.fn(); - repository.on(ShuttleRepositoryEvent.SHUTTLE_ARRIVED_AT_STOP, listener); + repository.on(ShuttleRepositoryEvent.SHUTTLE_WILL_ARRIVE_AT_STOP, listener); const shuttle = { id: "sh1", @@ -743,7 +743,7 @@ describe.each(repositoryImplementations)('$name', (holder) => { const { route, systemId } = await setupRouteAndOrderedStops(); const listener = jest.fn(); - repository.on(ShuttleRepositoryEvent.SHUTTLE_ARRIVED_AT_STOP, listener); + repository.on(ShuttleRepositoryEvent.SHUTTLE_WILL_ARRIVE_AT_STOP, listener); const shuttle = { id: "sh1", @@ -764,7 +764,7 @@ describe.each(repositoryImplementations)('$name', (holder) => { const { route, systemId, stop1, stop2 } = await setupRouteAndOrderedStops(); const listener = jest.fn(); - repository.on(ShuttleRepositoryEvent.SHUTTLE_ARRIVED_AT_STOP, listener); + repository.on(ShuttleRepositoryEvent.SHUTTLE_WILL_ARRIVE_AT_STOP, listener); const shuttle = { id: "sh1", diff --git a/src/repositories/shuttle/eta/RedisSelfUpdatingETARepository.ts b/src/repositories/shuttle/eta/RedisSelfUpdatingETARepository.ts index 3da5fbd..5899cfd 100644 --- a/src/repositories/shuttle/eta/RedisSelfUpdatingETARepository.ts +++ b/src/repositories/shuttle/eta/RedisSelfUpdatingETARepository.ts @@ -26,12 +26,12 @@ export class RedisSelfUpdatingETARepository extends BaseRedisETARepository imple startListeningForUpdates() { this.shuttleRepository.addListener(ShuttleRepositoryEvent.SHUTTLE_UPDATED, this.handleShuttleUpdate); - this.shuttleRepository.addListener(ShuttleRepositoryEvent.SHUTTLE_ARRIVED_AT_STOP, this.handleShuttleArriveAtStop) + this.shuttleRepository.addListener(ShuttleRepositoryEvent.SHUTTLE_WILL_ARRIVE_AT_STOP, this.handleShuttleArriveAtStop) } stopListeningForUpdates() { this.shuttleRepository.removeListener(ShuttleRepositoryEvent.SHUTTLE_UPDATED, this.handleShuttleUpdate); - this.shuttleRepository.removeListener(ShuttleRepositoryEvent.SHUTTLE_ARRIVED_AT_STOP, this.handleShuttleArriveAtStop); + this.shuttleRepository.removeListener(ShuttleRepositoryEvent.SHUTTLE_WILL_ARRIVE_AT_STOP, this.handleShuttleArriveAtStop); } private handleShuttleUpdate(shuttle: IShuttle) { @@ -40,5 +40,6 @@ export class RedisSelfUpdatingETARepository extends BaseRedisETARepository imple private handleShuttleArriveAtStop(shuttleArrival: ShuttleStopArrival) { // TODO: handle shuttle arrive at stop + } } From e8f4e7b9eebf9f4459e4354abd15ef38eb459fca Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Tue, 11 Nov 2025 19:33:52 -0800 Subject: [PATCH 67/96] Add a base protected method for adding ETAs and emitting events --- src/repositories/shuttle/eta/BaseRedisETARepository.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/repositories/shuttle/eta/BaseRedisETARepository.ts b/src/repositories/shuttle/eta/BaseRedisETARepository.ts index 7df36f8..872af13 100644 --- a/src/repositories/shuttle/eta/BaseRedisETARepository.ts +++ b/src/repositories/shuttle/eta/BaseRedisETARepository.ts @@ -1,6 +1,6 @@ import { IEta } from "../../../entities/ShuttleRepositoryEntities"; import { BaseRedisRepository } from "../../BaseRedisRepository"; -import { ETAGetterRepository, ETARepositoryEventListener, ETARepositoryEventName } from "./ETAGetterRepository"; +import { ETAGetterRepository, ETARepositoryEvent, ETARepositoryEventListener, ETARepositoryEventName } from "./ETAGetterRepository"; export abstract class BaseRedisETARepository extends BaseRedisRepository implements ETAGetterRepository { private static readonly ETA_KEY_PREFIX = 'shuttle:eta:'; @@ -65,6 +65,14 @@ export abstract class BaseRedisETARepository extends BaseRedisRepository impleme return this.createEtaFromRedisData(data); } + // Protected setter for internal use + protected async addOrUpdateEta(eta: IEta): Promise { + const key = this.createEtaKey(eta.shuttleId, eta.stopId); + const hash = this.createRedisHashFromEta(eta); + await this.redisClient.hSet(key, hash); + this.emit(ETARepositoryEvent.ETA_UPDATED, eta); + } + // EventEmitter override methods for type safety override on( event: T, From 4b37f3a12100d949cbb4f43552816ba53f359f88 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Tue, 11 Nov 2025 19:34:05 -0800 Subject: [PATCH 68/96] Add a helper method for setting the "reference time" of the self-updating repo --- .../eta/InMemorySelfUpdatingETARepository.ts | 3 ++ .../eta/RedisSelfUpdatingETARepository.ts | 53 +++++++++++++++++-- .../shuttle/eta/SelfUpdatingETARepository.ts | 7 +++ 3 files changed, 58 insertions(+), 5 deletions(-) diff --git a/src/repositories/shuttle/eta/InMemorySelfUpdatingETARepository.ts b/src/repositories/shuttle/eta/InMemorySelfUpdatingETARepository.ts index de42361..a94955d 100644 --- a/src/repositories/shuttle/eta/InMemorySelfUpdatingETARepository.ts +++ b/src/repositories/shuttle/eta/InMemorySelfUpdatingETARepository.ts @@ -8,6 +8,9 @@ export class InMemorySelfUpdatingETARepository extends BaseInMemoryETARepository ) { super(); } + setReferenceTime(referenceTime: Date): void { + throw new Error("Method not implemented."); + } getAverageTravelTimeSeconds(identifier: ShuttleTravelTimeDataIdentifier, dateFilter: ShuttleTravelTimeDateFilterArguments): Promise { throw new Error("Method not implemented."); diff --git a/src/repositories/shuttle/eta/RedisSelfUpdatingETARepository.ts b/src/repositories/shuttle/eta/RedisSelfUpdatingETARepository.ts index 5899cfd..971c21e 100644 --- a/src/repositories/shuttle/eta/RedisSelfUpdatingETARepository.ts +++ b/src/repositories/shuttle/eta/RedisSelfUpdatingETARepository.ts @@ -16,30 +16,73 @@ export class RedisSelfUpdatingETARepository extends BaseRedisETARepository imple reconnectStrategy: REDIS_RECONNECT_INTERVAL, }, }), + private referenceTime: Date | null = null, ) { super(redisClient); } + setReferenceTime(referenceTime: Date) { + this.referenceTime = referenceTime; + } + getAverageTravelTimeSeconds(identifier: ShuttleTravelTimeDataIdentifier, dateFilter: ShuttleTravelTimeDateFilterArguments): Promise { throw new Error("Method not implemented."); } startListeningForUpdates() { this.shuttleRepository.addListener(ShuttleRepositoryEvent.SHUTTLE_UPDATED, this.handleShuttleUpdate); - this.shuttleRepository.addListener(ShuttleRepositoryEvent.SHUTTLE_WILL_ARRIVE_AT_STOP, this.handleShuttleArriveAtStop) + this.shuttleRepository.addListener(ShuttleRepositoryEvent.SHUTTLE_WILL_ARRIVE_AT_STOP, this.handleShuttleWillArriveAtStop) } stopListeningForUpdates() { this.shuttleRepository.removeListener(ShuttleRepositoryEvent.SHUTTLE_UPDATED, this.handleShuttleUpdate); - this.shuttleRepository.removeListener(ShuttleRepositoryEvent.SHUTTLE_WILL_ARRIVE_AT_STOP, this.handleShuttleArriveAtStop); + this.shuttleRepository.removeListener(ShuttleRepositoryEvent.SHUTTLE_WILL_ARRIVE_AT_STOP, this.handleShuttleWillArriveAtStop); } private handleShuttleUpdate(shuttle: IShuttle) { // TODO: handle shuttle update } - private handleShuttleArriveAtStop(shuttleArrival: ShuttleStopArrival) { - // TODO: handle shuttle arrive at stop - + private async handleShuttleWillArriveAtStop( + shuttleArrival: ShuttleStopArrival, + + ) { + const lastStop = await this.shuttleRepository.getShuttleLastStopArrival(shuttleArrival.shuttleId); + if (lastStop == undefined) return; + + const shuttle = await this.shuttleRepository.getShuttleById(shuttleArrival.shuttleId); + if (shuttle == null) return; + + let referenceCurrentTime = new Date(); + if (this.referenceTime != null) { + referenceCurrentTime = this.referenceTime; + } + + const oneWeekAgo = new Date(referenceCurrentTime.getTime() - (60 * 60 * 24 * 7 * 1000)); + + const lastOrderedStop = await this.shuttleRepository.getOrderedStopByRouteAndStopId(shuttle.routeId, lastStop.stopId); + const nextStop = lastOrderedStop?.nextStop; + if (nextStop == null) return; + + const travelTimeSeconds = await this.getAverageTravelTimeSeconds({ + routeId: shuttle.routeId, + fromStopId: lastStop.stopId, + toStopId: nextStop.stopId, + }, { + from: oneWeekAgo, + to: new Date(oneWeekAgo.getTime() + (60 * 60 * 1000)) + }); + if (travelTimeSeconds == undefined) return; + + const elapsedTimeMs = referenceCurrentTime.getTime() - lastStop.timestamp.getTime(); + const predictedTimeSeconds = travelTimeSeconds - (elapsedTimeMs / 1000); + + await this.addOrUpdateEta({ + secondsRemaining: predictedTimeSeconds, + shuttleId: shuttle.id, + stopId: nextStop.stopId, + systemId: nextStop.systemId, + updatedTime: new Date(), + }); } } diff --git a/src/repositories/shuttle/eta/SelfUpdatingETARepository.ts b/src/repositories/shuttle/eta/SelfUpdatingETARepository.ts index 331e1cc..4dae81a 100644 --- a/src/repositories/shuttle/eta/SelfUpdatingETARepository.ts +++ b/src/repositories/shuttle/eta/SelfUpdatingETARepository.ts @@ -18,4 +18,11 @@ export interface SelfUpdatingETARepository extends ETAGetterRepository { identifier: ShuttleTravelTimeDataIdentifier, dateFilter: ShuttleTravelTimeDateFilterArguments ): Promise; + + /** + * Set the "current time" as the class knows it, in order to calculate + * ETAs based on past data. + * @param referenceTime + */ + setReferenceTime(referenceTime: Date): void; } From 5e8a3c84a6140a30da2e68aa74dd2a94e69574df Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Tue, 11 Nov 2025 19:41:19 -0800 Subject: [PATCH 69/96] Add previous reference implementation of getAverageTravelTimeSeconds and method bindings --- .../eta/RedisSelfUpdatingETARepository.ts | 42 ++++++++++++++++++- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/src/repositories/shuttle/eta/RedisSelfUpdatingETARepository.ts b/src/repositories/shuttle/eta/RedisSelfUpdatingETARepository.ts index 971c21e..322c11a 100644 --- a/src/repositories/shuttle/eta/RedisSelfUpdatingETARepository.ts +++ b/src/repositories/shuttle/eta/RedisSelfUpdatingETARepository.ts @@ -19,14 +19,52 @@ export class RedisSelfUpdatingETARepository extends BaseRedisETARepository imple private referenceTime: Date | null = null, ) { super(redisClient); + + this.setReferenceTime = this.setReferenceTime.bind(this); + this.getAverageTravelTimeSeconds = this.getAverageTravelTimeSeconds.bind(this); + this.startListeningForUpdates = this.startListeningForUpdates.bind(this); + this.handleShuttleWillArriveAtStop = this.handleShuttleWillArriveAtStop.bind(this); + this.handleShuttleUpdate = this.handleShuttleUpdate.bind(this); + } + + private createHistoricalEtaTimeSeriesKey = (routeId: string, fromStopId: string, toStopId: string) => { + return `shuttle:eta:historical:${routeId}:${fromStopId}:${toStopId}`; } setReferenceTime(referenceTime: Date) { this.referenceTime = referenceTime; } - getAverageTravelTimeSeconds(identifier: ShuttleTravelTimeDataIdentifier, dateFilter: ShuttleTravelTimeDateFilterArguments): Promise { - throw new Error("Method not implemented."); + public async getAverageTravelTimeSeconds( + { routeId, fromStopId, toStopId }: ShuttleTravelTimeDataIdentifier, + { from, to }: ShuttleTravelTimeDateFilterArguments + ): Promise { + const timeSeriesKey = this.createHistoricalEtaTimeSeriesKey(routeId, fromStopId, toStopId); + const fromTimestamp = from.getTime(); + const toTimestamp = to.getTime(); + const intervalMs = toTimestamp - fromTimestamp + 1; + + try { + const aggregationResult = await this.redisClient.sendCommand([ + 'TS.RANGE', + timeSeriesKey, + fromTimestamp.toString(), + toTimestamp.toString(), + 'AGGREGATION', + 'AVG', + intervalMs.toString() + ]) as [string, string][]; + + if (aggregationResult && aggregationResult.length > 0) { + const [, averageValue] = aggregationResult[0]; + return parseFloat(averageValue); + } + + return; + } catch (error) { + console.warn(`Failed to get average travel time: ${error instanceof Error ? error.message : String(error)}`); + return; + } } startListeningForUpdates() { From 88a393c1adc86b744c0352d982a3a28e719522f3 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Tue, 11 Nov 2025 19:41:42 -0800 Subject: [PATCH 70/96] Update ETA add test to use setReferenceTime --- .../SelfUpdatingETARepositorySharedTests.test.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/repositories/shuttle/eta/__tests__/SelfUpdatingETARepositorySharedTests.test.ts b/src/repositories/shuttle/eta/__tests__/SelfUpdatingETARepositorySharedTests.test.ts index a3a0eb2..8003c41 100644 --- a/src/repositories/shuttle/eta/__tests__/SelfUpdatingETARepositorySharedTests.test.ts +++ b/src/repositories/shuttle/eta/__tests__/SelfUpdatingETARepositorySharedTests.test.ts @@ -91,6 +91,9 @@ describe.each(repositoryImplementations)('$name', (holder) => { test("adds an ETA entry based on historical data", async () => { const { route, systemId, stop1, stop2 } = await setupRouteAndOrderedStops(); + const shuttleSecondArrivalTimeAtFirstStop = new Date(2025, 0, 8, 12, 0, 0); + // "current time" is 7 minutes past arrival, expected eta = 8 minutes + repository.setReferenceTime(new Date(shuttleSecondArrivalTimeAtFirstStop.getTime() + 7 * 60 * 1000)); repository.startListeningForUpdates(); const shuttle = { @@ -103,21 +106,21 @@ describe.each(repositoryImplementations)('$name', (holder) => { updatedTime: new Date(), }; - const firstStopArrivalTime = new Date(2025, 0, 1, 12, 0, 0); - await shuttleRepository.addOrUpdateShuttle(shuttle, firstStopArrivalTime.getTime()); + const firstStopLastWeekArrivalTime = new Date(2025, 0, 1, 12, 0, 0); + await shuttleRepository.addOrUpdateShuttle(shuttle, firstStopLastWeekArrivalTime.getTime()); shuttle.coordinates = stop2.coordinates; - const secondStopArrivalTime = new Date(2025, 0, 1, 12, 15, 0); - await shuttleRepository.addOrUpdateShuttle(shuttle, secondStopArrivalTime.getTime()); + const secondStopLastWeekArrivalTime = new Date(2025, 0, 1, 12, 15, 0); + await shuttleRepository.addOrUpdateShuttle(shuttle, secondStopLastWeekArrivalTime.getTime()); shuttle.coordinates = stop1.coordinates; await shuttleRepository.addOrUpdateShuttle( shuttle, - new Date(2025, 0, 8, 12, 0, 0).getTime(), + shuttleSecondArrivalTimeAtFirstStop.getTime(), ); const eta = await repository.getEtaForShuttleAndStopId(shuttle.id, stop2.id); - expect(eta?.secondsRemaining).toEqual(7 * 60 + 30); + expect(eta?.secondsRemaining).toEqual(8 * 60); }); }); From ac81806b26ba058e4e2f128f5ddb91241af927ef Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Tue, 11 Nov 2025 19:51:31 -0800 Subject: [PATCH 71/96] Swap implementations of two handler methods, and add reference implementation for adding travel time data --- .../eta/RedisSelfUpdatingETARepository.ts | 114 +++++++++++++++--- 1 file changed, 97 insertions(+), 17 deletions(-) diff --git a/src/repositories/shuttle/eta/RedisSelfUpdatingETARepository.ts b/src/repositories/shuttle/eta/RedisSelfUpdatingETARepository.ts index 322c11a..c0384d9 100644 --- a/src/repositories/shuttle/eta/RedisSelfUpdatingETARepository.ts +++ b/src/repositories/shuttle/eta/RedisSelfUpdatingETARepository.ts @@ -77,31 +77,20 @@ export class RedisSelfUpdatingETARepository extends BaseRedisETARepository imple this.shuttleRepository.removeListener(ShuttleRepositoryEvent.SHUTTLE_WILL_ARRIVE_AT_STOP, this.handleShuttleWillArriveAtStop); } - private handleShuttleUpdate(shuttle: IShuttle) { - // TODO: handle shuttle update - } + private async handleShuttleUpdate(shuttle: IShuttle) { + const lastStop = await this.shuttleRepository.getShuttleLastStopArrival(shuttle.id); + if (!lastStop) return; - private async handleShuttleWillArriveAtStop( - shuttleArrival: ShuttleStopArrival, - - ) { - const lastStop = await this.shuttleRepository.getShuttleLastStopArrival(shuttleArrival.shuttleId); - if (lastStop == undefined) return; - - const shuttle = await this.shuttleRepository.getShuttleById(shuttleArrival.shuttleId); - if (shuttle == null) return; + const lastOrderedStop = await this.shuttleRepository.getOrderedStopByRouteAndStopId(shuttle.routeId, lastStop.stopId); + const nextStop = lastOrderedStop?.nextStop; + if (!nextStop) return; let referenceCurrentTime = new Date(); if (this.referenceTime != null) { referenceCurrentTime = this.referenceTime; } - const oneWeekAgo = new Date(referenceCurrentTime.getTime() - (60 * 60 * 24 * 7 * 1000)); - const lastOrderedStop = await this.shuttleRepository.getOrderedStopByRouteAndStopId(shuttle.routeId, lastStop.stopId); - const nextStop = lastOrderedStop?.nextStop; - if (nextStop == null) return; - const travelTimeSeconds = await this.getAverageTravelTimeSeconds({ routeId: shuttle.routeId, fromStopId: lastStop.stopId, @@ -115,6 +104,7 @@ export class RedisSelfUpdatingETARepository extends BaseRedisETARepository imple const elapsedTimeMs = referenceCurrentTime.getTime() - lastStop.timestamp.getTime(); const predictedTimeSeconds = travelTimeSeconds - (elapsedTimeMs / 1000); + // TODO: update multiple ETAs await this.addOrUpdateEta({ secondsRemaining: predictedTimeSeconds, shuttleId: shuttle.id, @@ -123,4 +113,94 @@ export class RedisSelfUpdatingETARepository extends BaseRedisETARepository imple updatedTime: new Date(), }); } + + private async handleShuttleWillArriveAtStop( + shuttleArrival: ShuttleStopArrival, + ) { + const lastStopTimestamp = await this.shuttleRepository.getShuttleLastStopArrival(shuttleArrival.shuttleId); + if (lastStopTimestamp) { + const shuttle = await this.shuttleRepository.getShuttleById(lastStopTimestamp.shuttleId); + if (!shuttle) return; + + const routeId = shuttle.routeId; + const fromStopId = lastStopTimestamp.stopId; + const toStopId = shuttleArrival.stopId; + + const travelTimeSeconds = (shuttleArrival.timestamp.getTime() - lastStopTimestamp.timestamp.getTime()) / 1000; + await this.addTravelTimeDataPoint({ routeId, fromStopId, toStopId, }, travelTimeSeconds, shuttleArrival.timestamp.getTime()); + } + } + + public async addTravelTimeDataPoint( + { routeId, fromStopId, toStopId }: ShuttleTravelTimeDataIdentifier, + travelTimeSeconds: number, + timestamp = Date.now(), + ): Promise { + const historicalEtaTimeSeriesKey = this.createHistoricalEtaTimeSeriesKey(routeId, fromStopId, toStopId); + + try { + await this.redisClient.sendCommand([ + 'TS.ADD', + historicalEtaTimeSeriesKey, + timestamp.toString(), + travelTimeSeconds.toString(), + 'LABELS', + 'routeId', + routeId, + 'fromStopId', + fromStopId, + 'toStopId', + toStopId + ]); + } catch (error) { + await this.createHistoricalEtaTimeSeriesAndAddDataPoint( + historicalEtaTimeSeriesKey, + timestamp, + travelTimeSeconds, + routeId, + fromStopId, + toStopId + ); + } + } + + + private async createHistoricalEtaTimeSeriesAndAddDataPoint( + timeSeriesKey: string, + timestamp: number, + travelTimeSeconds: number, + routeId: string, + fromStopId: string, + toStopId: string, + ): Promise { + try { + await this.redisClient.sendCommand([ + 'TS.CREATE', + timeSeriesKey, + 'RETENTION', + '2678400000', // one month in milliseconds + 'LABELS', + 'routeId', + routeId, + 'fromStopId', + fromStopId, + 'toStopId', + toStopId + ]); + await this.redisClient.sendCommand([ + 'TS.ADD', + timeSeriesKey, + timestamp.toString(), + travelTimeSeconds.toString() + ]); + } catch (createError) { + await this.redisClient.sendCommand([ + 'TS.ADD', + timeSeriesKey, + timestamp.toString(), + travelTimeSeconds.toString() + ]); + } + } + } From 7c5aeda4a90b929d09ec72159b5542cfcb95232c Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Tue, 11 Nov 2025 20:11:45 -0800 Subject: [PATCH 72/96] Add timeouts for event emitters, and update reference behavior for the ETA entry test --- ...elfUpdatingETARepositorySharedTests.test.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/repositories/shuttle/eta/__tests__/SelfUpdatingETARepositorySharedTests.test.ts b/src/repositories/shuttle/eta/__tests__/SelfUpdatingETARepositorySharedTests.test.ts index 8003c41..75ce50a 100644 --- a/src/repositories/shuttle/eta/__tests__/SelfUpdatingETARepositorySharedTests.test.ts +++ b/src/repositories/shuttle/eta/__tests__/SelfUpdatingETARepositorySharedTests.test.ts @@ -77,6 +77,9 @@ describe.each(repositoryImplementations)('$name', (holder) => { const secondStopArrivalTime = new Date(2025, 0, 1, 12, 15, 0); await shuttleRepository.addOrUpdateShuttle(shuttle, secondStopArrivalTime.getTime()); + // Necessary to wait for the event emitter subscriber to execute + await new Promise((resolve) => setTimeout(resolve, 1000)); + const travelTime = await repository.getAverageTravelTimeSeconds({ routeId: route.id, fromStopId: stop1.id, @@ -92,8 +95,9 @@ describe.each(repositoryImplementations)('$name', (holder) => { const { route, systemId, stop1, stop2 } = await setupRouteAndOrderedStops(); const shuttleSecondArrivalTimeAtFirstStop = new Date(2025, 0, 8, 12, 0, 0); + const currentTime = new Date(shuttleSecondArrivalTimeAtFirstStop.getTime() + 7 * 60 * 1000); // "current time" is 7 minutes past arrival, expected eta = 8 minutes - repository.setReferenceTime(new Date(shuttleSecondArrivalTimeAtFirstStop.getTime() + 7 * 60 * 1000)); + repository.setReferenceTime(currentTime); repository.startListeningForUpdates(); const shuttle = { @@ -119,9 +123,17 @@ describe.each(repositoryImplementations)('$name', (holder) => { shuttleSecondArrivalTimeAtFirstStop.getTime(), ); + shuttle.coordinates = { latitude: 12.5, longitude: 22.5 }; + await shuttleRepository.addOrUpdateShuttle( + shuttle, + currentTime.getTime(), + ); + + await new Promise((resolve) => setTimeout(resolve, 1000)); + const eta = await repository.getEtaForShuttleAndStopId(shuttle.id, stop2.id); expect(eta?.secondsRemaining).toEqual(8 * 60); - }); + }, 60000); }); describe("getAverageTravelTimeSeconds", () => { @@ -147,6 +159,8 @@ describe.each(repositoryImplementations)('$name', (holder) => { const secondStopTime = new Date(2025, 0, 1, 12, 15, 0); await shuttleRepository.addOrUpdateShuttle(shuttle, secondStopTime.getTime()); + await new Promise((resolve) => setTimeout(resolve, 1000)); + const travelTime = await repository.getAverageTravelTimeSeconds({ routeId: route.id, fromStopId: stop1.id, From 6a31609960b83f0bf13aef61d9f324b7c7b6fdad Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Tue, 11 Nov 2025 20:21:40 -0800 Subject: [PATCH 73/96] Implement in-memory equivalents of both ETA repositories --- .../shuttle/eta/BaseInMemoryETARepository.ts | 13 ++- .../InMemoryExternalSourceETARepository.ts | 17 ++- .../eta/InMemorySelfUpdatingETARepository.ts | 110 ++++++++++++++++-- ...rnalSourceETARepositorySharedTests.test.ts | 17 ++- ...lfUpdatingETARepositorySharedTests.test.ts | 23 +++- 5 files changed, 165 insertions(+), 15 deletions(-) diff --git a/src/repositories/shuttle/eta/BaseInMemoryETARepository.ts b/src/repositories/shuttle/eta/BaseInMemoryETARepository.ts index 1a2b7cf..c260331 100644 --- a/src/repositories/shuttle/eta/BaseInMemoryETARepository.ts +++ b/src/repositories/shuttle/eta/BaseInMemoryETARepository.ts @@ -1,5 +1,5 @@ import { IEta } from "../../../entities/ShuttleRepositoryEntities"; -import { ETAGetterRepository, ETARepositoryEventListener, ETARepositoryEventName } from "./ETAGetterRepository"; +import { ETAGetterRepository, ETARepositoryEvent, ETARepositoryEventListener, ETARepositoryEventName } from "./ETAGetterRepository"; import EventEmitter from "node:events"; export abstract class BaseInMemoryETARepository extends EventEmitter implements ETAGetterRepository { @@ -18,6 +18,17 @@ export abstract class BaseInMemoryETARepository extends EventEmitter implements return eta ?? null; } + // Protected setter for internal use + protected 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; + } else { + this.etas.push(eta); + } + this.emit(ETARepositoryEvent.ETA_UPDATED, eta); + } + // EventEmitter overrides for type safety override on( event: T, diff --git a/src/repositories/shuttle/eta/InMemoryExternalSourceETARepository.ts b/src/repositories/shuttle/eta/InMemoryExternalSourceETARepository.ts index b769c12..4054e4f 100644 --- a/src/repositories/shuttle/eta/InMemoryExternalSourceETARepository.ts +++ b/src/repositories/shuttle/eta/InMemoryExternalSourceETARepository.ts @@ -1,13 +1,22 @@ import { IEta } from "../../../entities/ShuttleRepositoryEntities"; import { ExternalSourceETARepository } from "./ExternalSourceETARepository"; import { BaseInMemoryETARepository } from "./BaseInMemoryETARepository"; +import { ETARepositoryEvent } from "./ETAGetterRepository"; export class InMemoryExternalSourceETARepository extends BaseInMemoryETARepository implements ExternalSourceETARepository { - addOrUpdateEtaFromExternalSource(eta: IEta): Promise { - throw new Error("Method not implemented."); + async addOrUpdateEtaFromExternalSource(eta: IEta): Promise { + await this.addOrUpdateEta(eta); } - removeEtaIfExists(shuttleId: string, stopId: string): Promise { - throw new Error("Method not implemented."); + async removeEtaIfExists(shuttleId: string, stopId: string): Promise { + const index = this.etas.findIndex((e) => e.stopId === stopId && e.shuttleId === shuttleId); + if (index === -1) { + return null; + } + + const removedEta = this.etas[index]; + this.etas.splice(index, 1); + this.emit(ETARepositoryEvent.ETA_REMOVED, removedEta); + return removedEta; } } diff --git a/src/repositories/shuttle/eta/InMemorySelfUpdatingETARepository.ts b/src/repositories/shuttle/eta/InMemorySelfUpdatingETARepository.ts index a94955d..4f0f6e3 100644 --- a/src/repositories/shuttle/eta/InMemorySelfUpdatingETARepository.ts +++ b/src/repositories/shuttle/eta/InMemorySelfUpdatingETARepository.ts @@ -1,22 +1,118 @@ import { SelfUpdatingETARepository } from "./SelfUpdatingETARepository"; -import { ShuttleGetterRepository, ShuttleTravelTimeDataIdentifier, ShuttleTravelTimeDateFilterArguments } from "../ShuttleGetterRepository"; +import { ShuttleGetterRepository, ShuttleRepositoryEvent, ShuttleStopArrival, ShuttleTravelTimeDataIdentifier, ShuttleTravelTimeDateFilterArguments } from "../ShuttleGetterRepository"; import { BaseInMemoryETARepository } from "./BaseInMemoryETARepository"; +import { IShuttle } from "../../../entities/ShuttleRepositoryEntities"; export class InMemorySelfUpdatingETARepository extends BaseInMemoryETARepository implements SelfUpdatingETARepository { + private referenceTime: Date | null = null; + private travelTimeData: Map> = new Map(); + constructor( readonly shuttleRepository: ShuttleGetterRepository ) { super(); - } - setReferenceTime(referenceTime: Date): void { - throw new Error("Method not implemented."); + + this.setReferenceTime = this.setReferenceTime.bind(this); + this.getAverageTravelTimeSeconds = this.getAverageTravelTimeSeconds.bind(this); + this.startListeningForUpdates = this.startListeningForUpdates.bind(this); + this.handleShuttleUpdate = this.handleShuttleUpdate.bind(this); + this.handleShuttleWillArriveAtStop = this.handleShuttleWillArriveAtStop.bind(this); } - getAverageTravelTimeSeconds(identifier: ShuttleTravelTimeDataIdentifier, dateFilter: ShuttleTravelTimeDateFilterArguments): Promise { - throw new Error("Method not implemented."); + setReferenceTime(referenceTime: Date): void { + this.referenceTime = referenceTime; + } + + 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; } startListeningForUpdates(): void { - throw new Error("Method not implemented."); + this.shuttleRepository.addListener(ShuttleRepositoryEvent.SHUTTLE_UPDATED, this.handleShuttleUpdate); + this.shuttleRepository.addListener(ShuttleRepositoryEvent.SHUTTLE_WILL_ARRIVE_AT_STOP, this.handleShuttleWillArriveAtStop); + } + + private async handleShuttleUpdate(shuttle: IShuttle): Promise { + const lastStop = await this.shuttleRepository.getShuttleLastStopArrival(shuttle.id); + if (!lastStop) return; + + const lastOrderedStop = await this.shuttleRepository.getOrderedStopByRouteAndStopId(shuttle.routeId, lastStop.stopId); + const nextStop = lastOrderedStop?.nextStop; + if (!nextStop) return; + + let referenceCurrentTime = new Date(); + if (this.referenceTime != null) { + referenceCurrentTime = this.referenceTime; + } + const oneWeekAgo = new Date(referenceCurrentTime.getTime() - (60 * 60 * 24 * 7 * 1000)); + + const travelTimeSeconds = await this.getAverageTravelTimeSeconds({ + routeId: shuttle.routeId, + fromStopId: lastStop.stopId, + toStopId: nextStop.stopId, + }, { + from: oneWeekAgo, + to: new Date(oneWeekAgo.getTime() + (60 * 60 * 1000)) + }); + if (travelTimeSeconds == undefined) return; + + const elapsedTimeMs = referenceCurrentTime.getTime() - lastStop.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 handleShuttleWillArriveAtStop(shuttleArrival: ShuttleStopArrival): Promise { + const lastStopTimestamp = await this.shuttleRepository.getShuttleLastStopArrival(shuttleArrival.shuttleId); + if (lastStopTimestamp) { + const shuttle = await this.shuttleRepository.getShuttleById(lastStopTimestamp.shuttleId); + if (!shuttle) return; + + const routeId = shuttle.routeId; + const fromStopId = lastStopTimestamp.stopId; + const toStopId = shuttleArrival.stopId; + + const travelTimeSeconds = (shuttleArrival.timestamp.getTime() - lastStopTimestamp.timestamp.getTime()) / 1000; + await this.addTravelTimeDataPoint({ routeId, fromStopId, toStopId }, travelTimeSeconds, shuttleArrival.timestamp.getTime()); + } + } + + 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); } } diff --git a/src/repositories/shuttle/eta/__tests__/ExternalSourceETARepositorySharedTests.test.ts b/src/repositories/shuttle/eta/__tests__/ExternalSourceETARepositorySharedTests.test.ts index eb4d039..73aac3a 100644 --- a/src/repositories/shuttle/eta/__tests__/ExternalSourceETARepositorySharedTests.test.ts +++ b/src/repositories/shuttle/eta/__tests__/ExternalSourceETARepositorySharedTests.test.ts @@ -3,6 +3,7 @@ import { createClient, RedisClientType } from "redis"; import { RepositoryHolder } from "../../../../../testHelpers/RepositoryHolder"; import { ExternalSourceETARepository } from "../ExternalSourceETARepository"; import { RedisExternalSourceETARepository } from "../RedisExternalSourceETARepository"; +import { InMemoryExternalSourceETARepository } from "../InMemoryExternalSourceETARepository"; import { generateMockEtas } from "../../../../../testHelpers/mockDataGenerators"; class RedisExternalSourceETARepositoryHolder implements RepositoryHolder { @@ -26,8 +27,22 @@ class RedisExternalSourceETARepositoryHolder implements RepositoryHolder { + repo: InMemoryExternalSourceETARepository | undefined; + + name = "InMemoryExternalSourceETARepository" + factory = async () => { + this.repo = new InMemoryExternalSourceETARepository(); + return this.repo; + } + teardown = async () => { + // No teardown needed for in-memory + } +} + const repositoryImplementations = [ - new RedisExternalSourceETARepositoryHolder() + new RedisExternalSourceETARepositoryHolder(), + new InMemoryExternalSourceETARepositoryHolder() ]; describe.each(repositoryImplementations)('$name', (holder) => { diff --git a/src/repositories/shuttle/eta/__tests__/SelfUpdatingETARepositorySharedTests.test.ts b/src/repositories/shuttle/eta/__tests__/SelfUpdatingETARepositorySharedTests.test.ts index 75ce50a..cbf349f 100644 --- a/src/repositories/shuttle/eta/__tests__/SelfUpdatingETARepositorySharedTests.test.ts +++ b/src/repositories/shuttle/eta/__tests__/SelfUpdatingETARepositorySharedTests.test.ts @@ -3,8 +3,11 @@ import { createClient, RedisClientType } from "redis"; import { RepositoryHolder } from "../../../../../testHelpers/RepositoryHolder"; import { SelfUpdatingETARepository } from "../SelfUpdatingETARepository"; import { RedisSelfUpdatingETARepository } from "../RedisSelfUpdatingETARepository"; +import { InMemorySelfUpdatingETARepository } from "../InMemorySelfUpdatingETARepository"; import { RedisShuttleRepository } from "../../RedisShuttleRepository"; +import { UnoptimizedInMemoryShuttleRepository } from "../../UnoptimizedInMemoryShuttleRepository"; import { setupRouteAndOrderedStopsForShuttleRepository } from "../../../../../testHelpers/setupRouteAndOrderedStopsForShuttleRepository"; +import { ShuttleGetterSetterRepository } from "../../ShuttleGetterSetterRepository"; class RedisSelfUpdatingETARepositoryHolder implements RepositoryHolder { repo: RedisSelfUpdatingETARepository | undefined; @@ -32,13 +35,29 @@ class RedisSelfUpdatingETARepositoryHolder implements RepositoryHolder { + repo: InMemorySelfUpdatingETARepository | undefined; + shuttleRepo: UnoptimizedInMemoryShuttleRepository | undefined; + + name = "InMemorySelfUpdatingETARepository" + factory = async () => { + this.shuttleRepo = new UnoptimizedInMemoryShuttleRepository(); + this.repo = new InMemorySelfUpdatingETARepository(this.shuttleRepo); + return this.repo; + } + teardown = async () => { + // No teardown needed for in-memory + } +} + const repositoryImplementations = [ - new RedisSelfUpdatingETARepositoryHolder() + new RedisSelfUpdatingETARepositoryHolder(), + new InMemorySelfUpdatingETARepositoryHolder() ]; describe.each(repositoryImplementations)('$name', (holder) => { let repository: SelfUpdatingETARepository; - let shuttleRepository: RedisShuttleRepository; + let shuttleRepository: ShuttleGetterSetterRepository; beforeEach(async () => { repository = await holder.factory(); From e03e962ab9ec3ff32c0938bacb7fa21b4abdd67d Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Tue, 11 Nov 2025 20:24:55 -0800 Subject: [PATCH 74/96] Implement remaining logic for RedisExternalSourceETARepository --- .../shuttle/eta/BaseRedisETARepository.ts | 2 +- .../eta/RedisExternalSourceETARepository.ts | 17 +++++++++++++---- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/repositories/shuttle/eta/BaseRedisETARepository.ts b/src/repositories/shuttle/eta/BaseRedisETARepository.ts index 872af13..7fef6ee 100644 --- a/src/repositories/shuttle/eta/BaseRedisETARepository.ts +++ b/src/repositories/shuttle/eta/BaseRedisETARepository.ts @@ -6,7 +6,7 @@ export abstract class BaseRedisETARepository extends BaseRedisRepository impleme private static readonly ETA_KEY_PREFIX = 'shuttle:eta:'; // Helper methods - private createEtaKey = (shuttleId: string, stopId: string) => + protected createEtaKey = (shuttleId: string, stopId: string) => `${BaseRedisETARepository.ETA_KEY_PREFIX}${shuttleId}:${stopId}`; createRedisHashFromEta = (eta: IEta): Record => ({ diff --git a/src/repositories/shuttle/eta/RedisExternalSourceETARepository.ts b/src/repositories/shuttle/eta/RedisExternalSourceETARepository.ts index 3dac564..298709b 100644 --- a/src/repositories/shuttle/eta/RedisExternalSourceETARepository.ts +++ b/src/repositories/shuttle/eta/RedisExternalSourceETARepository.ts @@ -1,13 +1,22 @@ import { IEta } from "../../../entities/ShuttleRepositoryEntities"; import { BaseRedisETARepository } from "./BaseRedisETARepository"; import { ExternalSourceETARepository } from "./ExternalSourceETARepository"; +import { ETARepositoryEvent } from "./ETAGetterRepository"; export class RedisExternalSourceETARepository extends BaseRedisETARepository implements ExternalSourceETARepository { - addOrUpdateEtaFromExternalSource(eta: IEta): Promise { - throw new Error("Method not implemented."); + async addOrUpdateEtaFromExternalSource(eta: IEta): Promise { + await this.addOrUpdateEta(eta); } - removeEtaIfExists(shuttleId: string, stopId: string): Promise { - throw new Error("Method not implemented."); + async removeEtaIfExists(shuttleId: string, stopId: string): Promise { + const existingEta = await this.getEtaForShuttleAndStopId(shuttleId, stopId); + if (existingEta === null) { + return null; + } + + const key = this.createEtaKey(shuttleId, stopId); + await this.redisClient.del(key); + this.emit(ETARepositoryEvent.ETA_REMOVED, existingEta); + return existingEta; } } From 97feec6d3fa43df6ef192ebf41d186c04138a5bb Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Tue, 11 Nov 2025 20:47:20 -0800 Subject: [PATCH 75/96] Connect etaRepository to Redis in the builder --- src/entities/InterchangeSystem.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/entities/InterchangeSystem.ts b/src/entities/InterchangeSystem.ts index 49ec992..f9877b9 100644 --- a/src/entities/InterchangeSystem.ts +++ b/src/entities/InterchangeSystem.ts @@ -21,6 +21,7 @@ import { ETAGetterRepository } from "../repositories/shuttle/eta/ETAGetterReposi import { RedisSelfUpdatingETARepository } from "../repositories/shuttle/eta/RedisSelfUpdatingETARepository"; import { RedisExternalSourceETARepository } from "../repositories/shuttle/eta/RedisExternalSourceETARepository"; import { InMemorySelfUpdatingETARepository } from "../repositories/shuttle/eta/InMemorySelfUpdatingETARepository"; +import { BaseRedisETARepository } from "../repositories/shuttle/eta/BaseRedisETARepository"; export interface InterchangeSystemBuilderArguments { name: string; @@ -107,12 +108,13 @@ export class InterchangeSystem { shuttleDataLoader ); - let etaRepository: ETAGetterRepository; + let etaRepository: BaseRedisETARepository; if (args.useSelfUpdatingEtas) { etaRepository = new RedisSelfUpdatingETARepository(shuttleRepository); } else { etaRepository = new RedisExternalSourceETARepository(); } + await etaRepository.connect(); return { shuttleRepository, etaRepository, timedShuttleDataLoader }; } From 4096c0ce446ff1165b8b2bd8e9e22c34bbafb7b3 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Tue, 11 Nov 2025 21:05:43 -0800 Subject: [PATCH 76/96] Add call to startListeningForUpdates() --- src/entities/InterchangeSystem.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/entities/InterchangeSystem.ts b/src/entities/InterchangeSystem.ts index f9877b9..662cfe8 100644 --- a/src/entities/InterchangeSystem.ts +++ b/src/entities/InterchangeSystem.ts @@ -111,6 +111,7 @@ export class InterchangeSystem { let etaRepository: BaseRedisETARepository; if (args.useSelfUpdatingEtas) { etaRepository = new RedisSelfUpdatingETARepository(shuttleRepository); + (etaRepository as RedisSelfUpdatingETARepository).startListeningForUpdates(); } else { etaRepository = new RedisExternalSourceETARepository(); } From 1866224b5b0a9ac239474ed7880b12a8e96f97ad Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Tue, 11 Nov 2025 21:22:39 -0800 Subject: [PATCH 77/96] Prevent shuttle arriving at same stop from firing the SHUTTLE_WILL_ARRIVE_AT_STOP event --- src/repositories/shuttle/RedisShuttleRepository.ts | 6 +++++- .../shuttle/eta/InMemorySelfUpdatingETARepository.ts | 3 +++ .../shuttle/eta/RedisSelfUpdatingETARepository.ts | 3 +++ 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/repositories/shuttle/RedisShuttleRepository.ts b/src/repositories/shuttle/RedisShuttleRepository.ts index d343b4d..3cd452b 100644 --- a/src/repositories/shuttle/RedisShuttleRepository.ts +++ b/src/repositories/shuttle/RedisShuttleRepository.ts @@ -395,7 +395,11 @@ export class RedisShuttleRepository extends BaseRedisRepository implements Shutt ) { const arrivedStop = await this.getArrivedStopIfExists(shuttle); - if (arrivedStop != undefined) { + if (arrivedStop) { + // stop if same stop + const lastStop = await this.getShuttleLastStopArrival(shuttle.id); + if (lastStop?.stopId === arrivedStop.id) return; + const shuttleArrival = { stopId: arrivedStop.id, timestamp: new Date(travelTimeTimestamp), diff --git a/src/repositories/shuttle/eta/InMemorySelfUpdatingETARepository.ts b/src/repositories/shuttle/eta/InMemorySelfUpdatingETARepository.ts index 4f0f6e3..be2cea9 100644 --- a/src/repositories/shuttle/eta/InMemorySelfUpdatingETARepository.ts +++ b/src/repositories/shuttle/eta/InMemorySelfUpdatingETARepository.ts @@ -93,6 +93,9 @@ export class InMemorySelfUpdatingETARepository extends BaseInMemoryETARepository private async handleShuttleWillArriveAtStop(shuttleArrival: ShuttleStopArrival): Promise { const lastStopTimestamp = await this.shuttleRepository.getShuttleLastStopArrival(shuttleArrival.shuttleId); if (lastStopTimestamp) { + // disallow cases where this gets triggered multiple times + if (lastStopTimestamp.stopId === shuttleArrival.stopId) return; + const shuttle = await this.shuttleRepository.getShuttleById(lastStopTimestamp.shuttleId); if (!shuttle) return; diff --git a/src/repositories/shuttle/eta/RedisSelfUpdatingETARepository.ts b/src/repositories/shuttle/eta/RedisSelfUpdatingETARepository.ts index c0384d9..50af4a3 100644 --- a/src/repositories/shuttle/eta/RedisSelfUpdatingETARepository.ts +++ b/src/repositories/shuttle/eta/RedisSelfUpdatingETARepository.ts @@ -119,6 +119,9 @@ export class RedisSelfUpdatingETARepository extends BaseRedisETARepository imple ) { const lastStopTimestamp = await this.shuttleRepository.getShuttleLastStopArrival(shuttleArrival.shuttleId); if (lastStopTimestamp) { + // disallow cases where this gets triggered multiple times + if (lastStopTimestamp.stopId === shuttleArrival.stopId) return; + const shuttle = await this.shuttleRepository.getShuttleById(lastStopTimestamp.shuttleId); if (!shuttle) return; From 7463cb27f12ae9bfdb72c8f449cb79556a6a32e6 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Tue, 11 Nov 2025 21:41:32 -0800 Subject: [PATCH 78/96] Add proof-of-concept which also queries same time period yesterday, as well as last hour --- .../eta/RedisSelfUpdatingETARepository.ts | 28 ++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/src/repositories/shuttle/eta/RedisSelfUpdatingETARepository.ts b/src/repositories/shuttle/eta/RedisSelfUpdatingETARepository.ts index 50af4a3..8a62beb 100644 --- a/src/repositories/shuttle/eta/RedisSelfUpdatingETARepository.ts +++ b/src/repositories/shuttle/eta/RedisSelfUpdatingETARepository.ts @@ -90,8 +90,10 @@ export class RedisSelfUpdatingETARepository extends BaseRedisETARepository imple referenceCurrentTime = this.referenceTime; } const oneWeekAgo = new Date(referenceCurrentTime.getTime() - (60 * 60 * 24 * 7 * 1000)); + const oneDayAgo = new Date(referenceCurrentTime.getTime() - (60 * 60 * 24 * 1000)); + const oneHourAgo = new Date(referenceCurrentTime.getTime() - (60 * 60 * 1000)); - const travelTimeSeconds = await this.getAverageTravelTimeSeconds({ + let travelTimeSeconds = await this.getAverageTravelTimeSeconds({ routeId: shuttle.routeId, fromStopId: lastStop.stopId, toStopId: nextStop.stopId, @@ -99,6 +101,30 @@ export class RedisSelfUpdatingETARepository extends BaseRedisETARepository imple from: oneWeekAgo, to: new Date(oneWeekAgo.getTime() + (60 * 60 * 1000)) }); + + // Fallback to yesterday at the same time if no data + if (travelTimeSeconds == undefined) { + travelTimeSeconds = await this.getAverageTravelTimeSeconds({ + routeId: shuttle.routeId, + fromStopId: lastStop.stopId, + toStopId: nextStop.stopId, + }, { + from: oneDayAgo, + to: new Date(oneDayAgo.getTime() + (60 * 60 * 1000)) + }); + } + // Fallback to last hour if still no data + if (travelTimeSeconds == undefined) { + travelTimeSeconds = await this.getAverageTravelTimeSeconds({ + routeId: shuttle.routeId, + fromStopId: lastStop.stopId, + toStopId: nextStop.stopId, + }, { + from: oneHourAgo, + to: new Date(), + }); + } + if (travelTimeSeconds == undefined) return; const elapsedTimeMs = referenceCurrentTime.getTime() - lastStop.timestamp.getTime(); From d302e8a2cbf21decca2599d6357527b4935cacda Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Thu, 13 Nov 2025 16:07:42 -0800 Subject: [PATCH 79/96] Reorganize self-updating repository tests based on method name --- .../__tests__/SelfUpdatingETARepositorySharedTests.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/repositories/shuttle/eta/__tests__/SelfUpdatingETARepositorySharedTests.test.ts b/src/repositories/shuttle/eta/__tests__/SelfUpdatingETARepositorySharedTests.test.ts index cbf349f..539b35b 100644 --- a/src/repositories/shuttle/eta/__tests__/SelfUpdatingETARepositorySharedTests.test.ts +++ b/src/repositories/shuttle/eta/__tests__/SelfUpdatingETARepositorySharedTests.test.ts @@ -73,7 +73,7 @@ describe.each(repositoryImplementations)('$name', (holder) => { return await setupRouteAndOrderedStopsForShuttleRepository(shuttleRepository); } - describe("addOrUpdateShuttle triggers ETA calculations", () => { + describe("handleShuttleWillArriveAtStop", () => { test("updates how long the shuttle took to get from one stop to another", async () => { const { route, systemId, stop2, stop1 } = await setupRouteAndOrderedStops(); @@ -109,7 +109,9 @@ describe.each(repositoryImplementations)('$name', (holder) => { }); expect(travelTime).toEqual(15 * 60); }); + }); + describe("handleShuttleUpdate", () =>{ test("adds an ETA entry based on historical data", async () => { const { route, systemId, stop1, stop2 } = await setupRouteAndOrderedStops(); From 3f267be15453dc144466d1d64b92419ecc80f9ff Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Thu, 13 Nov 2025 16:41:51 -0800 Subject: [PATCH 80/96] Add test for previous day fallback calculation --- ...lfUpdatingETARepositorySharedTests.test.ts | 62 ++++++++++++++++--- 1 file changed, 55 insertions(+), 7 deletions(-) diff --git a/src/repositories/shuttle/eta/__tests__/SelfUpdatingETARepositorySharedTests.test.ts b/src/repositories/shuttle/eta/__tests__/SelfUpdatingETARepositorySharedTests.test.ts index 539b35b..df1dfc7 100644 --- a/src/repositories/shuttle/eta/__tests__/SelfUpdatingETARepositorySharedTests.test.ts +++ b/src/repositories/shuttle/eta/__tests__/SelfUpdatingETARepositorySharedTests.test.ts @@ -8,6 +8,7 @@ import { RedisShuttleRepository } from "../../RedisShuttleRepository"; import { UnoptimizedInMemoryShuttleRepository } from "../../UnoptimizedInMemoryShuttleRepository"; import { setupRouteAndOrderedStopsForShuttleRepository } from "../../../../../testHelpers/setupRouteAndOrderedStopsForShuttleRepository"; import { ShuttleGetterSetterRepository } from "../../ShuttleGetterSetterRepository"; +import { IRoute, IStop } from "../../../../entities/ShuttleRepositoryEntities"; class RedisSelfUpdatingETARepositoryHolder implements RepositoryHolder { repo: RedisSelfUpdatingETARepository | undefined; @@ -20,6 +21,7 @@ class RedisSelfUpdatingETARepositoryHolder implements RepositoryHolder { }); expect(travelTime).toEqual(15 * 60); }); + }); - describe("handleShuttleUpdate", () =>{ + describe("handleShuttleUpdate", () => { test("adds an ETA entry based on historical data", async () => { const { route, systemId, stop1, stop2 } = await setupRouteAndOrderedStops(); + const firstStopLastWeekArrivalTime = new Date(2025, 0, 1, 12, 0, 0); + const secondStopLastWeekArrivalTime = new Date(2025, 0, 1, 12, 15, 0); const shuttleSecondArrivalTimeAtFirstStop = new Date(2025, 0, 8, 12, 0, 0); const currentTime = new Date(shuttleSecondArrivalTimeAtFirstStop.getTime() + 7 * 60 * 1000); - // "current time" is 7 minutes past arrival, expected eta = 8 minutes - repository.setReferenceTime(currentTime); + + repository.setReferenceTime(currentTime); repository.startListeningForUpdates(); const shuttle = { @@ -131,23 +136,66 @@ describe.each(repositoryImplementations)('$name', (holder) => { updatedTime: new Date(), }; - const firstStopLastWeekArrivalTime = new Date(2025, 0, 1, 12, 0, 0); await shuttleRepository.addOrUpdateShuttle(shuttle, firstStopLastWeekArrivalTime.getTime()); shuttle.coordinates = stop2.coordinates; - const secondStopLastWeekArrivalTime = new Date(2025, 0, 1, 12, 15, 0); await shuttleRepository.addOrUpdateShuttle(shuttle, secondStopLastWeekArrivalTime.getTime()); shuttle.coordinates = stop1.coordinates; await shuttleRepository.addOrUpdateShuttle( shuttle, - shuttleSecondArrivalTimeAtFirstStop.getTime(), + shuttleSecondArrivalTimeAtFirstStop.getTime() ); shuttle.coordinates = { latitude: 12.5, longitude: 22.5 }; await shuttleRepository.addOrUpdateShuttle( shuttle, - currentTime.getTime(), + currentTime.getTime() + ); + + await new Promise((resolve) => setTimeout(resolve, 1000)); + + const eta = await repository.getEtaForShuttleAndStopId(shuttle.id, stop2.id); + expect(eta?.secondsRemaining).toEqual(8 * 60); + + }, 60000); + + test("uses previous day fallback calculation when no data available from one week ago", async () => { + const { route, systemId, stop1, stop2 } = await setupRouteAndOrderedStops(); + + const firstStopLastWeekArrivalTime = new Date(2025, 0, 1, 12, 0, 0); + const secondStopLastWeekArrivalTime = new Date(2025, 0, 1, 12, 15, 0); + const shuttleSecondArrivalTimeAtFirstStop = new Date(2025, 0, 2, 12, 0, 0); + const currentTime = new Date(shuttleSecondArrivalTimeAtFirstStop.getTime() + 7 * 60 * 1000); + + repository.setReferenceTime(currentTime); + repository.startListeningForUpdates(); + + const shuttle = { + id: "sh1", + name: "Shuttle 1", + routeId: route.id, + systemId: systemId, + coordinates: stop1.coordinates, + orientationInDegrees: 0, + updatedTime: new Date(), + }; + + await shuttleRepository.addOrUpdateShuttle(shuttle, firstStopLastWeekArrivalTime.getTime()); + + shuttle.coordinates = stop2.coordinates; + await shuttleRepository.addOrUpdateShuttle(shuttle, secondStopLastWeekArrivalTime.getTime()); + + shuttle.coordinates = stop1.coordinates; + await shuttleRepository.addOrUpdateShuttle( + shuttle, + shuttleSecondArrivalTimeAtFirstStop.getTime() + ); + + shuttle.coordinates = { latitude: 12.5, longitude: 22.5 }; + await shuttleRepository.addOrUpdateShuttle( + shuttle, + currentTime.getTime() ); await new Promise((resolve) => setTimeout(resolve, 1000)); From 71ac18cb8d2e2f0f9bc40b7941064bd3b8c1062f Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Thu, 13 Nov 2025 16:43:53 -0800 Subject: [PATCH 81/96] Add a similar test for previous hour --- ...lfUpdatingETARepositorySharedTests.test.ts | 45 ++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/src/repositories/shuttle/eta/__tests__/SelfUpdatingETARepositorySharedTests.test.ts b/src/repositories/shuttle/eta/__tests__/SelfUpdatingETARepositorySharedTests.test.ts index df1dfc7..7be9242 100644 --- a/src/repositories/shuttle/eta/__tests__/SelfUpdatingETARepositorySharedTests.test.ts +++ b/src/repositories/shuttle/eta/__tests__/SelfUpdatingETARepositorySharedTests.test.ts @@ -8,7 +8,6 @@ import { RedisShuttleRepository } from "../../RedisShuttleRepository"; import { UnoptimizedInMemoryShuttleRepository } from "../../UnoptimizedInMemoryShuttleRepository"; import { setupRouteAndOrderedStopsForShuttleRepository } from "../../../../../testHelpers/setupRouteAndOrderedStopsForShuttleRepository"; import { ShuttleGetterSetterRepository } from "../../ShuttleGetterSetterRepository"; -import { IRoute, IStop } from "../../../../entities/ShuttleRepositoryEntities"; class RedisSelfUpdatingETARepositoryHolder implements RepositoryHolder { repo: RedisSelfUpdatingETARepository | undefined; @@ -203,6 +202,50 @@ describe.each(repositoryImplementations)('$name', (holder) => { const eta = await repository.getEtaForShuttleAndStopId(shuttle.id, stop2.id); expect(eta?.secondsRemaining).toEqual(8 * 60); }, 60000); + + test("uses previous hour fallback calculation when no data available from one day ago", async () => { + const { route, systemId, stop1, stop2 } = await setupRouteAndOrderedStops(); + + const firstStopLastWeekArrivalTime = new Date(2025, 0, 1, 12, 0, 0); + const secondStopLastWeekArrivalTime = new Date(2025, 0, 1, 12, 15, 0); + const shuttleSecondArrivalTimeAtFirstStop = new Date(2025, 0, 1, 13, 5, 0); + const currentTime = new Date(shuttleSecondArrivalTimeAtFirstStop.getTime() + 7 * 60 * 1000); + + repository.setReferenceTime(currentTime); + repository.startListeningForUpdates(); + + const shuttle = { + id: "sh1", + name: "Shuttle 1", + routeId: route.id, + systemId: systemId, + coordinates: stop1.coordinates, + orientationInDegrees: 0, + updatedTime: new Date(), + }; + + await shuttleRepository.addOrUpdateShuttle(shuttle, firstStopLastWeekArrivalTime.getTime()); + + shuttle.coordinates = stop2.coordinates; + await shuttleRepository.addOrUpdateShuttle(shuttle, secondStopLastWeekArrivalTime.getTime()); + + shuttle.coordinates = stop1.coordinates; + await shuttleRepository.addOrUpdateShuttle( + shuttle, + shuttleSecondArrivalTimeAtFirstStop.getTime() + ); + + shuttle.coordinates = { latitude: 12.5, longitude: 22.5 }; + await shuttleRepository.addOrUpdateShuttle( + shuttle, + currentTime.getTime() + ); + + await new Promise((resolve) => setTimeout(resolve, 1000)); + + const eta = await repository.getEtaForShuttleAndStopId(shuttle.id, stop2.id); + expect(eta?.secondsRemaining).toEqual(8 * 60); + }); }); describe("getAverageTravelTimeSeconds", () => { From 076f1fd1e409ae6998be410f91766670fde86a5c Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Thu, 13 Nov 2025 16:49:32 -0800 Subject: [PATCH 82/96] Extract setup and assertion procedure into method for all three tests to use --- ...lfUpdatingETARepositorySharedTests.test.ts | 159 ++++++------------ 1 file changed, 48 insertions(+), 111 deletions(-) diff --git a/src/repositories/shuttle/eta/__tests__/SelfUpdatingETARepositorySharedTests.test.ts b/src/repositories/shuttle/eta/__tests__/SelfUpdatingETARepositorySharedTests.test.ts index 7be9242..40ac15c 100644 --- a/src/repositories/shuttle/eta/__tests__/SelfUpdatingETARepositorySharedTests.test.ts +++ b/src/repositories/shuttle/eta/__tests__/SelfUpdatingETARepositorySharedTests.test.ts @@ -114,137 +114,74 @@ describe.each(repositoryImplementations)('$name', (holder) => { }); describe("handleShuttleUpdate", () => { - test("adds an ETA entry based on historical data", async () => { + async function assertEtaIsValidGivenCurrentTimeAndSecondArrivalTime( + currentTime: Date, + shuttleSecondArrivalTimeAtFirstStop: Date + ) { const { route, systemId, stop1, stop2 } = await setupRouteAndOrderedStops(); const firstStopLastWeekArrivalTime = new Date(2025, 0, 1, 12, 0, 0); const secondStopLastWeekArrivalTime = new Date(2025, 0, 1, 12, 15, 0); + + repository.setReferenceTime(currentTime); + repository.startListeningForUpdates(); + + const shuttle = { + id: "sh1", + name: "Shuttle 1", + routeId: route.id, + systemId: systemId, + coordinates: stop1.coordinates, + orientationInDegrees: 0, + updatedTime: new Date(), + }; + + await shuttleRepository.addOrUpdateShuttle(shuttle, firstStopLastWeekArrivalTime.getTime()); + + shuttle.coordinates = stop2.coordinates; + await shuttleRepository.addOrUpdateShuttle(shuttle, secondStopLastWeekArrivalTime.getTime()); + + shuttle.coordinates = stop1.coordinates; + await shuttleRepository.addOrUpdateShuttle( + shuttle, + shuttleSecondArrivalTimeAtFirstStop.getTime() + ); + + shuttle.coordinates = { latitude: 12.5, longitude: 22.5 }; + await shuttleRepository.addOrUpdateShuttle( + shuttle, + currentTime.getTime() + ); + + await new Promise((resolve) => setTimeout(resolve, 1000)); + + const eta = await repository.getEtaForShuttleAndStopId(shuttle.id, stop2.id); + expect(eta?.secondsRemaining).toEqual(8 * 60); + } + + test("adds an ETA entry based on historical data", async () => { const shuttleSecondArrivalTimeAtFirstStop = new Date(2025, 0, 8, 12, 0, 0); const currentTime = new Date(shuttleSecondArrivalTimeAtFirstStop.getTime() + 7 * 60 * 1000); - repository.setReferenceTime(currentTime); - repository.startListeningForUpdates(); - - const shuttle = { - id: "sh1", - name: "Shuttle 1", - routeId: route.id, - systemId: systemId, - coordinates: stop1.coordinates, - orientationInDegrees: 0, - updatedTime: new Date(), - }; - - await shuttleRepository.addOrUpdateShuttle(shuttle, firstStopLastWeekArrivalTime.getTime()); - - shuttle.coordinates = stop2.coordinates; - await shuttleRepository.addOrUpdateShuttle(shuttle, secondStopLastWeekArrivalTime.getTime()); - - shuttle.coordinates = stop1.coordinates; - await shuttleRepository.addOrUpdateShuttle( - shuttle, - shuttleSecondArrivalTimeAtFirstStop.getTime() + await assertEtaIsValidGivenCurrentTimeAndSecondArrivalTime( + currentTime, shuttleSecondArrivalTimeAtFirstStop ); - - shuttle.coordinates = { latitude: 12.5, longitude: 22.5 }; - await shuttleRepository.addOrUpdateShuttle( - shuttle, - currentTime.getTime() - ); - - await new Promise((resolve) => setTimeout(resolve, 1000)); - - const eta = await repository.getEtaForShuttleAndStopId(shuttle.id, stop2.id); - expect(eta?.secondsRemaining).toEqual(8 * 60); - }, 60000); test("uses previous day fallback calculation when no data available from one week ago", async () => { - const { route, systemId, stop1, stop2 } = await setupRouteAndOrderedStops(); - - const firstStopLastWeekArrivalTime = new Date(2025, 0, 1, 12, 0, 0); - const secondStopLastWeekArrivalTime = new Date(2025, 0, 1, 12, 15, 0); const shuttleSecondArrivalTimeAtFirstStop = new Date(2025, 0, 2, 12, 0, 0); const currentTime = new Date(shuttleSecondArrivalTimeAtFirstStop.getTime() + 7 * 60 * 1000); - - repository.setReferenceTime(currentTime); - repository.startListeningForUpdates(); - - const shuttle = { - id: "sh1", - name: "Shuttle 1", - routeId: route.id, - systemId: systemId, - coordinates: stop1.coordinates, - orientationInDegrees: 0, - updatedTime: new Date(), - }; - - await shuttleRepository.addOrUpdateShuttle(shuttle, firstStopLastWeekArrivalTime.getTime()); - - shuttle.coordinates = stop2.coordinates; - await shuttleRepository.addOrUpdateShuttle(shuttle, secondStopLastWeekArrivalTime.getTime()); - - shuttle.coordinates = stop1.coordinates; - await shuttleRepository.addOrUpdateShuttle( - shuttle, - shuttleSecondArrivalTimeAtFirstStop.getTime() + await assertEtaIsValidGivenCurrentTimeAndSecondArrivalTime( + currentTime, shuttleSecondArrivalTimeAtFirstStop ); - - shuttle.coordinates = { latitude: 12.5, longitude: 22.5 }; - await shuttleRepository.addOrUpdateShuttle( - shuttle, - currentTime.getTime() - ); - - await new Promise((resolve) => setTimeout(resolve, 1000)); - - const eta = await repository.getEtaForShuttleAndStopId(shuttle.id, stop2.id); - expect(eta?.secondsRemaining).toEqual(8 * 60); }, 60000); test("uses previous hour fallback calculation when no data available from one day ago", async () => { - const { route, systemId, stop1, stop2 } = await setupRouteAndOrderedStops(); - - const firstStopLastWeekArrivalTime = new Date(2025, 0, 1, 12, 0, 0); - const secondStopLastWeekArrivalTime = new Date(2025, 0, 1, 12, 15, 0); const shuttleSecondArrivalTimeAtFirstStop = new Date(2025, 0, 1, 13, 5, 0); const currentTime = new Date(shuttleSecondArrivalTimeAtFirstStop.getTime() + 7 * 60 * 1000); - - repository.setReferenceTime(currentTime); - repository.startListeningForUpdates(); - - const shuttle = { - id: "sh1", - name: "Shuttle 1", - routeId: route.id, - systemId: systemId, - coordinates: stop1.coordinates, - orientationInDegrees: 0, - updatedTime: new Date(), - }; - - await shuttleRepository.addOrUpdateShuttle(shuttle, firstStopLastWeekArrivalTime.getTime()); - - shuttle.coordinates = stop2.coordinates; - await shuttleRepository.addOrUpdateShuttle(shuttle, secondStopLastWeekArrivalTime.getTime()); - - shuttle.coordinates = stop1.coordinates; - await shuttleRepository.addOrUpdateShuttle( - shuttle, - shuttleSecondArrivalTimeAtFirstStop.getTime() + await assertEtaIsValidGivenCurrentTimeAndSecondArrivalTime( + currentTime, shuttleSecondArrivalTimeAtFirstStop ); - - shuttle.coordinates = { latitude: 12.5, longitude: 22.5 }; - await shuttleRepository.addOrUpdateShuttle( - shuttle, - currentTime.getTime() - ); - - await new Promise((resolve) => setTimeout(resolve, 1000)); - - const eta = await repository.getEtaForShuttleAndStopId(shuttle.id, stop2.id); - expect(eta?.secondsRemaining).toEqual(8 * 60); }); }); From b5690d89837e0b5a71ab7f51b4b9a5d041ee2fd9 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Thu, 13 Nov 2025 16:52:39 -0800 Subject: [PATCH 83/96] Also implement the fallback logic in the in-memory repository --- .../eta/InMemorySelfUpdatingETARepository.ts | 28 ++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/src/repositories/shuttle/eta/InMemorySelfUpdatingETARepository.ts b/src/repositories/shuttle/eta/InMemorySelfUpdatingETARepository.ts index be2cea9..88fe3d9 100644 --- a/src/repositories/shuttle/eta/InMemorySelfUpdatingETARepository.ts +++ b/src/repositories/shuttle/eta/InMemorySelfUpdatingETARepository.ts @@ -67,8 +67,10 @@ export class InMemorySelfUpdatingETARepository extends BaseInMemoryETARepository referenceCurrentTime = this.referenceTime; } const oneWeekAgo = new Date(referenceCurrentTime.getTime() - (60 * 60 * 24 * 7 * 1000)); + const oneDayAgo = new Date(referenceCurrentTime.getTime() - (60 * 60 * 24 * 1000)); + const oneHourAgo = new Date(referenceCurrentTime.getTime() - (60 * 60 * 1000)); - const travelTimeSeconds = await this.getAverageTravelTimeSeconds({ + let travelTimeSeconds = await this.getAverageTravelTimeSeconds({ routeId: shuttle.routeId, fromStopId: lastStop.stopId, toStopId: nextStop.stopId, @@ -76,6 +78,30 @@ export class InMemorySelfUpdatingETARepository extends BaseInMemoryETARepository from: oneWeekAgo, to: new Date(oneWeekAgo.getTime() + (60 * 60 * 1000)) }); + + // Fallback to yesterday at the same time if no data + if (travelTimeSeconds == undefined) { + travelTimeSeconds = await this.getAverageTravelTimeSeconds({ + routeId: shuttle.routeId, + fromStopId: lastStop.stopId, + toStopId: nextStop.stopId, + }, { + from: oneDayAgo, + to: new Date(oneDayAgo.getTime() + (60 * 60 * 1000)) + }); + } + // Fallback to last hour if still no data + if (travelTimeSeconds == undefined) { + travelTimeSeconds = await this.getAverageTravelTimeSeconds({ + routeId: shuttle.routeId, + fromStopId: lastStop.stopId, + toStopId: nextStop.stopId, + }, { + from: oneHourAgo, + to: new Date(), + }); + } + if (travelTimeSeconds == undefined) return; const elapsedTimeMs = referenceCurrentTime.getTime() - lastStop.timestamp.getTime(); From f95263eec8739b251cf8ab56cb46fd88972df2a4 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Thu, 13 Nov 2025 17:07:28 -0800 Subject: [PATCH 84/96] Add another stop and ordered stop to the test data --- ...outeAndOrderedStopsForShuttleRepository.ts | 36 ++++++++++++++----- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/testHelpers/setupRouteAndOrderedStopsForShuttleRepository.ts b/testHelpers/setupRouteAndOrderedStopsForShuttleRepository.ts index a79f28a..8311cfa 100644 --- a/testHelpers/setupRouteAndOrderedStopsForShuttleRepository.ts +++ b/testHelpers/setupRouteAndOrderedStopsForShuttleRepository.ts @@ -1,4 +1,4 @@ -import { IOrderedStop } from "../src/entities/ShuttleRepositoryEntities"; +import { IOrderedStop, IStop } from "../src/entities/ShuttleRepositoryEntities"; import { ShuttleGetterSetterRepository } from "../src/repositories/shuttle/ShuttleGetterSetterRepository"; export async function setupRouteAndOrderedStopsForShuttleRepository( @@ -15,43 +15,61 @@ export async function setupRouteAndOrderedStopsForShuttleRepository( }; await shuttleRepository.addOrUpdateRoute(route); - const stop1 = { + const stop1: IStop = { id: "st1", name: "Stop 1", - systemId: systemId, + systemId, coordinates: { latitude: 10.0, longitude: 20.0 }, updatedTime: new Date(), }; - const stop2 = { + const stop2: IStop = { id: "st2", name: "Stop 2", - systemId: systemId, + systemId, coordinates: { latitude: 15.0, longitude: 25.0 }, updatedTime: new Date(), }; + const stop3: IStop = { + id: "st3", + name: "Stop 3", + systemId, + coordinates: { latitude: 20.0, longitude: 30.0 }, + updatedTime: new Date(), + } await shuttleRepository.addOrUpdateStop(stop1); await shuttleRepository.addOrUpdateStop(stop2); + await shuttleRepository.addOrUpdateStop(stop3); const orderedStop1: IOrderedStop = { routeId: route.id, stopId: stop1.id, position: 1, - systemId: systemId, + systemId, updatedTime: new Date(), }; const orderedStop2: IOrderedStop = { routeId: route.id, stopId: stop2.id, position: 2, - systemId: systemId, + systemId, updatedTime: new Date(), }; + const orderedStop3: IOrderedStop = { + routeId: route.id, + stopId: stop3.id, + position: 3, + systemId, + updatedTime: new Date(), + } orderedStop1.nextStop = orderedStop2; - orderedStop1.previousStop = orderedStop2; - orderedStop2.nextStop = orderedStop1; + orderedStop1.previousStop = orderedStop3; + orderedStop2.nextStop = orderedStop3; orderedStop2.previousStop = orderedStop1; + orderedStop3.nextStop = orderedStop1; + orderedStop3.previousStop = orderedStop2; await shuttleRepository.addOrUpdateOrderedStop(orderedStop1); await shuttleRepository.addOrUpdateOrderedStop(orderedStop2); + await shuttleRepository.addOrUpdateOrderedStop(orderedStop3); return { route, From e4151ed914e969ab152034ece148897dc7ec0713 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Thu, 13 Nov 2025 17:08:47 -0800 Subject: [PATCH 85/96] Return stop3 as part of the test data --- testHelpers/setupRouteAndOrderedStopsForShuttleRepository.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/testHelpers/setupRouteAndOrderedStopsForShuttleRepository.ts b/testHelpers/setupRouteAndOrderedStopsForShuttleRepository.ts index 8311cfa..7dd80e9 100644 --- a/testHelpers/setupRouteAndOrderedStopsForShuttleRepository.ts +++ b/testHelpers/setupRouteAndOrderedStopsForShuttleRepository.ts @@ -76,5 +76,6 @@ export async function setupRouteAndOrderedStopsForShuttleRepository( systemId, stop1, stop2, + stop3, }; } From 42c71815e66cc530d1d396b5961a1b65b3a61702 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Thu, 13 Nov 2025 17:13:43 -0800 Subject: [PATCH 86/96] Make the ETA assertion check for multiple ETA insertions --- ...lfUpdatingETARepositorySharedTests.test.ts | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/src/repositories/shuttle/eta/__tests__/SelfUpdatingETARepositorySharedTests.test.ts b/src/repositories/shuttle/eta/__tests__/SelfUpdatingETARepositorySharedTests.test.ts index 40ac15c..f490b0f 100644 --- a/src/repositories/shuttle/eta/__tests__/SelfUpdatingETARepositorySharedTests.test.ts +++ b/src/repositories/shuttle/eta/__tests__/SelfUpdatingETARepositorySharedTests.test.ts @@ -118,10 +118,12 @@ describe.each(repositoryImplementations)('$name', (holder) => { currentTime: Date, shuttleSecondArrivalTimeAtFirstStop: Date ) { - const { route, systemId, stop1, stop2 } = await setupRouteAndOrderedStops(); + const { route, systemId, stop1, stop2, stop3 } = await setupRouteAndOrderedStops(); - const firstStopLastWeekArrivalTime = new Date(2025, 0, 1, 12, 0, 0); - const secondStopLastWeekArrivalTime = new Date(2025, 0, 1, 12, 15, 0); + // Populating travel time data + const firstStopArrivalTime = new Date(2025, 0, 1, 12, 0, 0); + const secondStopArrivalTime = new Date(2025, 0, 1, 12, 15, 0); + const thirdStopArrivalTime = new Date(2025, 0, 1, 12, 20, 0); repository.setReferenceTime(currentTime); repository.startListeningForUpdates(); @@ -136,11 +138,15 @@ describe.each(repositoryImplementations)('$name', (holder) => { updatedTime: new Date(), }; - await shuttleRepository.addOrUpdateShuttle(shuttle, firstStopLastWeekArrivalTime.getTime()); + await shuttleRepository.addOrUpdateShuttle(shuttle, firstStopArrivalTime.getTime()); shuttle.coordinates = stop2.coordinates; - await shuttleRepository.addOrUpdateShuttle(shuttle, secondStopLastWeekArrivalTime.getTime()); + await shuttleRepository.addOrUpdateShuttle(shuttle, secondStopArrivalTime.getTime()); + shuttle.coordinates = stop3.coordinates; + await shuttleRepository.addOrUpdateShuttle(shuttle, thirdStopArrivalTime.getTime()); + + // Populating ETA data shuttle.coordinates = stop1.coordinates; await shuttleRepository.addOrUpdateShuttle( shuttle, @@ -155,11 +161,14 @@ describe.each(repositoryImplementations)('$name', (holder) => { await new Promise((resolve) => setTimeout(resolve, 1000)); - const eta = await repository.getEtaForShuttleAndStopId(shuttle.id, stop2.id); - expect(eta?.secondsRemaining).toEqual(8 * 60); + const secondStopEta = await repository.getEtaForShuttleAndStopId(shuttle.id, stop2.id); + expect(secondStopEta?.secondsRemaining).toEqual(8 * 60); + + const thirdStopEta = await repository.getEtaForShuttleAndStopId(shuttle.id, stop3.id); + expect(thirdStopEta?.secondsRemaining).toEqual(13 * 60); } - test("adds an ETA entry based on historical data", async () => { + test("adds ETA entries for stops based on historical data", async () => { const shuttleSecondArrivalTimeAtFirstStop = new Date(2025, 0, 8, 12, 0, 0); const currentTime = new Date(shuttleSecondArrivalTimeAtFirstStop.getTime() + 7 * 60 * 1000); From 91584fc2ab70ee5ca614326fbe8fdc0fb3eba544 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Thu, 13 Nov 2025 17:19:37 -0800 Subject: [PATCH 87/96] Extract fallback logic to a separate method --- .../eta/InMemorySelfUpdatingETARepository.ts | 48 +++++++++---------- .../eta/RedisSelfUpdatingETARepository.ts | 48 +++++++++---------- 2 files changed, 48 insertions(+), 48 deletions(-) diff --git a/src/repositories/shuttle/eta/InMemorySelfUpdatingETARepository.ts b/src/repositories/shuttle/eta/InMemorySelfUpdatingETARepository.ts index 88fe3d9..14a6a1a 100644 --- a/src/repositories/shuttle/eta/InMemorySelfUpdatingETARepository.ts +++ b/src/repositories/shuttle/eta/InMemorySelfUpdatingETARepository.ts @@ -54,6 +54,19 @@ export class InMemorySelfUpdatingETARepository extends BaseInMemoryETARepository this.shuttleRepository.addListener(ShuttleRepositoryEvent.SHUTTLE_WILL_ARRIVE_AT_STOP, this.handleShuttleWillArriveAtStop); } + private async getAverageTravelTimeSecondsWithFallbacks( + identifier: ShuttleTravelTimeDataIdentifier, + dateFilters: ShuttleTravelTimeDateFilterArguments[] + ): Promise { + for (const dateFilter of dateFilters) { + const result = await this.getAverageTravelTimeSeconds(identifier, dateFilter); + if (result !== undefined) { + return result; + } + } + return undefined; + } + private async handleShuttleUpdate(shuttle: IShuttle): Promise { const lastStop = await this.shuttleRepository.getShuttleLastStopArrival(shuttle.id); if (!lastStop) return; @@ -70,37 +83,24 @@ export class InMemorySelfUpdatingETARepository extends BaseInMemoryETARepository const oneDayAgo = new Date(referenceCurrentTime.getTime() - (60 * 60 * 24 * 1000)); const oneHourAgo = new Date(referenceCurrentTime.getTime() - (60 * 60 * 1000)); - let travelTimeSeconds = await this.getAverageTravelTimeSeconds({ + const travelTimeSeconds = await this.getAverageTravelTimeSecondsWithFallbacks({ routeId: shuttle.routeId, fromStopId: lastStop.stopId, toStopId: nextStop.stopId, - }, { - from: oneWeekAgo, - to: new Date(oneWeekAgo.getTime() + (60 * 60 * 1000)) - }); - - // Fallback to yesterday at the same time if no data - if (travelTimeSeconds == undefined) { - travelTimeSeconds = await this.getAverageTravelTimeSeconds({ - routeId: shuttle.routeId, - fromStopId: lastStop.stopId, - toStopId: nextStop.stopId, - }, { + }, [ + { + from: oneWeekAgo, + to: new Date(oneWeekAgo.getTime() + (60 * 60 * 1000)) + }, + { from: oneDayAgo, to: new Date(oneDayAgo.getTime() + (60 * 60 * 1000)) - }); - } - // Fallback to last hour if still no data - if (travelTimeSeconds == undefined) { - travelTimeSeconds = await this.getAverageTravelTimeSeconds({ - routeId: shuttle.routeId, - fromStopId: lastStop.stopId, - toStopId: nextStop.stopId, - }, { + }, + { from: oneHourAgo, to: new Date(), - }); - } + } + ]); if (travelTimeSeconds == undefined) return; diff --git a/src/repositories/shuttle/eta/RedisSelfUpdatingETARepository.ts b/src/repositories/shuttle/eta/RedisSelfUpdatingETARepository.ts index 8a62beb..76e3474 100644 --- a/src/repositories/shuttle/eta/RedisSelfUpdatingETARepository.ts +++ b/src/repositories/shuttle/eta/RedisSelfUpdatingETARepository.ts @@ -77,6 +77,19 @@ export class RedisSelfUpdatingETARepository extends BaseRedisETARepository imple this.shuttleRepository.removeListener(ShuttleRepositoryEvent.SHUTTLE_WILL_ARRIVE_AT_STOP, this.handleShuttleWillArriveAtStop); } + private async getAverageTravelTimeSecondsWithFallbacks( + identifier: ShuttleTravelTimeDataIdentifier, + dateFilters: ShuttleTravelTimeDateFilterArguments[] + ): Promise { + for (const dateFilter of dateFilters) { + const result = await this.getAverageTravelTimeSeconds(identifier, dateFilter); + if (result !== undefined) { + return result; + } + } + return undefined; + } + private async handleShuttleUpdate(shuttle: IShuttle) { const lastStop = await this.shuttleRepository.getShuttleLastStopArrival(shuttle.id); if (!lastStop) return; @@ -93,37 +106,24 @@ export class RedisSelfUpdatingETARepository extends BaseRedisETARepository imple const oneDayAgo = new Date(referenceCurrentTime.getTime() - (60 * 60 * 24 * 1000)); const oneHourAgo = new Date(referenceCurrentTime.getTime() - (60 * 60 * 1000)); - let travelTimeSeconds = await this.getAverageTravelTimeSeconds({ + const travelTimeSeconds = await this.getAverageTravelTimeSecondsWithFallbacks({ routeId: shuttle.routeId, fromStopId: lastStop.stopId, toStopId: nextStop.stopId, - }, { - from: oneWeekAgo, - to: new Date(oneWeekAgo.getTime() + (60 * 60 * 1000)) - }); - - // Fallback to yesterday at the same time if no data - if (travelTimeSeconds == undefined) { - travelTimeSeconds = await this.getAverageTravelTimeSeconds({ - routeId: shuttle.routeId, - fromStopId: lastStop.stopId, - toStopId: nextStop.stopId, - }, { + }, [ + { + from: oneWeekAgo, + to: new Date(oneWeekAgo.getTime() + (60 * 60 * 1000)) + }, + { from: oneDayAgo, to: new Date(oneDayAgo.getTime() + (60 * 60 * 1000)) - }); - } - // Fallback to last hour if still no data - if (travelTimeSeconds == undefined) { - travelTimeSeconds = await this.getAverageTravelTimeSeconds({ - routeId: shuttle.routeId, - fromStopId: lastStop.stopId, - toStopId: nextStop.stopId, - }, { + }, + { from: oneHourAgo, to: new Date(), - }); - } + } + ]); if (travelTimeSeconds == undefined) return; From bf6a2e466775379cab4a4d9253436fc8803292fd Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Thu, 13 Nov 2025 17:38:53 -0800 Subject: [PATCH 88/96] Add recursive implementation of cascading ETA updates for multiple ordered stops --- .../eta/RedisSelfUpdatingETARepository.ts | 45 ++++++++++++++++--- 1 file changed, 39 insertions(+), 6 deletions(-) diff --git a/src/repositories/shuttle/eta/RedisSelfUpdatingETARepository.ts b/src/repositories/shuttle/eta/RedisSelfUpdatingETARepository.ts index 76e3474..9b87919 100644 --- a/src/repositories/shuttle/eta/RedisSelfUpdatingETARepository.ts +++ b/src/repositories/shuttle/eta/RedisSelfUpdatingETARepository.ts @@ -3,7 +3,7 @@ import { BaseRedisETARepository } from "./BaseRedisETARepository"; import { createClient, RedisClientType } from "redis"; import { ShuttleGetterRepository, ShuttleRepositoryEvent, ShuttleStopArrival, ShuttleTravelTimeDataIdentifier, ShuttleTravelTimeDateFilterArguments } from "../ShuttleGetterRepository"; import { REDIS_RECONNECT_INTERVAL } from "../../../environment"; -import { IShuttle } from "../../../entities/ShuttleRepositoryEntities"; +import { IOrderedStop, IShuttle } from "../../../entities/ShuttleRepositoryEntities"; export class RedisSelfUpdatingETARepository extends BaseRedisETARepository implements SelfUpdatingETARepository { constructor( @@ -95,20 +95,43 @@ export class RedisSelfUpdatingETARepository extends BaseRedisETARepository imple if (!lastStop) return; const lastOrderedStop = await this.shuttleRepository.getOrderedStopByRouteAndStopId(shuttle.routeId, lastStop.stopId); - const nextStop = lastOrderedStop?.nextStop; + + await this.updateCascadingEta({ + shuttle, + currentStop: lastOrderedStop, + originalStopArrival: lastStop, + }); + } + + private async updateCascadingEta({ + shuttle, + currentStop, + originalStopArrival, + runningTravelTimeSeconds = 0 + }: { + shuttle: IShuttle; + currentStop: IOrderedStop | null; + originalStopArrival: ShuttleStopArrival; + runningTravelTimeSeconds?: number; + }) { + if (!currentStop) return; + const nextStop = currentStop?.nextStop; if (!nextStop) return; + // In case the system we have loops around + if (nextStop.stopId === originalStopArrival.stopId) return; let referenceCurrentTime = new Date(); if (this.referenceTime != null) { referenceCurrentTime = this.referenceTime; } + const oneWeekAgo = new Date(referenceCurrentTime.getTime() - (60 * 60 * 24 * 7 * 1000)); const oneDayAgo = new Date(referenceCurrentTime.getTime() - (60 * 60 * 24 * 1000)); const oneHourAgo = new Date(referenceCurrentTime.getTime() - (60 * 60 * 1000)); const travelTimeSeconds = await this.getAverageTravelTimeSecondsWithFallbacks({ routeId: shuttle.routeId, - fromStopId: lastStop.stopId, + fromStopId: currentStop.stopId, toStopId: nextStop.stopId, }, [ { @@ -127,10 +150,9 @@ export class RedisSelfUpdatingETARepository extends BaseRedisETARepository imple if (travelTimeSeconds == undefined) return; - const elapsedTimeMs = referenceCurrentTime.getTime() - lastStop.timestamp.getTime(); - const predictedTimeSeconds = travelTimeSeconds - (elapsedTimeMs / 1000); + const elapsedTimeMs = referenceCurrentTime.getTime() - originalStopArrival.timestamp.getTime(); + const predictedTimeSeconds = travelTimeSeconds - (elapsedTimeMs / 1000) + runningTravelTimeSeconds; - // TODO: update multiple ETAs await this.addOrUpdateEta({ secondsRemaining: predictedTimeSeconds, shuttleId: shuttle.id, @@ -138,8 +160,19 @@ export class RedisSelfUpdatingETARepository extends BaseRedisETARepository imple systemId: nextStop.systemId, updatedTime: new Date(), }); + + const nextStopWithNextNextStop = await this.shuttleRepository.getOrderedStopByRouteAndStopId(shuttle.routeId, nextStop.stopId); + await this.updateCascadingEta( + { + shuttle, + currentStop: nextStopWithNextNextStop, + originalStopArrival, + runningTravelTimeSeconds: runningTravelTimeSeconds + travelTimeSeconds, + }, + ) } + private async handleShuttleWillArriveAtStop( shuttleArrival: ShuttleStopArrival, ) { From 2fbc13202da264060e2792b3838e43b8b4b9fafe Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Thu, 13 Nov 2025 18:02:10 -0800 Subject: [PATCH 89/96] Apply the same changes to the in-memory repository --- .../eta/InMemorySelfUpdatingETARepository.ts | 48 +++++++++++++++++-- 1 file changed, 43 insertions(+), 5 deletions(-) diff --git a/src/repositories/shuttle/eta/InMemorySelfUpdatingETARepository.ts b/src/repositories/shuttle/eta/InMemorySelfUpdatingETARepository.ts index 14a6a1a..5503939 100644 --- a/src/repositories/shuttle/eta/InMemorySelfUpdatingETARepository.ts +++ b/src/repositories/shuttle/eta/InMemorySelfUpdatingETARepository.ts @@ -1,7 +1,7 @@ import { SelfUpdatingETARepository } from "./SelfUpdatingETARepository"; import { ShuttleGetterRepository, ShuttleRepositoryEvent, ShuttleStopArrival, ShuttleTravelTimeDataIdentifier, ShuttleTravelTimeDateFilterArguments } from "../ShuttleGetterRepository"; import { BaseInMemoryETARepository } from "./BaseInMemoryETARepository"; -import { IShuttle } from "../../../entities/ShuttleRepositoryEntities"; +import { IOrderedStop, IShuttle } from "../../../entities/ShuttleRepositoryEntities"; export class InMemorySelfUpdatingETARepository extends BaseInMemoryETARepository implements SelfUpdatingETARepository { private referenceTime: Date | null = null; @@ -54,6 +54,11 @@ export class InMemorySelfUpdatingETARepository extends BaseInMemoryETARepository this.shuttleRepository.addListener(ShuttleRepositoryEvent.SHUTTLE_WILL_ARRIVE_AT_STOP, this.handleShuttleWillArriveAtStop); } + stopListeningForUpdates(): void { + this.shuttleRepository.removeListener(ShuttleRepositoryEvent.SHUTTLE_UPDATED, this.handleShuttleUpdate); + this.shuttleRepository.removeListener(ShuttleRepositoryEvent.SHUTTLE_WILL_ARRIVE_AT_STOP, this.handleShuttleWillArriveAtStop); + } + private async getAverageTravelTimeSecondsWithFallbacks( identifier: ShuttleTravelTimeDataIdentifier, dateFilters: ShuttleTravelTimeDateFilterArguments[] @@ -72,20 +77,43 @@ export class InMemorySelfUpdatingETARepository extends BaseInMemoryETARepository if (!lastStop) return; const lastOrderedStop = await this.shuttleRepository.getOrderedStopByRouteAndStopId(shuttle.routeId, lastStop.stopId); - const nextStop = lastOrderedStop?.nextStop; + + await this.updateCascadingEta({ + shuttle, + currentStop: lastOrderedStop, + originalStopArrival: lastStop, + }); + } + + private async updateCascadingEta({ + shuttle, + currentStop, + originalStopArrival, + runningTravelTimeSeconds = 0 + }: { + shuttle: IShuttle; + currentStop: IOrderedStop | null; + originalStopArrival: ShuttleStopArrival; + runningTravelTimeSeconds?: number; + }) { + if (!currentStop) return; + const nextStop = currentStop?.nextStop; if (!nextStop) return; + // In case the system we have loops around + if (nextStop.stopId === originalStopArrival.stopId) return; let referenceCurrentTime = new Date(); if (this.referenceTime != null) { referenceCurrentTime = this.referenceTime; } + const oneWeekAgo = new Date(referenceCurrentTime.getTime() - (60 * 60 * 24 * 7 * 1000)); const oneDayAgo = new Date(referenceCurrentTime.getTime() - (60 * 60 * 24 * 1000)); const oneHourAgo = new Date(referenceCurrentTime.getTime() - (60 * 60 * 1000)); const travelTimeSeconds = await this.getAverageTravelTimeSecondsWithFallbacks({ routeId: shuttle.routeId, - fromStopId: lastStop.stopId, + fromStopId: currentStop.stopId, toStopId: nextStop.stopId, }, [ { @@ -104,8 +132,8 @@ export class InMemorySelfUpdatingETARepository extends BaseInMemoryETARepository if (travelTimeSeconds == undefined) return; - const elapsedTimeMs = referenceCurrentTime.getTime() - lastStop.timestamp.getTime(); - const predictedTimeSeconds = travelTimeSeconds - (elapsedTimeMs / 1000); + const elapsedTimeMs = referenceCurrentTime.getTime() - originalStopArrival.timestamp.getTime(); + const predictedTimeSeconds = travelTimeSeconds - (elapsedTimeMs / 1000) + runningTravelTimeSeconds; await this.addOrUpdateEta({ secondsRemaining: predictedTimeSeconds, @@ -114,6 +142,16 @@ export class InMemorySelfUpdatingETARepository extends BaseInMemoryETARepository systemId: nextStop.systemId, updatedTime: new Date(), }); + + const nextStopWithNextNextStop = await this.shuttleRepository.getOrderedStopByRouteAndStopId(shuttle.routeId, nextStop.stopId); + await this.updateCascadingEta( + { + shuttle, + currentStop: nextStopWithNextNextStop, + originalStopArrival, + runningTravelTimeSeconds: runningTravelTimeSeconds + travelTimeSeconds, + }, + ) } private async handleShuttleWillArriveAtStop(shuttleArrival: ShuttleStopArrival): Promise { From 0cf2a4d2e7324d58b9e07be45bb35e7f677e470f Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Thu, 13 Nov 2025 18:35:20 -0800 Subject: [PATCH 90/96] Add logic to clear out previous ETAs --- .../eta/InMemorySelfUpdatingETARepository.ts | 18 ++++++++++++++++++ .../eta/RedisSelfUpdatingETARepository.ts | 19 ++++++++++++++++++- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/repositories/shuttle/eta/InMemorySelfUpdatingETARepository.ts b/src/repositories/shuttle/eta/InMemorySelfUpdatingETARepository.ts index 5503939..bc56789 100644 --- a/src/repositories/shuttle/eta/InMemorySelfUpdatingETARepository.ts +++ b/src/repositories/shuttle/eta/InMemorySelfUpdatingETARepository.ts @@ -2,6 +2,7 @@ import { SelfUpdatingETARepository } from "./SelfUpdatingETARepository"; import { ShuttleGetterRepository, ShuttleRepositoryEvent, ShuttleStopArrival, ShuttleTravelTimeDataIdentifier, ShuttleTravelTimeDateFilterArguments } from "../ShuttleGetterRepository"; import { BaseInMemoryETARepository } from "./BaseInMemoryETARepository"; import { IOrderedStop, IShuttle } from "../../../entities/ShuttleRepositoryEntities"; +import { ETARepositoryEvent } from "./ETAGetterRepository"; export class InMemorySelfUpdatingETARepository extends BaseInMemoryETARepository implements SelfUpdatingETARepository { private referenceTime: Date | null = null; @@ -155,6 +156,11 @@ export class InMemorySelfUpdatingETARepository extends BaseInMemoryETARepository } private async handleShuttleWillArriveAtStop(shuttleArrival: ShuttleStopArrival): Promise { + const etas = await this.getEtasForShuttleId(shuttleArrival.shuttleId); + for (const eta of etas) { + await this.removeEtaIfExists(eta.shuttleId, eta.stopId); + } + const lastStopTimestamp = await this.shuttleRepository.getShuttleLastStopArrival(shuttleArrival.shuttleId); if (lastStopTimestamp) { // disallow cases where this gets triggered multiple times @@ -182,4 +188,16 @@ export class InMemorySelfUpdatingETARepository extends BaseInMemoryETARepository dataPoints.push({ timestamp, seconds: travelTimeSeconds }); this.travelTimeData.set(key, dataPoints); } + + private async removeEtaIfExists(shuttleId: string, stopId: string) { + const index = this.etas.findIndex((e) => e.stopId === stopId && e.shuttleId === shuttleId); + if (index === -1) { + return null; + } + + const removedEta = this.etas[index]; + this.etas.splice(index, 1); + this.emit(ETARepositoryEvent.ETA_REMOVED, removedEta); + return removedEta; + } } diff --git a/src/repositories/shuttle/eta/RedisSelfUpdatingETARepository.ts b/src/repositories/shuttle/eta/RedisSelfUpdatingETARepository.ts index 9b87919..6ce6e24 100644 --- a/src/repositories/shuttle/eta/RedisSelfUpdatingETARepository.ts +++ b/src/repositories/shuttle/eta/RedisSelfUpdatingETARepository.ts @@ -3,7 +3,8 @@ import { BaseRedisETARepository } from "./BaseRedisETARepository"; import { createClient, RedisClientType } from "redis"; import { ShuttleGetterRepository, ShuttleRepositoryEvent, ShuttleStopArrival, ShuttleTravelTimeDataIdentifier, ShuttleTravelTimeDateFilterArguments } from "../ShuttleGetterRepository"; import { REDIS_RECONNECT_INTERVAL } from "../../../environment"; -import { IOrderedStop, IShuttle } from "../../../entities/ShuttleRepositoryEntities"; +import { IEta, IOrderedStop, IShuttle } from "../../../entities/ShuttleRepositoryEntities"; +import { ETARepositoryEvent } from "./ETAGetterRepository"; export class RedisSelfUpdatingETARepository extends BaseRedisETARepository implements SelfUpdatingETARepository { constructor( @@ -176,6 +177,11 @@ export class RedisSelfUpdatingETARepository extends BaseRedisETARepository imple private async handleShuttleWillArriveAtStop( shuttleArrival: ShuttleStopArrival, ) { + const etas = await this.getEtasForShuttleId(shuttleArrival.shuttleId); + for (const eta of etas) { + await this.removeEtaIfExists(eta.shuttleId, eta.stopId); + } + const lastStopTimestamp = await this.shuttleRepository.getShuttleLastStopArrival(shuttleArrival.shuttleId); if (lastStopTimestamp) { // disallow cases where this gets triggered multiple times @@ -265,4 +271,15 @@ export class RedisSelfUpdatingETARepository extends BaseRedisETARepository imple } } + private async removeEtaIfExists(shuttleId: string, stopId: string): Promise { + const existingEta = await this.getEtaForShuttleAndStopId(shuttleId, stopId); + if (existingEta === null) { + return null; + } + + const key = this.createEtaKey(shuttleId, stopId); + await this.redisClient.del(key); + this.emit(ETARepositoryEvent.ETA_REMOVED, existingEta); + return existingEta; + } } From d6ad90ee7a42340e0b3d75e6c3316f2eaa175850 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Thu, 13 Nov 2025 18:52:13 -0800 Subject: [PATCH 91/96] Change SHUTTLE_WILL_ARRIVE_AT_STOP to return payload of last stop and current stop, to avoid data race --- .../shuttle/RedisShuttleRepository.ts | 5 +++- .../shuttle/ShuttleGetterRepository.ts | 7 ++++- .../UnoptimizedInMemoryShuttleRepository.ts | 9 ++++++- .../ShuttleRepositorySharedTests.test.ts | 24 ++++++++++------- .../eta/InMemorySelfUpdatingETARepository.ts | 24 +++++++++-------- .../eta/RedisSelfUpdatingETARepository.ts | 27 ++++++++++--------- 6 files changed, 60 insertions(+), 36 deletions(-) diff --git a/src/repositories/shuttle/RedisShuttleRepository.ts b/src/repositories/shuttle/RedisShuttleRepository.ts index 3cd452b..1b7ffd0 100644 --- a/src/repositories/shuttle/RedisShuttleRepository.ts +++ b/src/repositories/shuttle/RedisShuttleRepository.ts @@ -405,7 +405,10 @@ export class RedisShuttleRepository extends BaseRedisRepository implements Shutt timestamp: new Date(travelTimeTimestamp), shuttleId: shuttle.id, }; - this.emit(ShuttleRepositoryEvent.SHUTTLE_WILL_ARRIVE_AT_STOP, shuttleArrival); + this.emit(ShuttleRepositoryEvent.SHUTTLE_WILL_ARRIVE_AT_STOP, { + lastArrival: lastStop, + currentArrival: shuttleArrival, + }); await this.updateShuttleLastStopArrival(shuttleArrival); } } diff --git a/src/repositories/shuttle/ShuttleGetterRepository.ts b/src/repositories/shuttle/ShuttleGetterRepository.ts index 22118c5..7fbab0c 100644 --- a/src/repositories/shuttle/ShuttleGetterRepository.ts +++ b/src/repositories/shuttle/ShuttleGetterRepository.ts @@ -12,10 +12,15 @@ export type ShuttleRepositoryEventName = typeof ShuttleRepositoryEvent[keyof typ export type EtaRemovedEventPayload = IEta; export type EtaDataClearedEventPayload = IEta[]; +export interface WillArriveAtStopPayload { + lastArrival?: ShuttleStopArrival; + currentArrival: ShuttleStopArrival; +}; + export interface ShuttleRepositoryEventPayloads { [ShuttleRepositoryEvent.SHUTTLE_UPDATED]: IShuttle, [ShuttleRepositoryEvent.SHUTTLE_REMOVED]: IShuttle, - [ShuttleRepositoryEvent.SHUTTLE_WILL_ARRIVE_AT_STOP]: ShuttleStopArrival, + [ShuttleRepositoryEvent.SHUTTLE_WILL_ARRIVE_AT_STOP]: WillArriveAtStopPayload, } export type ShuttleRepositoryEventListener = ( diff --git a/src/repositories/shuttle/UnoptimizedInMemoryShuttleRepository.ts b/src/repositories/shuttle/UnoptimizedInMemoryShuttleRepository.ts index 068e6f7..14ba7d7 100644 --- a/src/repositories/shuttle/UnoptimizedInMemoryShuttleRepository.ts +++ b/src/repositories/shuttle/UnoptimizedInMemoryShuttleRepository.ts @@ -177,12 +177,19 @@ export class UnoptimizedInMemoryShuttleRepository const arrivedStop = await this.getArrivedStopIfExists(shuttle); if (arrivedStop != undefined) { + // stop if same stop + const lastStop = await this.getShuttleLastStopArrival(shuttle.id); + if (lastStop?.stopId === arrivedStop.id) return; + const shuttleArrival = { stopId: arrivedStop.id, timestamp: new Date(travelTimeTimestamp), shuttleId: shuttle.id, }; - this.emit(ShuttleRepositoryEvent.SHUTTLE_WILL_ARRIVE_AT_STOP, shuttleArrival); + this.emit(ShuttleRepositoryEvent.SHUTTLE_WILL_ARRIVE_AT_STOP, { + lastArrival: lastStop, + currentArrival: shuttleArrival, + }); await this.updateShuttleLastStopArrival(shuttleArrival); } } diff --git a/src/repositories/shuttle/__tests__/ShuttleRepositorySharedTests.test.ts b/src/repositories/shuttle/__tests__/ShuttleRepositorySharedTests.test.ts index e41971b..b45f2a3 100644 --- a/src/repositories/shuttle/__tests__/ShuttleRepositorySharedTests.test.ts +++ b/src/repositories/shuttle/__tests__/ShuttleRepositorySharedTests.test.ts @@ -734,9 +734,11 @@ describe.each(repositoryImplementations)('$name', (holder) => { expect(listener).toHaveBeenCalledTimes(1); const emittedPayload = listener.mock.calls[0][0] as any; - expect(emittedPayload.shuttleId).toBe(shuttle.id); - expect(emittedPayload.stopId).toBe(stop1.id); - expect(emittedPayload.timestamp.getTime()).toBe(arrivalTime.getTime()); + expect(emittedPayload.currentArrival).toEqual({ + shuttleId: shuttle.id, + stopId: stop1.id, + timestamp: arrivalTime, + }); }); test("does not emit event when shuttle is not at a stop", async () => { @@ -786,14 +788,18 @@ describe.each(repositoryImplementations)('$name', (holder) => { expect(listener).toHaveBeenCalledTimes(2); const firstPayload = listener.mock.calls[0][0] as any; - expect(firstPayload.shuttleId).toBe(shuttle.id); - expect(firstPayload.stopId).toBe(stop1.id); - expect(firstPayload.timestamp.getTime()).toBe(firstArrivalTime.getTime()); + expect(firstPayload.currentArrival).toEqual({ + shuttleId: shuttle.id, + stopId: stop1.id, + timestamp: firstArrivalTime, + }); const secondPayload = listener.mock.calls[1][0] as any; - expect(secondPayload.shuttleId).toBe(shuttle.id); - expect(secondPayload.stopId).toBe(stop2.id); - expect(secondPayload.timestamp.getTime()).toBe(secondArrivalTime.getTime()); + expect(secondPayload.currentArrival).toEqual({ + shuttleId: shuttle.id, + stopId: stop2.id, + timestamp: secondArrivalTime, + }); }); }); }); diff --git a/src/repositories/shuttle/eta/InMemorySelfUpdatingETARepository.ts b/src/repositories/shuttle/eta/InMemorySelfUpdatingETARepository.ts index bc56789..917e6bc 100644 --- a/src/repositories/shuttle/eta/InMemorySelfUpdatingETARepository.ts +++ b/src/repositories/shuttle/eta/InMemorySelfUpdatingETARepository.ts @@ -1,5 +1,5 @@ import { SelfUpdatingETARepository } from "./SelfUpdatingETARepository"; -import { ShuttleGetterRepository, ShuttleRepositoryEvent, ShuttleStopArrival, ShuttleTravelTimeDataIdentifier, ShuttleTravelTimeDateFilterArguments } from "../ShuttleGetterRepository"; +import { ShuttleGetterRepository, ShuttleRepositoryEvent, ShuttleStopArrival, ShuttleTravelTimeDataIdentifier, ShuttleTravelTimeDateFilterArguments, WillArriveAtStopPayload } from "../ShuttleGetterRepository"; import { BaseInMemoryETARepository } from "./BaseInMemoryETARepository"; import { IOrderedStop, IShuttle } from "../../../entities/ShuttleRepositoryEntities"; import { ETARepositoryEvent } from "./ETAGetterRepository"; @@ -155,26 +155,28 @@ export class InMemorySelfUpdatingETARepository extends BaseInMemoryETARepository ) } - private async handleShuttleWillArriveAtStop(shuttleArrival: ShuttleStopArrival): Promise { - const etas = await this.getEtasForShuttleId(shuttleArrival.shuttleId); + private async handleShuttleWillArriveAtStop({ + lastArrival, + currentArrival, + }: WillArriveAtStopPayload): Promise { + const etas = await this.getEtasForShuttleId(currentArrival.shuttleId); for (const eta of etas) { await this.removeEtaIfExists(eta.shuttleId, eta.stopId); } - const lastStopTimestamp = await this.shuttleRepository.getShuttleLastStopArrival(shuttleArrival.shuttleId); - if (lastStopTimestamp) { + if (lastArrival) { // disallow cases where this gets triggered multiple times - if (lastStopTimestamp.stopId === shuttleArrival.stopId) return; + if (lastArrival.stopId === currentArrival.stopId) return; - const shuttle = await this.shuttleRepository.getShuttleById(lastStopTimestamp.shuttleId); + const shuttle = await this.shuttleRepository.getShuttleById(lastArrival.shuttleId); if (!shuttle) return; const routeId = shuttle.routeId; - const fromStopId = lastStopTimestamp.stopId; - const toStopId = shuttleArrival.stopId; + const fromStopId = lastArrival.stopId; + const toStopId = currentArrival.stopId; - const travelTimeSeconds = (shuttleArrival.timestamp.getTime() - lastStopTimestamp.timestamp.getTime()) / 1000; - await this.addTravelTimeDataPoint({ routeId, fromStopId, toStopId }, travelTimeSeconds, shuttleArrival.timestamp.getTime()); + const travelTimeSeconds = (currentArrival.timestamp.getTime() - lastArrival.timestamp.getTime()) / 1000; + await this.addTravelTimeDataPoint({ routeId, fromStopId, toStopId }, travelTimeSeconds, currentArrival.timestamp.getTime()); } } diff --git a/src/repositories/shuttle/eta/RedisSelfUpdatingETARepository.ts b/src/repositories/shuttle/eta/RedisSelfUpdatingETARepository.ts index 6ce6e24..9481331 100644 --- a/src/repositories/shuttle/eta/RedisSelfUpdatingETARepository.ts +++ b/src/repositories/shuttle/eta/RedisSelfUpdatingETARepository.ts @@ -1,7 +1,7 @@ import { SelfUpdatingETARepository } from "./SelfUpdatingETARepository"; import { BaseRedisETARepository } from "./BaseRedisETARepository"; import { createClient, RedisClientType } from "redis"; -import { ShuttleGetterRepository, ShuttleRepositoryEvent, ShuttleStopArrival, ShuttleTravelTimeDataIdentifier, ShuttleTravelTimeDateFilterArguments } from "../ShuttleGetterRepository"; +import { ShuttleGetterRepository, ShuttleRepositoryEvent, ShuttleStopArrival, ShuttleTravelTimeDataIdentifier, ShuttleTravelTimeDateFilterArguments, WillArriveAtStopPayload } from "../ShuttleGetterRepository"; import { REDIS_RECONNECT_INTERVAL } from "../../../environment"; import { IEta, IOrderedStop, IShuttle } from "../../../entities/ShuttleRepositoryEntities"; import { ETARepositoryEvent } from "./ETAGetterRepository"; @@ -174,28 +174,29 @@ export class RedisSelfUpdatingETARepository extends BaseRedisETARepository imple } - private async handleShuttleWillArriveAtStop( - shuttleArrival: ShuttleStopArrival, - ) { - const etas = await this.getEtasForShuttleId(shuttleArrival.shuttleId); + private async handleShuttleWillArriveAtStop({ + lastArrival, + currentArrival, + }: WillArriveAtStopPayload) { + const etas = await this.getEtasForShuttleId(currentArrival.shuttleId); for (const eta of etas) { await this.removeEtaIfExists(eta.shuttleId, eta.stopId); } - const lastStopTimestamp = await this.shuttleRepository.getShuttleLastStopArrival(shuttleArrival.shuttleId); - if (lastStopTimestamp) { + // only update time traveled if last arrival exists + if (lastArrival) { // disallow cases where this gets triggered multiple times - if (lastStopTimestamp.stopId === shuttleArrival.stopId) return; + if (lastArrival.stopId === currentArrival.stopId) return; - const shuttle = await this.shuttleRepository.getShuttleById(lastStopTimestamp.shuttleId); + const shuttle = await this.shuttleRepository.getShuttleById(lastArrival.shuttleId); if (!shuttle) return; const routeId = shuttle.routeId; - const fromStopId = lastStopTimestamp.stopId; - const toStopId = shuttleArrival.stopId; + const fromStopId = lastArrival.stopId; + const toStopId = currentArrival.stopId; - const travelTimeSeconds = (shuttleArrival.timestamp.getTime() - lastStopTimestamp.timestamp.getTime()) / 1000; - await this.addTravelTimeDataPoint({ routeId, fromStopId, toStopId, }, travelTimeSeconds, shuttleArrival.timestamp.getTime()); + const travelTimeSeconds = (currentArrival.timestamp.getTime() - lastArrival.timestamp.getTime()) / 1000; + await this.addTravelTimeDataPoint({ routeId, fromStopId, toStopId, }, travelTimeSeconds, currentArrival.timestamp.getTime()); } } From f9ed8f16ac942b3a4964043bc2bf47981c1c82e5 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Thu, 13 Nov 2025 20:08:15 -0800 Subject: [PATCH 92/96] Bind methods and log errors for missing keys --- .../shuttle/eta/RedisSelfUpdatingETARepository.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/repositories/shuttle/eta/RedisSelfUpdatingETARepository.ts b/src/repositories/shuttle/eta/RedisSelfUpdatingETARepository.ts index 9481331..a841bc2 100644 --- a/src/repositories/shuttle/eta/RedisSelfUpdatingETARepository.ts +++ b/src/repositories/shuttle/eta/RedisSelfUpdatingETARepository.ts @@ -26,6 +26,9 @@ export class RedisSelfUpdatingETARepository extends BaseRedisETARepository imple this.startListeningForUpdates = this.startListeningForUpdates.bind(this); this.handleShuttleWillArriveAtStop = this.handleShuttleWillArriveAtStop.bind(this); this.handleShuttleUpdate = this.handleShuttleUpdate.bind(this); + this.updateCascadingEta = this.updateCascadingEta.bind(this); + this.getAverageTravelTimeSecondsWithFallbacks = this.getAverageTravelTimeSecondsWithFallbacks.bind(this); + this.removeEtaIfExists = this.removeEtaIfExists.bind(this); } private createHistoricalEtaTimeSeriesKey = (routeId: string, fromStopId: string, toStopId: string) => { @@ -63,7 +66,7 @@ export class RedisSelfUpdatingETARepository extends BaseRedisETARepository imple return; } catch (error) { - console.warn(`Failed to get average travel time: ${error instanceof Error ? error.message : String(error)}`); + console.warn(`Failed to get average travel time for ${timeSeriesKey}: ${error instanceof Error ? error.message : String(error)}`); return; } } From 2df8f389b344a9b5a8b6f9df75c807585f64db51 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Thu, 13 Nov 2025 20:23:14 -0800 Subject: [PATCH 93/96] Change the final fallback window to two hours --- .../shuttle/eta/RedisSelfUpdatingETARepository.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/repositories/shuttle/eta/RedisSelfUpdatingETARepository.ts b/src/repositories/shuttle/eta/RedisSelfUpdatingETARepository.ts index a841bc2..60e07b4 100644 --- a/src/repositories/shuttle/eta/RedisSelfUpdatingETARepository.ts +++ b/src/repositories/shuttle/eta/RedisSelfUpdatingETARepository.ts @@ -131,7 +131,7 @@ export class RedisSelfUpdatingETARepository extends BaseRedisETARepository imple const oneWeekAgo = new Date(referenceCurrentTime.getTime() - (60 * 60 * 24 * 7 * 1000)); const oneDayAgo = new Date(referenceCurrentTime.getTime() - (60 * 60 * 24 * 1000)); - const oneHourAgo = new Date(referenceCurrentTime.getTime() - (60 * 60 * 1000)); + const twoHoursAgo = new Date(referenceCurrentTime.getTime() - (120 * 60 * 1000)); const travelTimeSeconds = await this.getAverageTravelTimeSecondsWithFallbacks({ routeId: shuttle.routeId, @@ -147,7 +147,7 @@ export class RedisSelfUpdatingETARepository extends BaseRedisETARepository imple to: new Date(oneDayAgo.getTime() + (60 * 60 * 1000)) }, { - from: oneHourAgo, + from: twoHoursAgo, to: new Date(), } ]); From b74a0d5d6454359c8e3998f5568e0744469480b8 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Thu, 13 Nov 2025 22:13:17 -0800 Subject: [PATCH 94/96] Restrict stop arrival event to only next stop after shuttle's last stop, if last stop exists --- .../shuttle/RedisShuttleRepository.ts | 34 ++++++++++++++++--- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/src/repositories/shuttle/RedisShuttleRepository.ts b/src/repositories/shuttle/RedisShuttleRepository.ts index 1b7ffd0..cd90147 100644 --- a/src/repositories/shuttle/RedisShuttleRepository.ts +++ b/src/repositories/shuttle/RedisShuttleRepository.ts @@ -445,18 +445,42 @@ export class RedisShuttleRepository extends BaseRedisRepository implements Shutt } } + /** + * Get the stop that the shuttle is currently at, if it exists. + * + * If the shuttle has a "last stop", it will only return the stop + * directly after the last stop. Otherwise, it may return any stop that + * is on the shuttle's route. + * + * @param shuttle + * @param delta + * @returns + */ public async getArrivedStopIfExists( shuttle: IShuttle, delta = 0.001, ): Promise { - const orderedStops = await this.getOrderedStopsByRouteId(shuttle.routeId); + const lastStop = await this.getShuttleLastStopArrival(shuttle.id); + if (lastStop) { + const lastOrderedStop = await this.getOrderedStopByRouteAndStopId(shuttle.routeId, lastStop.stopId); + const orderedStopAfter = lastOrderedStop?.nextStop; + if (orderedStopAfter) { + const stopAfter = await this.getStopById(orderedStopAfter.stopId); + if (stopAfter && shuttleHasArrivedAtStop(shuttle, stopAfter, delta)) { + return stopAfter; + } + } + } else { + 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; + for (const orderedStop of orderedStops) { + const stop = await this.getStopById(orderedStop.stopId); + if (stop != null && shuttleHasArrivedAtStop(shuttle, stop, delta)) { + return stop; + } } } + return undefined; } From 6278d695fa78fcca24d8523dceb448465731518d Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Thu, 13 Nov 2025 22:28:07 -0800 Subject: [PATCH 95/96] Add support back in for external source ETA repositories, if we switch back later --- src/entities/InterchangeSystem.ts | 58 +++++++---- .../ApiBasedShuttleRepositoryLoader.ts | 98 ++++++++++++++++--- .../shuttle/ShuttleRepositoryLoader.ts | 2 + 3 files changed, 123 insertions(+), 35 deletions(-) diff --git a/src/entities/InterchangeSystem.ts b/src/entities/InterchangeSystem.ts index 662cfe8..985a819 100644 --- a/src/entities/InterchangeSystem.ts +++ b/src/entities/InterchangeSystem.ts @@ -22,6 +22,7 @@ import { RedisSelfUpdatingETARepository } from "../repositories/shuttle/eta/Redi import { RedisExternalSourceETARepository } from "../repositories/shuttle/eta/RedisExternalSourceETARepository"; import { InMemorySelfUpdatingETARepository } from "../repositories/shuttle/eta/InMemorySelfUpdatingETARepository"; import { BaseRedisETARepository } from "../repositories/shuttle/eta/BaseRedisETARepository"; +import { BaseInMemoryETARepository } from "../repositories/shuttle/eta/BaseInMemoryETARepository"; export interface InterchangeSystemBuilderArguments { name: string; @@ -99,24 +100,33 @@ export class InterchangeSystem { private static async buildRedisShuttleLoaderAndRepositories(args: InterchangeSystemBuilderArguments) { const shuttleRepository = new RedisShuttleRepository(); await shuttleRepository.connect(); - const shuttleDataLoader = new ApiBasedShuttleRepositoryLoader( - args.passioSystemId, - args.id, - shuttleRepository - ); - const timedShuttleDataLoader = new TimedApiBasedRepositoryLoader( - shuttleDataLoader - ); let etaRepository: BaseRedisETARepository; + let shuttleDataLoader: ApiBasedShuttleRepositoryLoader; if (args.useSelfUpdatingEtas) { etaRepository = new RedisSelfUpdatingETARepository(shuttleRepository); (etaRepository as RedisSelfUpdatingETARepository).startListeningForUpdates(); + shuttleDataLoader = new ApiBasedShuttleRepositoryLoader( + args.passioSystemId, + args.id, + shuttleRepository, + ); } else { etaRepository = new RedisExternalSourceETARepository(); + shuttleDataLoader = new ApiBasedShuttleRepositoryLoader( + args.passioSystemId, + args.id, + shuttleRepository, + etaRepository as RedisExternalSourceETARepository, + ); } await etaRepository.connect(); + const timedShuttleDataLoader = new TimedApiBasedRepositoryLoader( + shuttleDataLoader, + ); + + return { shuttleRepository, etaRepository, timedShuttleDataLoader }; } @@ -238,23 +248,33 @@ export class InterchangeSystem { private static buildInMemoryShuttleLoaderAndRepositories(args: InterchangeSystemBuilderArguments) { const shuttleRepository = new UnoptimizedInMemoryShuttleRepository(); - const shuttleDataLoader = new ApiBasedShuttleRepositoryLoader( - args.passioSystemId, - args.id, - shuttleRepository - ); + + let etaRepository: BaseInMemoryETARepository; + let shuttleDataLoader: ApiBasedShuttleRepositoryLoader; + if (args.useSelfUpdatingEtas) { + etaRepository = new InMemorySelfUpdatingETARepository(shuttleRepository); + (etaRepository as InMemorySelfUpdatingETARepository).startListeningForUpdates(); + shuttleDataLoader = new ApiBasedShuttleRepositoryLoader( + args.passioSystemId, + args.id, + shuttleRepository, + ); + } else { + etaRepository = new InMemoryExternalSourceETARepository(); + shuttleDataLoader = new ApiBasedShuttleRepositoryLoader( + args.passioSystemId, + args.id, + shuttleRepository, + etaRepository as InMemoryExternalSourceETARepository, + ); + } + // Note that this loader should not be started, // so the test data doesn't get overwritten const timedShuttleLoader = new TimedApiBasedRepositoryLoader( shuttleDataLoader ); - let etaRepository: ETAGetterRepository; - if (args.useSelfUpdatingEtas) { - etaRepository = new InMemorySelfUpdatingETARepository(shuttleRepository); - } else { - etaRepository = new InMemoryExternalSourceETARepository(); - } return { shuttleRepository, etaRepository, timedShuttleLoader }; } diff --git a/src/loaders/shuttle/ApiBasedShuttleRepositoryLoader.ts b/src/loaders/shuttle/ApiBasedShuttleRepositoryLoader.ts index 4d50dcf..19d4a14 100644 --- a/src/loaders/shuttle/ApiBasedShuttleRepositoryLoader.ts +++ b/src/loaders/shuttle/ApiBasedShuttleRepositoryLoader.ts @@ -1,9 +1,10 @@ import { ShuttleGetterSetterRepository } from "../../repositories/shuttle/ShuttleGetterSetterRepository"; -import { IRoute, IShuttle, IStop } from "../../entities/ShuttleRepositoryEntities"; +import { IEta, IRoute, IShuttle, IStop } from "../../entities/ShuttleRepositoryEntities"; import { ShuttleRepositoryLoader } from "./ShuttleRepositoryLoader"; import { ICoordinates, IEntityWithId } from "../../entities/SharedEntities"; import { ApiResponseError } from "../ApiResponseError"; import { SHUTTLE_TO_ROUTE_COORDINATE_MAXIMUM_DISTANCE_MILES } from "../../environment"; +import { ExternalSourceETARepository } from "../../repositories/shuttle/eta/ExternalSourceETARepository"; /** * Class which can load data into a repository from the @@ -16,7 +17,8 @@ export class ApiBasedShuttleRepositoryLoader implements ShuttleRepositoryLoader constructor( public passioSystemId: string, public systemIdForConstructedData: string, - public repository: ShuttleGetterSetterRepository, + public shuttleRepository: ShuttleGetterSetterRepository, + public etaRepository?: ExternalSourceETARepository, readonly shuttleToRouteCoordinateMaximumDistanceMiles = SHUTTLE_TO_ROUTE_COORDINATE_MAXIMUM_DISTANCE_MILES, ) { } @@ -34,6 +36,10 @@ export class ApiBasedShuttleRepositoryLoader implements ShuttleRepositoryLoader await this.updateRouteDataForSystem(); await this.updateStopAndPolylineDataForRoutesInSystem(); await this.updateShuttleDataForSystemBasedOnProximityToRoutes(); + + // Because ETA method doesn't support pruning yet, + // add a call to the clear method here + await this.updateEtaDataForExistingStopsForSystem(); } public async updateRouteDataForSystem() { @@ -52,16 +58,16 @@ export class ApiBasedShuttleRepositoryLoader implements ShuttleRepositoryLoader private async updateRouteDataInRepository(routes: IRoute[]) { const routeIdsToPrune = await this.constructExistingEntityIdSet(async () => { - return await this.repository.getRoutes(); + return await this.shuttleRepository.getRoutes(); }); await Promise.all(routes.map(async (route) => { - await this.repository.addOrUpdateRoute(route); + await this.shuttleRepository.addOrUpdateRoute(route); routeIdsToPrune.delete(route.id); })); await Promise.all(Array.from(routeIdsToPrune).map(async (routeId) => { - await this.repository.removeRouteIfExists(routeId); + await this.shuttleRepository.removeRouteIfExists(routeId); })); } @@ -117,7 +123,7 @@ export class ApiBasedShuttleRepositoryLoader implements ShuttleRepositoryLoader private async updateStopAndPolylineDataInRepository(json: any) { const stopIdsToPrune = await this.constructExistingEntityIdSet(async () => { - return await this.repository.getStops(); + return await this.shuttleRepository.getStops(); }); await this.updateStopDataForSystemAndApiResponse(json, stopIdsToPrune); @@ -125,7 +131,7 @@ export class ApiBasedShuttleRepositoryLoader implements ShuttleRepositoryLoader await this.updatePolylineDataForExistingRoutesAndApiResponse(json); await Promise.all(Array.from(stopIdsToPrune).map(async (stopId) => { - await this.repository.removeStopIfExists(stopId); + await this.shuttleRepository.removeStopIfExists(stopId); })); } @@ -169,16 +175,16 @@ export class ApiBasedShuttleRepositoryLoader implements ShuttleRepositoryLoader private async updateShuttleDataInRepository(shuttles: IShuttle[]) { const shuttleIdsToPrune = await this.constructExistingEntityIdSet(async () => { - return await this.repository.getShuttles(); + return await this.shuttleRepository.getShuttles(); }); await Promise.all(shuttles.map(async (shuttle) => { - await this.repository.addOrUpdateShuttle(shuttle); + await this.shuttleRepository.addOrUpdateShuttle(shuttle); shuttleIdsToPrune.delete(shuttle.id); })); await Promise.all(Array.from(shuttleIdsToPrune).map(async (shuttleId) => { - await this.repository.removeShuttleIfExists(shuttleId); + await this.shuttleRepository.removeShuttleIfExists(shuttleId); })); } @@ -233,6 +239,66 @@ export class ApiBasedShuttleRepositoryLoader implements ShuttleRepositoryLoader return null; } + public async updateEtaDataForExistingStopsForSystem() { + const stops = await this.shuttleRepository.getStops(); + await Promise.all(stops.map(async (stop) => { + let stopId = stop.id; + await this.updateEtaDataForStopId(stopId); + })); + } + + public async updateEtaDataForStopId(stopId: string) { + try { + const json = await this.fetchEtaDataJson(stopId); + const etas = this.constructEtasFromJson(json, stopId); + if (etas !== null) { + await this.updateEtaDataInRepository(etas); + } else { + console.warn(`ETA update failed for stop ${stopId} with the following JSON: ${JSON.stringify(json)}`); + } + } catch(e: any) { + throw new ApiResponseError(e.message); + } + } + + private async updateEtaDataInRepository(etas: IEta[]) { + await Promise.all(etas.map(async (eta) => { + await this.etaRepository?.addOrUpdateEtaFromExternalSource(eta); + })); + } + + private async fetchEtaDataJson(stopId: string) { + const params = { + eta: "3", + stopIds: stopId, + }; + + const query = new URLSearchParams(params).toString(); + + const response = await fetch(`${this.baseUrl}?${query}`, { + method: "GET", + }); + return await response.json(); + } + + private constructEtasFromJson(json: any, stopId: string): IEta[] | null { + if (json.ETAs && json.ETAs[stopId]) { + return json.ETAs[stopId].map((jsonEta: any) => { + const shuttleId: string = jsonEta.busId; + const eta: IEta = { + secondsRemaining: jsonEta.secondsSpent, + shuttleId: `${shuttleId}`, + stopId: stopId, + updatedTime: new Date(), + systemId: this.systemIdForConstructedData, + }; + return eta; + }); + } + + return null; + } + protected async updateStopDataForSystemAndApiResponse( json: any, setOfIdsToPrune: Set = new Set(), @@ -252,7 +318,7 @@ export class ApiBasedShuttleRepositoryLoader implements ShuttleRepositoryLoader updatedTime: new Date(), }; - await this.repository.addOrUpdateStop(constructedStop); + await this.shuttleRepository.addOrUpdateStop(constructedStop); setOfIdsToPrune.delete(constructedStop.id); })); @@ -274,7 +340,7 @@ export class ApiBasedShuttleRepositoryLoader implements ShuttleRepositoryLoader const orderedStopDataArray = jsonOrderedStopData[index]; const stopId = orderedStopDataArray[1]; - let constructedOrderedStop = await this.repository.getOrderedStopByRouteAndStopId(routeId, stopId) + let constructedOrderedStop = await this.shuttleRepository.getOrderedStopByRouteAndStopId(routeId, stopId) if (constructedOrderedStop === null) { constructedOrderedStop = { routeId, @@ -304,7 +370,7 @@ export class ApiBasedShuttleRepositoryLoader implements ShuttleRepositoryLoader }; } - await this.repository.addOrUpdateOrderedStop(constructedOrderedStop); + await this.shuttleRepository.addOrUpdateOrderedStop(constructedOrderedStop); } })); } @@ -315,7 +381,7 @@ export class ApiBasedShuttleRepositoryLoader implements ShuttleRepositoryLoader await Promise.all(Object.keys(json.routePoints).map(async (routeId) => { const routePoints = json.routePoints[routeId][0]; - const existingRoute = await this.repository.getRouteById(routeId); + const existingRoute = await this.shuttleRepository.getRouteById(routeId); if (!existingRoute) return; existingRoute.polylineCoordinates = routePoints.map((point: any) => { @@ -325,7 +391,7 @@ export class ApiBasedShuttleRepositoryLoader implements ShuttleRepositoryLoader }; }); - await this.repository.addOrUpdateRoute(existingRoute); + await this.shuttleRepository.addOrUpdateRoute(existingRoute); })) } } @@ -334,7 +400,7 @@ export class ApiBasedShuttleRepositoryLoader implements ShuttleRepositoryLoader let filteredShuttles: IShuttle[] = []; await Promise.all(shuttles.map(async (shuttle) => { - const route = await this.repository.getRouteById(shuttle.routeId); + const route = await this.shuttleRepository.getRouteById(shuttle.routeId); if (route != null) { let closestDistanceMiles = Number.MAX_VALUE; diff --git a/src/loaders/shuttle/ShuttleRepositoryLoader.ts b/src/loaders/shuttle/ShuttleRepositoryLoader.ts index ea67d22..d556455 100644 --- a/src/loaders/shuttle/ShuttleRepositoryLoader.ts +++ b/src/loaders/shuttle/ShuttleRepositoryLoader.ts @@ -4,4 +4,6 @@ export interface ShuttleRepositoryLoader extends RepositoryLoader { updateRouteDataForSystem(): Promise; updateStopAndPolylineDataForRoutesInSystem(): Promise; updateShuttleDataForSystemBasedOnProximityToRoutes(): Promise; + updateEtaDataForExistingStopsForSystem(): Promise; + updateEtaDataForStopId(stopId: string): Promise; } From a89d3c7ae38274d6dbf99eedb81659d7183aaebe Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Thu, 13 Nov 2025 22:36:13 -0800 Subject: [PATCH 96/96] Fix test references --- ...iBasedShuttleRepositoryLoaderTests.test.ts | 26 +++++++++++-------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/src/loaders/shuttle/__tests__/ApiBasedShuttleRepositoryLoaderTests.test.ts b/src/loaders/shuttle/__tests__/ApiBasedShuttleRepositoryLoaderTests.test.ts index f215b4c..ff98c40 100644 --- a/src/loaders/shuttle/__tests__/ApiBasedShuttleRepositoryLoaderTests.test.ts +++ b/src/loaders/shuttle/__tests__/ApiBasedShuttleRepositoryLoaderTests.test.ts @@ -9,6 +9,7 @@ import { generateMockRoutes, generateMockShuttles, generateMockStops } from "../ import { fetchShuttleDataSuccessfulResponse } from "../../../../testHelpers/jsonSnapshots/fetchShuttleData/fetchShuttleDataSuccessfulResponse"; +import { InMemoryExternalSourceETARepository } from "../../../repositories/shuttle/eta/InMemoryExternalSourceETARepository"; import { resetGlobalFetchMockJson, updateGlobalFetchMockJson, @@ -58,7 +59,7 @@ describe("ApiBasedShuttleRepositoryLoader", () => { const routesToPrune = generateMockRoutes(); await Promise.all(routesToPrune.map(async (route) => { route.systemId = systemId; - await loader.repository.addOrUpdateRoute(route); + await loader.shuttleRepository.addOrUpdateRoute(route); })); updateGlobalFetchMockJson(fetchRouteDataSuccessfulResponse); @@ -67,7 +68,7 @@ describe("ApiBasedShuttleRepositoryLoader", () => { await loader.updateRouteDataForSystem(); // Assert - const routes = await loader.repository.getRoutes(); + const routes = await loader.shuttleRepository.getRoutes(); expect(routes.length).toEqual(fetchRouteDataSuccessfulResponse.all.length) }); @@ -91,7 +92,7 @@ describe("ApiBasedShuttleRepositoryLoader", () => { const stopsToPrune = generateMockStops(); await Promise.all(stopsToPrune.map(async (stop) => { stop.systemId = systemId; - await loader.repository.addOrUpdateStop(stop); + await loader.shuttleRepository.addOrUpdateStop(stop); })); updateGlobalFetchMockJson(fetchStopAndPolylineDataSuccessfulResponse); @@ -100,15 +101,15 @@ describe("ApiBasedShuttleRepositoryLoader", () => { await loader.updateStopAndPolylineDataForRoutesInSystem(); - const stops = await loader.repository.getStops(); + const stops = await loader.shuttleRepository.getStops(); expect(stops.length).toEqual(stopsArray.length); await Promise.all(stops.map(async (stop) => { - const orderedStops = await loader.repository.getOrderedStopsByStopId(stop.id) + const orderedStops = await loader.shuttleRepository.getOrderedStopsByStopId(stop.id) expect(orderedStops.length).toBeGreaterThan(0); })); - const routes = await loader.repository.getRoutes(); + const routes = await loader.shuttleRepository.getRoutes(); routes.forEach((route) => { expect(route.polylineCoordinates.length).toBeGreaterThan(0); }); @@ -150,7 +151,7 @@ describe("ApiBasedShuttleRepositoryLoader", () => { async function addMockRoutes(routes: IRoute[]) { await Promise.all(routes.map(async (route) => { - await loader.repository.addOrUpdateRoute(route); + await loader.shuttleRepository.addOrUpdateRoute(route); })); } @@ -160,6 +161,7 @@ describe("ApiBasedShuttleRepositoryLoader", () => { "263", "1", new UnoptimizedInMemoryShuttleRepository(), + new InMemoryExternalSourceETARepository(), distanceMiles, ); @@ -175,7 +177,7 @@ describe("ApiBasedShuttleRepositoryLoader", () => { await loader.updateShuttleDataForSystemBasedOnProximityToRoutes(); - const shuttles = await loader.repository.getShuttles(); + const shuttles = await loader.shuttleRepository.getShuttles(); expect(shuttles.length).toEqual(busesInResponse.length); }); @@ -185,6 +187,7 @@ describe("ApiBasedShuttleRepositoryLoader", () => { "263", "1", new UnoptimizedInMemoryShuttleRepository(), + new InMemoryExternalSourceETARepository(), distanceMiles, ); @@ -200,7 +203,7 @@ describe("ApiBasedShuttleRepositoryLoader", () => { await loader.updateShuttleDataForSystemBasedOnProximityToRoutes(); - const shuttles = await loader.repository.getShuttles(); + const shuttles = await loader.shuttleRepository.getShuttles(); expect(shuttles.length).toEqual(0); }); @@ -210,6 +213,7 @@ describe("ApiBasedShuttleRepositoryLoader", () => { "263", "1", new UnoptimizedInMemoryShuttleRepository(), + new InMemoryExternalSourceETARepository(), distanceMiles, ); @@ -217,7 +221,7 @@ describe("ApiBasedShuttleRepositoryLoader", () => { const shuttlesToPrune = generateMockShuttles(); await Promise.all(shuttlesToPrune.map(async (shuttle) => { shuttle.systemId = systemId; - await loader.repository.addOrUpdateShuttle(shuttle); + await loader.shuttleRepository.addOrUpdateShuttle(shuttle); })); const routes = generateMockRoutesWithPolylineCoordinates(); @@ -235,7 +239,7 @@ describe("ApiBasedShuttleRepositoryLoader", () => { await loader.updateShuttleDataForSystemBasedOnProximityToRoutes(); // Old shuttles should be pruned, only API shuttles should remain - const shuttles = await loader.repository.getShuttles(); + const shuttles = await loader.shuttleRepository.getShuttles(); const busesInResponse = Object.values(modifiedSuccessfulResponse.buses); expect(shuttles.length).toEqual(busesInResponse.length);