Google Maps Bug

This commit is contained in:
Eric Gullickson
2025-11-08 12:17:29 -06:00
parent efbe9ba3c0
commit bb4a356b9e
39 changed files with 1175 additions and 449 deletions

View File

@@ -19,6 +19,12 @@ import {
import { useSavedStations } from '../../stations/hooks/useSavedStations';
import { useStationsSearch } from '../../stations/hooks/useStationsSearch';
import { Station, SavedStation, GeolocationCoordinates } from '../../stations/types/stations.types';
import {
resolveSavedStationAddress,
resolveSavedStationCoordinates,
resolveSavedStationName,
resolveSavedStationPlaceId
} from '../../stations/utils/savedStations';
import { LocationData } from '../types/fuel-logs.types';
interface StationPickerProps {
@@ -121,10 +127,18 @@ export const StationPicker: React.FC<StationPickerProps> = ({
// Add saved stations first
if (savedStations && savedStations.length > 0) {
savedStations.forEach((station) => {
const placeId = resolveSavedStationPlaceId(station);
if (!placeId) {
return;
}
const normalizedStation =
station.placeId === placeId ? station : { ...station, placeId };
opts.push({
type: 'saved',
station,
label: station.nickname || station.name,
station: normalizedStation,
label: resolveSavedStationName(normalizedStation),
group: 'Saved Stations'
});
});
@@ -133,7 +147,11 @@ export const StationPicker: React.FC<StationPickerProps> = ({
// Add nearby stations
if (nearbyStations && nearbyStations.length > 0) {
// Filter out stations already in saved list
const savedPlaceIds = new Set(savedStations?.map((s) => s.placeId) || []);
const savedPlaceIds = new Set(
(savedStations || [])
.map((station) => resolveSavedStationPlaceId(station))
.filter((id): id is string => Boolean(id))
);
nearbyStations
.filter((station) => !savedPlaceIds.has(station.placeId))
@@ -171,16 +189,37 @@ export const StationPicker: React.FC<StationPickerProps> = ({
// 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
const saved = isSavedStation(station);
const placeId = saved
? resolveSavedStationPlaceId(station) || station.placeId
: station.placeId;
const name = saved ? resolveSavedStationName(station) : station.name;
const address = saved ? resolveSavedStationAddress(station) : station.address;
let latitude = station.latitude;
let longitude = station.longitude;
if ((latitude === undefined || longitude === undefined) && saved) {
const coords = resolveSavedStationCoordinates(station);
if (coords) {
latitude = coords.latitude;
longitude = coords.longitude;
}
}
onChange({
stationName: name,
address,
googlePlaceId: placeId,
coordinates:
latitude !== undefined && longitude !== undefined
? {
latitude,
longitude
}
: undefined
});
setInputValue(station.name);
setInputValue(name);
}
},
[onChange]

View File

@@ -595,17 +595,18 @@ The Gas Stations feature uses MotoVaultPro's K8s-aligned runtime configuration p
### Accessing Configuration
```typescript
import { getGoogleMapsApiKey } from '@/core/config/config.types';
import { getGoogleMapsApiKey, getGoogleMapsMapId } from '@/core/config/config.types';
export function MyComponent() {
const apiKey = getGoogleMapsApiKey();
const mapId = getGoogleMapsMapId();
if (!apiKey) {
return <div>Google Maps API key not configured</div>;
if (!apiKey || !mapId) {
return <div>Google Maps configuration not complete</div>;
}
// Use API key
return <MapComponent apiKey={apiKey} />;
// Use API key + map id
return <StationMap apiKey={apiKey} mapId={mapId} />;
}
```
@@ -617,9 +618,11 @@ For local development (Vite dev server):
# Set up secrets
mkdir -p ./secrets/app
echo "YOUR_API_KEY" > ./secrets/app/google-maps-api-key.txt
echo "YOUR_MAP_ID" > ./secrets/app/google-maps-map-id.txt
# Alternatively, set environment variable
export VITE_GOOGLE_MAPS_API_KEY=YOUR_API_KEY
export VITE_GOOGLE_MAPS_MAP_ID=YOUR_MAP_ID
```
See `/frontend/docs/RUNTIME-CONFIG.md` for complete documentation.

View File

@@ -0,0 +1,55 @@
/**
* @ai-summary Selector for marking 93 octane availability on a saved station
*/
import React from 'react';
import {
FormControl,
InputLabel,
MenuItem,
Select,
SelectChangeEvent,
FormHelperText
} from '@mui/material';
import { OctanePreference } from '../types/stations.types';
interface OctanePreferenceSelectorProps {
value: OctanePreference;
onChange: (value: OctanePreference) => void;
disabled?: boolean;
helperText?: string;
label?: string;
}
const LABEL_ID = 'octane-preference-select';
export const OctanePreferenceSelector: React.FC<OctanePreferenceSelectorProps> = ({
value,
onChange,
disabled = false,
helperText,
label = '93 Octane'
}) => {
const handleChange = (event: SelectChangeEvent) => {
onChange(event.target.value as OctanePreference);
};
return (
<FormControl size="small" fullWidth disabled={disabled}>
<InputLabel id={LABEL_ID}>{label}</InputLabel>
<Select
labelId={LABEL_ID}
value={value}
label={label}
onChange={handleChange}
>
<MenuItem value="none">Not set</MenuItem>
<MenuItem value="with_ethanol">93 w/ Ethanol</MenuItem>
<MenuItem value="ethanol_free">93 Ethanol Free</MenuItem>
</Select>
{helperText && <FormHelperText>{helperText}</FormHelperText>}
</FormControl>
);
};
export default OctanePreferenceSelector;

View File

@@ -1,5 +1,5 @@
/**
* @ai-summary List of user's saved/favorited stations
* @ai-summary List of user's saved/favorited stations with octane metadata editing
*/
import React from 'react';
@@ -18,8 +18,15 @@ import {
Skeleton
} from '@mui/material';
import DeleteIcon from '@mui/icons-material/Delete';
import { SavedStation } from '../types/stations.types';
import { OctanePreference, SavedStation } from '../types/stations.types';
import { formatDistance } from '../utils/distance';
import {
getOctanePreferenceFromFlags,
resolveSavedStationAddress,
resolveSavedStationName,
resolveSavedStationPlaceId
} from '../utils/savedStations';
import { OctanePreferenceSelector } from './OctanePreferenceSelector';
interface SavedStationsListProps {
stations: SavedStation[];
@@ -27,19 +34,19 @@ interface SavedStationsListProps {
error?: string | null;
onSelectStation?: (station: SavedStation) => void;
onDeleteStation?: (placeId: string) => void;
onOctanePreferenceChange?: (placeId: string, preference: OctanePreference) => void;
octaneUpdatingId?: string | null;
}
/**
* Vertical list of saved stations with delete option
*/
export const SavedStationsList: React.FC<SavedStationsListProps> = ({
stations,
loading = false,
error = null,
onSelectStation,
onDeleteStation
onDeleteStation,
onOctanePreferenceChange,
octaneUpdatingId
}) => {
// Loading state
if (loading) {
return (
<List sx={{ width: '100%', bgcolor: 'background.paper' }}>
@@ -56,7 +63,6 @@ export const SavedStationsList: React.FC<SavedStationsListProps> = ({
);
}
// Error state
if (error) {
return (
<Box sx={{ padding: 2 }}>
@@ -65,7 +71,6 @@ export const SavedStationsList: React.FC<SavedStationsListProps> = ({
);
}
// Empty state
if (stations.length === 0) {
return (
<Box sx={{ textAlign: 'center', padding: 3 }}>
@@ -84,97 +89,118 @@ export const SavedStationsList: React.FC<SavedStationsListProps> = ({
bgcolor: 'background.paper'
}}
>
{stations.map((station, index) => (
<React.Fragment key={station.placeId}>
<ListItem
disablePadding
sx={{
'&:hover': {
backgroundColor: 'action.hover'
}
}}
>
<ListItemButton
onClick={() => onSelectStation?.(station)}
sx={{ flex: 1 }}
{stations.map((station, index) => {
const placeId = resolveSavedStationPlaceId(station);
const octanePreference = getOctanePreferenceFromFlags(
station.has93Octane ?? false,
station.has93OctaneEthanolFree ?? false
);
return (
<React.Fragment key={placeId ?? station.id}>
<ListItem
disablePadding
sx={{
'&:hover': {
backgroundColor: 'action.hover'
}
}}
>
<ListItemText
primary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography
variant="subtitle2"
component="span"
sx={{ fontWeight: 600 }}
>
{station.nickname || station.name}
</Typography>
{station.isFavorite && (
<Chip
label="Favorite"
size="small"
color="warning"
variant="filled"
/>
)}
</Box>
}
secondary={
<Box
sx={{
marginTop: 0.5,
display: 'flex',
flexDirection: 'column',
gap: 0.5
}}
>
<Typography variant="body2" color="textSecondary">
{station.address}
</Typography>
{station.notes && (
<Typography
variant="body2"
color="textSecondary"
sx={{
overflow: 'hidden',
textOverflow: 'ellipsis',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical'
}}
>
{station.notes}
</Typography>
)}
{station.distance !== undefined && (
<Typography variant="caption" color="textSecondary">
{formatDistance(station.distance)} away
</Typography>
)}
</Box>
}
/>
</ListItemButton>
<ListItemSecondaryAction>
<IconButton
edge="end"
aria-label="delete"
onClick={(e) => {
e.stopPropagation();
onDeleteStation?.(station.placeId);
}}
title="Delete saved station"
sx={{
minWidth: '44px',
minHeight: '44px'
}}
<ListItemButton
onClick={() => onSelectStation?.(station)}
sx={{ flex: 1 }}
>
<DeleteIcon />
</IconButton>
</ListItemSecondaryAction>
</ListItem>
{index < stations.length - 1 && <Divider />}
</React.Fragment>
))}
<ListItemText
primary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography
variant="subtitle2"
component="span"
sx={{ fontWeight: 600 }}
>
{resolveSavedStationName(station)}
</Typography>
{station.isFavorite && (
<Chip
label="Favorite"
size="small"
color="warning"
variant="filled"
/>
)}
</Box>
}
secondary={
<Box
sx={{
marginTop: 0.5,
display: 'flex',
flexDirection: 'column',
gap: 0.5
}}
>
<Typography variant="body2" color="textSecondary">
{resolveSavedStationAddress(station)}
</Typography>
{station.notes && (
<Typography
variant="body2"
color="textSecondary"
sx={{
overflow: 'hidden',
textOverflow: 'ellipsis',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical'
}}
>
{station.notes}
</Typography>
)}
{station.distance !== undefined && (
<Typography variant="caption" color="textSecondary">
{formatDistance(station.distance)} away
</Typography>
)}
{placeId && (
<Box sx={{ mt: 1 }}>
<OctanePreferenceSelector
value={octanePreference}
onChange={(value) => onOctanePreferenceChange?.(placeId, value)}
disabled={!onOctanePreferenceChange || octaneUpdatingId === placeId}
helperText="Show on search cards"
/>
</Box>
)}
</Box>
}
/>
</ListItemButton>
<ListItemSecondaryAction>
<IconButton
edge="end"
aria-label="delete"
onClick={(e) => {
e.stopPropagation();
if (placeId) {
onDeleteStation?.(placeId);
}
}}
title="Delete saved station"
sx={{
minWidth: '44px',
minHeight: '44px'
}}
disabled={!placeId}
>
<DeleteIcon />
</IconButton>
</ListItemSecondaryAction>
</ListItem>
{index < stations.length - 1 && <Divider />}
</React.Fragment>
);
})}
</List>
);
};

View File

@@ -16,12 +16,13 @@ import {
import BookmarkIcon from '@mui/icons-material/Bookmark';
import BookmarkBorderIcon from '@mui/icons-material/BookmarkBorder';
import DirectionsIcon from '@mui/icons-material/Directions';
import { Station } from '../types/stations.types';
import { Station, SavedStation } from '../types/stations.types';
import { formatDistance } from '../utils/distance';
interface StationCardProps {
station: Station;
isSaved: boolean;
savedStation?: SavedStation;
onSave?: (station: Station) => void;
onDelete?: (placeId: string) => void;
onSelect?: (station: Station) => void;
@@ -34,6 +35,7 @@ interface StationCardProps {
export const StationCard: React.FC<StationCardProps> = ({
station,
isSaved,
savedStation,
onSave,
onDelete,
onSelect
@@ -53,6 +55,19 @@ export const StationCard: React.FC<StationCardProps> = ({
window.open(mapsUrl, '_blank');
};
const savedMetadata = savedStation
? {
has93Octane: savedStation.has93Octane,
has93OctaneEthanolFree: savedStation.has93OctaneEthanolFree
}
: station.savedMetadata;
const octaneLabel = savedMetadata?.has93Octane
? savedMetadata.has93OctaneEthanolFree
? '93 Octane · Ethanol Free'
: '93 Octane · w/ Ethanol'
: null;
return (
<Card
onClick={() => onSelect?.(station)}
@@ -127,6 +142,16 @@ export const StationCard: React.FC<StationCardProps> = ({
sx={{ marginBottom: 1 }}
/>
)}
{/* 93 Octane metadata */}
{octaneLabel && (
<Chip
label={octaneLabel}
color="success"
size="small"
sx={{ marginTop: 0.5 }}
/>
)}
</CardContent>
{/* Actions */}

View File

@@ -12,6 +12,7 @@ import {
createInfoWindow,
fitBoundsToMarkers
} from '../utils/map-utils';
import { getGoogleMapsMapId } from '@/core/config/config.types';
interface StationMapProps {
stations: Station[];
@@ -41,9 +42,9 @@ export const StationMap: React.FC<StationMapProps> = ({
}) => {
const mapContainer = useRef<HTMLDivElement>(null);
const map = useRef<google.maps.Map | null>(null);
const markers = useRef<google.maps.Marker[]>([]);
const markers = useRef<google.maps.marker.AdvancedMarkerElement[]>([]);
const infoWindows = useRef<google.maps.InfoWindow[]>([]);
const currentLocationMarker = useRef<google.maps.Marker | null>(null);
const currentLocationMarker = useRef<google.maps.marker.AdvancedMarkerElement | null>(null);
const isInitializing = useRef<boolean>(false);
const [isLoading, setIsLoading] = useState(true);
@@ -89,13 +90,28 @@ export const StationMap: React.FC<StationMapProps> = ({
// Create map
const defaultCenter = center || {
lat: currentLocation?.latitude || 37.7749,
lng: currentLocation?.longitude || -122.4194
lat: currentLocation?.latitude || 43.074734,
lng: currentLocation?.longitude || -89.384271
};
if (mapIdRef.current === null) {
const mapId = getGoogleMapsMapId();
if (!mapId) {
console.error(
'[StationMap] Google Maps Map ID is not configured. Add google-maps-map-id secret to enable advanced markers.'
);
setError('Google Maps Map ID is not configured. Please contact support.');
isInitializing.current = false;
setIsLoading(false);
return;
}
mapIdRef.current = mapId;
}
map.current = new maps.Map(mapContainer.current, {
zoom,
center: defaultCenter,
mapId: mapIdRef.current || undefined,
mapTypeControl: true,
streetViewControl: false,
fullscreenControl: true
@@ -180,7 +196,7 @@ export const StationMap: React.FC<StationMapProps> = ({
try {
markers.current.forEach((marker) => {
try {
marker.setMap(null);
marker.map = null;
} catch (e) {
// Ignore individual marker cleanup errors
}
@@ -205,7 +221,7 @@ export const StationMap: React.FC<StationMapProps> = ({
try {
if (currentLocationMarker.current) {
currentLocationMarker.current.setMap(null);
currentLocationMarker.current.map = null;
currentLocationMarker.current = null;
}
} catch (err) {
@@ -232,13 +248,15 @@ export const StationMap: React.FC<StationMapProps> = ({
}
// Clear old markers and info windows
markers.current.forEach((marker) => marker.setMap(null));
markers.current.forEach((marker) => {
marker.map = null;
});
infoWindows.current.forEach((iw) => iw.close());
markers.current = [];
infoWindows.current = [];
getGoogleMapsApi();
let allMarkers: google.maps.Marker[] = [];
let allMarkers: google.maps.marker.AdvancedMarkerElement[] = [];
// Add station markers
stations.forEach((station) => {
@@ -256,7 +274,11 @@ export const StationMap: React.FC<StationMapProps> = ({
infoWindows.current.forEach((iw) => iw.close());
// Open this one
infoWindow.open(map.current, marker);
infoWindow.open({
anchor: marker,
map: map.current!,
shouldFocus: false
});
onMarkerClick?.(station);
});
});
@@ -264,7 +286,7 @@ export const StationMap: React.FC<StationMapProps> = ({
// Add current location marker
if (currentLocation) {
if (currentLocationMarker.current) {
currentLocationMarker.current.setMap(null);
currentLocationMarker.current.map = null;
}
currentLocationMarker.current = createCurrentLocationMarker(
@@ -346,3 +368,4 @@ export const StationMap: React.FC<StationMapProps> = ({
};
export default StationMap;
const mapIdRef = useRef<string | null>(null);

View File

@@ -11,12 +11,13 @@ import {
Alert,
Button
} from '@mui/material';
import { Station } from '../types/stations.types';
import { Station, SavedStation } from '../types/stations.types';
import StationCard from './StationCard';
interface StationsListProps {
stations: Station[];
savedPlaceIds?: Set<string>;
savedStationsMap?: Map<string, SavedStation>;
loading?: boolean;
error?: string | null;
onSaveStation?: (station: Station) => void;
@@ -32,6 +33,7 @@ interface StationsListProps {
export const StationsList: React.FC<StationsListProps> = ({
stations,
savedPlaceIds = new Set(),
savedStationsMap,
loading = false,
error = null,
onSaveStation,
@@ -92,6 +94,7 @@ export const StationsList: React.FC<StationsListProps> = ({
<StationCard
station={station}
isSaved={savedPlaceIds.has(station.placeId)}
savedStation={savedStationsMap?.get(station.placeId)}
onSave={onSaveStation}
onDelete={onDeleteStation}
onSelect={onSelectStation}

View File

@@ -298,7 +298,7 @@ export const StationsSearchForm: React.FC<StationsSearchFormProps> = ({
setCity(e.target.value);
markManualAddressInput();
}}
placeholder="San Francisco"
placeholder="Madison"
autoComplete="address-level2"
fullWidth
/>

View File

@@ -5,5 +5,6 @@
export { useStationsSearch } from './useStationsSearch';
export { useSavedStations, useInvalidateSavedStations, useUpdateSavedStationsCache } from './useSavedStations';
export { useSaveStation } from './useSaveStation';
export { useUpdateSavedStation } from './useUpdateSavedStation';
export { useDeleteStation } from './useDeleteStation';
export { useGeolocation } from './useGeolocation';

View File

@@ -2,10 +2,11 @@
* @ai-summary Hook for deleting saved stations
*/
import { useMutation } from '@tanstack/react-query';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { stationsApi } from '../api/stations.api';
import { SavedStation, ApiError } from '../types/stations.types';
import { useUpdateSavedStationsCache } from './useSavedStations';
import { useSavedStationsQueryKey, useUpdateSavedStationsCache } from './useSavedStations';
import { resolveSavedStationPlaceId } from '../utils/savedStations';
interface UseDeleteStationOptions {
onSuccess?: (placeId: string) => void;
@@ -27,6 +28,8 @@ interface UseDeleteStationOptions {
*/
export function useDeleteStation(options?: UseDeleteStationOptions) {
const updateCache = useUpdateSavedStationsCache();
const queryClient = useQueryClient();
const savedStationsKey = useSavedStationsQueryKey();
return useMutation({
mutationFn: async (placeId: string) => {
@@ -41,12 +44,18 @@ export function useDeleteStation(options?: UseDeleteStationOptions) {
updateCache((old) => {
previousStations = old;
if (!old) return [];
return old.filter((s) => s.placeId !== placeId);
return old.filter((station) => {
const stationPlaceId = resolveSavedStationPlaceId(station);
return stationPlaceId !== placeId;
});
});
return { previousStations, placeId };
},
onSuccess: (placeId) => {
queryClient.invalidateQueries({
queryKey: savedStationsKey
});
options?.onSuccess?.(placeId);
},
onError: (error, _placeId, context) => {

View File

@@ -55,6 +55,7 @@ export function useSaveStation(options?: UseSaveStationOptions) {
// Create optimistic station entry
const optimisticStation: SavedStation = {
id: `temp-${placeId}`,
savedStationId: `temp-${placeId}`,
placeId,
name: data.nickname || 'New Station',
address: '',
@@ -65,6 +66,8 @@ export function useSaveStation(options?: UseSaveStationOptions) {
nickname: data.nickname,
notes: data.notes,
isFavorite: data.isFavorite ?? false,
has93Octane: data.has93Octane ?? false,
has93OctaneEthanolFree: data.has93OctaneEthanolFree ?? false,
createdAt: new Date(),
updatedAt: new Date()
};

View File

@@ -0,0 +1,70 @@
/**
* @ai-summary Hook for updating saved station metadata
*/
import { useMutation } from '@tanstack/react-query';
import { stationsApi } from '../api/stations.api';
import { ApiError, SavedStation, SaveStationData } from '../types/stations.types';
import { useUpdateSavedStationsCache } from './useSavedStations';
import { resolveSavedStationPlaceId } from '../utils/savedStations';
interface UpdateSavedStationVariables {
placeId: string;
data: Partial<SaveStationData>;
}
interface UseUpdateSavedStationOptions {
onSuccess?: (station: SavedStation) => void;
onError?: (error: ApiError) => void;
}
/**
* Mutation hook to update saved station metadata (octane flags, nickname, etc.)
*/
export function useUpdateSavedStation(options?: UseUpdateSavedStationOptions) {
const updateCache = useUpdateSavedStationsCache();
return useMutation({
mutationFn: async ({ placeId, data }: UpdateSavedStationVariables) => {
return stationsApi.updateSavedStation(placeId, data);
},
onMutate: async ({ placeId, data }) => {
let previousStations: SavedStation[] | undefined;
updateCache((old) => {
previousStations = old;
if (!old) return [];
return old.map((station) => {
const stationPlaceId = resolveSavedStationPlaceId(station);
if (stationPlaceId !== placeId) {
return station;
}
return {
...station,
...data,
has93Octane:
data.has93Octane !== undefined ? data.has93Octane : station.has93Octane,
has93OctaneEthanolFree:
data.has93OctaneEthanolFree !== undefined
? data.has93OctaneEthanolFree
: station.has93OctaneEthanolFree
};
});
});
return { previousStations };
},
onSuccess: (station) => {
options?.onSuccess?.(station);
},
onError: (error, _variables, context) => {
if (context?.previousStations) {
updateCache(() => context.previousStations || []);
}
options?.onError?.(error as ApiError);
}
});
}

View File

@@ -30,14 +30,17 @@ import {
useSavedStations,
useSaveStation,
useDeleteStation,
useUpdateSavedStation,
useGeolocation
} from '../hooks';
import {
Station,
SavedStation,
StationSearchRequest
StationSearchRequest,
OctanePreference
} from '../types/stations.types';
import { octanePreferenceToFlags, resolveSavedStationPlaceId } from '../utils/savedStations';
// Tab indices
const TAB_SEARCH = 0;
@@ -56,6 +59,7 @@ export const StationsMobileScreen: React.FC = () => {
// Bottom sheet state
const [selectedStation, setSelectedStation] = useState<Station | SavedStation | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [octaneUpdatingId, setOctaneUpdatingId] = useState<string | null>(null);
// Hooks
const { coordinates } = useGeolocation();
@@ -74,10 +78,28 @@ export const StationsMobileScreen: React.FC = () => {
const { mutateAsync: saveStation } = useSaveStation();
const { mutateAsync: deleteStation } = useDeleteStation();
const { mutateAsync: updateSavedStation } = useUpdateSavedStation();
// Compute set of saved place IDs for quick lookup
const savedPlaceIds = useMemo(() => {
return new Set(savedStations?.map(s => s.placeId) || []);
const { savedStationsMap, savedPlaceIds } = useMemo(() => {
const map = new Map<string, SavedStation>();
(savedStations || []).forEach((station) => {
const placeId = resolveSavedStationPlaceId(station);
if (!placeId) {
return;
}
const normalizedStation =
station.placeId === placeId ? station : { ...station, placeId };
map.set(placeId, normalizedStation);
});
return {
savedStationsMap: map,
savedPlaceIds: new Set(map.keys())
};
}, [savedStations]);
// Handle search submission
@@ -121,6 +143,21 @@ export const StationsMobileScreen: React.FC = () => {
}
}, [deleteStation, selectedStation]);
const handleOctanePreferenceChange = useCallback(
async (placeId: string, preference: OctanePreference) => {
try {
setOctaneUpdatingId(placeId);
const data = octanePreferenceToFlags(preference);
await updateSavedStation({ placeId, data });
} catch (error) {
console.error('Failed to update octane preference:', error);
} finally {
setOctaneUpdatingId((current) => (current === placeId ? null : current));
}
},
[updateSavedStation]
);
// Close bottom sheet
const handleCloseDrawer = useCallback(() => {
setDrawerOpen(false);
@@ -214,6 +251,7 @@ export const StationsMobileScreen: React.FC = () => {
<StationsList
stations={searchResults}
savedPlaceIds={savedPlaceIds}
savedStationsMap={savedStationsMap}
loading={isSearching}
error={searchError ? 'Failed to search stations' : null}
onSaveStation={handleSaveStation}
@@ -235,6 +273,8 @@ export const StationsMobileScreen: React.FC = () => {
error={savedError ? 'Failed to load saved stations' : null}
onSelectStation={handleSelectStation}
onDeleteStation={handleDeleteStation}
onOctanePreferenceChange={handleOctanePreferenceChange}
octaneUpdatingId={octaneUpdatingId}
/>
</Box>
)}

View File

@@ -15,12 +15,13 @@ import {
CircularProgress,
Typography
} from '@mui/material';
import { Station, StationSearchRequest } from '../types/stations.types';
import { OctanePreference, SavedStation, Station, StationSearchRequest } from '../types/stations.types';
import {
useStationsSearch,
useSavedStations,
useSaveStation,
useDeleteStation
useDeleteStation,
useUpdateSavedStation
} from '../hooks';
import {
StationMap,
@@ -29,6 +30,7 @@ import {
StationsSearchForm,
GoogleMapsErrorBoundary
} from '../components';
import { octanePreferenceToFlags, resolveSavedStationPlaceId } from '../utils/savedStations';
interface TabPanelProps {
children?: React.ReactNode;
@@ -119,12 +121,30 @@ export const StationsPage: React.FC = () => {
const { mutate: saveStation } = useSaveStation();
const { mutate: deleteStation } = useDeleteStation();
const { mutate: updateSavedStation } = useUpdateSavedStation();
const [octaneUpdatingId, setOctaneUpdatingId] = useState<string | null>(null);
// Create set of saved place IDs for quick lookup
const savedPlaceIds = useMemo(
() => new Set(savedStations.map((s) => s.placeId)),
[savedStations]
);
const { savedStationsMap, savedPlaceIds } = useMemo(() => {
const map = new Map<string, SavedStation>();
savedStations.forEach((station) => {
const placeId = resolveSavedStationPlaceId(station);
if (!placeId) {
return;
}
const normalizedStation =
station.placeId === placeId ? station : { ...station, placeId };
map.set(placeId, normalizedStation);
});
return {
savedStationsMap: map,
savedPlaceIds: new Set(map.keys())
};
}, [savedStations]);
// Handle search
const handleSearch = (request: StationSearchRequest) => {
@@ -163,6 +183,23 @@ export const StationsPage: React.FC = () => {
deleteStation(placeId);
};
const handleOctanePreferenceChange = useCallback(
(placeId: string, preference: OctanePreference) => {
const flags = octanePreferenceToFlags(preference);
setOctaneUpdatingId(placeId);
updateSavedStation(
{ placeId, data: flags },
{
onSettled: () => {
setOctaneUpdatingId((current) => (current === placeId ? null : current));
}
}
);
},
[updateSavedStation]
);
// Handle station selection - wrapped in useCallback to prevent infinite renders
const handleSelectStation = useCallback((station: Station) => {
setMapCenter({
@@ -238,6 +275,7 @@ export const StationsPage: React.FC = () => {
<StationsList
stations={searchResults}
savedPlaceIds={savedPlaceIds}
savedStationsMap={savedStationsMap}
loading={isSearching}
error={searchError ? (searchError as any).message : null}
onSaveStation={handleSave}
@@ -252,90 +290,118 @@ export const StationsPage: React.FC = () => {
error={savedError ? (savedError as any).message : null}
onSelectStation={handleSelectStation}
onDeleteStation={handleDelete}
onOctanePreferenceChange={handleOctanePreferenceChange}
octaneUpdatingId={octaneUpdatingId}
/>
</TabPanel>
</Box>
);
}
// Desktop layout: side-by-side
// Desktop layout: top-row map + search, full-width results
return (
<Grid container spacing={2} sx={{ padding: 2, height: 'calc(100vh - 80px)' }}>
{/* Left: Map (60%) */}
<Grid item xs={12} md={6} sx={{ display: 'flex', flexDirection: 'column' }}>
<Paper sx={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
{isMapReady ? (
<GoogleMapsErrorBoundary>
<StationMap
key="desktop-station-map"
stations={searchResults}
savedPlaceIds={savedPlaceIds}
currentLocation={currentLocation}
center={mapCenter || undefined}
height="100%"
readyToRender={true}
/>
</GoogleMapsErrorBoundary>
) : (
<Box sx={{ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<CircularProgress />
</Box>
)}
</Paper>
</Grid>
{/* Right: Search + Tabs (40%) */}
<Grid item xs={12} md={6} sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{/* Search Form */}
<Paper>
<StationsSearchForm onSearch={handleSearch} isSearching={isSearching} />
</Paper>
{/* Error Alert */}
{searchError && (
<Alert severity="error">{(searchError as any).message || 'Search failed'}</Alert>
)}
{/* Tabs */}
<Paper sx={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<Tabs
value={tabValue}
onChange={(_, newValue) => setTabValue(newValue)}
indicatorColor="primary"
textColor="primary"
aria-label="stations tabs"
<Box sx={{ padding: 2 }}>
<Grid container spacing={2}>
{/* Map */}
<Grid item xs={12} md={6}>
<Paper
sx={{
height: { xs: 300, md: 520 },
display: 'flex',
overflow: 'hidden'
}}
>
<Tab label={`Results (${searchResults.length})`} id="stations-tab-0" />
<Tab label={`Saved (${savedStations.length})`} id="stations-tab-1" />
</Tabs>
{isMapReady ? (
<GoogleMapsErrorBoundary>
<StationMap
key="desktop-station-map"
stations={searchResults}
savedPlaceIds={savedPlaceIds}
currentLocation={currentLocation}
center={mapCenter || undefined}
height="100%"
readyToRender={true}
/>
</GoogleMapsErrorBoundary>
) : (
<Box sx={{ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<CircularProgress />
</Box>
)}
</Paper>
</Grid>
{/* Tab Content with overflow */}
<Box sx={{ flex: 1, overflow: 'auto' }}>
<TabPanel value={tabValue} index={0}>
<StationsList
stations={searchResults}
savedPlaceIds={savedPlaceIds}
loading={isSearching}
error={searchError ? (searchError as any).message : null}
onSaveStation={handleSave}
onDeleteStation={handleDelete}
onSelectStation={handleSelectStation}
/>
</TabPanel>
<TabPanel value={tabValue} index={1}>
<SavedStationsList
stations={savedStations}
loading={isSavedLoading}
error={savedError ? (savedError as any).message : null}
onSelectStation={handleSelectStation}
onDeleteStation={handleDelete}
/>
</TabPanel>
</Box>
</Paper>
{/* Search form */}
<Grid item xs={12} md={6}>
<Paper
sx={{
height: { xs: 'auto', md: 520 },
display: 'flex',
flexDirection: 'column',
overflow: 'hidden'
}}
>
<Box sx={{ padding: 2, flex: 1 }}>
<StationsSearchForm onSearch={handleSearch} isSearching={isSearching} />
</Box>
</Paper>
</Grid>
</Grid>
</Grid>
{/* Error Alert */}
{searchError && (
<Box sx={{ mt: 2 }}>
<Alert severity="error">{(searchError as any).message || 'Search failed'}</Alert>
</Box>
)}
{/* Full-width Results */}
<Paper
sx={{
mt: 2,
display: 'flex',
flexDirection: 'column'
}}
>
<Tabs
value={tabValue}
onChange={(_, newValue) => setTabValue(newValue)}
indicatorColor="primary"
textColor="primary"
aria-label="stations tabs"
>
<Tab label={`Results (${searchResults.length})`} id="stations-tab-0" />
<Tab label={`Saved (${savedStations.length})`} id="stations-tab-1" />
</Tabs>
<Box sx={{ flex: 1, overflow: 'auto', padding: 2 }}>
<TabPanel value={tabValue} index={0}>
<StationsList
stations={searchResults}
savedPlaceIds={savedPlaceIds}
savedStationsMap={savedStationsMap}
loading={isSearching}
error={searchError ? (searchError as any).message : null}
onSaveStation={handleSave}
onDeleteStation={handleDelete}
onSelectStation={handleSelectStation}
/>
</TabPanel>
<TabPanel value={tabValue} index={1}>
<SavedStationsList
stations={savedStations}
loading={isSavedLoading}
error={savedError ? (savedError as any).message : null}
onSelectStation={handleSelectStation}
onDeleteStation={handleDelete}
onOctanePreferenceChange={handleOctanePreferenceChange}
octaneUpdatingId={octaneUpdatingId}
/>
</TabPanel>
</Box>
</Paper>
</Box>
);
};

View File

@@ -44,7 +44,7 @@ declare global {
*/
class InfoWindow {
constructor(options?: google.maps.InfoWindowOptions);
open(map?: google.maps.Map | null, anchor?: google.maps.Marker): void;
open(target?: google.maps.Map | { anchor?: any; map?: google.maps.Map | null; shouldFocus?: boolean } | null, anchor?: google.maps.Marker): void;
close(): void;
setContent(content: string | HTMLElement): void;
}
@@ -77,6 +77,27 @@ declare global {
BACKWARD_OPEN_ARROW = 'BACKWARD_OPEN_ARROW'
}
namespace marker {
interface AdvancedMarkerElementOptions {
position?: google.maps.LatLng | google.maps.LatLngLiteral;
map?: google.maps.Map | null;
title?: string;
content?: HTMLElement;
}
class AdvancedMarkerElement {
constructor(options?: AdvancedMarkerElementOptions);
map: google.maps.Map | null;
position?: google.maps.LatLng | google.maps.LatLngLiteral;
content?: HTMLElement;
title?: string;
addListener(
eventName: string,
callback: (...args: any[]) => void
): google.maps.MapsEventListener;
}
}
/**
* Google Maps Event Listener
*/
@@ -91,6 +112,7 @@ declare global {
zoom?: number;
center?: google.maps.LatLng | google.maps.LatLngLiteral;
mapTypeId?: string;
mapId?: string;
[key: string]: any;
}

View File

@@ -28,6 +28,14 @@ export interface SearchLocation {
longitude: number;
}
export interface StationSavedMetadata {
nickname?: string;
notes?: string;
isFavorite: boolean;
has93Octane: boolean;
has93OctaneEthanolFree: boolean;
}
/**
* Single gas station from search results
*/
@@ -50,6 +58,10 @@ export interface Station {
distance?: number;
/** URL to station photo if available */
photoUrl?: string;
/** Whether the station is saved for the user */
isSaved?: boolean;
/** Saved-station metadata if applicable */
savedMetadata?: StationSavedMetadata;
}
/**
@@ -58,18 +70,28 @@ export interface Station {
export interface SavedStation extends Station {
/** Database record ID */
id: string;
/** Optional saved-station identifier (alias for id when needed) */
savedStationId?: string;
/** User ID who saved the station */
userId: string;
/** Stored station id (Google place id) */
stationId?: string;
/** Custom nickname given by user */
nickname?: string;
/** User notes about the station */
notes?: string;
/** Whether station is marked as favorite */
isFavorite: boolean;
/** Whether the station is confirmed to have 93 octane */
has93Octane: boolean;
/** Whether the 93 octane is ethanol free */
has93OctaneEthanolFree: boolean;
/** Created timestamp */
createdAt: Date;
/** Last updated timestamp */
updatedAt: Date;
/** Raw station object returned by backend, if any */
station?: Station | null;
}
/**
@@ -96,6 +118,10 @@ export interface SaveStationData {
notes?: string;
/** Whether to mark as favorite */
isFavorite?: boolean;
/** Whether 93 octane is available */
has93Octane?: boolean;
/** Whether the 93 octane option is ethanol free */
has93OctaneEthanolFree?: boolean;
}
/**
@@ -137,3 +163,6 @@ export interface ApiError {
code?: string;
details?: Record<string, unknown>;
}
/** User-facing preference for 93 octane availability */
export type OctanePreference = 'none' | 'with_ethanol' | 'ethanol_free';

View File

@@ -1,58 +1,62 @@
/**
* @ai-summary Google Maps utility functions
* @ai-summary Google Maps utility helpers using AdvancedMarkerElement
*/
import { getGoogleMapsApi } from './maps-loader';
import { Station, MapMarker } from '../types/stations.types';
import { formatDistance } from './distance';
/**
* Create a marker for a station
*
* @param station Station data
* @param map Google Map instance
* @param isSaved Whether station is saved
* @returns Google Maps Marker
*/
type AdvancedMarker = google.maps.marker.AdvancedMarkerElement;
function createMarkerElement(color: string, label?: string): HTMLElement {
const marker = document.createElement('div');
marker.style.width = '24px';
marker.style.height = '24px';
marker.style.borderRadius = '50%';
marker.style.backgroundColor = color;
marker.style.border = '2px solid #ffffff';
marker.style.boxShadow = '0 1px 4px rgba(0,0,0,0.4)';
marker.style.display = 'flex';
marker.style.alignItems = 'center';
marker.style.justifyContent = 'center';
marker.style.color = '#000';
marker.style.fontSize = '12px';
marker.style.fontWeight = 'bold';
marker.style.lineHeight = '1';
marker.style.transform = 'translate(-50%, -50%)';
if (label) {
marker.textContent = label;
}
return marker;
}
export function createStationMarker(
station: Station,
map: google.maps.Map,
isSaved: boolean
): google.maps.Marker {
): AdvancedMarker {
const maps = getGoogleMapsApi();
const markerColor = isSaved ? '#FFD700' : '#4285F4'; // Gold for saved, blue for normal
const markerColor = isSaved ? '#FFD700' : '#4285F4';
const content = createMarkerElement(markerColor, isSaved ? '★' : undefined);
const marker = new maps.Marker({
const marker = new maps.marker.AdvancedMarkerElement({
position: {
lat: station.latitude,
lng: station.longitude
},
map,
title: station.name,
icon: {
path: maps.SymbolPath.CIRCLE,
scale: 8,
fillColor: markerColor,
fillOpacity: 1,
strokeColor: '#fff',
strokeWeight: 2
}
content
});
// Store station data on marker
(marker as any).stationData = station;
(marker as any).isSaved = isSaved;
return marker;
}
/**
* Create info window for a station
*
* @param station Station data
* @param isSaved Whether station is saved
* @returns Google Maps InfoWindow
*/
export function createInfoWindow(
station: Station,
isSaved: boolean
@@ -73,11 +77,15 @@ export function createInfoWindow(
}
${
station.rating
? `<p style="margin: 4px 0; font-size: 12px;">Rating: ⭐ ${station.rating.toFixed(1)}</p>`
? `<p style="margin: 4px 0; font-size: 12px;">Rating: ⭐ ${station.rating.toFixed(
1
)}</p>`
: ''
}
<div style="margin-top: 8px;">
<a href="https://www.google.com/maps/search/${encodeURIComponent(station.address)}" target="_blank" style="color: #4285F4; text-decoration: none; font-size: 12px; margin-right: 8px;">
<a href="https://www.google.com/maps/search/${encodeURIComponent(
station.address
)}" target="_blank" style="color: #4285F4; text-decoration: none; font-size: 12px; margin-right: 8px;">
Directions
</a>
${isSaved ? '<span style="color: #FFD700; font-size: 12px;">★ Saved</span>' : ''}
@@ -85,20 +93,12 @@ export function createInfoWindow(
</div>
`;
return new maps.InfoWindow({
content
});
return new maps.InfoWindow({ content });
}
/**
* Fit map bounds to show all markers
*
* @param map Google Map instance
* @param markers Array of markers
*/
export function fitBoundsToMarkers(
map: google.maps.Map,
markers: google.maps.Marker[]
markers: AdvancedMarker[]
): void {
if (markers.length === 0) return;
@@ -106,55 +106,37 @@ export function fitBoundsToMarkers(
const bounds = new maps.LatLngBounds();
markers.forEach((marker) => {
const position = marker.getPosition();
if (position) {
bounds.extend(position);
const positionLiteral = marker.position;
if (!positionLiteral) {
return;
}
const latLng =
positionLiteral instanceof maps.LatLng
? positionLiteral
: new maps.LatLng(positionLiteral.lat, positionLiteral.lng);
bounds.extend(latLng);
});
map.fitBounds(bounds);
// Add padding
const padding = { top: 50, right: 50, bottom: 50, left: 50 };
map.fitBounds(bounds, padding);
map.fitBounds(bounds, { top: 50, right: 50, bottom: 50, left: 50 });
}
/**
* Create current location marker
*
* @param latitude Current latitude
* @param longitude Current longitude
* @param map Google Map instance
* @returns Google Maps Marker
*/
export function createCurrentLocationMarker(
latitude: number,
longitude: number,
map: google.maps.Map
): google.maps.Marker {
): AdvancedMarker {
const maps = getGoogleMapsApi();
const content = createMarkerElement('#FF0000');
return new maps.Marker({
position: {
lat: latitude,
lng: longitude
},
return new maps.marker.AdvancedMarkerElement({
position: { lat: latitude, lng: longitude },
map,
title: 'Your Location',
icon: {
path: maps.SymbolPath.CIRCLE,
scale: 10,
fillColor: '#FF0000',
fillOpacity: 0.7,
strokeColor: '#fff',
strokeWeight: 2
}
content
});
}
/**
* Convert Station to MapMarker
*/
export function stationToMapMarker(
station: Station,
isSaved: boolean

View File

@@ -53,7 +53,7 @@ export function loadGoogleMaps(): Promise<void> {
// The callback parameter tells Google Maps to call our function when ready
// Using async + callback ensures Google Maps initializes asynchronously
const script = document.createElement('script');
script.src = `https://maps.googleapis.com/maps/api/js?key=${apiKey}&callback=${callbackName}&libraries=places&loading=async`;
script.src = `https://maps.googleapis.com/maps/api/js?key=${apiKey}&callback=${callbackName}&libraries=places,marker&loading=async`;
script.async = true;
script.defer = true; // Load asynchronously without blocking parsing (per Maps best practices)

View File

@@ -0,0 +1,69 @@
/**
* @ai-summary Helper utilities for working with saved stations data
*/
import { OctanePreference, SavedStation, SaveStationData } from '../types/stations.types';
export function resolveSavedStationPlaceId(station: SavedStation): string | undefined {
return station.placeId || station.station?.placeId || station.stationId;
}
export function resolveSavedStationName(station: SavedStation): string {
return (
station.nickname ||
station.name ||
station.station?.name ||
'Saved Station'
);
}
export function resolveSavedStationAddress(station: SavedStation): string {
return station.address || station.station?.address || '';
}
export function resolveSavedStationCoordinates(
station: SavedStation
): { latitude: number; longitude: number } | undefined {
const lat = station.latitude ?? station.station?.latitude;
const lng = station.longitude ?? station.station?.longitude;
if (lat === undefined || lng === undefined) {
return undefined;
}
return { latitude: lat, longitude: lng };
}
export function getOctanePreferenceFromFlags(
has93Octane: boolean,
has93OctaneEthanolFree: boolean
): OctanePreference {
if (!has93Octane) {
return 'none';
}
return has93OctaneEthanolFree ? 'ethanol_free' : 'with_ethanol';
}
export function octanePreferenceToFlags(
preference: OctanePreference
): Pick<SaveStationData, 'has93Octane' | 'has93OctaneEthanolFree'> {
if (preference === 'none') {
return {
has93Octane: false,
has93OctaneEthanolFree: false
};
}
if (preference === 'ethanol_free') {
return {
has93Octane: true,
has93OctaneEthanolFree: true
};
}
return {
has93Octane: true,
has93OctaneEthanolFree: false
};
}

View File

@@ -67,6 +67,15 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
initialData,
loading,
}) => {
const formatVehicleLabel = (value?: string): string => {
if (!value) return '';
return value
.split(' ')
.filter(Boolean)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
};
const [years, setYears] = useState<number[]>([]);
const [makes, setMakes] = useState<DropdownOption[]>([]);
const [models, setModels] = useState<DropdownOption[]>([]);
@@ -338,7 +347,7 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
<option value="">Select Make</option>
{makes.map((make) => (
<option key={make.id} value={make.name}>
{make.name}
{formatVehicleLabel(make.name)}
</option>
))}
</select>
@@ -357,7 +366,7 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
<option value="">Select Model</option>
{models.map((model) => (
<option key={model.id} value={model.name}>
{model.name}
{formatVehicleLabel(model.name)}
</option>
))}
</select>