import express from 'express'; import cors from 'cors'; import { createClient } from '@libsql/client'; import multer from 'multer'; import path from 'path'; import { fileURLToPath } from 'url'; import fs from 'fs'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const app = express(); const PORT = process.env.PORT || 3001; // Middleware app.use(cors()); app.use(express.json()); // Serve static files in production if (process.env.NODE_ENV === 'production') { app.use(express.static(path.join(__dirname, '../public'))); } // Serve uploads app.use('/uploads', express.static('uploads')); // Ensure uploads directory exists const uploadsDir = 'uploads'; if (!fs.existsSync(uploadsDir)) { fs.mkdirSync(uploadsDir); } // Configure multer for file uploads const storage = multer.diskStorage({ destination: (req, file, cb) => { cb(null, 'uploads/'); }, filename: (req, file, cb) => { const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9); cb(null, file.fieldname + '-' + uniqueSuffix + path.extname(file.originalname)); } }); const upload = multer({ storage: storage, fileFilter: (req, file, cb) => { if (file.mimetype.startsWith('image/')) { cb(null, true); } else { cb(new Error('Only image files are allowed!'), false); } }, limits: { fileSize: 5 * 1024 * 1024 // 5MB limit } }); // Initialize libsql database const db = createClient({ url: 'file:db/lawn_scheduler.db' }); // Create mowers table await db.execute(` CREATE TABLE IF NOT EXISTS mowers ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, type TEXT NOT NULL, brand TEXT, model TEXT, isActive BOOLEAN DEFAULT 1, createdAt TEXT DEFAULT CURRENT_TIMESTAMP ) `); // Create zones table with updated schema await db.execute(` CREATE TABLE IF NOT EXISTS zones ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, imagePath TEXT, lastMowedDate TEXT, intervalDays INTEGER, nextMowDate TEXT, scheduleType TEXT DEFAULT 'interval', area REAL DEFAULT 0, createdAt TEXT DEFAULT CURRENT_TIMESTAMP ) `); // Create mowing history table await db.execute(` CREATE TABLE IF NOT EXISTS mowing_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, zoneId INTEGER NOT NULL, mowerId INTEGER, mowedDate TEXT NOT NULL, notes TEXT, duration INTEGER, -- in minutes weather TEXT, activityType TEXT DEFAULT 'mowing', -- 'mowing' or 'trimming' sessionId TEXT, -- for grouping bulk mowing sessions createdAt TEXT DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (zoneId) REFERENCES zones (id) ON DELETE CASCADE, FOREIGN KEY (mowerId) REFERENCES mowers (id) ON DELETE SET NULL ) `); // Add new columns to existing zones table if they don't exist try { await db.execute(`ALTER TABLE zones ADD COLUMN nextMowDate TEXT`); } catch (error) { // Column already exists, ignore error } try { await db.execute(`ALTER TABLE zones ADD COLUMN scheduleType TEXT DEFAULT 'interval'`); } catch (error) { // Column already exists, ignore error } // Add area column to existing table if it doesn't exist try { await db.execute(`ALTER TABLE zones ADD COLUMN area REAL DEFAULT 0`); } catch (error) { // Column already exists, ignore error } // Add mowerId column to existing mowing_history table if it doesn't exist try { await db.execute(`ALTER TABLE mowing_history ADD COLUMN mowerId INTEGER REFERENCES mowers (id) ON DELETE SET NULL`); } catch (error) { // Column already exists, ignore error } // Add sessionId column to existing mowing_history table if it doesn't exist try { await db.execute(`ALTER TABLE mowing_history ADD COLUMN sessionId TEXT`); } catch (error) { // Column already exists, ignore error } // Add activityType column to existing mowing_history table if it doesn't exist try { await db.execute(`ALTER TABLE mowing_history ADD COLUMN activityType TEXT DEFAULT 'mowing'`); } catch (error) { // Column already exists, ignore error } // Insert default mowers if table is empty const mowerCountResult = await db.execute('SELECT COUNT(*) as count FROM mowers'); const mowerCount = mowerCountResult.rows[0].count; if (mowerCount === 0) { await db.execute({ sql: 'INSERT INTO mowers (name, type, brand, model) VALUES (?, ?, ?, ?)', args: ['Battery Mower', 'Battery', 'Generic', 'Battery Model'] }); await db.execute({ sql: 'INSERT INTO mowers (name, type, brand, model) VALUES (?, ?, ?, ?)', args: ['Electric Mower', 'Electric', 'Generic', 'Electric Model'] }); } // Insert sample data if table is empty const countResult = await db.execute('SELECT COUNT(*) as count FROM zones'); const count = countResult.rows[0].count; if (count === 0) { const today = new Date(); const weekAgo = new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000); const twoWeeksAgo = new Date(today.getTime() - 14 * 24 * 60 * 60 * 1000); // Get mower IDs for sample data const mowersResult = await db.execute('SELECT id FROM mowers LIMIT 2'); const batteryMowerId = mowersResult.rows[0]?.id || 1; const electricMowerId = mowersResult.rows[1]?.id || 2; // Insert zones with different schedule types const frontYardResult = await db.execute({ sql: 'INSERT INTO zones (name, lastMowedDate, intervalDays, scheduleType, area) VALUES (?, ?, ?, ?, ?)', args: ['Front Yard', weekAgo.toISOString(), 7, 'interval', 150.5] }); const backYardResult = await db.execute({ sql: 'INSERT INTO zones (name, lastMowedDate, intervalDays, scheduleType, area) VALUES (?, ?, ?, ?, ?)', args: ['Back Yard', twoWeeksAgo.toISOString(), 10, 'interval', 280.0] }); const nextWeek = new Date(today.getTime() + 7 * 24 * 60 * 60 * 1000); const sideGardenResult = await db.execute({ sql: 'INSERT INTO zones (name, lastMowedDate, nextMowDate, scheduleType, area) VALUES (?, ?, ?, ?, ?)', args: ['Side Garden', today.toISOString(), nextWeek.toISOString(), 'specific', 75.25] }); // Insert sample mowing history with mower assignments const sampleHistory = [ { zoneId: frontYardResult.lastInsertRowid, mowerId: batteryMowerId, date: today, duration: 45, weather: 'Sunny', notes: 'Perfect mowing conditions today' }, { zoneId: frontYardResult.lastInsertRowid, mowerId: electricMowerId, date: weekAgo, duration: 40, weather: 'Cloudy', notes: 'Grass was a bit wet from morning dew' }, { zoneId: backYardResult.lastInsertRowid, mowerId: electricMowerId, date: new Date(today.getTime() - 2 * 24 * 60 * 60 * 1000), duration: 60, weather: 'Partly cloudy', notes: 'Trimmed edges and cleaned up leaves' }, { zoneId: sideGardenResult.lastInsertRowid, mowerId: batteryMowerId, date: new Date(today.getTime() - 3 * 24 * 60 * 60 * 1000), duration: 25, weather: 'Sunny', notes: 'Quick touch-up, looks great' }, { zoneId: frontYardResult.lastInsertRowid, mowerId: batteryMowerId, date: new Date(today.getTime() - 14 * 24 * 60 * 60 * 1000), duration: 50, weather: 'Overcast', notes: 'First mow of the season' }, { zoneId: backYardResult.lastInsertRowid, mowerId: electricMowerId, date: new Date(today.getTime() - 16 * 24 * 60 * 60 * 1000), duration: 65, weather: 'Sunny', notes: 'Had to go slow due to thick growth' }, { zoneId: sideGardenResult.lastInsertRowid, mowerId: batteryMowerId, date: new Date(today.getTime() - 17 * 24 * 60 * 60 * 1000), duration: 30, weather: 'Windy', notes: 'Challenging conditions but got it done' }, { zoneId: frontYardResult.lastInsertRowid, mowerId: electricMowerId, date: new Date(today.getTime() - 21 * 24 * 60 * 60 * 1000), duration: 42, weather: 'Cool', notes: 'Nice cool morning for mowing' }, { zoneId: backYardResult.lastInsertRowid, mowerId: batteryMowerId, date: new Date(today.getTime() - 30 * 24 * 60 * 60 * 1000), duration: 55, weather: 'Humid', notes: 'Very humid day, took frequent breaks' }, { zoneId: sideGardenResult.lastInsertRowid, mowerId: electricMowerId, date: new Date(today.getTime() - 31 * 24 * 60 * 60 * 1000), duration: 28, weather: 'Sunny', notes: 'Beautiful day for yard work' }, { zoneId: frontYardResult.lastInsertRowid, mowerId: batteryMowerId, date: new Date(today.getTime() - 35 * 24 * 60 * 60 * 1000), duration: 38, weather: 'Partly cloudy', notes: 'Standard weekly maintenance' }, { zoneId: backYardResult.lastInsertRowid, mowerId: electricMowerId, date: new Date(today.getTime() - 44 * 24 * 60 * 60 * 1000), duration: 70, weather: 'Hot', notes: 'Very hot day, started early morning' }, ]; for (const history of sampleHistory) { await db.execute({ sql: 'INSERT INTO mowing_history (zoneId, mowerId, mowedDate, duration, weather, notes) VALUES (?, ?, ?, ?, ?, ?)', args: [history.zoneId, history.mowerId, history.date.toISOString(), history.duration, history.weather, history.notes] }); } } // Helper function to calculate zone status function calculateZoneStatus(zone) { const today = new Date(); // For new zones that haven't been mowed yet if (!zone.lastMowedDate) { return { ...zone, daysSinceLastMow: null, daysUntilNext: null, status: 'new', isOverdue: false, isDueToday: false, isNew: true }; } const lastMowed = new Date(zone.lastMowedDate); const daysSinceLastMow = Math.floor((today - lastMowed) / (1000 * 60 * 60 * 24)); let daysUntilNext; let status = 'ok'; if (zone.scheduleType === 'specific' && zone.nextMowDate) { // Specific date scheduling const nextMowDate = new Date(zone.nextMowDate); daysUntilNext = Math.floor((nextMowDate - today) / (1000 * 60 * 60 * 24)); } else { // Interval-based scheduling daysUntilNext = zone.intervalDays - daysSinceLastMow; } if (daysUntilNext < 0) { status = 'overdue'; } else if (daysUntilNext <= 0) { status = 'due'; } else if (daysUntilNext <= 1) { status = 'due'; } return { ...zone, daysSinceLastMow, daysUntilNext, status, isOverdue: daysUntilNext < 0, isDueToday: daysUntilNext <= 0 && daysUntilNext >= -1, isNew: false }; } // Helper function to calculate next mow date for interval scheduling function calculateNextMowDate(lastMowedDate, intervalDays) { if (!lastMowedDate || !intervalDays) return null; const lastMowed = new Date(lastMowedDate); const nextMow = new Date(lastMowed.getTime() + (intervalDays * 24 * 60 * 60 * 1000)); return nextMow.toISOString(); } // Helper function to generate session ID function generateSessionId() { return `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; } // Mower routes app.get('/api/mowers', async (req, res) => { try { const result = await db.execute('SELECT * FROM mowers WHERE isActive = 1 ORDER BY name'); const mowers = result.rows.map(row => ({ id: row.id, name: row.name, type: row.type, brand: row.brand, model: row.model, isActive: row.isActive, createdAt: row.createdAt })); res.json(mowers); } catch (error) { res.status(500).json({ error: error.message }); } }); app.post('/api/mowers', async (req, res) => { try { const { name, type, brand, model } = req.body; const result = await db.execute({ sql: 'INSERT INTO mowers (name, type, brand, model) VALUES (?, ?, ?, ?)', args: [name, type, brand || null, model || null] }); const newMowerResult = await db.execute({ sql: 'SELECT * FROM mowers WHERE id = ?', args: [result.lastInsertRowid] }); const newMower = { id: newMowerResult.rows[0].id, name: newMowerResult.rows[0].name, type: newMowerResult.rows[0].type, brand: newMowerResult.rows[0].brand, model: newMowerResult.rows[0].model, isActive: newMowerResult.rows[0].isActive, createdAt: newMowerResult.rows[0].createdAt }; res.status(201).json(newMower); } catch (error) { res.status(500).json({ error: error.message }); } }); // Routes app.get('/api/zones', async (req, res) => { try { const result = await db.execute('SELECT * FROM zones ORDER BY name'); const zones = result.rows.map(row => ({ id: row.id, name: row.name, imagePath: row.imagePath, lastMowedDate: row.lastMowedDate, intervalDays: row.intervalDays, nextMowDate: row.nextMowDate, scheduleType: row.scheduleType || 'interval', area: row.area || 0, createdAt: row.createdAt })); const zonesWithStatus = zones.map(calculateZoneStatus); res.json(zonesWithStatus); } catch (error) { res.status(500).json({ error: error.message }); } }); app.get('/api/zones/:id', async (req, res) => { try { const result = await db.execute({ sql: 'SELECT * FROM zones WHERE id = ?', args: [req.params.id] }); if (result.rows.length === 0) { return res.status(404).json({ error: 'Zone not found' }); } const zone = { id: result.rows[0].id, name: result.rows[0].name, imagePath: result.rows[0].imagePath, lastMowedDate: result.rows[0].lastMowedDate, intervalDays: result.rows[0].intervalDays, nextMowDate: result.rows[0].nextMowDate, scheduleType: result.rows[0].scheduleType || 'interval', area: result.rows[0].area || 0, createdAt: result.rows[0].createdAt }; const zoneWithStatus = calculateZoneStatus(zone); res.json(zoneWithStatus); } catch (error) { res.status(500).json({ error: error.message }); } }); // Get mowing history for all zones or specific zone with pagination app.get('/api/history', async (req, res) => { try { const { zoneId, limit = 10, offset = 0 } = req.query; // Get total count for pagination let countSql = ` SELECT COUNT(*) as total FROM mowing_history mh JOIN zones z ON mh.zoneId = z.id LEFT JOIN mowers m ON mh.mowerId = m.id `; let countArgs = []; if (zoneId) { countSql += ' WHERE mh.zoneId = ?'; countArgs.push(zoneId); } const countResult = await db.execute({ sql: countSql, args: countArgs }); const total = countResult.rows[0].total; // Get paginated results let sql = ` SELECT mh.*, z.name as zoneName, z.area as zoneArea, m.name as mowerName, m.type as mowerType FROM mowing_history mh JOIN zones z ON mh.zoneId = z.id LEFT JOIN mowers m ON mh.mowerId = m.id `; let args = []; if (zoneId) { sql += ' WHERE mh.zoneId = ?'; args.push(zoneId); } sql += ' ORDER BY mh.mowedDate DESC LIMIT ? OFFSET ?'; args.push(parseInt(limit), parseInt(offset)); const result = await db.execute({ sql, args }); const history = result.rows.map(row => ({ id: row.id, zoneId: row.zoneId, zoneName: row.zoneName, zoneArea: row.zoneArea, mowerId: row.mowerId, mowerName: row.mowerName, mowerType: row.mowerType, mowedDate: row.mowedDate, notes: row.notes, duration: row.duration, weather: row.weather, activityType: row.activityType || 'mowing', sessionId: row.sessionId, createdAt: row.createdAt })); // Calculate pagination info const currentPage = Math.floor(parseInt(offset) / parseInt(limit)) + 1; const totalPages = Math.ceil(total / parseInt(limit)); const hasNextPage = currentPage < totalPages; const hasPrevPage = currentPage > 1; res.json({ data: history, pagination: { total, currentPage, totalPages, hasNextPage, hasPrevPage, limit: parseInt(limit), offset: parseInt(offset) } }); } catch (error) { res.status(500).json({ error: error.message }); } }); // Get mowing statistics app.get('/api/history/stats', async (req, res) => { try { const { period = '30' } = req.query; // days const periodDays = parseInt(period); const startDate = new Date(); startDate.setDate(startDate.getDate() - periodDays); // Total mowing sessions const totalResult = await db.execute({ sql: ` SELECT COUNT(*) as total FROM ( SELECT 1 FROM mowing_history WHERE mowedDate >= ? AND sessionId IS NULL UNION ALL SELECT 1 FROM mowing_history WHERE mowedDate >= ? AND sessionId IS NOT NULL GROUP BY sessionId ) AS combined `, args: [startDate.toISOString(), startDate.toISOString()] }); // Total time spent const timeResult = await db.execute({ sql: 'SELECT SUM(duration) as totalMinutes FROM mowing_history WHERE mowedDate >= ? AND duration IS NOT NULL', args: [startDate.toISOString()] }); // Total area mowed const areaResult = await db.execute({ sql: ` SELECT SUM(z.area) as totalArea FROM mowing_history mh JOIN zones z ON mh.zoneId = z.id WHERE mh.mowedDate >= ? `, args: [startDate.toISOString()] }); // Most active zone const activeZoneResult = await db.execute({ sql: ` SELECT z.name, COUNT(*) as sessions FROM mowing_history mh JOIN zones z ON mh.zoneId = z.id WHERE mh.mowedDate >= ? GROUP BY mh.zoneId, z.name ORDER BY sessions DESC LIMIT 1 `, args: [startDate.toISOString()] }); // Most used mower const activeMowerResult = await db.execute({ sql: ` SELECT m.name, m.type, COUNT(*) as sessions FROM mowing_history mh JOIN mowers m ON mh.mowerId = m.id WHERE mh.mowedDate >= ? GROUP BY mh.mowerId, m.name, m.type ORDER BY sessions DESC LIMIT 1 `, args: [startDate.toISOString()] }); // Daily activity for chart const dailyResult = await db.execute({ sql: ` SELECT DATE(mowedDate) as date, COUNT(*) as sessions, SUM(duration) as totalDuration, SUM(z.area) as totalArea FROM mowing_history mh JOIN zones z ON mh.zoneId = z.id WHERE mowedDate >= ? GROUP BY DATE(mowedDate) ORDER BY date DESC LIMIT 30 `, args: [startDate.toISOString()] }); res.json({ period: periodDays, totalSessions: totalResult.rows[0].total, totalMinutes: timeResult.rows[0].totalMinutes || 0, totalArea: areaResult.rows[0].totalArea || 0, mostActiveZone: activeZoneResult.rows[0] || null, mostUsedMower: activeMowerResult.rows[0] || null, dailyActivity: dailyResult.rows.map(row => ({ date: row.date, sessions: row.sessions, duration: row.totalDuration || 0, area: row.totalArea || 0 })) }); } catch (error) { res.status(500).json({ error: error.message }); } }); app.post('/api/zones', upload.single('image'), async (req, res) => { try { const { name, intervalDays, nextMowDate, scheduleType, area } = req.body; const imagePath = req.file ? `/uploads/${req.file.filename}` : null; // For new zones, don't set lastMowedDate (they haven't been mowed yet) let sql, args; if (scheduleType === 'specific') { sql = 'INSERT INTO zones (name, imagePath, nextMowDate, scheduleType, area) VALUES (?, ?, ?, ?, ?)'; args = [name, imagePath, nextMowDate, scheduleType, parseFloat(area) || 0]; } else { sql = 'INSERT INTO zones (name, imagePath, intervalDays, scheduleType, area) VALUES (?, ?, ?, ?, ?)'; args = [name, imagePath, parseInt(intervalDays), scheduleType || 'interval', parseFloat(area) || 0]; } const result = await db.execute({ sql, args }); const newZoneResult = await db.execute({ sql: 'SELECT * FROM zones WHERE id = ?', args: [result.lastInsertRowid] }); const newZone = { id: newZoneResult.rows[0].id, name: newZoneResult.rows[0].name, imagePath: newZoneResult.rows[0].imagePath, lastMowedDate: newZoneResult.rows[0].lastMowedDate, intervalDays: newZoneResult.rows[0].intervalDays, nextMowDate: newZoneResult.rows[0].nextMowDate, scheduleType: newZoneResult.rows[0].scheduleType || 'interval', area: newZoneResult.rows[0].area || 0, createdAt: newZoneResult.rows[0].createdAt }; const zoneWithStatus = calculateZoneStatus(newZone); res.status(201).json(zoneWithStatus); } catch (error) { res.status(500).json({ error: error.message }); } }); app.put('/api/zones/:id', upload.single('image'), async (req, res) => { try { const { name, intervalDays, lastMowedDate, nextMowDate, scheduleType, area } = req.body; const existingResult = await db.execute({ sql: 'SELECT * FROM zones WHERE id = ?', args: [req.params.id] }); if (existingResult.rows.length === 0) { return res.status(404).json({ error: 'Zone not found' }); } const existingZone = existingResult.rows[0]; const imagePath = req.file ? `/uploads/${req.file.filename}` : existingZone.imagePath; let sql, args; if (scheduleType === 'specific') { sql = 'UPDATE zones SET name = ?, imagePath = ?, lastMowedDate = ?, nextMowDate = ?, scheduleType = ?, intervalDays = NULL, area = ? WHERE id = ?'; args = [name, imagePath, lastMowedDate || null, nextMowDate, scheduleType, parseFloat(area) || 0, req.params.id]; } else { sql = 'UPDATE zones SET name = ?, imagePath = ?, lastMowedDate = ?, intervalDays = ?, scheduleType = ?, nextMowDate = NULL, area = ? WHERE id = ?'; args = [name, imagePath, lastMowedDate || null, parseInt(intervalDays), scheduleType || 'interval', parseFloat(area) || 0, req.params.id]; } await db.execute({ sql, args }); // Delete old image if new one was provided if (req.file && existingZone.imagePath) { const oldImagePath = path.join(process.cwd(), existingZone.imagePath.substring(1)); if (fs.existsSync(oldImagePath)) { fs.unlinkSync(oldImagePath); } } const updatedResult = await db.execute({ sql: 'SELECT * FROM zones WHERE id = ?', args: [req.params.id] }); const updatedZone = { id: updatedResult.rows[0].id, name: updatedResult.rows[0].name, imagePath: updatedResult.rows[0].imagePath, lastMowedDate: updatedResult.rows[0].lastMowedDate, intervalDays: updatedResult.rows[0].intervalDays, nextMowDate: updatedResult.rows[0].nextMowDate, scheduleType: updatedResult.rows[0].scheduleType || 'interval', area: updatedResult.rows[0].area || 0, createdAt: updatedResult.rows[0].createdAt }; const zoneWithStatus = calculateZoneStatus(updatedZone); res.json(zoneWithStatus); } catch (error) { res.status(500).json({ error: error.message }); } }); app.delete('/api/zones/:id', async (req, res) => { try { const result = await db.execute({ sql: 'SELECT * FROM zones WHERE id = ?', args: [req.params.id] }); if (result.rows.length === 0) { return res.status(404).json({ error: 'Zone not found' }); } const zone = result.rows[0]; // Delete associated image if (zone.imagePath) { const imagePath = path.join(process.cwd(), zone.imagePath.substring(1)); if (fs.existsSync(imagePath)) { fs.unlinkSync(imagePath); } } await db.execute({ sql: 'DELETE FROM zones WHERE id = ?', args: [req.params.id] }); res.status(204).send(); } catch (error) { res.status(500).json({ error: error.message }); } }); app.post('/api/zones/:id/mow', async (req, res) => { try { const { notes, duration, weather, mowerId } = req.body; const today = new Date().toISOString(); // Get current zone data const zoneResult = await db.execute({ sql: 'SELECT * FROM zones WHERE id = ?', args: [req.params.id] }); if (zoneResult.rows.length === 0) { return res.status(404).json({ error: 'Zone not found' }); } const zone = zoneResult.rows[0]; // Update zone's last mowed date and calculate next mow date if using interval scheduling let sql, args; if (zone.scheduleType === 'interval' && zone.intervalDays) { const nextMowDate = calculateNextMowDate(today, zone.intervalDays); sql = 'UPDATE zones SET lastMowedDate = ?, nextMowDate = ? WHERE id = ?'; args = [today, nextMowDate, req.params.id]; } else { // For specific date scheduling, just update last mowed date sql = 'UPDATE zones SET lastMowedDate = ? WHERE id = ?'; args = [today, req.params.id]; } await db.execute({ sql, args }); // Add to mowing history await db.execute({ sql: 'INSERT INTO mowing_history (zoneId, mowerId, mowedDate, notes, duration, weather, activityType) VALUES (?, ?, ?, ?, ?, ?, ?)', args: [req.params.id, mowerId || null, today, notes || null, duration || null, weather || null, 'mowing'] }); const updatedResult = await db.execute({ sql: 'SELECT * FROM zones WHERE id = ?', args: [req.params.id] }); const updatedZone = { id: updatedResult.rows[0].id, name: updatedResult.rows[0].name, imagePath: updatedResult.rows[0].imagePath, lastMowedDate: updatedResult.rows[0].lastMowedDate, intervalDays: updatedResult.rows[0].intervalDays, nextMowDate: updatedResult.rows[0].nextMowDate, scheduleType: updatedResult.rows[0].scheduleType || 'interval', area: updatedResult.rows[0].area || 0, createdAt: updatedResult.rows[0].createdAt }; const zoneWithStatus = calculateZoneStatus(updatedZone); res.json(zoneWithStatus); } catch (error) { res.status(500).json({ error: error.message }); } }); // Trimming endpoint (doesn't update zone schedule) app.post('/api/zones/:id/trim', async (req, res) => { try { const { notes, duration, weather, mowerId } = req.body; const today = new Date().toISOString(); // Get current zone data to verify it exists const zoneResult = await db.execute({ sql: 'SELECT * FROM zones WHERE id = ?', args: [req.params.id] }); if (zoneResult.rows.length === 0) { return res.status(404).json({ error: 'Zone not found' }); } // Add to mowing history with trimming activity type // Note: We don't update the zone's lastMowedDate or nextMowDate for trimming await db.execute({ sql: 'INSERT INTO mowing_history (zoneId, mowerId, mowedDate, notes, duration, weather, activityType) VALUES (?, ?, ?, ?, ?, ?, ?)', args: [req.params.id, mowerId || null, today, notes || null, duration || null, weather || null, 'trimming'] }); res.json({ success: true, message: 'Trimming recorded successfully' }); } catch (error) { res.status(500).json({ error: error.message }); } }); // Bulk mowing endpoint app.post('/api/zones/bulk-mow', async (req, res) => { try { const { selectedZoneIds, notes, totalDuration, weather, mowerId, activityType = 'mowing' } = req.body; if (!selectedZoneIds || selectedZoneIds.length === 0) { return res.status(400).json({ error: 'No zones selected' }); } const today = new Date().toISOString(); const sessionId = generateSessionId(); // Get zone data for selected zones const zoneResults = await Promise.all( selectedZoneIds.map(id => db.execute({ sql: 'SELECT * FROM zones WHERE id = ?', args: [id] }) ) ); const zones = zoneResults.map(result => result.rows[0]).filter(Boolean); if (zones.length === 0) { return res.status(404).json({ error: 'No valid zones found' }); } // Calculate total area for proportional time distribution const totalArea = zones.reduce((sum, zone) => sum + (zone.area || 0), 0); // Process each zone for (const zone of zones) { // Calculate proportional duration let zoneDuration = null; if (totalDuration && totalArea > 0 && zone.area > 0) { const proportion = zone.area / totalArea; zoneDuration = Math.round(totalDuration * proportion); } // Only update zone schedule for mowing, not trimming if (activityType === 'mowing') { // Update zone's last mowed date and calculate next mow date if using interval scheduling let sql, args; if (zone.scheduleType === 'interval' && zone.intervalDays) { const nextMowDate = calculateNextMowDate(today, zone.intervalDays); sql = 'UPDATE zones SET lastMowedDate = ?, nextMowDate = ? WHERE id = ?'; args = [today, nextMowDate, zone.id]; } else { // For specific date scheduling, just update last mowed date sql = 'UPDATE zones SET lastMowedDate = ? WHERE id = ?'; args = [today, zone.id]; } await db.execute({ sql, args }); } // Add to mowing history with session ID await db.execute({ sql: 'INSERT INTO mowing_history (zoneId, mowerId, mowedDate, notes, duration, weather, sessionId, activityType) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', args: [zone.id, mowerId || null, today, notes || null, zoneDuration, weather || null, sessionId, activityType] }); } res.json({ success: true, message: `Successfully recorded ${activityType} session for ${zones.length} zones`, sessionId, zonesUpdated: zones.length }); } catch (error) { res.status(500).json({ error: error.message }); } }); // Serve React app in production if (process.env.NODE_ENV === 'production') { app.get('*', (req, res) => { res.sendFile(path.join(__dirname, '../public/index.html')); }); } // Health check endpoint app.get('/health', (req, res) => { res.status(200).json({ status: 'healthy', timestamp: new Date().toISOString() }); }); app.listen(PORT, '0.0.0.0', () => { console.log(`Server running on http://0.0.0.0:${PORT}`); console.log(`Environment: ${process.env.NODE_ENV || 'development'}`); });