From eca2ffe8eebc2a2cade58c0a5abde9de9343a28e Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Tue, 1 Jul 2025 19:00:53 -0400 Subject: [PATCH 01/28] rename redis.conf to redis-stack.conf --- redis.conf => redis-stack.conf | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename redis.conf => redis-stack.conf (100%) diff --git a/redis.conf b/redis-stack.conf similarity index 100% rename from redis.conf rename to redis-stack.conf From 6400929eb68c536c4614735cfd9da9e8008eb544 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Tue, 1 Jul 2025 19:01:33 -0400 Subject: [PATCH 02/28] Update docker-compose.yml to use redis-stack instead of redis:alpine This is the easiest way to set up the `timeseries` module during development --- docker-compose.yml | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index e10842b..5c71264 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -50,15 +50,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 + From ab29b0833755c81d408d580e6ab479b05288e813 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Tue, 1 Jul 2025 21:12:22 -0400 Subject: [PATCH 03/28] Add entities IParkingStructureTimestampRecord and type HistoricalParkingAggregatedQueryResult To support historical data storage --- src/entities/ParkingRepositoryEntities.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/entities/ParkingRepositoryEntities.ts b/src/entities/ParkingRepositoryEntities.ts index b78a754..076669d 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 extends IEntityWithTimestamp { + timestampMs: number; + id: string; + spotsAvailable: number; +} + // In the future, add support for viewing different levels of the structure From 1f252ee468d2b3cdc25ec34d1f49562ced641767 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Tue, 1 Jul 2025 21:13:55 -0400 Subject: [PATCH 04/28] Define new method to get historical averages depending on timestamps and interval --- src/repositories/ParkingGetterRepository.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/repositories/ParkingGetterRepository.ts b/src/repositories/ParkingGetterRepository.ts index 0e7e00c..c098c0e 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; + averageSpotsTaken: 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; } From 81272e94b10845a7056463674587605d2343fa22 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Tue, 1 Jul 2025 21:20:11 -0400 Subject: [PATCH 05/28] Add getHistoricalAveragesOfParkingStructureCounts stub and update constructor with dependency injection support --- src/repositories/InMemoryParkingRepository.ts | 40 +++++++++++-------- 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/src/repositories/InMemoryParkingRepository.ts b/src/repositories/InMemoryParkingRepository.ts index 763ab38..a23a886 100644 --- a/src/repositories/InMemoryParkingRepository.ts +++ b/src/repositories/InMemoryParkingRepository.ts @@ -1,36 +1,44 @@ import { ParkingGetterSetterRepository } from "./ParkingGetterSetterRepository"; -import { IParkingStructure } from "../entities/ParkingRepositoryEntities"; +import { + IParkingStructure, + IParkingStructureTimestampRecord +} from "../entities/ParkingRepositoryEntities"; +import { HistoricalParkingAverageQueryResult } from "./ParkingGetterRepository"; + +type ParkingStructureID = string; export class InMemoryParkingRepository implements ParkingGetterSetterRepository { - private structures: Map; - - 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 }); - } + }; - async clearParkingStructureData(): Promise { + clearParkingStructureData = async (): Promise => { this.structures.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); return { ...structure }; } return null; - } + }; + + getHistoricalAveragesOfParkingStructureCounts = async (id: string): Promise => { + return []; + }; } From 6759bba2cef54650d2a68b55f48b87980964fff9 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Tue, 1 Jul 2025 21:23:15 -0400 Subject: [PATCH 06/28] Add properties to determine when the parking repository should log data --- src/repositories/InMemoryParkingRepository.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/repositories/InMemoryParkingRepository.ts b/src/repositories/InMemoryParkingRepository.ts index a23a886..ae6bdef 100644 --- a/src/repositories/InMemoryParkingRepository.ts +++ b/src/repositories/InMemoryParkingRepository.ts @@ -7,7 +7,11 @@ import { HistoricalParkingAverageQueryResult } from "./ParkingGetterRepository"; type ParkingStructureID = string; +export const PARKING_LOGGING_INTERVAL_MS = 60000; + export class InMemoryParkingRepository implements ParkingGetterSetterRepository { + private dataLastAdded: Map = new Map(); + constructor( private structures: Map = new Map(), private historicalData: Map = new Map(), From 95fa610d77a274e3b1e3362b1440ea6ee8bf9419 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Tue, 1 Jul 2025 21:32:26 -0400 Subject: [PATCH 07/28] define behavior of historical data logging through tests --- .../InMemoryParkingRepositoryTests.test.ts | 47 +++++++++++++++++-- 1 file changed, 44 insertions(+), 3 deletions(-) diff --git a/test/repositories/InMemoryParkingRepositoryTests.test.ts b/test/repositories/InMemoryParkingRepositoryTests.test.ts index d358d14..41ea843 100644 --- a/test/repositories/InMemoryParkingRepositoryTests.test.ts +++ b/test/repositories/InMemoryParkingRepositoryTests.test.ts @@ -1,5 +1,8 @@ -import { beforeEach, describe, expect, it } from "@jest/globals"; -import { InMemoryParkingRepository } from "../../src/repositories/InMemoryParkingRepository"; +import { beforeEach, describe, expect, it, jest } from "@jest/globals"; +import { + InMemoryParkingRepository, + PARKING_LOGGING_INTERVAL_MS +} from "../../src/repositories/InMemoryParkingRepository"; import { IParkingStructure } from "../../src/entities/ParkingRepositoryEntities"; describe("InMemoryParkingRepository", () => { @@ -16,9 +19,11 @@ describe("InMemoryParkingRepository", () => { address: "300 E Walnut Ave, Orange, CA 92867", updatedTime: new Date(), }; + let historicalData = new Map(); beforeEach(() => { - repository = new InMemoryParkingRepository(); + historicalData = new Map(); + repository = new InMemoryParkingRepository(new Map(), historicalData); }); describe("addOrUpdateParkingStructure", () => { @@ -35,6 +40,42 @@ describe("InMemoryParkingRepository", () => { const result = await repository.getParkingStructureById(testStructure.id); expect(result).toEqual(updatedStructure); }); + + it("should log historical data if past the logging interval", async () => { + const now = Date.now(); + jest + .useFakeTimers() + .setSystemTime(now); + + const expectedTimestampRecordMatcher = { + spotsAvailable: testStructure.spotsAvailable, + id: testStructure.id, + timestampMs: now, + } + + await repository.addOrUpdateParkingStructure(testStructure); + jest.setSystemTime(now + PARKING_LOGGING_INTERVAL_MS + 60); + await repository.addOrUpdateParkingStructure(testStructure); + + expect(historicalData.get(testStructure.id)).toContain(expectedTimestampRecordMatcher); + expect(historicalData.get(testStructure.id)).toContain({ + ...expectedTimestampRecordMatcher, + timestampMs: now + PARKING_LOGGING_INTERVAL_MS + 60, + }); + }); + + it("should not log historical data if not past the logging interval", async () => { + const now = Date.now(); + jest + .useFakeTimers() + .setSystemTime(now); + + await repository.addOrUpdateParkingStructure(testStructure); + jest.setSystemTime(now + 60); + await repository.addOrUpdateParkingStructure(testStructure); + + expect(historicalData.get(testStructure.id)).toHaveLength(1); + }); }); describe("removeParkingStructureIfExists", () => { From 8fb296027d72319b18c2520313d99f2e42f55ad8 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Tue, 1 Jul 2025 21:37:26 -0400 Subject: [PATCH 08/28] Fix remaining issues with tests, and add implementation within InMemoryParkingRepository.ts --- src/entities/ParkingRepositoryEntities.ts | 2 +- src/repositories/InMemoryParkingRepository.ts | 25 +++++++++++++++++++ .../InMemoryParkingRepositoryTests.test.ts | 4 +-- 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/entities/ParkingRepositoryEntities.ts b/src/entities/ParkingRepositoryEntities.ts index 076669d..36ca488 100644 --- a/src/entities/ParkingRepositoryEntities.ts +++ b/src/entities/ParkingRepositoryEntities.ts @@ -8,7 +8,7 @@ export interface IParkingStructure extends IEntityWithTimestamp, IEntityWithId { name: string; } -export interface IParkingStructureTimestampRecord extends IEntityWithTimestamp { +export interface IParkingStructureTimestampRecord { timestampMs: number; id: string; spotsAvailable: number; diff --git a/src/repositories/InMemoryParkingRepository.ts b/src/repositories/InMemoryParkingRepository.ts index ae6bdef..db0e543 100644 --- a/src/repositories/InMemoryParkingRepository.ts +++ b/src/repositories/InMemoryParkingRepository.ts @@ -20,8 +20,33 @@ export class InMemoryParkingRepository implements ParkingGetterSetterRepository addOrUpdateParkingStructure = async (structure: IParkingStructure): Promise => { this.structures.set(structure.id, { ...structure }); + await this.addHistoricalDataForStructure(structure); }; + private addHistoricalDataForStructure = async (structure: IParkingStructure): Promise => { + const now = Date.now(); + const lastAdded = this.dataLastAdded.get(structure.id); + + function parkingLoggingIntervalExceeded() { + return !lastAdded || (now - lastAdded.getTime()) >= PARKING_LOGGING_INTERVAL_MS; + } + + if (parkingLoggingIntervalExceeded()) { + const timestampRecord: IParkingStructureTimestampRecord = { + id: structure.id, + spotsAvailable: structure.spotsAvailable, + timestampMs: now, + }; + + if (!this.historicalData.has(structure.id)) { + this.historicalData.set(structure.id, []); + } + + this.historicalData.get(structure.id)?.push(timestampRecord); + this.dataLastAdded.set(structure.id, new Date(now)); + } + } + clearParkingStructureData = async (): Promise => { this.structures.clear(); }; diff --git a/test/repositories/InMemoryParkingRepositoryTests.test.ts b/test/repositories/InMemoryParkingRepositoryTests.test.ts index 41ea843..b3de36d 100644 --- a/test/repositories/InMemoryParkingRepositoryTests.test.ts +++ b/test/repositories/InMemoryParkingRepositoryTests.test.ts @@ -57,8 +57,8 @@ describe("InMemoryParkingRepository", () => { jest.setSystemTime(now + PARKING_LOGGING_INTERVAL_MS + 60); await repository.addOrUpdateParkingStructure(testStructure); - expect(historicalData.get(testStructure.id)).toContain(expectedTimestampRecordMatcher); - expect(historicalData.get(testStructure.id)).toContain({ + expect(historicalData.get(testStructure.id)).toContainEqual(expectedTimestampRecordMatcher); + expect(historicalData.get(testStructure.id)).toContainEqual({ ...expectedTimestampRecordMatcher, timestampMs: now + PARKING_LOGGING_INTERVAL_MS + 60, }); From 4758d566daea61572a464617a8523bf0fba05bca Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Tue, 1 Jul 2025 21:47:15 -0400 Subject: [PATCH 09/28] Add constant MAX_NUM_ENTRIES and adjust PARKING_LOGGING_INTERVAL_MS --- src/repositories/InMemoryParkingRepository.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/repositories/InMemoryParkingRepository.ts b/src/repositories/InMemoryParkingRepository.ts index db0e543..96488b8 100644 --- a/src/repositories/InMemoryParkingRepository.ts +++ b/src/repositories/InMemoryParkingRepository.ts @@ -7,7 +7,12 @@ import { HistoricalParkingAverageQueryResult } from "./ParkingGetterRepository"; type ParkingStructureID = string; -export const PARKING_LOGGING_INTERVAL_MS = 60000; +// Every 10 minutes +// 6x per hour * 24x per day * 7x per week = 1008 entries for one week +export const PARKING_LOGGING_INTERVAL_MS = 600000; + +// This will last two weeks +export const MAX_NUM_ENTRIES = 2016; export class InMemoryParkingRepository implements ParkingGetterSetterRepository { private dataLastAdded: Map = new Map(); From 18be03bfa6f9edc6caa989da3b2d846ff11a8129 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Tue, 1 Jul 2025 22:01:07 -0400 Subject: [PATCH 10/28] Add stub implementation for my best friend, the humble circular queue Add stubs for a custom circular queue implementation for the historical data in the parking repo --- src/types/CircularQueue.ts | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 src/types/CircularQueue.ts diff --git a/src/types/CircularQueue.ts b/src/types/CircularQueue.ts new file mode 100644 index 0000000..57298a0 --- /dev/null +++ b/src/types/CircularQueue.ts @@ -0,0 +1,27 @@ +export class CircularQueue { + private startIndex: number; + private endIndex: number; + private _data: T[]; + + 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 = size - 1; + } + + appendWithSorting = ( + data: T, + sortingCallback: ((a: T, b: T) => number) | undefined + ) => { + // In case something is added that's not sorted, the sortingCallback + // will be used to sort + } + + popFront = (data: T) => { + + } +} From 4fbc30a264f88bbd1bb4a4af76bcb8eb3fe40d51 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Wed, 2 Jul 2025 18:34:55 -0400 Subject: [PATCH 11/28] Add CircularQueue.ts and test file --- src/types/CircularQueue.ts | 97 ++++++++++++++++- test/types/CircularQueue.test.ts | 176 +++++++++++++++++++++++++++++++ 2 files changed, 268 insertions(+), 5 deletions(-) create mode 100644 test/types/CircularQueue.test.ts diff --git a/src/types/CircularQueue.ts b/src/types/CircularQueue.ts index 57298a0..9ef6ca0 100644 --- a/src/types/CircularQueue.ts +++ b/src/types/CircularQueue.ts @@ -2,6 +2,8 @@ export class CircularQueue { private startIndex: number; private endIndex: number; private _data: T[]; + private _size: number; + private _capacity: number; constructor( size: number, @@ -10,18 +12,103 @@ export class CircularQueue { // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Indexed_collections#sparse_arrays this._data = new Array(size); this.startIndex = 0; - this.endIndex = size - 1; + 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) | undefined + sortingCallback: (a: T, b: T) => number ) => { - // In case something is added that's not sorted, the sortingCallback - // will be used to sort + if (this._size === 0) { + this._data[this.startIndex] = data; + this._size = 1; + this.endIndex = this.startIndex; + return; + } + + 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; + } + + this.sortData(sortingCallback); } - popFront = (data: T) => { + 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/types/CircularQueue.test.ts b/test/types/CircularQueue.test.ts new file mode 100644 index 0000000..60ac085 --- /dev/null +++ b/test/types/CircularQueue.test.ts @@ -0,0 +1,176 @@ +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); + }); + }); + + 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); + }); + }); +}); \ No newline at end of file From 731ac4bafcfa4bc521bbd89ba217a13685a7fd71 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Wed, 2 Jul 2025 18:35:35 -0400 Subject: [PATCH 12/28] Make .idea an ignored folder --- .gitignore | 3 +++ .idea/.gitignore | 8 -------- .idea/codeStyles/codeStyleConfig.xml | 5 ----- .idea/inspectionProfiles/Project_Default.xml | 6 ------ .idea/modules.xml | 8 -------- .idea/project-inter-server.iml | 12 ------------ .idea/vcs.xml | 6 ------ 7 files changed, 3 insertions(+), 45 deletions(-) delete mode 100644 .idea/.gitignore delete mode 100644 .idea/codeStyles/codeStyleConfig.xml delete mode 100644 .idea/inspectionProfiles/Project_Default.xml delete mode 100644 .idea/modules.xml delete mode 100644 .idea/project-inter-server.iml delete mode 100644 .idea/vcs.xml 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 From db506e8b1f805a39eb49a8633694f73da762b74c Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Wed, 2 Jul 2025 18:36:21 -0400 Subject: [PATCH 13/28] Update CLAUDE.md with testing and code style instructions --- CLAUDE.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 643118a..7a5beb5 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 and always validate code through testing + ### 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 From 220b402b3be785bf6de1d5c5cd9338c8767c2da9 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Wed, 2 Jul 2025 18:43:41 -0400 Subject: [PATCH 14/28] Optimize the append method to check if the data is already sorted --- src/types/CircularQueue.ts | 7 ++++++- test/types/CircularQueue.test.ts | 29 +++++++++++++++++++++++++++-- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/src/types/CircularQueue.ts b/src/types/CircularQueue.ts index 9ef6ca0..8d7db7d 100644 --- a/src/types/CircularQueue.ts +++ b/src/types/CircularQueue.ts @@ -38,6 +38,9 @@ export class CircularQueue { 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; @@ -48,7 +51,9 @@ export class CircularQueue { this._data[this.endIndex] = data; } - this.sortData(sortingCallback); + if (!isAlreadyInOrder) { + this.sortData(sortingCallback); + } } popFront = () => { diff --git a/test/types/CircularQueue.test.ts b/test/types/CircularQueue.test.ts index 60ac085..748396d 100644 --- a/test/types/CircularQueue.test.ts +++ b/test/types/CircularQueue.test.ts @@ -69,6 +69,31 @@ describe("CircularQueue", () => { 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", () => { @@ -150,7 +175,7 @@ describe("CircularQueue", () => { 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); @@ -173,4 +198,4 @@ describe("CircularQueue", () => { expect(queue.binarySearch(4, keyExtractors.id)).toEqual(testItems.fourth); }); }); -}); \ No newline at end of file +}); From 2b04ca01a980e2e530ce422ed6b8a8a65f09a399 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Wed, 2 Jul 2025 18:48:33 -0400 Subject: [PATCH 15/28] Update InMemoryParkingRepository.ts and tests to use the circular queue --- src/repositories/InMemoryParkingRepository.ts | 10 ++++++---- .../InMemoryParkingRepositoryTests.test.ts | 13 +++++++------ 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/src/repositories/InMemoryParkingRepository.ts b/src/repositories/InMemoryParkingRepository.ts index 96488b8..f5c63df 100644 --- a/src/repositories/InMemoryParkingRepository.ts +++ b/src/repositories/InMemoryParkingRepository.ts @@ -4,8 +4,9 @@ import { IParkingStructureTimestampRecord } from "../entities/ParkingRepositoryEntities"; import { HistoricalParkingAverageQueryResult } from "./ParkingGetterRepository"; +import { CircularQueue } from "../types/CircularQueue"; -type ParkingStructureID = string; +export type ParkingStructureID = string; // Every 10 minutes // 6x per hour * 24x per day * 7x per week = 1008 entries for one week @@ -19,7 +20,7 @@ export class InMemoryParkingRepository implements ParkingGetterSetterRepository constructor( private structures: Map = new Map(), - private historicalData: Map = new Map(), + private historicalData: Map> = new Map(), ) { } @@ -44,10 +45,11 @@ export class InMemoryParkingRepository implements ParkingGetterSetterRepository }; if (!this.historicalData.has(structure.id)) { - this.historicalData.set(structure.id, []); + this.historicalData.set(structure.id, new CircularQueue(MAX_NUM_ENTRIES)); } - this.historicalData.get(structure.id)?.push(timestampRecord); + const sortingCallback = (a: IParkingStructureTimestampRecord, b: IParkingStructureTimestampRecord) => a.timestampMs - b.timestampMs; + this.historicalData.get(structure.id)?.appendWithSorting(timestampRecord, sortingCallback); this.dataLastAdded.set(structure.id, new Date(now)); } } diff --git a/test/repositories/InMemoryParkingRepositoryTests.test.ts b/test/repositories/InMemoryParkingRepositoryTests.test.ts index b3de36d..24c292f 100644 --- a/test/repositories/InMemoryParkingRepositoryTests.test.ts +++ b/test/repositories/InMemoryParkingRepositoryTests.test.ts @@ -1,9 +1,10 @@ import { beforeEach, describe, expect, it, jest } from "@jest/globals"; import { InMemoryParkingRepository, - PARKING_LOGGING_INTERVAL_MS + PARKING_LOGGING_INTERVAL_MS, ParkingStructureID } from "../../src/repositories/InMemoryParkingRepository"; -import { IParkingStructure } from "../../src/entities/ParkingRepositoryEntities"; +import { IParkingStructure, IParkingStructureTimestampRecord } from "../../src/entities/ParkingRepositoryEntities"; +import { CircularQueue } from "../../src/types/CircularQueue"; describe("InMemoryParkingRepository", () => { let repository: InMemoryParkingRepository; @@ -19,7 +20,7 @@ describe("InMemoryParkingRepository", () => { address: "300 E Walnut Ave, Orange, CA 92867", updatedTime: new Date(), }; - let historicalData = new Map(); + let historicalData: Map> = new Map(); beforeEach(() => { historicalData = new Map(); @@ -57,8 +58,8 @@ describe("InMemoryParkingRepository", () => { jest.setSystemTime(now + PARKING_LOGGING_INTERVAL_MS + 60); await repository.addOrUpdateParkingStructure(testStructure); - expect(historicalData.get(testStructure.id)).toContainEqual(expectedTimestampRecordMatcher); - expect(historicalData.get(testStructure.id)).toContainEqual({ + expect(historicalData.get(testStructure.id)?.get(0)).toEqual(expectedTimestampRecordMatcher); + expect(historicalData.get(testStructure.id)?.get(1)).toEqual({ ...expectedTimestampRecordMatcher, timestampMs: now + PARKING_LOGGING_INTERVAL_MS + 60, }); @@ -74,7 +75,7 @@ describe("InMemoryParkingRepository", () => { jest.setSystemTime(now + 60); await repository.addOrUpdateParkingStructure(testStructure); - expect(historicalData.get(testStructure.id)).toHaveLength(1); + expect(historicalData.get(testStructure.id)?.size()).toEqual(1); }); }); From 8b5d80a9d62324457f3cf390614f0dc50760323f Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Wed, 2 Jul 2025 19:02:28 -0400 Subject: [PATCH 16/28] Update test-driven development guideline for Claude --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7a5beb5..c438853 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -94,7 +94,7 @@ Currently supports Chapman University (Passio System ID: "263"). Each university ## Development Guidelines ### General Guidelines -- Use test-driven development and always validate code through testing +- 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 From ca2a66509be24deb6d3574238b231dabc2d104b4 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Wed, 2 Jul 2025 19:10:16 -0400 Subject: [PATCH 17/28] Implement getHistoricalAveragesOfParkingStructureCounts for InMemoryParkingRepository.ts, and add tests --- src/repositories/InMemoryParkingRepository.ts | 60 +++++++++- src/repositories/ParkingGetterRepository.ts | 2 +- .../InMemoryParkingRepositoryTests.test.ts | 109 ++++++++++++++++++ 3 files changed, 167 insertions(+), 4 deletions(-) diff --git a/src/repositories/InMemoryParkingRepository.ts b/src/repositories/InMemoryParkingRepository.ts index f5c63df..e162dcb 100644 --- a/src/repositories/InMemoryParkingRepository.ts +++ b/src/repositories/InMemoryParkingRepository.ts @@ -3,7 +3,7 @@ import { IParkingStructure, IParkingStructureTimestampRecord } from "../entities/ParkingRepositoryEntities"; -import { HistoricalParkingAverageQueryResult } from "./ParkingGetterRepository"; +import { HistoricalParkingAverageQueryResult, ParkingStructureCountOptions } from "./ParkingGetterRepository"; import { CircularQueue } from "../types/CircularQueue"; export type ParkingStructureID = string; @@ -74,7 +74,61 @@ export class InMemoryParkingRepository implements ParkingGetterSetterRepository return null; }; - getHistoricalAveragesOfParkingStructureCounts = async (id: string): Promise => { - return []; + getHistoricalAveragesOfParkingStructureCounts = async (id: string, options: ParkingStructureCountOptions): Promise => { + const queue = this.historicalData.get(id); + if (!queue || queue.size() === 0) { + return []; + } + + 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(queue, currentIntervalStart, currentIntervalEnd); + + if (recordsInInterval.length > 0) { + const averageResult = this.calculateAverageForInterval(currentIntervalStart, currentIntervalEnd, recordsInInterval); + results.push(averageResult); + } + + currentIntervalStart = currentIntervalEnd; + } + + return results; + }; + + private getRecordsInTimeRange = ( + queue: CircularQueue, + startMs: number, + endMs: number + ): IParkingStructureTimestampRecord[] => { + const recordsInInterval: IParkingStructureTimestampRecord[] = []; + + for (let i = 0; i < queue.size(); i++) { + const record = queue.get(i); + if (record && record.timestampMs >= startMs && record.timestampMs < endMs) { + recordsInInterval.push(record); + } + } + + return recordsInInterval; + }; + + 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 + }; }; } diff --git a/src/repositories/ParkingGetterRepository.ts b/src/repositories/ParkingGetterRepository.ts index c098c0e..8ef13c9 100644 --- a/src/repositories/ParkingGetterRepository.ts +++ b/src/repositories/ParkingGetterRepository.ts @@ -9,7 +9,7 @@ export interface ParkingStructureCountOptions { export interface HistoricalParkingAverageQueryResult { fromUnixEpochMs: number; toUnixEpochMs: number; - averageSpotsTaken: number; + averageSpotsAvailable: number; } diff --git a/test/repositories/InMemoryParkingRepositoryTests.test.ts b/test/repositories/InMemoryParkingRepositoryTests.test.ts index 24c292f..ff74c68 100644 --- a/test/repositories/InMemoryParkingRepositoryTests.test.ts +++ b/test/repositories/InMemoryParkingRepositoryTests.test.ts @@ -5,6 +5,7 @@ import { } from "../../src/repositories/InMemoryParkingRepository"; import { IParkingStructure, IParkingStructureTimestampRecord } from "../../src/entities/ParkingRepositoryEntities"; import { CircularQueue } from "../../src/types/CircularQueue"; +import { ParkingStructureCountOptions } from "../../src/repositories/ParkingGetterRepository"; describe("InMemoryParkingRepository", () => { let repository: InMemoryParkingRepository; @@ -145,4 +146,112 @@ describe("InMemoryParkingRepository", () => { expect(result).toEqual(testStructure); }); }); + + describe("getHistoricalAveragesOfParkingStructureCounts", () => { + const sortingCallback = (a: IParkingStructureTimestampRecord, b: IParkingStructureTimestampRecord) => a.timestampMs - b.timestampMs; + + const setupHistoricalData = (records: IParkingStructureTimestampRecord[]) => { + const queue = new CircularQueue(10); + records.forEach(record => queue.appendWithSorting(record, sortingCallback)); + historicalData.set(testStructure.id, queue); + }; + + it("should return empty array for non-existent structure or no data", async () => { + const options: ParkingStructureCountOptions = { + startUnixEpochMs: 1000, + endUnixEpochMs: 2000, + intervalMs: 500 + }; + + // Non-existent structure + expect(await repository.getHistoricalAveragesOfParkingStructureCounts("non-existent", options)).toEqual([]); + + // Structure with no historical data + await repository.addOrUpdateParkingStructure(testStructure); + expect(await repository.getHistoricalAveragesOfParkingStructureCounts(testStructure.id, options)).toEqual([]); + }); + + it("should calculate averages for single and multiple intervals", async () => { + const records = [ + { id: testStructure.id, spotsAvailable: 80, timestampMs: 1100 }, + { id: testStructure.id, spotsAvailable: 70, timestampMs: 1200 }, + { id: testStructure.id, spotsAvailable: 50, timestampMs: 1600 }, + { id: testStructure.id, spotsAvailable: 40, timestampMs: 1700 } + ]; + + setupHistoricalData(records); + await repository.addOrUpdateParkingStructure(testStructure); + + // Single interval test + const singleIntervalOptions: ParkingStructureCountOptions = { + startUnixEpochMs: 1000, + endUnixEpochMs: 1500, + intervalMs: 500 + }; + + const singleResult = await repository.getHistoricalAveragesOfParkingStructureCounts(testStructure.id, singleIntervalOptions); + expect(singleResult).toHaveLength(1); + expect(singleResult[0]).toEqual({ + fromUnixEpochMs: 1000, + toUnixEpochMs: 1500, + averageSpotsAvailable: 75 // (80 + 70) / 2 + }); + + // Multiple intervals test + const multipleIntervalOptions: ParkingStructureCountOptions = { + startUnixEpochMs: 1000, + endUnixEpochMs: 2000, + intervalMs: 500 + }; + + const multipleResult = await repository.getHistoricalAveragesOfParkingStructureCounts(testStructure.id, multipleIntervalOptions); + expect(multipleResult).toHaveLength(2); + expect(multipleResult[0]).toEqual({ + fromUnixEpochMs: 1000, + toUnixEpochMs: 1500, + averageSpotsAvailable: 75 // (80 + 70) / 2 + }); + expect(multipleResult[1]).toEqual({ + fromUnixEpochMs: 1500, + toUnixEpochMs: 2000, + averageSpotsAvailable: 45 // (50 + 40) / 2 + }); + }); + + it("should handle edge cases: skipped intervals and boundaries", async () => { + const records = [ + { id: testStructure.id, spotsAvailable: 90, timestampMs: 1000 }, // start boundary - included + { id: testStructure.id, spotsAvailable: 80, timestampMs: 1500 }, // interval boundary - included in second + { id: testStructure.id, spotsAvailable: 60, timestampMs: 2100 } // skip interval, in third + ]; + + setupHistoricalData(records); + await repository.addOrUpdateParkingStructure(testStructure); + + const options: ParkingStructureCountOptions = { + startUnixEpochMs: 1000, + endUnixEpochMs: 2500, + intervalMs: 500 + }; + + const result = await repository.getHistoricalAveragesOfParkingStructureCounts(testStructure.id, options); + + expect(result).toHaveLength(3); + expect(result[0]).toEqual({ + fromUnixEpochMs: 1000, + toUnixEpochMs: 1500, + averageSpotsAvailable: 90 // only record at 1000ms + }); + expect(result[1]).toEqual({ + fromUnixEpochMs: 1500, + toUnixEpochMs: 2000, + averageSpotsAvailable: 80 // only record at 1500ms + }); + expect(result[2]).toEqual({ + fromUnixEpochMs: 2000, + toUnixEpochMs: 2500, + averageSpotsAvailable: 60 // only record at 2100ms + }); + }); + }); }); From 868a9f3b1d5fa1b6b96ebcc59c6739fa9b097d30 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Wed, 2 Jul 2025 19:40:45 -0400 Subject: [PATCH 18/28] Add RedisParkingRepository.ts and convert existing tests to shared tests for both classes --- .../RedisNotificationRepository.ts | 4 +- src/repositories/RedisParkingRepository.ts | 265 ++++++++++++++++++ .../InMemoryParkingRepositoryTests.test.ts | 257 ----------------- .../ParkingRepositorySharedTests.test.ts | 202 +++++++++++++ 4 files changed, 469 insertions(+), 259 deletions(-) create mode 100644 src/repositories/RedisParkingRepository.ts delete mode 100644 test/repositories/InMemoryParkingRepositoryTests.test.ts create mode 100644 test/repositories/ParkingRepositorySharedTests.test.ts diff --git a/src/repositories/RedisNotificationRepository.ts b/src/repositories/RedisNotificationRepository.ts index ad189bf..cb4a30c 100644 --- a/src/repositories/RedisNotificationRepository.ts +++ b/src/repositories/RedisNotificationRepository.ts @@ -16,8 +16,8 @@ export class RedisNotificationRepository implements NotificationRepository { private redisClient = createClient({ url: process.env.REDIS_URL, socket: { - tls: (process.env.REDIS_URL?.match(/rediss:/) != null), - rejectUnauthorized: false, + tls: (process.env.REDIS_URL?.match(/rediss:/) != null), + rejectUnauthorized: false, } }), ) { diff --git a/src/repositories/RedisParkingRepository.ts b/src/repositories/RedisParkingRepository.ts new file mode 100644 index 0000000..07fa188 --- /dev/null +++ b/src/repositories/RedisParkingRepository.ts @@ -0,0 +1,265 @@ +import { createClient, RedisClientType } from 'redis'; +import { ParkingGetterSetterRepository } from "./ParkingGetterSetterRepository"; +import { IParkingStructure, IParkingStructureTimestampRecord } from "../entities/ParkingRepositoryEntities"; +import { HistoricalParkingAverageQueryResult, ParkingStructureCountOptions } from "./ParkingGetterRepository"; + +export type ParkingStructureID = string; + +// Every 10 minutes +export const PARKING_LOGGING_INTERVAL_MS = 600000; + +export class RedisParkingRepository implements ParkingGetterSetterRepository { + private dataLastAdded: Map = new Map(); + + constructor( + private redisClient = createClient({ + url: process.env.REDIS_URL, + socket: { + tls: (process.env.REDIS_URL?.match(/rediss:/) != null), + rejectUnauthorized: false, + } + }), + ) { + } + + 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(); + this.dataLastAdded.clear(); + } + + addOrUpdateParkingStructure = async (structure: IParkingStructure): Promise => { + // Store current structure data + await this.redisClient.hSet(`parking:structure:${structure.id}`, { + 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() + }); + + // Add to historical data if needed + await this.addHistoricalDataForStructure(structure); + }; + + private addHistoricalDataForStructure = async (structure: IParkingStructure): Promise => { + const now = Date.now(); + const lastAdded = this.dataLastAdded.get(structure.id); + + const parkingLoggingIntervalExceeded = () => { + return !lastAdded || (now - lastAdded.getTime()) >= PARKING_LOGGING_INTERVAL_MS; + }; + + if (parkingLoggingIntervalExceeded()) { + // Use Redis Time Series to store historical data + const timeSeriesKey = `parking:timeseries:${structure.id}`; + + try { + // Try to add the time series data point + await this.redisClient.sendCommand([ + 'TS.ADD', + timeSeriesKey, + now.toString(), + structure.spotsAvailable.toString(), + 'LABELS', + 'structureId', + structure.id + ]); + } catch (error) { + // If time series doesn't exist, create it first + try { + await this.redisClient.sendCommand([ + 'TS.CREATE', + timeSeriesKey, + 'LABELS', + 'structureId', + structure.id + ]); + // Now add the data point + await this.redisClient.sendCommand([ + 'TS.ADD', + timeSeriesKey, + now.toString(), + structure.spotsAvailable.toString() + ]); + } catch (createError) { + // If still fails, it might be because time series already exists, try adding again + await this.redisClient.sendCommand([ + 'TS.ADD', + timeSeriesKey, + now.toString(), + structure.spotsAvailable.toString() + ]); + } + } + + this.dataLastAdded.set(structure.id, new Date(now)); + } + }; + + clearParkingStructureData = async (): Promise => { + // Get all parking structure keys + const structureKeys = await this.redisClient.keys('parking:structure:*'); + const timeSeriesKeys = await this.redisClient.keys('parking:timeseries:*'); + + // Delete all structure and time series data + if (structureKeys.length > 0) { + await this.redisClient.del(structureKeys); + } + if (timeSeriesKeys.length > 0) { + await this.redisClient.del(timeSeriesKeys); + } + + this.dataLastAdded.clear(); + }; + + getParkingStructureById = async (id: string): Promise => { + const data = await this.redisClient.hGetAll(`parking:structure:${id}`); + + if (Object.keys(data).length === 0) { + return null; + } + + return { + 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) + }; + }; + + 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({ + 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) + }); + } + } + + return structures; + }; + + removeParkingStructureIfExists = async (id: string): Promise => { + const structure = await this.getParkingStructureById(id); + if (structure) { + await this.redisClient.del(`parking:structure:${id}`); + await this.redisClient.del(`parking:timeseries:${id}`); + this.dataLastAdded.delete(id); + return structure; + } + return null; + }; + + getHistoricalAveragesOfParkingStructureCounts = async (id: string, options: ParkingStructureCountOptions): Promise => { + const timeSeriesKey = `parking:timeseries:${id}`; + + try { + // Get time series data for the specified range + const timeSeriesData = await this.redisClient.sendCommand([ + 'TS.RANGE', + timeSeriesKey, + options.startUnixEpochMs.toString(), + options.endUnixEpochMs.toString() + ]) as [string, string][]; + + if (!timeSeriesData || timeSeriesData.length === 0) { + return []; + } + + // Convert Redis time series data to our record format + const records: IParkingStructureTimestampRecord[] = timeSeriesData.map(([timestamp, value]) => ({ + id, + timestampMs: parseInt(timestamp), + spotsAvailable: parseInt(value) + })); + + return this.calculateAveragesFromRecords(records, options); + } catch (error) { + // Time series might not exist + return []; + } + }; + + 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 + }; + }; +} diff --git a/test/repositories/InMemoryParkingRepositoryTests.test.ts b/test/repositories/InMemoryParkingRepositoryTests.test.ts deleted file mode 100644 index ff74c68..0000000 --- a/test/repositories/InMemoryParkingRepositoryTests.test.ts +++ /dev/null @@ -1,257 +0,0 @@ -import { beforeEach, describe, expect, it, jest } from "@jest/globals"; -import { - InMemoryParkingRepository, - PARKING_LOGGING_INTERVAL_MS, ParkingStructureID -} from "../../src/repositories/InMemoryParkingRepository"; -import { IParkingStructure, IParkingStructureTimestampRecord } from "../../src/entities/ParkingRepositoryEntities"; -import { CircularQueue } from "../../src/types/CircularQueue"; -import { ParkingStructureCountOptions } from "../../src/repositories/ParkingGetterRepository"; - -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(), - }; - let historicalData: Map> = new Map(); - - beforeEach(() => { - historicalData = new Map(); - repository = new InMemoryParkingRepository(new Map(), historicalData); - }); - - 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); - }); - - it("should log historical data if past the logging interval", async () => { - const now = Date.now(); - jest - .useFakeTimers() - .setSystemTime(now); - - const expectedTimestampRecordMatcher = { - spotsAvailable: testStructure.spotsAvailable, - id: testStructure.id, - timestampMs: now, - } - - await repository.addOrUpdateParkingStructure(testStructure); - jest.setSystemTime(now + PARKING_LOGGING_INTERVAL_MS + 60); - await repository.addOrUpdateParkingStructure(testStructure); - - expect(historicalData.get(testStructure.id)?.get(0)).toEqual(expectedTimestampRecordMatcher); - expect(historicalData.get(testStructure.id)?.get(1)).toEqual({ - ...expectedTimestampRecordMatcher, - timestampMs: now + PARKING_LOGGING_INTERVAL_MS + 60, - }); - }); - - it("should not log historical data if not past the logging interval", async () => { - const now = Date.now(); - jest - .useFakeTimers() - .setSystemTime(now); - - await repository.addOrUpdateParkingStructure(testStructure); - jest.setSystemTime(now + 60); - await repository.addOrUpdateParkingStructure(testStructure); - - expect(historicalData.get(testStructure.id)?.size()).toEqual(1); - }); - }); - - 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", () => { - const sortingCallback = (a: IParkingStructureTimestampRecord, b: IParkingStructureTimestampRecord) => a.timestampMs - b.timestampMs; - - const setupHistoricalData = (records: IParkingStructureTimestampRecord[]) => { - const queue = new CircularQueue(10); - records.forEach(record => queue.appendWithSorting(record, sortingCallback)); - historicalData.set(testStructure.id, queue); - }; - - it("should return empty array for non-existent structure or no data", async () => { - const options: ParkingStructureCountOptions = { - startUnixEpochMs: 1000, - endUnixEpochMs: 2000, - intervalMs: 500 - }; - - // Non-existent structure - expect(await repository.getHistoricalAveragesOfParkingStructureCounts("non-existent", options)).toEqual([]); - - // Structure with no historical data - await repository.addOrUpdateParkingStructure(testStructure); - expect(await repository.getHistoricalAveragesOfParkingStructureCounts(testStructure.id, options)).toEqual([]); - }); - - it("should calculate averages for single and multiple intervals", async () => { - const records = [ - { id: testStructure.id, spotsAvailable: 80, timestampMs: 1100 }, - { id: testStructure.id, spotsAvailable: 70, timestampMs: 1200 }, - { id: testStructure.id, spotsAvailable: 50, timestampMs: 1600 }, - { id: testStructure.id, spotsAvailable: 40, timestampMs: 1700 } - ]; - - setupHistoricalData(records); - await repository.addOrUpdateParkingStructure(testStructure); - - // Single interval test - const singleIntervalOptions: ParkingStructureCountOptions = { - startUnixEpochMs: 1000, - endUnixEpochMs: 1500, - intervalMs: 500 - }; - - const singleResult = await repository.getHistoricalAveragesOfParkingStructureCounts(testStructure.id, singleIntervalOptions); - expect(singleResult).toHaveLength(1); - expect(singleResult[0]).toEqual({ - fromUnixEpochMs: 1000, - toUnixEpochMs: 1500, - averageSpotsAvailable: 75 // (80 + 70) / 2 - }); - - // Multiple intervals test - const multipleIntervalOptions: ParkingStructureCountOptions = { - startUnixEpochMs: 1000, - endUnixEpochMs: 2000, - intervalMs: 500 - }; - - const multipleResult = await repository.getHistoricalAveragesOfParkingStructureCounts(testStructure.id, multipleIntervalOptions); - expect(multipleResult).toHaveLength(2); - expect(multipleResult[0]).toEqual({ - fromUnixEpochMs: 1000, - toUnixEpochMs: 1500, - averageSpotsAvailable: 75 // (80 + 70) / 2 - }); - expect(multipleResult[1]).toEqual({ - fromUnixEpochMs: 1500, - toUnixEpochMs: 2000, - averageSpotsAvailable: 45 // (50 + 40) / 2 - }); - }); - - it("should handle edge cases: skipped intervals and boundaries", async () => { - const records = [ - { id: testStructure.id, spotsAvailable: 90, timestampMs: 1000 }, // start boundary - included - { id: testStructure.id, spotsAvailable: 80, timestampMs: 1500 }, // interval boundary - included in second - { id: testStructure.id, spotsAvailable: 60, timestampMs: 2100 } // skip interval, in third - ]; - - setupHistoricalData(records); - await repository.addOrUpdateParkingStructure(testStructure); - - const options: ParkingStructureCountOptions = { - startUnixEpochMs: 1000, - endUnixEpochMs: 2500, - intervalMs: 500 - }; - - const result = await repository.getHistoricalAveragesOfParkingStructureCounts(testStructure.id, options); - - expect(result).toHaveLength(3); - expect(result[0]).toEqual({ - fromUnixEpochMs: 1000, - toUnixEpochMs: 1500, - averageSpotsAvailable: 90 // only record at 1000ms - }); - expect(result[1]).toEqual({ - fromUnixEpochMs: 1500, - toUnixEpochMs: 2000, - averageSpotsAvailable: 80 // only record at 1500ms - }); - expect(result[2]).toEqual({ - fromUnixEpochMs: 2000, - toUnixEpochMs: 2500, - averageSpotsAvailable: 60 // only record at 2100ms - }); - }); - }); -}); diff --git a/test/repositories/ParkingRepositorySharedTests.test.ts b/test/repositories/ParkingRepositorySharedTests.test.ts new file mode 100644 index 0000000..319008c --- /dev/null +++ b/test/repositories/ParkingRepositorySharedTests.test.ts @@ -0,0 +1,202 @@ +import { afterEach, beforeEach, describe, expect, it, jest } from "@jest/globals"; +import { InMemoryParkingRepository, PARKING_LOGGING_INTERVAL_MS, ParkingStructureID } from "../../src/repositories/InMemoryParkingRepository"; +import { IParkingStructure, IParkingStructureTimestampRecord } from "../../src/entities/ParkingRepositoryEntities"; +import { CircularQueue } from "../../src/types/CircularQueue"; +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(); + }); + + 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 + }; + + // Non-existent structure + expect(await repository.getHistoricalAveragesOfParkingStructureCounts("non-existent", options)).toEqual([]); + + // Structure with no historical data + await repository.addOrUpdateParkingStructure(testStructure); + expect(await repository.getHistoricalAveragesOfParkingStructureCounts(testStructure.id, options)).toEqual([]); + }); + + // it("should calculate averages for intervals with manual historical data", async () => { + // await repository.addOrUpdateParkingStructure(testStructure); + // + // const now = Date.now(); + // jest.useFakeTimers().setSystemTime(now); + // + // const updates = [ + // { ...testStructure, spotsAvailable: 80, updatedTime: new Date(now + 1000) }, + // { ...testStructure, spotsAvailable: 70, updatedTime: new Date(now + 2000) }, + // ]; + // + // for (let i = 0; i < updates.length; i++) { + // jest.setSystemTime(now + (i + 1) * PARKING_LOGGING_INTERVAL_MS + 100); + // await repository.addOrUpdateParkingStructure(updates[i]); + // } + // + // const options: ParkingStructureCountOptions = { + // startUnixEpochMs: now, + // endUnixEpochMs: now + 4000000, // Large range to capture all data + // intervalMs: 1000000 + // }; + // + // const result = await repository.getHistoricalAveragesOfParkingStructureCounts(testStructure.id, options); + // + // // Should have at least some historical data + // expect(result.length).toBeGreaterThan(0); + // if (result.length > 0) { + // expect(result[0]).toHaveProperty('fromUnixEpochMs'); + // expect(result[0]).toHaveProperty('toUnixEpochMs'); + // expect(result[0]).toHaveProperty('averageSpotsAvailable'); + // expect(typeof result[0].averageSpotsAvailable).toBe('number'); + // } + // }); + }); +}); From 19336ce6ec3560fc017a5b978aa5d2a849726232 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Wed, 2 Jul 2025 19:47:44 -0400 Subject: [PATCH 19/28] Add setLoggingInterval method and implementation for tests and other purposes. Let users control the logging interval of both parking repositories. --- src/repositories/InMemoryParkingRepository.ts | 11 ++- .../ParkingGetterSetterRepository.ts | 2 + src/repositories/RedisParkingRepository.ts | 9 ++- .../ParkingRepositorySharedTests.test.ts | 77 ++++++++++--------- 4 files changed, 57 insertions(+), 42 deletions(-) diff --git a/src/repositories/InMemoryParkingRepository.ts b/src/repositories/InMemoryParkingRepository.ts index e162dcb..05c7b3e 100644 --- a/src/repositories/InMemoryParkingRepository.ts +++ b/src/repositories/InMemoryParkingRepository.ts @@ -17,6 +17,7 @@ export const MAX_NUM_ENTRIES = 2016; export class InMemoryParkingRepository implements ParkingGetterSetterRepository { private dataLastAdded: Map = new Map(); + private loggingIntervalMs = PARKING_LOGGING_INTERVAL_MS; constructor( private structures: Map = new Map(), @@ -33,9 +34,9 @@ export class InMemoryParkingRepository implements ParkingGetterSetterRepository const now = Date.now(); const lastAdded = this.dataLastAdded.get(structure.id); - function parkingLoggingIntervalExceeded() { - return !lastAdded || (now - lastAdded.getTime()) >= PARKING_LOGGING_INTERVAL_MS; - } + const parkingLoggingIntervalExceeded = () => { + return !lastAdded || (now - lastAdded.getTime()) >= this.loggingIntervalMs; + }; if (parkingLoggingIntervalExceeded()) { const timestampRecord: IParkingStructureTimestampRecord = { @@ -131,4 +132,8 @@ export class InMemoryParkingRepository implements ParkingGetterSetterRepository averageSpotsAvailable }; }; + + setLoggingInterval = (intervalMs: number): void => { + this.loggingIntervalMs = intervalMs; + }; } 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/RedisParkingRepository.ts b/src/repositories/RedisParkingRepository.ts index 07fa188..99be870 100644 --- a/src/repositories/RedisParkingRepository.ts +++ b/src/repositories/RedisParkingRepository.ts @@ -1,4 +1,4 @@ -import { createClient, RedisClientType } from 'redis'; +import { createClient } from 'redis'; import { ParkingGetterSetterRepository } from "./ParkingGetterSetterRepository"; import { IParkingStructure, IParkingStructureTimestampRecord } from "../entities/ParkingRepositoryEntities"; import { HistoricalParkingAverageQueryResult, ParkingStructureCountOptions } from "./ParkingGetterRepository"; @@ -10,6 +10,7 @@ export const PARKING_LOGGING_INTERVAL_MS = 600000; export class RedisParkingRepository implements ParkingGetterSetterRepository { private dataLastAdded: Map = new Map(); + private loggingIntervalMs = PARKING_LOGGING_INTERVAL_MS; constructor( private redisClient = createClient({ @@ -61,7 +62,7 @@ export class RedisParkingRepository implements ParkingGetterSetterRepository { const lastAdded = this.dataLastAdded.get(structure.id); const parkingLoggingIntervalExceeded = () => { - return !lastAdded || (now - lastAdded.getTime()) >= PARKING_LOGGING_INTERVAL_MS; + return !lastAdded || (now - lastAdded.getTime()) >= this.loggingIntervalMs; }; if (parkingLoggingIntervalExceeded()) { @@ -262,4 +263,8 @@ export class RedisParkingRepository implements ParkingGetterSetterRepository { averageSpotsAvailable }; }; + + setLoggingInterval = (intervalMs: number): void => { + this.loggingIntervalMs = intervalMs; + }; } diff --git a/test/repositories/ParkingRepositorySharedTests.test.ts b/test/repositories/ParkingRepositorySharedTests.test.ts index 319008c..58c6880 100644 --- a/test/repositories/ParkingRepositorySharedTests.test.ts +++ b/test/repositories/ParkingRepositorySharedTests.test.ts @@ -1,7 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, jest } from "@jest/globals"; -import { InMemoryParkingRepository, PARKING_LOGGING_INTERVAL_MS, ParkingStructureID } from "../../src/repositories/InMemoryParkingRepository"; -import { IParkingStructure, IParkingStructureTimestampRecord } from "../../src/entities/ParkingRepositoryEntities"; -import { CircularQueue } from "../../src/types/CircularQueue"; +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"; @@ -38,7 +37,7 @@ class RedisParkingRepositoryHolder implements RepositoryHolder { } const repositoryImplementations = [ - // new InMemoryParkingRepositoryHolder(), + new InMemoryParkingRepositoryHolder(), new RedisParkingRepositoryHolder(), ]; @@ -165,38 +164,42 @@ describe.each(repositoryImplementations)('$name', (holder) => { expect(await repository.getHistoricalAveragesOfParkingStructureCounts(testStructure.id, options)).toEqual([]); }); - // it("should calculate averages for intervals with manual historical data", async () => { - // await repository.addOrUpdateParkingStructure(testStructure); - // - // const now = Date.now(); - // jest.useFakeTimers().setSystemTime(now); - // - // const updates = [ - // { ...testStructure, spotsAvailable: 80, updatedTime: new Date(now + 1000) }, - // { ...testStructure, spotsAvailable: 70, updatedTime: new Date(now + 2000) }, - // ]; - // - // for (let i = 0; i < updates.length; i++) { - // jest.setSystemTime(now + (i + 1) * PARKING_LOGGING_INTERVAL_MS + 100); - // await repository.addOrUpdateParkingStructure(updates[i]); - // } - // - // const options: ParkingStructureCountOptions = { - // startUnixEpochMs: now, - // endUnixEpochMs: now + 4000000, // Large range to capture all data - // intervalMs: 1000000 - // }; - // - // const result = await repository.getHistoricalAveragesOfParkingStructureCounts(testStructure.id, options); - // - // // Should have at least some historical data - // expect(result.length).toBeGreaterThan(0); - // if (result.length > 0) { - // expect(result[0]).toHaveProperty('fromUnixEpochMs'); - // expect(result[0]).toHaveProperty('toUnixEpochMs'); - // expect(result[0]).toHaveProperty('averageSpotsAvailable'); - // expect(typeof result[0].averageSpotsAvailable).toBe('number'); - // } - // }); + 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() }, + ]; + + // Add updates with small delays to ensure different timestamps + for (let i = 0; i < updates.length; i++) { + await repository.addOrUpdateParkingStructure(updates[i]); + await new Promise(resolve => setTimeout(resolve, 10)); // Small delay + } + + 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).toBeGreaterThan(0); + if (result.length > 0) { + expect(result[0]).toHaveProperty('fromUnixEpochMs'); + expect(result[0]).toHaveProperty('toUnixEpochMs'); + expect(result[0]).toHaveProperty('averageSpotsAvailable'); + expect(typeof result[0].averageSpotsAvailable).toBe('number'); + expect(result[0].averageSpotsAvailable).toBeGreaterThan(0); + } + }); }); }); From 53632fe470eb90b5cde19175e3048a382e8a15a6 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Wed, 2 Jul 2025 20:00:44 -0400 Subject: [PATCH 20/28] Add BaseRedisRepository as parent class of RedisNotificationRepository.ts and RedisParkingRepository.ts --- src/repositories/BaseRedisRepository.ts | 33 ++++++++ .../RedisNotificationRepository.ts | 76 +++++-------------- src/repositories/RedisParkingRepository.ts | 32 +------- 3 files changed, 56 insertions(+), 85 deletions(-) create mode 100644 src/repositories/BaseRedisRepository.ts 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/RedisNotificationRepository.ts b/src/repositories/RedisNotificationRepository.ts index cb4a30c..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 index 99be870..f3beb2f 100644 --- a/src/repositories/RedisParkingRepository.ts +++ b/src/repositories/RedisParkingRepository.ts @@ -1,45 +1,17 @@ -import { createClient } from 'redis'; import { ParkingGetterSetterRepository } from "./ParkingGetterSetterRepository"; import { IParkingStructure, IParkingStructureTimestampRecord } from "../entities/ParkingRepositoryEntities"; import { HistoricalParkingAverageQueryResult, ParkingStructureCountOptions } from "./ParkingGetterRepository"; +import { BaseRedisRepository } from "./BaseRedisRepository"; export type ParkingStructureID = string; // Every 10 minutes export const PARKING_LOGGING_INTERVAL_MS = 600000; -export class RedisParkingRepository implements ParkingGetterSetterRepository { +export class RedisParkingRepository extends BaseRedisRepository implements ParkingGetterSetterRepository { private dataLastAdded: Map = new Map(); private loggingIntervalMs = PARKING_LOGGING_INTERVAL_MS; - constructor( - private redisClient = createClient({ - url: process.env.REDIS_URL, - socket: { - tls: (process.env.REDIS_URL?.match(/rediss:/) != null), - rejectUnauthorized: false, - } - }), - ) { - } - - 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(); - this.dataLastAdded.clear(); - } - addOrUpdateParkingStructure = async (structure: IParkingStructure): Promise => { // Store current structure data await this.redisClient.hSet(`parking:structure:${structure.id}`, { From 7f453157ee8885f08b5a6293d7f16641f32bd45b Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Wed, 2 Jul 2025 20:34:29 -0400 Subject: [PATCH 21/28] Update timeout within test to avoid UPSERT error --- test/repositories/ParkingRepositorySharedTests.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/test/repositories/ParkingRepositorySharedTests.test.ts b/test/repositories/ParkingRepositorySharedTests.test.ts index 58c6880..729e88b 100644 --- a/test/repositories/ParkingRepositorySharedTests.test.ts +++ b/test/repositories/ParkingRepositorySharedTests.test.ts @@ -156,10 +156,8 @@ describe.each(repositoryImplementations)('$name', (holder) => { intervalMs: 500 }; - // Non-existent structure expect(await repository.getHistoricalAveragesOfParkingStructureCounts("non-existent", options)).toEqual([]); - // Structure with no historical data await repository.addOrUpdateParkingStructure(testStructure); expect(await repository.getHistoricalAveragesOfParkingStructureCounts(testStructure.id, options)).toEqual([]); }); @@ -179,7 +177,7 @@ describe.each(repositoryImplementations)('$name', (holder) => { // Add updates with small delays to ensure different timestamps for (let i = 0; i < updates.length; i++) { await repository.addOrUpdateParkingStructure(updates[i]); - await new Promise(resolve => setTimeout(resolve, 10)); // Small delay + await new Promise(resolve => setTimeout(resolve, 100)); // Small delay } const now = Date.now(); From b9d5f7b3df1966d8954cbdde08407b360eef7f14 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Thu, 3 Jul 2025 15:19:57 -0400 Subject: [PATCH 22/28] Refactor parking repositories to use dedicated Constants file and smaller functions --- src/repositories/InMemoryParkingRepository.ts | 109 +++++---- .../ParkingRepositoryConstants.ts | 3 + src/repositories/RedisParkingRepository.ts | 208 +++++++++--------- .../ParkingRepositorySharedTests.test.ts | 3 +- 4 files changed, 170 insertions(+), 153 deletions(-) create mode 100644 src/repositories/ParkingRepositoryConstants.ts diff --git a/src/repositories/InMemoryParkingRepository.ts b/src/repositories/InMemoryParkingRepository.ts index 05c7b3e..741cfd9 100644 --- a/src/repositories/InMemoryParkingRepository.ts +++ b/src/repositories/InMemoryParkingRepository.ts @@ -5,16 +5,13 @@ import { } 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; -// Every 10 minutes -// 6x per hour * 24x per day * 7x per week = 1008 entries for one week -export const PARKING_LOGGING_INTERVAL_MS = 600000; - -// This will last two weeks -export const MAX_NUM_ENTRIES = 2016; - export class InMemoryParkingRepository implements ParkingGetterSetterRepository { private dataLastAdded: Map = new Map(); private loggingIntervalMs = PARKING_LOGGING_INTERVAL_MS; @@ -34,29 +31,18 @@ export class InMemoryParkingRepository implements ParkingGetterSetterRepository const now = Date.now(); const lastAdded = this.dataLastAdded.get(structure.id); - const parkingLoggingIntervalExceeded = () => { - return !lastAdded || (now - lastAdded.getTime()) >= this.loggingIntervalMs; - }; - - if (parkingLoggingIntervalExceeded()) { - const timestampRecord: IParkingStructureTimestampRecord = { - id: structure.id, - spotsAvailable: structure.spotsAvailable, - timestampMs: now, - }; - - if (!this.historicalData.has(structure.id)) { - this.historicalData.set(structure.id, new CircularQueue(MAX_NUM_ENTRIES)); - } - - const sortingCallback = (a: IParkingStructureTimestampRecord, b: IParkingStructureTimestampRecord) => a.timestampMs - b.timestampMs; - this.historicalData.get(structure.id)?.appendWithSorting(timestampRecord, sortingCallback); + 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(); }; getParkingStructureById = async (id: string): Promise => { @@ -70,6 +56,8 @@ export class InMemoryParkingRepository implements ParkingGetterSetterRepository const structure = this.structures.get(id); if (structure) { this.structures.delete(id); + this.historicalData.delete(id); + this.dataLastAdded.delete(id); return { ...structure }; } return null; @@ -81,41 +69,74 @@ export class InMemoryParkingRepository implements ParkingGetterSetterRepository 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(queue, currentIntervalStart, currentIntervalEnd); - + 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 = ( - queue: CircularQueue, - startMs: number, + records: IParkingStructureTimestampRecord[], + startMs: number, endMs: number ): IParkingStructureTimestampRecord[] => { - const recordsInInterval: IParkingStructureTimestampRecord[] = []; - - for (let i = 0; i < queue.size(); i++) { - const record = queue.get(i); - if (record && record.timestampMs >= startMs && record.timestampMs < endMs) { - recordsInInterval.push(record); - } - } - - return recordsInInterval; + return records.filter(record => + record.timestampMs >= startMs && record.timestampMs < endMs + ); }; private calculateAverageForInterval = ( @@ -125,7 +146,7 @@ export class InMemoryParkingRepository implements ParkingGetterSetterRepository ): HistoricalParkingAverageQueryResult => { const totalSpotsAvailable = records.reduce((sum, record) => sum + record.spotsAvailable, 0); const averageSpotsAvailable = totalSpotsAvailable / records.length; - + return { fromUnixEpochMs: fromMs, toUnixEpochMs: toMs, 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/RedisParkingRepository.ts b/src/repositories/RedisParkingRepository.ts index f3beb2f..d71a953 100644 --- a/src/repositories/RedisParkingRepository.ts +++ b/src/repositories/RedisParkingRepository.ts @@ -2,30 +2,17 @@ import { ParkingGetterSetterRepository } from "./ParkingGetterSetterRepository"; import { IParkingStructure, IParkingStructureTimestampRecord } from "../entities/ParkingRepositoryEntities"; import { HistoricalParkingAverageQueryResult, ParkingStructureCountOptions } from "./ParkingGetterRepository"; import { BaseRedisRepository } from "./BaseRedisRepository"; +import { PARKING_LOGGING_INTERVAL_MS } from "./ParkingRepositoryConstants"; export type ParkingStructureID = string; -// Every 10 minutes -export const PARKING_LOGGING_INTERVAL_MS = 600000; - export class RedisParkingRepository extends BaseRedisRepository implements ParkingGetterSetterRepository { private dataLastAdded: Map = new Map(); private loggingIntervalMs = PARKING_LOGGING_INTERVAL_MS; addOrUpdateParkingStructure = async (structure: IParkingStructure): Promise => { - // Store current structure data - await this.redisClient.hSet(`parking:structure:${structure.id}`, { - 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() - }); - - // Add to historical data if needed + const keys = this.createRedisKeys(structure.id); + await this.redisClient.hSet(keys.structure, this.createRedisHashFromStructure(structure)); await this.addHistoricalDataForStructure(structure); }; @@ -33,92 +20,34 @@ export class RedisParkingRepository extends BaseRedisRepository implements Parki const now = Date.now(); const lastAdded = this.dataLastAdded.get(structure.id); - const parkingLoggingIntervalExceeded = () => { - return !lastAdded || (now - lastAdded.getTime()) >= this.loggingIntervalMs; - }; - - if (parkingLoggingIntervalExceeded()) { - // Use Redis Time Series to store historical data - const timeSeriesKey = `parking:timeseries:${structure.id}`; - - try { - // Try to add the time series data point - await this.redisClient.sendCommand([ - 'TS.ADD', - timeSeriesKey, - now.toString(), - structure.spotsAvailable.toString(), - 'LABELS', - 'structureId', - structure.id - ]); - } catch (error) { - // If time series doesn't exist, create it first - try { - await this.redisClient.sendCommand([ - 'TS.CREATE', - timeSeriesKey, - 'LABELS', - 'structureId', - structure.id - ]); - // Now add the data point - await this.redisClient.sendCommand([ - 'TS.ADD', - timeSeriesKey, - now.toString(), - structure.spotsAvailable.toString() - ]); - } catch (createError) { - // If still fails, it might be because time series already exists, try adding again - await this.redisClient.sendCommand([ - 'TS.ADD', - timeSeriesKey, - now.toString(), - structure.spotsAvailable.toString() - ]); - } - } - + 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 => { - // Get all parking structure keys const structureKeys = await this.redisClient.keys('parking:structure:*'); const timeSeriesKeys = await this.redisClient.keys('parking:timeseries:*'); - // Delete all structure and time series data - if (structureKeys.length > 0) { - await this.redisClient.del(structureKeys); - } - if (timeSeriesKeys.length > 0) { - await this.redisClient.del(timeSeriesKeys); + const allKeys = [...structureKeys, ...timeSeriesKeys]; + if (allKeys.length > 0) { + await this.redisClient.del(allKeys); } this.dataLastAdded.clear(); }; getParkingStructureById = async (id: string): Promise => { - const data = await this.redisClient.hGetAll(`parking:structure:${id}`); + const keys = this.createRedisKeys(id); + const data = await this.redisClient.hGetAll(keys.structure); if (Object.keys(data).length === 0) { return null; } - return { - 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) - }; + return this.createStructureFromRedisData(data); }; getParkingStructures = async (): Promise => { @@ -128,18 +57,7 @@ export class RedisParkingRepository extends BaseRedisRepository implements Parki for (const key of keys) { const data = await this.redisClient.hGetAll(key); if (Object.keys(data).length > 0) { - structures.push({ - 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) - }); + structures.push(this.createStructureFromRedisData(data)); } } @@ -149,8 +67,8 @@ export class RedisParkingRepository extends BaseRedisRepository implements Parki removeParkingStructureIfExists = async (id: string): Promise => { const structure = await this.getParkingStructureById(id); if (structure) { - await this.redisClient.del(`parking:structure:${id}`); - await this.redisClient.del(`parking:timeseries:${id}`); + const keys = this.createRedisKeys(id); + await this.redisClient.del([keys.structure, keys.timeSeries]); this.dataLastAdded.delete(id); return structure; } @@ -158,13 +76,12 @@ export class RedisParkingRepository extends BaseRedisRepository implements Parki }; getHistoricalAveragesOfParkingStructureCounts = async (id: string, options: ParkingStructureCountOptions): Promise => { - const timeSeriesKey = `parking:timeseries:${id}`; + const keys = this.createRedisKeys(id); try { - // Get time series data for the specified range const timeSeriesData = await this.redisClient.sendCommand([ 'TS.RANGE', - timeSeriesKey, + keys.timeSeries, options.startUnixEpochMs.toString(), options.endUnixEpochMs.toString() ]) as [string, string][]; @@ -173,20 +90,95 @@ export class RedisParkingRepository extends BaseRedisRepository implements Parki return []; } - // Convert Redis time series data to our record format - const records: IParkingStructureTimestampRecord[] = timeSeriesData.map(([timestamp, value]) => ({ - id, - timestampMs: parseInt(timestamp), - spotsAvailable: parseInt(value) - })); - + const records = this.convertTimeSeriesDataToRecords(timeSeriesData, id); return this.calculateAveragesFromRecords(records, options); } catch (error) { - // Time series might not exist return []; } }; + 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, + '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 convertTimeSeriesDataToRecords = (timeSeriesData: [string, string][], id: string): IParkingStructureTimestampRecord[] => { + return timeSeriesData.map(([timestamp, value]) => ({ + id, + timestampMs: parseInt(timestamp), + spotsAvailable: parseInt(value) + })); + }; + private calculateAveragesFromRecords = ( records: IParkingStructureTimestampRecord[], options: ParkingStructureCountOptions diff --git a/test/repositories/ParkingRepositorySharedTests.test.ts b/test/repositories/ParkingRepositorySharedTests.test.ts index 729e88b..21d9f41 100644 --- a/test/repositories/ParkingRepositorySharedTests.test.ts +++ b/test/repositories/ParkingRepositorySharedTests.test.ts @@ -63,6 +63,7 @@ describe.each(repositoryImplementations)('$name', (holder) => { afterEach(async () => { await holder.teardown(); + jest.useRealTimers(); }); describe("addOrUpdateParkingStructure", () => { @@ -177,7 +178,7 @@ describe.each(repositoryImplementations)('$name', (holder) => { // Add updates with small delays to ensure different timestamps for (let i = 0; i < updates.length; i++) { await repository.addOrUpdateParkingStructure(updates[i]); - await new Promise(resolve => setTimeout(resolve, 100)); // Small delay + await new Promise((resolve) => setTimeout(resolve, 200)); } const now = Date.now(); From df657a02f33e5ee80a1c8c83649efd51e40a04ef Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Thu, 3 Jul 2025 15:20:07 -0400 Subject: [PATCH 23/28] Build Redis parking repository instead of in-memory one --- src/entities/InterchangeSystem.ts | 34 +++++++++++++++++++++++++++---- 1 file changed, 30 insertions(+), 4 deletions(-) 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 }; } From 5f982fe18283757ddac1f4ef87ac930bb3272b77 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Thu, 3 Jul 2025 15:20:16 -0400 Subject: [PATCH 24/28] Add PARKING_LOGGING_INTERVAL_MS as an environment variable --- .env.example | 2 ++ docker-compose.yml | 1 + 2 files changed, 3 insertions(+) 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/docker-compose.yml b/docker-compose.yml index 5c71264..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: From 7c359dd775aa246049d09758539b54de1276493b Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Thu, 3 Jul 2025 15:40:14 -0400 Subject: [PATCH 25/28] Move the 200ms delay to before the add call This ensures that a new timestamp is created after the initial call to `addOrUpdateParkingStructure` --- test/repositories/ParkingRepositorySharedTests.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/repositories/ParkingRepositorySharedTests.test.ts b/test/repositories/ParkingRepositorySharedTests.test.ts index 21d9f41..d243b4e 100644 --- a/test/repositories/ParkingRepositorySharedTests.test.ts +++ b/test/repositories/ParkingRepositorySharedTests.test.ts @@ -175,10 +175,10 @@ describe.each(repositoryImplementations)('$name', (holder) => { { ...testStructure, spotsAvailable: 60, updatedTime: new Date() }, ]; - // Add updates with small delays to ensure different timestamps for (let i = 0; i < updates.length; i++) { - await repository.addOrUpdateParkingStructure(updates[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(); From f406e3e01d4705af49541d9ac603704c11c4efa0 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Thu, 3 Jul 2025 15:52:56 -0400 Subject: [PATCH 26/28] Add retention period for new time series --- src/repositories/RedisParkingRepository.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/repositories/RedisParkingRepository.ts b/src/repositories/RedisParkingRepository.ts index d71a953..dcb96e8 100644 --- a/src/repositories/RedisParkingRepository.ts +++ b/src/repositories/RedisParkingRepository.ts @@ -151,6 +151,8 @@ export class RedisParkingRepository extends BaseRedisRepository implements Parki await this.redisClient.sendCommand([ 'TS.CREATE', timeSeriesKey, + 'RETENTION', + '2678400000', // one month 'LABELS', 'structureId', structureId From d1b60772d8dd3b577fb8805414ca0386e14acc1e Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Thu, 3 Jul 2025 16:00:57 -0400 Subject: [PATCH 27/28] Update RedisParkingRepository.ts to use Redis aggregation functions --- src/repositories/RedisParkingRepository.ts | 89 +++++++--------------- 1 file changed, 28 insertions(+), 61 deletions(-) diff --git a/src/repositories/RedisParkingRepository.ts b/src/repositories/RedisParkingRepository.ts index dcb96e8..fae968c 100644 --- a/src/repositories/RedisParkingRepository.ts +++ b/src/repositories/RedisParkingRepository.ts @@ -1,5 +1,5 @@ import { ParkingGetterSetterRepository } from "./ParkingGetterSetterRepository"; -import { IParkingStructure, IParkingStructureTimestampRecord } from "../entities/ParkingRepositoryEntities"; +import { IParkingStructure } from "../entities/ParkingRepositoryEntities"; import { HistoricalParkingAverageQueryResult, ParkingStructureCountOptions } from "./ParkingGetterRepository"; import { BaseRedisRepository } from "./BaseRedisRepository"; import { PARKING_LOGGING_INTERVAL_MS } from "./ParkingRepositoryConstants"; @@ -76,25 +76,7 @@ export class RedisParkingRepository extends BaseRedisRepository implements Parki }; getHistoricalAveragesOfParkingStructureCounts = async (id: string, options: ParkingStructureCountOptions): Promise => { - const keys = this.createRedisKeys(id); - - try { - const timeSeriesData = await this.redisClient.sendCommand([ - 'TS.RANGE', - keys.timeSeries, - options.startUnixEpochMs.toString(), - options.endUnixEpochMs.toString() - ]) as [string, string][]; - - if (!timeSeriesData || timeSeriesData.length === 0) { - return []; - } - - const records = this.convertTimeSeriesDataToRecords(timeSeriesData, id); - return this.calculateAveragesFromRecords(records, options); - } catch (error) { - return []; - } + return this.calculateAveragesFromRecords(id, options); }; private createRedisKeys = (structureId: string) => ({ @@ -173,30 +155,40 @@ export class RedisParkingRepository extends BaseRedisRepository implements Parki } }; - private convertTimeSeriesDataToRecords = (timeSeriesData: [string, string][], id: string): IParkingStructureTimestampRecord[] => { - return timeSeriesData.map(([timestamp, value]) => ({ - id, - timestampMs: parseInt(timestamp), - spotsAvailable: parseInt(value) - })); - }; - - private calculateAveragesFromRecords = ( - records: IParkingStructureTimestampRecord[], + private calculateAveragesFromRecords = async ( + id: string, options: ParkingStructureCountOptions - ): HistoricalParkingAverageQueryResult[] => { - const results: HistoricalParkingAverageQueryResult[] = []; + ): 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); - const recordsInInterval = this.getRecordsInTimeRange(records, currentIntervalStart, currentIntervalEnd); + + try { + const aggregationResult = await this.redisClient.sendCommand([ + 'TS.RANGE', + keys.timeSeries, + currentIntervalStart.toString(), + currentIntervalEnd.toString(), + 'AGGREGATION', + 'AVG', + intervalMs.toString() + ]) as [string, string][]; - if (recordsInInterval.length > 0) { - const averageResult = this.calculateAverageForInterval(currentIntervalStart, currentIntervalEnd, recordsInInterval); - results.push(averageResult); + 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; @@ -205,31 +197,6 @@ export class RedisParkingRepository extends BaseRedisRepository implements Parki 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; }; From 5db4a82535c9dcf6203a237f6649a44dfd461d38 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Thu, 3 Jul 2025 16:01:14 -0400 Subject: [PATCH 28/28] Update historical average test to actually check the average value --- test/repositories/ParkingRepositorySharedTests.test.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/test/repositories/ParkingRepositorySharedTests.test.ts b/test/repositories/ParkingRepositorySharedTests.test.ts index d243b4e..c43cf07 100644 --- a/test/repositories/ParkingRepositorySharedTests.test.ts +++ b/test/repositories/ParkingRepositorySharedTests.test.ts @@ -191,13 +191,12 @@ describe.each(repositoryImplementations)('$name', (holder) => { const result = await repository.getHistoricalAveragesOfParkingStructureCounts(testStructure.id, options); // Should have at least some historical data - expect(result.length).toBeGreaterThan(0); + 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(typeof result[0].averageSpotsAvailable).toBe('number'); - expect(result[0].averageSpotsAvailable).toBeGreaterThan(0); + expect(result[0].averageSpotsAvailable).toBeCloseTo(52.5); } }); });