1132 lines
34 KiB
Markdown
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. |