This commit is contained in:
Jake Kasper
2025-09-02 09:18:41 -05:00
parent 90af8fab02
commit f29876b041
8 changed files with 389 additions and 4 deletions

View File

@@ -26,6 +26,7 @@ import ApplicationPlan from './pages/Applications/ApplicationPlan';
import ApplicationLog from './pages/Applications/ApplicationLog';
import History from './pages/History/History';
import Weather from './pages/Weather/Weather';
import Mowing from './pages/Mowing/Mowing';
import Profile from './pages/Profile/Profile';
// Admin pages
@@ -240,6 +241,16 @@ function App() {
</ProtectedRoute>
}
/>
<Route
path="/mowing"
element={
<ProtectedRoute>
<Layout>
<Mowing />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/profile"
element={
@@ -335,4 +346,4 @@ function App() {
);
}
export default App;
export default App;

View File

@@ -65,6 +65,12 @@ const Layout = ({ children }) => {
icon: CalendarDaysIcon,
iconSolid: CalendarIconSolid,
},
{
name: 'Mowing',
href: '/mowing',
icon: ClockIcon,
iconSolid: ClockIconSolid,
},
{
name: 'History',
href: '/history',
@@ -343,4 +349,4 @@ const Layout = ({ children }) => {
);
};
export default Layout;
export default Layout;

View File

@@ -0,0 +1,193 @@
import React, { useEffect, useMemo, useState } from 'react';
import { propertiesAPI, equipmentAPI } from '../../services/api';
import { applicationsAPI } from '../../services/api';
import { weatherAPI } from '../../services/api';
import { apiClient } from '../../services/api';
import toast from 'react-hot-toast';
const directionOptions = [
{ value: 'N_S', label: 'North to South' },
{ value: 'E_W', label: 'East to West' },
{ value: 'NE_SW', label: 'NE to SW' },
{ value: 'NW_SE', label: 'NW to SE' },
{ value: 'CIRCULAR', label: 'Circular' },
];
// Lightweight execution component (reuses GPS loop philosophy from applications)
const Mowing = () => {
const [properties, setProperties] = useState([]);
const [equipment, setEquipment] = useState([]);
const [selectedProperty, setSelectedProperty] = useState('');
const [sections, setSections] = useState([]);
const [selectedSections, setSelectedSections] = useState([]);
const [selectedEquipment, setSelectedEquipment] = useState('');
const [cutHeight, setCutHeight] = useState(3.0);
const [direction, setDirection] = useState('N_S');
const [tracking, setTracking] = useState(false);
const [gpsTrack, setGpsTrack] = useState([]);
const [startTime, setStartTime] = useState(null);
const [totalDistance, setTotalDistance] = useState(0);
const [averageSpeed, setAverageSpeed] = useState(0);
const [watchId, setWatchId] = useState(null);
useEffect(() => {
const load = async () => {
try {
const [props, equip] = await Promise.all([
propertiesAPI.getAll(),
equipmentAPI.getAll(),
]);
setProperties(props.data.data.properties || []);
const eq = (equip.data.data.equipment || []).filter((e) =>
(e.categoryName || '').toLowerCase().includes('mower')
);
setEquipment(eq);
} catch (e) {
toast.error('Failed to load planning data');
}
};
load();
}, []);
useEffect(() => {
const loadSections = async () => {
if (!selectedProperty) { setSections([]); return; }
try {
const resp = await propertiesAPI.getById(selectedProperty);
setSections(resp.data.data.property.sections || []);
} catch (e) {
toast.error('Failed to load sections');
}
};
loadSections();
}, [selectedProperty]);
const toRad = (d) => (d * Math.PI) / 180;
const haversineMeters = (a, b) => {
const R = 6371e3;
const dLat = toRad(b.lat - a.lat);
const dLng = toRad(b.lng - a.lng);
const A = Math.sin(dLat/2)**2 + Math.cos(toRad(a.lat))*Math.cos(toRad(b.lat))*Math.sin(dLng/2)**2;
return 2 * R * Math.atan2(Math.sqrt(A), Math.sqrt(1-A));
};
const start = () => {
if (!selectedProperty || selectedSections.length === 0 || !selectedEquipment) {
toast.error('Select property, sections and mower first');
return;
}
if (!navigator.geolocation) { toast.error('GPS not available'); return; }
setTracking(true);
setStartTime(new Date());
setGpsTrack([]);
setTotalDistance(0);
const id = navigator.geolocation.watchPosition((pos) => {
const { latitude, longitude, speed } = pos.coords;
const point = { lat: latitude, lng: longitude, timestamp: new Date(pos.timestamp).toISOString(), speed: speed || 0 };
setGpsTrack((prev) => {
if (prev.length > 0) {
const meters = haversineMeters(prev[prev.length-1], point);
setTotalDistance((d) => d + meters);
const seconds = (new Date(pos.timestamp) - startTime) / 1000;
if (seconds > 0) setAverageSpeed((( (d + meters) / seconds) * 2.237));
}
return [...prev, point];
});
}, (err) => {
toast.error('GPS error: ' + err.message);
}, { enableHighAccuracy: true, timeout: 5000, maximumAge: 1000 });
setWatchId(id);
};
const stop = () => {
if (watchId) navigator.geolocation.clearWatch(watchId);
setWatchId(null);
setTracking(false);
};
const complete = async () => {
stop();
try {
const durationSeconds = startTime ? Math.round((new Date() - startTime)/1000) : 0;
const payload = {
propertyId: Number(selectedProperty),
lawnSectionIds: selectedSections.map(Number),
equipmentId: Number(selectedEquipment),
cutHeightInches: Number(cutHeight),
direction,
gpsTrack: { points: gpsTrack, totalDistance: Math.round(totalDistance*100)/100, duration: durationSeconds, averageSpeed: Math.round(averageSpeed*100)/100 },
averageSpeed: Math.max(averageSpeed, 0.1),
durationSeconds,
totalDistanceMeters: Math.round(totalDistance*100)/100,
notes: ''
};
const resp = await apiClient.post('/mowing/sessions', payload);
toast.success('Mowing session saved');
// reset
setGpsTrack([]); setStartTime(null); setTotalDistance(0); setAverageSpeed(0);
} catch (e) {
console.error(e);
toast.error(e.response?.data?.message || 'Failed to save session');
}
};
return (
<div className="p-6">
<h1 className="text-2xl font-bold text-gray-900 mb-4">Mowing Tracker</h1>
<div className="bg-white p-4 rounded shadow mb-4 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div>
<label className="block text-sm mb-1">Property</label>
<select className="w-full border rounded px-2 py-2" value={selectedProperty} onChange={(e)=>{setSelectedProperty(e.target.value); setSelectedSections([]);}}>
<option value="">Select</option>
{properties.map(p=> <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
</div>
<div>
<label className="block text-sm mb-1">Areas</label>
<select multiple className="w-full border rounded px-2 py-2 h-28" value={selectedSections} onChange={(e)=> setSelectedSections(Array.from(e.target.selectedOptions).map(o=>o.value))}>
{sections.map(s=> <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
</div>
<div>
<label className="block text-sm mb-1">Mower</label>
<select className="w-full border rounded px-2 py-2" value={selectedEquipment} onChange={(e)=> setSelectedEquipment(e.target.value)}>
<option value="">Select</option>
{equipment.map(e=> <option key={e.id} value={e.id}>{e.customName || e.manufacturer || 'Mower'} {e.model}</option>)}
</select>
</div>
<div>
<label className="block text-sm mb-1">Cut Height (in)</label>
<input type="number" step="0.25" className="w-full border rounded px-2 py-2" value={cutHeight} onChange={(e)=> setCutHeight(e.target.value)} />
</div>
<div>
<label className="block text-sm mb-1">Direction</label>
<select className="w-full border rounded px-2 py-2" value={direction} onChange={(e)=> setDirection(e.target.value)}>
{directionOptions.map(d=> <option key={d.value} value={d.value}>{d.label}</option>)}
</select>
</div>
</div>
<div className="bg-white p-4 rounded shadow mb-4">
<div className="flex gap-3">
{!tracking ? (
<button className="btn-primary" onClick={start}>Start Tracking</button>
) : (
<>
<button className="btn-secondary" onClick={stop}>Pause</button>
<button className="btn-primary" onClick={complete}>Complete</button>
</>
)}
</div>
<div className="mt-4 grid grid-cols-4 gap-4 text-sm">
<div><span className="font-medium">Points:</span> {gpsTrack.length}</div>
<div><span className="font-medium">Distance:</span> {(totalDistance*3.28084).toFixed(0)} ft</div>
<div><span className="font-medium">Avg Speed:</span> {averageSpeed.toFixed(1)} mph</div>
<div><span className="font-medium">Duration:</span> {startTime ? Math.round((new Date()-startTime)/60000) : 0} min</div>
</div>
</div>
</div>
);
};
export default Mowing;

View File

@@ -5,7 +5,7 @@ import toast from 'react-hot-toast';
const API_BASE_URL = process.env.REACT_APP_API_URL || 'http://localhost:5000/api';
// Create axios instance
const apiClient = axios.create({
export const apiClient = axios.create({
baseURL: API_BASE_URL,
timeout: 10000,
headers: {