From e5a0e9555a3ebc0fe61c15582fdf6b2e4b6f7a13 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 6 Nov 2025 11:12:35 +0000 Subject: [PATCH 1/6] Initial plan From 80a9a6084a86fdfabe17840d744033ddff9704e3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 6 Nov 2025 11:25:29 +0000 Subject: [PATCH 2/6] Implement client-side map data caching service Co-authored-by: jjasloot <5612709+jjasloot@users.noreply.github.com> --- .../route-detail/route-detail.component.ts | 4 ++ .../wizard-step2/wizard-step2.component.ts | 3 + .../src/app/map/map.component.html | 8 +++ .../src/app/map/map.component.scss | 13 ++++ .../OVDBFrontend/src/app/map/map.component.ts | 46 ++++++++++++++- .../app/services/map-data-cache.service.ts | 59 +++++++++++++++++++ 6 files changed, 132 insertions(+), 1 deletion(-) create mode 100644 OV_DB/OVDBFrontend/src/app/services/map-data-cache.service.ts diff --git a/OV_DB/OVDBFrontend/src/app/admin/route-detail/route-detail.component.ts b/OV_DB/OVDBFrontend/src/app/admin/route-detail/route-detail.component.ts index 3def85f6..d1097dca 100644 --- a/OV_DB/OVDBFrontend/src/app/admin/route-detail/route-detail.component.ts +++ b/OV_DB/OVDBFrontend/src/app/admin/route-detail/route-detail.component.ts @@ -17,6 +17,7 @@ import { AreYouSureDialogComponent } from "src/app/are-you-sure-dialog/are-you-s import saveAs from "file-saver"; import { AuthenticationService } from "src/app/services/authentication.service"; import { OperatorService } from "src/app/services/operator.service"; +import { DataUpdateService } from "src/app/services/data-update.service"; import { MatButton } from "@angular/material/button"; import { MatIcon } from "@angular/material/icon"; import { MatFormField, MatLabel, MatSuffix } from "@angular/material/form-field"; @@ -77,6 +78,7 @@ export class RouteDetailComponent implements OnInit { private dateAdapter = inject>(DateAdapter); private dialog = inject(MatDialog); private router = inject(Router); + private dataUpdateService = inject(DataUpdateService); routeId: number; route: Route; @@ -180,6 +182,7 @@ export class RouteDetailComponent implements OnInit { route.operatorIds = this.activeOperators(); } this.apiService.updateRoute(values as Route).subscribe((_) => { + this.dataUpdateService.requestUpdate(); if (!goToInstances) { this.goBack(); } else { @@ -215,6 +218,7 @@ export class RouteDetailComponent implements OnInit { dialogRef.afterClosed().subscribe((result: boolean) => { if (result) { this.apiService.deleteRoute(this.route.routeId).subscribe((_) => { + this.dataUpdateService.requestUpdate(); this.goBack(); }); } diff --git a/OV_DB/OVDBFrontend/src/app/admin/wizzard/wizard-step2/wizard-step2.component.ts b/OV_DB/OVDBFrontend/src/app/admin/wizzard/wizard-step2/wizard-step2.component.ts index 3d6918c1..ded294d8 100644 --- a/OV_DB/OVDBFrontend/src/app/admin/wizzard/wizard-step2/wizard-step2.component.ts +++ b/OV_DB/OVDBFrontend/src/app/admin/wizzard/wizard-step2/wizard-step2.component.ts @@ -11,6 +11,7 @@ import { MatDialog } from "@angular/material/dialog"; import { AreYouSureDialogComponent } from "src/app/are-you-sure-dialog/are-you-sure-dialog.component"; import { Moment } from "moment"; import moment from "moment"; +import { DataUpdateService } from "src/app/services/data-update.service"; import { MatIconButton, MatButton } from "@angular/material/button"; import { MatIcon } from "@angular/material/icon"; import { MatCard, MatCardHeader, MatCardTitle, MatCardContent } from "@angular/material/card"; @@ -54,6 +55,7 @@ export class WizzardStep2Component implements OnInit { private translateService = inject(TranslateService); private dialog = inject(MatDialog); private router = inject(Router); + private dataUpdateService = inject(DataUpdateService); id: string; data: OSMDataLine; @@ -141,6 +143,7 @@ export class WizzardStep2Component implements OnInit { this.apiService.importerAddRoute(this.data).subscribe( (route) => { + this.dataUpdateService.requestUpdate(); // If this comes from Träwelling, navigate to route edit with trip data pre-populated if (this.fromTraewelling && this.trawellingTripData) { this.router.navigate(["/", "admin", "routes", route.routeId], { diff --git a/OV_DB/OVDBFrontend/src/app/map/map.component.html b/OV_DB/OVDBFrontend/src/app/map/map.component.html index b7b98802..9e812e48 100644 --- a/OV_DB/OVDBFrontend/src/app/map/map.component.html +++ b/OV_DB/OVDBFrontend/src/app/map/map.component.html @@ -88,4 +88,12 @@ > refresh + @if (isFromCache()) { + + cached + @if (cacheAge() !== null) { + {{ cacheAge() }}s ago + } + + } diff --git a/OV_DB/OVDBFrontend/src/app/map/map.component.scss b/OV_DB/OVDBFrontend/src/app/map/map.component.scss index 75473476..ac0c086c 100644 --- a/OV_DB/OVDBFrontend/src/app/map/map.component.scss +++ b/OV_DB/OVDBFrontend/src/app/map/map.component.scss @@ -88,3 +88,16 @@ mat-panel-title { #refresh { margin-left: 1rem; } + +.cache-indicator { + display: inline-flex; + align-items: center; + gap: 4px; + margin-left: 8px; + color: rgba(0, 0, 0, 0.6); + font-size: 12px; + + mat-icon { + vertical-align: middle; + } +} diff --git a/OV_DB/OVDBFrontend/src/app/map/map.component.ts b/OV_DB/OVDBFrontend/src/app/map/map.component.ts index 948d8703..32cb33b9 100644 --- a/OV_DB/OVDBFrontend/src/app/map/map.component.ts +++ b/OV_DB/OVDBFrontend/src/app/map/map.component.ts @@ -12,10 +12,12 @@ import { TranslationService } from "../services/translation.service"; import { Router, ActivatedRoute } from "@angular/router"; import { MapInstanceDialogComponent } from "../map-instance-dialog/map-instance-dialog.component"; import { switchMap } from "rxjs/operators"; -import { Observable } from "rxjs"; +import { Observable, of } from "rxjs"; import { MapDataDTO } from "../models/map-data.model"; import { v4 as uuidv4 } from "uuid"; import { SignalRService } from "../services/signal-r.service"; +import { MapDataCacheService } from "../services/map-data-cache.service"; +import { DataUpdateService } from "../services/data-update.service"; import { NgTemplateOutlet, NgClass, @@ -60,10 +62,14 @@ export class MapComponent implements OnInit, AfterViewInit, OnDestroy { private activatedRoute = inject(ActivatedRoute); private signalRService = inject(SignalRService); private cd = inject(ChangeDetectorRef); + private mapDataCacheService = inject(MapDataCacheService); + private dataUpdateService = inject(DataUpdateService); readonly guid = input(undefined); readonly mapContainer = viewChild("mapContainer"); loading: boolean | number = false; + isFromCache = signal(false); + cacheAge = signal(null); from: moment.Moment; to: moment.Moment; selectedRegion: number[] = []; @@ -228,6 +234,11 @@ export class MapComponent implements OnInit, AfterViewInit, OnDestroy { } }, }); + + // Subscribe to data updates to clear cache + this.dataUpdateService.updateRequested$.subscribe(() => { + this.mapDataCacheService.clear(); + }); } ngOnDestroy() { @@ -272,6 +283,26 @@ export class MapComponent implements OnInit, AfterViewInit, OnDestroy { } private getRoutes(filter: string): Observable { + // Check cache first + const cacheKey = this.mapDataCacheService.getCacheKey( + this.guid() || '', + filter, + this.translationService.language, + this.includeLineColours, + this.limitToSelectedArea + ); + + const cachedData = this.mapDataCacheService.get(cacheKey); + if (cachedData) { + this.isFromCache.set(true); + this.cacheAge.set(this.mapDataCacheService.getCacheAge(cacheKey)); + return of(cachedData); + } + + // Not in cache, fetch from API + this.isFromCache.set(false); + this.cacheAge.set(null); + //Generate a GUID this.requestIdentifier = uuidv4(); @@ -285,6 +316,18 @@ export class MapComponent implements OnInit, AfterViewInit, OnDestroy { ); } private showRoutes(data: MapDataDTO) { + // Cache the data if it's not from cache + if (!this.isFromCache()) { + const cacheKey = this.mapDataCacheService.getCacheKey( + this.guid() || '', + this.getFilter(), + this.translationService.language, + this.includeLineColours, + this.limitToSelectedArea + ); + this.mapDataCacheService.set(cacheKey, data); + } + const parent = this; const track = geoJSON(data.routes, { style: (feature) => { @@ -566,6 +609,7 @@ export class MapComponent implements OnInit, AfterViewInit, OnDestroy { } refresh() { + this.mapDataCacheService.clear(); this.getRoutes$.next(this.getFilter()); } diff --git a/OV_DB/OVDBFrontend/src/app/services/map-data-cache.service.ts b/OV_DB/OVDBFrontend/src/app/services/map-data-cache.service.ts new file mode 100644 index 00000000..227d20e7 --- /dev/null +++ b/OV_DB/OVDBFrontend/src/app/services/map-data-cache.service.ts @@ -0,0 +1,59 @@ +import { Injectable } from '@angular/core'; +import { MapDataDTO } from '../models/map-data.model'; + +interface CachedMapData { + data: MapDataDTO; + timestamp: number; +} + +@Injectable({ + providedIn: 'root' +}) +export class MapDataCacheService { + private cache = new Map(); + private readonly CACHE_DURATION = 5 * 60 * 1000; // 5 minutes + + getCacheKey( + guid: string, + filter: string, + language: string, + includeLineColours: boolean, + limitToSelectedArea: boolean + ): string { + return `${guid}|${filter}|${language}|${includeLineColours}|${limitToSelectedArea}`; + } + + get(key: string): MapDataDTO | null { + const cached = this.cache.get(key); + + if (!cached) { + return null; + } + + // Check if expired + if (Date.now() - cached.timestamp > this.CACHE_DURATION) { + this.cache.delete(key); + return null; + } + + return cached.data; + } + + set(key: string, data: MapDataDTO): void { + this.cache.set(key, { + data, + timestamp: Date.now() + }); + } + + clear(): void { + this.cache.clear(); + } + + // Get cache age in seconds for a key (for UI display) + getCacheAge(key: string): number | null { + const cached = this.cache.get(key); + if (!cached) return null; + return Math.floor((Date.now() - cached.timestamp) / 1000); + } +} From d3881a82d313d29809ddd26434b8bf09594a53d5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 6 Nov 2025 11:27:37 +0000 Subject: [PATCH 3/6] Add unit tests for map data cache service Co-authored-by: jjasloot <5612709+jjasloot@users.noreply.github.com> --- .../services/map-data-cache.service.spec.ts | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 OV_DB/OVDBFrontend/src/app/services/map-data-cache.service.spec.ts diff --git a/OV_DB/OVDBFrontend/src/app/services/map-data-cache.service.spec.ts b/OV_DB/OVDBFrontend/src/app/services/map-data-cache.service.spec.ts new file mode 100644 index 00000000..2c30f314 --- /dev/null +++ b/OV_DB/OVDBFrontend/src/app/services/map-data-cache.service.spec.ts @@ -0,0 +1,100 @@ +import { TestBed } from '@angular/core/testing'; +import { MapDataCacheService } from './map-data-cache.service'; +import { MapDataDTO } from '../models/map-data.model'; + +describe('MapDataCacheService', () => { + let service: MapDataCacheService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(MapDataCacheService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should generate unique cache keys', () => { + const key1 = service.getCacheKey('guid1', 'filter1', 'en', true, false); + const key2 = service.getCacheKey('guid1', 'filter1', 'en', false, false); + const key3 = service.getCacheKey('guid1', 'filter1', 'nl', true, false); + + expect(key1).not.toEqual(key2); + expect(key1).not.toEqual(key3); + expect(key2).not.toEqual(key3); + }); + + it('should store and retrieve cached data', () => { + const mockData: MapDataDTO = { + routes: { type: 'FeatureCollection', features: [] }, + area: null + }; + const key = service.getCacheKey('guid1', 'filter1', 'en', true, false); + + service.set(key, mockData); + const retrieved = service.get(key); + + expect(retrieved).toEqual(mockData); + }); + + it('should return null for non-existent cache key', () => { + const retrieved = service.get('non-existent-key'); + expect(retrieved).toBeNull(); + }); + + it('should clear all cache', () => { + const mockData: MapDataDTO = { + routes: { type: 'FeatureCollection', features: [] }, + area: null + }; + const key1 = service.getCacheKey('guid1', 'filter1', 'en', true, false); + const key2 = service.getCacheKey('guid2', 'filter2', 'nl', false, true); + + service.set(key1, mockData); + service.set(key2, mockData); + + service.clear(); + + expect(service.get(key1)).toBeNull(); + expect(service.get(key2)).toBeNull(); + }); + + it('should return cache age in seconds', () => { + const mockData: MapDataDTO = { + routes: { type: 'FeatureCollection', features: [] }, + area: null + }; + const key = service.getCacheKey('guid1', 'filter1', 'en', true, false); + + service.set(key, mockData); + const age = service.getCacheAge(key); + + expect(age).toBeDefined(); + expect(age).toBeGreaterThanOrEqual(0); + expect(age).toBeLessThan(2); // Should be less than 2 seconds old + }); + + it('should return null for cache age of non-existent key', () => { + const age = service.getCacheAge('non-existent-key'); + expect(age).toBeNull(); + }); + + it('should expire cache after 5 minutes', (done) => { + const mockData: MapDataDTO = { + routes: { type: 'FeatureCollection', features: [] }, + area: null + }; + const key = service.getCacheKey('guid1', 'filter1', 'en', true, false); + + // Mock the timestamp to be 5 minutes and 1 second ago + service.set(key, mockData); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const cache = (service as any).cache; + const cached = cache.get(key); + cached.timestamp = Date.now() - (5 * 60 * 1000 + 1000); + + const retrieved = service.get(key); + expect(retrieved).toBeNull(); + done(); + }); +}); From e6add1fa44625b826ed7b5e18c3175113c9c421d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 6 Nov 2025 11:32:18 +0000 Subject: [PATCH 4/6] Address code review feedback: refactor and improve code quality Co-authored-by: jjasloot <5612709+jjasloot@users.noreply.github.com> --- .../src/app/map/map.component.html | 4 ++-- .../src/app/map/map.component.scss | 10 +++++++++- .../OVDBFrontend/src/app/map/map.component.ts | 18 ++++++++---------- .../services/map-data-cache.service.spec.ts | 16 +++++++++------- 4 files changed, 28 insertions(+), 20 deletions(-) diff --git a/OV_DB/OVDBFrontend/src/app/map/map.component.html b/OV_DB/OVDBFrontend/src/app/map/map.component.html index 9e812e48..c58ca51f 100644 --- a/OV_DB/OVDBFrontend/src/app/map/map.component.html +++ b/OV_DB/OVDBFrontend/src/app/map/map.component.html @@ -90,9 +90,9 @@ @if (isFromCache()) { - cached + cached @if (cacheAge() !== null) { - {{ cacheAge() }}s ago + {{ cacheAge() }}s ago } } diff --git a/OV_DB/OVDBFrontend/src/app/map/map.component.scss b/OV_DB/OVDBFrontend/src/app/map/map.component.scss index ac0c086c..33a81e68 100644 --- a/OV_DB/OVDBFrontend/src/app/map/map.component.scss +++ b/OV_DB/OVDBFrontend/src/app/map/map.component.scss @@ -97,7 +97,15 @@ mat-panel-title { color: rgba(0, 0, 0, 0.6); font-size: 12px; - mat-icon { + .cache-icon { + font-size: 16px; + width: 16px; + height: 16px; vertical-align: middle; } + + .cache-age { + font-size: 12px; + opacity: 0.7; + } } diff --git a/OV_DB/OVDBFrontend/src/app/map/map.component.ts b/OV_DB/OVDBFrontend/src/app/map/map.component.ts index 32cb33b9..339f5dbe 100644 --- a/OV_DB/OVDBFrontend/src/app/map/map.component.ts +++ b/OV_DB/OVDBFrontend/src/app/map/map.component.ts @@ -282,15 +282,19 @@ export class MapComponent implements OnInit, AfterViewInit, OnDestroy { this.setApplicableFilter(); } - private getRoutes(filter: string): Observable { - // Check cache first - const cacheKey = this.mapDataCacheService.getCacheKey( + private getCurrentCacheKey(filter: string): string { + return this.mapDataCacheService.getCacheKey( this.guid() || '', filter, this.translationService.language, this.includeLineColours, this.limitToSelectedArea ); + } + + private getRoutes(filter: string): Observable { + // Check cache first + const cacheKey = this.getCurrentCacheKey(filter); const cachedData = this.mapDataCacheService.get(cacheKey); if (cachedData) { @@ -318,13 +322,7 @@ export class MapComponent implements OnInit, AfterViewInit, OnDestroy { private showRoutes(data: MapDataDTO) { // Cache the data if it's not from cache if (!this.isFromCache()) { - const cacheKey = this.mapDataCacheService.getCacheKey( - this.guid() || '', - this.getFilter(), - this.translationService.language, - this.includeLineColours, - this.limitToSelectedArea - ); + const cacheKey = this.getCurrentCacheKey(this.getFilter()); this.mapDataCacheService.set(cacheKey, data); } diff --git a/OV_DB/OVDBFrontend/src/app/services/map-data-cache.service.spec.ts b/OV_DB/OVDBFrontend/src/app/services/map-data-cache.service.spec.ts index 2c30f314..6670f3c8 100644 --- a/OV_DB/OVDBFrontend/src/app/services/map-data-cache.service.spec.ts +++ b/OV_DB/OVDBFrontend/src/app/services/map-data-cache.service.spec.ts @@ -79,22 +79,24 @@ describe('MapDataCacheService', () => { expect(age).toBeNull(); }); - it('should expire cache after 5 minutes', (done) => { + it('should expire cache after 5 minutes', () => { const mockData: MapDataDTO = { routes: { type: 'FeatureCollection', features: [] }, area: null }; const key = service.getCacheKey('guid1', 'filter1', 'en', true, false); - // Mock the timestamp to be 5 minutes and 1 second ago + // Store data in cache service.set(key, mockData); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const cache = (service as any).cache; - const cached = cache.get(key); - cached.timestamp = Date.now() - (5 * 60 * 1000 + 1000); + // Verify data is in cache + expect(service.get(key)).toEqual(mockData); + + // Mock Date.now to return time 5 minutes and 1 second in the future + spyOn(Date, 'now').and.returnValue(Date.now() + (5 * 60 * 1000 + 1000)); + + // Cache should be expired now const retrieved = service.get(key); expect(retrieved).toBeNull(); - done(); }); }); From 4ecd0b0b8bf1aeabd4ac4dbdb14d79d8f4b61dcf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 6 Nov 2025 12:15:05 +0000 Subject: [PATCH 5/6] Add translation for cache age indicator Co-authored-by: jjasloot <5612709+jjasloot@users.noreply.github.com> --- OV_DB/OVDBFrontend/src/app/map/map.component.html | 2 +- OV_DB/OVDBFrontend/src/assets/i18n/en.json | 1 + OV_DB/OVDBFrontend/src/assets/i18n/nl.json | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/OV_DB/OVDBFrontend/src/app/map/map.component.html b/OV_DB/OVDBFrontend/src/app/map/map.component.html index c58ca51f..9f81d250 100644 --- a/OV_DB/OVDBFrontend/src/app/map/map.component.html +++ b/OV_DB/OVDBFrontend/src/app/map/map.component.html @@ -92,7 +92,7 @@ cached @if (cacheAge() !== null) { - {{ cacheAge() }}s ago + {{ cacheAge() }}s {{ "MAP.CACHE.AGO" | translate }} } } diff --git a/OV_DB/OVDBFrontend/src/assets/i18n/en.json b/OV_DB/OVDBFrontend/src/assets/i18n/en.json index a90b9c48..1bcc7665 100644 --- a/OV_DB/OVDBFrontend/src/assets/i18n/en.json +++ b/OV_DB/OVDBFrontend/src/assets/i18n/en.json @@ -194,6 +194,7 @@ "MAP.POPUP.REMARK": "Remark", "MAP.POPUP.LINENUMBER": "Line number", "MAP.POPUP.OPERATINGCOMPANY": "Operating company", + "MAP.CACHE.AGO": "ago", "ADDMAP.SHOWROUTEINFO": "Show route information on map", "ADDMAP.SHOWROUTEOUTLINE": "Highlight route on selection", "ROUTESLIST.SELECT": "Select", diff --git a/OV_DB/OVDBFrontend/src/assets/i18n/nl.json b/OV_DB/OVDBFrontend/src/assets/i18n/nl.json index 16181f5c..5b16859b 100644 --- a/OV_DB/OVDBFrontend/src/assets/i18n/nl.json +++ b/OV_DB/OVDBFrontend/src/assets/i18n/nl.json @@ -195,6 +195,7 @@ "MAP.POPUP.REMARK": "Opmerking", "MAP.POPUP.LINENUMBER": "Lijnnummer", "MAP.POPUP.OPERATINGCOMPANY": "Bedienend bedrijf", + "MAP.CACHE.AGO": "geleden", "ADDMAP.SHOWROUTEINFO": "Laat route informatie zien op de kaart", "ADDMAP.SHOWROUTEOUTLINE": "Benadruk route bij selectie", "ROUTESLIST.SELECT": "Selecteer", From 2017d2dcaf87d4f74cc26bd94931edac926257cc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 6 Nov 2025 12:17:32 +0000 Subject: [PATCH 6/6] Fix comment formatting - add space after // Co-authored-by: jjasloot <5612709+jjasloot@users.noreply.github.com> --- OV_DB/OVDBFrontend/src/app/map/map.component.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/OV_DB/OVDBFrontend/src/app/map/map.component.ts b/OV_DB/OVDBFrontend/src/app/map/map.component.ts index 339f5dbe..54442ef3 100644 --- a/OV_DB/OVDBFrontend/src/app/map/map.component.ts +++ b/OV_DB/OVDBFrontend/src/app/map/map.component.ts @@ -307,7 +307,7 @@ export class MapComponent implements OnInit, AfterViewInit, OnDestroy { this.isFromCache.set(false); this.cacheAge.set(null); - //Generate a GUID + // Generate a GUID this.requestIdentifier = uuidv4(); return this.apiService.getRoutes(