diff --git a/.env.example b/.env.example index 2e0ac7f..bcf9bdc 100644 --- a/.env.example +++ b/.env.example @@ -7,3 +7,5 @@ APNS_BUNDLE_ID= # base64-encoded APNs private key APNS_PRIVATE_KEY= + +PARKING_LOGGING_INTERVAL_MS= diff --git a/.gitignore b/.gitignore index 496b53a..5d63b40 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,6 @@ yarn-error.log* # Keys private/ + +# JetBrains +.idea diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index 13566b8..0000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml -# Editor-based HTTP Client requests -/httpRequests/ -# Datasource local storage ignored files -/dataSources/ -/dataSources.local.xml diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml deleted file mode 100644 index a55e7a1..0000000 --- a/.idea/codeStyles/codeStyleConfig.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml deleted file mode 100644 index cb83045..0000000 --- a/.idea/inspectionProfiles/Project_Default.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index b5faada..0000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/project-inter-server.iml b/.idea/project-inter-server.iml deleted file mode 100644 index 24643cc..0000000 --- a/.idea/project-inter-server.iml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 94a25f7..0000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 643118a..c438853 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -93,5 +93,11 @@ Currently supports Chapman University (Passio System ID: "263"). Each university ## Development Guidelines +### General Guidelines +- Use test-driven development. Always write tests before implementation, and run them before and after implementation. + ### Git Workflow -- Use the name of the branch for all pull requests \ No newline at end of file +- Use the name of the branch for all pull requests + +### Code Style +- Prefer arrow functions, especially in classes diff --git a/docker-compose.yml b/docker-compose.yml index e10842b..5d21e58 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,6 +10,7 @@ x-common-environment: &common-server-environment APNS_TEAM_ID: ${APNS_TEAM_ID} APNS_KEY_ID: ${APNS_KEY_ID} APNS_PRIVATE_KEY: ${APNS_PRIVATE_KEY} + PARKING_LOGGING_INTERVAL_MS: ${PARKING_LOGGING_INTERVAL_MS} REDIS_URL: redis://redis:6379 services: @@ -50,15 +51,21 @@ services: - .:/usr/src/app redis: - image: redis:alpine + image: redis/redis-stack:7.2.0-v17 + container_name: redis-timeseries ports: - "6379:6379" volumes: - - ./redis.conf:/usr/local/etc/redis/redis.conf - command: redis-server /usr/local/etc/redis/redis.conf + - redis_data:/data + - ./redis-stack.conf:/redis-stack.conf + command: redis-stack-server /redis-stack.conf redis-no-persistence: - image: redis:alpine + image: redis/redis-stack:7.2.0-v17 + container_name: redis-timeseries-no-persistence ports: - "6379:6379" +volumes: + redis_data: # Add this volume definition + diff --git a/redis.conf b/redis-stack.conf similarity index 100% rename from redis.conf rename to redis-stack.conf diff --git a/src/entities/InterchangeSystem.ts b/src/entities/InterchangeSystem.ts index 37aed58..af9faed 100644 --- a/src/entities/InterchangeSystem.ts +++ b/src/entities/InterchangeSystem.ts @@ -13,6 +13,7 @@ import { buildParkingRepositoryLoaderIfExists, ParkingRepositoryLoaderBuilderArguments } from "../loaders/parking/buildParkingRepositoryLoaderIfExists"; +import { RedisParkingRepository } from "../repositories/RedisParkingRepository"; export interface InterchangeSystemBuilderArguments { name: string; @@ -75,7 +76,7 @@ export class InterchangeSystem { ); notificationScheduler.startListeningForUpdates(); - let { parkingRepository, timedParkingLoader } = this.buildParkingLoaderAndRepository(args.parkingSystemId); + let { parkingRepository, timedParkingLoader } = await this.buildRedisParkingLoaderAndRepository(args.parkingSystemId); timedParkingLoader?.start(); return new InterchangeSystem( @@ -120,8 +121,8 @@ export class InterchangeSystem { ); notificationScheduler.startListeningForUpdates(); - let { parkingRepository, timedParkingLoader } = this.buildParkingLoaderAndRepository(args.parkingSystemId); - // Timed parking loader is not started + let { parkingRepository, timedParkingLoader } = this.buildInMemoryParkingLoaderAndRepository(args.parkingSystemId); + // Timed parking loader is not started here return new InterchangeSystem( args.name, @@ -135,7 +136,32 @@ export class InterchangeSystem { ); } - private static buildParkingLoaderAndRepository(id?: string) { + private static async buildRedisParkingLoaderAndRepository(id?: string) { + if (id === undefined) { + return { parkingRepository: null, timedParkingLoader: null }; + } + + let parkingRepository: RedisParkingRepository | null = new RedisParkingRepository(); + await parkingRepository.connect(); + + const loaderBuilderArguments: ParkingRepositoryLoaderBuilderArguments = { + id, + repository: parkingRepository, + }; + let parkingLoader = buildParkingRepositoryLoaderIfExists( + loaderBuilderArguments, + ); + + let timedParkingLoader = null; + if (parkingLoader == null) { + parkingRepository = null; + } else { + timedParkingLoader = new TimedApiBasedRepositoryLoader(parkingLoader); + } + return { parkingRepository, timedParkingLoader }; + } + + private static buildInMemoryParkingLoaderAndRepository(id?: string) { if (id === undefined) { return { parkingRepository: null, timedParkingLoader: null }; } diff --git a/src/entities/ParkingRepositoryEntities.ts b/src/entities/ParkingRepositoryEntities.ts index b78a754..36ca488 100644 --- a/src/entities/ParkingRepositoryEntities.ts +++ b/src/entities/ParkingRepositoryEntities.ts @@ -8,4 +8,10 @@ export interface IParkingStructure extends IEntityWithTimestamp, IEntityWithId { name: string; } +export interface IParkingStructureTimestampRecord { + timestampMs: number; + id: string; + spotsAvailable: number; +} + // In the future, add support for viewing different levels of the structure diff --git a/src/repositories/BaseRedisRepository.ts b/src/repositories/BaseRedisRepository.ts new file mode 100644 index 0000000..4793d0a --- /dev/null +++ b/src/repositories/BaseRedisRepository.ts @@ -0,0 +1,33 @@ +import { createClient } from 'redis'; + +export abstract class BaseRedisRepository { + protected redisClient; + + constructor( + redisClient = createClient({ + url: process.env.REDIS_URL, + socket: { + tls: (process.env.REDIS_URL?.match(/rediss:/) != null), + rejectUnauthorized: false, + } + }), + ) { + this.redisClient = redisClient; + } + + 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(); + } +} \ No newline at end of file diff --git a/src/repositories/InMemoryParkingRepository.ts b/src/repositories/InMemoryParkingRepository.ts index 763ab38..741cfd9 100644 --- a/src/repositories/InMemoryParkingRepository.ts +++ b/src/repositories/InMemoryParkingRepository.ts @@ -1,36 +1,160 @@ import { ParkingGetterSetterRepository } from "./ParkingGetterSetterRepository"; -import { IParkingStructure } from "../entities/ParkingRepositoryEntities"; +import { + IParkingStructure, + IParkingStructureTimestampRecord +} from "../entities/ParkingRepositoryEntities"; +import { HistoricalParkingAverageQueryResult, ParkingStructureCountOptions } from "./ParkingGetterRepository"; +import { CircularQueue } from "../types/CircularQueue"; +import { PARKING_LOGGING_INTERVAL_MS } from "./ParkingRepositoryConstants"; + +// If every 10 minutes, two weeks of data (6x per hour * 24x per day * 7x per week * 2) +export const MAX_NUM_ENTRIES = 2016; + +export type ParkingStructureID = string; export class InMemoryParkingRepository implements ParkingGetterSetterRepository { - private structures: Map; + private dataLastAdded: Map = new Map(); + private loggingIntervalMs = PARKING_LOGGING_INTERVAL_MS; - constructor() { - this.structures = new Map(); + constructor( + private structures: Map = new Map(), + private historicalData: Map> = new Map(), + ) { } - async addOrUpdateParkingStructure(structure: IParkingStructure): Promise { + addOrUpdateParkingStructure = async (structure: IParkingStructure): Promise => { this.structures.set(structure.id, { ...structure }); - } + await this.addHistoricalDataForStructure(structure); + }; - async clearParkingStructureData(): Promise { + private addHistoricalDataForStructure = async (structure: IParkingStructure): Promise => { + const now = Date.now(); + const lastAdded = this.dataLastAdded.get(structure.id); + + if (this.shouldLogHistoricalData(lastAdded, now)) { + const timestampRecord = this.createTimestampRecord(structure, now); + this.ensureHistoricalDataExists(structure.id); + this.addRecordToHistoricalData(structure.id, timestampRecord); + this.dataLastAdded.set(structure.id, new Date(now)); + } + }; + + clearParkingStructureData = async (): Promise => { this.structures.clear(); - } + this.historicalData.clear(); + this.dataLastAdded.clear(); + }; - async getParkingStructureById(id: string): Promise { + getParkingStructureById = async (id: string): Promise => { const structure = this.structures.get(id); return structure ? { ...structure } : null; - } + }; - async getParkingStructures(): Promise { - return Array.from(this.structures.values()).map(structure => ({ ...structure })); - } + getParkingStructures = async (): Promise => Array.from(this.structures.values()).map(structure => ({...structure})); - async removeParkingStructureIfExists(id: string): Promise { + removeParkingStructureIfExists = async (id: string): Promise => { const structure = this.structures.get(id); if (structure) { this.structures.delete(id); + this.historicalData.delete(id); + this.dataLastAdded.delete(id); return { ...structure }; } return null; - } + }; + + getHistoricalAveragesOfParkingStructureCounts = async (id: string, options: ParkingStructureCountOptions): Promise => { + const queue = this.historicalData.get(id); + if (!queue || queue.size() === 0) { + return []; + } + + const records = this.extractRecordsFromQueue(queue); + return this.calculateAveragesFromRecords(records, options); + }; + + private shouldLogHistoricalData = (lastAdded: Date | undefined, currentTime: number): boolean => { + return !lastAdded || (currentTime - lastAdded.getTime()) >= this.loggingIntervalMs; + }; + + private createTimestampRecord = (structure: IParkingStructure, timestampMs: number): IParkingStructureTimestampRecord => ({ + id: structure.id, + spotsAvailable: structure.spotsAvailable, + timestampMs, + }); + + private ensureHistoricalDataExists = (structureId: string): void => { + if (!this.historicalData.has(structureId)) { + this.historicalData.set(structureId, new CircularQueue(MAX_NUM_ENTRIES)); + } + }; + + private addRecordToHistoricalData = (structureId: string, record: IParkingStructureTimestampRecord): void => { + const sortingCallback = (a: IParkingStructureTimestampRecord, b: IParkingStructureTimestampRecord) => a.timestampMs - b.timestampMs; + this.historicalData.get(structureId)?.appendWithSorting(record, sortingCallback); + }; + + private extractRecordsFromQueue = (queue: CircularQueue): IParkingStructureTimestampRecord[] => { + const records: IParkingStructureTimestampRecord[] = []; + for (let i = 0; i < queue.size(); i++) { + const record = queue.get(i); + if (record) { + records.push(record); + } + } + return records; + }; + + private calculateAveragesFromRecords = ( + records: IParkingStructureTimestampRecord[], + options: ParkingStructureCountOptions + ): HistoricalParkingAverageQueryResult[] => { + const results: HistoricalParkingAverageQueryResult[] = []; + const { startUnixEpochMs, endUnixEpochMs, intervalMs } = options; + + let currentIntervalStart = startUnixEpochMs; + + while (currentIntervalStart < endUnixEpochMs) { + const currentIntervalEnd = Math.min(currentIntervalStart + intervalMs, endUnixEpochMs); + const recordsInInterval = this.getRecordsInTimeRange(records, currentIntervalStart, currentIntervalEnd); + + if (recordsInInterval.length > 0) { + const averageResult = this.calculateAverageForInterval(currentIntervalStart, currentIntervalEnd, recordsInInterval); + results.push(averageResult); + } + + currentIntervalStart = currentIntervalEnd; + } + + return results; + }; + + private getRecordsInTimeRange = ( + records: IParkingStructureTimestampRecord[], + startMs: number, + endMs: number + ): IParkingStructureTimestampRecord[] => { + return records.filter(record => + record.timestampMs >= startMs && record.timestampMs < endMs + ); + }; + + private calculateAverageForInterval = ( + fromMs: number, + toMs: number, + records: IParkingStructureTimestampRecord[] + ): HistoricalParkingAverageQueryResult => { + const totalSpotsAvailable = records.reduce((sum, record) => sum + record.spotsAvailable, 0); + const averageSpotsAvailable = totalSpotsAvailable / records.length; + + return { + fromUnixEpochMs: fromMs, + toUnixEpochMs: toMs, + averageSpotsAvailable + }; + }; + + setLoggingInterval = (intervalMs: number): void => { + this.loggingIntervalMs = intervalMs; + }; } diff --git a/src/repositories/ParkingGetterRepository.ts b/src/repositories/ParkingGetterRepository.ts index 0e7e00c..8ef13c9 100644 --- a/src/repositories/ParkingGetterRepository.ts +++ b/src/repositories/ParkingGetterRepository.ts @@ -1,6 +1,26 @@ import { IParkingStructure } from "../entities/ParkingRepositoryEntities"; +export interface ParkingStructureCountOptions { + startUnixEpochMs: number; + endUnixEpochMs: number; + intervalMs: number; +} + +export interface HistoricalParkingAverageQueryResult { + fromUnixEpochMs: number; + toUnixEpochMs: number; + averageSpotsAvailable: number; +} + + export interface ParkingGetterRepository { getParkingStructures(): Promise; getParkingStructureById(id: string): Promise; + + /** + * Get historical averages of parking structure data using the filtering options. + * @param id + * @param options + */ + getHistoricalAveragesOfParkingStructureCounts(id: string, options: ParkingStructureCountOptions): Promise; } diff --git a/src/repositories/ParkingGetterSetterRepository.ts b/src/repositories/ParkingGetterSetterRepository.ts index cfe23ba..63b60e5 100644 --- a/src/repositories/ParkingGetterSetterRepository.ts +++ b/src/repositories/ParkingGetterSetterRepository.ts @@ -7,4 +7,6 @@ export interface ParkingGetterSetterRepository extends ParkingGetterRepository { removeParkingStructureIfExists(id: string): Promise; clearParkingStructureData(): Promise; + + setLoggingInterval(intervalMs: number): void; } diff --git a/src/repositories/ParkingRepositoryConstants.ts b/src/repositories/ParkingRepositoryConstants.ts new file mode 100644 index 0000000..4d8b30e --- /dev/null +++ b/src/repositories/ParkingRepositoryConstants.ts @@ -0,0 +1,3 @@ +export const PARKING_LOGGING_INTERVAL_MS = process.env.PARKING_LOGGING_INTERVAL_MS + ? parseInt(process.env.PARKING_LOGGING_INTERVAL_MS) + : 600000; // Every 10 minutes diff --git a/src/repositories/RedisNotificationRepository.ts b/src/repositories/RedisNotificationRepository.ts index ad189bf..6648642 100644 --- a/src/repositories/RedisNotificationRepository.ts +++ b/src/repositories/RedisNotificationRepository.ts @@ -6,52 +6,18 @@ import { NotificationRepository, ScheduledNotification } from "./NotificationRepository"; -import { createClient } from "redis"; +import { BaseRedisRepository } from "./BaseRedisRepository"; -export class RedisNotificationRepository implements NotificationRepository { +export class RedisNotificationRepository extends BaseRedisRepository implements NotificationRepository { private listeners: Listener[] = []; private readonly NOTIFICATION_KEY_PREFIX = 'notification:'; - constructor( - private redisClient = createClient({ - url: process.env.REDIS_URL, - socket: { - tls: (process.env.REDIS_URL?.match(/rediss:/) != null), - rejectUnauthorized: false, - } - }), - ) { - this.getAllNotificationsForShuttleAndStopId = this.getAllNotificationsForShuttleAndStopId.bind(this); - this.getSecondsThresholdForNotificationIfExists = this.getSecondsThresholdForNotificationIfExists.bind(this); - this.deleteNotificationIfExists = this.deleteNotificationIfExists.bind(this); - this.addOrUpdateNotification = this.addOrUpdateNotification.bind(this); - this.isNotificationScheduled = this.isNotificationScheduled.bind(this); - this.subscribeToNotificationChanges = this.subscribeToNotificationChanges.bind(this); - this.unsubscribeFromNotificationChanges = this.unsubscribeFromNotificationChanges.bind(this); - } - - 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(); - } - - private getNotificationKey(shuttleId: string, stopId: string): string { + private getNotificationKey = (shuttleId: string, stopId: string): string => { const tuple = new TupleKey(shuttleId, stopId); return `${this.NOTIFICATION_KEY_PREFIX}${tuple.toString()}`; - } + }; - public async addOrUpdateNotification(notification: ScheduledNotification): Promise { + public addOrUpdateNotification = async (notification: ScheduledNotification): Promise => { const { shuttleId, stopId, deviceId, secondsThreshold } = notification; const key = this.getNotificationKey(shuttleId, stopId); @@ -64,9 +30,9 @@ export class RedisNotificationRepository implements NotificationRepository { }; listener(event); }); - } + }; - public async deleteNotificationIfExists(lookupArguments: NotificationLookupArguments): Promise { + public deleteNotificationIfExists = async (lookupArguments: NotificationLookupArguments): Promise => { const { shuttleId, stopId, deviceId } = lookupArguments; const key = this.getNotificationKey(shuttleId, stopId); @@ -93,12 +59,12 @@ export class RedisNotificationRepository implements NotificationRepository { listener(event); }); } - } + }; - public async getAllNotificationsForShuttleAndStopId( + public getAllNotificationsForShuttleAndStopId = async ( shuttleId: string, stopId: string - ): Promise { + ): Promise => { const key = this.getNotificationKey(shuttleId, stopId); const allNotifications = await this.redisClient.hGetAll(key); @@ -108,40 +74,40 @@ export class RedisNotificationRepository implements NotificationRepository { deviceId, secondsThreshold: parseInt(secondsThreshold) })); - } + }; - public async getSecondsThresholdForNotificationIfExists( + public getSecondsThresholdForNotificationIfExists = async ( lookupArguments: NotificationLookupArguments - ): Promise { + ): Promise => { const { shuttleId, stopId, deviceId } = lookupArguments; const key = this.getNotificationKey(shuttleId, stopId); const threshold = await this.redisClient.hGet(key, deviceId); return threshold ? parseInt(threshold) : null; - } + }; - public async isNotificationScheduled( + public isNotificationScheduled = async ( lookupArguments: NotificationLookupArguments - ): Promise { + ): Promise => { const threshold = await this.getSecondsThresholdForNotificationIfExists(lookupArguments); return threshold !== null; - } + }; - public subscribeToNotificationChanges(listener: Listener): void { + public subscribeToNotificationChanges = (listener: Listener): void => { const index = this.listeners.findIndex( (existingListener) => existingListener === listener ); if (index < 0) { this.listeners.push(listener); } - } + }; - public unsubscribeFromNotificationChanges(listener: Listener): void { + public unsubscribeFromNotificationChanges = (listener: Listener): void => { const index = this.listeners.findIndex( (existingListener) => existingListener === listener ); if (index >= 0) { this.listeners.splice(index, 1); } - } + }; } diff --git a/src/repositories/RedisParkingRepository.ts b/src/repositories/RedisParkingRepository.ts new file mode 100644 index 0000000..fae968c --- /dev/null +++ b/src/repositories/RedisParkingRepository.ts @@ -0,0 +1,203 @@ +import { ParkingGetterSetterRepository } from "./ParkingGetterSetterRepository"; +import { IParkingStructure } from "../entities/ParkingRepositoryEntities"; +import { HistoricalParkingAverageQueryResult, ParkingStructureCountOptions } from "./ParkingGetterRepository"; +import { BaseRedisRepository } from "./BaseRedisRepository"; +import { PARKING_LOGGING_INTERVAL_MS } from "./ParkingRepositoryConstants"; + +export type ParkingStructureID = string; + +export class RedisParkingRepository extends BaseRedisRepository implements ParkingGetterSetterRepository { + private dataLastAdded: Map = new Map(); + private loggingIntervalMs = PARKING_LOGGING_INTERVAL_MS; + + addOrUpdateParkingStructure = async (structure: IParkingStructure): Promise => { + const keys = this.createRedisKeys(structure.id); + await this.redisClient.hSet(keys.structure, this.createRedisHashFromStructure(structure)); + await this.addHistoricalDataForStructure(structure); + }; + + private addHistoricalDataForStructure = async (structure: IParkingStructure): Promise => { + const now = Date.now(); + const lastAdded = this.dataLastAdded.get(structure.id); + + if (this.shouldLogHistoricalData(lastAdded, now)) { + const keys = this.createRedisKeys(structure.id); + await this.addTimeSeriesDataPoint(keys.timeSeries, now, structure.spotsAvailable, structure.id); + this.dataLastAdded.set(structure.id, new Date(now)); + } + }; + + clearParkingStructureData = async (): Promise => { + const structureKeys = await this.redisClient.keys('parking:structure:*'); + const timeSeriesKeys = await this.redisClient.keys('parking:timeseries:*'); + + const allKeys = [...structureKeys, ...timeSeriesKeys]; + if (allKeys.length > 0) { + await this.redisClient.del(allKeys); + } + + this.dataLastAdded.clear(); + }; + + getParkingStructureById = async (id: string): Promise => { + const keys = this.createRedisKeys(id); + const data = await this.redisClient.hGetAll(keys.structure); + + if (Object.keys(data).length === 0) { + return null; + } + + return this.createStructureFromRedisData(data); + }; + + getParkingStructures = async (): Promise => { + const keys = await this.redisClient.keys('parking:structure:*'); + const structures: IParkingStructure[] = []; + + for (const key of keys) { + const data = await this.redisClient.hGetAll(key); + if (Object.keys(data).length > 0) { + structures.push(this.createStructureFromRedisData(data)); + } + } + + return structures; + }; + + removeParkingStructureIfExists = async (id: string): Promise => { + const structure = await this.getParkingStructureById(id); + if (structure) { + const keys = this.createRedisKeys(id); + await this.redisClient.del([keys.structure, keys.timeSeries]); + this.dataLastAdded.delete(id); + return structure; + } + return null; + }; + + getHistoricalAveragesOfParkingStructureCounts = async (id: string, options: ParkingStructureCountOptions): Promise => { + return this.calculateAveragesFromRecords(id, options); + }; + + private createRedisKeys = (structureId: string) => ({ + structure: `parking:structure:${structureId}`, + timeSeries: `parking:timeseries:${structureId}` + }); + + private createRedisHashFromStructure = (structure: IParkingStructure): Record => ({ + id: structure.id, + name: structure.name, + address: structure.address, + capacity: structure.capacity.toString(), + spotsAvailable: structure.spotsAvailable.toString(), + latitude: structure.coordinates.latitude.toString(), + longitude: structure.coordinates.longitude.toString(), + updatedTime: structure.updatedTime.toISOString() + }); + + private createStructureFromRedisData = (data: Record): IParkingStructure => ({ + id: data.id, + name: data.name, + address: data.address, + capacity: parseInt(data.capacity), + spotsAvailable: parseInt(data.spotsAvailable), + coordinates: { + latitude: parseFloat(data.latitude), + longitude: parseFloat(data.longitude) + }, + updatedTime: new Date(data.updatedTime) + }); + + private shouldLogHistoricalData = (lastAdded: Date | undefined, currentTime: number): boolean => { + return !lastAdded || (currentTime - lastAdded.getTime()) >= this.loggingIntervalMs; + }; + + private addTimeSeriesDataPoint = async (timeSeriesKey: string, timestamp: number, value: number, structureId: string): Promise => { + try { + await this.redisClient.sendCommand([ + 'TS.ADD', + timeSeriesKey, + timestamp.toString(), + value.toString(), + 'LABELS', + 'structureId', + structureId + ]); + } catch (error) { + await this.createTimeSeriesAndAddDataPoint(timeSeriesKey, timestamp, value, structureId); + } + }; + + private createTimeSeriesAndAddDataPoint = async (timeSeriesKey: string, timestamp: number, value: number, structureId: string): Promise => { + try { + await this.redisClient.sendCommand([ + 'TS.CREATE', + timeSeriesKey, + 'RETENTION', + '2678400000', // one month + 'LABELS', + 'structureId', + structureId + ]); + await this.redisClient.sendCommand([ + 'TS.ADD', + timeSeriesKey, + timestamp.toString(), + value.toString() + ]); + } catch (createError) { + await this.redisClient.sendCommand([ + 'TS.ADD', + timeSeriesKey, + timestamp.toString(), + value.toString() + ]); + } + }; + + private calculateAveragesFromRecords = async ( + id: string, + options: ParkingStructureCountOptions + ): Promise => { + const keys = this.createRedisKeys(id); + const { startUnixEpochMs, endUnixEpochMs, intervalMs } = options; + const results: HistoricalParkingAverageQueryResult[] = []; + + let currentIntervalStart = startUnixEpochMs; + + while (currentIntervalStart < endUnixEpochMs) { + const currentIntervalEnd = Math.min(currentIntervalStart + intervalMs, endUnixEpochMs); + + try { + const aggregationResult = await this.redisClient.sendCommand([ + 'TS.RANGE', + keys.timeSeries, + currentIntervalStart.toString(), + currentIntervalEnd.toString(), + 'AGGREGATION', + 'AVG', + intervalMs.toString() + ]) as [string, string][]; + + if (aggregationResult && aggregationResult.length > 0) { + const [, averageValue] = aggregationResult[0]; + results.push({ + fromUnixEpochMs: currentIntervalStart, + toUnixEpochMs: currentIntervalEnd, + averageSpotsAvailable: parseFloat(averageValue) + }); + } + } catch (error) { + // If Redis aggregation fails, skip this interval + } + + currentIntervalStart = currentIntervalEnd; + } + + return results; + }; + + setLoggingInterval = (intervalMs: number): void => { + this.loggingIntervalMs = intervalMs; + }; +} diff --git a/src/types/CircularQueue.ts b/src/types/CircularQueue.ts new file mode 100644 index 0000000..8d7db7d --- /dev/null +++ b/src/types/CircularQueue.ts @@ -0,0 +1,119 @@ +export class CircularQueue { + private startIndex: number; + private endIndex: number; + private _data: T[]; + private _size: number; + private _capacity: number; + + constructor( + size: number, + ) { + // See the Mozilla documentation on sparse arrays (*not* undefined values) + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Indexed_collections#sparse_arrays + this._data = new Array(size); + this.startIndex = 0; + this.endIndex = 0; + this._size = 0; + this._capacity = size; + } + + size = (): number => this._size; + + get = (index: number): T | undefined => { + if (index < 0 || index >= this._size) { + return undefined; + } + const actualIndex = (this.startIndex + index) % this._capacity; + return this._data[actualIndex]; + }; + + appendWithSorting = ( + data: T, + sortingCallback: (a: T, b: T) => number + ) => { + if (this._size === 0) { + this._data[this.startIndex] = data; + this._size = 1; + this.endIndex = this.startIndex; + return; + } + + const lastItem = this.get(this._size - 1); + const isAlreadyInOrder = lastItem && sortingCallback(lastItem, data) <= 0; + + if (this._size < this._capacity) { + this.endIndex = (this.endIndex + 1) % this._capacity; + this._data[this.endIndex] = data; + this._size++; + } else { + this.startIndex = (this.startIndex + 1) % this._capacity; + this.endIndex = (this.endIndex + 1) % this._capacity; + this._data[this.endIndex] = data; + } + + if (!isAlreadyInOrder) { + this.sortData(sortingCallback); + } + } + + popFront = () => { + if (this._size === 0) { + return; + } + + this._data[this.startIndex] = undefined as any; + if (this._size === 1) { + this._size = 0; + this.startIndex = 0; + this.endIndex = 0; + } else { + this.startIndex = (this.startIndex + 1) % this._capacity; + this._size--; + } + } + + binarySearch = ( + searchKey: K, + keyExtractor: (item: T) => K + ): T | undefined => { + if (this._size === 0) { + return undefined; + } + + let left = 0; + let right = this._size - 1; + + while (left <= right) { + const mid = Math.floor((left + right) / 2); + const midItem = this.get(mid)!; + const midKey = keyExtractor(midItem); + + if (midKey === searchKey) { + return midItem; + } else if (midKey < searchKey) { + left = mid + 1; + } else { + right = mid - 1; + } + } + + return undefined; + } + + private sortData = (sortingCallback: (a: T, b: T) => number) => { + const items: T[] = []; + for (let i = 0; i < this._size; i++) { + const item = this.get(i); + if (item !== undefined) { + items.push(item); + } + } + + items.sort(sortingCallback); + + for (let i = 0; i < items.length; i++) { + const actualIndex = (this.startIndex + i) % this._capacity; + this._data[actualIndex] = items[i]; + } + }; +} diff --git a/test/repositories/InMemoryParkingRepositoryTests.test.ts b/test/repositories/InMemoryParkingRepositoryTests.test.ts deleted file mode 100644 index d358d14..0000000 --- a/test/repositories/InMemoryParkingRepositoryTests.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { beforeEach, describe, expect, it } from "@jest/globals"; -import { InMemoryParkingRepository } from "../../src/repositories/InMemoryParkingRepository"; -import { IParkingStructure } from "../../src/entities/ParkingRepositoryEntities"; - -describe("InMemoryParkingRepository", () => { - let repository: InMemoryParkingRepository; - const testStructure: IParkingStructure = { - coordinates: { - latitude: 33.794795, - longitude: -117.850807, - }, - spotsAvailable: 0, - id: "1", - name: "Anderson Parking Structure", - capacity: 100, - address: "300 E Walnut Ave, Orange, CA 92867", - updatedTime: new Date(), - }; - - beforeEach(() => { - repository = new InMemoryParkingRepository(); - }); - - describe("addOrUpdateParkingStructure", () => { - it("should add a new parking structure", async () => { - await repository.addOrUpdateParkingStructure(testStructure); - const result = await repository.getParkingStructureById(testStructure.id); - expect(result).toEqual(testStructure); - }); - - it("should update existing parking structure", async () => { - await repository.addOrUpdateParkingStructure(testStructure); - const updatedStructure = { ...testStructure, name: "Updated Garage" }; - await repository.addOrUpdateParkingStructure(updatedStructure); - const result = await repository.getParkingStructureById(testStructure.id); - expect(result).toEqual(updatedStructure); - }); - }); - - describe("removeParkingStructureIfExists", () => { - it("should remove existing parking structure and return it", async () => { - await repository.addOrUpdateParkingStructure(testStructure); - const removed = await repository.removeParkingStructureIfExists(testStructure.id); - expect(removed).toEqual(testStructure); - const result = await repository.getParkingStructureById(testStructure.id); - expect(result).toBeNull(); - }); - - it("should return null when removing non-existent structure", async () => { - const result = await repository.removeParkingStructureIfExists("non-existent"); - expect(result).toBeNull(); - }); - }); - - describe("clearParkingStructureData", () => { - it("should remove all parking structures", async () => { - const structures = [ - testStructure, - { ...testStructure, id: "test-id-2", name: "Second Garage" } - ]; - - for (const structure of structures) { - await repository.addOrUpdateParkingStructure(structure); - } - - await repository.clearParkingStructureData(); - const result = await repository.getParkingStructures(); - expect(result).toHaveLength(0); - }); - }); - - describe("getParkingStructures", () => { - it("should return empty array when no structures exist", async () => { - const result = await repository.getParkingStructures(); - expect(result).toEqual([]); - }); - - it("should return all added structures", async () => { - const structures = [ - testStructure, - { ...testStructure, id: "test-id-2", name: "Second Garage" } - ]; - - for (const structure of structures) { - await repository.addOrUpdateParkingStructure(structure); - } - - const result = await repository.getParkingStructures(); - expect(result).toHaveLength(2); - expect(result).toEqual(expect.arrayContaining(structures)); - }); - }); - - describe("getParkingStructureById", () => { - it("should return null for non-existent structure", async () => { - const result = await repository.getParkingStructureById("non-existent"); - expect(result).toBeNull(); - }); - - it("should return structure by id", async () => { - await repository.addOrUpdateParkingStructure(testStructure); - const result = await repository.getParkingStructureById(testStructure.id); - expect(result).toEqual(testStructure); - }); - }); -}); diff --git a/test/repositories/ParkingRepositorySharedTests.test.ts b/test/repositories/ParkingRepositorySharedTests.test.ts new file mode 100644 index 0000000..c43cf07 --- /dev/null +++ b/test/repositories/ParkingRepositorySharedTests.test.ts @@ -0,0 +1,203 @@ +import { afterEach, beforeEach, describe, expect, it, jest } from "@jest/globals"; +import { InMemoryParkingRepository, } from "../../src/repositories/InMemoryParkingRepository"; +import { IParkingStructure } from "../../src/entities/ParkingRepositoryEntities"; +import { ParkingStructureCountOptions } from "../../src/repositories/ParkingGetterRepository"; +import { ParkingGetterSetterRepository } from "../../src/repositories/ParkingGetterSetterRepository"; +import { RedisParkingRepository } from "../../src/repositories/RedisParkingRepository"; + +interface RepositoryHolder { + name: string; + factory(): Promise; + teardown(): Promise; +} + +class InMemoryParkingRepositoryHolder implements RepositoryHolder { + name = 'InMemoryParkingRepository'; + factory = async () => { + return new InMemoryParkingRepository(); + }; + teardown = async () => {}; +} + +class RedisParkingRepositoryHolder implements RepositoryHolder { + repo: RedisParkingRepository | undefined; + + name = 'RedisParkingRepository'; + factory = async () => { + this.repo = new RedisParkingRepository(); + await this.repo.connect(); + return this.repo; + }; + teardown = async () => { + if (this.repo) { + await this.repo.clearAllData(); + await this.repo.disconnect(); + } + }; +} + +const repositoryImplementations = [ + new InMemoryParkingRepositoryHolder(), + new RedisParkingRepositoryHolder(), +]; + +describe.each(repositoryImplementations)('$name', (holder) => { + let repository: ParkingGetterSetterRepository; + const testStructure: IParkingStructure = { + coordinates: { + latitude: 33.794795, + longitude: -117.850807, + }, + spotsAvailable: 0, + id: "1", + name: "Anderson Parking Structure", + capacity: 100, + address: "300 E Walnut Ave, Orange, CA 92867", + updatedTime: new Date(), + }; + + beforeEach(async () => { + repository = await holder.factory(); + jest.useRealTimers(); + }); + + afterEach(async () => { + await holder.teardown(); + jest.useRealTimers(); + }); + + describe("addOrUpdateParkingStructure", () => { + it("should add a new parking structure", async () => { + await repository.addOrUpdateParkingStructure(testStructure); + const result = await repository.getParkingStructureById(testStructure.id); + expect(result).toEqual(testStructure); + }); + + it("should update existing parking structure", async () => { + await repository.addOrUpdateParkingStructure(testStructure); + const updatedStructure = { ...testStructure, name: "Updated Garage" }; + await repository.addOrUpdateParkingStructure(updatedStructure); + const result = await repository.getParkingStructureById(testStructure.id); + expect(result).toEqual(updatedStructure); + }); + }); + + describe("removeParkingStructureIfExists", () => { + it("should remove existing parking structure and return it", async () => { + await repository.addOrUpdateParkingStructure(testStructure); + const removed = await repository.removeParkingStructureIfExists(testStructure.id); + expect(removed).toEqual(testStructure); + const result = await repository.getParkingStructureById(testStructure.id); + expect(result).toBeNull(); + }); + + it("should return null when removing non-existent structure", async () => { + const result = await repository.removeParkingStructureIfExists("non-existent"); + expect(result).toBeNull(); + }); + }); + + describe("clearParkingStructureData", () => { + it("should remove all parking structures", async () => { + const structures = [ + testStructure, + { ...testStructure, id: "test-id-2", name: "Second Garage" } + ]; + + for (const structure of structures) { + await repository.addOrUpdateParkingStructure(structure); + } + + await repository.clearParkingStructureData(); + const result = await repository.getParkingStructures(); + expect(result).toHaveLength(0); + }); + }); + + describe("getParkingStructures", () => { + it("should return empty array when no structures exist", async () => { + const result = await repository.getParkingStructures(); + expect(result).toEqual([]); + }); + + it("should return all added structures", async () => { + const structures = [ + testStructure, + { ...testStructure, id: "test-id-2", name: "Second Garage" } + ]; + + for (const structure of structures) { + await repository.addOrUpdateParkingStructure(structure); + } + + const result = await repository.getParkingStructures(); + expect(result).toHaveLength(2); + expect(result).toEqual(expect.arrayContaining(structures)); + }); + }); + + describe("getParkingStructureById", () => { + it("should return null for non-existent structure", async () => { + const result = await repository.getParkingStructureById("non-existent"); + expect(result).toBeNull(); + }); + + it("should return structure by id", async () => { + await repository.addOrUpdateParkingStructure(testStructure); + const result = await repository.getParkingStructureById(testStructure.id); + expect(result).toEqual(testStructure); + }); + }); + + describe("getHistoricalAveragesOfParkingStructureCounts", () => { + it("should return empty array for non-existent structure or no data", async () => { + const options: ParkingStructureCountOptions = { + startUnixEpochMs: 1000, + endUnixEpochMs: 2000, + intervalMs: 500 + }; + + expect(await repository.getHistoricalAveragesOfParkingStructureCounts("non-existent", options)).toEqual([]); + + await repository.addOrUpdateParkingStructure(testStructure); + expect(await repository.getHistoricalAveragesOfParkingStructureCounts(testStructure.id, options)).toEqual([]); + }); + + it("should calculate averages for intervals with manual historical data", async () => { + // Set logging interval to 0 so every update creates historical data + repository.setLoggingInterval(0); + + await repository.addOrUpdateParkingStructure(testStructure); + + const updates = [ + { ...testStructure, spotsAvailable: 80, updatedTime: new Date() }, + { ...testStructure, spotsAvailable: 70, updatedTime: new Date() }, + { ...testStructure, spotsAvailable: 60, updatedTime: new Date() }, + ]; + + for (let i = 0; i < updates.length; i++) { + // Ensure that different timestamps are created, even after adding the first test structure + await new Promise((resolve) => setTimeout(resolve, 200)); + await repository.addOrUpdateParkingStructure(updates[i]); + } + + const now = Date.now(); + const options: ParkingStructureCountOptions = { + startUnixEpochMs: now - 10000, // Look back 10 seconds + endUnixEpochMs: now + 10000, // Look forward 10 seconds + intervalMs: 20000 // Single large interval + }; + + const result = await repository.getHistoricalAveragesOfParkingStructureCounts(testStructure.id, options); + + // Should have at least some historical data + expect(result.length).toEqual(1); + if (result.length > 0) { + expect(result[0]).toHaveProperty('fromUnixEpochMs'); + expect(result[0]).toHaveProperty('toUnixEpochMs'); + expect(result[0]).toHaveProperty('averageSpotsAvailable'); + expect(result[0].averageSpotsAvailable).toBeCloseTo(52.5); + } + }); + }); +}); diff --git a/test/types/CircularQueue.test.ts b/test/types/CircularQueue.test.ts new file mode 100644 index 0000000..748396d --- /dev/null +++ b/test/types/CircularQueue.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, it } from "@jest/globals"; +import { CircularQueue } from "../../src/types/CircularQueue"; + +interface TestItem { + id: number; + value: string; +} + +describe("CircularQueue", () => { + const testItems = { + first: { id: 1, value: "first" }, + second: { id: 2, value: "second" }, + third: { id: 3, value: "third" }, + fourth: { id: 4, value: "fourth" }, + test: { id: 1, value: "test" }, + apple: { id: 1, value: "apple" }, + banana: { id: 2, value: "banana" }, + cherry: { id: 3, value: "cherry" }, + grape: { id: 5, value: "grape" }, + orange: { id: 7, value: "orange" }, + a: { id: 1, value: "a" }, + b: { id: 2, value: "b" }, + c: { id: 3, value: "c" }, + d: { id: 4, value: "d" } + }; + + const sortingCallbacks = { + byId: (a: TestItem, b: TestItem) => a.id - b.id, + byValue: (a: TestItem, b: TestItem) => a.value.localeCompare(b.value) + }; + + const keyExtractors = { + id: (item: TestItem) => item.id, + value: (item: TestItem) => item.value + }; + + const createQueueWithItems = (size: number, items: TestItem[], sortingCallback: (a: TestItem, b: TestItem) => number) => { + const queue = new CircularQueue(size); + items.forEach(item => queue.appendWithSorting(item, sortingCallback)); + return queue; + }; + + describe("constructor", () => { + it("creates queue with specified size", () => { + const queue = new CircularQueue(5); + expect(queue).toBeDefined(); + }); + }); + + describe("appendWithSorting", () => { + it("adds items to the queue with sorting callback", () => { + const queue = createQueueWithItems(3, [testItems.third, testItems.first, testItems.second], sortingCallbacks.byId); + + expect(queue.size()).toBe(3); + expect(queue.get(0)).toEqual(testItems.first); + expect(queue.get(1)).toEqual(testItems.second); + expect(queue.get(2)).toEqual(testItems.third); + }); + + it("overwrites oldest items when queue is full", () => { + const queue = createQueueWithItems(2, [testItems.first, testItems.second, testItems.third], sortingCallbacks.byId); + + expect(queue.size()).toBe(2); + }); + + it("handles appending to empty queue", () => { + const queue = createQueueWithItems(3, [testItems.test], sortingCallbacks.byId); + + expect(queue.size()).toBe(1); + expect(queue.get(0)).toEqual(testItems.test); + }); + + it("optimizes append when items are already in order", () => { + const queue = new CircularQueue(5); + let sortCallCount = 0; + + const trackingSortCallback = (a: TestItem, b: TestItem) => { + sortCallCount++; + return a.id - b.id; + }; + + queue.appendWithSorting(testItems.first, trackingSortCallback); + expect(sortCallCount).toBe(0); + + queue.appendWithSorting(testItems.second, trackingSortCallback); + expect(sortCallCount).toBe(1); + + queue.appendWithSorting(testItems.third, trackingSortCallback); + expect(sortCallCount).toBe(2); + + queue.appendWithSorting({ id: 0, value: "zero" }, trackingSortCallback); + expect(sortCallCount).toBeGreaterThan(3); + + expect(queue.get(0)).toEqual({ id: 0, value: "zero" }); + expect(queue.get(1)).toEqual(testItems.first); + }); + }); + + describe("popFront", () => { + it("removes the oldest item from queue", () => { + const queue = createQueueWithItems(3, [testItems.first, testItems.second], sortingCallbacks.byId); + + expect(queue.size()).toBe(2); + queue.popFront(); + expect(queue.size()).toBe(1); + expect(queue.get(0)).toEqual(testItems.second); + }); + + it("handles popping from empty queue", () => { + const queue = new CircularQueue(3); + + expect(() => queue.popFront()).not.toThrow(); + expect(queue.size()).toBe(0); + }); + + it("handles popping until empty", () => { + const queue = createQueueWithItems(2, [testItems.first, testItems.second], sortingCallbacks.byId); + + queue.popFront(); + expect(queue.size()).toBe(1); + queue.popFront(); + expect(queue.size()).toBe(0); + queue.popFront(); + expect(queue.size()).toBe(0); + }); + }); + + describe("binarySearch", () => { + it("finds item using key extractor function", () => { + const queue = createQueueWithItems(5, [testItems.apple, testItems.cherry, testItems.grape, testItems.orange], sortingCallbacks.byId); + + const result = queue.binarySearch(5, keyExtractors.id); + + expect(result).toEqual(testItems.grape); + }); + + it("returns undefined when item not found", () => { + const queue = createQueueWithItems(5, [testItems.apple, testItems.cherry, testItems.orange], sortingCallbacks.byId); + + const result = queue.binarySearch(5, keyExtractors.id); + + expect(result).toBeUndefined(); + }); + + it("finds first item", () => { + const queue = createQueueWithItems(5, [testItems.apple, testItems.cherry, testItems.orange], sortingCallbacks.byId); + + const result = queue.binarySearch(1, keyExtractors.id); + + expect(result).toEqual(testItems.apple); + }); + + it("finds last item", () => { + const queue = createQueueWithItems(5, [testItems.apple, testItems.cherry, testItems.orange], sortingCallbacks.byId); + + const result = queue.binarySearch(7, keyExtractors.id); + + expect(result).toEqual(testItems.orange); + }); + + it("returns undefined for empty queue", () => { + const queue = new CircularQueue(5); + const result = queue.binarySearch(1, keyExtractors.id); + + expect(result).toBeUndefined(); + }); + + it("works with string keys", () => { + const queue = createQueueWithItems(5, [testItems.apple, testItems.banana, testItems.cherry], sortingCallbacks.byValue); + + const result = queue.binarySearch("banana", keyExtractors.value); + + expect(result).toEqual(testItems.banana); + }); + + it("maintains sorted order assumption", () => { + const queue = createQueueWithItems(5, [testItems.d, testItems.a, testItems.c, testItems.b], sortingCallbacks.byValue); + + expect(queue.binarySearch("a", keyExtractors.value)).toEqual(testItems.a); + expect(queue.binarySearch("b", keyExtractors.value)).toEqual(testItems.b); + expect(queue.binarySearch("c", keyExtractors.value)).toEqual(testItems.c); + expect(queue.binarySearch("d", keyExtractors.value)).toEqual(testItems.d); + expect(queue.binarySearch("z", keyExtractors.value)).toBeUndefined(); + }); + }); + + describe("integration", () => { + it("handles appendWithSorting, popFront, and binarySearch together", () => { + const queue = createQueueWithItems(3, [testItems.third, testItems.first, testItems.second], sortingCallbacks.byId); + + expect(queue.binarySearch(2, keyExtractors.id)).toEqual(testItems.second); + + queue.popFront(); + expect(queue.binarySearch(1, keyExtractors.id)).toBeUndefined(); + expect(queue.binarySearch(2, keyExtractors.id)).toEqual(testItems.second); + + queue.appendWithSorting(testItems.fourth, sortingCallbacks.byId); + expect(queue.binarySearch(4, keyExtractors.id)).toEqual(testItems.fourth); + }); + }); +});