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

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