Modernization Project Complete. Updated to latest versions of frameworks.

This commit is contained in:
Eric Gullickson
2025-08-24 09:49:21 -05:00
parent 673fe7ce91
commit b534e92636
46 changed files with 2341 additions and 5267 deletions

View File

@@ -0,0 +1,136 @@
/**
* @ai-summary React 19 useOptimistic hook for vehicle operations
* @ai-context Provides optimistic updates for better perceived performance
*/
import { useOptimistic, useTransition } from 'react';
import { Vehicle, CreateVehicleRequest } from '../types/vehicles.types';
import { useQueryClient } from '@tanstack/react-query';
import { vehiclesApi } from '../api/vehicles.api';
import toast from 'react-hot-toast';
type VehicleAction =
| { type: 'add'; vehicle: Vehicle }
| { type: 'delete'; id: string }
| { type: 'update'; id: string; updates: Partial<Vehicle> };
function vehicleReducer(state: Vehicle[], action: VehicleAction): Vehicle[] {
switch (action.type) {
case 'add':
return [...state, action.vehicle];
case 'delete':
return state.filter(v => v.id !== action.id);
case 'update':
return state.map(v =>
v.id === action.id ? { ...v, ...action.updates } : v
);
default:
return state;
}
}
export function useOptimisticVehicles(vehicles: Vehicle[] = []) {
const [optimisticVehicles, addOptimistic] = useOptimistic(
vehicles,
vehicleReducer
);
const [isPending, startTransition] = useTransition();
const queryClient = useQueryClient();
const optimisticCreateVehicle = async (data: CreateVehicleRequest) => {
// Create optimistic vehicle with temporary ID
const optimisticVehicle: Vehicle = {
id: `temp-${Date.now()}`,
vin: data.vin,
nickname: data.nickname,
color: data.color,
licensePlate: data.licensePlate,
odometerReading: data.odometerReading || 0,
make: undefined,
model: undefined,
year: undefined,
engine: undefined,
transmission: undefined,
trimLevel: undefined,
driveType: undefined,
fuelType: undefined,
isActive: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
userId: 'current-user' // This would come from auth context
};
// Show optimistic update immediately
startTransition(() => {
addOptimistic({ type: 'add', vehicle: optimisticVehicle });
});
try {
// Perform actual API call
const createdVehicle = await vehiclesApi.create(data);
// Invalidate and refetch to get real data
await queryClient.invalidateQueries({ queryKey: ['vehicles'] });
toast.success('Vehicle added successfully');
return createdVehicle;
} catch (error: any) {
// Revert optimistic update on error
await queryClient.invalidateQueries({ queryKey: ['vehicles'] });
toast.error(error.response?.data?.error || 'Failed to add vehicle');
throw error;
}
};
const optimisticDeleteVehicle = async (id: string) => {
// Show optimistic delete immediately
startTransition(() => {
addOptimistic({ type: 'delete', id });
});
try {
// Perform actual API call
await vehiclesApi.delete(id);
// Invalidate and refetch to ensure consistency
await queryClient.invalidateQueries({ queryKey: ['vehicles'] });
toast.success('Vehicle deleted successfully');
} catch (error: any) {
// Revert optimistic update on error
await queryClient.invalidateQueries({ queryKey: ['vehicles'] });
toast.error(error.response?.data?.error || 'Failed to delete vehicle');
throw error;
}
};
const optimisticUpdateVehicle = async (id: string, updates: Partial<Vehicle>) => {
// Show optimistic update immediately
startTransition(() => {
addOptimistic({ type: 'update', id, updates });
});
try {
// Perform actual API call
const updatedVehicle = await vehiclesApi.update(id, updates);
// Invalidate and refetch to get real data
await queryClient.invalidateQueries({ queryKey: ['vehicles'] });
toast.success('Vehicle updated successfully');
return updatedVehicle;
} catch (error: any) {
// Revert optimistic update on error
await queryClient.invalidateQueries({ queryKey: ['vehicles'] });
toast.error(error.response?.data?.error || 'Failed to update vehicle');
throw error;
}
};
return {
optimisticVehicles,
isPending,
optimisticCreateVehicle,
optimisticDeleteVehicle,
optimisticUpdateVehicle
};
}

View File

@@ -0,0 +1,191 @@
/**
* @ai-summary React 19 useTransition hooks for vehicle operations
* @ai-context Non-blocking updates for better UI responsiveness
*/
import { useTransition, useState, useCallback } from 'react';
import { Vehicle } from '../types/vehicles.types';
export function useVehicleSearch(vehicles: Vehicle[]) {
const [isPending, startTransition] = useTransition();
const [searchQuery, setSearchQuery] = useState('');
const [filteredVehicles, setFilteredVehicles] = useState<Vehicle[]>(vehicles);
const handleSearch = useCallback((query: string) => {
// High priority: Update search input immediately
setSearchQuery(query);
// Low priority: Update filtered results (non-blocking)
startTransition(() => {
if (!query.trim()) {
setFilteredVehicles(vehicles);
} else {
const filtered = vehicles.filter(vehicle => {
const searchTerm = query.toLowerCase();
return (
vehicle.nickname?.toLowerCase().includes(searchTerm) ||
vehicle.make?.toLowerCase().includes(searchTerm) ||
vehicle.model?.toLowerCase().includes(searchTerm) ||
vehicle.vin.toLowerCase().includes(searchTerm) ||
vehicle.year?.toString().includes(searchTerm)
);
});
setFilteredVehicles(filtered);
}
});
}, [vehicles]);
const clearSearch = useCallback(() => {
setSearchQuery('');
startTransition(() => {
setFilteredVehicles(vehicles);
});
}, [vehicles]);
// Update filtered vehicles when vehicles data changes
const updateVehicles = useCallback((newVehicles: Vehicle[]) => {
startTransition(() => {
if (!searchQuery.trim()) {
setFilteredVehicles(newVehicles);
} else {
// Re-apply search filter to new data
const filtered = newVehicles.filter(vehicle => {
const searchTerm = searchQuery.toLowerCase();
return (
vehicle.nickname?.toLowerCase().includes(searchTerm) ||
vehicle.make?.toLowerCase().includes(searchTerm) ||
vehicle.model?.toLowerCase().includes(searchTerm) ||
vehicle.vin.toLowerCase().includes(searchTerm) ||
vehicle.year?.toString().includes(searchTerm)
);
});
setFilteredVehicles(filtered);
}
});
}, [searchQuery]);
return {
searchQuery,
filteredVehicles,
isPending,
handleSearch,
clearSearch,
updateVehicles
};
}
export function useVehicleSort(vehicles: Vehicle[]) {
const [isPending, startTransition] = useTransition();
const [sortBy, setSortBy] = useState<'name' | 'make' | 'year' | 'created'>('created');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
const [sortedVehicles, setSortedVehicles] = useState<Vehicle[]>(vehicles);
const handleSort = useCallback((newSortBy: typeof sortBy, newSortOrder: typeof sortOrder = 'asc') => {
// High priority: Update sort state immediately
setSortBy(newSortBy);
setSortOrder(newSortOrder);
// Low priority: Update sorted results (non-blocking)
startTransition(() => {
const sorted = [...vehicles].sort((a, b) => {
let aValue: string | number | null;
let bValue: string | number | null;
switch (newSortBy) {
case 'name':
aValue = a.nickname || `${a.make || ''} ${a.model || ''}`.trim() || 'Unknown';
bValue = b.nickname || `${b.make || ''} ${b.model || ''}`.trim() || 'Unknown';
break;
case 'make':
aValue = a.make || 'Unknown';
bValue = b.make || 'Unknown';
break;
case 'year':
aValue = a.year || 0;
bValue = b.year || 0;
break;
case 'created':
aValue = a.createdAt;
bValue = b.createdAt;
break;
default:
return 0;
}
if (typeof aValue === 'string' && typeof bValue === 'string') {
const result = aValue.localeCompare(bValue);
return newSortOrder === 'asc' ? result : -result;
} else {
const result = (aValue as number) - (bValue as number);
return newSortOrder === 'asc' ? result : -result;
}
});
setSortedVehicles(sorted);
});
}, [vehicles]);
// Update sorted vehicles when vehicles data changes
const updateVehicles = useCallback((_newVehicles: Vehicle[]) => {
startTransition(() => {
handleSort(sortBy, sortOrder);
});
}, [sortBy, sortOrder, handleSort]);
return {
sortBy,
sortOrder,
sortedVehicles,
isPending,
handleSort,
updateVehicles
};
}
export function useVehicleFilter(vehicles: Vehicle[]) {
const [isPending, startTransition] = useTransition();
const [activeFilters, setActiveFilters] = useState<{
make?: string;
year?: number;
isActive?: boolean;
}>({});
const [filteredVehicles, setFilteredVehicles] = useState<Vehicle[]>(vehicles);
const applyFilters = useCallback((filters: typeof activeFilters) => {
// High priority: Update filter state immediately
setActiveFilters(filters);
// Low priority: Apply filters (non-blocking)
startTransition(() => {
let filtered = vehicles;
if (filters.make) {
filtered = filtered.filter(v => v.make === filters.make);
}
if (filters.year) {
filtered = filtered.filter(v => v.year === filters.year);
}
if (filters.isActive !== undefined) {
filtered = filtered.filter(v => v.isActive === filters.isActive);
}
setFilteredVehicles(filtered);
});
}, [vehicles]);
const clearFilters = useCallback(() => {
setActiveFilters({});
startTransition(() => {
setFilteredVehicles(vehicles);
});
}, [vehicles]);
return {
activeFilters,
filteredVehicles,
isPending,
applyFilters,
clearFilters
};
}

View File

@@ -1,10 +1,14 @@
/**
* @ai-summary Mobile-optimized vehicles screen with Material Design 3
* @ai-summary Mobile vehicles screen with React 19 enhancements
* @ai-context Enhanced with Suspense, optimistic updates, and transitions
*/
import React from 'react';
import React, { useTransition, useEffect } from 'react';
import { Box, Typography, Grid } from '@mui/material';
import { useVehicles } from '../hooks/useVehicles';
import { useOptimisticVehicles } from '../hooks/useOptimisticVehicles';
import { useVehicleSearch } from '../hooks/useVehicleTransitions';
import { MobileVehiclesSuspense } from '../../../components/SuspenseWrappers';
import { VehicleMobileCard } from './VehicleMobileCard';
import { Vehicle } from '../types/vehicles.types';
@@ -32,6 +36,31 @@ export const VehiclesMobileScreen: React.FC<VehiclesMobileScreenProps> = ({
onVehicleSelect
}) => {
const { data: vehicles, isLoading } = useVehicles();
const [_isPending, startTransition] = useTransition();
// React 19 optimistic updates
const {
optimisticVehicles,
isPending: isOptimisticPending
} = useOptimisticVehicles(vehicles || []);
// Enhanced search with transitions
const {
filteredVehicles,
updateVehicles
} = useVehicleSearch(optimisticVehicles);
// Update search when optimistic vehicles change
useEffect(() => {
updateVehicles(optimisticVehicles);
}, [optimisticVehicles, updateVehicles]);
const handleVehicleSelect = (vehicle: Vehicle) => {
// Use transition to avoid blocking UI during navigation
startTransition(() => {
onVehicleSelect?.(vehicle);
});
};
if (isLoading) {
return (
@@ -43,7 +72,7 @@ export const VehiclesMobileScreen: React.FC<VehiclesMobileScreenProps> = ({
);
}
if (!vehicles?.length) {
if (!optimisticVehicles.length) {
return (
<Box sx={{ pb: 10 }}>
<Section title="Vehicles">
@@ -61,19 +90,21 @@ export const VehiclesMobileScreen: React.FC<VehiclesMobileScreenProps> = ({
}
return (
<Box sx={{ pb: 10 }}>
<Section title="Vehicles">
<Grid container spacing={2}>
{vehicles.map((vehicle) => (
<Grid item xs={12} key={vehicle.id}>
<VehicleMobileCard
vehicle={vehicle}
onClick={() => onVehicleSelect?.(vehicle)}
/>
</Grid>
))}
</Grid>
</Section>
</Box>
<MobileVehiclesSuspense>
<Box sx={{ pb: 10 }}>
<Section title={`Vehicles ${isOptimisticPending ? '(Updating...)' : ''}`}>
<Grid container spacing={2}>
{filteredVehicles.map((vehicle) => (
<Grid item xs={12} key={vehicle.id}>
<VehicleMobileCard
vehicle={vehicle}
onClick={() => handleVehicleSelect(vehicle)}
/>
</Grid>
))}
</Grid>
</Section>
</Box>
</MobileVehiclesSuspense>
);
};

View File

@@ -1,37 +1,75 @@
/**
* @ai-summary Main vehicles page with Material Design 3
* @ai-summary Main vehicles page with React 19 advanced features
* @ai-context Enhanced with Suspense, useOptimistic, and useTransition
*/
import React, { useState } from 'react';
import { Box, Typography, Grid, Button as MuiButton } from '@mui/material';
import React, { useState, useEffect, useTransition } from 'react';
import { Box, Typography, Grid, Button as MuiButton, TextField, IconButton } from '@mui/material';
import AddIcon from '@mui/icons-material/Add';
import { useVehicles, useCreateVehicle, useDeleteVehicle } from '../hooks/useVehicles';
import SearchIcon from '@mui/icons-material/Search';
import ClearIcon from '@mui/icons-material/Clear';
import { useVehicles } from '../hooks/useVehicles';
import { useOptimisticVehicles } from '../hooks/useOptimisticVehicles';
import { useVehicleSearch } from '../hooks/useVehicleTransitions';
import { VehicleCard } from '../components/VehicleCard';
import { VehicleForm } from '../components/VehicleForm';
import { Card } from '../../../shared-minimal/components/Card';
import { VehicleListSuspense, FormSuspense } from '../../../components/SuspenseWrappers';
import { useAppStore } from '../../../core/store';
import { useNavigate } from 'react-router-dom';
export const VehiclesPage: React.FC = () => {
const navigate = useNavigate();
const { data: vehicles, isLoading } = useVehicles();
const createVehicle = useCreateVehicle();
const deleteVehicle = useDeleteVehicle();
const setSelectedVehicle = useAppStore(state => state.setSelectedVehicle);
// React 19 optimistic updates and transitions
const {
optimisticVehicles,
isPending: isOptimisticPending,
optimisticCreateVehicle,
optimisticDeleteVehicle
} = useOptimisticVehicles(vehicles || []);
const {
searchQuery,
filteredVehicles,
isPending: isSearchPending,
handleSearch,
clearSearch,
updateVehicles
} = useVehicleSearch(optimisticVehicles);
const [isPending, startTransition] = useTransition();
const [showForm, setShowForm] = useState(false);
// Update search vehicles when optimistic vehicles change
useEffect(() => {
updateVehicles(optimisticVehicles);
}, [optimisticVehicles, updateVehicles]);
const handleSelectVehicle = (id: string) => {
setSelectedVehicle(id);
navigate(`/vehicles/${id}`);
// Use transition for navigation to avoid blocking UI
startTransition(() => {
setSelectedVehicle(id);
navigate(`/vehicles/${id}`);
});
};
const handleDelete = async (id: string) => {
if (confirm('Are you sure you want to delete this vehicle?')) {
await deleteVehicle.mutateAsync(id);
await optimisticDeleteVehicle(id);
}
};
const handleCreateVehicle = async (data: any) => {
await optimisticCreateVehicle(data);
// Use transition for UI state change
startTransition(() => {
setShowForm(false);
});
};
if (isLoading) {
return (
<Box sx={{
@@ -46,45 +84,77 @@ export const VehiclesPage: React.FC = () => {
}
return (
<Box sx={{ py: 2 }}>
<Box sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: 4
}}>
<Typography variant="h4" sx={{ fontWeight: 700, color: 'text.primary' }}>
My Vehicles
</Typography>
{!showForm && (
<MuiButton
variant="contained"
startIcon={<AddIcon />}
onClick={() => setShowForm(true)}
sx={{ borderRadius: '999px' }}
>
Add Vehicle
</MuiButton>
<VehicleListSuspense>
<Box sx={{ py: 2 }}>
<Box sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: 4
}}>
<Typography variant="h4" sx={{ fontWeight: 700, color: 'text.primary' }}>
My Vehicles
</Typography>
{!showForm && (
<MuiButton
variant="contained"
startIcon={<AddIcon />}
onClick={() => startTransition(() => setShowForm(true))}
sx={{ borderRadius: '999px' }}
disabled={isPending || isOptimisticPending}
>
Add Vehicle
</MuiButton>
)}
</Box>
{/* Search functionality */}
{vehicles && vehicles.length > 0 && (
<Box sx={{ mb: 3 }}>
<TextField
fullWidth
placeholder="Search vehicles by name, make, model, or VIN..."
value={searchQuery}
onChange={(e) => handleSearch(e.target.value)}
InputProps={{
startAdornment: <SearchIcon sx={{ mr: 1, color: 'text.secondary' }} />,
endAdornment: searchQuery && (
<IconButton onClick={clearSearch} size="small">
<ClearIcon />
</IconButton>
),
}}
sx={{
'& .MuiOutlinedInput-root': {
borderRadius: '999px',
backgroundColor: isSearchPending ? 'action.hover' : 'background.paper'
}
}}
/>
{searchQuery && (
<Typography variant="body2" sx={{ mt: 1, color: 'text.secondary' }}>
{isSearchPending ? 'Searching...' : `Found ${filteredVehicles.length} vehicle(s)`}
</Typography>
)}
</Box>
)}
</Box>
{showForm && (
<Card className="mb-6">
<Typography variant="h6" sx={{ fontWeight: 600, mb: 2 }}>
Add New Vehicle
</Typography>
<VehicleForm
onSubmit={async (data) => {
await createVehicle.mutateAsync(data);
setShowForm(false);
}}
onCancel={() => setShowForm(false)}
loading={createVehicle.isPending}
/>
</Card>
<FormSuspense>
<Card className="mb-6">
<Typography variant="h6" sx={{ fontWeight: 600, mb: 2 }}>
Add New Vehicle
</Typography>
<VehicleForm
onSubmit={handleCreateVehicle}
onCancel={() => startTransition(() => setShowForm(false))}
loading={isOptimisticPending}
/>
</Card>
</FormSuspense>
)}
{vehicles?.length === 0 ? (
{optimisticVehicles.length === 0 ? (
<Card>
<Box sx={{ textAlign: 'center', py: 8 }}>
<Typography color="text.secondary" sx={{ mb: 3 }}>
@@ -94,8 +164,9 @@ export const VehiclesPage: React.FC = () => {
<MuiButton
variant="contained"
startIcon={<AddIcon />}
onClick={() => setShowForm(true)}
onClick={() => startTransition(() => setShowForm(true))}
sx={{ borderRadius: '999px' }}
disabled={isPending || isOptimisticPending}
>
Add Your First Vehicle
</MuiButton>
@@ -104,7 +175,7 @@ export const VehiclesPage: React.FC = () => {
</Card>
) : (
<Grid container spacing={3}>
{vehicles?.map((vehicle) => (
{filteredVehicles.map((vehicle) => (
<Grid item xs={12} md={6} lg={4} key={vehicle.id}>
<VehicleCard
vehicle={vehicle}
@@ -116,6 +187,7 @@ export const VehiclesPage: React.FC = () => {
))}
</Grid>
)}
</Box>
</Box>
</VehicleListSuspense>
);
};