This commit is contained in:
2025-09-09 06:04:29 -07:00
parent a62b4e8c12
commit 1c69424fff
133 changed files with 190095 additions and 322 deletions
+8 -44
View File
@@ -1,64 +1,28 @@
import React, { Component } from 'react';
import React from 'react';
class ErrorBoundary extends Component {
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false, error: null, errorInfo: null };
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error) {
return { hasError: true };
return { hasError: true, error };
}
componentDidCatch(error, errorInfo) {
this.setState({
error: error,
errorInfo: errorInfo
});
// Log error to analytics service in production
if (process.env.NODE_ENV === 'production') {
console.error('Error caught by boundary:', error, errorInfo);
}
console.error('Error caught by boundary:', error, errorInfo);
}
render() {
if (this.state.hasError) {
return (
<div className="error-boundary bg-red-50 border border-red-200 rounded-lg p-6">
<div className="flex items-start">
<div className="flex-shrink-0">
<svg className="h-12 w-12 text-red-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<div className="ml-4">
<h3 className="text-lg font-medium text-red-800">Something went wrong</h3>
<div className="mt-2 text-sm text-red-700">
<p>We're sorry - an unexpected error occurred. Please try refreshing the page.</p>
{process.env.NODE_ENV === 'development' && (
<details className="mt-3">
<summary className="font-medium cursor-pointer">Error details</summary>
<div className="mt-2 bg-red-100 p-2 rounded-md overflow-auto max-h-48">
<p className="font-mono text-xs">{this.state.error && this.state.error.toString()}</p>
<pre className="text-xs mt-2">{this.state.errorInfo?.componentStack}</pre>
</div>
</details>
)}
</div>
<div className="mt-4">
<button
onClick={() => window.location.reload()}
className="px-4 py-2 bg-red-600 text-white rounded-md text-sm font-medium hover:bg-red-700 transition-colors"
>
Reload Page
</button>
</div>
</div>
</div>
<div className="p-4 bg-red-50 text-red-700 rounded-lg">
<h2 className="font-bold">Something went wrong</h2>
<p>{this.state.error.message}</p>
</div>
);
}
return this.props.children;
}
}
+108 -96
View File
@@ -1,10 +1,9 @@
import React, { useState, useCallback } from 'react';
import React, { useState } from 'react';
import { toast } from 'react-toastify';
const FileUpload = ({ onUpload, acceptedTypes = ['.gpx'] }) => {
const [isDragging, setIsDragging] = useState(false);
const [previewContent, setPreviewContent] = useState(null);
const [error, setError] = useState(null);
const [isLoading, setIsLoading] = useState(false);
const [files, setFiles] = useState([]);
const handleDragOver = (e) => {
e.preventDefault();
@@ -18,66 +17,85 @@ const FileUpload = ({ onUpload, acceptedTypes = ['.gpx'] }) => {
const handleDrop = (e) => {
e.preventDefault();
setIsDragging(false);
const files = e.dataTransfer.files;
if (files.length > 0) {
handleFile(files[0]);
}
handleFiles(e.dataTransfer.files);
};
const handleFileInput = (e) => {
const files = e.target.files;
if (files.length > 0) {
handleFile(files[0]);
handleFiles(e.target.files);
};
const handleFiles = (newFiles) => {
const validFiles = Array.from(newFiles).map(file => {
const fileExt = file.name.split('.').pop().toLowerCase();
// Validate file type
if (!acceptedTypes.includes(`.${fileExt}`)) {
return { file, error: 'Invalid file type', progress: 0 };
}
// Validate file size
if (file.size > 10 * 1024 * 1024) {
return { file, error: 'File too large', progress: 0 };
}
return { file, progress: 0, error: null };
});
setFiles(prev => [...prev, ...validFiles]);
uploadFiles(validFiles.filter(f => !f.error));
};
const uploadFiles = async (filesToUpload) => {
for (const fileObj of filesToUpload) {
try {
const formData = new FormData();
formData.append('files', fileObj.file);
const response = await fetch('/api/routes/upload', {
method: 'POST',
body: formData
});
if (!response.ok) throw new Error('Upload failed');
setFiles(prev => prev.map(f =>
f.file === fileObj.file ? { ...f, progress: 100 } : f
));
toast.success(`${fileObj.file.name} uploaded successfully`);
if (onUpload) onUpload(fileObj.file);
} catch (err) {
setFiles(prev => prev.map(f =>
f.file === fileObj.file ? { ...f, error: err.message } : f
));
toast.error(`${fileObj.file.name} upload failed: ${err.message}`);
}
}
};
const handleFile = async (file) => {
setError(null);
// Validate file type
const fileExt = file.name.split('.').pop().toLowerCase();
if (!acceptedTypes.includes(`.${fileExt}`)) {
setError(`Invalid file type. Supported types: ${acceptedTypes.join(', ')}`);
return;
}
// Validate file size (max 10MB)
if (file.size > 10 * 1024 * 1024) {
setError('File size exceeds 10MB limit');
return;
}
const parseGPXMetadata = (content) => {
try {
setIsLoading(true);
// Preview GPX content
if (fileExt === 'gpx') {
const content = await file.text();
setPreviewContent(content);
} else {
setPreviewContent(null);
}
// Pass file to parent component for upload
if (onUpload) {
onUpload(file);
}
} catch (err) {
console.error('File processing error:', err);
setError('Failed to process file');
} finally {
setIsLoading(false);
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(content, "text/xml");
return {
name: xmlDoc.getElementsByTagName('name')[0]?.textContent || 'Unnamed Route',
distance: xmlDoc.getElementsByTagName('distance')[0]?.textContent || 'N/A',
elevation: xmlDoc.getElementsByTagName('ele')[0]?.textContent || 'N/A'
};
} catch {
return null;
}
};
const clearPreview = () => {
setPreviewContent(null);
const removeFile = (fileName) => {
setFiles(prev => prev.filter(f => f.file.name !== fileName));
};
return (
<div className="file-upload">
<div
<div className="space-y-4">
<div
className={`border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors ${
isDragging ? 'border-blue-500 bg-blue-50' : 'border-gray-300 hover:border-blue-400'
}`}
@@ -92,55 +110,49 @@ const FileUpload = ({ onUpload, acceptedTypes = ['.gpx'] }) => {
className="hidden"
accept={acceptedTypes.join(',')}
onChange={handleFileInput}
multiple
/>
<div className="flex flex-col items-center justify-center">
{isLoading ? (
<>
<svg className="animate-spin h-10 w-10 text-blue-500 mx-auto" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<p className="mt-2 text-gray-600">Processing file...</p>
</>
) : (
<>
<svg className="h-10 w-10 text-gray-400 mx-auto" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
<p className="mt-2 text-sm text-gray-600">
<span className="font-medium text-blue-600">Click to upload</span> or drag and drop
</p>
<p className="text-xs text-gray-500 mt-1">
{acceptedTypes.join(', ')} files, max 10MB
</p>
</>
)}
<svg className="h-10 w-10 text-gray-400 mx-auto" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
<p className="mt-2 text-sm text-gray-600">
<span className="font-medium text-blue-600">Click to upload</span> or drag and drop
</p>
<p className="text-xs text-gray-500 mt-1">
{acceptedTypes.join(', ')} files, max 10MB each
</p>
</div>
</div>
{error && (
<div className="mt-2 p-2 bg-red-50 text-red-700 text-sm rounded-md">
{error}
</div>
)}
{previewContent && (
<div className="mt-4">
<div className="flex justify-between items-center mb-2">
<h3 className="font-medium text-gray-800">File Preview</h3>
<button
onClick={clearPreview}
className="text-sm text-gray-500 hover:text-gray-700"
>
Clear preview
</button>
</div>
<div className="bg-gray-50 p-3 rounded-md border border-gray-200 max-h-60 overflow-auto">
<pre className="text-xs text-gray-700 whitespace-pre-wrap">
{previewContent}
</pre>
</div>
{files.length > 0 && (
<div className="space-y-2">
{files.map((fileObj, index) => (
<div key={index} className="p-4 border rounded-lg bg-white">
<div className="flex items-center justify-between mb-2">
<div className="truncate">
<span className="font-medium">{fileObj.file.name}</span>
{fileObj.error && (
<span className="text-red-500 text-sm ml-2">- {fileObj.error}</span>
)}
</div>
<button
onClick={() => removeFile(fileObj.file.name)}
className="text-gray-400 hover:text-gray-600"
>
×
</button>
</div>
{!fileObj.error && (
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-blue-500 h-2 rounded-full transition-all duration-300"
style={{ width: `${fileObj.progress}%` }}
/>
</div>
)}
</div>
))}
</div>
)}
</div>
+28 -4
View File
@@ -9,10 +9,16 @@ const GarminSync = () => {
setSyncing(true);
setError(null);
try {
// Check API key configuration
if (!process.env.REACT_APP_API_KEY) {
throw new Error('API key missing - check environment configuration');
}
const response = await fetch('/api/workouts/sync', {
method: 'POST',
headers: {
'X-API-Key': process.env.REACT_APP_API_KEY
'X-API-Key': process.env.REACT_APP_API_KEY,
'Content-Type': 'application/json'
}
});
@@ -105,10 +111,28 @@ const GarminSync = () => {
</>
)}
{syncStatus.error_message && (
<div className="text-gray-600">Last Updated:</div>
<div className="text-gray-800">
{syncStatus.last_sync_time
? new Date(syncStatus.last_sync_time).toLocaleTimeString([], {
hour: '2-digit', minute: '2-digit', hour12: true
})
: 'Never'}
</div>
{syncStatus.activities_synced > 0 && (
<>
<div className="text-gray-600">Error:</div>
<div className="text-red-600">{syncStatus.error_message}</div>
<div className="text-gray-600">New Activities:</div>
<div className="text-green-600 font-medium">{syncStatus.activities_synced}</div>
</>
)}
{syncStatus.warnings?.length > 0 && (
<>
<div className="text-gray-600">Warnings:</div>
<div className="text-yellow-600 text-sm">
{syncStatus.warnings.join(', ')}
</div>
</>
)}
</div>
@@ -0,0 +1,9 @@
import React from 'react';
const LoadingSpinner = () => (
<div className="flex justify-center items-center h-screen">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
</div>
);
export default LoadingSpinner;
+38
View File
@@ -0,0 +1,38 @@
import { Link } from 'react-router-dom';
const Navigation = () => {
return (
<nav className="bg-white shadow-sm">
<div className="max-w-7xl mx-auto px-4 py-3">
<div className="flex space-x-4">
<Link
to="/"
className="text-gray-700 hover:text-blue-600 px-3 py-2 rounded-md"
>
Dashboard
</Link>
<Link
to="/workouts"
className="text-gray-700 hover:text-blue-600 px-3 py-2 rounded-md"
>
Workouts
</Link>
<Link
to="/plans"
className="text-gray-700 hover:text-blue-600 px-3 py-2 rounded-md"
>
Plans
</Link>
<Link
to="/routes"
className="text-gray-700 hover:text-blue-600 px-3 py-2 rounded-md"
>
Routes
</Link>
</div>
</div>
</nav>
);
};
export default Navigation;
@@ -157,6 +157,45 @@ const WorkoutAnalysis = ({ workout, analysis }) => {
)}
</div>
);
useEffect(() => {
const fetchMetrics = async () => {
try {
const response = await fetch(`/api/workouts/${workout.id}/metrics`);
const data = await response.json();
setMetrics(data);
} catch (err) {
console.error('Error fetching workout metrics:', err);
} finally {
setLoadingMetrics(false);
}
};
if (workout?.id) {
fetchMetrics();
}
}, [workout]);
return (
<div className="workout-analysis bg-white rounded-lg shadow-md p-5 space-y-6">
{/* Workout Summary */}
<div className="workout-summary border-b border-gray-200 pb-4 mb-4">
<h3 className="text-xl font-semibold text-gray-800">
{workout.activity_type || 'Cycling'} - {new Date(workout.start_time).toLocaleDateString()}
</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mt-3 text-sm">
{/* Existing metric cards */}
</div>
</div>
{/* Metrics Charts */}
{/* Existing chart implementation */}
{/* Analysis Content */}
{/* Existing analysis content */}
</div>
);
};
export default WorkoutAnalysis;
@@ -0,0 +1,97 @@
import { useState, useEffect } from 'react';
import axios from 'axios';
import { useAuth } from '../../context/AuthContext';
import WorkoutMetrics from './WorkoutMetrics';
const WorkoutAnalysis = ({ workoutId }) => {
const { apiKey } = useAuth();
const [analysis, setAnalysis] = useState(null);
const [isApproving, setIsApproving] = useState(false);
useEffect(() => {
const fetchAnalysis = async () => {
try {
const response = await axios.get(`/api/analyses/${workoutId}`, {
headers: { 'X-API-Key': apiKey }
});
setAnalysis(response.data);
} catch (error) {
console.error('Error fetching analysis:', error);
}
};
if (workoutId) {
fetchAnalysis();
}
}, [workoutId, apiKey]);
const handleApprove = async () => {
setIsApproving(true);
try {
await axios.post(`/api/analyses/${analysis.id}/approve`, {}, {
headers: { 'X-API-Key': apiKey }
});
// Refresh analysis data
const response = await axios.get(`/api/analyses/${analysis.id}`, {
headers: { 'X-API-Key': apiKey }
});
setAnalysis(response.data);
} catch (error) {
console.error('Approval failed:', error);
}
setIsApproving(false);
};
if (!analysis) return <div>Loading analysis...</div>;
return (
<div className="bg-white p-6 rounded-lg shadow-md">
<h3 className="text-xl font-semibold mb-4">
{analysis.workout.activity_type} Analysis
</h3>
<WorkoutMetrics workout={analysis.workout} />
<div className="mt-6">
<h4 className="font-medium mb-2">AI Feedback</h4>
<div className="bg-gray-50 p-4 rounded-md">
<p className="mb-3">{analysis.jsonb_feedback.summary}</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<h5 className="font-medium">Strengths</h5>
<ul className="list-disc pl-5">
{analysis.jsonb_feedback.strengths.map((s, i) => (
<li key={i} className="text-green-700">{s}</li>
))}
</ul>
</div>
<div>
<h5 className="font-medium">Improvements</h5>
<ul className="list-disc pl-5">
{analysis.jsonb_feedback.areas_for_improvement.map((s, i) => (
<li key={i} className="text-orange-700">{s}</li>
))}
</ul>
</div>
</div>
</div>
</div>
{analysis.suggestions?.length > 0 && !analysis.approved && (
<div className="mt-6">
<button
onClick={handleApprove}
disabled={isApproving}
className="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 disabled:bg-gray-400"
>
{isApproving ? 'Approving...' : 'Approve Suggestions'}
</button>
</div>
)}
<WorkoutCharts metrics={analysis.workout.metrics} />
</div>
);
};
export default WorkoutAnalysis;
@@ -0,0 +1,76 @@
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
const WorkoutCharts = ({ metrics }) => {
if (!metrics?.time_series?.length) return null;
// Process metrics data for charting
const chartData = metrics.time_series.map(entry => ({
time: new Date(entry.start_time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
power: entry.avg_power,
heartRate: entry.avg_heart_rate,
cadence: entry.avg_cadence
}));
return (
<div className="mt-6 space-y-6">
<div className="h-64">
<h4 className="font-medium mb-2">Power Output</h4>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="time" />
<YAxis unit="W" />
<Tooltip />
<Line
type="monotone"
dataKey="power"
stroke="#10b981"
strokeWidth={2}
dot={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
<div className="h-64">
<h4 className="font-medium mb-2">Heart Rate</h4>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="time" />
<YAxis unit="bpm" />
<Tooltip />
<Line
type="monotone"
dataKey="heartRate"
stroke="#3b82f6"
strokeWidth={2}
dot={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
<div className="h-64">
<h4 className="font-medium mb-2">Cadence</h4>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="time" />
<YAxis unit="rpm" />
<Tooltip />
<Line
type="monotone"
dataKey="cadence"
stroke="#f59e0b"
strokeWidth={2}
dot={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
</div>
);
};
export default WorkoutCharts;
@@ -0,0 +1,43 @@
const WorkoutMetrics = ({ workout }) => {
const formatDuration = (seconds) => {
const mins = Math.floor(seconds / 60);
return `${mins} minutes`;
};
return (
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-6">
<div className="bg-gray-50 p-4 rounded-md">
<p className="text-sm text-gray-600">Duration</p>
<p className="text-lg font-medium">
{formatDuration(workout.duration_seconds)}
</p>
</div>
<div className="bg-gray-50 p-4 rounded-md">
<p className="text-sm text-gray-600">Distance</p>
<p className="text-lg font-medium">
{(workout.distance_m / 1000).toFixed(1)} km
</p>
</div>
<div className="bg-gray-50 p-4 rounded-md">
<p className="text-sm text-gray-600">Avg Power</p>
<p className="text-lg font-medium">
{workout.avg_power?.toFixed(0) || '-'} W
</p>
</div>
<div className="bg-gray-50 p-4 rounded-md">
<p className="text-sm text-gray-600">Avg HR</p>
<p className="text-lg font-medium">
{workout.avg_hr?.toFixed(0) || '-'} bpm
</p>
</div>
<div className="bg-gray-50 p-4 rounded-md">
<p className="text-sm text-gray-600">Elevation</p>
<p className="text-lg font-medium">
{workout.elevation_gain_m?.toFixed(0) || '-'} m
</p>
</div>
</div>
);
};
export default WorkoutMetrics;
@@ -0,0 +1,86 @@
import { useState, useEffect } from 'react';
import axios from 'axios';
import { formatDistanceToNow } from 'date-fns';
const GarminSync = ({ apiKey }) => {
const [syncStatus, setSyncStatus] = useState(null);
const [isSyncing, setIsSyncing] = useState(false);
const [error, setError] = useState('');
const fetchSyncStatus = async () => {
try {
const response = await axios.get('/api/workouts/sync-status', {
headers: { 'X-API-Key': apiKey }
});
setSyncStatus(response.data);
} catch (err) {
setError('Failed to fetch sync status');
}
};
const triggerSync = async () => {
setIsSyncing(true);
setError('');
try {
await axios.post('/api/workouts/sync', {}, {
headers: { 'X-API-Key': apiKey }
});
// Poll status every 2 seconds
const interval = setInterval(fetchSyncStatus, 2000);
setTimeout(() => {
clearInterval(interval);
setIsSyncing(false);
}, 30000);
} catch (err) {
setError('Failed to start sync');
setIsSyncing(false);
}
};
useEffect(() => {
fetchSyncStatus();
}, []);
return (
<div className="bg-white p-6 rounded-lg shadow-md">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold">Garmin Connect Sync</h2>
<button
onClick={triggerSync}
disabled={isSyncing}
className={`px-4 py-2 rounded-md ${
isSyncing ? 'bg-gray-300' : 'bg-blue-600 hover:bg-blue-700'
} text-white transition-colors`}
>
{isSyncing ? 'Syncing...' : 'Sync Now'}
</button>
</div>
{error && (
<div className="mb-4 p-3 bg-red-100 text-red-700 rounded-md">{error}</div>
)}
{syncStatus && (
<div className="space-y-2">
<p className="text-sm">
Last sync: {syncStatus.last_sync_time ?
formatDistanceToNow(new Date(syncStatus.last_sync_time)) + ' ago' : 'Never'}
</p>
<p className="text-sm">
Status: <span className="font-medium">{syncStatus.status}</span>
</p>
{syncStatus.activities_synced > 0 && (
<p className="text-sm">
Activities synced: {syncStatus.activities_synced}
</p>
)}
{syncStatus.error_message && (
<p className="text-sm text-red-600">{syncStatus.error_message}</p>
)}
</div>
)}
</div>
);
};
export default GarminSync;
@@ -0,0 +1,93 @@
import { useState, useEffect } from 'react';
import axios from 'axios';
import { useAuth } from '../../context/AuthContext';
import { formatDistanceToNow } from 'date-fns';
const PlanTimeline = ({ planId }) => {
const { apiKey } = useAuth();
const [evolution, setEvolution] = useState([]);
const [selectedVersion, setSelectedVersion] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchEvolution = async () => {
try {
const response = await axios.get(`/api/plans/${planId}/evolution`, {
headers: { 'X-API-Key': apiKey }
});
setEvolution(response.data.evolution_history);
setSelectedVersion(response.data.current_version);
} catch (error) {
console.error('Error fetching plan evolution:', error);
} finally {
setLoading(false);
}
};
if (planId) {
fetchEvolution();
}
}, [planId, apiKey]);
if (loading) return <div>Loading plan history...</div>;
return (
<div className="bg-white p-6 rounded-lg shadow-md">
<h3 className="text-xl font-semibold mb-4">Plan Evolution</h3>
<div className="flex flex-col md:flex-row gap-6">
<div className="md:w-1/3 space-y-4">
{evolution.map((version, idx) => (
<div
key={version.version}
onClick={() => setSelectedVersion(version)}
className={`p-4 border-l-4 cursor-pointer transition-colors ${
selectedVersion?.version === version.version
? 'border-blue-500 bg-blue-50'
: 'border-gray-200 hover:bg-gray-50'
}`}
>
<div className="flex justify-between items-center">
<span className="font-medium">v{version.version}</span>
<span className="text-sm text-gray-500">
{formatDistanceToNow(new Date(version.created_at))} ago
</span>
</div>
<p className="text-sm text-gray-600 mt-1">
{version.trigger || 'Initial version'}
</p>
</div>
))}
</div>
{selectedVersion && (
<div className="md:w-2/3 p-4 bg-gray-50 rounded-md">
<h4 className="font-medium mb-4">
Version {selectedVersion.version} Details
</h4>
<div className="space-y-3">
<p>
<span className="font-medium">Created:</span>{' '}
{new Date(selectedVersion.created_at).toLocaleString()}
</p>
{selectedVersion.changes_summary && (
<p>
<span className="font-medium">Changes:</span>{' '}
{selectedVersion.changes_summary}
</p>
)}
{selectedVersion.parent_plan_id && (
<p>
<span className="font-medium">Parent Version:</span>{' '}
v{selectedVersion.parent_plan_id}
</p>
)}
</div>
</div>
)}
</div>
</div>
);
};
export default PlanTimeline;