From f29876b041de9bebbfaba075d0bf22e3248ff7b3 Mon Sep 17 00:00:00 2001 From: Jake Kasper Date: Tue, 2 Sep 2025 09:18:41 -0500 Subject: [PATCH] mowing --- backend/src/app.js | 2 + backend/src/routes/mowing.js | 129 ++++++++++++ backend/src/utils/validation.js | 18 +- .../migrations/V3__add_mowing_sessions.sql | 28 +++ frontend/src/App.js | 13 +- frontend/src/components/Layout/Layout.js | 8 +- frontend/src/pages/Mowing/Mowing.js | 193 ++++++++++++++++++ frontend/src/services/api.js | 2 +- 8 files changed, 389 insertions(+), 4 deletions(-) create mode 100644 backend/src/routes/mowing.js create mode 100644 database/migrations/V3__add_mowing_sessions.sql create mode 100644 frontend/src/pages/Mowing/Mowing.js diff --git a/backend/src/app.js b/backend/src/app.js index 5b62819..1310df2 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -18,6 +18,7 @@ const productSpreaderSettingsRoutes = require('./routes/productSpreaderSettings' const weatherRoutes = require('./routes/weather'); const weatherPublicRoutes = require('./routes/weatherPublic'); const adminRoutes = require('./routes/admin'); +const mowingRoutes = require('./routes/mowing'); const { errorHandler } = require('./middleware/errorHandler'); const { authenticateToken } = require('./middleware/auth'); @@ -100,6 +101,7 @@ app.use('/api/equipment', authenticateToken, equipmentRoutes); app.use('/api/nozzles', authenticateToken, nozzleRoutes); app.use('/api/products', authenticateToken, productRoutes); app.use('/api/applications', authenticateToken, applicationRoutes); +app.use('/api/mowing', authenticateToken, mowingRoutes); app.use('/api/spreader-settings', authenticateToken, spreaderSettingsRoutes); app.use('/api/product-spreader-settings', authenticateToken, productSpreaderSettingsRoutes); app.use('/api/weather', authenticateToken, weatherRoutes); diff --git a/backend/src/routes/mowing.js b/backend/src/routes/mowing.js new file mode 100644 index 0000000..89a8344 --- /dev/null +++ b/backend/src/routes/mowing.js @@ -0,0 +1,129 @@ +const express = require('express'); +const pool = require('../config/database'); +const { AppError } = require('../middleware/errorHandler'); +const { validateRequest } = require('../utils/validation'); +const { mowingSessionSchema } = require('../utils/validation'); + +const router = express.Router(); + +// POST /api/mowing/sessions - create a completed mowing session +router.post('/sessions', validateRequest(mowingSessionSchema), async (req, res, next) => { + try { + const { + propertyId, + lawnSectionIds, + equipmentId, + cutHeightInches, + direction, + gpsTrack, + averageSpeed, + durationSeconds, + totalDistanceMeters, + areaCoveredSqft, + notes + } = req.body; + + // Verify ownership of property, sections, equipment + const prop = await pool.query('SELECT id FROM properties WHERE id=$1 AND user_id=$2', [propertyId, req.user.id]); + if (prop.rows.length === 0) throw new AppError('Property not found', 404); + + const sections = await pool.query( + `SELECT ls.id FROM lawn_sections ls JOIN properties p ON ls.property_id=p.id + WHERE ls.id = ANY($1::int[]) AND p.user_id=$2`, + [lawnSectionIds, req.user.id] + ); + if (sections.rows.length !== lawnSectionIds.length) throw new AppError('One or more sections not found', 404); + + const equip = await pool.query('SELECT id FROM user_equipment WHERE id=$1 AND user_id=$2', [equipmentId, req.user.id]); + if (equip.rows.length === 0) throw new AppError('Equipment not found', 404); + + const client = await pool.connect(); + try { + await client.query('BEGIN'); + const ins = await client.query( + `INSERT INTO mowing_sessions + (user_id, property_id, equipment_id, cut_height_inches, direction, gps_track, + duration_seconds, total_distance_meters, average_speed_mph, area_covered_sqft, notes) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) + RETURNING *`, + [ + req.user.id, + propertyId, + equipmentId, + cutHeightInches, + direction, + JSON.stringify(gpsTrack), + durationSeconds, + totalDistanceMeters, + averageSpeed, + areaCoveredSqft || null, + notes || null + ] + ); + const session = ins.rows[0]; + for (const sid of lawnSectionIds) { + await client.query( + `INSERT INTO mowing_session_sections (session_id, lawn_section_id) VALUES ($1,$2)`, + [session.id, sid] + ); + } + await client.query('COMMIT'); + return res.status(201).json({ success: true, data: { session: { id: session.id } } }); + } catch (e) { + await client.query('ROLLBACK'); + throw e; + } finally { + client.release(); + } + } catch (error) { + next(error); + } +}); + +// GET /api/mowing/sessions - list sessions for user +router.get('/sessions', async (req, res, next) => { + try { + const result = await pool.query( + `SELECT ms.*, p.name as property_name, ue.custom_name as equipment_name + FROM mowing_sessions ms + JOIN properties p ON ms.property_id=p.id + LEFT JOIN user_equipment ue ON ms.equipment_id=ue.id + WHERE ms.user_id=$1 + ORDER BY ms.created_at DESC + LIMIT 200`, + [req.user.id] + ); + res.json({ success: true, data: { sessions: result.rows } }); + } catch (error) { + next(error); + } +}); + +// GET /api/mowing/sessions/:id - details +router.get('/sessions/:id', async (req, res, next) => { + try { + const { id } = req.params; + const sessionRes = await pool.query( + `SELECT ms.*, p.name as property_name, ue.custom_name as equipment_name + FROM mowing_sessions ms + JOIN properties p ON ms.property_id=p.id + LEFT JOIN user_equipment ue ON ms.equipment_id=ue.id + WHERE ms.id=$1 AND ms.user_id=$2`, + [id, req.user.id] + ); + if (sessionRes.rows.length === 0) throw new AppError('Session not found', 404); + const sectionsRes = await pool.query( + `SELECT lss.lawn_section_id, ls.name, ls.area, ls.polygon_data + FROM mowing_session_sections lss + JOIN lawn_sections ls ON lss.lawn_section_id=ls.id + WHERE lss.session_id=$1`, + [id] + ); + res.json({ success: true, data: { session: sessionRes.rows[0], sections: sectionsRes.rows } }); + } catch (error) { + next(error); + } +}); + +module.exports = router; + diff --git a/backend/src/utils/validation.js b/backend/src/utils/validation.js index 38e24ac..2d9dd02 100644 --- a/backend/src/utils/validation.js +++ b/backend/src/utils/validation.js @@ -180,6 +180,21 @@ const applicationLogSchema = Joi.object({ }).or('productId', 'userProductId')).min(1).required() }); +// Mowing session validation +const mowingSessionSchema = Joi.object({ + propertyId: Joi.number().integer().positive().required(), + lawnSectionIds: Joi.array().items(Joi.number().integer().positive()).min(1).required(), + equipmentId: Joi.number().integer().positive().required(), + cutHeightInches: Joi.number().positive().precision(2).required(), + direction: Joi.string().valid('N_S','E_W','NE_SW','NW_SE','CIRCULAR').required(), + gpsTrack: Joi.object().required(), + averageSpeed: Joi.number().positive().required(), + durationSeconds: Joi.number().integer().positive().required(), + totalDistanceMeters: Joi.number().positive().required(), + areaCoveredSqft: Joi.number().positive().optional(), + notes: Joi.string().allow('').optional() +}); + // Validation middleware const validateRequest = (schema) => { return (req, res, next) => { @@ -219,9 +234,10 @@ module.exports = { userProductSchema, applicationPlanSchema, applicationLogSchema, + mowingSessionSchema, idParamSchema, // Middleware validateRequest, validateParams -}; \ No newline at end of file +}; diff --git a/database/migrations/V3__add_mowing_sessions.sql b/database/migrations/V3__add_mowing_sessions.sql new file mode 100644 index 0000000..248f9b6 --- /dev/null +++ b/database/migrations/V3__add_mowing_sessions.sql @@ -0,0 +1,28 @@ +-- Mowing sessions to track mowing activity with GPS +CREATE TABLE IF NOT EXISTS mowing_sessions ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + property_id INTEGER REFERENCES properties(id) ON DELETE CASCADE, + equipment_id INTEGER REFERENCES user_equipment(id), + cut_height_inches DECIMAL(4,2), + direction VARCHAR(20) CHECK (direction IN ('N_S','E_W','NE_SW','NW_SE','CIRCULAR')), + session_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + gps_track JSON, + duration_seconds INTEGER, + total_distance_meters DECIMAL(10,2), + average_speed_mph DECIMAL(5,2), + area_covered_sqft DECIMAL(12,2), + notes TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS mowing_session_sections ( + id SERIAL PRIMARY KEY, + session_id INTEGER REFERENCES mowing_sessions(id) ON DELETE CASCADE, + lawn_section_id INTEGER REFERENCES lawn_sections(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_mowing_sessions_user_id ON mowing_sessions(user_id); +CREATE INDEX IF NOT EXISTS idx_mowing_sessions_property_id ON mowing_sessions(property_id); +CREATE INDEX IF NOT EXISTS idx_mowing_session_sections_session_id ON mowing_session_sections(session_id); + diff --git a/frontend/src/App.js b/frontend/src/App.js index 262391d..d3b3d86 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -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() { } /> + + + + + + } + /> { 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; \ No newline at end of file +export default Layout; diff --git a/frontend/src/pages/Mowing/Mowing.js b/frontend/src/pages/Mowing/Mowing.js new file mode 100644 index 0000000..a58be9a --- /dev/null +++ b/frontend/src/pages/Mowing/Mowing.js @@ -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 ( +
+

Mowing Tracker

+
+
+ + +
+
+ + +
+
+ + +
+
+ + setCutHeight(e.target.value)} /> +
+
+ + +
+
+ +
+
+ {!tracking ? ( + + ) : ( + <> + + + + )} +
+
+
Points: {gpsTrack.length}
+
Distance: {(totalDistance*3.28084).toFixed(0)} ft
+
Avg Speed: {averageSpeed.toFixed(1)} mph
+
Duration: {startTime ? Math.round((new Date()-startTime)/60000) : 0} min
+
+
+
+ ); +}; + +export default Mowing; + diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index 49308f9..0db5621 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -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: {