UX Improvements

This commit is contained in:
Eric Gullickson
2025-09-26 14:45:03 -05:00
parent 56443d5b2f
commit 2e1b588270
13 changed files with 389 additions and 97 deletions

View File

@@ -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": []
}

View File

@@ -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 };
}

View File

@@ -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> {

View File

@@ -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;
}

View File

@@ -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",

View File

@@ -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;

View File

@@ -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';
}

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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
</>
);
};

View File

@@ -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>
);
};

View File

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

View File

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