diff --git a/backend/src/core/config/redis.ts b/backend/src/core/config/redis.ts index 665c9f5..d4e1374 100644 --- a/backend/src/core/config/redis.ts +++ b/backend/src/core/config/redis.ts @@ -51,6 +51,34 @@ export class CacheService { logger.error('Cache delete error', { key, error }); } } + + /** + * Delete all keys matching the provided pattern (without namespace prefix). + * Uses SCAN to avoid blocking Redis for large keyspaces. + */ + async deletePattern(pattern: string): Promise { + try { + const namespacedPattern = this.prefix + pattern; + let cursor = '0'; + + do { + const [nextCursor, keys] = await redis.scan( + cursor, + 'MATCH', + namespacedPattern, + 'COUNT', + 100 + ); + cursor = nextCursor; + + if (keys.length > 0) { + await redis.del(...keys); + } + } while (cursor !== '0'); + } catch (error) { + logger.error('Cache delete pattern error', { pattern, error }); + } + } } export const cacheService = new CacheService(); diff --git a/backend/src/features/admin/tests/integration/catalog.integration.test.ts b/backend/src/features/admin/tests/integration/catalog.integration.test.ts index 278eded..7420e39 100644 --- a/backend/src/features/admin/tests/integration/catalog.integration.test.ts +++ b/backend/src/features/admin/tests/integration/catalog.integration.test.ts @@ -315,6 +315,7 @@ describe('Admin Catalog Integration Tests', () => { it('should invalidate cache after create operation', async () => { // Set a cache value await redis.set('mvp:platform:vehicle-data:makes:2024', JSON.stringify([]), 3600); + await redis.set('mvp:platform:years', JSON.stringify([2024]), 3600); // Create make (should invalidate cache) await app.inject({ @@ -324,12 +325,11 @@ describe('Admin Catalog Integration Tests', () => { payload: { name: 'Honda' } }); - // Check if cache was invalidated (implementation depends on invalidateVehicleData) - // Note: Current implementation logs warning but doesn't actually invalidate - // This test documents expected behavior const cacheValue = await redis.get('mvp:platform:vehicle-data:makes:2024'); - // Cache should be invalidated or remain (depending on implementation) - expect(cacheValue).toBeDefined(); + const yearsCacheValue = await redis.get('mvp:platform:years'); + + expect(cacheValue).toBeNull(); + expect(yearsCacheValue).toBeNull(); }); }); diff --git a/backend/src/features/platform/domain/platform-cache.service.ts b/backend/src/features/platform/domain/platform-cache.service.ts index d5bf449..978b752 100644 --- a/backend/src/features/platform/domain/platform-cache.service.ts +++ b/backend/src/features/platform/domain/platform-cache.service.ts @@ -114,6 +114,15 @@ export class PlatformCacheService { * Invalidate all vehicle data cache (for admin operations) */ async invalidateVehicleData(): Promise { - logger.warn('Vehicle data cache invalidation not implemented (requires pattern deletion)'); + try { + const yearsKey = this.prefix + 'years'; + await Promise.all([ + this.cacheService.del(yearsKey), + this.cacheService.deletePattern(this.prefix + 'vehicle-data:*') + ]); + logger.debug('Vehicle data cache invalidated'); + } catch (error) { + logger.error('Vehicle data cache invalidation failed', { error }); + } } } diff --git a/backend/src/features/stations/api/stations.controller.ts b/backend/src/features/stations/api/stations.controller.ts index a523334..0daade7 100644 --- a/backend/src/features/stations/api/stations.controller.ts +++ b/backend/src/features/stations/api/stations.controller.ts @@ -8,7 +8,12 @@ import { StationsService } from '../domain/stations.service'; import { StationsRepository } from '../data/stations.repository'; import { pool } from '../../../core/config/database'; import { logger } from '../../../core/logging/logger'; -import { StationSearchBody, SaveStationBody, StationParams } from '../domain/stations.types'; +import { + StationSearchBody, + SaveStationBody, + StationParams, + UpdateSavedStationBody +} from '../domain/stations.types'; export class StationsController { private stationsService: StationsService; @@ -50,7 +55,14 @@ export class StationsController { async saveStation(request: FastifyRequest<{ Body: SaveStationBody }>, reply: FastifyReply) { try { const userId = (request as any).user.sub; - const { placeId, nickname, notes, isFavorite } = request.body; + const { + placeId, + nickname, + notes, + isFavorite, + has93Octane, + has93OctaneEthanolFree + } = request.body; if (!placeId) { return reply.code(400).send({ @@ -62,7 +74,9 @@ export class StationsController { const result = await this.stationsService.saveStation(placeId, userId, { nickname, notes, - isFavorite + isFavorite, + has93Octane, + has93OctaneEthanolFree }); return reply.code(201).send(result); @@ -82,6 +96,38 @@ export class StationsController { }); } } + + async updateSavedStation( + request: FastifyRequest<{ Params: StationParams; Body: UpdateSavedStationBody }>, + reply: FastifyReply + ) { + try { + const userId = (request as any).user.sub; + const { placeId } = request.params; + + const result = await this.stationsService.updateSavedStation(placeId, userId, request.body); + + return reply.code(200).send(result); + } catch (error: any) { + logger.error('Error updating saved station', { + error, + placeId: request.params.placeId, + userId: (request as any).user?.sub + }); + + if (error.message.includes('not found')) { + return reply.code(404).send({ + error: 'Not Found', + message: error.message + }); + } + + return reply.code(500).send({ + error: 'Internal server error', + message: 'Failed to update saved station' + }); + } + } async getSavedStations(request: FastifyRequest, reply: FastifyReply) { try { @@ -122,4 +168,4 @@ export class StationsController { }); } } -} \ No newline at end of file +} diff --git a/backend/src/features/stations/api/stations.routes.ts b/backend/src/features/stations/api/stations.routes.ts index b8d0460..f0efdf5 100644 --- a/backend/src/features/stations/api/stations.routes.ts +++ b/backend/src/features/stations/api/stations.routes.ts @@ -8,7 +8,8 @@ import { FastifyPluginAsync } from 'fastify'; import { StationSearchBody, SaveStationBody, - StationParams + StationParams, + UpdateSavedStationBody } from '../domain/stations.types'; import { StationsController } from './stations.controller'; @@ -30,6 +31,15 @@ export const stationsRoutes: FastifyPluginAsync = async ( handler: stationsController.saveStation.bind(stationsController) }); + // PATCH /api/stations/saved/:placeId - Update saved station metadata + fastify.patch<{ Params: StationParams; Body: UpdateSavedStationBody }>( + '/stations/saved/:placeId', + { + preHandler: [fastify.authenticate], + handler: stationsController.updateSavedStation.bind(stationsController) + } + ); + // GET /api/stations/saved - Get user's saved stations fastify.get('/stations/saved', { preHandler: [fastify.authenticate], diff --git a/backend/src/features/stations/data/stations.repository.ts b/backend/src/features/stations/data/stations.repository.ts index 7c3db03..7fc41e3 100644 --- a/backend/src/features/stations/data/stations.repository.ts +++ b/backend/src/features/stations/data/stations.repository.ts @@ -45,14 +45,37 @@ export class StationsRepository { return this.mapCacheRow(result.rows[0]); } - async saveStation(userId: string, placeId: string, data?: { nickname?: string; notes?: string; isFavorite?: boolean }): Promise { + async saveStation( + userId: string, + placeId: string, + data?: { + nickname?: string; + notes?: string; + isFavorite?: boolean; + has93Octane?: boolean; + has93OctaneEthanolFree?: boolean; + } + ): Promise { const query = ` - INSERT INTO saved_stations (user_id, place_id, nickname, notes, is_favorite) - VALUES ($1, $2, $3, $4, $5) + INSERT INTO saved_stations ( + user_id, + place_id, + nickname, + notes, + is_favorite, + has_93_octane, + has_93_octane_ethanol_free + ) + VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (user_id, place_id) DO UPDATE SET nickname = COALESCE($3, saved_stations.nickname), notes = COALESCE($4, saved_stations.notes), is_favorite = COALESCE($5, saved_stations.is_favorite), + has_93_octane = COALESCE($6, saved_stations.has_93_octane), + has_93_octane_ethanol_free = CASE + WHEN $6 IS NOT NULL AND $6 = false THEN false + ELSE COALESCE($7, saved_stations.has_93_octane_ethanol_free) + END, updated_at = NOW() RETURNING * `; @@ -62,12 +85,58 @@ export class StationsRepository { placeId, data?.nickname, data?.notes, - data?.isFavorite || false + data?.isFavorite ?? false, + data?.has93Octane ?? false, + data?.has93OctaneEthanolFree ?? false ]); return this.mapSavedRow(result.rows[0]); } + async updateSavedStation( + userId: string, + placeId: string, + data: { + nickname?: string; + notes?: string; + isFavorite?: boolean; + has93Octane?: boolean; + has93OctaneEthanolFree?: boolean; + } + ): Promise { + const query = ` + UPDATE saved_stations + SET + nickname = COALESCE($3, nickname), + notes = COALESCE($4, notes), + is_favorite = COALESCE($5, is_favorite), + has_93_octane = COALESCE($6, has_93_octane), + has_93_octane_ethanol_free = CASE + WHEN $6 IS NOT NULL AND $6 = false THEN false + ELSE COALESCE($7, has_93_octane_ethanol_free) + END, + updated_at = NOW() + WHERE user_id = $1 AND place_id = $2 + RETURNING * + `; + + const result = await this.pool.query(query, [ + userId, + placeId, + data.nickname, + data.notes, + data.isFavorite, + data.has93Octane, + data.has93OctaneEthanolFree + ]); + + if (result.rows.length === 0) { + return null; + } + + return this.mapSavedRow(result.rows[0]); + } + async getUserSavedStations(userId: string): Promise { const query = ` SELECT * FROM saved_stations @@ -107,11 +176,14 @@ export class StationsRepository { id: row.id, userId: row.user_id, stationId: row.place_id, + placeId: row.place_id, nickname: row.nickname, notes: row.notes, isFavorite: row.is_favorite, + has93Octane: row.has_93_octane ?? false, + has93OctaneEthanolFree: row.has_93_octane_ethanol_free ?? false, createdAt: row.created_at, updatedAt: row.updated_at }; } -} \ No newline at end of file +} diff --git a/backend/src/features/stations/domain/stations.service.ts b/backend/src/features/stations/domain/stations.service.ts index 2eea95f..9e27fd0 100644 --- a/backend/src/features/stations/domain/stations.service.ts +++ b/backend/src/features/stations/domain/stations.service.ts @@ -4,7 +4,13 @@ import { StationsRepository } from '../data/stations.repository'; import { googleMapsClient } from '../external/google-maps/google-maps.client'; -import { StationSearchRequest, StationSearchResponse, SavedStation } from './stations.types'; +import { + StationSearchRequest, + StationSearchResponse, + SavedStation, + StationSavedMetadata, + UpdateSavedStationBody +} from './stations.types'; import { logger } from '../../../core/logging/logger'; export class StationsService { @@ -22,14 +28,43 @@ export class StationsService { request.longitude, request.radius || 5000 ); + + // Fetch saved stations for prioritization and metadata + const savedStations = await this.repository.getUserSavedStations(userId); + const savedMap = new Map(savedStations.map(saved => [saved.placeId, saved])); // Cache stations for future reference for (const station of stations) { await this.repository.cacheStation(station); + + // Enrich station with saved metadata + const saved = savedMap.get(station.placeId); + if (saved) { + station.isSaved = true; + station.savedMetadata = this.mapSavedMetadata(saved); + } } - // Sort by distance - stations.sort((a, b) => (a.distance || 0) - (b.distance || 0)); + // Sort saved stations first, favorites next, then distance + stations.sort((a, b) => { + const aSaved = a.savedMetadata ? 1 : 0; + const bSaved = b.savedMetadata ? 1 : 0; + + if (aSaved !== bSaved) { + return bSaved - aSaved; + } + + const aFavorite = a.savedMetadata?.isFavorite ? 1 : 0; + const bFavorite = b.savedMetadata?.isFavorite ? 1 : 0; + + if (aFavorite !== bFavorite) { + return bFavorite - aFavorite; + } + + const aDistance = a.distance ?? Number.POSITIVE_INFINITY; + const bDistance = b.distance ?? Number.POSITIVE_INFINITY; + return aDistance - bDistance; + }); return { stations, @@ -45,7 +80,13 @@ export class StationsService { async saveStation( placeId: string, userId: string, - data?: { nickname?: string; notes?: string; isFavorite?: boolean } + data?: { + nickname?: string; + notes?: string; + isFavorite?: boolean; + has93Octane?: boolean; + has93OctaneEthanolFree?: boolean; + } ) { // Get station details from cache const station = await this.repository.getCachedStation(placeId); @@ -62,6 +103,25 @@ export class StationsService { station }; } + + async updateSavedStation( + placeId: string, + userId: string, + data: UpdateSavedStationBody + ) { + const updated = await this.repository.updateSavedStation(userId, placeId, data); + + if (!updated) { + throw new Error('Saved station not found'); + } + + const station = await this.repository.getCachedStation(placeId); + + return { + ...updated, + station + }; + } async getUserSavedStations(userId: string) { const savedStations = await this.repository.getUserSavedStations(userId); @@ -87,4 +147,14 @@ export class StationsService { throw new Error('Saved station not found'); } } -} \ No newline at end of file + + private mapSavedMetadata(saved: SavedStation): StationSavedMetadata { + return { + nickname: saved.nickname, + notes: saved.notes, + isFavorite: saved.isFavorite, + has93Octane: saved.has93Octane, + has93OctaneEthanolFree: saved.has93OctaneEthanolFree + }; + } +} diff --git a/backend/src/features/stations/domain/stations.types.ts b/backend/src/features/stations/domain/stations.types.ts index 58841cd..33f47c5 100644 --- a/backend/src/features/stations/domain/stations.types.ts +++ b/backend/src/features/stations/domain/stations.types.ts @@ -18,6 +18,8 @@ export interface Station { isOpen?: boolean; rating?: number; photoUrl?: string; + isSaved?: boolean; + savedMetadata?: StationSavedMetadata; } export interface StationSearchRequest { @@ -41,9 +43,12 @@ export interface SavedStation { id: string; userId: string; stationId: string; + placeId: string; nickname?: string; notes?: string; isFavorite: boolean; + has93Octane: boolean; + has93OctaneEthanolFree: boolean; createdAt: Date; updatedAt: Date; } @@ -61,8 +66,26 @@ export interface SaveStationBody { nickname?: string; notes?: string; isFavorite?: boolean; + has93Octane?: boolean; + has93OctaneEthanolFree?: boolean; } export interface StationParams { placeId: string; -} \ No newline at end of file +} + +export interface UpdateSavedStationBody { + nickname?: string; + notes?: string; + isFavorite?: boolean; + has93Octane?: boolean; + has93OctaneEthanolFree?: boolean; +} + +export interface StationSavedMetadata { + nickname?: string; + notes?: string; + isFavorite: boolean; + has93Octane: boolean; + has93OctaneEthanolFree: boolean; +} diff --git a/backend/src/features/stations/migrations/002_add_93_octane_flags.sql b/backend/src/features/stations/migrations/002_add_93_octane_flags.sql new file mode 100644 index 0000000..868e1e4 --- /dev/null +++ b/backend/src/features/stations/migrations/002_add_93_octane_flags.sql @@ -0,0 +1,11 @@ +-- Add 93 octane metadata to saved stations +ALTER TABLE saved_stations + ADD COLUMN IF NOT EXISTS has_93_octane BOOLEAN NOT NULL DEFAULT false; + +ALTER TABLE saved_stations + ADD COLUMN IF NOT EXISTS has_93_octane_ethanol_free BOOLEAN NOT NULL DEFAULT false; + +-- Backfill existing rows with defaults +UPDATE saved_stations +SET has_93_octane = COALESCE(has_93_octane, false), + has_93_octane_ethanol_free = COALESCE(has_93_octane_ethanol_free, false); diff --git a/backend/src/features/stations/tests/fixtures/mock-stations.ts b/backend/src/features/stations/tests/fixtures/mock-stations.ts index a6b28ae..8a3c9f0 100644 --- a/backend/src/features/stations/tests/fixtures/mock-stations.ts +++ b/backend/src/features/stations/tests/fixtures/mock-stations.ts @@ -52,9 +52,12 @@ export const mockSavedStations: SavedStation[] = [ id: '550e8400-e29b-41d4-a716-446655440000', userId: 'user123', stationId: mockStations[0].placeId, + placeId: mockStations[0].placeId, nickname: 'Work Gas Station', notes: 'Usually has good prices, rewards program available', isFavorite: true, + has93Octane: true, + has93OctaneEthanolFree: true, createdAt: new Date('2024-01-01'), updatedAt: new Date('2024-01-15') }, @@ -62,9 +65,12 @@ export const mockSavedStations: SavedStation[] = [ id: '550e8400-e29b-41d4-a716-446655440001', userId: 'user123', stationId: mockStations[1].placeId, + placeId: mockStations[1].placeId, nickname: 'Home Station', notes: 'Closest to apartment', isFavorite: true, + has93Octane: true, + has93OctaneEthanolFree: false, createdAt: new Date('2024-01-05'), updatedAt: new Date('2024-01-10') } diff --git a/backend/src/features/stations/tests/unit/stations.service.test.ts b/backend/src/features/stations/tests/unit/stations.service.test.ts index 0d7593c..6479bde 100644 --- a/backend/src/features/stations/tests/unit/stations.service.test.ts +++ b/backend/src/features/stations/tests/unit/stations.service.test.ts @@ -26,7 +26,8 @@ describe('StationsService', () => { cacheStation: jest.fn().mockResolvedValue(undefined), getCachedStation: jest.fn(), saveStation: jest.fn(), - getUserSavedStations: jest.fn(), + getUserSavedStations: jest.fn().mockResolvedValue([]), + updateSavedStation: jest.fn(), deleteSavedStation: jest.fn() } as unknown as jest.Mocked; @@ -51,6 +52,7 @@ describe('StationsService', () => { expect(result.stations).toHaveLength(3); expect(result.stations[0]?.name).toBe('Shell Gas Station - Downtown'); expect(mockRepository.cacheStation).toHaveBeenCalledTimes(3); + expect(mockRepository.getUserSavedStations).toHaveBeenCalledWith(mockUserId); }); it('should sort stations by distance', async () => { @@ -108,6 +110,32 @@ describe('StationsService', () => { expect(mockRepository.cacheStation).toHaveBeenCalled(); }); + + it('should prioritize saved stations and include saved metadata', async () => { + const savedStation = mockSavedStations[0]; + if (!savedStation) throw new Error('Mock saved station not found'); + mockRepository.getUserSavedStations.mockResolvedValue([savedStation]); + + const stationsWithDistance = [ + { ...mockStations[0], distance: 900 }, + { ...mockStations[1], distance: 100 } + ]; + + (googleMapsClient.searchNearbyStations as jest.Mock).mockResolvedValue( + stationsWithDistance + ); + + const result = await service.searchNearbyStations( + { + latitude: searchCoordinates.sanFrancisco.latitude, + longitude: searchCoordinates.sanFrancisco.longitude + }, + mockUserId + ); + + expect(result.stations[0]?.placeId).toBe(savedStation.stationId); + expect(result.stations[0]?.savedMetadata?.has93Octane).toBe(true); + }); }); describe('saveStation', () => { @@ -153,13 +181,17 @@ describe('StationsService', () => { await service.saveStation(station.placeId, mockUserId, { nickname: 'Favorite Station', notes: 'Best prices in area', - isFavorite: true + isFavorite: true, + has93Octane: true, + has93OctaneEthanolFree: false }); expect(mockRepository.saveStation).toHaveBeenCalledWith(mockUserId, station.placeId, { nickname: 'Favorite Station', notes: 'Best prices in area', - isFavorite: true + isFavorite: true, + has93Octane: true, + has93OctaneEthanolFree: false }); }); }); @@ -188,6 +220,40 @@ describe('StationsService', () => { }); }); + describe('updateSavedStation', () => { + it('should update saved station metadata', async () => { + const savedStation = mockSavedStations[0]; + const station = mockStations[0]; + if (!savedStation || !station) throw new Error('Mock data not found'); + + mockRepository.updateSavedStation.mockResolvedValue(savedStation); + mockRepository.getCachedStation.mockResolvedValue(station); + + const result = await service.updateSavedStation(savedStation.stationId, mockUserId, { + has93Octane: true, + has93OctaneEthanolFree: true + }); + + expect(mockRepository.updateSavedStation).toHaveBeenCalledWith( + mockUserId, + savedStation.stationId, + { + has93Octane: true, + has93OctaneEthanolFree: true + } + ); + expect(result.station).toEqual(station); + }); + + it('should throw if saved station not found', async () => { + mockRepository.updateSavedStation.mockResolvedValue(null); + + await expect( + service.updateSavedStation('unknown', mockUserId, { nickname: 'Update' }) + ).rejects.toThrow('Saved station not found'); + }); + }); + describe('removeSavedStation', () => { it('should delete a saved station', async () => { const savedStation = mockSavedStations[0]; diff --git a/database-exports/motovaultpro_export_20251102_094057.sql.gz b/database-exports/motovaultpro_export_20251102_094057.sql.gz deleted file mode 100644 index 6fc8b9a..0000000 Binary files a/database-exports/motovaultpro_export_20251102_094057.sql.gz and /dev/null differ diff --git a/database-exports/motovaultpro_export_20251102_094057_import_instructions.txt b/database-exports/motovaultpro_export_20251102_094057_import_instructions.txt deleted file mode 100644 index 2f54c3f..0000000 --- a/database-exports/motovaultpro_export_20251102_094057_import_instructions.txt +++ /dev/null @@ -1,39 +0,0 @@ -=========================================== -MotoVaultPro Database Import Instructions -=========================================== - -Export Details: -- Export Date: Sun Nov 2 09:40:58 CST 2025 -- Format: sql -- Compressed: true -- File: /home/egullickson/motovaultpro/database-exports/motovaultpro_export_20251102_094057.sql.gz - -Import Instructions: --------------------- - -1. Copy the export file to your target server: - scp /home/egullickson/motovaultpro/database-exports/motovaultpro_export_20251102_094057.sql.gz user@server:/path/to/import/ - -2. Import the database (compressed SQL): - # Using Docker: - gunzip -c /path/to/import/motovaultpro_export_20251102_094057.sql.gz | docker exec -i mvp-postgres psql -U postgres -d motovaultpro - - # Direct PostgreSQL: - gunzip -c /path/to/import/motovaultpro_export_20251102_094057.sql.gz | psql -U postgres -d motovaultpro - -Notes: ------- -- The -c flag drops existing database objects before recreating them -- Ensure the target database exists before importing -- For production imports, always test on a staging environment first -- Consider creating a backup of the target database before importing - -Create target database: ------------------------ -docker exec -i mvp-postgres psql -U postgres -c "CREATE DATABASE motovaultpro;" - -Or if database exists and you want to start fresh: --------------------------------------------------- -docker exec -i mvp-postgres psql -U postgres -c "DROP DATABASE IF EXISTS motovaultpro;" -docker exec -i mvp-postgres psql -U postgres -c "CREATE DATABASE motovaultpro;" - diff --git a/database-exports/motovaultpro_export_20251102_094057_metadata.json b/database-exports/motovaultpro_export_20251102_094057_metadata.json deleted file mode 100644 index 2f8dc29..0000000 --- a/database-exports/motovaultpro_export_20251102_094057_metadata.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "export_timestamp": "2025-11-02T15:40:58Z", - "database_name": "motovaultpro", - "export_format": "sql", - "compressed": true, - "schema_included": true, - "data_included": true, - "postgresql_version": "PostgreSQL 15.14 on x86_64-pc-linux-musl, compiled by gcc (Alpine 14.2.0) 14.2.0, 64-bit", - "file_path": "/home/egullickson/motovaultpro/database-exports/motovaultpro_export_20251102_094057.sql.gz", - "file_size": "4.0K" -} diff --git a/docker-compose.yml b/docker-compose.yml index 458df6c..7a15b89 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -56,6 +56,7 @@ services: SECRETS_DIR: /run/secrets volumes: - ./secrets/app/google-maps-api-key.txt:/run/secrets/google-maps-api-key:ro + - ./secrets/app/google-maps-map-id.txt:/run/secrets/google-maps-map-id:ro networks: - frontend depends_on: @@ -103,6 +104,7 @@ services: - ./secrets/app/postgres-password.txt:/run/secrets/postgres-password:ro - ./secrets/app/auth0-client-secret.txt:/run/secrets/auth0-client-secret:ro - ./secrets/app/google-maps-api-key.txt:/run/secrets/google-maps-api-key:ro + - ./secrets/app/google-maps-map-id.txt:/run/secrets/google-maps-map-id:ro # Filesystem storage for documents - ./data/documents:/app/data/documents networks: diff --git a/frontend/docs/RUNTIME-CONFIG.md b/frontend/docs/RUNTIME-CONFIG.md index 759a82a..a8484ca 100644 --- a/frontend/docs/RUNTIME-CONFIG.md +++ b/frontend/docs/RUNTIME-CONFIG.md @@ -16,7 +16,7 @@ This approach: 1. **Build Time**: Container is built WITHOUT secrets (no API keys in image) 2. **Container Startup**: - - `/app/load-config.sh` reads `/run/secrets/google-maps-api-key` + - `/app/load-config.sh` reads `/run/secrets/google-maps-api-key` and `/run/secrets/google-maps-map-id` - Generates `/usr/share/nginx/html/config.js` with runtime values - Starts nginx 3. **App Load Time**: @@ -86,6 +86,7 @@ fi cat > "$CONFIG_FILE" < "$CONFIG_FILE" < = ({ // Add saved stations first if (savedStations && savedStations.length > 0) { savedStations.forEach((station) => { + const placeId = resolveSavedStationPlaceId(station); + if (!placeId) { + return; + } + + const normalizedStation = + station.placeId === placeId ? station : { ...station, placeId }; + opts.push({ type: 'saved', - station, - label: station.nickname || station.name, + station: normalizedStation, + label: resolveSavedStationName(normalizedStation), group: 'Saved Stations' }); }); @@ -133,7 +147,11 @@ export const StationPicker: React.FC = ({ // Add nearby stations if (nearbyStations && nearbyStations.length > 0) { // Filter out stations already in saved list - const savedPlaceIds = new Set(savedStations?.map((s) => s.placeId) || []); + const savedPlaceIds = new Set( + (savedStations || []) + .map((station) => resolveSavedStationPlaceId(station)) + .filter((id): id is string => Boolean(id)) + ); nearbyStations .filter((station) => !savedPlaceIds.has(station.placeId)) @@ -171,16 +189,37 @@ export const StationPicker: React.FC = ({ // Selected from options const { station } = newValue; if (station) { - onChange({ - stationName: station.name, - address: station.address, - googlePlaceId: station.placeId, - coordinates: { - latitude: station.latitude, - longitude: station.longitude + const saved = isSavedStation(station); + const placeId = saved + ? resolveSavedStationPlaceId(station) || station.placeId + : station.placeId; + const name = saved ? resolveSavedStationName(station) : station.name; + const address = saved ? resolveSavedStationAddress(station) : station.address; + + let latitude = station.latitude; + let longitude = station.longitude; + + if ((latitude === undefined || longitude === undefined) && saved) { + const coords = resolveSavedStationCoordinates(station); + if (coords) { + latitude = coords.latitude; + longitude = coords.longitude; } + } + + onChange({ + stationName: name, + address, + googlePlaceId: placeId, + coordinates: + latitude !== undefined && longitude !== undefined + ? { + latitude, + longitude + } + : undefined }); - setInputValue(station.name); + setInputValue(name); } }, [onChange] diff --git a/frontend/src/features/stations/README.md b/frontend/src/features/stations/README.md index 954a4d6..ca67792 100644 --- a/frontend/src/features/stations/README.md +++ b/frontend/src/features/stations/README.md @@ -595,17 +595,18 @@ The Gas Stations feature uses MotoVaultPro's K8s-aligned runtime configuration p ### Accessing Configuration ```typescript -import { getGoogleMapsApiKey } from '@/core/config/config.types'; +import { getGoogleMapsApiKey, getGoogleMapsMapId } from '@/core/config/config.types'; export function MyComponent() { const apiKey = getGoogleMapsApiKey(); + const mapId = getGoogleMapsMapId(); - if (!apiKey) { - return
Google Maps API key not configured
; + if (!apiKey || !mapId) { + return
Google Maps configuration not complete
; } - // Use API key - return ; + // Use API key + map id + return ; } ``` @@ -617,9 +618,11 @@ For local development (Vite dev server): # Set up secrets mkdir -p ./secrets/app echo "YOUR_API_KEY" > ./secrets/app/google-maps-api-key.txt +echo "YOUR_MAP_ID" > ./secrets/app/google-maps-map-id.txt # Alternatively, set environment variable export VITE_GOOGLE_MAPS_API_KEY=YOUR_API_KEY +export VITE_GOOGLE_MAPS_MAP_ID=YOUR_MAP_ID ``` See `/frontend/docs/RUNTIME-CONFIG.md` for complete documentation. diff --git a/frontend/src/features/stations/components/OctanePreferenceSelector.tsx b/frontend/src/features/stations/components/OctanePreferenceSelector.tsx new file mode 100644 index 0000000..2023dbf --- /dev/null +++ b/frontend/src/features/stations/components/OctanePreferenceSelector.tsx @@ -0,0 +1,55 @@ +/** + * @ai-summary Selector for marking 93 octane availability on a saved station + */ + +import React from 'react'; +import { + FormControl, + InputLabel, + MenuItem, + Select, + SelectChangeEvent, + FormHelperText +} from '@mui/material'; +import { OctanePreference } from '../types/stations.types'; + +interface OctanePreferenceSelectorProps { + value: OctanePreference; + onChange: (value: OctanePreference) => void; + disabled?: boolean; + helperText?: string; + label?: string; +} + +const LABEL_ID = 'octane-preference-select'; + +export const OctanePreferenceSelector: React.FC = ({ + value, + onChange, + disabled = false, + helperText, + label = '93 Octane' +}) => { + const handleChange = (event: SelectChangeEvent) => { + onChange(event.target.value as OctanePreference); + }; + + return ( + + {label} + + {helperText && {helperText}} + + ); +}; + +export default OctanePreferenceSelector; diff --git a/frontend/src/features/stations/components/SavedStationsList.tsx b/frontend/src/features/stations/components/SavedStationsList.tsx index 8dd697e..08252ac 100644 --- a/frontend/src/features/stations/components/SavedStationsList.tsx +++ b/frontend/src/features/stations/components/SavedStationsList.tsx @@ -1,5 +1,5 @@ /** - * @ai-summary List of user's saved/favorited stations + * @ai-summary List of user's saved/favorited stations with octane metadata editing */ import React from 'react'; @@ -18,8 +18,15 @@ import { Skeleton } from '@mui/material'; import DeleteIcon from '@mui/icons-material/Delete'; -import { SavedStation } from '../types/stations.types'; +import { OctanePreference, SavedStation } from '../types/stations.types'; import { formatDistance } from '../utils/distance'; +import { + getOctanePreferenceFromFlags, + resolveSavedStationAddress, + resolveSavedStationName, + resolveSavedStationPlaceId +} from '../utils/savedStations'; +import { OctanePreferenceSelector } from './OctanePreferenceSelector'; interface SavedStationsListProps { stations: SavedStation[]; @@ -27,19 +34,19 @@ interface SavedStationsListProps { error?: string | null; onSelectStation?: (station: SavedStation) => void; onDeleteStation?: (placeId: string) => void; + onOctanePreferenceChange?: (placeId: string, preference: OctanePreference) => void; + octaneUpdatingId?: string | null; } -/** - * Vertical list of saved stations with delete option - */ export const SavedStationsList: React.FC = ({ stations, loading = false, error = null, onSelectStation, - onDeleteStation + onDeleteStation, + onOctanePreferenceChange, + octaneUpdatingId }) => { - // Loading state if (loading) { return ( @@ -56,7 +63,6 @@ export const SavedStationsList: React.FC = ({ ); } - // Error state if (error) { return ( @@ -65,7 +71,6 @@ export const SavedStationsList: React.FC = ({ ); } - // Empty state if (stations.length === 0) { return ( @@ -84,97 +89,118 @@ export const SavedStationsList: React.FC = ({ bgcolor: 'background.paper' }} > - {stations.map((station, index) => ( - - - onSelectStation?.(station)} - sx={{ flex: 1 }} + {stations.map((station, index) => { + const placeId = resolveSavedStationPlaceId(station); + const octanePreference = getOctanePreferenceFromFlags( + station.has93Octane ?? false, + station.has93OctaneEthanolFree ?? false + ); + + return ( + + - - - {station.nickname || station.name} - - {station.isFavorite && ( - - )} - - } - secondary={ - - - {station.address} - - {station.notes && ( - - {station.notes} - - )} - {station.distance !== undefined && ( - - {formatDistance(station.distance)} away - - )} - - } - /> - - - { - e.stopPropagation(); - onDeleteStation?.(station.placeId); - }} - title="Delete saved station" - sx={{ - minWidth: '44px', - minHeight: '44px' - }} + onSelectStation?.(station)} + sx={{ flex: 1 }} > - - - - - {index < stations.length - 1 && } - - ))} + + + {resolveSavedStationName(station)} + + {station.isFavorite && ( + + )} + + } + secondary={ + + + {resolveSavedStationAddress(station)} + + {station.notes && ( + + {station.notes} + + )} + {station.distance !== undefined && ( + + {formatDistance(station.distance)} away + + )} + {placeId && ( + + onOctanePreferenceChange?.(placeId, value)} + disabled={!onOctanePreferenceChange || octaneUpdatingId === placeId} + helperText="Show on search cards" + /> + + )} + + } + /> + + + { + e.stopPropagation(); + if (placeId) { + onDeleteStation?.(placeId); + } + }} + title="Delete saved station" + sx={{ + minWidth: '44px', + minHeight: '44px' + }} + disabled={!placeId} + > + + + + + {index < stations.length - 1 && } + + ); + })} ); }; diff --git a/frontend/src/features/stations/components/StationCard.tsx b/frontend/src/features/stations/components/StationCard.tsx index 2baa714..c354c56 100644 --- a/frontend/src/features/stations/components/StationCard.tsx +++ b/frontend/src/features/stations/components/StationCard.tsx @@ -16,12 +16,13 @@ import { import BookmarkIcon from '@mui/icons-material/Bookmark'; import BookmarkBorderIcon from '@mui/icons-material/BookmarkBorder'; import DirectionsIcon from '@mui/icons-material/Directions'; -import { Station } from '../types/stations.types'; +import { Station, SavedStation } from '../types/stations.types'; import { formatDistance } from '../utils/distance'; interface StationCardProps { station: Station; isSaved: boolean; + savedStation?: SavedStation; onSave?: (station: Station) => void; onDelete?: (placeId: string) => void; onSelect?: (station: Station) => void; @@ -34,6 +35,7 @@ interface StationCardProps { export const StationCard: React.FC = ({ station, isSaved, + savedStation, onSave, onDelete, onSelect @@ -53,6 +55,19 @@ export const StationCard: React.FC = ({ window.open(mapsUrl, '_blank'); }; + const savedMetadata = savedStation + ? { + has93Octane: savedStation.has93Octane, + has93OctaneEthanolFree: savedStation.has93OctaneEthanolFree + } + : station.savedMetadata; + + const octaneLabel = savedMetadata?.has93Octane + ? savedMetadata.has93OctaneEthanolFree + ? '93 Octane · Ethanol Free' + : '93 Octane · w/ Ethanol' + : null; + return ( onSelect?.(station)} @@ -127,6 +142,16 @@ export const StationCard: React.FC = ({ sx={{ marginBottom: 1 }} /> )} + + {/* 93 Octane metadata */} + {octaneLabel && ( + + )} {/* Actions */} diff --git a/frontend/src/features/stations/components/StationMap.tsx b/frontend/src/features/stations/components/StationMap.tsx index 75ed83a..4c861ec 100644 --- a/frontend/src/features/stations/components/StationMap.tsx +++ b/frontend/src/features/stations/components/StationMap.tsx @@ -12,6 +12,7 @@ import { createInfoWindow, fitBoundsToMarkers } from '../utils/map-utils'; +import { getGoogleMapsMapId } from '@/core/config/config.types'; interface StationMapProps { stations: Station[]; @@ -41,9 +42,9 @@ export const StationMap: React.FC = ({ }) => { const mapContainer = useRef(null); const map = useRef(null); - const markers = useRef([]); + const markers = useRef([]); const infoWindows = useRef([]); - const currentLocationMarker = useRef(null); + const currentLocationMarker = useRef(null); const isInitializing = useRef(false); const [isLoading, setIsLoading] = useState(true); @@ -89,13 +90,28 @@ export const StationMap: React.FC = ({ // Create map const defaultCenter = center || { - lat: currentLocation?.latitude || 37.7749, - lng: currentLocation?.longitude || -122.4194 + lat: currentLocation?.latitude || 43.074734, + lng: currentLocation?.longitude || -89.384271 }; + if (mapIdRef.current === null) { + const mapId = getGoogleMapsMapId(); + if (!mapId) { + console.error( + '[StationMap] Google Maps Map ID is not configured. Add google-maps-map-id secret to enable advanced markers.' + ); + setError('Google Maps Map ID is not configured. Please contact support.'); + isInitializing.current = false; + setIsLoading(false); + return; + } + mapIdRef.current = mapId; + } + map.current = new maps.Map(mapContainer.current, { zoom, center: defaultCenter, + mapId: mapIdRef.current || undefined, mapTypeControl: true, streetViewControl: false, fullscreenControl: true @@ -180,7 +196,7 @@ export const StationMap: React.FC = ({ try { markers.current.forEach((marker) => { try { - marker.setMap(null); + marker.map = null; } catch (e) { // Ignore individual marker cleanup errors } @@ -205,7 +221,7 @@ export const StationMap: React.FC = ({ try { if (currentLocationMarker.current) { - currentLocationMarker.current.setMap(null); + currentLocationMarker.current.map = null; currentLocationMarker.current = null; } } catch (err) { @@ -232,13 +248,15 @@ export const StationMap: React.FC = ({ } // Clear old markers and info windows - markers.current.forEach((marker) => marker.setMap(null)); + markers.current.forEach((marker) => { + marker.map = null; + }); infoWindows.current.forEach((iw) => iw.close()); markers.current = []; infoWindows.current = []; getGoogleMapsApi(); - let allMarkers: google.maps.Marker[] = []; + let allMarkers: google.maps.marker.AdvancedMarkerElement[] = []; // Add station markers stations.forEach((station) => { @@ -256,7 +274,11 @@ export const StationMap: React.FC = ({ infoWindows.current.forEach((iw) => iw.close()); // Open this one - infoWindow.open(map.current, marker); + infoWindow.open({ + anchor: marker, + map: map.current!, + shouldFocus: false + }); onMarkerClick?.(station); }); }); @@ -264,7 +286,7 @@ export const StationMap: React.FC = ({ // Add current location marker if (currentLocation) { if (currentLocationMarker.current) { - currentLocationMarker.current.setMap(null); + currentLocationMarker.current.map = null; } currentLocationMarker.current = createCurrentLocationMarker( @@ -346,3 +368,4 @@ export const StationMap: React.FC = ({ }; export default StationMap; + const mapIdRef = useRef(null); diff --git a/frontend/src/features/stations/components/StationsList.tsx b/frontend/src/features/stations/components/StationsList.tsx index 8f9091f..a353c31 100644 --- a/frontend/src/features/stations/components/StationsList.tsx +++ b/frontend/src/features/stations/components/StationsList.tsx @@ -11,12 +11,13 @@ import { Alert, Button } from '@mui/material'; -import { Station } from '../types/stations.types'; +import { Station, SavedStation } from '../types/stations.types'; import StationCard from './StationCard'; interface StationsListProps { stations: Station[]; savedPlaceIds?: Set; + savedStationsMap?: Map; loading?: boolean; error?: string | null; onSaveStation?: (station: Station) => void; @@ -32,6 +33,7 @@ interface StationsListProps { export const StationsList: React.FC = ({ stations, savedPlaceIds = new Set(), + savedStationsMap, loading = false, error = null, onSaveStation, @@ -92,6 +94,7 @@ export const StationsList: React.FC = ({ = ({ setCity(e.target.value); markManualAddressInput(); }} - placeholder="San Francisco" + placeholder="Madison" autoComplete="address-level2" fullWidth /> diff --git a/frontend/src/features/stations/hooks/index.ts b/frontend/src/features/stations/hooks/index.ts index 92445ed..9be68ad 100644 --- a/frontend/src/features/stations/hooks/index.ts +++ b/frontend/src/features/stations/hooks/index.ts @@ -5,5 +5,6 @@ export { useStationsSearch } from './useStationsSearch'; export { useSavedStations, useInvalidateSavedStations, useUpdateSavedStationsCache } from './useSavedStations'; export { useSaveStation } from './useSaveStation'; +export { useUpdateSavedStation } from './useUpdateSavedStation'; export { useDeleteStation } from './useDeleteStation'; export { useGeolocation } from './useGeolocation'; diff --git a/frontend/src/features/stations/hooks/useDeleteStation.ts b/frontend/src/features/stations/hooks/useDeleteStation.ts index 5f820e4..417aab8 100644 --- a/frontend/src/features/stations/hooks/useDeleteStation.ts +++ b/frontend/src/features/stations/hooks/useDeleteStation.ts @@ -2,10 +2,11 @@ * @ai-summary Hook for deleting saved stations */ -import { useMutation } from '@tanstack/react-query'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; import { stationsApi } from '../api/stations.api'; import { SavedStation, ApiError } from '../types/stations.types'; -import { useUpdateSavedStationsCache } from './useSavedStations'; +import { useSavedStationsQueryKey, useUpdateSavedStationsCache } from './useSavedStations'; +import { resolveSavedStationPlaceId } from '../utils/savedStations'; interface UseDeleteStationOptions { onSuccess?: (placeId: string) => void; @@ -27,6 +28,8 @@ interface UseDeleteStationOptions { */ export function useDeleteStation(options?: UseDeleteStationOptions) { const updateCache = useUpdateSavedStationsCache(); + const queryClient = useQueryClient(); + const savedStationsKey = useSavedStationsQueryKey(); return useMutation({ mutationFn: async (placeId: string) => { @@ -41,12 +44,18 @@ export function useDeleteStation(options?: UseDeleteStationOptions) { updateCache((old) => { previousStations = old; if (!old) return []; - return old.filter((s) => s.placeId !== placeId); + return old.filter((station) => { + const stationPlaceId = resolveSavedStationPlaceId(station); + return stationPlaceId !== placeId; + }); }); return { previousStations, placeId }; }, onSuccess: (placeId) => { + queryClient.invalidateQueries({ + queryKey: savedStationsKey + }); options?.onSuccess?.(placeId); }, onError: (error, _placeId, context) => { diff --git a/frontend/src/features/stations/hooks/useSaveStation.ts b/frontend/src/features/stations/hooks/useSaveStation.ts index 339db16..7ac32b6 100644 --- a/frontend/src/features/stations/hooks/useSaveStation.ts +++ b/frontend/src/features/stations/hooks/useSaveStation.ts @@ -55,6 +55,7 @@ export function useSaveStation(options?: UseSaveStationOptions) { // Create optimistic station entry const optimisticStation: SavedStation = { id: `temp-${placeId}`, + savedStationId: `temp-${placeId}`, placeId, name: data.nickname || 'New Station', address: '', @@ -65,6 +66,8 @@ export function useSaveStation(options?: UseSaveStationOptions) { nickname: data.nickname, notes: data.notes, isFavorite: data.isFavorite ?? false, + has93Octane: data.has93Octane ?? false, + has93OctaneEthanolFree: data.has93OctaneEthanolFree ?? false, createdAt: new Date(), updatedAt: new Date() }; diff --git a/frontend/src/features/stations/hooks/useUpdateSavedStation.ts b/frontend/src/features/stations/hooks/useUpdateSavedStation.ts new file mode 100644 index 0000000..fcd5f14 --- /dev/null +++ b/frontend/src/features/stations/hooks/useUpdateSavedStation.ts @@ -0,0 +1,70 @@ +/** + * @ai-summary Hook for updating saved station metadata + */ + +import { useMutation } from '@tanstack/react-query'; +import { stationsApi } from '../api/stations.api'; +import { ApiError, SavedStation, SaveStationData } from '../types/stations.types'; +import { useUpdateSavedStationsCache } from './useSavedStations'; +import { resolveSavedStationPlaceId } from '../utils/savedStations'; + +interface UpdateSavedStationVariables { + placeId: string; + data: Partial; +} + +interface UseUpdateSavedStationOptions { + onSuccess?: (station: SavedStation) => void; + onError?: (error: ApiError) => void; +} + +/** + * Mutation hook to update saved station metadata (octane flags, nickname, etc.) + */ +export function useUpdateSavedStation(options?: UseUpdateSavedStationOptions) { + const updateCache = useUpdateSavedStationsCache(); + + return useMutation({ + mutationFn: async ({ placeId, data }: UpdateSavedStationVariables) => { + return stationsApi.updateSavedStation(placeId, data); + }, + onMutate: async ({ placeId, data }) => { + let previousStations: SavedStation[] | undefined; + + updateCache((old) => { + previousStations = old; + if (!old) return []; + + return old.map((station) => { + const stationPlaceId = resolveSavedStationPlaceId(station); + if (stationPlaceId !== placeId) { + return station; + } + + return { + ...station, + ...data, + has93Octane: + data.has93Octane !== undefined ? data.has93Octane : station.has93Octane, + has93OctaneEthanolFree: + data.has93OctaneEthanolFree !== undefined + ? data.has93OctaneEthanolFree + : station.has93OctaneEthanolFree + }; + }); + }); + + return { previousStations }; + }, + onSuccess: (station) => { + options?.onSuccess?.(station); + }, + onError: (error, _variables, context) => { + if (context?.previousStations) { + updateCache(() => context.previousStations || []); + } + + options?.onError?.(error as ApiError); + } + }); +} diff --git a/frontend/src/features/stations/mobile/StationsMobileScreen.tsx b/frontend/src/features/stations/mobile/StationsMobileScreen.tsx index 7104550..a6e84b3 100644 --- a/frontend/src/features/stations/mobile/StationsMobileScreen.tsx +++ b/frontend/src/features/stations/mobile/StationsMobileScreen.tsx @@ -30,14 +30,17 @@ import { useSavedStations, useSaveStation, useDeleteStation, + useUpdateSavedStation, useGeolocation } from '../hooks'; import { Station, SavedStation, - StationSearchRequest + StationSearchRequest, + OctanePreference } from '../types/stations.types'; +import { octanePreferenceToFlags, resolveSavedStationPlaceId } from '../utils/savedStations'; // Tab indices const TAB_SEARCH = 0; @@ -56,6 +59,7 @@ export const StationsMobileScreen: React.FC = () => { // Bottom sheet state const [selectedStation, setSelectedStation] = useState(null); const [drawerOpen, setDrawerOpen] = useState(false); + const [octaneUpdatingId, setOctaneUpdatingId] = useState(null); // Hooks const { coordinates } = useGeolocation(); @@ -74,10 +78,28 @@ export const StationsMobileScreen: React.FC = () => { const { mutateAsync: saveStation } = useSaveStation(); const { mutateAsync: deleteStation } = useDeleteStation(); + const { mutateAsync: updateSavedStation } = useUpdateSavedStation(); // Compute set of saved place IDs for quick lookup - const savedPlaceIds = useMemo(() => { - return new Set(savedStations?.map(s => s.placeId) || []); + const { savedStationsMap, savedPlaceIds } = useMemo(() => { + const map = new Map(); + + (savedStations || []).forEach((station) => { + const placeId = resolveSavedStationPlaceId(station); + if (!placeId) { + return; + } + + const normalizedStation = + station.placeId === placeId ? station : { ...station, placeId }; + + map.set(placeId, normalizedStation); + }); + + return { + savedStationsMap: map, + savedPlaceIds: new Set(map.keys()) + }; }, [savedStations]); // Handle search submission @@ -121,6 +143,21 @@ export const StationsMobileScreen: React.FC = () => { } }, [deleteStation, selectedStation]); + const handleOctanePreferenceChange = useCallback( + async (placeId: string, preference: OctanePreference) => { + try { + setOctaneUpdatingId(placeId); + const data = octanePreferenceToFlags(preference); + await updateSavedStation({ placeId, data }); + } catch (error) { + console.error('Failed to update octane preference:', error); + } finally { + setOctaneUpdatingId((current) => (current === placeId ? null : current)); + } + }, + [updateSavedStation] + ); + // Close bottom sheet const handleCloseDrawer = useCallback(() => { setDrawerOpen(false); @@ -214,6 +251,7 @@ export const StationsMobileScreen: React.FC = () => { { error={savedError ? 'Failed to load saved stations' : null} onSelectStation={handleSelectStation} onDeleteStation={handleDeleteStation} + onOctanePreferenceChange={handleOctanePreferenceChange} + octaneUpdatingId={octaneUpdatingId} /> )} diff --git a/frontend/src/features/stations/pages/StationsPage.tsx b/frontend/src/features/stations/pages/StationsPage.tsx index 74c6966..46803cd 100644 --- a/frontend/src/features/stations/pages/StationsPage.tsx +++ b/frontend/src/features/stations/pages/StationsPage.tsx @@ -15,12 +15,13 @@ import { CircularProgress, Typography } from '@mui/material'; -import { Station, StationSearchRequest } from '../types/stations.types'; +import { OctanePreference, SavedStation, Station, StationSearchRequest } from '../types/stations.types'; import { useStationsSearch, useSavedStations, useSaveStation, - useDeleteStation + useDeleteStation, + useUpdateSavedStation } from '../hooks'; import { StationMap, @@ -29,6 +30,7 @@ import { StationsSearchForm, GoogleMapsErrorBoundary } from '../components'; +import { octanePreferenceToFlags, resolveSavedStationPlaceId } from '../utils/savedStations'; interface TabPanelProps { children?: React.ReactNode; @@ -119,12 +121,30 @@ export const StationsPage: React.FC = () => { const { mutate: saveStation } = useSaveStation(); const { mutate: deleteStation } = useDeleteStation(); + const { mutate: updateSavedStation } = useUpdateSavedStation(); + const [octaneUpdatingId, setOctaneUpdatingId] = useState(null); // Create set of saved place IDs for quick lookup - const savedPlaceIds = useMemo( - () => new Set(savedStations.map((s) => s.placeId)), - [savedStations] - ); + const { savedStationsMap, savedPlaceIds } = useMemo(() => { + const map = new Map(); + + savedStations.forEach((station) => { + const placeId = resolveSavedStationPlaceId(station); + if (!placeId) { + return; + } + + const normalizedStation = + station.placeId === placeId ? station : { ...station, placeId }; + + map.set(placeId, normalizedStation); + }); + + return { + savedStationsMap: map, + savedPlaceIds: new Set(map.keys()) + }; + }, [savedStations]); // Handle search const handleSearch = (request: StationSearchRequest) => { @@ -163,6 +183,23 @@ export const StationsPage: React.FC = () => { deleteStation(placeId); }; + const handleOctanePreferenceChange = useCallback( + (placeId: string, preference: OctanePreference) => { + const flags = octanePreferenceToFlags(preference); + setOctaneUpdatingId(placeId); + + updateSavedStation( + { placeId, data: flags }, + { + onSettled: () => { + setOctaneUpdatingId((current) => (current === placeId ? null : current)); + } + } + ); + }, + [updateSavedStation] + ); + // Handle station selection - wrapped in useCallback to prevent infinite renders const handleSelectStation = useCallback((station: Station) => { setMapCenter({ @@ -238,6 +275,7 @@ export const StationsPage: React.FC = () => { { error={savedError ? (savedError as any).message : null} onSelectStation={handleSelectStation} onDeleteStation={handleDelete} + onOctanePreferenceChange={handleOctanePreferenceChange} + octaneUpdatingId={octaneUpdatingId} /> ); } - // Desktop layout: side-by-side + // Desktop layout: top-row map + search, full-width results return ( - - {/* Left: Map (60%) */} - - - {isMapReady ? ( - - - - ) : ( - - - - )} - - - - {/* Right: Search + Tabs (40%) */} - - {/* Search Form */} - - - - - {/* Error Alert */} - {searchError && ( - {(searchError as any).message || 'Search failed'} - )} - - {/* Tabs */} - - setTabValue(newValue)} - indicatorColor="primary" - textColor="primary" - aria-label="stations tabs" + + + {/* Map */} + + - - - + {isMapReady ? ( + + + + ) : ( + + + + )} + + - {/* Tab Content with overflow */} - - - - - - - - - - + {/* Search form */} + + + + + + + - + + {/* Error Alert */} + {searchError && ( + + {(searchError as any).message || 'Search failed'} + + )} + + {/* Full-width Results */} + + setTabValue(newValue)} + indicatorColor="primary" + textColor="primary" + aria-label="stations tabs" + > + + + + + + + + + + + + + + + ); }; diff --git a/frontend/src/features/stations/types/google-maps.d.ts b/frontend/src/features/stations/types/google-maps.d.ts index c1d8888..e1e70ad 100644 --- a/frontend/src/features/stations/types/google-maps.d.ts +++ b/frontend/src/features/stations/types/google-maps.d.ts @@ -44,7 +44,7 @@ declare global { */ class InfoWindow { constructor(options?: google.maps.InfoWindowOptions); - open(map?: google.maps.Map | null, anchor?: google.maps.Marker): void; + open(target?: google.maps.Map | { anchor?: any; map?: google.maps.Map | null; shouldFocus?: boolean } | null, anchor?: google.maps.Marker): void; close(): void; setContent(content: string | HTMLElement): void; } @@ -77,6 +77,27 @@ declare global { BACKWARD_OPEN_ARROW = 'BACKWARD_OPEN_ARROW' } + namespace marker { + interface AdvancedMarkerElementOptions { + position?: google.maps.LatLng | google.maps.LatLngLiteral; + map?: google.maps.Map | null; + title?: string; + content?: HTMLElement; + } + + class AdvancedMarkerElement { + constructor(options?: AdvancedMarkerElementOptions); + map: google.maps.Map | null; + position?: google.maps.LatLng | google.maps.LatLngLiteral; + content?: HTMLElement; + title?: string; + addListener( + eventName: string, + callback: (...args: any[]) => void + ): google.maps.MapsEventListener; + } + } + /** * Google Maps Event Listener */ @@ -91,6 +112,7 @@ declare global { zoom?: number; center?: google.maps.LatLng | google.maps.LatLngLiteral; mapTypeId?: string; + mapId?: string; [key: string]: any; } diff --git a/frontend/src/features/stations/types/stations.types.ts b/frontend/src/features/stations/types/stations.types.ts index c7f7bd8..bd3d2a3 100644 --- a/frontend/src/features/stations/types/stations.types.ts +++ b/frontend/src/features/stations/types/stations.types.ts @@ -28,6 +28,14 @@ export interface SearchLocation { longitude: number; } +export interface StationSavedMetadata { + nickname?: string; + notes?: string; + isFavorite: boolean; + has93Octane: boolean; + has93OctaneEthanolFree: boolean; +} + /** * Single gas station from search results */ @@ -50,6 +58,10 @@ export interface Station { distance?: number; /** URL to station photo if available */ photoUrl?: string; + /** Whether the station is saved for the user */ + isSaved?: boolean; + /** Saved-station metadata if applicable */ + savedMetadata?: StationSavedMetadata; } /** @@ -58,18 +70,28 @@ export interface Station { export interface SavedStation extends Station { /** Database record ID */ id: string; + /** Optional saved-station identifier (alias for id when needed) */ + savedStationId?: string; /** User ID who saved the station */ userId: string; + /** Stored station id (Google place id) */ + stationId?: string; /** Custom nickname given by user */ nickname?: string; /** User notes about the station */ notes?: string; /** Whether station is marked as favorite */ isFavorite: boolean; + /** Whether the station is confirmed to have 93 octane */ + has93Octane: boolean; + /** Whether the 93 octane is ethanol free */ + has93OctaneEthanolFree: boolean; /** Created timestamp */ createdAt: Date; /** Last updated timestamp */ updatedAt: Date; + /** Raw station object returned by backend, if any */ + station?: Station | null; } /** @@ -96,6 +118,10 @@ export interface SaveStationData { notes?: string; /** Whether to mark as favorite */ isFavorite?: boolean; + /** Whether 93 octane is available */ + has93Octane?: boolean; + /** Whether the 93 octane option is ethanol free */ + has93OctaneEthanolFree?: boolean; } /** @@ -137,3 +163,6 @@ export interface ApiError { code?: string; details?: Record; } + +/** User-facing preference for 93 octane availability */ +export type OctanePreference = 'none' | 'with_ethanol' | 'ethanol_free'; diff --git a/frontend/src/features/stations/utils/map-utils.ts b/frontend/src/features/stations/utils/map-utils.ts index a688387..607806f 100644 --- a/frontend/src/features/stations/utils/map-utils.ts +++ b/frontend/src/features/stations/utils/map-utils.ts @@ -1,58 +1,62 @@ /** - * @ai-summary Google Maps utility functions + * @ai-summary Google Maps utility helpers using AdvancedMarkerElement */ import { getGoogleMapsApi } from './maps-loader'; import { Station, MapMarker } from '../types/stations.types'; import { formatDistance } from './distance'; -/** - * Create a marker for a station - * - * @param station Station data - * @param map Google Map instance - * @param isSaved Whether station is saved - * @returns Google Maps Marker - */ +type AdvancedMarker = google.maps.marker.AdvancedMarkerElement; + +function createMarkerElement(color: string, label?: string): HTMLElement { + const marker = document.createElement('div'); + marker.style.width = '24px'; + marker.style.height = '24px'; + marker.style.borderRadius = '50%'; + marker.style.backgroundColor = color; + marker.style.border = '2px solid #ffffff'; + marker.style.boxShadow = '0 1px 4px rgba(0,0,0,0.4)'; + marker.style.display = 'flex'; + marker.style.alignItems = 'center'; + marker.style.justifyContent = 'center'; + marker.style.color = '#000'; + marker.style.fontSize = '12px'; + marker.style.fontWeight = 'bold'; + marker.style.lineHeight = '1'; + marker.style.transform = 'translate(-50%, -50%)'; + + if (label) { + marker.textContent = label; + } + + return marker; +} + export function createStationMarker( station: Station, map: google.maps.Map, isSaved: boolean -): google.maps.Marker { +): AdvancedMarker { const maps = getGoogleMapsApi(); - const markerColor = isSaved ? '#FFD700' : '#4285F4'; // Gold for saved, blue for normal + const markerColor = isSaved ? '#FFD700' : '#4285F4'; + const content = createMarkerElement(markerColor, isSaved ? '★' : undefined); - const marker = new maps.Marker({ + const marker = new maps.marker.AdvancedMarkerElement({ position: { lat: station.latitude, lng: station.longitude }, map, title: station.name, - icon: { - path: maps.SymbolPath.CIRCLE, - scale: 8, - fillColor: markerColor, - fillOpacity: 1, - strokeColor: '#fff', - strokeWeight: 2 - } + content }); - // Store station data on marker (marker as any).stationData = station; (marker as any).isSaved = isSaved; return marker; } -/** - * Create info window for a station - * - * @param station Station data - * @param isSaved Whether station is saved - * @returns Google Maps InfoWindow - */ export function createInfoWindow( station: Station, isSaved: boolean @@ -73,11 +77,15 @@ export function createInfoWindow( } ${ station.rating - ? `

Rating: ⭐ ${station.rating.toFixed(1)}

` + ? `

Rating: ⭐ ${station.rating.toFixed( + 1 + )}

` : '' }
- + Directions ${isSaved ? '★ Saved' : ''} @@ -85,20 +93,12 @@ export function createInfoWindow(
`; - return new maps.InfoWindow({ - content - }); + return new maps.InfoWindow({ content }); } -/** - * Fit map bounds to show all markers - * - * @param map Google Map instance - * @param markers Array of markers - */ export function fitBoundsToMarkers( map: google.maps.Map, - markers: google.maps.Marker[] + markers: AdvancedMarker[] ): void { if (markers.length === 0) return; @@ -106,55 +106,37 @@ export function fitBoundsToMarkers( const bounds = new maps.LatLngBounds(); markers.forEach((marker) => { - const position = marker.getPosition(); - if (position) { - bounds.extend(position); + const positionLiteral = marker.position; + if (!positionLiteral) { + return; } + + const latLng = + positionLiteral instanceof maps.LatLng + ? positionLiteral + : new maps.LatLng(positionLiteral.lat, positionLiteral.lng); + bounds.extend(latLng); }); - map.fitBounds(bounds); - - // Add padding - const padding = { top: 50, right: 50, bottom: 50, left: 50 }; - map.fitBounds(bounds, padding); + map.fitBounds(bounds, { top: 50, right: 50, bottom: 50, left: 50 }); } -/** - * Create current location marker - * - * @param latitude Current latitude - * @param longitude Current longitude - * @param map Google Map instance - * @returns Google Maps Marker - */ export function createCurrentLocationMarker( latitude: number, longitude: number, map: google.maps.Map -): google.maps.Marker { +): AdvancedMarker { const maps = getGoogleMapsApi(); + const content = createMarkerElement('#FF0000'); - return new maps.Marker({ - position: { - lat: latitude, - lng: longitude - }, + return new maps.marker.AdvancedMarkerElement({ + position: { lat: latitude, lng: longitude }, map, title: 'Your Location', - icon: { - path: maps.SymbolPath.CIRCLE, - scale: 10, - fillColor: '#FF0000', - fillOpacity: 0.7, - strokeColor: '#fff', - strokeWeight: 2 - } + content }); } -/** - * Convert Station to MapMarker - */ export function stationToMapMarker( station: Station, isSaved: boolean diff --git a/frontend/src/features/stations/utils/maps-loader.ts b/frontend/src/features/stations/utils/maps-loader.ts index 90b1bbb..9637797 100644 --- a/frontend/src/features/stations/utils/maps-loader.ts +++ b/frontend/src/features/stations/utils/maps-loader.ts @@ -53,7 +53,7 @@ export function loadGoogleMaps(): Promise { // The callback parameter tells Google Maps to call our function when ready // Using async + callback ensures Google Maps initializes asynchronously const script = document.createElement('script'); - script.src = `https://maps.googleapis.com/maps/api/js?key=${apiKey}&callback=${callbackName}&libraries=places&loading=async`; + script.src = `https://maps.googleapis.com/maps/api/js?key=${apiKey}&callback=${callbackName}&libraries=places,marker&loading=async`; script.async = true; script.defer = true; // Load asynchronously without blocking parsing (per Maps best practices) diff --git a/frontend/src/features/stations/utils/savedStations.ts b/frontend/src/features/stations/utils/savedStations.ts new file mode 100644 index 0000000..0f13489 --- /dev/null +++ b/frontend/src/features/stations/utils/savedStations.ts @@ -0,0 +1,69 @@ +/** + * @ai-summary Helper utilities for working with saved stations data + */ + +import { OctanePreference, SavedStation, SaveStationData } from '../types/stations.types'; + +export function resolveSavedStationPlaceId(station: SavedStation): string | undefined { + return station.placeId || station.station?.placeId || station.stationId; +} + +export function resolveSavedStationName(station: SavedStation): string { + return ( + station.nickname || + station.name || + station.station?.name || + 'Saved Station' + ); +} + +export function resolveSavedStationAddress(station: SavedStation): string { + return station.address || station.station?.address || ''; +} + +export function resolveSavedStationCoordinates( + station: SavedStation +): { latitude: number; longitude: number } | undefined { + const lat = station.latitude ?? station.station?.latitude; + const lng = station.longitude ?? station.station?.longitude; + + if (lat === undefined || lng === undefined) { + return undefined; + } + + return { latitude: lat, longitude: lng }; +} + +export function getOctanePreferenceFromFlags( + has93Octane: boolean, + has93OctaneEthanolFree: boolean +): OctanePreference { + if (!has93Octane) { + return 'none'; + } + + return has93OctaneEthanolFree ? 'ethanol_free' : 'with_ethanol'; +} + +export function octanePreferenceToFlags( + preference: OctanePreference +): Pick { + if (preference === 'none') { + return { + has93Octane: false, + has93OctaneEthanolFree: false + }; + } + + if (preference === 'ethanol_free') { + return { + has93Octane: true, + has93OctaneEthanolFree: true + }; + } + + return { + has93Octane: true, + has93OctaneEthanolFree: false + }; +} diff --git a/frontend/src/features/vehicles/components/VehicleForm.tsx b/frontend/src/features/vehicles/components/VehicleForm.tsx index a14ce80..a84b0bf 100644 --- a/frontend/src/features/vehicles/components/VehicleForm.tsx +++ b/frontend/src/features/vehicles/components/VehicleForm.tsx @@ -67,6 +67,15 @@ export const VehicleForm: React.FC = ({ initialData, loading, }) => { + const formatVehicleLabel = (value?: string): string => { + if (!value) return ''; + return value + .split(' ') + .filter(Boolean) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); + }; + const [years, setYears] = useState([]); const [makes, setMakes] = useState([]); const [models, setModels] = useState([]); @@ -338,7 +347,7 @@ export const VehicleForm: React.FC = ({ {makes.map((make) => ( ))} @@ -357,7 +366,7 @@ export const VehicleForm: React.FC = ({ {models.map((model) => ( ))} diff --git a/test-bulk-delete.sh b/test-bulk-delete.sh deleted file mode 100755 index 04a6d9f..0000000 --- a/test-bulk-delete.sh +++ /dev/null @@ -1,82 +0,0 @@ -#!/bin/bash - -# Test script for bulk catalog delete endpoint -# Note: This is a test script to verify the endpoint structure - -BASE_URL="http://localhost/api" - -echo "Testing bulk delete catalog endpoint" -echo "====================================" -echo "" - -# Test 1: Invalid entity type -echo "Test 1: Invalid entity type (should return 400)" -curl -X DELETE "${BASE_URL}/admin/catalog/invalid/bulk-delete" \ - -H "Content-Type: application/json" \ - -d '{"ids": [1, 2, 3]}' \ - -w "\nStatus: %{http_code}\n\n" - -# Test 2: Empty IDs array -echo "Test 2: Empty IDs array (should return 400)" -curl -X DELETE "${BASE_URL}/admin/catalog/makes/bulk-delete" \ - -H "Content-Type: application/json" \ - -d '{"ids": []}' \ - -w "\nStatus: %{http_code}\n\n" - -# Test 3: Invalid IDs (negative numbers) -echo "Test 3: Invalid IDs - negative numbers (should return 400)" -curl -X DELETE "${BASE_URL}/admin/catalog/makes/bulk-delete" \ - -H "Content-Type: application/json" \ - -d '{"ids": [1, -2, 3]}' \ - -w "\nStatus: %{http_code}\n\n" - -# Test 4: Invalid IDs (strings instead of numbers) -echo "Test 4: Invalid IDs - strings (should return 400)" -curl -X DELETE "${BASE_URL}/admin/catalog/makes/bulk-delete" \ - -H "Content-Type: application/json" \ - -d '{"ids": ["abc", "def"]}' \ - -w "\nStatus: %{http_code}\n\n" - -# Test 5: Valid request format (will fail without auth, but shows structure) -echo "Test 5: Valid request format - makes (needs auth)" -curl -X DELETE "${BASE_URL}/admin/catalog/makes/bulk-delete" \ - -H "Content-Type: application/json" \ - -d '{"ids": [999, 998]}' \ - -w "\nStatus: %{http_code}\n\n" - -# Test 6: Valid request format - models (needs auth) -echo "Test 6: Valid request format - models (needs auth)" -curl -X DELETE "${BASE_URL}/admin/catalog/models/bulk-delete" \ - -H "Content-Type: application/json" \ - -d '{"ids": [999, 998]}' \ - -w "\nStatus: %{http_code}\n\n" - -# Test 7: Valid request format - years (needs auth) -echo "Test 7: Valid request format - years (needs auth)" -curl -X DELETE "${BASE_URL}/admin/catalog/years/bulk-delete" \ - -H "Content-Type: application/json" \ - -d '{"ids": [999, 998]}' \ - -w "\nStatus: %{http_code}\n\n" - -# Test 8: Valid request format - trims (needs auth) -echo "Test 8: Valid request format - trims (needs auth)" -curl -X DELETE "${BASE_URL}/admin/catalog/trims/bulk-delete" \ - -H "Content-Type: application/json" \ - -d '{"ids": [999, 998]}' \ - -w "\nStatus: %{http_code}\n\n" - -# Test 9: Valid request format - engines (needs auth) -echo "Test 9: Valid request format - engines (needs auth)" -curl -X DELETE "${BASE_URL}/admin/catalog/engines/bulk-delete" \ - -H "Content-Type: application/json" \ - -d '{"ids": [999, 998]}' \ - -w "\nStatus: %{http_code}\n\n" - -echo "====================================" -echo "Test script complete" -echo "" -echo "Expected results:" -echo "- Tests 1-4: Should return 400 (Bad Request)" -echo "- Tests 5-9: Should return 401 (Unauthorized) without auth token" -echo "" -echo "Note: Full testing requires admin authentication token"