Gas Station Feature

This commit is contained in:
Eric Gullickson
2025-11-04 18:46:46 -06:00
parent d8d0ada83f
commit 5dc58d73b9
61 changed files with 12952 additions and 52 deletions

View File

@@ -10,10 +10,11 @@ import { VehicleSelector } from './VehicleSelector';
import { DistanceInput } from './DistanceInput';
import { FuelTypeSelector } from './FuelTypeSelector';
import { UnitSystemDisplay } from './UnitSystemDisplay';
import { LocationInput } from './LocationInput';
import { StationPicker } from './StationPicker';
import { CostCalculator } from './CostCalculator';
import { useFuelLogs } from '../hooks/useFuelLogs';
import { useUserSettings } from '../hooks/useUserSettings';
import { useGeolocation } from '../../stations/hooks/useGeolocation';
import { CreateFuelLogRequest, FuelType } from '../types/fuel-logs.types';
const schema = z.object({
@@ -39,6 +40,9 @@ const FuelLogFormComponent: React.FC<{ onSuccess?: () => void; initial?: Partial
const [useOdometer, setUseOdometer] = useState(false);
const formInitialized = useRef(false);
// Get user location for nearby station search
const { coordinates: userLocation } = useGeolocation();
const { control, handleSubmit, watch, setValue, reset, formState: { errors, isValid } } = useForm<CreateFuelLogRequest>({
resolver: zodResolver(schema),
mode: 'onChange',
@@ -282,7 +286,12 @@ const FuelLogFormComponent: React.FC<{ onSuccess?: () => void; initial?: Partial
</Grid>
<Grid item xs={12}>
<Controller name="locationData" control={control} render={({ field }) => (
<LocationInput value={field.value as any} onChange={field.onChange as any} placeholder="Station location (optional)" />
<StationPicker
value={field.value as any}
onChange={field.onChange as any}
userLocation={userLocation}
placeholder="Station location (optional)"
/>
)} />
</Grid>
<Grid item xs={12}>

View File

@@ -0,0 +1,309 @@
/**
* @ai-summary Autocomplete component for selecting gas stations
* Integrates with saved stations and nearby search
*/
import React, { useState, useCallback, useMemo, useEffect } from 'react';
import {
Autocomplete,
TextField,
Box,
Typography,
CircularProgress,
InputAdornment
} from '@mui/material';
import {
Bookmark as BookmarkIcon,
LocationOn as LocationIcon
} from '@mui/icons-material';
import { useSavedStations } from '../../stations/hooks/useSavedStations';
import { useStationsSearch } from '../../stations/hooks/useStationsSearch';
import { Station, SavedStation, GeolocationCoordinates } from '../../stations/types/stations.types';
import { LocationData } from '../types/fuel-logs.types';
interface StationPickerProps {
/** Current location data value */
value?: LocationData;
/** Callback when station is selected */
onChange: (value?: LocationData) => void;
/** User's current location (optional) */
userLocation?: GeolocationCoordinates | null;
/** Placeholder text */
placeholder?: string;
/** Error message */
error?: string;
}
interface StationOption {
type: 'saved' | 'nearby' | 'manual';
station?: Station | SavedStation;
label: string;
group: string;
}
/**
* Format distance from meters to user-friendly string
*/
function formatDistance(meters: number): string {
const miles = meters / 1609.34;
if (miles < 0.1) return '< 0.1 mi';
if (miles < 10) return `${miles.toFixed(1)} mi`;
return `${Math.round(miles)} mi`;
}
/**
* Check if station is saved
*/
function isSavedStation(station: Station | SavedStation): station is SavedStation {
return 'userId' in station;
}
/**
* StationPicker Component
*
* Autocomplete component that allows users to:
* - Select from saved stations
* - Search nearby stations (if location available)
* - Enter manual text input
*
* Features:
* - Grouped options (Saved / Nearby)
* - Debounced search (300ms)
* - Loading indicators
* - Fallback to text input on API failure
* - Distance display
* - Bookmark icons for saved stations
*/
export const StationPicker: React.FC<StationPickerProps> = ({
value,
onChange,
userLocation,
placeholder = 'Station location (optional)',
error
}) => {
const [inputValue, setInputValue] = useState(value?.stationName || '');
const [searchTrigger, setSearchTrigger] = useState(0);
// Fetch saved stations
const { data: savedStations, isPending: savedLoading } = useSavedStations();
// Search mutation for nearby stations
const { mutate: searchStations, data: nearbyStations, isPending: searchLoading } = useStationsSearch();
// Debounced search effect
useEffect(() => {
if (!userLocation || !inputValue || inputValue.length < 2) {
return;
}
const timer = setTimeout(() => {
setSearchTrigger((prev) => prev + 1);
}, 300);
return () => clearTimeout(timer);
}, [inputValue, userLocation]);
// Execute search when trigger changes
useEffect(() => {
if (searchTrigger > 0 && userLocation) {
searchStations({
latitude: userLocation.latitude,
longitude: userLocation.longitude,
radius: 8000 // 5 miles in meters
});
}
}, [searchTrigger, userLocation, searchStations]);
// Build options list
const options: StationOption[] = useMemo(() => {
const opts: StationOption[] = [];
// Add saved stations first
if (savedStations && savedStations.length > 0) {
savedStations.forEach((station) => {
opts.push({
type: 'saved',
station,
label: station.nickname || station.name,
group: 'Saved Stations'
});
});
}
// Add nearby stations
if (nearbyStations && nearbyStations.length > 0) {
// Filter out stations already in saved list
const savedPlaceIds = new Set(savedStations?.map((s) => s.placeId) || []);
nearbyStations
.filter((station) => !savedPlaceIds.has(station.placeId))
.forEach((station) => {
opts.push({
type: 'nearby',
station,
label: station.name,
group: 'Nearby Stations'
});
});
}
return opts;
}, [savedStations, nearbyStations]);
// Handle option selection
const handleChange = useCallback(
(_event: React.SyntheticEvent, newValue: StationOption | string | null) => {
if (!newValue) {
onChange(undefined);
setInputValue('');
return;
}
// Manual text input (freeSolo)
if (typeof newValue === 'string') {
onChange({
stationName: newValue
});
setInputValue(newValue);
return;
}
// Selected from options
const { station } = newValue;
if (station) {
onChange({
stationName: station.name,
address: station.address,
googlePlaceId: station.placeId,
coordinates: {
latitude: station.latitude,
longitude: station.longitude
}
});
setInputValue(station.name);
}
},
[onChange]
);
// Handle input text change
const handleInputChange = useCallback((_event: React.SyntheticEvent, newInputValue: string) => {
setInputValue(newInputValue);
}, []);
// Custom option rendering
const renderOption = useCallback(
(props: React.HTMLAttributes<HTMLLIElement>, option: StationOption | string) => {
// Handle manual text input option
if (typeof option === 'string') {
return (
<li {...props}>
<Typography variant="body2">{option}</Typography>
</li>
);
}
const { station, type } = option;
if (!station) return null;
const isSaved = isSavedStation(station);
const displayName = isSaved && station.nickname ? station.nickname : station.name;
const distance = station.distance ? formatDistance(station.distance) : null;
return (
<li {...props}>
<Box display="flex" alignItems="center" width="100%" gap={1}>
{type === 'saved' && (
<BookmarkIcon fontSize="small" color="primary" />
)}
{type === 'nearby' && (
<LocationIcon fontSize="small" color="action" />
)}
<Box flex={1} minWidth={0}>
<Typography variant="body2" noWrap>
{displayName}
</Typography>
<Typography variant="caption" color="text.secondary" noWrap>
{distance && `${distance}`}
{station.address}
</Typography>
</Box>
</Box>
</li>
);
},
[]
);
// Group options by category
const groupBy = useCallback((option: StationOption | string) => {
if (typeof option === 'string') return '';
return option.group;
}, []);
// Get option label
const getOptionLabel = useCallback((option: StationOption | string) => {
if (typeof option === 'string') return option;
return option.label;
}, []);
// Loading state
const isLoading = savedLoading || searchLoading;
return (
<Autocomplete
freeSolo
options={options}
value={null} // Controlled by inputValue
inputValue={inputValue}
onChange={handleChange}
onInputChange={handleInputChange}
groupBy={groupBy}
getOptionLabel={getOptionLabel}
renderOption={renderOption}
filterOptions={(opts) => opts} // Don't filter, we control options
loading={isLoading}
loadingText="Searching stations..."
noOptionsText={
userLocation
? 'No stations found. Type to enter manually.'
: 'Enable location to search nearby stations.'
}
renderInput={(params) => (
<TextField
{...params}
label="Location (optional)"
placeholder={placeholder}
error={!!error}
helperText={error || (userLocation ? 'Search saved or nearby stations' : 'Type station name')}
InputProps={{
...params.InputProps,
endAdornment: (
<>
{isLoading && (
<InputAdornment position="end">
<CircularProgress size={20} />
</InputAdornment>
)}
{params.InputProps.endAdornment}
</>
)
}}
/>
)}
sx={{
'& .MuiAutocomplete-groupLabel': {
fontWeight: 600,
backgroundColor: 'grey.100',
fontSize: '0.75rem',
textTransform: 'uppercase',
letterSpacing: '0.5px'
},
'& .MuiAutocomplete-option': {
minHeight: '44px', // Mobile touch target
padding: '8px 16px'
}
}}
/>
);
};