Files
motovaultpro/docs/changes/fuel-logs-v1/FUEL-LOGS-PHASE-5.md
Eric Gullickson a052040e3a Initial Commit
2025-09-17 16:09:15 -05:00

1132 lines
34 KiB
Markdown

# Phase 5: Future Integration Preparation
## Overview
Design and prepare architecture for future Google Maps integration, location services, station price data, and extensibility for advanced fuel management features.
## Prerequisites
- ✅ Phases 1-4 completed (database, business logic, API, frontend)
- All core fuel logs functionality tested and working
- Location data structure in place
## Google Maps Integration Architecture
### Service Architecture Design
```
Location Services Architecture
├── LocationService (Interface)
│ ├── GoogleMapsService (Implementation)
│ ├── MockLocationService (Testing)
│ └── Future: MapboxService, HereService
├── StationService (Interface)
│ ├── GooglePlacesStationService
│ └── Future: GasBuddyService, AAA Service
└── PricingService (Interface)
├── GooglePlacesPricingService
└── Future: GasBuddyPricingService
```
### Location Service Interface
**File**: `backend/src/features/fuel-logs/external/location.service.interface.ts`
```typescript
export interface Coordinates {
latitude: number;
longitude: number;
}
export interface Address {
streetNumber?: string;
streetName?: string;
city: string;
state: string;
zipCode?: string;
country: string;
formattedAddress: string;
}
export interface LocationSearchResult {
placeId: string;
name: string;
address: Address;
coordinates: Coordinates;
placeTypes: string[];
rating?: number;
priceLevel?: number;
isOpen?: boolean;
distance?: number; // meters from search center
}
export interface StationSearchOptions {
coordinates: Coordinates;
radius?: number; // meters, default 5000 (5km)
fuelTypes?: string[]; // ['gasoline', 'diesel', 'electric']
maxResults?: number; // default 20
openNow?: boolean;
priceRange?: 'inexpensive' | 'moderate' | 'expensive' | 'very_expensive';
}
export interface FuelPrice {
fuelType: string;
fuelGrade?: string;
pricePerUnit: number;
currency: string;
lastUpdated: Date;
source: string;
}
export interface FuelStation extends LocationSearchResult {
fuelTypes: string[];
amenities: string[];
brands: string[];
currentPrices?: FuelPrice[];
hasCarWash?: boolean;
hasConvenienceStore?: boolean;
hasRestrooms?: boolean;
hasAirPump?: boolean;
}
export abstract class LocationService {
abstract searchNearbyStations(options: StationSearchOptions): Promise<FuelStation[]>;
abstract getStationDetails(placeId: string): Promise<FuelStation>;
abstract getCurrentPrices(placeId: string): Promise<FuelPrice[]>;
abstract geocodeAddress(address: string): Promise<Coordinates>;
abstract reverseGeocode(coordinates: Coordinates): Promise<Address>;
}
```
### Google Maps Service Implementation
**File**: `backend/src/features/fuel-logs/external/google-maps.service.ts`
```typescript
import { LocationService, StationSearchOptions, FuelStation, Coordinates, Address, FuelPrice } from './location.service.interface';
import { logger } from '../../../core/logging/logger';
export class GoogleMapsService extends LocationService {
private apiKey: string;
private baseUrl = 'https://maps.googleapis.com/maps/api';
constructor() {
super();
this.apiKey = process.env.GOOGLE_MAPS_API_KEY || '';
if (!this.apiKey) {
throw new Error('GOOGLE_MAPS_API_KEY environment variable is required');
}
}
async searchNearbyStations(options: StationSearchOptions): Promise<FuelStation[]> {
try {
logger.info('Searching nearby fuel stations', {
coordinates: options.coordinates,
radius: options.radius
});
const radius = options.radius || 5000;
const location = `${options.coordinates.latitude},${options.coordinates.longitude}`;
// Use Google Places API to find gas stations
const url = `${this.baseUrl}/place/nearbysearch/json`;
const params = new URLSearchParams({
location,
radius: radius.toString(),
type: 'gas_station',
key: this.apiKey
});
if (options.openNow) {
params.append('opennow', 'true');
}
const response = await fetch(`${url}?${params}`);
const data = await response.json();
if (data.status !== 'OK' && data.status !== 'ZERO_RESULTS') {
throw new Error(`Google Places API error: ${data.status}`);
}
const stations = await Promise.all(
data.results.slice(0, options.maxResults || 20).map(async (place: any) => {
return this.transformPlaceToStation(place, options.coordinates);
})
);
return stations.filter(Boolean);
} catch (error) {
logger.error('Error searching nearby stations', { error, options });
throw error;
}
}
async getStationDetails(placeId: string): Promise<FuelStation> {
try {
const url = `${this.baseUrl}/place/details/json`;
const params = new URLSearchParams({
place_id: placeId,
fields: 'name,formatted_address,geometry,rating,price_level,opening_hours,types,photos',
key: this.apiKey
});
const response = await fetch(`${url}?${params}`);
const data = await response.json();
if (data.status !== 'OK') {
throw new Error(`Google Places API error: ${data.status}`);
}
return this.transformPlaceToStation(data.result);
} catch (error) {
logger.error('Error getting station details', { error, placeId });
throw error;
}
}
async getCurrentPrices(placeId: string): Promise<FuelPrice[]> {
// Note: Google Maps API doesn't provide real-time fuel prices
// This would need integration with fuel price services like GasBuddy
logger.warn('Current prices not available from Google Maps API', { placeId });
return [];
}
async geocodeAddress(address: string): Promise<Coordinates> {
try {
const url = `${this.baseUrl}/geocode/json`;
const params = new URLSearchParams({
address,
key: this.apiKey
});
const response = await fetch(`${url}?${params}`);
const data = await response.json();
if (data.status !== 'OK' || data.results.length === 0) {
throw new Error(`Geocoding failed: ${data.status}`);
}
const location = data.results[0].geometry.location;
return {
latitude: location.lat,
longitude: location.lng
};
} catch (error) {
logger.error('Error geocoding address', { error, address });
throw error;
}
}
async reverseGeocode(coordinates: Coordinates): Promise<Address> {
try {
const url = `${this.baseUrl}/geocode/json`;
const params = new URLSearchParams({
latlng: `${coordinates.latitude},${coordinates.longitude}`,
key: this.apiKey
});
const response = await fetch(`${url}?${params}`);
const data = await response.json();
if (data.status !== 'OK' || data.results.length === 0) {
throw new Error(`Reverse geocoding failed: ${data.status}`);
}
return this.parseAddressComponents(data.results[0]);
} catch (error) {
logger.error('Error reverse geocoding', { error, coordinates });
throw error;
}
}
private async transformPlaceToStation(place: any, searchCenter?: Coordinates): Promise<FuelStation> {
const station: FuelStation = {
placeId: place.place_id,
name: place.name,
address: this.parseAddressComponents(place),
coordinates: {
latitude: place.geometry.location.lat,
longitude: place.geometry.location.lng
},
placeTypes: place.types || [],
rating: place.rating,
priceLevel: place.price_level,
isOpen: place.opening_hours?.open_now,
fuelTypes: this.inferFuelTypes(place),
amenities: this.inferAmenities(place),
brands: this.inferBrands(place.name)
};
if (searchCenter) {
station.distance = this.calculateDistance(searchCenter, station.coordinates);
}
return station;
}
private parseAddressComponents(place: any): Address {
const components = place.address_components || [];
const address: Partial<Address> = {
formattedAddress: place.formatted_address || ''
};
components.forEach((component: any) => {
const types = component.types;
if (types.includes('street_number')) {
address.streetNumber = component.long_name;
} else if (types.includes('route')) {
address.streetName = component.long_name;
} else if (types.includes('locality')) {
address.city = component.long_name;
} else if (types.includes('administrative_area_level_1')) {
address.state = component.short_name;
} else if (types.includes('postal_code')) {
address.zipCode = component.long_name;
} else if (types.includes('country')) {
address.country = component.long_name;
}
});
return address as Address;
}
private inferFuelTypes(place: any): string[] {
// Basic inference - could be enhanced with more sophisticated logic
const fuelTypes = ['gasoline']; // Default assumption
if (place.name?.toLowerCase().includes('diesel')) {
fuelTypes.push('diesel');
}
if (place.name?.toLowerCase().includes('electric') ||
place.name?.toLowerCase().includes('ev') ||
place.name?.toLowerCase().includes('tesla')) {
fuelTypes.push('electric');
}
return fuelTypes;
}
private inferAmenities(place: any): string[] {
const amenities: string[] = [];
const name = place.name?.toLowerCase() || '';
if (name.includes('car wash')) amenities.push('car_wash');
if (name.includes('convenience') || name.includes('store')) amenities.push('convenience_store');
if (name.includes('restroom') || name.includes('bathroom')) amenities.push('restrooms');
if (name.includes('air')) amenities.push('air_pump');
return amenities;
}
private inferBrands(name: string): string[] {
const knownBrands = [
'shell', 'exxon', 'mobil', 'chevron', 'bp', 'citgo', 'sunoco',
'marathon', 'speedway', 'wawa', '7-eleven', 'circle k'
];
return knownBrands.filter(brand =>
name.toLowerCase().includes(brand)
);
}
private calculateDistance(coord1: Coordinates, coord2: Coordinates): number {
const R = 6371000; // Earth's radius in meters
const dLat = (coord2.latitude - coord1.latitude) * Math.PI / 180;
const dLon = (coord2.longitude - coord1.longitude) * Math.PI / 180;
const a =
Math.sin(dLat/2) * Math.sin(dLat/2) +
Math.cos(coord1.latitude * Math.PI / 180) * Math.cos(coord2.latitude * Math.PI / 180) *
Math.sin(dLon/2) * Math.sin(dLon/2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
return R * c;
}
}
```
### Mock Location Service for Development
**File**: `backend/src/features/fuel-logs/external/mock-location.service.ts`
```typescript
import { LocationService, StationSearchOptions, FuelStation, Coordinates, Address, FuelPrice } from './location.service.interface';
export class MockLocationService extends LocationService {
private mockStations: FuelStation[] = [
{
placeId: 'mock-station-1',
name: 'Shell Station',
address: {
city: 'Anytown',
state: 'CA',
zipCode: '12345',
country: 'USA',
formattedAddress: '123 Main St, Anytown, CA 12345'
},
coordinates: { latitude: 37.7749, longitude: -122.4194 },
placeTypes: ['gas_station'],
rating: 4.2,
fuelTypes: ['gasoline', 'diesel'],
amenities: ['convenience_store', 'car_wash'],
brands: ['shell'],
currentPrices: [
{
fuelType: 'gasoline',
fuelGrade: '87',
pricePerUnit: 3.49,
currency: 'USD',
lastUpdated: new Date(),
source: 'mock'
},
{
fuelType: 'gasoline',
fuelGrade: '91',
pricePerUnit: 3.79,
currency: 'USD',
lastUpdated: new Date(),
source: 'mock'
}
]
},
{
placeId: 'mock-station-2',
name: 'EV Charging Station',
address: {
city: 'Anytown',
state: 'CA',
zipCode: '12345',
country: 'USA',
formattedAddress: '456 Electric Ave, Anytown, CA 12345'
},
coordinates: { latitude: 37.7849, longitude: -122.4094 },
placeTypes: ['gas_station', 'electric_vehicle_charging_station'],
rating: 4.5,
fuelTypes: ['electric'],
amenities: ['restrooms'],
brands: ['tesla'],
currentPrices: [
{
fuelType: 'electric',
pricePerUnit: 0.28,
currency: 'USD',
lastUpdated: new Date(),
source: 'mock'
}
]
}
];
async searchNearbyStations(options: StationSearchOptions): Promise<FuelStation[]> {
// Simulate network delay
await new Promise(resolve => setTimeout(resolve, 500));
let results = [...this.mockStations];
// Filter by fuel types if specified
if (options.fuelTypes && options.fuelTypes.length > 0) {
results = results.filter(station =>
station.fuelTypes.some(type => options.fuelTypes!.includes(type))
);
}
// Add distance calculation
results = results.map(station => ({
...station,
distance: this.calculateMockDistance(options.coordinates, station.coordinates)
}));
// Sort by distance and limit results
results.sort((a, b) => (a.distance || 0) - (b.distance || 0));
return results.slice(0, options.maxResults || 20);
}
async getStationDetails(placeId: string): Promise<FuelStation> {
const station = this.mockStations.find(s => s.placeId === placeId);
if (!station) {
throw new Error(`Station not found: ${placeId}`);
}
// Simulate network delay
await new Promise(resolve => setTimeout(resolve, 300));
return station;
}
async getCurrentPrices(placeId: string): Promise<FuelPrice[]> {
const station = this.mockStations.find(s => s.placeId === placeId);
return station?.currentPrices || [];
}
async geocodeAddress(address: string): Promise<Coordinates> {
// Return mock coordinates for any address
return { latitude: 37.7749, longitude: -122.4194 };
}
async reverseGeocode(coordinates: Coordinates): Promise<Address> {
return {
city: 'Anytown',
state: 'CA',
zipCode: '12345',
country: 'USA',
formattedAddress: '123 Mock St, Anytown, CA 12345'
};
}
private calculateMockDistance(coord1: Coordinates, coord2: Coordinates): number {
// Simple mock distance calculation
const dLat = coord2.latitude - coord1.latitude;
const dLon = coord2.longitude - coord1.longitude;
return Math.sqrt(dLat * dLat + dLon * dLon) * 111000; // Rough conversion to meters
}
}
```
## Frontend Integration Preparation
### Location Input Component Enhancement
**File**: `frontend/src/features/fuel-logs/components/LocationInput.tsx` (Enhanced)
```tsx
import React, { useState, useEffect } from 'react';
import {
TextField,
Autocomplete,
Box,
Typography,
Chip,
CircularProgress,
Paper,
List,
ListItem,
ListItemText,
ListItemIcon,
Button,
Dialog,
DialogTitle,
DialogContent,
DialogActions
} from '@mui/material';
import {
LocationOn,
LocalGasStation,
Star,
AttachMoney,
MyLocation
} from '@mui/icons-material';
import { LocationData, FuelStation } from '../types/fuel-logs.types';
import { useGeolocation } from '../hooks/useGeolocation';
import { useNearbyStations } from '../hooks/useNearbyStations';
interface LocationInputProps {
value?: LocationData;
onChange: (location?: LocationData) => void;
placeholder?: string;
disabled?: boolean;
}
export const LocationInput: React.FC<LocationInputProps> = ({
value,
onChange,
placeholder = "Enter station location or search nearby",
disabled = false
}) => {
const [inputValue, setInputValue] = useState('');
const [showStationPicker, setShowStationPicker] = useState(false);
const { getCurrentLocation, isGettingLocation } = useGeolocation();
const {
nearbyStations,
searchNearbyStations,
isSearching
} = useNearbyStations();
// Initialize input value from existing location data
useEffect(() => {
if (value?.address) {
setInputValue(value.address);
} else if (value?.stationName) {
setInputValue(value.stationName);
}
}, [value]);
const handleLocationSearch = async () => {
try {
const position = await getCurrentLocation();
if (position) {
await searchNearbyStations({
coordinates: {
latitude: position.latitude,
longitude: position.longitude
},
radius: 5000, // 5km
maxResults: 10
});
setShowStationPicker(true);
}
} catch (error) {
console.error('Failed to get location or search stations:', error);
}
};
const handleStationSelect = (station: FuelStation) => {
const locationData: LocationData = {
address: station.address.formattedAddress,
coordinates: station.coordinates,
googlePlaceId: station.placeId,
stationName: station.name,
// Future: include pricing and amenity data
stationDetails: {
rating: station.rating,
fuelTypes: station.fuelTypes,
amenities: station.amenities,
brands: station.brands,
currentPrices: station.currentPrices
}
};
onChange(locationData);
setInputValue(`${station.name} - ${station.address.formattedAddress}`);
setShowStationPicker(false);
};
const handleManualInput = (input: string) => {
setInputValue(input);
if (input.trim()) {
onChange({
address: input.trim()
});
} else {
onChange(undefined);
}
};
return (
<>
<Box>
<TextField
label="Station Location"
value={inputValue}
onChange={(e) => handleManualInput(e.target.value)}
placeholder={placeholder}
fullWidth
disabled={disabled}
InputProps={{
endAdornment: (
<Box display="flex" gap={1}>
<Button
size="small"
onClick={handleLocationSearch}
disabled={disabled || isGettingLocation || isSearching}
startIcon={
isGettingLocation || isSearching ? (
<CircularProgress size={16} />
) : (
<MyLocation />
)
}
>
Nearby
</Button>
</Box>
)
}}
/>
{value?.stationDetails && (
<Box mt={1}>
<Typography variant="caption" color="text.secondary">
Selected Station Details:
</Typography>
<Box display="flex" gap={1} mt={0.5} flexWrap="wrap">
{value.stationDetails.rating && (
<Chip
size="small"
icon={<Star />}
label={`${value.stationDetails.rating}/5`}
variant="outlined"
/>
)}
{value.stationDetails.fuelTypes.map(fuelType => (
<Chip
key={fuelType}
size="small"
label={fuelType}
variant="outlined"
/>
))}
</Box>
</Box>
)}
</Box>
{/* Station Picker Dialog */}
<Dialog
open={showStationPicker}
onClose={() => setShowStationPicker(false)}
maxWidth="sm"
fullWidth
>
<DialogTitle>
<Box display="flex" alignItems="center" gap={1}>
<LocalGasStation />
Nearby Fuel Stations
</Box>
</DialogTitle>
<DialogContent>
{isSearching ? (
<Box display="flex" justifyContent="center" p={3}>
<CircularProgress />
</Box>
) : (
<List>
{nearbyStations.map((station) => (
<ListItem
key={station.placeId}
button
onClick={() => handleStationSelect(station)}
>
<ListItemIcon>
<LocalGasStation />
</ListItemIcon>
<ListItemText
primary={
<Box display="flex" alignItems="center" gap={1}>
<Typography variant="body1">
{station.name}
</Typography>
{station.rating && (
<Chip
size="small"
icon={<Star />}
label={station.rating}
variant="outlined"
/>
)}
{station.distance && (
<Typography variant="caption" color="text.secondary">
{Math.round(station.distance)}m away
</Typography>
)}
</Box>
}
secondary={
<Box>
<Typography variant="body2" color="text.secondary">
{station.address.formattedAddress}
</Typography>
<Box display="flex" gap={0.5} mt={0.5} flexWrap="wrap">
{station.fuelTypes.map(fuelType => (
<Chip
key={fuelType}
size="small"
label={fuelType}
variant="outlined"
/>
))}
</Box>
{station.currentPrices && station.currentPrices.length > 0 && (
<Box mt={0.5}>
<Typography variant="caption" color="text.secondary">
Prices: {station.currentPrices.map(price =>
`${price.fuelGrade || price.fuelType}: $${price.pricePerUnit.toFixed(2)}`
).join(', ')}
</Typography>
</Box>
)}
</Box>
}
/>
</ListItem>
))}
</List>
)}
</DialogContent>
<DialogActions>
<Button onClick={() => setShowStationPicker(false)}>
Cancel
</Button>
</DialogActions>
</Dialog>
</>
);
};
```
## Database Schema for Station Data
### Future Station Data Tables
**File**: `backend/src/features/fuel-logs/migrations/003_add_station_data_support.sql`
```sql
-- Migration: 003_add_station_data_support.sql
-- Purpose: Add tables for caching station data and prices
BEGIN;
-- Stations cache table
CREATE TABLE IF NOT EXISTS fuel_stations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
google_place_id VARCHAR(255) UNIQUE NOT NULL,
name VARCHAR(200) NOT NULL,
formatted_address TEXT,
latitude DECIMAL(10,8),
longitude DECIMAL(11,8),
rating DECIMAL(2,1),
price_level INTEGER,
fuel_types TEXT[], -- Array of fuel types
amenities TEXT[], -- Array of amenities
brands TEXT[], -- Array of brand names
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Fuel prices cache table
CREATE TABLE IF NOT EXISTS fuel_prices (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
station_id UUID REFERENCES fuel_stations(id) ON DELETE CASCADE,
fuel_type VARCHAR(20) NOT NULL,
fuel_grade VARCHAR(10),
price_per_unit DECIMAL(6,3) NOT NULL,
currency VARCHAR(3) DEFAULT 'USD',
source VARCHAR(50) NOT NULL,
reported_at TIMESTAMP WITH TIME ZONE NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
-- Ensure unique price per station/fuel combination
UNIQUE(station_id, fuel_type, fuel_grade, source)
);
-- Add indexes for performance
CREATE INDEX IF NOT EXISTS idx_fuel_stations_location ON fuel_stations USING GIST (
ll_to_earth(latitude, longitude)
);
CREATE INDEX IF NOT EXISTS idx_fuel_stations_place_id ON fuel_stations(google_place_id);
CREATE INDEX IF NOT EXISTS idx_fuel_prices_station ON fuel_prices(station_id);
CREATE INDEX IF NOT EXISTS idx_fuel_prices_reported_at ON fuel_prices(reported_at);
-- Update triggers
CREATE OR REPLACE FUNCTION update_fuel_stations_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trigger_fuel_stations_updated_at
BEFORE UPDATE ON fuel_stations
FOR EACH ROW EXECUTE FUNCTION update_fuel_stations_updated_at();
COMMIT;
```
## Configuration and Environment Setup
### Environment Variables for Location Services
**File**: `.env.example` (additions)
```bash
# Google Maps Integration
GOOGLE_MAPS_API_KEY=your_google_maps_api_key_here
GOOGLE_PLACES_CACHE_TTL=3600 # 1 hour in seconds
GOOGLE_GEOCODING_CACHE_TTL=86400 # 24 hours in seconds
# Location Service Configuration
LOCATION_SERVICE_PROVIDER=google # 'google' | 'mock'
STATION_SEARCH_DEFAULT_RADIUS=5000 # meters
STATION_SEARCH_MAX_RESULTS=20
STATION_PRICE_CACHE_TTL=1800 # 30 minutes in seconds
# Future: Additional service integrations
GASBUDDY_API_KEY=your_gasbuddy_api_key_here
MAPBOX_API_KEY=your_mapbox_api_key_here
```
### Service Factory Pattern
**File**: `backend/src/features/fuel-logs/external/location-service.factory.ts`
```typescript
import { LocationService } from './location.service.interface';
import { GoogleMapsService } from './google-maps.service';
import { MockLocationService } from './mock-location.service';
export class LocationServiceFactory {
static create(): LocationService {
const provider = process.env.LOCATION_SERVICE_PROVIDER || 'mock';
switch (provider.toLowerCase()) {
case 'google':
return new GoogleMapsService();
case 'mock':
return new MockLocationService();
default:
throw new Error(`Unsupported location service provider: ${provider}`);
}
}
}
```
## API Endpoints for Location Integration
### Station Search Endpoints
**File**: `backend/src/features/fuel-logs/api/station-search.controller.ts`
```typescript
import { FastifyRequest, FastifyReply } from 'fastify';
import { LocationServiceFactory } from '../external/location-service.factory';
import { StationSearchOptions } from '../external/location.service.interface';
import { logger } from '../../../core/logging/logger';
export class StationSearchController {
private locationService = LocationServiceFactory.create();
async searchNearbyStations(
request: FastifyRequest<{
Body: {
latitude: number;
longitude: number;
radius?: number;
fuelTypes?: string[];
maxResults?: number;
openNow?: boolean;
}
}>,
reply: FastifyReply
) {
try {
const { latitude, longitude, radius, fuelTypes, maxResults, openNow } = request.body;
const options: StationSearchOptions = {
coordinates: { latitude, longitude },
radius,
fuelTypes,
maxResults,
openNow
};
const stations = await this.locationService.searchNearbyStations(options);
return reply.code(200).send({
stations,
searchOptions: options,
resultCount: stations.length
});
} catch (error: any) {
logger.error('Error searching nearby stations', {
error,
userId: (request as any).user?.sub
});
return reply.code(500).send({
error: 'Internal server error',
message: 'Failed to search nearby stations'
});
}
}
async getStationDetails(
request: FastifyRequest<{ Params: { placeId: string } }>,
reply: FastifyReply
) {
try {
const { placeId } = request.params;
const station = await this.locationService.getStationDetails(placeId);
return reply.code(200).send(station);
} catch (error: any) {
logger.error('Error getting station details', {
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: 'Station not found'
});
}
return reply.code(500).send({
error: 'Internal server error',
message: 'Failed to get station details'
});
}
}
async getCurrentPrices(
request: FastifyRequest<{ Params: { placeId: string } }>,
reply: FastifyReply
) {
try {
const { placeId } = request.params;
const prices = await this.locationService.getCurrentPrices(placeId);
return reply.code(200).send({
placeId,
prices,
lastUpdated: new Date().toISOString()
});
} catch (error: any) {
logger.error('Error getting current prices', {
error,
placeId: request.params.placeId,
userId: (request as any).user?.sub
});
return reply.code(500).send({
error: 'Internal server error',
message: 'Failed to get current prices'
});
}
}
}
```
## Testing Strategy for Location Services
### Mock Service Testing
**File**: `backend/src/features/fuel-logs/tests/unit/location-services.test.ts`
```typescript
import { MockLocationService } from '../../external/mock-location.service';
import { StationSearchOptions } from '../../external/location.service.interface';
describe('Location Services', () => {
let mockLocationService: MockLocationService;
beforeEach(() => {
mockLocationService = new MockLocationService();
});
describe('MockLocationService', () => {
it('should search nearby stations', async () => {
const options: StationSearchOptions = {
coordinates: { latitude: 37.7749, longitude: -122.4194 },
radius: 5000,
maxResults: 10
};
const stations = await mockLocationService.searchNearbyStations(options);
expect(stations).toHaveLength(2);
expect(stations[0].name).toBe('Shell Station');
expect(stations[0].fuelTypes).toContain('gasoline');
});
it('should filter by fuel type', async () => {
const options: StationSearchOptions = {
coordinates: { latitude: 37.7749, longitude: -122.4194 },
fuelTypes: ['electric']
};
const stations = await mockLocationService.searchNearbyStations(options);
expect(stations).toHaveLength(1);
expect(stations[0].name).toBe('EV Charging Station');
expect(stations[0].fuelTypes).toEqual(['electric']);
});
it('should get station details', async () => {
const station = await mockLocationService.getStationDetails('mock-station-1');
expect(station.name).toBe('Shell Station');
expect(station.currentPrices).toBeDefined();
expect(station.currentPrices!.length).toBeGreaterThan(0);
});
});
});
```
## Future Enhancement Opportunities
### Advanced Features for Future Development
1. **Price Comparison & Alerts**
- Real-time price comparison across stations
- Price alert notifications when fuel prices drop
- Historical price tracking and trends
2. **Route Optimization**
- Find cheapest stations along a planned route
- Integration with navigation apps
- Multi-stop route planning with fuel considerations
3. **Loyalty Program Integration**
- Connect with fuel station loyalty programs
- Automatic discount application
- Cashback and rewards tracking
4. **Predictive Analytics**
- Predict fuel needs based on driving patterns
- Suggest optimal refueling timing
- Maintenance reminder integration
5. **Social Features**
- User-reported prices and reviews
- Station amenity crowdsourcing
- Community-driven station information
6. **Fleet Management**
- Multi-vehicle fleet tracking
- Fuel budget management
- Driver behavior analytics
## Implementation Tasks
### Backend Tasks
1. ✅ Create location service interface and implementations
2. ✅ Implement Google Maps service integration
3. ✅ Create mock service for development/testing
4. ✅ Design station data caching schema
5. ✅ Implement service factory pattern
6. ✅ Add API endpoints for station search
7. ✅ Create comprehensive testing strategy
### Frontend Tasks
1. ✅ Enhance location input component
2. ✅ Create station picker interface
3. ✅ Add geolocation functionality
4. ✅ Implement nearby station search
5. ✅ Design station details display
6. ✅ Add price display integration
### Configuration Tasks
1. ✅ Add environment variables for API keys
2. ✅ Create service configuration options
3. ✅ Implement caching strategies
4. ✅ Add error handling and fallbacks
## Success Criteria
### Phase 5 Complete When:
- ✅ Location service architecture designed and documented
- ✅ Google Maps integration interface implemented
- ✅ Mock location service functional for development
- ✅ Database schema ready for station data caching
- ✅ Frontend components prepared for location integration
- ✅ API endpoints designed and documented
- ✅ Testing strategy implemented
- ✅ Configuration management in place
- ✅ Future enhancement roadmap documented
### Ready for Google Maps Integration When:
- All interfaces and architecture in place
- Mock services tested and functional
- Frontend components integrated and tested
- API endpoints ready for production
- Caching strategy implemented
- Error handling comprehensive
- Documentation complete for future developers
---
**Implementation Complete**: All phases documented and ready for sequential implementation by future AI developers.