Initial Commit

This commit is contained in:
Eric Gullickson
2025-09-17 16:09:15 -05:00
parent 0cdb9803de
commit a052040e3a
373 changed files with 437090 additions and 6773 deletions

View File

@@ -0,0 +1,164 @@
# Fuel Logs Feature Enhancement - Master Implementation Guide
## Overview
This document provides comprehensive instructions for enhancing the existing fuel logs feature with advanced business logic, improved user experience, and future integration capabilities.
## Current State Analysis
The existing fuel logs feature has:
- ✅ Basic CRUD operations implemented
- ✅ Service layer with MPG calculations
- ✅ Database schema with basic fields
- ✅ API endpoints and controllers
- ❌ Missing comprehensive test suite
- ❌ Limited field options and validation
- ❌ No Imperial/Metric support
- ❌ No fuel type/grade system
- ❌ No trip distance alternative to odometer
## Enhanced Requirements Summary
### New Fields & Logic
1. **Vehicle Selection**: Dropdown from user's vehicles
2. **Distance Tracking**: Either `trip_distance` OR `odometer` required
3. **Fuel System**: Type (gasoline/diesel/electric) with dynamic grade selection
4. **Units**: Imperial/Metric support based on user settings
5. **Cost Calculation**: Auto-calculated from `cost_per_unit` × `total_units`
6. **Location**: Placeholder for future Google Maps integration
7. **DateTime**: Date/time picker defaulting to current
### Business Rules
- **Validation**: Either trip_distance OR odometer must be provided
- **Fuel Grades**: Dynamic based on fuel type selection
- Gasoline: 87, 88, 89, 91, 93
- Diesel: #1, #2
- Electric: N/A
- **Units**: Display/calculate based on user's Imperial/Metric preference
- **Cost**: Total cost = cost_per_unit × total_units (auto-calculated)
## Implementation Strategy
This enhancement requires **5 coordinated phases** due to the scope of changes:
### Phase Dependencies
```
Phase 1 (Database) → Phase 2 (Logic) → Phase 3 (API) → Phase 4 (Frontend)
Phase 5 (Future Prep)
```
### Phase Breakdown
#### Phase 1: Database Schema & Core Logic
**File**: `docs/phases/FUEL-LOGS-PHASE-1.md`
- Database schema migration for new fields
- Update existing fuel_logs table structure
- Core type system updates
- Basic validation logic
#### Phase 2: Enhanced Business Logic
**File**: `docs/phases/FUEL-LOGS-PHASE-2.md`
- Fuel type/grade relationship system
- Imperial/Metric conversion utilities
- Enhanced MPG calculations for trip_distance
- Advanced validation rules
#### Phase 3: API & Backend Implementation
**File**: `docs/phases/FUEL-LOGS-PHASE-3.md`
- Updated API contracts and endpoints
- New fuel grade endpoint
- User settings integration
- Comprehensive test suite
#### Phase 4: Frontend Implementation
**File**: `docs/phases/FUEL-LOGS-PHASE-4.md`
- Enhanced form components
- Dynamic dropdowns and calculations
- Imperial/Metric UI support
- Real-time cost calculations
#### Phase 5: Future Integration Preparation
**File**: `docs/phases/FUEL-LOGS-PHASE-5.md`
- Google Maps service architecture
- Location service interface design
- Extensibility planning
## Critical Implementation Notes
### Database Migration Strategy
- **Approach**: Additive migrations to preserve existing data
- **Backward Compatibility**: Existing `gallons`/`pricePerGallon` fields remain during transition
- **Data Migration**: Convert existing records to new schema format
### User Experience Considerations
- **Progressive Enhancement**: New features don't break existing workflows
- **Mobile Optimization**: Form designed for fuel station usage
- **Real-time Feedback**: Immediate cost calculations and validation
### Testing Requirements
- **Unit Tests**: Each business logic component
- **Integration Tests**: Complete API workflows
- **Frontend Tests**: Form validation and user interactions
- **Migration Tests**: Database schema changes
## Success Criteria
### Phase Completion Checklist
Each phase must achieve:
- ✅ All documented requirements implemented
- ✅ Comprehensive test coverage
- ✅ Documentation updated
- ✅ No breaking changes to existing functionality
- ✅ Code follows project conventions
### Final Feature Validation
- ✅ All new fields working correctly
- ✅ Fuel type/grade system functional
- ✅ Imperial/Metric units display properly
- ✅ Cost calculations accurate
- ✅ Trip distance alternative to odometer works
- ✅ Existing fuel logs data preserved and functional
- ✅ Mobile-friendly form interface
- ✅ Future Google Maps integration ready
## Architecture Considerations
### Service Boundaries
- **Core Feature**: Remains in `backend/src/features/fuel-logs/`
- **User Settings**: Integration with user preferences system
- **Location Service**: Separate service interface for future Maps integration
### Caching Strategy Updates
- **New Cache Keys**: Include fuel type/grade lookups
- **Imperial/Metric**: Cache converted values when appropriate
- **Location**: Prepare for station/price caching
### Security & Validation
- **Input Validation**: Enhanced validation for new field combinations
- **User Isolation**: All new data remains user-scoped
- **API Security**: Maintain existing JWT authentication requirements
## Next Steps for Implementation
1. **Start with Phase 1**: Database foundation is critical
2. **Sequential Execution**: Each phase builds on the previous
3. **Test Early**: Implement tests alongside each component
4. **Monitor Performance**: Track impact of new features on existing functionality
5. **User Feedback**: Consider beta testing the enhanced form interface
## Future Enhancement Opportunities
### Post-Implementation Features
- **Analytics**: Fuel efficiency trends and insights
- **Notifications**: Maintenance reminders based on fuel logs
- **Export**: CSV/PDF reports of fuel data
- **Social**: Share fuel efficiency achievements
- **Integration**: Connect with vehicle manufacturer APIs
### Technical Debt Reduction
- **Test Coverage**: Complete the missing test suite from original implementation
- **Performance**: Optimize database queries for new field combinations
- **Monitoring**: Add detailed logging for enhanced business logic
---
**Implementation Guide Created**: Use the phase-specific documents in `docs/phases/` for detailed technical instructions.

View File

@@ -0,0 +1,391 @@
# Phase 1: Database Schema & Core Logic
## Overview
Establish the database foundation for enhanced fuel logs with new fields, validation rules, and core type system updates.
## Prerequisites
- Existing fuel logs feature (basic implementation)
- PostgreSQL database with current `fuel_logs` table
- Migration system functional
## Database Schema Changes
### New Fields to Add
```sql
-- Add these columns to fuel_logs table
ALTER TABLE fuel_logs ADD COLUMN trip_distance INTEGER; -- Alternative to odometer reading
ALTER TABLE fuel_logs ADD COLUMN fuel_type VARCHAR(20) NOT NULL DEFAULT 'gasoline';
ALTER TABLE fuel_logs ADD COLUMN fuel_grade VARCHAR(10);
ALTER TABLE fuel_logs ADD COLUMN fuel_units DECIMAL(8,3); -- Replaces gallons for metric support
ALTER TABLE fuel_logs ADD COLUMN cost_per_unit DECIMAL(6,3); -- Replaces price_per_gallon
ALTER TABLE fuel_logs ADD COLUMN location_data JSONB; -- Future Google Maps integration
ALTER TABLE fuel_logs ADD COLUMN date_time TIMESTAMP WITH TIME ZONE; -- Enhanced date/time
-- Add constraints
ALTER TABLE fuel_logs ADD CONSTRAINT fuel_type_check
CHECK (fuel_type IN ('gasoline', 'diesel', 'electric'));
-- Add conditional constraint: either trip_distance OR odometer_reading required
ALTER TABLE fuel_logs ADD CONSTRAINT distance_required_check
CHECK ((trip_distance IS NOT NULL AND trip_distance > 0) OR (odometer_reading IS NOT NULL AND odometer_reading > 0));
-- Add indexes for performance
CREATE INDEX idx_fuel_logs_fuel_type ON fuel_logs(fuel_type);
CREATE INDEX idx_fuel_logs_date_time ON fuel_logs(date_time);
```
### Migration Strategy
#### Step 1: Additive Migration
**File**: `backend/src/features/fuel-logs/migrations/002_enhance_fuel_logs_schema.sql`
```sql
-- Migration: 002_enhance_fuel_logs_schema.sql
BEGIN;
-- Add new columns (nullable initially for data migration)
ALTER TABLE fuel_logs ADD COLUMN IF NOT EXISTS trip_distance INTEGER;
ALTER TABLE fuel_logs ADD COLUMN IF NOT EXISTS fuel_type VARCHAR(20);
ALTER TABLE fuel_logs ADD COLUMN IF NOT EXISTS fuel_grade VARCHAR(10);
ALTER TABLE fuel_logs ADD COLUMN IF NOT EXISTS fuel_units DECIMAL(8,3);
ALTER TABLE fuel_logs ADD COLUMN IF NOT EXISTS cost_per_unit DECIMAL(6,3);
ALTER TABLE fuel_logs ADD COLUMN IF NOT EXISTS location_data JSONB;
ALTER TABLE fuel_logs ADD COLUMN IF NOT EXISTS date_time TIMESTAMP WITH TIME ZONE;
-- Migrate existing data
UPDATE fuel_logs SET
fuel_type = 'gasoline',
fuel_units = gallons,
cost_per_unit = price_per_gallon,
date_time = date::timestamp + interval '12 hours' -- Default to noon
WHERE fuel_type IS NULL;
-- Add constraints after data migration
ALTER TABLE fuel_logs ALTER COLUMN fuel_type SET NOT NULL;
ALTER TABLE fuel_logs ALTER COLUMN fuel_type SET DEFAULT 'gasoline';
-- Add check constraints
ALTER TABLE fuel_logs ADD CONSTRAINT fuel_type_check
CHECK (fuel_type IN ('gasoline', 'diesel', 'electric'));
-- Distance requirement constraint (either trip_distance OR odometer_reading)
ALTER TABLE fuel_logs ADD CONSTRAINT distance_required_check
CHECK ((trip_distance IS NOT NULL AND trip_distance > 0) OR
(odometer_reading IS NOT NULL AND odometer_reading > 0));
-- Add performance indexes
CREATE INDEX IF NOT EXISTS idx_fuel_logs_fuel_type ON fuel_logs(fuel_type);
CREATE INDEX IF NOT EXISTS idx_fuel_logs_date_time ON fuel_logs(date_time);
COMMIT;
```
#### Step 2: Backward Compatibility Plan
- Keep existing `gallons` and `price_per_gallon` fields during transition
- Update application logic to use new fields preferentially
- Plan deprecation of old fields in future migration
### Data Validation Rules
#### Core Business Rules
1. **Distance Requirement**: Either `trip_distance` OR `odometer_reading` must be provided
2. **Fuel Type Validation**: Must be one of: 'gasoline', 'diesel', 'electric'
3. **Fuel Grade Validation**: Must match fuel type options
4. **Positive Values**: All numeric fields must be > 0
5. **DateTime**: Cannot be in the future
#### Fuel Grade Validation Logic
```sql
-- Fuel grade validation by type
CREATE OR REPLACE FUNCTION validate_fuel_grade()
RETURNS TRIGGER AS $$
BEGIN
-- Gasoline grades
IF NEW.fuel_type = 'gasoline' AND
NEW.fuel_grade NOT IN ('87', '88', '89', '91', '93') THEN
RAISE EXCEPTION 'Invalid fuel grade % for gasoline', NEW.fuel_grade;
END IF;
-- Diesel grades
IF NEW.fuel_type = 'diesel' AND
NEW.fuel_grade NOT IN ('#1', '#2') THEN
RAISE EXCEPTION 'Invalid fuel grade % for diesel', NEW.fuel_grade;
END IF;
-- Electric (no grades)
IF NEW.fuel_type = 'electric' AND
NEW.fuel_grade IS NOT NULL THEN
RAISE EXCEPTION 'Electric fuel type cannot have a grade';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Create trigger
CREATE TRIGGER fuel_grade_validation_trigger
BEFORE INSERT OR UPDATE ON fuel_logs
FOR EACH ROW EXECUTE FUNCTION validate_fuel_grade();
```
## TypeScript Type System Updates
### New Core Types
**File**: `backend/src/features/fuel-logs/domain/fuel-logs.types.ts`
```typescript
// Fuel system enums
export enum FuelType {
GASOLINE = 'gasoline',
DIESEL = 'diesel',
ELECTRIC = 'electric'
}
export enum GasolineFuelGrade {
REGULAR_87 = '87',
MIDGRADE_88 = '88',
MIDGRADE_89 = '89',
PREMIUM_91 = '91',
PREMIUM_93 = '93'
}
export enum DieselFuelGrade {
DIESEL_1 = '#1',
DIESEL_2 = '#2'
}
export type FuelGrade = GasolineFuelGrade | DieselFuelGrade | null;
// Unit system types
export enum UnitSystem {
IMPERIAL = 'imperial',
METRIC = 'metric'
}
export interface UnitConversion {
fuelUnits: string; // 'gallons' | 'liters'
distanceUnits: string; // 'miles' | 'kilometers'
efficiencyUnits: string; // 'mpg' | 'l/100km'
}
// Enhanced location data structure
export interface LocationData {
address?: string;
coordinates?: {
latitude: number;
longitude: number;
};
googlePlaceId?: string;
stationName?: string;
// Future: station prices, fuel availability
}
// Updated core FuelLog interface
export interface FuelLog {
id: string;
userId: string;
vehicleId: string;
dateTime: Date; // Enhanced from simple date
// Distance tracking (either/or required)
odometerReading?: number;
tripDistance?: number;
// Fuel system
fuelType: FuelType;
fuelGrade?: FuelGrade;
fuelUnits: number; // Replaces gallons
costPerUnit: number; // Replaces pricePerGallon
totalCost: number; // Auto-calculated
// Location (future Google Maps integration)
locationData?: LocationData;
// Legacy fields (maintain during transition)
gallons?: number; // Deprecated
pricePerGallon?: number; // Deprecated
// Metadata
notes?: string;
mpg?: number; // Calculated efficiency
createdAt: Date;
updatedAt: Date;
}
```
### Request/Response Type Updates
```typescript
export interface CreateFuelLogRequest {
vehicleId: string;
dateTime: string; // ISO datetime string
// Distance (either required)
odometerReading?: number;
tripDistance?: number;
// Fuel system
fuelType: FuelType;
fuelGrade?: FuelGrade;
fuelUnits: number;
costPerUnit: number;
// totalCost calculated automatically
// Location
locationData?: LocationData;
notes?: string;
}
export interface UpdateFuelLogRequest {
dateTime?: string;
odometerReading?: number;
tripDistance?: number;
fuelType?: FuelType;
fuelGrade?: FuelGrade;
fuelUnits?: number;
costPerUnit?: number;
locationData?: LocationData;
notes?: string;
}
```
## Core Validation Logic
### Business Rule Validation
**File**: `backend/src/features/fuel-logs/domain/fuel-logs.validation.ts`
```typescript
export class FuelLogValidation {
static validateDistanceRequirement(data: CreateFuelLogRequest | UpdateFuelLogRequest): void {
const hasOdometer = data.odometerReading && data.odometerReading > 0;
const hasTripDistance = data.tripDistance && data.tripDistance > 0;
if (!hasOdometer && !hasTripDistance) {
throw new ValidationError('Either odometer reading or trip distance is required');
}
if (hasOdometer && hasTripDistance) {
throw new ValidationError('Cannot specify both odometer reading and trip distance');
}
}
static validateFuelGrade(fuelType: FuelType, fuelGrade?: FuelGrade): void {
switch (fuelType) {
case FuelType.GASOLINE:
if (fuelGrade && !Object.values(GasolineFuelGrade).includes(fuelGrade as GasolineFuelGrade)) {
throw new ValidationError(`Invalid gasoline grade: ${fuelGrade}`);
}
break;
case FuelType.DIESEL:
if (fuelGrade && !Object.values(DieselFuelGrade).includes(fuelGrade as DieselFuelGrade)) {
throw new ValidationError(`Invalid diesel grade: ${fuelGrade}`);
}
break;
case FuelType.ELECTRIC:
if (fuelGrade) {
throw new ValidationError('Electric vehicles cannot have fuel grades');
}
break;
}
}
static validatePositiveValues(data: CreateFuelLogRequest | UpdateFuelLogRequest): void {
if (data.fuelUnits && data.fuelUnits <= 0) {
throw new ValidationError('Fuel units must be positive');
}
if (data.costPerUnit && data.costPerUnit <= 0) {
throw new ValidationError('Cost per unit must be positive');
}
if (data.odometerReading && data.odometerReading <= 0) {
throw new ValidationError('Odometer reading must be positive');
}
if (data.tripDistance && data.tripDistance <= 0) {
throw new ValidationError('Trip distance must be positive');
}
}
static validateDateTime(dateTime: string): void {
const date = new Date(dateTime);
const now = new Date();
if (date > now) {
throw new ValidationError('Cannot create fuel logs in the future');
}
}
}
```
## Implementation Tasks
### Database Tasks
1. ✅ Create migration file `002_enhance_fuel_logs_schema.sql`
2. ✅ Add new columns with appropriate types
3. ✅ Migrate existing data to new schema
4. ✅ Add database constraints and triggers
5. ✅ Create performance indexes
### Type System Tasks
1. ✅ Define fuel system enums
2. ✅ Create unit system types
3. ✅ Update core FuelLog interface
4. ✅ Update request/response interfaces
5. ✅ Add location data structure
### Validation Tasks
1. ✅ Create validation utility class
2. ✅ Implement distance requirement validation
3. ✅ Implement fuel grade validation
4. ✅ Add positive value checks
5. ✅ Add datetime validation
## Testing Requirements
### Database Testing
```sql
-- Test distance requirement constraint
INSERT INTO fuel_logs (...) -- Should fail without distance
INSERT INTO fuel_logs (trip_distance = 150, ...) -- Should succeed
INSERT INTO fuel_logs (odometer_reading = 50000, ...) -- Should succeed
INSERT INTO fuel_logs (trip_distance = 150, odometer_reading = 50000, ...) -- Should fail
-- Test fuel type/grade validation
INSERT INTO fuel_logs (fuel_type = 'gasoline', fuel_grade = '87', ...) -- Should succeed
INSERT INTO fuel_logs (fuel_type = 'gasoline', fuel_grade = '#1', ...) -- Should fail
INSERT INTO fuel_logs (fuel_type = 'electric', fuel_grade = '87', ...) -- Should fail
```
### Unit Tests Required
- Validation logic for all business rules
- Type conversion utilities
- Migration data integrity
- Constraint enforcement
## Success Criteria
### Phase 1 Complete When:
- ✅ Database migration runs successfully
- ✅ All new fields available with proper types
- ✅ Existing data migrated and preserved
- ✅ Database constraints enforce business rules
- ✅ TypeScript interfaces updated and compiling
- ✅ Core validation logic implemented and tested
- ✅ No breaking changes to existing functionality
### Ready for Phase 2 When:
- All database changes deployed and tested
- Type system fully updated
- Core validation passes all tests
- Existing fuel logs feature still functional
---
**Next Phase**: [Phase 2 - Enhanced Business Logic](FUEL-LOGS-PHASE-2.md)

View File

@@ -0,0 +1,658 @@
# Phase 2: Enhanced Business Logic
## Overview
Implement sophisticated business logic for fuel type/grade relationships, Imperial/Metric conversion system, enhanced MPG calculations, and advanced validation rules.
## Prerequisites
- ✅ Phase 1 completed (database schema and core types)
- Database migration deployed and tested
- Core validation logic functional
## Fuel Type/Grade Dynamic System
### Fuel Grade Service
**File**: `backend/src/features/fuel-logs/domain/fuel-grade.service.ts`
```typescript
import { FuelType, FuelGrade, GasolineFuelGrade, DieselFuelGrade } from './fuel-logs.types';
export interface FuelGradeOption {
value: FuelGrade;
label: string;
description?: string;
}
export class FuelGradeService {
static getFuelGradeOptions(fuelType: FuelType): FuelGradeOption[] {
switch (fuelType) {
case FuelType.GASOLINE:
return [
{ value: GasolineFuelGrade.REGULAR_87, label: '87 (Regular)', description: 'Regular unleaded gasoline' },
{ value: GasolineFuelGrade.MIDGRADE_88, label: '88 (Mid-Grade)', description: 'Mid-grade gasoline' },
{ value: GasolineFuelGrade.MIDGRADE_89, label: '89 (Mid-Grade Plus)', description: 'Mid-grade plus gasoline' },
{ value: GasolineFuelGrade.PREMIUM_91, label: '91 (Premium)', description: 'Premium gasoline' },
{ value: GasolineFuelGrade.PREMIUM_93, label: '93 (Premium Plus)', description: 'Premium plus gasoline' }
];
case FuelType.DIESEL:
return [
{ value: DieselFuelGrade.DIESEL_1, label: '#1 Diesel', description: 'Light diesel fuel' },
{ value: DieselFuelGrade.DIESEL_2, label: '#2 Diesel', description: 'Standard diesel fuel' }
];
case FuelType.ELECTRIC:
return []; // No grades for electric
default:
return [];
}
}
static isValidGradeForFuelType(fuelType: FuelType, fuelGrade?: FuelGrade): boolean {
if (!fuelGrade) {
return fuelType === FuelType.ELECTRIC; // Only electric allows null grade
}
const validGrades = this.getFuelGradeOptions(fuelType).map(option => option.value);
return validGrades.includes(fuelGrade);
}
static getDefaultGrade(fuelType: FuelType): FuelGrade {
switch (fuelType) {
case FuelType.GASOLINE:
return GasolineFuelGrade.REGULAR_87;
case FuelType.DIESEL:
return DieselFuelGrade.DIESEL_2;
case FuelType.ELECTRIC:
return null;
default:
return null;
}
}
}
```
## Imperial/Metric Conversion System
### Unit Conversion Service
**File**: `backend/src/features/fuel-logs/domain/unit-conversion.service.ts`
```typescript
import { UnitSystem, UnitConversion } from './fuel-logs.types';
export interface ConversionFactors {
// Volume conversions
gallonsToLiters: number;
litersToGallons: number;
// Distance conversions
milesToKilometers: number;
kilometersToMiles: number;
}
export class UnitConversionService {
private static readonly FACTORS: ConversionFactors = {
gallonsToLiters: 3.78541,
litersToGallons: 0.264172,
milesToKilometers: 1.60934,
kilometersToMiles: 0.621371
};
static getUnitLabels(unitSystem: UnitSystem): UnitConversion {
switch (unitSystem) {
case UnitSystem.IMPERIAL:
return {
fuelUnits: 'gallons',
distanceUnits: 'miles',
efficiencyUnits: 'mpg'
};
case UnitSystem.METRIC:
return {
fuelUnits: 'liters',
distanceUnits: 'kilometers',
efficiencyUnits: 'L/100km'
};
}
}
// Volume conversions
static convertFuelUnits(value: number, fromSystem: UnitSystem, toSystem: UnitSystem): number {
if (fromSystem === toSystem) return value;
if (fromSystem === UnitSystem.IMPERIAL && toSystem === UnitSystem.METRIC) {
return value * this.FACTORS.gallonsToLiters; // gallons to liters
}
if (fromSystem === UnitSystem.METRIC && toSystem === UnitSystem.IMPERIAL) {
return value * this.FACTORS.litersToGallons; // liters to gallons
}
return value;
}
// Distance conversions
static convertDistance(value: number, fromSystem: UnitSystem, toSystem: UnitSystem): number {
if (fromSystem === toSystem) return value;
if (fromSystem === UnitSystem.IMPERIAL && toSystem === UnitSystem.METRIC) {
return value * this.FACTORS.milesToKilometers; // miles to kilometers
}
if (fromSystem === UnitSystem.METRIC && toSystem === UnitSystem.IMPERIAL) {
return value * this.FACTORS.kilometersToMiles; // kilometers to miles
}
return value;
}
// Efficiency calculations
static calculateEfficiency(distance: number, fuelUnits: number, unitSystem: UnitSystem): number {
if (fuelUnits <= 0) return 0;
switch (unitSystem) {
case UnitSystem.IMPERIAL:
return distance / fuelUnits; // miles per gallon
case UnitSystem.METRIC:
return (fuelUnits / distance) * 100; // liters per 100 kilometers
default:
return 0;
}
}
// Convert efficiency between unit systems
static convertEfficiency(efficiency: number, fromSystem: UnitSystem, toSystem: UnitSystem): number {
if (fromSystem === toSystem) return efficiency;
if (fromSystem === UnitSystem.IMPERIAL && toSystem === UnitSystem.METRIC) {
// MPG to L/100km: L/100km = 235.214 / MPG
return efficiency > 0 ? 235.214 / efficiency : 0;
}
if (fromSystem === UnitSystem.METRIC && toSystem === UnitSystem.IMPERIAL) {
// L/100km to MPG: MPG = 235.214 / (L/100km)
return efficiency > 0 ? 235.214 / efficiency : 0;
}
return efficiency;
}
}
```
## Enhanced MPG/Efficiency Calculations
### Efficiency Calculation Service
**File**: `backend/src/features/fuel-logs/domain/efficiency-calculation.service.ts`
```typescript
import { FuelLog, UnitSystem } from './fuel-logs.types';
import { UnitConversionService } from './unit-conversion.service';
export interface EfficiencyResult {
value: number;
unitSystem: UnitSystem;
label: string;
calculationMethod: 'odometer' | 'trip_distance';
}
export class EfficiencyCalculationService {
/**
* Calculate efficiency for a fuel log entry
*/
static calculateEfficiency(
currentLog: Partial<FuelLog>,
previousLog: FuelLog | null,
userUnitSystem: UnitSystem
): EfficiencyResult | null {
// Determine calculation method and distance
let distance: number;
let calculationMethod: 'odometer' | 'trip_distance';
if (currentLog.tripDistance) {
// Use trip distance directly
distance = currentLog.tripDistance;
calculationMethod = 'trip_distance';
} else if (currentLog.odometerReading && previousLog?.odometerReading) {
// Calculate from odometer difference
distance = currentLog.odometerReading - previousLog.odometerReading;
calculationMethod = 'odometer';
if (distance <= 0) {
return null; // Invalid distance
}
} else {
return null; // Cannot calculate efficiency
}
if (!currentLog.fuelUnits || currentLog.fuelUnits <= 0) {
return null; // Invalid fuel amount
}
// Calculate efficiency in user's preferred unit system
const efficiency = UnitConversionService.calculateEfficiency(
distance,
currentLog.fuelUnits,
userUnitSystem
);
const unitLabels = UnitConversionService.getUnitLabels(userUnitSystem);
return {
value: efficiency,
unitSystem: userUnitSystem,
label: unitLabels.efficiencyUnits,
calculationMethod
};
}
/**
* Calculate average efficiency for a set of fuel logs
*/
static calculateAverageEfficiency(
fuelLogs: FuelLog[],
userUnitSystem: UnitSystem
): EfficiencyResult | null {
const validLogs = fuelLogs.filter(log => log.mpg && log.mpg > 0);
if (validLogs.length === 0) {
return null;
}
// Convert all efficiencies to user's unit system and average
const efficiencies = validLogs.map(log => {
// Assume stored efficiency is in Imperial (MPG)
return UnitConversionService.convertEfficiency(
log.mpg!,
UnitSystem.IMPERIAL,
userUnitSystem
);
});
const averageEfficiency = efficiencies.reduce((sum, eff) => sum + eff, 0) / efficiencies.length;
const unitLabels = UnitConversionService.getUnitLabels(userUnitSystem);
return {
value: averageEfficiency,
unitSystem: userUnitSystem,
label: unitLabels.efficiencyUnits,
calculationMethod: 'odometer' // Mixed, but default to odometer
};
}
/**
* Calculate total distance traveled from fuel logs
*/
static calculateTotalDistance(fuelLogs: FuelLog[], userUnitSystem: UnitSystem): number {
let totalDistance = 0;
for (let i = 1; i < fuelLogs.length; i++) {
const current = fuelLogs[i];
const previous = fuelLogs[i - 1];
if (current.tripDistance) {
// Use trip distance if available
totalDistance += current.tripDistance;
} else if (current.odometerReading && previous.odometerReading) {
// Calculate from odometer difference
const distance = current.odometerReading - previous.odometerReading;
if (distance > 0) {
totalDistance += distance;
}
}
}
return totalDistance;
}
}
```
## Advanced Validation Rules
### Enhanced Validation Service
**File**: `backend/src/features/fuel-logs/domain/enhanced-validation.service.ts`
```typescript
import { CreateFuelLogRequest, UpdateFuelLogRequest, FuelType, UnitSystem } from './fuel-logs.types';
import { FuelGradeService } from './fuel-grade.service';
export interface ValidationResult {
isValid: boolean;
errors: string[];
warnings: string[];
}
export class EnhancedValidationService {
static validateFuelLogData(
data: CreateFuelLogRequest | UpdateFuelLogRequest,
userUnitSystem: UnitSystem
): ValidationResult {
const errors: string[] = [];
const warnings: string[] = [];
// Distance requirement validation
this.validateDistanceRequirement(data, errors);
// Fuel system validation
this.validateFuelSystem(data, errors);
// Numeric value validation
this.validateNumericValues(data, errors, warnings);
// DateTime validation
this.validateDateTime(data, errors);
// Business logic validation
this.validateBusinessRules(data, errors, warnings, userUnitSystem);
return {
isValid: errors.length === 0,
errors,
warnings
};
}
private static validateDistanceRequirement(
data: CreateFuelLogRequest | UpdateFuelLogRequest,
errors: string[]
): void {
const hasOdometer = data.odometerReading && data.odometerReading > 0;
const hasTripDistance = data.tripDistance && data.tripDistance > 0;
if (!hasOdometer && !hasTripDistance) {
errors.push('Either odometer reading or trip distance is required');
}
if (hasOdometer && hasTripDistance) {
errors.push('Cannot specify both odometer reading and trip distance');
}
}
private static validateFuelSystem(
data: CreateFuelLogRequest | UpdateFuelLogRequest,
errors: string[]
): void {
if (!data.fuelType) return;
// Validate fuel type
if (!Object.values(FuelType).includes(data.fuelType)) {
errors.push(`Invalid fuel type: ${data.fuelType}`);
return;
}
// Validate fuel grade for fuel type
if (!FuelGradeService.isValidGradeForFuelType(data.fuelType, data.fuelGrade)) {
errors.push(`Invalid fuel grade '${data.fuelGrade}' for fuel type '${data.fuelType}'`);
}
}
private static validateNumericValues(
data: CreateFuelLogRequest | UpdateFuelLogRequest,
errors: string[],
warnings: string[]
): void {
// Positive value checks
if (data.fuelUnits !== undefined && data.fuelUnits <= 0) {
errors.push('Fuel units must be positive');
}
if (data.costPerUnit !== undefined && data.costPerUnit <= 0) {
errors.push('Cost per unit must be positive');
}
if (data.odometerReading !== undefined && data.odometerReading <= 0) {
errors.push('Odometer reading must be positive');
}
if (data.tripDistance !== undefined && data.tripDistance <= 0) {
errors.push('Trip distance must be positive');
}
// Reasonable value warnings
if (data.fuelUnits && data.fuelUnits > 100) {
warnings.push('Fuel amount seems unusually high (>100 units)');
}
if (data.costPerUnit && data.costPerUnit > 10) {
warnings.push('Cost per unit seems unusually high (>$10)');
}
if (data.tripDistance && data.tripDistance > 1000) {
warnings.push('Trip distance seems unusually high (>1000 miles)');
}
}
private static validateDateTime(
data: CreateFuelLogRequest | UpdateFuelLogRequest,
errors: string[]
): void {
if (!data.dateTime) return;
const date = new Date(data.dateTime);
const now = new Date();
if (isNaN(date.getTime())) {
errors.push('Invalid date/time format');
return;
}
if (date > now) {
errors.push('Cannot create fuel logs in the future');
}
// Check if date is too far in the past (>2 years)
const twoYearsAgo = new Date(now.getTime() - (2 * 365 * 24 * 60 * 60 * 1000));
if (date < twoYearsAgo) {
errors.push('Fuel log date cannot be more than 2 years in the past');
}
}
private static validateBusinessRules(
data: CreateFuelLogRequest | UpdateFuelLogRequest,
errors: string[],
warnings: string[],
userUnitSystem: UnitSystem
): void {
// Electric vehicle specific validation
if (data.fuelType === FuelType.ELECTRIC) {
if (data.costPerUnit && data.costPerUnit > 0.50) {
warnings.push('Cost per kWh seems high for electric charging');
}
}
// Efficiency warning calculation
if (data.fuelUnits && data.tripDistance) {
const estimatedMPG = data.tripDistance / data.fuelUnits;
if (userUnitSystem === UnitSystem.IMPERIAL) {
if (estimatedMPG < 5) {
warnings.push('Calculated efficiency is very low (<5 MPG)');
} else if (estimatedMPG > 50) {
warnings.push('Calculated efficiency is very high (>50 MPG)');
}
}
}
// Cost validation
if (data.fuelUnits && data.costPerUnit) {
const calculatedTotal = data.fuelUnits * data.costPerUnit;
// Allow 1 cent tolerance for rounding
if (Math.abs(calculatedTotal - (data.totalCost || calculatedTotal)) > 0.01) {
warnings.push('Total cost does not match fuel units × cost per unit');
}
}
}
}
```
## User Settings Integration
### User Settings Service Interface
**File**: `backend/src/features/fuel-logs/external/user-settings.service.ts`
```typescript
import { UnitSystem } from '../domain/fuel-logs.types';
export interface UserSettings {
unitSystem: UnitSystem;
defaultFuelType?: string;
currencyCode: string;
timeZone: string;
}
export class UserSettingsService {
/**
* Get user's unit system preference
* TODO: Integrate with actual user settings service
*/
static async getUserUnitSystem(userId: string): Promise<UnitSystem> {
// Placeholder implementation - replace with actual user settings lookup
// For now, default to Imperial
return UnitSystem.IMPERIAL;
}
/**
* Get full user settings for fuel logs
*/
static async getUserSettings(userId: string): Promise<UserSettings> {
// Placeholder implementation
return {
unitSystem: await this.getUserUnitSystem(userId),
currencyCode: 'USD',
timeZone: 'America/New_York'
};
}
/**
* Update user's unit system preference
*/
static async updateUserUnitSystem(userId: string, unitSystem: UnitSystem): Promise<void> {
// Placeholder implementation - replace with actual user settings update
console.log(`Update user ${userId} unit system to ${unitSystem}`);
}
}
```
## Implementation Tasks
### Fuel Type/Grade System
1. ✅ Create FuelGradeService with dynamic grade options
2. ✅ Implement fuel type validation logic
3. ✅ Add default grade selection
4. ✅ Create grade validation for each fuel type
### Unit Conversion System
1. ✅ Create UnitConversionService with conversion factors
2. ✅ Implement volume/distance conversions
3. ✅ Add efficiency calculation methods
4. ✅ Create unit label management
### Enhanced Calculations
1. ✅ Create EfficiencyCalculationService
2. ✅ Implement trip distance vs odometer logic
3. ✅ Add average efficiency calculations
4. ✅ Create total distance calculations
### Advanced Validation
1. ✅ Create EnhancedValidationService
2. ✅ Implement comprehensive validation rules
3. ✅ Add business logic validation
4. ✅ Create warning system for unusual values
### User Settings Integration
1. ✅ Create UserSettingsService interface
2. ✅ Add unit system preference lookup
3. ✅ Prepare for actual user settings integration
## Testing Requirements
### Unit Tests Required
```typescript
// Test fuel grade service
describe('FuelGradeService', () => {
it('should return correct grades for gasoline', () => {
const grades = FuelGradeService.getFuelGradeOptions(FuelType.GASOLINE);
expect(grades).toHaveLength(5);
expect(grades[0].value).toBe('87');
});
it('should validate grades correctly', () => {
expect(FuelGradeService.isValidGradeForFuelType(FuelType.GASOLINE, '87')).toBe(true);
expect(FuelGradeService.isValidGradeForFuelType(FuelType.GASOLINE, '#1')).toBe(false);
});
});
// Test unit conversion service
describe('UnitConversionService', () => {
it('should convert gallons to liters correctly', () => {
const liters = UnitConversionService.convertFuelUnits(10, UnitSystem.IMPERIAL, UnitSystem.METRIC);
expect(liters).toBeCloseTo(37.85, 2);
});
it('should calculate MPG correctly', () => {
const mpg = UnitConversionService.calculateEfficiency(300, 10, UnitSystem.IMPERIAL);
expect(mpg).toBe(30);
});
});
// Test efficiency calculation service
describe('EfficiencyCalculationService', () => {
it('should calculate efficiency from trip distance', () => {
const result = EfficiencyCalculationService.calculateEfficiency(
{ tripDistance: 300, fuelUnits: 10 },
null,
UnitSystem.IMPERIAL
);
expect(result?.value).toBe(30);
expect(result?.calculationMethod).toBe('trip_distance');
});
});
// Test validation service
describe('EnhancedValidationService', () => {
it('should require distance input', () => {
const result = EnhancedValidationService.validateFuelLogData(
{ fuelType: FuelType.GASOLINE, fuelUnits: 10, costPerUnit: 3.50 },
UnitSystem.IMPERIAL
);
expect(result.isValid).toBe(false);
expect(result.errors).toContain('Either odometer reading or trip distance is required');
});
});
```
## Success Criteria
### Phase 2 Complete When:
- ✅ Fuel type/grade system fully functional
- ✅ Imperial/Metric conversions working correctly
- ✅ Enhanced efficiency calculations implemented
- ✅ Advanced validation rules active
- ✅ User settings integration interface ready
- ✅ All business logic unit tested
- ✅ Integration with existing fuel logs service
### Ready for Phase 3 When:
- All business logic services tested and functional
- Unit conversion system verified accurate
- Fuel grade system working correctly
- Validation rules catching all edge cases
- Ready for API integration
---
**Next Phase**: [Phase 3 - API & Backend Implementation](FUEL-LOGS-PHASE-3.md)

View File

@@ -0,0 +1,932 @@
# Phase 3: API & Backend Implementation
## Overview
Update API contracts, implement enhanced backend services, create new endpoints, and build comprehensive test suite for the enhanced fuel logs system.
## Prerequisites
- ✅ Phase 1 completed (database schema and core types)
- ✅ Phase 2 completed (enhanced business logic services)
- All business logic services tested and functional
## Updated Service Layer
### Enhanced Fuel Logs Service
**File**: `backend/src/features/fuel-logs/domain/fuel-logs.service.ts` (Updated)
```typescript
import { FuelLogsRepository } from '../data/fuel-logs.repository';
import {
FuelLog, CreateFuelLogRequest, UpdateFuelLogRequest,
FuelLogResponse, FuelStats, UnitSystem
} from './fuel-logs.types';
import { EnhancedValidationService } from './enhanced-validation.service';
import { EfficiencyCalculationService } from './efficiency-calculation.service';
import { UnitConversionService } from './unit-conversion.service';
import { UserSettingsService } from '../external/user-settings.service';
import { logger } from '../../../core/logging/logger';
import { cacheService } from '../../../core/config/redis';
import pool from '../../../core/config/database';
export class FuelLogsService {
private readonly cachePrefix = 'fuel-logs';
private readonly cacheTTL = 300; // 5 minutes
constructor(private repository: FuelLogsRepository) {}
async createFuelLog(data: CreateFuelLogRequest, userId: string): Promise<FuelLogResponse> {
logger.info('Creating enhanced fuel log', {
userId,
vehicleId: data.vehicleId,
fuelType: data.fuelType,
hasTrip: !!data.tripDistance,
hasOdometer: !!data.odometerReading
});
// Get user settings for unit system
const userSettings = await UserSettingsService.getUserSettings(userId);
// Enhanced validation
const validation = EnhancedValidationService.validateFuelLogData(data, userSettings.unitSystem);
if (!validation.isValid) {
throw new ValidationError(`Invalid fuel log data: ${validation.errors.join(', ')}`);
}
// Log warnings
if (validation.warnings.length > 0) {
logger.warn('Fuel log validation warnings', { warnings: validation.warnings });
}
// Verify vehicle ownership
const vehicleCheck = await pool.query(
'SELECT id FROM vehicles WHERE id = $1 AND user_id = $2',
[data.vehicleId, userId]
);
if (vehicleCheck.rows.length === 0) {
throw new Error('Vehicle not found or unauthorized');
}
// Calculate total cost
const totalCost = data.fuelUnits * data.costPerUnit;
// Get previous log for efficiency calculation
const previousLog = data.odometerReading ?
await this.repository.getPreviousLogByOdometer(data.vehicleId, data.odometerReading) :
await this.repository.getLatestLogForVehicle(data.vehicleId);
// Calculate efficiency
const efficiencyResult = EfficiencyCalculationService.calculateEfficiency(
{ ...data, totalCost },
previousLog,
userSettings.unitSystem
);
// Prepare fuel log data
const fuelLogData = {
...data,
userId,
dateTime: new Date(data.dateTime),
totalCost,
mpg: efficiencyResult?.value || null,
efficiencyCalculationMethod: efficiencyResult?.calculationMethod || null
};
// Create fuel log
const fuelLog = await this.repository.create(fuelLogData);
// Update vehicle odometer if provided
if (data.odometerReading) {
await pool.query(
'UPDATE vehicles SET odometer_reading = $1 WHERE id = $2 AND (odometer_reading IS NULL OR odometer_reading < $1)',
[data.odometerReading, data.vehicleId]
);
}
// Invalidate caches
await this.invalidateCaches(userId, data.vehicleId);
return this.toResponse(fuelLog, userSettings.unitSystem);
}
async getFuelLogsByVehicle(
vehicleId: string,
userId: string,
options?: { unitSystem?: UnitSystem }
): Promise<FuelLogResponse[]> {
// Verify vehicle ownership
const vehicleCheck = await pool.query(
'SELECT id FROM vehicles WHERE id = $1 AND user_id = $2',
[vehicleId, userId]
);
if (vehicleCheck.rows.length === 0) {
throw new Error('Vehicle not found or unauthorized');
}
// Get user settings
const userSettings = await UserSettingsService.getUserSettings(userId);
const unitSystem = options?.unitSystem || userSettings.unitSystem;
const cacheKey = `${this.cachePrefix}:vehicle:${vehicleId}:${unitSystem}`;
// Check cache
const cached = await cacheService.get<FuelLogResponse[]>(cacheKey);
if (cached) {
return cached;
}
// Get from database
const logs = await this.repository.findByVehicleId(vehicleId);
const response = logs.map((log: FuelLog) => this.toResponse(log, unitSystem));
// Cache result
await cacheService.set(cacheKey, response, this.cacheTTL);
return response;
}
async getEnhancedVehicleStats(vehicleId: string, userId: string): Promise<EnhancedFuelStats> {
// Verify vehicle ownership
const vehicleCheck = await pool.query(
'SELECT id FROM vehicles WHERE id = $1 AND user_id = $2',
[vehicleId, userId]
);
if (vehicleCheck.rows.length === 0) {
throw new Error('Vehicle not found or unauthorized');
}
const userSettings = await UserSettingsService.getUserSettings(userId);
const logs = await this.repository.findByVehicleId(vehicleId);
if (logs.length === 0) {
return this.getEmptyStats(userSettings.unitSystem);
}
// Calculate comprehensive stats
const totalFuelUnits = logs.reduce((sum, log) => sum + log.fuelUnits, 0);
const totalCost = logs.reduce((sum, log) => sum + log.totalCost, 0);
const averageCostPerUnit = totalCost / totalFuelUnits;
const totalDistance = EfficiencyCalculationService.calculateTotalDistance(logs, userSettings.unitSystem);
const averageEfficiency = EfficiencyCalculationService.calculateAverageEfficiency(logs, userSettings.unitSystem);
// Group by fuel type
const fuelTypeBreakdown = this.calculateFuelTypeBreakdown(logs, userSettings.unitSystem);
// Calculate trends (last 30 days vs previous 30 days)
const trends = this.calculateEfficiencyTrends(logs, userSettings.unitSystem);
const unitLabels = UnitConversionService.getUnitLabels(userSettings.unitSystem);
return {
logCount: logs.length,
totalFuelUnits,
totalCost,
averageCostPerUnit,
totalDistance,
averageEfficiency: averageEfficiency?.value || 0,
fuelTypeBreakdown,
trends,
unitLabels,
dateRange: {
earliest: logs[logs.length - 1]?.dateTime,
latest: logs[0]?.dateTime
}
};
}
private toResponse(log: FuelLog, unitSystem: UnitSystem): FuelLogResponse {
const unitLabels = UnitConversionService.getUnitLabels(unitSystem);
// Convert efficiency to user's unit system if needed
let displayEfficiency = log.mpg;
if (log.mpg && unitSystem === UnitSystem.METRIC) {
displayEfficiency = UnitConversionService.convertEfficiency(
log.mpg,
UnitSystem.IMPERIAL, // Assuming stored as MPG
UnitSystem.METRIC
);
}
return {
id: log.id,
userId: log.userId,
vehicleId: log.vehicleId,
dateTime: log.dateTime.toISOString(),
// Distance information
odometerReading: log.odometerReading,
tripDistance: log.tripDistance,
// Fuel information
fuelType: log.fuelType,
fuelGrade: log.fuelGrade,
fuelUnits: log.fuelUnits,
costPerUnit: log.costPerUnit,
totalCost: log.totalCost,
// Location
locationData: log.locationData,
// Calculated fields
efficiency: displayEfficiency,
efficiencyLabel: unitLabels.efficiencyUnits,
// Metadata
notes: log.notes,
createdAt: log.createdAt.toISOString(),
updatedAt: log.updatedAt.toISOString(),
// Legacy fields (for backward compatibility)
date: log.dateTime.toISOString().split('T')[0],
odometer: log.odometerReading,
gallons: log.fuelUnits, // May need conversion
pricePerGallon: log.costPerUnit, // May need conversion
mpg: log.mpg
};
}
}
```
### New API Endpoints
#### Fuel Grade Endpoint
**File**: `backend/src/features/fuel-logs/api/fuel-grade.controller.ts`
```typescript
import { FastifyRequest, FastifyReply } from 'fastify';
import { FuelGradeService } from '../domain/fuel-grade.service';
import { FuelType } from '../domain/fuel-logs.types';
import { logger } from '../../../core/logging/logger';
export class FuelGradeController {
async getFuelGrades(
request: FastifyRequest<{ Params: { fuelType: FuelType } }>,
reply: FastifyReply
) {
try {
const { fuelType } = request.params;
// Validate fuel type
if (!Object.values(FuelType).includes(fuelType)) {
return reply.code(400).send({
error: 'Bad Request',
message: `Invalid fuel type: ${fuelType}`
});
}
const grades = FuelGradeService.getFuelGradeOptions(fuelType);
return reply.code(200).send({
fuelType,
grades
});
} catch (error: any) {
logger.error('Error getting fuel grades', { error, fuelType: request.params.fuelType });
return reply.code(500).send({
error: 'Internal server error',
message: 'Failed to get fuel grades'
});
}
}
async getAllFuelTypes(request: FastifyRequest, reply: FastifyReply) {
try {
const fuelTypes = Object.values(FuelType).map(type => ({
value: type,
label: type.charAt(0).toUpperCase() + type.slice(1),
grades: FuelGradeService.getFuelGradeOptions(type)
}));
return reply.code(200).send({ fuelTypes });
} catch (error: any) {
logger.error('Error getting fuel types', { error });
return reply.code(500).send({
error: 'Internal server error',
message: 'Failed to get fuel types'
});
}
}
}
```
### Enhanced Routes
**File**: `backend/src/features/fuel-logs/api/fuel-logs.routes.ts` (Updated)
```typescript
import { FastifyInstance, FastifyPluginOptions } from 'fastify';
import { FuelLogsController } from './fuel-logs.controller';
import { FuelGradeController } from './fuel-grade.controller';
import {
createFuelLogSchema,
updateFuelLogSchema,
fuelLogParamsSchema,
vehicleParamsSchema,
fuelTypeParamsSchema
} from './fuel-logs.validators';
export async function fuelLogsRoutes(
fastify: FastifyInstance,
options: FastifyPluginOptions
) {
const fuelLogsController = new FuelLogsController();
const fuelGradeController = new FuelGradeController();
// Existing fuel log CRUD endpoints (enhanced)
fastify.post('/fuel-logs', {
preHandler: [fastify.authenticate],
schema: createFuelLogSchema
}, fuelLogsController.createFuelLog.bind(fuelLogsController));
fastify.get('/fuel-logs', {
preHandler: [fastify.authenticate]
}, fuelLogsController.getUserFuelLogs.bind(fuelLogsController));
fastify.get('/fuel-logs/:id', {
preHandler: [fastify.authenticate],
schema: { params: fuelLogParamsSchema }
}, fuelLogsController.getFuelLog.bind(fuelLogsController));
fastify.put('/fuel-logs/:id', {
preHandler: [fastify.authenticate],
schema: {
params: fuelLogParamsSchema,
body: updateFuelLogSchema
}
}, fuelLogsController.updateFuelLog.bind(fuelLogsController));
fastify.delete('/fuel-logs/:id', {
preHandler: [fastify.authenticate],
schema: { params: fuelLogParamsSchema }
}, fuelLogsController.deleteFuelLog.bind(fuelLogsController));
// Vehicle-specific endpoints (enhanced)
fastify.get('/fuel-logs/vehicle/:vehicleId', {
preHandler: [fastify.authenticate],
schema: { params: vehicleParamsSchema }
}, fuelLogsController.getFuelLogsByVehicle.bind(fuelLogsController));
fastify.get('/fuel-logs/vehicle/:vehicleId/stats', {
preHandler: [fastify.authenticate],
schema: { params: vehicleParamsSchema }
}, fuelLogsController.getEnhancedVehicleStats.bind(fuelLogsController));
// NEW: Fuel type/grade endpoints
fastify.get('/fuel-logs/fuel-types', {
preHandler: [fastify.authenticate]
}, fuelGradeController.getAllFuelTypes.bind(fuelGradeController));
fastify.get('/fuel-logs/fuel-grades/:fuelType', {
preHandler: [fastify.authenticate],
schema: { params: fuelTypeParamsSchema }
}, fuelGradeController.getFuelGrades.bind(fuelGradeController));
}
export function registerFuelLogsRoutes(fastify: FastifyInstance) {
return fastify.register(fuelLogsRoutes, { prefix: '/api' });
}
```
### Enhanced Validation Schemas
**File**: `backend/src/features/fuel-logs/api/fuel-logs.validators.ts` (Updated)
```typescript
import { Type } from '@sinclair/typebox';
import { FuelType } from '../domain/fuel-logs.types';
export const createFuelLogSchema = {
body: Type.Object({
vehicleId: Type.String({ format: 'uuid' }),
dateTime: Type.String({ format: 'date-time' }),
// Distance (one required)
odometerReading: Type.Optional(Type.Number({ minimum: 0 })),
tripDistance: Type.Optional(Type.Number({ minimum: 0 })),
// Fuel system
fuelType: Type.Enum(FuelType),
fuelGrade: Type.Optional(Type.String()),
fuelUnits: Type.Number({ minimum: 0.01 }),
costPerUnit: Type.Number({ minimum: 0.01 }),
// Location (optional)
locationData: Type.Optional(Type.Object({
address: Type.Optional(Type.String()),
coordinates: Type.Optional(Type.Object({
latitude: Type.Number({ minimum: -90, maximum: 90 }),
longitude: Type.Number({ minimum: -180, maximum: 180 })
})),
googlePlaceId: Type.Optional(Type.String()),
stationName: Type.Optional(Type.String())
})),
notes: Type.Optional(Type.String({ maxLength: 500 }))
}),
response: {
201: Type.Object({
id: Type.String({ format: 'uuid' }),
userId: Type.String(),
vehicleId: Type.String({ format: 'uuid' }),
dateTime: Type.String({ format: 'date-time' }),
odometerReading: Type.Optional(Type.Number()),
tripDistance: Type.Optional(Type.Number()),
fuelType: Type.Enum(FuelType),
fuelGrade: Type.Optional(Type.String()),
fuelUnits: Type.Number(),
costPerUnit: Type.Number(),
totalCost: Type.Number(),
efficiency: Type.Optional(Type.Number()),
efficiencyLabel: Type.String(),
createdAt: Type.String({ format: 'date-time' }),
updatedAt: Type.String({ format: 'date-time' })
})
}
};
export const updateFuelLogSchema = {
body: Type.Partial(Type.Object({
dateTime: Type.String({ format: 'date-time' }),
odometerReading: Type.Number({ minimum: 0 }),
tripDistance: Type.Number({ minimum: 0 }),
fuelType: Type.Enum(FuelType),
fuelGrade: Type.String(),
fuelUnits: Type.Number({ minimum: 0.01 }),
costPerUnit: Type.Number({ minimum: 0.01 }),
locationData: Type.Object({
address: Type.Optional(Type.String()),
coordinates: Type.Optional(Type.Object({
latitude: Type.Number({ minimum: -90, maximum: 90 }),
longitude: Type.Number({ minimum: -180, maximum: 180 })
})),
googlePlaceId: Type.Optional(Type.String()),
stationName: Type.Optional(Type.String())
}),
notes: Type.String({ maxLength: 500 })
}))
};
export const fuelLogParamsSchema = Type.Object({
id: Type.String({ format: 'uuid' })
});
export const vehicleParamsSchema = Type.Object({
vehicleId: Type.String({ format: 'uuid' })
});
export const fuelTypeParamsSchema = Type.Object({
fuelType: Type.Enum(FuelType)
});
```
## Repository Layer Updates
### Enhanced Repository
**File**: `backend/src/features/fuel-logs/data/fuel-logs.repository.ts` (Updated)
```typescript
import { Pool } from 'pg';
import { FuelLog, CreateFuelLogData } from '../domain/fuel-logs.types';
export interface CreateFuelLogData {
userId: string;
vehicleId: string;
dateTime: Date;
odometerReading?: number;
tripDistance?: number;
fuelType: string;
fuelGrade?: string;
fuelUnits: number;
costPerUnit: number;
totalCost: number;
locationData?: any;
notes?: string;
mpg?: number;
efficiencyCalculationMethod?: string;
}
export class FuelLogsRepository {
constructor(private pool: Pool) {}
async create(data: CreateFuelLogData): Promise<FuelLog> {
const query = `
INSERT INTO fuel_logs (
user_id, vehicle_id, date_time, odometer_reading, trip_distance,
fuel_type, fuel_grade, fuel_units, cost_per_unit, total_cost,
location_data, notes, mpg, efficiency_calculation_method,
created_at, updated_at
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, NOW(), NOW()
) RETURNING *
`;
const values = [
data.userId,
data.vehicleId,
data.dateTime,
data.odometerReading || null,
data.tripDistance || null,
data.fuelType,
data.fuelGrade || null,
data.fuelUnits,
data.costPerUnit,
data.totalCost,
data.locationData ? JSON.stringify(data.locationData) : null,
data.notes || null,
data.mpg || null,
data.efficiencyCalculationMethod || null
];
const result = await this.pool.query(query, values);
return this.mapRowToFuelLog(result.rows[0]);
}
async getPreviousLogByOdometer(vehicleId: string, currentOdometer: number): Promise<FuelLog | null> {
const query = `
SELECT * FROM fuel_logs
WHERE vehicle_id = $1
AND odometer_reading IS NOT NULL
AND odometer_reading < $2
ORDER BY odometer_reading DESC, date_time DESC
LIMIT 1
`;
const result = await this.pool.query(query, [vehicleId, currentOdometer]);
return result.rows.length > 0 ? this.mapRowToFuelLog(result.rows[0]) : null;
}
async getLatestLogForVehicle(vehicleId: string): Promise<FuelLog | null> {
const query = `
SELECT * FROM fuel_logs
WHERE vehicle_id = $1
ORDER BY date_time DESC, created_at DESC
LIMIT 1
`;
const result = await this.pool.query(query, [vehicleId]);
return result.rows.length > 0 ? this.mapRowToFuelLog(result.rows[0]) : null;
}
async findByVehicleId(vehicleId: string): Promise<FuelLog[]> {
const query = `
SELECT * FROM fuel_logs
WHERE vehicle_id = $1
ORDER BY date_time DESC, created_at DESC
`;
const result = await this.pool.query(query, [vehicleId]);
return result.rows.map(row => this.mapRowToFuelLog(row));
}
private mapRowToFuelLog(row: any): FuelLog {
return {
id: row.id,
userId: row.user_id,
vehicleId: row.vehicle_id,
dateTime: row.date_time,
odometerReading: row.odometer_reading,
tripDistance: row.trip_distance,
fuelType: row.fuel_type,
fuelGrade: row.fuel_grade,
fuelUnits: parseFloat(row.fuel_units),
costPerUnit: parseFloat(row.cost_per_unit),
totalCost: parseFloat(row.total_cost),
locationData: row.location_data ? JSON.parse(row.location_data) : null,
notes: row.notes,
mpg: row.mpg ? parseFloat(row.mpg) : null,
createdAt: row.created_at,
updatedAt: row.updated_at,
// Legacy field mapping
date: row.date_time,
odometer: row.odometer_reading,
gallons: parseFloat(row.fuel_units), // Assuming stored in user's preferred units
pricePerGallon: parseFloat(row.cost_per_unit)
};
}
}
```
## Comprehensive Test Suite
### Service Layer Tests
**File**: `backend/src/features/fuel-logs/tests/unit/enhanced-fuel-logs.service.test.ts`
```typescript
import { FuelLogsService } from '../../domain/fuel-logs.service';
import { FuelLogsRepository } from '../../data/fuel-logs.repository';
import { FuelType, UnitSystem } from '../../domain/fuel-logs.types';
import { UserSettingsService } from '../../external/user-settings.service';
// Mock dependencies
jest.mock('../../data/fuel-logs.repository');
jest.mock('../../external/user-settings.service');
jest.mock('../../../core/config/database');
jest.mock('../../../core/config/redis');
describe('Enhanced FuelLogsService', () => {
let service: FuelLogsService;
let mockRepository: jest.Mocked<FuelLogsRepository>;
beforeEach(() => {
mockRepository = new FuelLogsRepository({} as any) as jest.Mocked<FuelLogsRepository>;
service = new FuelLogsService(mockRepository);
// Mock user settings
(UserSettingsService.getUserSettings as jest.Mock).mockResolvedValue({
unitSystem: UnitSystem.IMPERIAL,
currencyCode: 'USD',
timeZone: 'America/New_York'
});
});
describe('createFuelLog', () => {
it('should create fuel log with trip distance', async () => {
const createData = {
vehicleId: 'vehicle-id',
dateTime: '2024-01-15T10:30:00Z',
tripDistance: 300,
fuelType: FuelType.GASOLINE,
fuelGrade: '87',
fuelUnits: 10,
costPerUnit: 3.50,
notes: 'Test fuel log'
};
// Mock vehicle check
(pool.query as jest.Mock)
.mockResolvedValueOnce({ rows: [{ id: 'vehicle-id' }] }) // Vehicle exists
.mockResolvedValueOnce({}); // Odometer update (not applicable for trip distance)
mockRepository.create.mockResolvedValue({
id: 'fuel-log-id',
userId: 'user-id',
...createData,
totalCost: 35.0,
mpg: 30,
createdAt: new Date(),
updatedAt: new Date()
} as any);
const result = await service.createFuelLog(createData, 'user-id');
expect(result.id).toBe('fuel-log-id');
expect(result.totalCost).toBe(35.0);
expect(result.efficiency).toBe(30);
expect(mockRepository.create).toHaveBeenCalledWith(
expect.objectContaining({
tripDistance: 300,
totalCost: 35.0
})
);
});
it('should validate distance requirement', async () => {
const createData = {
vehicleId: 'vehicle-id',
dateTime: '2024-01-15T10:30:00Z',
fuelType: FuelType.GASOLINE,
fuelGrade: '87',
fuelUnits: 10,
costPerUnit: 3.50
// Missing both tripDistance and odometerReading
};
await expect(service.createFuelLog(createData, 'user-id'))
.rejects.toThrow('Either odometer reading or trip distance is required');
});
it('should validate fuel grade for fuel type', async () => {
const createData = {
vehicleId: 'vehicle-id',
dateTime: '2024-01-15T10:30:00Z',
tripDistance: 300,
fuelType: FuelType.GASOLINE,
fuelGrade: '#1', // Invalid for gasoline
fuelUnits: 10,
costPerUnit: 3.50
};
await expect(service.createFuelLog(createData, 'user-id'))
.rejects.toThrow('Invalid fuel grade');
});
});
describe('getEnhancedVehicleStats', () => {
it('should calculate comprehensive vehicle statistics', async () => {
const mockLogs = [
{
fuelUnits: 10,
totalCost: 35,
tripDistance: 300,
mpg: 30,
fuelType: FuelType.GASOLINE,
dateTime: new Date('2024-01-15')
},
{
fuelUnits: 12,
totalCost: 42,
tripDistance: 350,
mpg: 29,
fuelType: FuelType.GASOLINE,
dateTime: new Date('2024-01-10')
}
];
// Mock vehicle check
(pool.query as jest.Mock).mockResolvedValue({ rows: [{ id: 'vehicle-id' }] });
mockRepository.findByVehicleId.mockResolvedValue(mockLogs as any);
const stats = await service.getEnhancedVehicleStats('vehicle-id', 'user-id');
expect(stats.logCount).toBe(2);
expect(stats.totalFuelUnits).toBe(22);
expect(stats.totalCost).toBe(77);
expect(stats.averageCostPerUnit).toBeCloseTo(3.5, 2);
expect(stats.totalDistance).toBe(650);
expect(stats.averageEfficiency).toBeCloseTo(29.5, 1);
});
});
});
```
### Integration Tests
**File**: `backend/src/features/fuel-logs/tests/integration/enhanced-fuel-logs.integration.test.ts`
```typescript
import request from 'supertest';
import { app } from '../../../app';
import { pool } from '../../../core/config/database';
import { FuelType } from '../../domain/fuel-logs.types';
describe('Enhanced Fuel Logs API Integration', () => {
let authToken: string;
let vehicleId: string;
beforeAll(async () => {
// Setup test data
authToken = await getTestAuthToken();
vehicleId = await createTestVehicle();
});
afterAll(async () => {
// Cleanup
await cleanupTestData();
await pool.end();
});
describe('POST /api/fuel-logs', () => {
it('should create fuel log with enhanced fields', async () => {
const fuelLogData = {
vehicleId,
dateTime: '2024-01-15T10:30:00Z',
tripDistance: 300,
fuelType: FuelType.GASOLINE,
fuelGrade: '87',
fuelUnits: 10,
costPerUnit: 3.50,
locationData: {
address: '123 Main St, Anytown, USA',
stationName: 'Shell Station'
},
notes: 'Full tank'
};
const response = await request(app)
.post('/api/fuel-logs')
.set('Authorization', `Bearer ${authToken}`)
.send(fuelLogData)
.expect(201);
expect(response.body.id).toBeDefined();
expect(response.body.tripDistance).toBe(300);
expect(response.body.fuelType).toBe(FuelType.GASOLINE);
expect(response.body.fuelGrade).toBe('87');
expect(response.body.totalCost).toBe(35.0);
expect(response.body.efficiency).toBe(30); // 300 miles / 10 gallons
expect(response.body.efficiencyLabel).toBe('mpg');
});
it('should validate distance requirement', async () => {
const fuelLogData = {
vehicleId,
dateTime: '2024-01-15T10:30:00Z',
fuelType: FuelType.GASOLINE,
fuelGrade: '87',
fuelUnits: 10,
costPerUnit: 3.50
// Missing both tripDistance and odometerReading
};
const response = await request(app)
.post('/api/fuel-logs')
.set('Authorization', `Bearer ${authToken}`)
.send(fuelLogData)
.expect(400);
expect(response.body.message).toContain('Either odometer reading or trip distance is required');
});
});
describe('GET /api/fuel-logs/fuel-grades/:fuelType', () => {
it('should return gasoline fuel grades', async () => {
const response = await request(app)
.get('/api/fuel-logs/fuel-grades/gasoline')
.set('Authorization', `Bearer ${authToken}`)
.expect(200);
expect(response.body.fuelType).toBe('gasoline');
expect(response.body.grades).toHaveLength(5);
expect(response.body.grades[0]).toEqual({
value: '87',
label: '87 (Regular)',
description: 'Regular unleaded gasoline'
});
});
it('should return empty grades for electric', async () => {
const response = await request(app)
.get('/api/fuel-logs/fuel-grades/electric')
.set('Authorization', `Bearer ${authToken}`)
.expect(200);
expect(response.body.fuelType).toBe('electric');
expect(response.body.grades).toHaveLength(0);
});
});
describe('GET /api/fuel-logs/fuel-types', () => {
it('should return all fuel types with grades', async () => {
const response = await request(app)
.get('/api/fuel-logs/fuel-types')
.set('Authorization', `Bearer ${authToken}`)
.expect(200);
expect(response.body.fuelTypes).toHaveLength(3);
const gasoline = response.body.fuelTypes.find(ft => ft.value === 'gasoline');
expect(gasoline.grades).toHaveLength(5);
const electric = response.body.fuelTypes.find(ft => ft.value === 'electric');
expect(electric.grades).toHaveLength(0);
});
});
});
```
## Implementation Tasks
### Service Layer Updates
1. ✅ Update FuelLogsService with enhanced business logic
2. ✅ Integrate validation and efficiency calculation services
3. ✅ Add user settings integration
4. ✅ Implement comprehensive stats calculations
### API Layer Updates
1. ✅ Create FuelGradeController for dynamic grades
2. ✅ Update existing controllers with enhanced validation
3. ✅ Add new API endpoints for fuel types/grades
4. ✅ Update validation schemas
### Repository Updates
1. ✅ Update repository for new database fields
2. ✅ Add methods for enhanced queries
3. ✅ Implement proper data mapping
### Testing Implementation
1. ✅ Create comprehensive unit test suite
2. ✅ Implement integration tests for all endpoints
3. ✅ Add validation testing
4. ✅ Test business logic edge cases
## Success Criteria
### Phase 3 Complete When:
- ✅ All API endpoints functional with enhanced data
- ✅ Comprehensive validation working correctly
- ✅ Fuel type/grade system fully operational
- ✅ Unit conversion integration functional
- ✅ Enhanced statistics calculations working
- ✅ Complete test suite passes (>90% coverage)
- ✅ All new endpoints documented and tested
- ✅ Backward compatibility maintained
### Ready for Phase 4 When:
- All backend services tested and stable
- API contracts finalized and documented
- Frontend integration points clearly defined
- Enhanced business logic fully functional
---
**Next Phase**: [Phase 4 - Frontend Implementation](FUEL-LOGS-PHASE-4.md)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff