10 Commits

Author SHA1 Message Date
9eeac3cffa Remove the migrator utility
It's easier to migrate with a one-time script than bundle it with the codebase. To modify production, pull down the Docker volume there and run the modifications locally before pushing it back up.
2026-03-27 18:34:40 -07:00
6d66e8f25b Add system ID prefix to all Redis keys to prevent cross-system ID clashes
When multiple university systems share the same Redis instance, entity IDs
(shuttles, stops, routes, etc.) could collide. This namespaces all Redis
keys with the system ID (e.g., `1:shuttle:stop:123` instead of
`shuttle:stop:123`).

- Add systemId field and prefixKey() helper to BaseRedisRepository
- Update all Redis repository subclasses to use prefixed keys
- Wire system ID from InterchangeSystem.build() into Redis repositories
- Add migration utility (migrateRedisKeysToSystemPrefix) with tests
- Update all test holders to pass a test system ID

https://claude.ai/code/session_012Vfz1NHWJbVtoDEWcE5tq6
2026-03-23 23:17:13 +00:00
4764ee6af0 Merge pull request #106 from brendan-ch/claude/add-initial-region-loading-jfNcI 2026-03-23 15:23:47 -07:00
44d2da45d1 Update Chapman region to wider campus bounds
https://claude.ai/code/session_0162emnCi1KxW5FkTNn2ZrvH
2026-03-23 22:08:57 +00:00
fe649c81eb Change region to use top-left and bottom-right coordinates
Replace center+delta region representation with a bounding box defined
by topLeft and bottomRight coordinate pairs, which maps more directly
to the visible map area.

https://claude.ai/code/session_0162emnCi1KxW5FkTNn2ZrvH
2026-03-23 22:05:57 +00:00
946f8dd342 Add initial region loading for supported systems
Expose an initialRegion field on the System GraphQL type so the app can
display the correct campus area on first load instead of a zoomed-out
default map view. Chapman University is configured with coordinates
centered on campus.

https://claude.ai/code/session_0162emnCi1KxW5FkTNn2ZrvH
2026-03-23 22:02:42 +00:00
690b2e1559 Merge pull request #105 from brendan-ch:chore/update-caddy-config
chore/update-caddy-config
2026-02-25 16:42:23 -08:00
78f20fa925 Remove the Caddyfile and expose port 4000 locally 2026-02-25 16:40:43 -08:00
90d993736d Merge pull request #104 from brendan-ch/chore/patch-deploy-workflow-1
chore/patch-deploy-workflow
2026-02-23 12:40:31 -08:00
4d79b842f5 Set Docker host for deployment workflow 2026-02-23 12:39:59 -08:00
22 changed files with 142 additions and 50 deletions

View File

@@ -54,6 +54,7 @@ jobs:
export RATE_LIMIT_WINDOW_MS=${{ secrets.RATE_LIMIT_WINDOW_MS }} export RATE_LIMIT_WINDOW_MS=${{ secrets.RATE_LIMIT_WINDOW_MS }}
export RATE_LIMIT_DELAY_AFTER_REQUESTS=${{ secrets.RATE_LIMIT_DELAY_AFTER_REQUESTS }} export RATE_LIMIT_DELAY_AFTER_REQUESTS=${{ secrets.RATE_LIMIT_DELAY_AFTER_REQUESTS }}
export RATE_LIMIT_DELAY_MULTIPLIER_MS=${{ secrets.RATE_LIMIT_DELAY_MULTIPLIER_MS }} export RATE_LIMIT_DELAY_MULTIPLIER_MS=${{ secrets.RATE_LIMIT_DELAY_MULTIPLIER_MS }}
export DOCKER_HOST=unix://$XDG_RUNTIME_DIR/docker.sock
docker compose -f docker-compose.prod.yml down --remove-orphans docker compose -f docker-compose.prod.yml down --remove-orphans
docker compose -f docker-compose.prod.yml up -d --build docker compose -f docker-compose.prod.yml up -d --build
EOF EOF

View File

@@ -1,4 +0,0 @@
interchange-api.bchen.dev {
reverse_proxy app:4000
}

View File

@@ -19,6 +19,8 @@ services:
build: . build: .
command: npm run start command: npm run start
restart: unless-stopped restart: unless-stopped
ports:
- "4000:4000"
depends_on: depends_on:
- redis - redis
environment: environment:
@@ -34,18 +36,6 @@ services:
- ./redis-stack.conf:/redis-stack.conf - ./redis-stack.conf:/redis-stack.conf
command: redis-stack-server /redis-stack.conf command: redis-stack-server /redis-stack.conf
caddy:
image: caddy:2-alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
- caddy_config:/config
volumes: volumes:
redis_data: redis_data:
caddy_data: caddy_data:

View File

@@ -6,6 +6,7 @@ scalar DateTime
type System { type System {
id: ID! id: ID!
name: String! name: String!
initialRegion: Region
routes: [Route!] routes: [Route!]
route(id: ID): Route route(id: ID): Route
stops: [Stop!] stops: [Stop!]
@@ -16,6 +17,11 @@ type System {
parkingSystem: ParkingSystem parkingSystem: ParkingSystem
} }
type Region {
topLeft: Coordinates!
bottomRight: Coordinates!
}
type ParkingSystem { type ParkingSystem {
systemId: ID! systemId: ID!
parkingStructures: [ParkingStructure!] parkingStructures: [ParkingStructure!]

View File

@@ -19,6 +19,7 @@ import { InMemoryExternalSourceETARepository } from "../repositories/shuttle/eta
import { ETAGetterRepository } from "../repositories/shuttle/eta/ETAGetterRepository"; import { ETAGetterRepository } from "../repositories/shuttle/eta/ETAGetterRepository";
import { InMemorySelfUpdatingETARepository } from "../repositories/shuttle/eta/InMemorySelfUpdatingETARepository"; import { InMemorySelfUpdatingETARepository } from "../repositories/shuttle/eta/InMemorySelfUpdatingETARepository";
import { BaseInMemoryETARepository } from "../repositories/shuttle/eta/BaseInMemoryETARepository"; import { BaseInMemoryETARepository } from "../repositories/shuttle/eta/BaseInMemoryETARepository";
import { IRegion } from "./SharedEntities";
export interface InterchangeSystemBuilderArguments { export interface InterchangeSystemBuilderArguments {
name: string; name: string;
@@ -49,6 +50,12 @@ export interface InterchangeSystemBuilderArguments {
* at a stop, in latitude/longitude degrees. * at a stop, in latitude/longitude degrees.
*/ */
shuttleStopArrivalDegreeDelta: number; shuttleStopArrivalDegreeDelta: number;
/**
* The initial map region to display when the app first loads
* this system. Represents a center coordinate and span.
*/
initialRegion?: IRegion;
} }
export class InterchangeSystem { export class InterchangeSystem {
@@ -62,6 +69,7 @@ export class InterchangeSystem {
public notificationRepository: NotificationRepository, public notificationRepository: NotificationRepository,
public parkingTimedDataLoader: TimedApiBasedRepositoryLoader | null, public parkingTimedDataLoader: TimedApiBasedRepositoryLoader | null,
public parkingRepository: ParkingGetterSetterRepository | null, public parkingRepository: ParkingGetterSetterRepository | null,
public initialRegion: IRegion | null,
) { ) {
} }
@@ -83,7 +91,7 @@ export class InterchangeSystem {
); );
notificationScheduler.startListeningForUpdates(); notificationScheduler.startListeningForUpdates();
let { parkingRepository, timedParkingLoader } = await InterchangeSystem.buildRedisParkingLoaderAndRepository(args.parkingSystemId); let { parkingRepository, timedParkingLoader } = await InterchangeSystem.buildRedisParkingLoaderAndRepository(args.parkingSystemId, args.id);
timedParkingLoader?.start(); timedParkingLoader?.start();
return new InterchangeSystem( return new InterchangeSystem(
@@ -96,6 +104,7 @@ export class InterchangeSystem {
notificationRepository, notificationRepository,
timedParkingLoader, timedParkingLoader,
parkingRepository, parkingRepository,
args.initialRegion ?? null,
); );
} }
@@ -136,7 +145,7 @@ export class InterchangeSystem {
shuttleRepository: ShuttleGetterRepository, shuttleRepository: ShuttleGetterRepository,
args: InterchangeSystemBuilderArguments args: InterchangeSystemBuilderArguments
) { ) {
const notificationRepository = new RedisNotificationRepository(); const notificationRepository = new RedisNotificationRepository(undefined, args.id);
await notificationRepository.connect(); await notificationRepository.connect();
const notificationScheduler = new ETANotificationScheduler( const notificationScheduler = new ETANotificationScheduler(
etaRepository, etaRepository,
@@ -148,12 +157,12 @@ export class InterchangeSystem {
return { notificationScheduler, notificationRepository }; return { notificationScheduler, notificationRepository };
} }
private static async buildRedisParkingLoaderAndRepository(id?: string) { private static async buildRedisParkingLoaderAndRepository(id?: string, systemId: string = '') {
if (id === undefined) { if (id === undefined) {
return { parkingRepository: null, timedParkingLoader: null }; return { parkingRepository: null, timedParkingLoader: null };
} }
let parkingRepository: RedisParkingRepository | null = new RedisParkingRepository(); let parkingRepository: RedisParkingRepository | null = new RedisParkingRepository(undefined, systemId);
await parkingRepository.connect(); await parkingRepository.connect();
const loaderBuilderArguments: ParkingRepositoryLoaderBuilderArguments = { const loaderBuilderArguments: ParkingRepositoryLoaderBuilderArguments = {
@@ -205,6 +214,7 @@ export class InterchangeSystem {
notificationRepository, notificationRepository,
timedParkingLoader, timedParkingLoader,
parkingRepository, parkingRepository,
args.initialRegion ?? null,
); );
} }

View File

@@ -11,3 +11,8 @@ export interface ICoordinates {
longitude: number; longitude: number;
} }
export interface IRegion {
topLeft: ICoordinates;
bottomRight: ICoordinates;
}

View File

@@ -25,6 +25,10 @@ const supportedSystems: InterchangeSystemBuilderArguments[] = [
name: "Chapman University", name: "Chapman University",
useSelfUpdatingEtas: true, useSelfUpdatingEtas: true,
shuttleStopArrivalDegreeDelta: 0.001, shuttleStopArrivalDegreeDelta: 0.001,
initialRegion: {
topLeft: { latitude: 33.85733, longitude: -117.89553 },
bottomRight: { latitude: 33.73970, longitude: -117.81878 },
},
} }
] ]

View File

@@ -4,17 +4,23 @@ import createRedisClientForRepository from '../helpers/createRedisClientForRepos
export abstract class BaseRedisRepository extends EventEmitter { export abstract class BaseRedisRepository extends EventEmitter {
protected redisClient; protected redisClient;
protected readonly systemId: string;
constructor( constructor(
redisClient: RedisClientType = createRedisClientForRepository(), redisClient: RedisClientType = createRedisClientForRepository(),
systemId: string = '',
) { ) {
super(); super();
this.redisClient = redisClient; this.redisClient = redisClient;
this.systemId = systemId;
this.redisClient.on('error', (err) => { this.redisClient.on('error', (err) => {
console.error(err.stack); console.error(err.stack);
}); });
} }
protected prefixKey = (key: string): string =>
this.systemId ? `${this.systemId}:${key}` : key;
get isReady() { get isReady() {
return this.redisClient.isReady; return this.redisClient.isReady;
} }

View File

@@ -1,3 +1,4 @@
import { RedisClientType } from 'redis';
import { TupleKey } from '../../types/TupleKey'; import { TupleKey } from '../../types/TupleKey';
import { import {
Listener, Listener,
@@ -7,14 +8,22 @@ import {
ScheduledNotification ScheduledNotification
} from "./NotificationRepository"; } from "./NotificationRepository";
import { BaseRedisRepository } from "../BaseRedisRepository"; import { BaseRedisRepository } from "../BaseRedisRepository";
import createRedisClientForRepository from '../../helpers/createRedisClientForRepository';
export class RedisNotificationRepository extends BaseRedisRepository implements NotificationRepository { export class RedisNotificationRepository extends BaseRedisRepository implements NotificationRepository {
private notificationListeners: Listener[] = []; private notificationListeners: Listener[] = [];
private readonly NOTIFICATION_KEY_PREFIX = 'notification:'; private readonly NOTIFICATION_KEY_PREFIX = 'notification:';
constructor(
redisClient: RedisClientType = createRedisClientForRepository(),
systemId: string = '',
) {
super(redisClient, systemId);
}
private getNotificationKey = (shuttleId: string, stopId: string): string => { private getNotificationKey = (shuttleId: string, stopId: string): string => {
const tuple = new TupleKey(shuttleId, stopId); const tuple = new TupleKey(shuttleId, stopId);
return `${this.NOTIFICATION_KEY_PREFIX}${tuple.toString()}`; return this.prefixKey(`${this.NOTIFICATION_KEY_PREFIX}${tuple.toString()}`);
}; };
public addOrUpdateNotification = async (notification: ScheduledNotification): Promise<void> => { public addOrUpdateNotification = async (notification: ScheduledNotification): Promise<void> => {

View File

@@ -28,7 +28,7 @@ class RedisNotificationRepositoryHolder implements RepositoryHolder {
url: process.env.REDIS_URL, url: process.env.REDIS_URL,
}); });
await this.redisClient.connect(); await this.redisClient.connect();
this.repo = new RedisNotificationRepository(this.redisClient); this.repo = new RedisNotificationRepository(this.redisClient, 'test-system');
return this.repo; return this.repo;
} }
teardown = async () => { teardown = async () => {

View File

@@ -3,6 +3,8 @@ import { IParkingStructure } from "../../entities/ParkingRepositoryEntities";
import { HistoricalParkingAverageQueryResult, HistoricalParkingAverageFilterArguments } from "./ParkingGetterRepository"; import { HistoricalParkingAverageQueryResult, HistoricalParkingAverageFilterArguments } from "./ParkingGetterRepository";
import { BaseRedisRepository } from "../BaseRedisRepository"; import { BaseRedisRepository } from "../BaseRedisRepository";
import { PARKING_LOGGING_INTERVAL_MS } from "../../environment"; import { PARKING_LOGGING_INTERVAL_MS } from "../../environment";
import { RedisClientType } from "redis";
import createRedisClientForRepository from "../../helpers/createRedisClientForRepository";
export type ParkingStructureID = string; export type ParkingStructureID = string;
@@ -10,6 +12,13 @@ export class RedisParkingRepository extends BaseRedisRepository implements Parki
private dataLastAdded: Map<ParkingStructureID, Date> = new Map(); private dataLastAdded: Map<ParkingStructureID, Date> = new Map();
private loggingIntervalMs = PARKING_LOGGING_INTERVAL_MS; private loggingIntervalMs = PARKING_LOGGING_INTERVAL_MS;
constructor(
redisClient: RedisClientType = createRedisClientForRepository(),
systemId: string = '',
) {
super(redisClient, systemId);
}
addOrUpdateParkingStructure = async (structure: IParkingStructure): Promise<void> => { addOrUpdateParkingStructure = async (structure: IParkingStructure): Promise<void> => {
const keys = this.createRedisKeys(structure.id); const keys = this.createRedisKeys(structure.id);
await this.redisClient.hSet(keys.structure, this.createRedisHashFromStructure(structure)); await this.redisClient.hSet(keys.structure, this.createRedisHashFromStructure(structure));
@@ -28,8 +37,8 @@ export class RedisParkingRepository extends BaseRedisRepository implements Parki
}; };
clearParkingStructureData = async (): Promise<void> => { clearParkingStructureData = async (): Promise<void> => {
const structureKeys = await this.redisClient.keys('parking:structure:*'); const structureKeys = await this.redisClient.keys(this.prefixKey('parking:structure:*'));
const timeSeriesKeys = await this.redisClient.keys('parking:timeseries:*'); const timeSeriesKeys = await this.redisClient.keys(this.prefixKey('parking:timeseries:*'));
const allKeys = [...structureKeys, ...timeSeriesKeys]; const allKeys = [...structureKeys, ...timeSeriesKeys];
if (allKeys.length > 0) { if (allKeys.length > 0) {
@@ -51,7 +60,7 @@ export class RedisParkingRepository extends BaseRedisRepository implements Parki
}; };
getParkingStructures = async (): Promise<IParkingStructure[]> => { getParkingStructures = async (): Promise<IParkingStructure[]> => {
const keys = await this.redisClient.keys('parking:structure:*'); const keys = await this.redisClient.keys(this.prefixKey('parking:structure:*'));
const structures: IParkingStructure[] = []; const structures: IParkingStructure[] = [];
for (const key of keys) { for (const key of keys) {
@@ -80,8 +89,8 @@ export class RedisParkingRepository extends BaseRedisRepository implements Parki
}; };
private createRedisKeys = (structureId: string) => ({ private createRedisKeys = (structureId: string) => ({
structure: `parking:structure:${structureId}`, structure: this.prefixKey(`parking:structure:${structureId}`),
timeSeries: `parking:timeseries:${structureId}` timeSeries: this.prefixKey(`parking:timeseries:${structureId}`),
}); });
private createRedisHashFromStructure = (structure: IParkingStructure): Record<string, string> => ({ private createRedisHashFromStructure = (structure: IParkingStructure): Record<string, string> => ({

View File

@@ -25,7 +25,7 @@ class RedisParkingRepositoryHolder implements RepositoryHolder<ParkingGetterSett
url: process.env.REDIS_URL, url: process.env.REDIS_URL,
}); });
await this.redisClient.connect(); await this.redisClient.connect();
this.repo = new RedisParkingRepository(this.redisClient); this.repo = new RedisParkingRepository(this.redisClient, 'test-system');
return this.repo; return this.repo;
}; };
teardown = async () => { teardown = async () => {

View File

@@ -17,8 +17,9 @@ export class RedisShuttleRepository extends BaseRedisRepository implements Shutt
constructor( constructor(
redisClient: RedisClientType = createRedisClientForRepository(), redisClient: RedisClientType = createRedisClientForRepository(),
readonly shuttleStopArrivalDegreeDelta: number = 0.001, readonly shuttleStopArrivalDegreeDelta: number = 0.001,
systemId: string = '',
) { ) {
super(redisClient); super(redisClient, systemId);
} }
get isReady() { get isReady() {
@@ -83,24 +84,24 @@ export class RedisShuttleRepository extends BaseRedisRepository implements Shutt
} }
// Key prefixes for individual entity keys // Key prefixes for individual entity keys
private readonly stopKeyPrefix = 'shuttle:stop:'; private get stopKeyPrefix() { return this.prefixKey('shuttle:stop:'); }
private readonly routeKeyPrefix = 'shuttle:route:'; private get routeKeyPrefix() { return this.prefixKey('shuttle:route:'); }
private readonly shuttleKeyPrefix = 'shuttle:shuttle:'; private get shuttleKeyPrefix() { return this.prefixKey('shuttle:shuttle:'); }
private readonly orderedStopKeyPrefix = 'shuttle:orderedstop:'; private get orderedStopKeyPrefix() { return this.prefixKey('shuttle:orderedstop:'); }
private readonly lastStopKeyPrefix = 'shuttle:laststop:'; private get lastStopKeyPrefix() { return this.prefixKey('shuttle:laststop:'); }
private readonly historicalEtaKeyPrefix = 'shuttle:eta:historical:'; private get historicalEtaKeyPrefix() { return this.prefixKey('shuttle:eta:historical:'); }
// Key patterns for bulk operations (e.g., getting all keys, clearing data) // Key patterns for bulk operations (e.g., getting all keys, clearing data)
private readonly stopKeyPattern = 'shuttle:stop:*'; private get stopKeyPattern() { return this.prefixKey('shuttle:stop:*'); }
private readonly routeKeyPattern = 'shuttle:route:*'; private get routeKeyPattern() { return this.prefixKey('shuttle:route:*'); }
private readonly shuttleKeyPattern = 'shuttle:shuttle:*'; private get shuttleKeyPattern() { return this.prefixKey('shuttle:shuttle:*'); }
private readonly orderedStopKeyPattern = 'shuttle:orderedstop:*'; private get orderedStopKeyPattern() { return this.prefixKey('shuttle:orderedstop:*'); }
private readonly lastStopKeyPattern = 'shuttle:laststop:*'; private get lastStopKeyPattern() { return this.prefixKey('shuttle:laststop:*'); }
/** /**
* Represents a set storing the shuttles that are currently at a stop. * Represents a set storing the shuttles that are currently at a stop.
*/ */
private readonly shuttleIsAtStopKey = 'shuttle:atstop'; private get shuttleIsAtStopKey() { return this.prefixKey('shuttle:atstop'); }
// Helper methods for Redis key generation // Helper methods for Redis key generation
private readonly createStopKey = (stopId: string) => `${this.stopKeyPrefix}${stopId}`; private readonly createStopKey = (stopId: string) => `${this.stopKeyPrefix}${stopId}`;

View File

@@ -31,7 +31,7 @@ class RedisShuttleRepositoryHolder implements RepositoryHolder<ShuttleGetterSett
url: process.env.REDIS_URL, url: process.env.REDIS_URL,
}); });
await this.redisClient.connect(); await this.redisClient.connect();
this.repo = new RedisShuttleRepository(this.redisClient); this.repo = new RedisShuttleRepository(this.redisClient, 0.001, 'test-system');
return this.repo; return this.repo;
}; };
teardown = async () => { teardown = async () => {

View File

@@ -3,11 +3,11 @@ import { BaseRedisRepository } from "../../BaseRedisRepository";
import { ETAGetterRepository, ETARepositoryEvent, ETARepositoryEventListener, ETARepositoryEventName } from "./ETAGetterRepository"; import { ETAGetterRepository, ETARepositoryEvent, ETARepositoryEventListener, ETARepositoryEventName } from "./ETAGetterRepository";
export abstract class BaseRedisETARepository extends BaseRedisRepository implements ETAGetterRepository { export abstract class BaseRedisETARepository extends BaseRedisRepository implements ETAGetterRepository {
private static readonly ETA_KEY_PREFIX = 'shuttle:eta:'; private get etaKeyPrefix() { return this.prefixKey('shuttle:eta:'); }
// Helper methods // Helper methods
protected createEtaKey = (shuttleId: string, stopId: string) => protected createEtaKey = (shuttleId: string, stopId: string) =>
`${BaseRedisETARepository.ETA_KEY_PREFIX}${shuttleId}:${stopId}`; `${this.etaKeyPrefix}${shuttleId}:${stopId}`;
createRedisHashFromEta = (eta: IEta): Record<string, string> => ({ createRedisHashFromEta = (eta: IEta): Record<string, string> => ({
secondsRemaining: eta.secondsRemaining.toString(), secondsRemaining: eta.secondsRemaining.toString(),
@@ -27,7 +27,7 @@ export abstract class BaseRedisETARepository extends BaseRedisRepository impleme
// Getter implementations // Getter implementations
async getEtasForShuttleId(shuttleId: string): Promise<IEta[]> { async getEtasForShuttleId(shuttleId: string): Promise<IEta[]> {
const keys = await this.redisClient.keys(`${BaseRedisETARepository.ETA_KEY_PREFIX}${shuttleId}:*`); const keys = await this.redisClient.keys(`${this.etaKeyPrefix}${shuttleId}:*`);
const etas: IEta[] = []; const etas: IEta[] = [];
for (const key of keys) { for (const key of keys) {
@@ -41,7 +41,7 @@ export abstract class BaseRedisETARepository extends BaseRedisRepository impleme
} }
async getEtasForStopId(stopId: string): Promise<IEta[]> { async getEtasForStopId(stopId: string): Promise<IEta[]> {
const keys = await this.redisClient.keys(`${BaseRedisETARepository.ETA_KEY_PREFIX}*`); const keys = await this.redisClient.keys(`${this.etaKeyPrefix}*`);
const etas: IEta[] = []; const etas: IEta[] = [];
for (const key of keys) { for (const key of keys) {

View File

@@ -2,8 +2,17 @@ import { IEta } from "../../../entities/ShuttleRepositoryEntities";
import { BaseRedisETARepository } from "./BaseRedisETARepository"; import { BaseRedisETARepository } from "./BaseRedisETARepository";
import { ExternalSourceETARepository } from "./ExternalSourceETARepository"; import { ExternalSourceETARepository } from "./ExternalSourceETARepository";
import { ETARepositoryEvent } from "./ETAGetterRepository"; import { ETARepositoryEvent } from "./ETAGetterRepository";
import { RedisClientType } from "redis";
import createRedisClientForRepository from "../../../helpers/createRedisClientForRepository";
export class RedisExternalSourceETARepository extends BaseRedisETARepository implements ExternalSourceETARepository { export class RedisExternalSourceETARepository extends BaseRedisETARepository implements ExternalSourceETARepository {
constructor(
redisClient: RedisClientType = createRedisClientForRepository(),
systemId: string = '',
) {
super(redisClient, systemId);
}
async addOrUpdateEtaFromExternalSource(eta: IEta): Promise<void> { async addOrUpdateEtaFromExternalSource(eta: IEta): Promise<void> {
await this.addOrUpdateEta(eta); await this.addOrUpdateEta(eta);
} }

View File

@@ -13,8 +13,9 @@ export class RedisSelfUpdatingETARepository extends BaseRedisETARepository imple
readonly shuttleRepository: ShuttleGetterRepository, readonly shuttleRepository: ShuttleGetterRepository,
redisClient: RedisClientType = createRedisClientForRepository(), redisClient: RedisClientType = createRedisClientForRepository(),
private referenceTime: Date | null = null, private referenceTime: Date | null = null,
systemId: string = '',
) { ) {
super(redisClient); super(redisClient, systemId);
this.setReferenceTime = this.setReferenceTime.bind(this); this.setReferenceTime = this.setReferenceTime.bind(this);
this.getAverageTravelTimeSeconds = this.getAverageTravelTimeSeconds.bind(this); this.getAverageTravelTimeSeconds = this.getAverageTravelTimeSeconds.bind(this);
@@ -28,7 +29,7 @@ export class RedisSelfUpdatingETARepository extends BaseRedisETARepository imple
} }
private createHistoricalEtaTimeSeriesKey = (routeId: string, fromStopId: string, toStopId: string) => { private createHistoricalEtaTimeSeriesKey = (routeId: string, fromStopId: string, toStopId: string) => {
return `shuttle:eta:historical:${routeId}:${fromStopId}:${toStopId}`; return this.prefixKey(`shuttle:eta:historical:${routeId}:${fromStopId}:${toStopId}`);
} }
setReferenceTime(referenceTime: Date) { setReferenceTime(referenceTime: Date) {

View File

@@ -16,7 +16,7 @@ class RedisExternalSourceETARepositoryHolder implements RepositoryHolder<Externa
url: process.env.REDIS_URL, url: process.env.REDIS_URL,
}); });
await this.redisClient.connect(); await this.redisClient.connect();
this.repo = new RedisExternalSourceETARepository(this.redisClient); this.repo = new RedisExternalSourceETARepository(this.redisClient, 'test-system');
return this.repo; return this.repo;
} }
teardown = async () => { teardown = async () => {

View File

@@ -22,10 +22,12 @@ class RedisSelfUpdatingETARepositoryHolder implements RepositoryHolder<SelfUpdat
}); });
await this.redisClient.connect(); await this.redisClient.connect();
await this.redisClient.flushAll(); await this.redisClient.flushAll();
this.shuttleRepo = new RedisShuttleRepository(this.redisClient); this.shuttleRepo = new RedisShuttleRepository(this.redisClient, 0.001, 'test-system');
this.repo = new RedisSelfUpdatingETARepository( this.repo = new RedisSelfUpdatingETARepository(
this.shuttleRepo, this.shuttleRepo,
this.redisClient, this.redisClient,
null,
'test-system',
); );
return this.repo; return this.repo;
} }

View File

@@ -83,6 +83,14 @@ export const SystemResolvers: Resolvers<ServerContext> = {
const shuttles = await system.shuttleRepository.getShuttles(); const shuttles = await system.shuttleRepository.getShuttles();
return shuttles.slice().sort((a, b) => a.name.localeCompare(b.name)); return shuttles.slice().sort((a, b) => a.name.localeCompare(b.name));
}, },
initialRegion: async (parent, _args, contextValue, _info) => {
const system = contextValue.findSystemById(parent.id);
if (!system) {
return null;
}
return system.initialRegion;
},
parkingSystem: async (parent, _args, contextValue, _info) => { parkingSystem: async (parent, _args, contextValue, _info) => {
const system = contextValue.findSystemById(parent.id); const system = contextValue.findSystemById(parent.id);
if (!system) { if (!system) {

View File

@@ -35,6 +35,37 @@ describe("SystemResolvers", () => {
}); });
} }
describe("initialRegion", () => {
const query = `
query GetSystemInitialRegion($systemId: ID!) {
system(id: $systemId) {
initialRegion {
topLeft {
latitude
longitude
}
bottomRight {
latitude
longitude
}
}
}
}
`;
it("returns the initial region for the system", async () => {
const response = await getResponseFromQueryNeedingSystemId(query);
assert(response.body.kind === "single");
expect(response.body.singleResult.errors).toBeUndefined();
const initialRegion = (response.body.singleResult.data as any).system.initialRegion;
expect(initialRegion).toEqual({
topLeft: { latitude: 33.85733, longitude: -117.89553 },
bottomRight: { latitude: 33.7397, longitude: -117.81878 },
});
});
});
describe("routes", () => { describe("routes", () => {
const query = ` const query = `
query GetSystemRoutes($systemId: ID!) { query GetSystemRoutes($systemId: ID!) {

View File

@@ -26,6 +26,10 @@ const systemInfoForTesting: InterchangeSystemBuilderArguments = {
parkingSystemId: ChapmanApiBasedParkingRepositoryLoader.id, parkingSystemId: ChapmanApiBasedParkingRepositoryLoader.id,
useSelfUpdatingEtas: false, useSelfUpdatingEtas: false,
shuttleStopArrivalDegreeDelta: 0.001, shuttleStopArrivalDegreeDelta: 0.001,
initialRegion: {
topLeft: { latitude: 33.85733, longitude: -117.89553 },
bottomRight: { latitude: 33.73970, longitude: -117.81878 },
},
}; };
export function buildSystemForTesting() { export function buildSystemForTesting() {