This commit is contained in:
2025-09-11 07:45:25 -07:00
parent f443e7a64e
commit 651ce46183
46 changed files with 5063 additions and 164 deletions

View File

@@ -0,0 +1,231 @@
import { useState, useCallback } from 'react'
import { useAuth } from '../../context/AuthContext'
import LoadingSpinner from '../LoadingSpinner'
import dynamic from 'next/dynamic'
import { gpx } from '@tmcw/togeojson'
const RouteVisualization = dynamic(() => import('./RouteVisualization'), {
ssr: false,
loading: () => <div className="h-64 bg-gray-100 rounded-md flex items-center justify-center">Loading map...</div>
})
const FileUpload = ({ onUploadSuccess }) => {
const { apiKey } = useAuth()
const [isDragging, setIsDragging] = useState(false)
const [previewData, setPreviewData] = useState(null)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState(null)
const handleFile = async (file) => {
if (file.type !== 'application/gpx+xml') {
setError('Please upload a valid GPX file')
return
}
try {
// Preview parsing
const reader = new FileReader()
reader.onload = (e) => {
try {
const parser = new DOMParser()
const gpxDoc = parser.parseFromString(e.target.result, 'text/xml')
const geoJson = gpx(gpxDoc)
// Extract basic info from GeoJSON
const name = geoJson.features[0]?.properties?.name || 'Unnamed Route'
// Calculate distance and elevation (simplified)
let totalDistance = 0
let elevationGain = 0
let elevationLoss = 0
let maxElevation = 0
// Simple calculation - in a real app you'd want more accurate distance calculation
const coordinates = geoJson.features[0]?.geometry?.coordinates || []
for (let i = 1; i < coordinates.length; i++) {
const [prevLon, prevLat, prevEle] = coordinates[i-1]
const [currLon, currLat, currEle] = coordinates[i]
// Simple distance calculation (you might want to use a more accurate method)
const distance = Math.sqrt(Math.pow(currLon - prevLon, 2) + Math.pow(currLat - prevLat, 2)) * 111000 // rough meters
totalDistance += distance
if (prevEle && currEle) {
const eleDiff = currEle - prevEle
if (eleDiff > 0) {
elevationGain += eleDiff
} else {
elevationLoss += Math.abs(eleDiff)
}
maxElevation = Math.max(maxElevation, currEle)
}
}
const avgGrade = totalDistance > 0 ? ((elevationGain / totalDistance) * 100).toFixed(1) : '0.0'
setPreviewData({
name,
distance: (totalDistance / 1000).toFixed(1) + 'km',
elevationGain: elevationGain.toFixed(0) + 'm',
elevationLoss: elevationLoss.toFixed(0) + 'm',
maxElevation: maxElevation.toFixed(0) + 'm',
avgGrade: avgGrade + '%',
category: 'mixed',
gpxContent: e.target.result
})
} catch (parseError) {
setError('Error parsing GPX file: ' + parseError.message)
}
}
reader.readAsText(file)
} catch (err) {
setError('Error parsing GPX file')
}
}
const handleUpload = async () => {
if (!previewData) return
setIsLoading(true)
try {
const formData = new FormData()
const blob = new Blob([previewData.gpxContent], { type: 'application/gpx+xml' })
formData.append('file', blob, previewData.name + '.gpx')
const response = await fetch('/api/routes/upload', {
method: 'POST',
headers: {
'X-API-Key': apiKey
},
body: formData
})
if (!response.ok) throw new Error('Upload failed')
const result = await response.json()
onUploadSuccess(result)
setPreviewData(null)
setError(null)
} catch (err) {
setError(err.message || 'Upload failed')
} finally {
setIsLoading(false)
}
}
const onDragOver = useCallback((e) => {
e.preventDefault()
setIsDragging(true)
}, [])
const onDragLeave = useCallback((e) => {
e.preventDefault()
setIsDragging(false)
}, [])
const onDrop = useCallback((e) => {
e.preventDefault()
setIsDragging(false)
const file = e.dataTransfer.files[0]
if (file) handleFile(file)
}, [])
return (
<div className="mb-8">
<div
className={`border-2 border-dashed rounded-lg p-6 text-center ${
isDragging ? 'border-blue-500 bg-blue-50' : 'border-gray-300'
}`}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
>
<input
type="file"
id="gpx-upload"
className="hidden"
accept=".gpx,application/gpx+xml"
onChange={(e) => e.target.files[0] && handleFile(e.target.files[0])}
/>
<label htmlFor="gpx-upload" className="cursor-pointer">
<p className="text-gray-600">
Drag and drop GPX file here or{' '}
<span className="text-blue-600 font-medium">browse files</span>
</p>
</label>
</div>
{previewData && (
<div className="mt-6 bg-white p-6 rounded-lg shadow-md">
<h3 className="text-lg font-medium mb-4">Route Preview</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Route Name</label>
<input
type="text"
value={previewData.name}
onChange={(e) => setPreviewData(prev => ({...prev, name: e.target.value}))}
className="w-full p-2 border rounded-md"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Category</label>
<select
value={previewData.category}
onChange={(e) => setPreviewData(prev => ({...prev, category: e.target.value}))}
className="w-full p-2 border rounded-md"
>
<option value="climbing">Climbing</option>
<option value="flat">Flat</option>
<option value="mixed">Mixed</option>
<option value="intervals">Intervals</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Distance</label>
<p className="p-2 bg-gray-50 rounded-md">{previewData.distance}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Elevation Gain</label>
<p className="p-2 bg-gray-50 rounded-md">{previewData.elevationGain}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Avg Grade</label>
<p className="p-2 bg-gray-50 rounded-md">{previewData.avgGrade}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Max Elevation</label>
<p className="p-2 bg-gray-50 rounded-md">{previewData.maxElevation}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Elevation Loss</label>
<p className="p-2 bg-gray-50 rounded-md">{previewData.elevationLoss}</p>
</div>
</div>
<button
onClick={handleUpload}
disabled={isLoading}
className="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 disabled:bg-gray-400"
>
{isLoading ? 'Uploading...' : 'Confirm Upload'}
</button>
</div>
<div className="h-64">
<RouteVisualization gpxData={previewData.gpxContent} />
</div>
</div>
</div>
)}
{error && (
<div className="mt-4 text-red-600 bg-red-50 p-3 rounded-md">
{error}
</div>
)}
</div>
)
}
export default FileUpload

View File

@@ -0,0 +1,80 @@
import PropTypes from 'prop-types';
const RouteFilter = ({ filters, onFilterChange }) => {
const handleChange = (field, value) => {
onFilterChange({
...filters,
[field]: value
});
};
return (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6 p-4 bg-gray-50 rounded-lg">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Search
</label>
<input
type="text"
value={filters.searchQuery}
onChange={(e) => handleChange('searchQuery', e.target.value)}
placeholder="Search routes..."
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Min Distance (km)
</label>
<input
type="number"
value={filters.minDistance}
onChange={(e) => handleChange('minDistance', Number(e.target.value))}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Max Distance (km)
</label>
<input
type="number"
value={filters.maxDistance}
onChange={(e) => handleChange('maxDistance', Number(e.target.value))}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Difficulty
</label>
<select
value={filters.difficulty}
onChange={(e) => handleChange('difficulty', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="all">All Difficulties</option>
<option value="easy">Easy</option>
<option value="moderate">Moderate</option>
<option value="hard">Hard</option>
<option value="extreme">Extreme</option>
</select>
</div>
</div>
);
};
RouteFilter.propTypes = {
filters: PropTypes.shape({
searchQuery: PropTypes.string,
minDistance: PropTypes.number,
maxDistance: PropTypes.number,
difficulty: PropTypes.string
}).isRequired,
onFilterChange: PropTypes.func.isRequired
};
export default RouteFilter;

View File

@@ -0,0 +1,107 @@
import { useState } from 'react'
import { useAuth } from '../../context/AuthContext'
import LoadingSpinner from '../LoadingSpinner'
import { format } from 'date-fns'
import { FaStar } from 'react-icons/fa'
const RouteList = ({ routes, onRouteSelect }) => {
const { apiKey } = useAuth()
const [searchQuery, setSearchQuery] = useState('')
const [selectedCategory, setSelectedCategory] = useState('all')
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState(null)
const categories = ['all', 'climbing', 'flat', 'mixed', 'intervals']
const filteredRoutes = routes.filter(route => {
const matchesSearch = route.name.toLowerCase().includes(searchQuery.toLowerCase())
const matchesCategory = selectedCategory === 'all' || route.category === selectedCategory
return matchesSearch && matchesCategory
})
return (
<div className="bg-white p-6 rounded-lg shadow-md">
<div className="mb-6 space-y-4">
<input
type="text"
placeholder="Search routes..."
className="w-full p-2 border rounded-md"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
<div className="flex flex-wrap gap-2">
{categories.map(category => (
<button
key={category}
onClick={() => setSelectedCategory(category)}
className={`px-4 py-2 rounded-full text-sm font-medium ${
selectedCategory === category
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
{category.charAt(0).toUpperCase() + category.slice(1)}
</button>
))}
</div>
</div>
{isLoading ? (
<LoadingSpinner />
) : error ? (
<div className="text-red-600">{error}</div>
) : (
<div className="space-y-4">
{filteredRoutes.map(route => (
<div
key={route.id}
className="p-4 border rounded-md hover:bg-gray-50 cursor-pointer"
onClick={() => onRouteSelect(route)}
>
<div className="flex justify-between items-start">
<h3 className="font-medium text-lg">{route.name}</h3>
<span className="text-sm text-gray-500">
{format(new Date(route.created_at), 'MMM d, yyyy')}
</span>
</div>
<div className="grid grid-cols-4 gap-4 mt-2 text-gray-600">
<div>
<span className="font-medium">Distance: </span>
{(route.distance / 1000).toFixed(1)}km
</div>
<div>
<span className="font-medium">Elevation: </span>
{route.elevation_gain}m
</div>
<div>
<span className="font-medium">Category: </span>
{route.category}
</div>
<div>
<span className="font-medium">Grade: </span>
{route.grade_avg}%
</div>
<div className="flex items-center gap-1">
<span className="font-medium">Difficulty: </span>
{[...Array(5)].map((_, index) => (
<FaStar
key={index}
className={`w-4 h-4 ${
index < Math.round(route.difficulty_rating / 2)
? 'text-yellow-400'
: 'text-gray-300'
}`}
/>
))}
</div>
</div>
</div>
))}
</div>
)}
</div>
)
}
export default RouteList

View File

@@ -0,0 +1,61 @@
import { FaRoute, FaMountain, FaTachometerAlt, FaStar } from 'react-icons/fa'
const RouteMetadata = ({ route }) => {
return (
<div className="bg-white p-6 rounded-lg shadow-md">
<h3 className="text-xl font-semibold mb-4 flex items-center gap-2">
<FaRoute className="text-blue-600" /> Route Details
</h3>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
<div className="flex items-center gap-2 p-3 bg-gray-50 rounded-lg">
<FaMountain className="text-gray-600" />
<div>
<p className="text-sm text-gray-500">Elevation Gain</p>
<p className="font-medium">{route.elevation_gain}m</p>
</div>
</div>
<div className="flex items-center gap-2 p-3 bg-gray-50 rounded-lg">
<FaTachometerAlt className="text-gray-600" />
<div>
<p className="text-sm text-gray-500">Avg Grade</p>
<p className="font-medium">{route.grade_avg}%</p>
</div>
</div>
<div className="flex items-center gap-2 p-3 bg-gray-50 rounded-lg">
<FaStar className="text-gray-600" />
<div>
<p className="text-sm text-gray-500">Difficulty</p>
<div className="flex gap-1 text-yellow-400">
{[...Array(5)].map((_, index) => (
<FaStar
key={index}
className={`w-4 h-4 ${
index < Math.round(route.difficulty_rating / 2)
? 'fill-current'
: 'text-gray-300'
}`}
/>
))}
</div>
</div>
</div>
<div className="col-span-2 md:col-span-3 grid grid-cols-2 gap-4 mt-4">
<div className="p-3 bg-gray-50 rounded-lg">
<p className="text-sm text-gray-500">Distance</p>
<p className="font-medium">{(route.distance / 1000).toFixed(1)}km</p>
</div>
<div className="p-3 bg-gray-50 rounded-lg">
<p className="text-sm text-gray-500">Category</p>
<p className="font-medium capitalize">{route.category}</p>
</div>
</div>
</div>
</div>
)
}
export default RouteMetadata

View File

@@ -0,0 +1,93 @@
import { useRef, useEffect, useState } from 'react'
import { MapContainer, TileLayer, Polyline, Marker, Popup } from 'react-leaflet'
import L from 'leaflet'
import 'leaflet/dist/leaflet.css'
import { gpx } from '@tmcw/togeojson'
// Fix leaflet marker icons
delete L.Icon.Default.prototype._getIconUrl
L.Icon.Default.mergeOptions({
iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon-2x.png',
iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon.png',
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-shadow.png'
})
const RouteVisualization = ({ gpxData }) => {
const mapRef = useRef()
const elevationChartRef = useRef(null)
const [routePoints, setRoutePoints] = useState([])
useEffect(() => {
if (!gpxData) return
try {
const parser = new DOMParser()
const gpxDoc = parser.parseFromString(gpxData, 'text/xml')
const geoJson = gpx(gpxDoc)
if (!geoJson.features[0]) return
const coordinates = geoJson.features[0].geometry.coordinates
const points = coordinates.map(coord => [coord[1], coord[0]]) // [lat, lon]
const bounds = L.latLngBounds(points)
setRoutePoints(points)
if (mapRef.current) {
mapRef.current.flyToBounds(bounds, { padding: [50, 50] })
}
// Plot elevation profile
if (elevationChartRef.current) {
const elevations = coordinates.map(coord => coord[2] || 0)
const distances = []
let distance = 0
for (let i = 1; i < coordinates.length; i++) {
const prevPoint = L.latLng(coordinates[i-1][1], coordinates[i-1][0])
const currPoint = L.latLng(coordinates[i][1], coordinates[i][0])
distance += prevPoint.distanceTo(currPoint)
distances.push(distance)
}
// TODO: Integrate charting library
}
} catch (error) {
console.error('Error parsing GPX data:', error)
}
}, [gpxData])
return (
<div className="h-full w-full relative">
<MapContainer
center={[51.505, -0.09]}
zoom={13}
scrollWheelZoom={false}
className="h-full rounded-md"
ref={mapRef}
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<Polyline
positions={routePoints}
color="#3b82f6"
weight={4}
/>
<Marker position={[51.505, -0.09]}>
<Popup>Start/End Point</Popup>
</Marker>
</MapContainer>
<div
ref={elevationChartRef}
className="absolute bottom-4 left-4 right-4 h-32 bg-white/90 backdrop-blur-sm rounded-md p-4 shadow-md"
>
{/* Elevation chart will be rendered here */}
</div>
</div>
)
}
export default RouteVisualization

View File

@@ -0,0 +1,116 @@
import { useState } from 'react';
import PropTypes from 'prop-types';
const SectionList = ({ sections, onSplit, onUpdate }) => {
const [editing, setEditing] = useState(null);
const [localSections, setLocalSections] = useState(sections);
const surfaceTypes = ['road', 'gravel', 'mixed', 'trail'];
const gearOptions = {
road: ['Standard (39x25)', 'Mid-compact (36x30)', 'Compact (34x28)'],
gravel: ['1x System', '2x Gravel', 'Adventure'],
trail: ['MTB Wide-range', 'Fat Bike']
};
const handleEdit = (sectionId) => {
setEditing(sectionId);
setLocalSections(sections);
};
const handleSave = () => {
onUpdate(localSections);
setEditing(null);
};
const handleChange = (sectionId, field, value) => {
setLocalSections(prev => prev.map(section =>
section.id === sectionId ? { ...section, [field]: value } : section
));
};
return (
<div className="space-y-4">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-semibold">Route Sections</h3>
<div className="space-x-2">
<button
onClick={() => onSplit([Math.floor(sections.length/2)])}
className="bg-green-600 text-white px-3 py-1 rounded-md hover:bg-green-700"
>
Split Route
</button>
{editing && (
<button
onClick={handleSave}
className="bg-blue-600 text-white px-3 py-1 rounded-md hover:bg-blue-700"
>
Save All
</button>
)}
</div>
</div>
{localSections.map((section) => (
<div key={section.id} className="bg-white p-4 rounded-lg shadow-md">
<div className="flex justify-between items-start mb-2">
<h4 className="font-medium">Section {section.id}</h4>
<button
onClick={() => editing === section.id ? handleSave() : handleEdit(section.id)}
className={`text-sm ${editing === section.id ? 'text-blue-600' : 'text-gray-600 hover:text-gray-800'}`}
>
{editing === section.id ? 'Save' : 'Edit'}
</button>
</div>
<div className="grid grid-cols-2 gap-2 text-sm">
<div className="space-y-1">
<p>Distance: {section.distance} km</p>
<p>Elevation: {section.elevationGain} m</p>
</div>
<div className="space-y-1">
<p>Max Grade: {section.maxGrade}%</p>
<p>Surface:
{editing === section.id ? (
<select
value={section.surfaceType}
onChange={(e) => handleChange(section.id, 'surfaceType', e.target.value)}
className="ml-2 p-1 border rounded"
>
{surfaceTypes.map(type => (
<option key={type} value={type}>{type.charAt(0).toUpperCase() + type.slice(1)}</option>
))}
</select>
) : (
<span className="ml-2 capitalize">{section.surfaceType}</span>
)}
</p>
</div>
</div>
{editing === section.id && (
<div className="mt-3 pt-2 border-t">
<label className="block text-sm font-medium mb-1">Gear Recommendation</label>
<select
value={section.gearRecommendation}
onChange={(e) => handleChange(section.id, 'gearRecommendation', e.target.value)}
className="w-full p-1 border rounded"
>
{gearOptions[section.surfaceType]?.map(gear => (
<option key={gear} value={gear}>{gear}</option>
))}
</select>
</div>
)}
</div>
))}
</div>
);
};
SectionList.propTypes = {
sections: PropTypes.arrayOf(PropTypes.object).isRequired,
onSplit: PropTypes.func.isRequired,
onUpdate: PropTypes.func.isRequired
};
export default SectionList;

View File

@@ -0,0 +1,104 @@
import { useState, useEffect } from 'react'
import { Button, Input, Select } from '../ui'
import { FaPlus, FaTrash } from 'react-icons/fa'
const SectionManager = ({ route, onSectionsUpdate }) => {
const [sections, setSections] = useState(route.sections || [])
const [newSection, setNewSection] = useState({
name: '',
start: 0,
end: 0,
difficulty: 3,
recommended_gear: 'road'
})
useEffect(() => {
onSectionsUpdate(sections)
}, [sections, onSectionsUpdate])
const addSection = () => {
if (newSection.name && newSection.start < newSection.end) {
setSections([...sections, {
...newSection,
id: Date.now().toString(),
distance: newSection.end - newSection.start
}])
setNewSection({
name: '',
start: 0,
end: 0,
difficulty: 3,
recommended_gear: 'road'
})
}
}
const removeSection = (sectionId) => {
setSections(sections.filter(s => s.id !== sectionId))
}
return (
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-5 gap-4 items-end">
<Input
label="Section Name"
value={newSection.name}
onChange={(e) => setNewSection({...newSection, name: e.target.value})}
/>
<Input
type="number"
label="Start (km)"
value={newSection.start}
onChange={(e) => setNewSection({...newSection, start: +e.target.value})}
/>
<Input
type="number"
label="End (km)"
value={newSection.end}
onChange={(e) => setNewSection({...newSection, end: +e.target.value})}
/>
<Select
label="Difficulty"
value={newSection.difficulty}
options={[1,2,3,4,5].map(n => ({value: n, label: `${n}/5`}))}
onChange={(e) => setNewSection({...newSection, difficulty: +e.target.value})}
/>
<Select
label="Gear"
value={newSection.recommended_gear}
options={[
{value: 'road', label: 'Road Bike'},
{value: 'gravel', label: 'Gravel Bike'},
{value: 'tt', label: 'Time Trial'},
{value: 'climbing', label: 'Climbing Bike'}
]}
onChange={(e) => setNewSection({...newSection, recommended_gear: e.target.value})}
/>
<Button onClick={addSection} className="h-[42px]">
<FaPlus className="mr-2" /> Add Section
</Button>
</div>
<div className="space-y-4">
{sections.map(section => (
<div key={section.id} className="flex items-center gap-4 p-4 bg-gray-50 rounded-lg">
<div className="flex-1 grid grid-cols-4 gap-4">
<p className="font-medium">{section.name}</p>
<p>{section.start}km - {section.end}km</p>
<p>Difficulty: {section.difficulty}/5</p>
<p className="capitalize">{section.recommended_gear.replace('_', ' ')}</p>
</div>
<button
onClick={() => removeSection(section.id)}
className="text-red-600 hover:text-red-700 p-2"
>
<FaTrash />
</button>
</div>
))}
</div>
</div>
)
}
export default SectionManager

View File

@@ -0,0 +1,26 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import FileUpload from '../FileUpload'
describe('FileUpload', () => {
const mockFile = new File(['<gpx><trk><name>Test Route</name></trk></gpx>'], 'test.gpx', {
type: 'application/gpx+xml'
})
it('handles file upload and preview', async () => {
const mockSuccess = jest.fn()
render(<FileUpload onUploadSuccess={mockSuccess} />)
// Simulate file drop
const dropZone = screen.getByText('Drag and drop GPX file here')
fireEvent.dragOver(dropZone)
fireEvent.drop(dropZone, {
dataTransfer: { files: [mockFile] }
})
// Check preview
await waitFor(() => {
expect(screen.getByText('Test Route')).toBeInTheDocument()
expect(screen.getByText('Confirm Upload')).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,41 @@
import { render, screen, fireEvent } from '@testing-library/react'
import RouteList from '../RouteList'
const mockRoutes = [
{
id: 1,
name: 'Mountain Loop',
distance: 45000,
elevation_gain: 800,
category: 'climbing'
},
{
id: 2,
name: 'Lakeside Ride',
distance: 25000,
elevation_gain: 200,
category: 'flat'
}
]
describe('RouteList', () => {
it('displays routes and handles filtering', () => {
render(<RouteList routes={mockRoutes} />)
// Check initial render
expect(screen.getByText('Mountain Loop')).toBeInTheDocument()
expect(screen.getByText('Lakeside Ride')).toBeInTheDocument()
// Test search
fireEvent.change(screen.getByPlaceholderText('Search routes...'), {
target: { value: 'mountain' }
})
expect(screen.getByText('Mountain Loop')).toBeInTheDocument()
expect(screen.queryByText('Lakeside Ride')).not.toBeInTheDocument()
// Test category filter
fireEvent.click(screen.getByText('Flat'))
expect(screen.queryByText('Mountain Loop')).not.toBeInTheDocument()
expect(screen.getByText('Lakeside Ride')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,52 @@
import { render, screen, fireEvent } from '@testing-library/react'
import RouteMetadata from '../RouteMetadata'
import { AuthProvider } from '../../../context/AuthContext'
const mockRoute = {
id: 1,
name: 'Test Route',
description: 'Initial description',
category: 'mixed'
}
const Wrapper = ({ children }) => (
<AuthProvider>
{children}
</AuthProvider>
)
describe('RouteMetadata', () => {
it('handles editing and updating route details', async () => {
const mockUpdate = jest.fn()
render(<RouteMetadata route={mockRoute} onUpdate={mockUpdate} />, { wrapper: Wrapper })
// Test initial view mode
expect(screen.getByText('Test Route')).toBeInTheDocument()
expect(screen.getByText('Initial description')).toBeInTheDocument()
// Enter edit mode
fireEvent.click(screen.getByText('Edit'))
// Verify form fields
const nameInput = screen.getByDisplayValue('Test Route')
const descInput = screen.getByDisplayValue('Initial description')
const categorySelect = screen.getByDisplayValue('Mixed')
// Make changes
fireEvent.change(nameInput, { target: { value: 'Updated Route' } })
fireEvent.change(descInput, { target: { value: 'New description' } })
fireEvent.change(categorySelect, { target: { value: 'climbing' } })
// Save changes
fireEvent.click(screen.getByText('Save'))
// Verify update was called
await waitFor(() => {
expect(mockUpdate).toHaveBeenCalledWith(expect.objectContaining({
name: 'Updated Route',
description: 'New description',
category: 'climbing'
}))
})
})
})

View File

@@ -0,0 +1,26 @@
import { render, screen } from '@testing-library/react'
import RouteVisualization from '../RouteVisualization'
const mockGPX = `
<gpx>
<trk>
<trkseg>
<trkpt lat="37.7749" lon="-122.4194"><ele>50</ele></trkpt>
<trkpt lat="37.7859" lon="-122.4294"><ele>60</ele></trkpt>
</trkseg>
</trk>
</gpx>
`
describe('RouteVisualization', () => {
it('renders map with GPX track', () => {
render(<RouteVisualization gpxData={mockGPX} />)
// Check map container is rendered
expect(screen.getByRole('presentation')).toBeInTheDocument()
// Check if polyline is created with coordinates
const path = document.querySelector('.leaflet-overlay-pane path')
expect(path).toHaveAttribute('d', expect.stringContaining('M37.7749 -122.4194L37.7859 -122.4294'))
})
})