Google Maps Bug

This commit is contained in:
Eric Gullickson
2025-11-08 12:17:29 -06:00
parent efbe9ba3c0
commit bb4a356b9e
39 changed files with 1175 additions and 449 deletions

View File

@@ -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<void> {
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();

View File

@@ -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();
});
});

View File

@@ -114,6 +114,15 @@ export class PlatformCacheService {
* Invalidate all vehicle data cache (for admin operations)
*/
async invalidateVehicleData(): Promise<void> {
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 });
}
}
}

View File

@@ -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);
@@ -83,6 +97,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 {
const userId = (request as any).user.sub;

View File

@@ -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],

View File

@@ -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<SavedStation> {
async saveStation(
userId: string,
placeId: string,
data?: {
nickname?: string;
notes?: string;
isFavorite?: boolean;
has93Octane?: boolean;
has93OctaneEthanolFree?: boolean;
}
): Promise<SavedStation> {
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<SavedStation | null> {
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<SavedStation[]> {
const query = `
SELECT * FROM saved_stations
@@ -107,9 +176,12 @@ 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
};

View File

@@ -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 {
@@ -23,13 +29,42 @@ export class StationsService {
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);
@@ -63,6 +104,25 @@ export class StationsService {
};
}
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');
}
}
private mapSavedMetadata(saved: SavedStation): StationSavedMetadata {
return {
nickname: saved.nickname,
notes: saved.notes,
isFavorite: saved.isFavorite,
has93Octane: saved.has93Octane,
has93OctaneEthanolFree: saved.has93OctaneEthanolFree
};
}
}

View File

@@ -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;
}
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;
}

View File

@@ -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);

View File

@@ -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')
}

View File

@@ -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<StationsRepository>;
@@ -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];

View File

@@ -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;"

View File

@@ -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"
}

View File

@@ -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:

View File

@@ -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" <<EOF
window.CONFIG = {
googleMapsApiKey: '$GOOGLE_MAPS_API_KEY',
googleMapsMapId: '$GOOGLE_MAPS_MAP_ID',
newApiKey: '$NEW_API_KEY'
};
EOF
@@ -96,6 +97,7 @@ EOF
```typescript
export interface AppConfig {
googleMapsApiKey: string;
googleMapsMapId?: string;
newApiKey: string; // Add new field
}
@@ -108,6 +110,16 @@ export function getNewApiKey(): string {
return '';
}
}
export function getGoogleMapsMapId(): string {
try {
const config = getConfig();
return config.googleMapsMapId || '';
} catch {
console.warn('Google Maps Map ID not available.');
return '';
}
}
```
### 3. Update docker-compose.yml
@@ -116,6 +128,7 @@ export function getNewApiKey(): string {
mvp-frontend:
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
- ./secrets/app/new-api-key.txt:/run/secrets/new-api-key:ro # Add new secret
```

View File

@@ -7,6 +7,7 @@ set -e
SECRETS_DIR="${SECRETS_DIR:-/run/secrets}"
CONFIG_FILE="/usr/share/nginx/html/config.js"
GOOGLE_MAPS_API_KEY=""
GOOGLE_MAPS_MAP_ID=""
# Try to read Google Maps API key from secret file
if [ -f "$SECRETS_DIR/google-maps-api-key" ]; then
@@ -17,10 +18,20 @@ else
GOOGLE_MAPS_API_KEY=""
fi
# Try to read Google Maps Map ID (optional)
if [ -f "$SECRETS_DIR/google-maps-map-id" ]; then
GOOGLE_MAPS_MAP_ID=$(cat "$SECRETS_DIR/google-maps-map-id")
echo "[Config] Loaded Google Maps Map ID from $SECRETS_DIR/google-maps-map-id"
else
echo "[Config] Info: Google Maps Map ID not found at $SECRETS_DIR/google-maps-map-id (advanced markers require this)"
GOOGLE_MAPS_MAP_ID=""
fi
# Generate config.js
cat > "$CONFIG_FILE" <<EOF
window.CONFIG = {
googleMapsApiKey: '$GOOGLE_MAPS_API_KEY'
googleMapsApiKey: '$GOOGLE_MAPS_API_KEY',
googleMapsMapId: '$GOOGLE_MAPS_MAP_ID'
};
EOF

View File

@@ -13,6 +13,8 @@
export interface AppConfig {
/** Google Maps JavaScript API key for map visualization */
googleMapsApiKey: string;
/** Google Maps Map ID for vector basemap and advanced markers */
googleMapsMapId?: string;
}
/**
@@ -60,6 +62,21 @@ export function getGoogleMapsApiKey(): string {
}
}
/**
* Get Google Maps Map ID (optional)
*
* @returns Google Maps Map ID or empty string
*/
export function getGoogleMapsMapId(): string {
try {
const config = getConfig();
return config.googleMapsMapId || '';
} catch {
console.warn('Google Maps Map ID not available. Advanced map features may be limited.');
return '';
}
}
/**
* Check if configuration is available
* Useful for conditional feature enablement

View File

@@ -19,6 +19,12 @@ import {
import { useSavedStations } from '../../stations/hooks/useSavedStations';
import { useStationsSearch } from '../../stations/hooks/useStationsSearch';
import { Station, SavedStation, GeolocationCoordinates } from '../../stations/types/stations.types';
import {
resolveSavedStationAddress,
resolveSavedStationCoordinates,
resolveSavedStationName,
resolveSavedStationPlaceId
} from '../../stations/utils/savedStations';
import { LocationData } from '../types/fuel-logs.types';
interface StationPickerProps {
@@ -121,10 +127,18 @@ export const StationPicker: React.FC<StationPickerProps> = ({
// 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<StationPickerProps> = ({
// 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<StationPickerProps> = ({
// 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]

View File

@@ -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 <div>Google Maps API key not configured</div>;
if (!apiKey || !mapId) {
return <div>Google Maps configuration not complete</div>;
}
// Use API key
return <MapComponent apiKey={apiKey} />;
// Use API key + map id
return <StationMap apiKey={apiKey} mapId={mapId} />;
}
```
@@ -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.

View File

@@ -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<OctanePreferenceSelectorProps> = ({
value,
onChange,
disabled = false,
helperText,
label = '93 Octane'
}) => {
const handleChange = (event: SelectChangeEvent) => {
onChange(event.target.value as OctanePreference);
};
return (
<FormControl size="small" fullWidth disabled={disabled}>
<InputLabel id={LABEL_ID}>{label}</InputLabel>
<Select
labelId={LABEL_ID}
value={value}
label={label}
onChange={handleChange}
>
<MenuItem value="none">Not set</MenuItem>
<MenuItem value="with_ethanol">93 w/ Ethanol</MenuItem>
<MenuItem value="ethanol_free">93 Ethanol Free</MenuItem>
</Select>
{helperText && <FormHelperText>{helperText}</FormHelperText>}
</FormControl>
);
};
export default OctanePreferenceSelector;

View File

@@ -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<SavedStationsListProps> = ({
stations,
loading = false,
error = null,
onSelectStation,
onDeleteStation
onDeleteStation,
onOctanePreferenceChange,
octaneUpdatingId
}) => {
// Loading state
if (loading) {
return (
<List sx={{ width: '100%', bgcolor: 'background.paper' }}>
@@ -56,7 +63,6 @@ export const SavedStationsList: React.FC<SavedStationsListProps> = ({
);
}
// Error state
if (error) {
return (
<Box sx={{ padding: 2 }}>
@@ -65,7 +71,6 @@ export const SavedStationsList: React.FC<SavedStationsListProps> = ({
);
}
// Empty state
if (stations.length === 0) {
return (
<Box sx={{ textAlign: 'center', padding: 3 }}>
@@ -84,8 +89,15 @@ export const SavedStationsList: React.FC<SavedStationsListProps> = ({
bgcolor: 'background.paper'
}}
>
{stations.map((station, index) => (
<React.Fragment key={station.placeId}>
{stations.map((station, index) => {
const placeId = resolveSavedStationPlaceId(station);
const octanePreference = getOctanePreferenceFromFlags(
station.has93Octane ?? false,
station.has93OctaneEthanolFree ?? false
);
return (
<React.Fragment key={placeId ?? station.id}>
<ListItem
disablePadding
sx={{
@@ -106,7 +118,7 @@ export const SavedStationsList: React.FC<SavedStationsListProps> = ({
component="span"
sx={{ fontWeight: 600 }}
>
{station.nickname || station.name}
{resolveSavedStationName(station)}
</Typography>
{station.isFavorite && (
<Chip
@@ -128,7 +140,7 @@ export const SavedStationsList: React.FC<SavedStationsListProps> = ({
}}
>
<Typography variant="body2" color="textSecondary">
{station.address}
{resolveSavedStationAddress(station)}
</Typography>
{station.notes && (
<Typography
@@ -150,6 +162,16 @@ export const SavedStationsList: React.FC<SavedStationsListProps> = ({
{formatDistance(station.distance)} away
</Typography>
)}
{placeId && (
<Box sx={{ mt: 1 }}>
<OctanePreferenceSelector
value={octanePreference}
onChange={(value) => onOctanePreferenceChange?.(placeId, value)}
disabled={!onOctanePreferenceChange || octaneUpdatingId === placeId}
helperText="Show on search cards"
/>
</Box>
)}
</Box>
}
/>
@@ -160,13 +182,16 @@ export const SavedStationsList: React.FC<SavedStationsListProps> = ({
aria-label="delete"
onClick={(e) => {
e.stopPropagation();
onDeleteStation?.(station.placeId);
if (placeId) {
onDeleteStation?.(placeId);
}
}}
title="Delete saved station"
sx={{
minWidth: '44px',
minHeight: '44px'
}}
disabled={!placeId}
>
<DeleteIcon />
</IconButton>
@@ -174,7 +199,8 @@ export const SavedStationsList: React.FC<SavedStationsListProps> = ({
</ListItem>
{index < stations.length - 1 && <Divider />}
</React.Fragment>
))}
);
})}
</List>
);
};

View File

@@ -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<StationCardProps> = ({
station,
isSaved,
savedStation,
onSave,
onDelete,
onSelect
@@ -53,6 +55,19 @@ export const StationCard: React.FC<StationCardProps> = ({
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 (
<Card
onClick={() => onSelect?.(station)}
@@ -127,6 +142,16 @@ export const StationCard: React.FC<StationCardProps> = ({
sx={{ marginBottom: 1 }}
/>
)}
{/* 93 Octane metadata */}
{octaneLabel && (
<Chip
label={octaneLabel}
color="success"
size="small"
sx={{ marginTop: 0.5 }}
/>
)}
</CardContent>
{/* Actions */}

View File

@@ -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<StationMapProps> = ({
}) => {
const mapContainer = useRef<HTMLDivElement>(null);
const map = useRef<google.maps.Map | null>(null);
const markers = useRef<google.maps.Marker[]>([]);
const markers = useRef<google.maps.marker.AdvancedMarkerElement[]>([]);
const infoWindows = useRef<google.maps.InfoWindow[]>([]);
const currentLocationMarker = useRef<google.maps.Marker | null>(null);
const currentLocationMarker = useRef<google.maps.marker.AdvancedMarkerElement | null>(null);
const isInitializing = useRef<boolean>(false);
const [isLoading, setIsLoading] = useState(true);
@@ -89,13 +90,28 @@ export const StationMap: React.FC<StationMapProps> = ({
// 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<StationMapProps> = ({
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<StationMapProps> = ({
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<StationMapProps> = ({
}
// 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<StationMapProps> = ({
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<StationMapProps> = ({
// 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<StationMapProps> = ({
};
export default StationMap;
const mapIdRef = useRef<string | null>(null);

View File

@@ -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<string>;
savedStationsMap?: Map<string, SavedStation>;
loading?: boolean;
error?: string | null;
onSaveStation?: (station: Station) => void;
@@ -32,6 +33,7 @@ interface StationsListProps {
export const StationsList: React.FC<StationsListProps> = ({
stations,
savedPlaceIds = new Set(),
savedStationsMap,
loading = false,
error = null,
onSaveStation,
@@ -92,6 +94,7 @@ export const StationsList: React.FC<StationsListProps> = ({
<StationCard
station={station}
isSaved={savedPlaceIds.has(station.placeId)}
savedStation={savedStationsMap?.get(station.placeId)}
onSave={onSaveStation}
onDelete={onDeleteStation}
onSelect={onSelectStation}

View File

@@ -298,7 +298,7 @@ export const StationsSearchForm: React.FC<StationsSearchFormProps> = ({
setCity(e.target.value);
markManualAddressInput();
}}
placeholder="San Francisco"
placeholder="Madison"
autoComplete="address-level2"
fullWidth
/>

View File

@@ -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';

View File

@@ -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) => {

View File

@@ -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()
};

View File

@@ -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<SaveStationData>;
}
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);
}
});
}

View File

@@ -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<Station | SavedStation | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [octaneUpdatingId, setOctaneUpdatingId] = useState<string | null>(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<string, SavedStation>();
(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 = () => {
<StationsList
stations={searchResults}
savedPlaceIds={savedPlaceIds}
savedStationsMap={savedStationsMap}
loading={isSearching}
error={searchError ? 'Failed to search stations' : null}
onSaveStation={handleSaveStation}
@@ -235,6 +273,8 @@ export const StationsMobileScreen: React.FC = () => {
error={savedError ? 'Failed to load saved stations' : null}
onSelectStation={handleSelectStation}
onDeleteStation={handleDeleteStation}
onOctanePreferenceChange={handleOctanePreferenceChange}
octaneUpdatingId={octaneUpdatingId}
/>
</Box>
)}

View File

@@ -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<string | null>(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<string, SavedStation>();
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 = () => {
<StationsList
stations={searchResults}
savedPlaceIds={savedPlaceIds}
savedStationsMap={savedStationsMap}
loading={isSearching}
error={searchError ? (searchError as any).message : null}
onSaveStation={handleSave}
@@ -252,18 +290,27 @@ export const StationsPage: React.FC = () => {
error={savedError ? (savedError as any).message : null}
onSelectStation={handleSelectStation}
onDeleteStation={handleDelete}
onOctanePreferenceChange={handleOctanePreferenceChange}
octaneUpdatingId={octaneUpdatingId}
/>
</TabPanel>
</Box>
);
}
// Desktop layout: side-by-side
// Desktop layout: top-row map + search, full-width results
return (
<Grid container spacing={2} sx={{ padding: 2, height: 'calc(100vh - 80px)' }}>
{/* Left: Map (60%) */}
<Grid item xs={12} md={6} sx={{ display: 'flex', flexDirection: 'column' }}>
<Paper sx={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
<Box sx={{ padding: 2 }}>
<Grid container spacing={2}>
{/* Map */}
<Grid item xs={12} md={6}>
<Paper
sx={{
height: { xs: 300, md: 520 },
display: 'flex',
overflow: 'hidden'
}}
>
{isMapReady ? (
<GoogleMapsErrorBoundary>
<StationMap
@@ -284,20 +331,38 @@ export const StationsPage: React.FC = () => {
</Paper>
</Grid>
{/* Right: Search + Tabs (40%) */}
<Grid item xs={12} md={6} sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{/* Search Form */}
<Paper>
{/* Search form */}
<Grid item xs={12} md={6}>
<Paper
sx={{
height: { xs: 'auto', md: 520 },
display: 'flex',
flexDirection: 'column',
overflow: 'hidden'
}}
>
<Box sx={{ padding: 2, flex: 1 }}>
<StationsSearchForm onSearch={handleSearch} isSearching={isSearching} />
</Box>
</Paper>
</Grid>
</Grid>
{/* Error Alert */}
{searchError && (
<Box sx={{ mt: 2 }}>
<Alert severity="error">{(searchError as any).message || 'Search failed'}</Alert>
</Box>
)}
{/* Tabs */}
<Paper sx={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
{/* Full-width Results */}
<Paper
sx={{
mt: 2,
display: 'flex',
flexDirection: 'column'
}}
>
<Tabs
value={tabValue}
onChange={(_, newValue) => setTabValue(newValue)}
@@ -309,12 +374,12 @@ export const StationsPage: React.FC = () => {
<Tab label={`Saved (${savedStations.length})`} id="stations-tab-1" />
</Tabs>
{/* Tab Content with overflow */}
<Box sx={{ flex: 1, overflow: 'auto' }}>
<Box sx={{ flex: 1, overflow: 'auto', padding: 2 }}>
<TabPanel value={tabValue} index={0}>
<StationsList
stations={searchResults}
savedPlaceIds={savedPlaceIds}
savedStationsMap={savedStationsMap}
loading={isSearching}
error={searchError ? (searchError as any).message : null}
onSaveStation={handleSave}
@@ -330,12 +395,13 @@ export const StationsPage: React.FC = () => {
error={savedError ? (savedError as any).message : null}
onSelectStation={handleSelectStation}
onDeleteStation={handleDelete}
onOctanePreferenceChange={handleOctanePreferenceChange}
octaneUpdatingId={octaneUpdatingId}
/>
</TabPanel>
</Box>
</Paper>
</Grid>
</Grid>
</Box>
);
};

View File

@@ -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;
}

View File

@@ -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<string, unknown>;
}
/** User-facing preference for 93 octane availability */
export type OctanePreference = 'none' | 'with_ethanol' | 'ethanol_free';

View File

@@ -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
? `<p style="margin: 4px 0; font-size: 12px;">Rating: ⭐ ${station.rating.toFixed(1)}</p>`
? `<p style="margin: 4px 0; font-size: 12px;">Rating: ⭐ ${station.rating.toFixed(
1
)}</p>`
: ''
}
<div style="margin-top: 8px;">
<a href="https://www.google.com/maps/search/${encodeURIComponent(station.address)}" target="_blank" style="color: #4285F4; text-decoration: none; font-size: 12px; margin-right: 8px;">
<a href="https://www.google.com/maps/search/${encodeURIComponent(
station.address
)}" target="_blank" style="color: #4285F4; text-decoration: none; font-size: 12px; margin-right: 8px;">
Directions
</a>
${isSaved ? '<span style="color: #FFD700; font-size: 12px;">★ Saved</span>' : ''}
@@ -85,20 +93,12 @@ export function createInfoWindow(
</div>
`;
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

View File

@@ -53,7 +53,7 @@ export function loadGoogleMaps(): Promise<void> {
// 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)

View File

@@ -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<SaveStationData, 'has93Octane' | 'has93OctaneEthanolFree'> {
if (preference === 'none') {
return {
has93Octane: false,
has93OctaneEthanolFree: false
};
}
if (preference === 'ethanol_free') {
return {
has93Octane: true,
has93OctaneEthanolFree: true
};
}
return {
has93Octane: true,
has93OctaneEthanolFree: false
};
}

View File

@@ -67,6 +67,15 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
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<number[]>([]);
const [makes, setMakes] = useState<DropdownOption[]>([]);
const [models, setModels] = useState<DropdownOption[]>([]);
@@ -338,7 +347,7 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
<option value="">Select Make</option>
{makes.map((make) => (
<option key={make.id} value={make.name}>
{make.name}
{formatVehicleLabel(make.name)}
</option>
))}
</select>
@@ -357,7 +366,7 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
<option value="">Select Model</option>
{models.map((model) => (
<option key={model.id} value={model.name}>
{model.name}
{formatVehicleLabel(model.name)}
</option>
))}
</select>

View File

@@ -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"