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

34 KiB

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

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

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

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)

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

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

# 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

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

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

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.