UX Improvements
This commit is contained in:
@@ -53,7 +53,8 @@
|
||||
"Bash(for feature in vehicles fuel-logs maintenance stations)",
|
||||
"Bash(ls:*)",
|
||||
"Bash(cp:*)",
|
||||
"Bash(openssl:*)"
|
||||
"Bash(openssl:*)",
|
||||
"Bash(npm run type-check:*)"
|
||||
],
|
||||
"deny": []
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ export class EfficiencyCalculationService {
|
||||
return null;
|
||||
}
|
||||
|
||||
const value = UnitConversionService.calculateEfficiency(distance, currentLog.fuelUnits, unitSystem);
|
||||
const value = UnitConversionService.calculateEfficiency(distance, currentLog.fuelUnits);
|
||||
const labels = UnitConversionService.getUnitLabels(unitSystem);
|
||||
return { value, unitSystem, label: labels.efficiencyUnits, calculationMethod: method };
|
||||
}
|
||||
|
||||
@@ -85,7 +85,17 @@ export class FuelLogsService {
|
||||
if (cached) return cached;
|
||||
|
||||
const rows = await this.repository.findByVehicleIdEnhanced(vehicleId);
|
||||
const response = rows.map(r => this.toEnhancedResponse(r, undefined, unitSystem));
|
||||
const response = rows.map(r => {
|
||||
const efficiency = EfficiencyCalculationService.calculateEfficiency(
|
||||
{
|
||||
tripDistance: r.trip_distance ?? undefined,
|
||||
fuelUnits: r.fuel_units ?? undefined
|
||||
},
|
||||
null, // No previous odometer needed for trip distance calculation
|
||||
unitSystem
|
||||
);
|
||||
return this.toEnhancedResponse(r, efficiency?.value ?? undefined, unitSystem);
|
||||
});
|
||||
await cacheService.set(cacheKey, response, this.cacheTTL);
|
||||
return response;
|
||||
}
|
||||
@@ -96,7 +106,17 @@ export class FuelLogsService {
|
||||
const cached = await cacheService.get<EnhancedFuelLogResponse[]>(cacheKey);
|
||||
if (cached) return cached;
|
||||
const rows = await this.repository.findByUserIdEnhanced(userId);
|
||||
const response = rows.map(r => this.toEnhancedResponse(r, undefined, unitSystem));
|
||||
const response = rows.map(r => {
|
||||
const efficiency = EfficiencyCalculationService.calculateEfficiency(
|
||||
{
|
||||
tripDistance: r.trip_distance ?? undefined,
|
||||
fuelUnits: r.fuel_units ?? undefined
|
||||
},
|
||||
null, // No previous odometer needed for trip distance calculation
|
||||
unitSystem
|
||||
);
|
||||
return this.toEnhancedResponse(r, efficiency?.value ?? undefined, unitSystem);
|
||||
});
|
||||
await cacheService.set(cacheKey, response, this.cacheTTL);
|
||||
return response;
|
||||
}
|
||||
@@ -106,7 +126,17 @@ export class FuelLogsService {
|
||||
if (!row) throw new Error('Fuel log not found');
|
||||
if (row.user_id !== userId) throw new Error('Unauthorized');
|
||||
const { unitSystem } = await UserSettingsService.getUserSettings(userId);
|
||||
return this.toEnhancedResponse(row, undefined, unitSystem);
|
||||
|
||||
const efficiency = EfficiencyCalculationService.calculateEfficiency(
|
||||
{
|
||||
tripDistance: row.trip_distance ?? undefined,
|
||||
fuelUnits: row.fuel_units ?? undefined
|
||||
},
|
||||
null, // No previous odometer needed for trip distance calculation
|
||||
unitSystem
|
||||
);
|
||||
|
||||
return this.toEnhancedResponse(row, efficiency?.value ?? undefined, unitSystem);
|
||||
}
|
||||
|
||||
async updateFuelLog(id: string, data: EnhancedUpdateFuelLogRequest, userId: string): Promise<EnhancedFuelLogResponse> {
|
||||
|
||||
@@ -3,28 +3,29 @@ import { UnitSystem as CoreUnitSystem } from '../../../shared-minimal/utils/unit
|
||||
export type UnitSystem = CoreUnitSystem;
|
||||
|
||||
export class UnitConversionService {
|
||||
private static readonly MPG_TO_L100KM = 235.214;
|
||||
private static readonly KM_PER_MILE = 1.60934;
|
||||
private static readonly GALLONS_TO_LITERS = 3.78541;
|
||||
|
||||
static getUnitLabels(unitSystem: UnitSystem) {
|
||||
return unitSystem === 'metric'
|
||||
? { fuelUnits: 'liters', distanceUnits: 'kilometers', efficiencyUnits: 'L/100km' }
|
||||
: { fuelUnits: 'gallons', distanceUnits: 'miles', efficiencyUnits: 'mpg' };
|
||||
? { fuelUnits: 'liters', distanceUnits: 'kilometers', efficiencyUnits: 'km/L' }
|
||||
: { fuelUnits: 'gallons', distanceUnits: 'miles', efficiencyUnits: 'MPG' };
|
||||
}
|
||||
|
||||
static calculateEfficiency(distance: number, fuelUnits: number, unitSystem: UnitSystem): number {
|
||||
static calculateEfficiency(distance: number, fuelUnits: number): number {
|
||||
if (fuelUnits <= 0 || distance <= 0) return 0;
|
||||
return unitSystem === 'metric'
|
||||
? (fuelUnits / distance) * 100
|
||||
: distance / fuelUnits;
|
||||
return distance / fuelUnits; // Both km/L and MPG use distance/fuel formula
|
||||
}
|
||||
|
||||
static convertEfficiency(efficiency: number, from: UnitSystem, to: UnitSystem): number {
|
||||
if (from === to) return efficiency;
|
||||
if (from === 'imperial' && to === 'metric') {
|
||||
return efficiency > 0 ? this.MPG_TO_L100KM / efficiency : 0;
|
||||
// MPG to km/L: MPG * (gallons_to_liters / km_per_mile)
|
||||
return efficiency * (this.GALLONS_TO_LITERS / this.KM_PER_MILE);
|
||||
}
|
||||
if (from === 'metric' && to === 'imperial') {
|
||||
return efficiency > 0 ? this.MPG_TO_L100KM / efficiency : 0;
|
||||
// km/L to MPG: km/L * (km_per_mile / gallons_to_liters)
|
||||
return efficiency * (this.KM_PER_MILE / this.GALLONS_TO_LITERS);
|
||||
}
|
||||
return efficiency;
|
||||
}
|
||||
|
||||
@@ -22,11 +22,12 @@
|
||||
"react-hook-form": "^7.48.2",
|
||||
"@hookform/resolvers": "^3.3.2",
|
||||
"zod": "^3.22.4",
|
||||
"date-fns": "^3.0.0",
|
||||
"date-fns": "^2.30.0",
|
||||
"clsx": "^2.0.0",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"framer-motion": "^11.0.0",
|
||||
"@mui/material": "^5.15.0",
|
||||
"@mui/x-date-pickers": "^6.19.0",
|
||||
"@emotion/react": "^11.11.4",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@emotion/cache": "^11.11.0",
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
export type UnitSystem = 'imperial' | 'metric';
|
||||
export type DistanceUnit = 'miles' | 'km';
|
||||
export type VolumeUnit = 'gallons' | 'liters';
|
||||
export type FuelEfficiencyUnit = 'mpg' | 'l100km';
|
||||
export type FuelEfficiencyUnit = 'mpg' | 'kml';
|
||||
|
||||
export interface UnitPreferences {
|
||||
system: UnitSystem;
|
||||
|
||||
@@ -10,7 +10,9 @@ const MILES_TO_KM = 1.60934;
|
||||
const KM_TO_MILES = 0.621371;
|
||||
const GALLONS_TO_LITERS = 3.78541;
|
||||
const LITERS_TO_GALLONS = 0.264172;
|
||||
const MPG_TO_L100KM_FACTOR = 235.214;
|
||||
// For km/L conversion
|
||||
const MPG_TO_KML = 1.60934 / 3.78541; // ≈ 0.425144
|
||||
const KML_TO_MPG = 3.78541 / 1.60934; // ≈ 2.352145
|
||||
|
||||
// Distance Conversions
|
||||
export function convertDistance(value: number, fromUnit: DistanceUnit, toUnit: DistanceUnit): number {
|
||||
@@ -60,12 +62,12 @@ export function convertVolumeBySystem(gallons: number, toSystem: UnitSystem): nu
|
||||
export function convertFuelEfficiency(value: number, fromUnit: FuelEfficiencyUnit, toUnit: FuelEfficiencyUnit): number {
|
||||
if (fromUnit === toUnit) return value;
|
||||
|
||||
if (fromUnit === 'mpg' && toUnit === 'l100km') {
|
||||
return value === 0 ? 0 : MPG_TO_L100KM_FACTOR / value;
|
||||
if (fromUnit === 'mpg' && toUnit === 'kml') {
|
||||
return value * MPG_TO_KML;
|
||||
}
|
||||
|
||||
if (fromUnit === 'l100km' && toUnit === 'mpg') {
|
||||
return value === 0 ? 0 : MPG_TO_L100KM_FACTOR / value;
|
||||
if (fromUnit === 'kml' && toUnit === 'mpg') {
|
||||
return value * KML_TO_MPG;
|
||||
}
|
||||
|
||||
return value;
|
||||
@@ -73,7 +75,7 @@ export function convertFuelEfficiency(value: number, fromUnit: FuelEfficiencyUni
|
||||
|
||||
export function convertFuelEfficiencyBySystem(mpg: number, toSystem: UnitSystem): number {
|
||||
if (toSystem === 'metric') {
|
||||
return convertFuelEfficiency(mpg, 'mpg', 'l100km');
|
||||
return convertFuelEfficiency(mpg, 'mpg', 'kml');
|
||||
}
|
||||
return mpg;
|
||||
}
|
||||
@@ -109,7 +111,7 @@ export function formatVolume(value: number, unit: VolumeUnit, precision = 2): st
|
||||
|
||||
export function formatFuelEfficiency(value: number, unit: FuelEfficiencyUnit, precision = 1): string {
|
||||
if (typeof value !== 'number' || isNaN(value)) {
|
||||
return unit === 'mpg' ? '0 MPG' : '0 L/100km';
|
||||
return unit === 'mpg' ? '0 MPG' : '0 km/L';
|
||||
}
|
||||
|
||||
const rounded = parseFloat(value.toFixed(precision));
|
||||
@@ -117,7 +119,7 @@ export function formatFuelEfficiency(value: number, unit: FuelEfficiencyUnit, pr
|
||||
if (unit === 'mpg') {
|
||||
return `${rounded} MPG`;
|
||||
} else {
|
||||
return `${rounded} L/100km`;
|
||||
return `${rounded} km/L`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,8 +168,8 @@ export function formatVolumeBySystem(gallons: number, system: UnitSystem, precis
|
||||
|
||||
export function formatFuelEfficiencyBySystem(mpg: number, system: UnitSystem, precision = 1): string {
|
||||
if (system === 'metric') {
|
||||
const l100km = convertFuelEfficiencyBySystem(mpg, system);
|
||||
return formatFuelEfficiency(l100km, 'l100km', precision);
|
||||
const kml = convertFuelEfficiencyBySystem(mpg, system);
|
||||
return formatFuelEfficiency(kml, 'kml', precision);
|
||||
}
|
||||
return formatFuelEfficiency(mpg, 'mpg', precision);
|
||||
}
|
||||
@@ -190,5 +192,5 @@ export function getVolumeUnit(system: UnitSystem): VolumeUnit {
|
||||
}
|
||||
|
||||
export function getFuelEfficiencyUnit(system: UnitSystem): FuelEfficiencyUnit {
|
||||
return system === 'metric' ? 'l100km' : 'mpg';
|
||||
return system === 'metric' ? 'kml' : 'mpg';
|
||||
}
|
||||
@@ -15,6 +15,9 @@ import {
|
||||
Typography,
|
||||
useMediaQuery
|
||||
} from '@mui/material';
|
||||
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
|
||||
import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns';
|
||||
import { DateTimePicker } from '@mui/x-date-pickers/DateTimePicker';
|
||||
import { FuelLogResponse, UpdateFuelLogRequest, FuelType } from '../types/fuel-logs.types';
|
||||
import { useFuelGrades } from '../hooks/useFuelGrades';
|
||||
|
||||
@@ -130,41 +133,40 @@ export const FuelLogEditDialog: React.FC<FuelLogEditDialogProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
// Format datetime for input (datetime-local expects YYYY-MM-DDTHH:mm format)
|
||||
const formatDateTimeForInput = (isoString: string) => {
|
||||
const date = new Date(isoString);
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={handleCancel}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
fullScreen={isSmallScreen}
|
||||
PaperProps={{
|
||||
sx: { maxHeight: '90vh' }
|
||||
}}
|
||||
>
|
||||
<DialogTitle>Edit Fuel Log</DialogTitle>
|
||||
<DialogContent>
|
||||
<LocalizationProvider dateAdapter={AdapterDateFns}>
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={handleCancel}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
fullScreen={isSmallScreen}
|
||||
PaperProps={{
|
||||
sx: { maxHeight: '90vh' }
|
||||
}}
|
||||
>
|
||||
<DialogTitle>Edit Fuel Log</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<Grid container spacing={2}>
|
||||
{/* Date and Time */}
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
<DateTimePicker
|
||||
label="Date & Time"
|
||||
type="datetime-local"
|
||||
fullWidth
|
||||
value={formData.dateTime ? formatDateTimeForInput(formData.dateTime) : ''}
|
||||
onChange={(e) => handleInputChange('dateTime', new Date(e.target.value).toISOString())}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
value={formData.dateTime ? new Date(formData.dateTime) : null}
|
||||
onChange={(newValue) => handleInputChange('dateTime', newValue?.toISOString() || '')}
|
||||
format="MM/dd/yyyy hh:mm a"
|
||||
slotProps={{
|
||||
textField: {
|
||||
fullWidth: true,
|
||||
sx: {
|
||||
'& .MuiOutlinedInput-root': {
|
||||
minHeight: '56px', // Ensure consistent height for mobile touch targets
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
@@ -284,5 +286,6 @@ export const FuelLogEditDialog: React.FC<FuelLogEditDialogProps> = ({
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</LocalizationProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,7 +2,10 @@ import React, { useEffect, useMemo, useState, useRef, memo } from 'react';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Grid, Card, CardHeader, CardContent, TextField, Switch, FormControlLabel, Box, Button, CircularProgress } from '@mui/material';
|
||||
import { Grid, Card, CardHeader, CardContent, TextField, Box, Button, CircularProgress, ToggleButton, ToggleButtonGroup } from '@mui/material';
|
||||
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
|
||||
import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns';
|
||||
import { DateTimePicker } from '@mui/x-date-pickers/DateTimePicker';
|
||||
import { VehicleSelector } from './VehicleSelector';
|
||||
import { DistanceInput } from './DistanceInput';
|
||||
import { FuelTypeSelector } from './FuelTypeSelector';
|
||||
@@ -94,6 +97,19 @@ const FuelLogFormComponent: React.FC<{ onSuccess?: () => void; initial?: Partial
|
||||
return units > 0 && cost > 0 ? units * cost : 0;
|
||||
}, [fuelUnits, costPerUnit]);
|
||||
|
||||
// Watch for distance and fuel units to calculate efficiency
|
||||
const watchedDistance = watch(useOdometer ? 'odometerReading' : 'tripDistance');
|
||||
const distanceValue = typeof watchedDistance === 'string' ? parseFloat(watchedDistance) : watchedDistance;
|
||||
|
||||
const calculatedEfficiency = useMemo(() => {
|
||||
const distance = distanceValue && !isNaN(distanceValue) ? distanceValue : 0;
|
||||
const units = fuelUnits && !isNaN(fuelUnits) ? fuelUnits : 0;
|
||||
if (distance > 0 && units > 0) {
|
||||
return distance / units;
|
||||
}
|
||||
return 0;
|
||||
}, [distanceValue, fuelUnits]);
|
||||
|
||||
const onSubmit = async (data: CreateFuelLogRequest) => {
|
||||
const payload: CreateFuelLogRequest = {
|
||||
...data,
|
||||
@@ -123,29 +139,107 @@ const FuelLogFormComponent: React.FC<{ onSuccess?: () => void; initial?: Partial
|
||||
}, [useOdometer, setValue]);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader title="Add Fuel Log" subheader={<UnitSystemDisplay unitSystem={userSettings?.unitSystem} showLabel="Displaying in" />} />
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Grid container spacing={2}>
|
||||
<LocalizationProvider dateAdapter={AdapterDateFns}>
|
||||
<Card>
|
||||
<CardHeader title="Add Fuel Log" subheader={<UnitSystemDisplay unitSystem={userSettings?.unitSystem} showLabel="Displaying in" />} />
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Grid container spacing={2}>
|
||||
{/* Row 1: Select Vehicle */}
|
||||
<Grid item xs={12}>
|
||||
<Controller name="vehicleId" control={control} render={({ field }) => (
|
||||
<VehicleSelector value={field.value} onChange={field.onChange} error={errors.vehicleId?.message} required />
|
||||
)} />
|
||||
</Grid>
|
||||
|
||||
{/* Row 2: Date/Time | MPG/km/L */}
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Controller name="dateTime" control={control} render={({ field }) => (
|
||||
<TextField {...field} label="Date & Time" type="datetime-local" fullWidth error={!!errors.dateTime} helperText={errors.dateTime?.message} InputLabelProps={{ shrink: true }} />
|
||||
<DateTimePicker
|
||||
label="Date & Time"
|
||||
value={field.value ? new Date(field.value) : null}
|
||||
onChange={(newValue) => field.onChange(newValue?.toISOString() || '')}
|
||||
format="MM/dd/yyyy hh:mm a"
|
||||
slotProps={{
|
||||
textField: {
|
||||
fullWidth: true,
|
||||
error: !!errors.dateTime,
|
||||
helperText: errors.dateTime?.message,
|
||||
sx: {
|
||||
'& .MuiOutlinedInput-root': {
|
||||
minHeight: '56px',
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)} />
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<FormControlLabel control={<Switch checked={useOdometer} onChange={(e) => setUseOdometer(e.target.checked)} />} label={`Use ${useOdometer ? 'Odometer' : 'Trip Distance'}`} />
|
||||
<TextField
|
||||
label={`${userSettings?.unitSystem === 'metric' ? 'km/L' : 'MPG'}`}
|
||||
value={calculatedEfficiency > 0 ? calculatedEfficiency.toFixed(3) : ''}
|
||||
fullWidth
|
||||
InputProps={{
|
||||
readOnly: true,
|
||||
sx: {
|
||||
backgroundColor: 'grey.50',
|
||||
'& .MuiOutlinedInput-input': {
|
||||
cursor: 'default',
|
||||
},
|
||||
},
|
||||
}}
|
||||
helperText="Calculated from distance ÷ fuel amount"
|
||||
sx={{
|
||||
'& .MuiOutlinedInput-root': {
|
||||
minHeight: '56px',
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* Row 3: Odometer | Distance Input Method */}
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Controller name={useOdometer ? 'odometerReading' : 'tripDistance'} control={control} render={({ field }) => (
|
||||
<DistanceInput type={useOdometer ? 'odometer' : 'trip'} value={field.value as any} onChange={field.onChange as any} unitSystem={userSettings?.unitSystem} error={useOdometer ? (errors.odometerReading?.message as any) : (errors.tripDistance?.message as any)} />
|
||||
)} />
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<ToggleButtonGroup
|
||||
value={useOdometer ? 'odometer' : 'trip'}
|
||||
exclusive
|
||||
onChange={(_, newValue) => {
|
||||
if (newValue !== null) {
|
||||
setUseOdometer(newValue === 'odometer');
|
||||
}
|
||||
}}
|
||||
aria-label="distance input method"
|
||||
fullWidth
|
||||
sx={{
|
||||
height: '56px', // Match input field height
|
||||
'& .MuiToggleButton-root': {
|
||||
textTransform: 'none',
|
||||
fontWeight: 500,
|
||||
borderRadius: '8px',
|
||||
height: '56px', // Ensure button height matches
|
||||
'&.Mui-selected': {
|
||||
backgroundColor: 'primary.main',
|
||||
color: 'primary.contrastText',
|
||||
'&:hover': {
|
||||
backgroundColor: 'primary.dark',
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ToggleButton value="trip" aria-label="trip distance">
|
||||
Trip Distance
|
||||
</ToggleButton>
|
||||
<ToggleButton value="odometer" aria-label="odometer reading">
|
||||
Odometer Reading
|
||||
</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Controller name="fuelType" control={control} render={({ field: fuelTypeField }) => (
|
||||
<Controller name="fuelGrade" control={control} render={({ field: fuelGradeField }) => (
|
||||
@@ -205,6 +299,7 @@ const FuelLogFormComponent: React.FC<{ onSuccess?: () => void; initial?: Partial
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</LocalizationProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import { Edit, Delete } from '@mui/icons-material';
|
||||
import { FuelLogResponse } from '../types/fuel-logs.types';
|
||||
import { fuelLogsApi } from '../api/fuel-logs.api';
|
||||
import { useUnits } from '../../../core/units/UnitsContext';
|
||||
|
||||
interface FuelLogsListProps {
|
||||
logs?: FuelLogResponse[];
|
||||
@@ -30,10 +31,26 @@ interface FuelLogsListProps {
|
||||
export const FuelLogsList: React.FC<FuelLogsListProps> = ({ logs, onEdit, onDelete }) => {
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
|
||||
const { unitSystem, convertDistance, convertVolume } = useUnits();
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [logToDelete, setLogToDelete] = useState<FuelLogResponse | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
// Precompute previous odometer per log for delta calculations
|
||||
const prevOdoById = useMemo(() => {
|
||||
const map = new Map<string, number | undefined>();
|
||||
if (!Array.isArray(logs)) return map;
|
||||
const asc = [...logs].sort((a, b) => new Date(a.dateTime).getTime() - new Date(b.dateTime).getTime());
|
||||
let lastOdo: number | undefined = undefined;
|
||||
for (const l of asc) {
|
||||
map.set(l.id, lastOdo);
|
||||
if (typeof l.odometerReading === 'number' && !isNaN(l.odometerReading)) {
|
||||
lastOdo = l.odometerReading;
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [logs]);
|
||||
|
||||
const handleDeleteClick = (log: FuelLogResponse) => {
|
||||
setLogToDelete(log);
|
||||
setDeleteDialogOpen(true);
|
||||
@@ -110,6 +127,31 @@ export const FuelLogsList: React.FC<FuelLogsListProps> = ({ logs, onEdit, onDele
|
||||
? `Trip: ${log.tripDistance}`
|
||||
: 'No distance';
|
||||
|
||||
// Compute local efficiency if not provided
|
||||
let localEffLabel: string | null = null;
|
||||
try {
|
||||
const gallons = typeof log.fuelUnits === 'number' ? log.fuelUnits : undefined;
|
||||
let miles: number | undefined =
|
||||
typeof log.tripDistance === 'number' && !isNaN(log.tripDistance)
|
||||
? log.tripDistance
|
||||
: undefined;
|
||||
if (miles === undefined && typeof log.odometerReading === 'number') {
|
||||
const prev = prevOdoById.get(log.id);
|
||||
if (typeof prev === 'number' && log.odometerReading > prev) {
|
||||
miles = log.odometerReading - prev;
|
||||
}
|
||||
}
|
||||
if (typeof miles === 'number' && typeof gallons === 'number' && gallons > 0) {
|
||||
if (unitSystem === 'metric') {
|
||||
const km = convertDistance(miles);
|
||||
const liters = convertVolume(gallons);
|
||||
if (liters > 0) localEffLabel = `${(km / liters).toFixed(1)} km/L`;
|
||||
} else {
|
||||
localEffLabel = `${(miles / gallons).toFixed(1)} MPG`;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
key={log.id}
|
||||
@@ -133,18 +175,19 @@ export const FuelLogsList: React.FC<FuelLogsListProps> = ({ logs, onEdit, onDele
|
||||
secondary={`${fuelUnits} @ $${costPerUnit} • ${distanceText}`}
|
||||
sx={{ flex: 1, minWidth: 0 }}
|
||||
/>
|
||||
{log.efficiency &&
|
||||
typeof log.efficiency === 'number' &&
|
||||
!isNaN(log.efficiency) &&
|
||||
log.efficiencyLabel && (
|
||||
{(log.efficiency && typeof log.efficiency === 'number' && !isNaN(log.efficiency) && log.efficiencyLabel) || localEffLabel ? (
|
||||
<Box sx={{ mr: isMobile ? 0 : 1 }}>
|
||||
<Chip
|
||||
label={`${log.efficiency.toFixed(1)} ${log.efficiencyLabel}`}
|
||||
label={
|
||||
log.efficiency && log.efficiencyLabel
|
||||
? `${log.efficiency.toFixed(1)} ${log.efficiencyLabel}`
|
||||
: localEffLabel || ''
|
||||
}
|
||||
size="small"
|
||||
color="primary"
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
) : null}
|
||||
</Box>
|
||||
|
||||
<Box sx={{
|
||||
@@ -238,4 +281,3 @@ export const FuelLogsList: React.FC<FuelLogsListProps> = ({ logs, onEdit, onDele
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -4,11 +4,10 @@ import { UnitSystem } from '../types/fuel-logs.types';
|
||||
|
||||
export const UnitSystemDisplay: React.FC<{ unitSystem?: UnitSystem; showLabel?: string }> = ({ unitSystem, showLabel }) => {
|
||||
if (!unitSystem) return null;
|
||||
const label = unitSystem === 'imperial' ? 'Imperial (miles, gallons, MPG)' : 'Metric (km, liters, L/100km)';
|
||||
const label = unitSystem === 'imperial' ? 'Imperial (miles, gallons, MPG)' : 'Metric (km, liters, km/L)';
|
||||
return (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{showLabel ? `${showLabel} ` : ''}{label}
|
||||
</Typography>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { Box, Typography, Button, Card, CardContent, Divider, FormControl, InputLabel, Select, MenuItem, List, ListItem, ListItemText } from '@mui/material';
|
||||
import { Box, Typography, Button, Card, CardContent, Divider, FormControl, InputLabel, Select, MenuItem, List, ListItem } from '@mui/material';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { Vehicle } from '../types/vehicles.types';
|
||||
import { useFuelLogs } from '../../fuel-logs/hooks/useFuelLogs';
|
||||
@@ -57,6 +57,7 @@ export const VehicleDetailMobile: React.FC<VehicleDetailMobileProps> = ({
|
||||
const { fuelLogs, isLoading: isFuelLoading } = useFuelLogs(vehicle.id);
|
||||
const queryClient = useQueryClient();
|
||||
const [editingLog, setEditingLog] = useState<FuelLogResponse | null>(null);
|
||||
// Unit conversions are now handled by the backend
|
||||
|
||||
type VehicleRecord = {
|
||||
id: string;
|
||||
@@ -64,19 +65,71 @@ export const VehicleDetailMobile: React.FC<VehicleDetailMobileProps> = ({
|
||||
date: string; // ISO
|
||||
summary: string;
|
||||
amount?: string;
|
||||
secondary?: string;
|
||||
};
|
||||
|
||||
const records: VehicleRecord[] = useMemo(() => {
|
||||
const list: VehicleRecord[] = [];
|
||||
if (fuelLogs && Array.isArray(fuelLogs)) {
|
||||
// Build a map of prior odometer readings to compute trip distance when missing
|
||||
const logsAsc = [...(fuelLogs as FuelLogResponse[])].sort(
|
||||
(a, b) => new Date(a.dateTime).getTime() - new Date(b.dateTime).getTime()
|
||||
);
|
||||
const prevOdoById = new Map<string, number | undefined>();
|
||||
let lastOdo: number | undefined = undefined;
|
||||
for (const l of logsAsc) {
|
||||
prevOdoById.set(l.id, lastOdo);
|
||||
if (typeof l.odometerReading === 'number' && !isNaN(l.odometerReading)) {
|
||||
lastOdo = l.odometerReading;
|
||||
}
|
||||
}
|
||||
|
||||
for (const log of fuelLogs as FuelLogResponse[]) {
|
||||
const parts: string[] = [];
|
||||
if (log.fuelUnits) parts.push(`${Number(log.fuelUnits).toFixed(3)} units`);
|
||||
if (log.fuelType) parts.push(`${log.fuelType}${log.fuelGrade ? ' ' + log.fuelGrade : ''}`);
|
||||
if (log.efficiencyLabel) parts.push(log.efficiencyLabel);
|
||||
const summary = parts.join(' • ');
|
||||
// Use efficiency from API response (backend calculates this properly)
|
||||
let efficiency = '';
|
||||
if (typeof log.efficiency === 'number' && log.efficiency > 0) {
|
||||
// DEBUG: Log what the backend is actually returning
|
||||
console.log('🔍 Fuel Log API Response:', {
|
||||
efficiency: log.efficiency,
|
||||
efficiencyLabel: log.efficiencyLabel,
|
||||
logId: log.id
|
||||
});
|
||||
// Backend returns efficiency in correct units based on user preference
|
||||
efficiency = `${log.efficiencyLabel || 'MPG'}: ${log.efficiency.toFixed(3)}`;
|
||||
}
|
||||
|
||||
// Secondary line components
|
||||
const secondaryParts: string[] = [];
|
||||
|
||||
// Grade label (prefer grade only)
|
||||
if (log.fuelGrade) {
|
||||
secondaryParts.push(`Grade: ${log.fuelGrade}`);
|
||||
} else if (log.fuelType) {
|
||||
const ft = String(log.fuelType);
|
||||
secondaryParts.push(ft.charAt(0).toUpperCase() + ft.slice(1));
|
||||
}
|
||||
|
||||
// Date
|
||||
secondaryParts.push(new Date(log.dateTime).toLocaleDateString());
|
||||
|
||||
// Type
|
||||
secondaryParts.push('Fuel Log');
|
||||
|
||||
const summary = efficiency; // Primary line shows MPG/km/L from backend
|
||||
const secondary = secondaryParts.join(' • '); // Secondary line shows Grade • Date • Type
|
||||
const amount = (typeof log.totalCost === 'number') ? `$${log.totalCost.toFixed(2)}` : undefined;
|
||||
list.push({ id: log.id, type: 'Fuel Logs', date: log.dateTime, summary, amount });
|
||||
|
||||
// DEBUG: Log the final display values
|
||||
console.log('🎯 Display Summary:', { summary, efficiency, logId: log.id });
|
||||
|
||||
list.push({
|
||||
id: log.id,
|
||||
type: 'Fuel Logs',
|
||||
date: log.dateTime,
|
||||
summary,
|
||||
amount,
|
||||
secondary
|
||||
});
|
||||
}
|
||||
}
|
||||
return list.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
||||
@@ -212,13 +265,21 @@ export const VehicleDetailMobile: React.FC<VehicleDetailMobileProps> = ({
|
||||
<List disablePadding>
|
||||
{filteredRecords.map(rec => (
|
||||
<ListItem key={rec.id} divider button onClick={() => openEditLog(rec.id, rec.type)}>
|
||||
<ListItemText
|
||||
primary={rec.summary || new Date(rec.date).toLocaleString()}
|
||||
secondary={`${new Date(rec.date).toLocaleString()} • ${rec.type}`}
|
||||
/>
|
||||
<Typography sx={{ ml: 2 }} color="text.primary">
|
||||
{rec.amount || '—'}
|
||||
</Typography>
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
{/* Primary line: MPG/km-L and amount */}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="body1" color="text.primary">
|
||||
{rec.summary || 'MPG: —'}
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.primary">
|
||||
{rec.amount || '—'}
|
||||
</Typography>
|
||||
</Box>
|
||||
{/* Secondary line: Grade • Date • Type */}
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.25 }}>
|
||||
{rec.secondary || `${new Date(rec.date).toLocaleDateString()} • ${rec.type}`}
|
||||
</Typography>
|
||||
</Box>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import React, { useMemo, useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { Box, Typography, Button as MuiButton, Divider, FormControl, InputLabel, Select, MenuItem, Table, TableHead, TableRow, TableCell, TableBody } from '@mui/material';
|
||||
import { Box, Typography, Button as MuiButton, Divider, FormControl, InputLabel, Select, MenuItem, Table, TableHead, TableRow, TableCell, TableBody, Dialog, DialogTitle, DialogContent, useMediaQuery } from '@mui/material';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
@@ -17,6 +17,8 @@ import { VehicleForm } from '../components/VehicleForm';
|
||||
import { useFuelLogs } from '../../fuel-logs/hooks/useFuelLogs';
|
||||
import { FuelLogResponse, UpdateFuelLogRequest } from '../../fuel-logs/types/fuel-logs.types';
|
||||
import { FuelLogEditDialog } from '../../fuel-logs/components/FuelLogEditDialog';
|
||||
import { FuelLogForm } from '../../fuel-logs/components/FuelLogForm';
|
||||
// Unit conversions now handled by backend
|
||||
import { fuelLogsApi } from '../../fuel-logs/api/fuel-logs.api';
|
||||
|
||||
const DetailField: React.FC<{
|
||||
@@ -49,6 +51,9 @@ export const VehicleDetailPage: React.FC = () => {
|
||||
const { fuelLogs, isLoading: isFuelLoading } = useFuelLogs(id);
|
||||
const queryClient = useQueryClient();
|
||||
const [editingLog, setEditingLog] = useState<FuelLogResponse | null>(null);
|
||||
const [showAddDialog, setShowAddDialog] = useState(false);
|
||||
const isSmallScreen = useMediaQuery('(max-width:600px)');
|
||||
// Unit conversions now handled by backend
|
||||
|
||||
// Define records list hooks BEFORE any early returns to keep hooks order stable
|
||||
type VehicleRecord = {
|
||||
@@ -62,11 +67,35 @@ export const VehicleDetailPage: React.FC = () => {
|
||||
const records: VehicleRecord[] = useMemo(() => {
|
||||
const list: VehicleRecord[] = [];
|
||||
if (fuelLogs && Array.isArray(fuelLogs)) {
|
||||
// Build a map of prior odometer readings to compute trip distance when missing
|
||||
const logsAsc = [...(fuelLogs as FuelLogResponse[])].sort(
|
||||
(a, b) => new Date(a.dateTime).getTime() - new Date(b.dateTime).getTime()
|
||||
);
|
||||
const prevOdoById = new Map<string, number | undefined>();
|
||||
let lastOdo: number | undefined = undefined;
|
||||
for (const l of logsAsc) {
|
||||
prevOdoById.set(l.id, lastOdo);
|
||||
if (typeof l.odometerReading === 'number' && !isNaN(l.odometerReading)) {
|
||||
lastOdo = l.odometerReading;
|
||||
}
|
||||
}
|
||||
|
||||
for (const log of fuelLogs as FuelLogResponse[]) {
|
||||
const parts: string[] = [];
|
||||
if (log.fuelUnits) parts.push(`${Number(log.fuelUnits).toFixed(3)} units`);
|
||||
if (log.fuelType) parts.push(`${log.fuelType}${log.fuelGrade ? ' ' + log.fuelGrade : ''}`);
|
||||
if (log.efficiencyLabel) parts.push(log.efficiencyLabel);
|
||||
|
||||
// Efficiency: Use backend calculation (primary display)
|
||||
if (typeof log.efficiency === 'number' && log.efficiency > 0) {
|
||||
parts.push(`${log.efficiencyLabel || 'MPG'}: ${log.efficiency.toFixed(3)}`);
|
||||
}
|
||||
|
||||
// Grade label (secondary display)
|
||||
if (log.fuelGrade) {
|
||||
parts.push(`Grade: ${log.fuelGrade}`);
|
||||
} else if (log.fuelType) {
|
||||
const ft = String(log.fuelType);
|
||||
parts.push(ft.charAt(0).toUpperCase() + ft.slice(1));
|
||||
}
|
||||
|
||||
const summary = parts.join(' • ');
|
||||
const amount = (typeof log.totalCost === 'number') ? `$${log.totalCost.toFixed(2)}` : undefined;
|
||||
list.push({ id: log.id, type: 'Fuel Logs', date: log.dateTime, summary, amount });
|
||||
@@ -244,6 +273,7 @@ export const VehicleDetailPage: React.FC = () => {
|
||||
variant="contained"
|
||||
startIcon={<LocalGasStationIcon />}
|
||||
sx={{ borderRadius: '999px' }}
|
||||
onClick={() => setShowAddDialog(true)}
|
||||
>
|
||||
Add Fuel Log
|
||||
</MuiButton>
|
||||
@@ -343,7 +373,7 @@ export const VehicleDetailPage: React.FC = () => {
|
||||
)}
|
||||
{!isFuelLoading && filteredRecords.map((rec) => (
|
||||
<TableRow key={rec.id} hover sx={{ cursor: 'pointer' }} onClick={() => handleRowClick(rec.id, rec.type)}>
|
||||
<TableCell>{new Date(rec.date).toLocaleString()}</TableCell>
|
||||
<TableCell>{new Date(rec.date).toLocaleDateString()}</TableCell>
|
||||
<TableCell>{rec.type}</TableCell>
|
||||
<TableCell>{rec.summary}</TableCell>
|
||||
<TableCell align="right">{rec.amount || '—'}</TableCell>
|
||||
@@ -360,6 +390,33 @@ export const VehicleDetailPage: React.FC = () => {
|
||||
onClose={handleCloseEdit}
|
||||
onSave={handleSaveEdit}
|
||||
/>
|
||||
|
||||
{/* Add Fuel Log Dialog */}
|
||||
<Dialog
|
||||
open={showAddDialog}
|
||||
onClose={() => setShowAddDialog(false)}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
fullScreen={isSmallScreen}
|
||||
PaperProps={{
|
||||
sx: { maxHeight: '90vh' }
|
||||
}}
|
||||
>
|
||||
<DialogTitle>Add Fuel Log</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<FuelLogForm
|
||||
initial={{ vehicleId: vehicle?.id }}
|
||||
onSuccess={() => {
|
||||
setShowAddDialog(false);
|
||||
// Refresh fuel logs data
|
||||
queryClient.invalidateQueries({ queryKey: ['fuelLogs', vehicle?.id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['fuelLogs'] });
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Card>
|
||||
</Box>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user