Initial Commit
This commit is contained in:
164
docs/changes/fuel-logs-v1/FUEL-LOGS-IMPLEMENTATION.md
Normal file
164
docs/changes/fuel-logs-v1/FUEL-LOGS-IMPLEMENTATION.md
Normal 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.
|
||||
391
docs/changes/fuel-logs-v1/FUEL-LOGS-PHASE-1.md
Normal file
391
docs/changes/fuel-logs-v1/FUEL-LOGS-PHASE-1.md
Normal 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)
|
||||
658
docs/changes/fuel-logs-v1/FUEL-LOGS-PHASE-2.md
Normal file
658
docs/changes/fuel-logs-v1/FUEL-LOGS-PHASE-2.md
Normal 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)
|
||||
932
docs/changes/fuel-logs-v1/FUEL-LOGS-PHASE-3.md
Normal file
932
docs/changes/fuel-logs-v1/FUEL-LOGS-PHASE-3.md
Normal 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)
|
||||
1080
docs/changes/fuel-logs-v1/FUEL-LOGS-PHASE-4.md
Normal file
1080
docs/changes/fuel-logs-v1/FUEL-LOGS-PHASE-4.md
Normal file
File diff suppressed because it is too large
Load Diff
1132
docs/changes/fuel-logs-v1/FUEL-LOGS-PHASE-5.md
Normal file
1132
docs/changes/fuel-logs-v1/FUEL-LOGS-PHASE-5.md
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user