diff --git a/backend/src/app.js b/backend/src/app.js index 9c18440..bb43bdf 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -19,6 +19,7 @@ const weatherRoutes = require('./routes/weather'); const weatherPublicRoutes = require('./routes/weatherPublic'); const adminRoutes = require('./routes/admin'); const mowingRoutes = require('./routes/mowing'); +const wateringRoutes = require('./routes/watering'); const { errorHandler } = require('./middleware/errorHandler'); const { authenticateToken } = require('./middleware/auth'); @@ -106,6 +107,7 @@ 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/watering', authenticateToken, wateringRoutes); 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/watering.js b/backend/src/routes/watering.js new file mode 100644 index 0000000..b329a74 --- /dev/null +++ b/backend/src/routes/watering.js @@ -0,0 +1,105 @@ +const express = require('express'); +const pool = require('../config/database'); +const { AppError } = require('../middleware/errorHandler'); + +const router = express.Router(); + +// Compute coverage area in sqft (server-side helper) +const computeCoverageSqft = (point) => { + const { + sprinkler_head_type, + sprinkler_throw_feet, + sprinkler_degrees, + sprinkler_length_feet, + sprinkler_width_feet + } = point; + if (sprinkler_head_type === 'rotor_impact' || sprinkler_head_type === 'spray_fixed') { + const r = Number(sprinkler_throw_feet || 0); + const deg = Math.min(360, Math.max(0, Number(sprinkler_degrees || 360))); + const area = Math.PI * r * r * (deg / 360); + return Math.round(area * 100) / 100; + } + if (sprinkler_head_type === 'oscillating_fan') { + const L = Number(sprinkler_length_feet || 0); + const W = Number(sprinkler_width_feet || 0); + const area = L * W; + return Math.round(area * 100) / 100; + } + if (sprinkler_head_type === 'drip') { + return 0; // coverage represented differently; skip for now + } + return 0; +}; + +// GET /api/watering/plans?property_id=... +router.get('/plans', async (req, res, next) => { + try { + const { property_id } = req.query; + const rows = await pool.query( + `SELECT * FROM watering_plans WHERE user_id=$1 ${property_id? 'AND property_id=$2':''} ORDER BY created_at DESC`, + property_id? [req.user.id, property_id] : [req.user.id] + ); + res.json({ success:true, data:{ plans: rows.rows }}); + } catch (e) { next(e); } +}); + +// POST /api/watering/plans +router.post('/plans', async (req, res, next) => { + try { + const { propertyId, name, notes } = req.body; + if (!propertyId || !name) throw new AppError('propertyId and name required', 400); + // Verify property ownership + const pr = await pool.query('SELECT id FROM properties WHERE id=$1 AND user_id=$2', [propertyId, req.user.id]); + if (pr.rows.length === 0) throw new AppError('Property not found', 404); + + const ins = await pool.query( + `INSERT INTO watering_plans(user_id, property_id, name, notes) VALUES ($1,$2,$3,$4) RETURNING *`, + [req.user.id, propertyId, name, notes||null] + ); + res.status(201).json({ success:true, data:{ plan: ins.rows[0] }}); + } catch (e) { next(e); } +}); + +// GET /api/watering/plans/:id/points +router.get('/plans/:id/points', async (req, res, next) => { + try { + const planId = req.params.id; + const ch = await pool.query('SELECT id FROM watering_plans WHERE id=$1 AND user_id=$2',[planId, req.user.id]); + if (ch.rows.length===0) throw new AppError('Plan not found',404); + const rs = await pool.query('SELECT * FROM watering_plan_points WHERE plan_id=$1 ORDER BY sequence', [planId]); + res.json({ success:true, data:{ points: rs.rows }}); + } catch (e) { next(e); } +}); + +// POST /api/watering/plans/:id/points +router.post('/plans/:id/points', async (req,res,next)=>{ + try { + const planId = req.params.id; + const ch = await pool.query('SELECT id, property_id FROM watering_plans WHERE id=$1 AND user_id=$2',[planId, req.user.id]); + if (ch.rows.length===0) throw new AppError('Plan not found',404); + const seqrs = await pool.query('SELECT COALESCE(MAX(sequence),0)+1 as next FROM watering_plan_points WHERE plan_id=$1',[planId]); + const sequence = seqrs.rows[0].next; + const payload = req.body || {}; + const point = { + sprinkler_head_type: payload.sprinklerHeadType, + sprinkler_throw_feet: payload.throwFeet, + sprinkler_degrees: payload.degrees, + sprinkler_length_feet: payload.lengthFeet, + sprinkler_width_feet: payload.widthFeet + }; + const coverage = computeCoverageSqft(point); + const ins = await pool.query( + `INSERT INTO watering_plan_points + (plan_id, sequence, lat, lng, duration_minutes, sprinkler_mount, sprinkler_head_type, + sprinkler_gpm, sprinkler_throw_feet, sprinkler_degrees, sprinkler_length_feet, sprinkler_width_feet, coverage_sqft) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) RETURNING *`, + [planId, sequence, payload.lat, payload.lng, payload.durationMinutes||0, payload.mountType||null, + payload.sprinklerHeadType||null, payload.gpm||null, payload.throwFeet||null, payload.degrees||null, + payload.lengthFeet||null, payload.widthFeet||null, coverage] + ); + res.status(201).json({ success:true, data:{ point: ins.rows[0] }}); + } catch (e) { next(e); } +}); + +module.exports = router; + diff --git a/database/migrations/V10__sprinkler_equipment.sql b/database/migrations/V10__sprinkler_equipment.sql new file mode 100644 index 0000000..0c48571 --- /dev/null +++ b/database/migrations/V10__sprinkler_equipment.sql @@ -0,0 +1,27 @@ +-- Add sprinkler-specific fields to user_equipment +ALTER TABLE user_equipment + ADD COLUMN IF NOT EXISTS sprinkler_mount VARCHAR(20) CHECK (sprinkler_mount IN ('in_ground','above_ground')), + ADD COLUMN IF NOT EXISTS sprinkler_head_type VARCHAR(30) CHECK (sprinkler_head_type IN ('rotor_impact','oscillating_fan','spray_fixed','drip')), + ADD COLUMN IF NOT EXISTS sprinkler_gpm DECIMAL(8,2), + ADD COLUMN IF NOT EXISTS sprinkler_throw_feet DECIMAL(8,2), + ADD COLUMN IF NOT EXISTS sprinkler_degrees INTEGER, + ADD COLUMN IF NOT EXISTS sprinkler_length_feet DECIMAL(8,2), + ADD COLUMN IF NOT EXISTS sprinkler_width_feet DECIMAL(8,2), + ADD COLUMN IF NOT EXISTS sprinkler_coverage_sqft DECIMAL(10,2); + +-- Ensure a Sprinkler category and type exist +DO $$ +DECLARE cid INT; tid INT; +BEGIN + SELECT id INTO cid FROM equipment_categories WHERE name ILIKE 'Sprinkler' LIMIT 1; + IF cid IS NULL THEN + INSERT INTO equipment_categories(name, description) VALUES ('Sprinkler','Watering sprinklers') RETURNING id INTO cid; + END IF; + SELECT id INTO tid FROM equipment_types WHERE name ILIKE 'Sprinkler' LIMIT 1; + IF tid IS NULL THEN + INSERT INTO equipment_types(name, category_id) VALUES ('Sprinkler', cid); + END IF; +END $$; + +SELECT 'Sprinkler equipment fields added' as migration_status; + diff --git a/database/migrations/V11__watering_plans.sql b/database/migrations/V11__watering_plans.sql new file mode 100644 index 0000000..9c6df3d --- /dev/null +++ b/database/migrations/V11__watering_plans.sql @@ -0,0 +1,34 @@ +-- Watering plans for guiding sprinkler placement and timing +CREATE TABLE IF NOT EXISTS watering_plans ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + property_id INTEGER REFERENCES properties(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + notes TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS watering_plan_points ( + id SERIAL PRIMARY KEY, + plan_id INTEGER REFERENCES watering_plans(id) ON DELETE CASCADE, + sequence INTEGER NOT NULL, + lat DECIMAL(10,8) NOT NULL, + lng DECIMAL(11,8) NOT NULL, + duration_minutes INTEGER DEFAULT 0, + sprinkler_mount VARCHAR(20) CHECK (sprinkler_mount IN ('in_ground','above_ground')), + sprinkler_head_type VARCHAR(30) CHECK (sprinkler_head_type IN ('rotor_impact','oscillating_fan','spray_fixed','drip')), + sprinkler_gpm DECIMAL(8,2), + sprinkler_throw_feet DECIMAL(8,2), + sprinkler_degrees INTEGER, + sprinkler_length_feet DECIMAL(8,2), + sprinkler_width_feet DECIMAL(8,2), + coverage_sqft DECIMAL(10,2), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_watering_plans_user ON watering_plans(user_id); +CREATE INDEX IF NOT EXISTS idx_watering_points_plan ON watering_plan_points(plan_id); + +SELECT 'Watering plans tables created' as migration_status; + diff --git a/frontend/src/App.js b/frontend/src/App.js index 4f64dac..e8536aa 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -27,6 +27,7 @@ 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 Watering from './pages/Watering/Watering'; import Profile from './pages/Profile/Profile'; // Admin pages @@ -256,6 +257,16 @@ function App() { } /> + + + + + + } + /> { + useMapEvents({ + click(e) { onPlace([e.latlng.lat, e.latlng.lng]); } + }); + return null; +}; + +const computeCoverage = (sprinkler) => { + if (sprinkler.type === 'rotor_impact' || sprinkler.type === 'spray_fixed') { + return { kind: 'circle', radius: sprinkler.throwFeet, degrees: sprinkler.degrees || 360 }; + } + if (sprinkler.type === 'oscillating_fan') { + const L = sprinkler.lengthFeet || 0; const W = sprinkler.widthFeet || 0; + return { kind: 'rect', length: L, width: W }; + } + return null; +}; + +const Watering = () => { + const [properties, setProperties] = useState([]); + const [selectedProperty, setSelectedProperty] = useState(null); + const [sections, setSections] = useState([]); + const [placing, setPlacing] = useState(false); + const [sprinklerForm, setSprinklerForm] = useState({ + mount: 'above_ground', + type: 'rotor_impact', + gpm: 2.5, + throwFeet: 20, + degrees: 360, + lengthFeet: 30, + widthFeet: 20, + durationMinutes: 60 + }); + const [plan, setPlan] = useState(null); + const [points, setPoints] = useState([]); + const [guiding, setGuiding] = useState(false); + const [guideIndex, setGuideIndex] = useState(0); + const [currentPos, setCurrentPos] = useState(null); + const [watchId, setWatchId] = useState(null); + + useEffect(() => { (async () => { + try { const r = await propertiesAPI.getAll(); setProperties(r.data?.data?.properties||[]); } + catch(e){ toast.error('Failed to load properties'); } + })(); }, []); + + const loadProperty = async (pid) => { + try { const r = await propertiesAPI.getById(pid); const p = r.data?.data?.property; setSelectedProperty(p); setSections(p.sections||[]); } + catch(e){ toast.error('Failed to load property'); } + }; + + const ensurePlan = async () => { + if (plan) return plan; + if (!selectedProperty) { toast.error('Select a property first'); return null; } + try { const r = await wateringAPI.createPlan({ propertyId: selectedProperty.id, name: `${selectedProperty.name} - Sprinklers` }); setPlan(r.data?.data?.plan); return r.data?.data?.plan; } + catch(e){ toast.error('Failed to create plan'); return null; } + }; + + const onPlace = async (latlng) => { + const p = await ensurePlan(); if (!p) return; + try { + const payload = { + lat: latlng[0], lng: latlng[1], durationMinutes: sprinklerForm.durationMinutes, + mountType: sprinklerForm.mount, + sprinklerHeadType: sprinklerForm.type, + gpm: sprinklerForm.gpm, + throwFeet: sprinklerForm.throwFeet, + degrees: sprinklerForm.degrees, + lengthFeet: sprinklerForm.lengthFeet, + widthFeet: sprinklerForm.widthFeet + }; + const r = await wateringAPI.addPlanPoint(p.id, payload); + setPoints(prev => [...prev, r.data?.data?.point]); + toast.success('Sprinkler location added'); + setPlacing(false); + } catch(e){ toast.error('Failed to add sprinkler point'); } + }; + + const center = useMemo(() => { + if (selectedProperty?.latitude && selectedProperty?.longitude) return [selectedProperty.latitude, selectedProperty.longitude]; + if (sections?.length){ const s=sections[0]; const c=s.polygonData?.coordinates?.[0]?.[0]; if (c) return [c[0], c[1]]; } + return [39.8,-98.6]; + }, [selectedProperty, sections]); + + const startGuidance = () => { + if (points.length === 0) { toast.error('Add at least one point'); return; } + if (!navigator.geolocation) { toast.error('GPS not available'); return; } + setGuiding(true); setGuideIndex(0); + const id = navigator.geolocation.watchPosition(pos => { + setCurrentPos({ lat: pos.coords.latitude, lng: pos.coords.longitude }); + }, err => { console.warn(err); toast.error('GPS error'); }, { enableHighAccuracy:true, maximumAge: 1000, timeout: 10000 }); + setWatchId(id); + }; + + const stopGuidance = () => { if (watchId){ navigator.geolocation.clearWatch(watchId); setWatchId(null);} setGuiding(false); }; + + const distanceFeet = (a,b) => { + const R=6371000; const toRad=d=>d*Math.PI/180; + 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)))*3.28084; + }; + + return ( +
+

Watering - Sprinklers

+
+
+
+ + +
+
+
Sprinkler Settings
+
Mount
+ +
Type
+ + {sprinklerForm.type === 'rotor_impact' || sprinklerForm.type === 'spray_fixed' ? ( + <> +
GPM
+ setSprinklerForm({...sprinklerForm, gpm: parseFloat(e.target.value)})} /> +
Throw distance (ft)
+ setSprinklerForm({...sprinklerForm, throwFeet: parseFloat(e.target.value)})} /> +
Degrees (0‑360)
+ setSprinklerForm({...sprinklerForm, degrees: parseInt(e.target.value||'0',10)})} /> + + ) : ( + <> +
Length (ft)
+ setSprinklerForm({...sprinklerForm, lengthFeet: parseFloat(e.target.value)})} /> +
Width (ft)
+ setSprinklerForm({...sprinklerForm, widthFeet: parseFloat(e.target.value)})} /> + + )} +
Run Duration (minutes)
+ setSprinklerForm({...sprinklerForm, durationMinutes: parseInt(e.target.value||'0',10)})} /> + +
+
+
Plan Points
+
    + {points.map(pt => ( +
  • #{pt.sequence}: {Number(pt.lat).toFixed(5)}, {Number(pt.lng).toFixed(5)} • {pt.duration_minutes} min
  • + ))} + {points.length===0 &&
  • No points yet
  • } +
+
+
+
+
+
+
{guiding && currentPos && points[guideIndex] ? ( + <>Go to point #{points[guideIndex].sequence}: {distanceFeet(currentPos, {lat: Number(points[guideIndex].lat), lng: Number(points[guideIndex].lng)}).toFixed(0)} ft away + ) : 'Map'}
+
+ {!guiding ? ( + + ) : ( + <> + + + + + )} +
+
+ + + {sections.map((s)=> ( + + ))} + {placing && } + {points.map(pt => { + const cov = computeCoverage({ + type: pt.sprinkler_head_type, + throwFeet: parseFloat(pt.sprinkler_throw_feet||0), + degrees: parseInt(pt.sprinkler_degrees||360,10), + lengthFeet: parseFloat(pt.sprinkler_length_feet||0), + widthFeet: parseFloat(pt.sprinkler_width_feet||0) + }); + return ( + + + {cov?.kind==='circle' && ( + + )} + {cov?.kind==='rect' && ( + + )} + + ); + })} + +
+
+
+
+ ); +}; + +export default Watering; diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index 5ab9cf2..897d83d 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -225,6 +225,14 @@ export const weatherAPI = { apiClient.get(`/weather/conditions/suitable/${propertyId}`, { params }), }; +// Watering API endpoints +export const wateringAPI = { + getPlans: (params) => apiClient.get('/watering/plans', { params }), + createPlan: (payload) => apiClient.post('/watering/plans', payload), + getPlanPoints: (planId) => apiClient.get(`/watering/plans/${planId}/points`), + addPlanPoint: (planId, payload) => apiClient.post(`/watering/plans/${planId}/points`, payload), +}; + // Admin API endpoints export const adminAPI = { getDashboard: () => apiClient.get('/admin/dashboard'),