demeter

Autonomous Hydroponic Intelligence
commit d3123e08500f0533f63c75d19fa82ab971f60af1
parent 10d67e93d63b43c2578ab9377fe5a8461634960c
Author: maydayv7 <maydayv7@gmail.com>
Date:   Sat, 28 Mar 2026 03:26:42 +0530

Update frontend to match new backend

Diffstat:
Mbackend/node_server/controllers/cropController.js | 134++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Mbackend/node_server/routes/cropRoutes.js | 20++++++++++++++------
Mbackend/node_server/schema/cropSchema.js | 44++++++++++++++++++++++++++++++--------------
Mfrontend/src/App.js | 2++
Mfrontend/src/api/farmApi.jsx | 98++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mfrontend/src/components/Sidebar.jsx | 188+++++++++++++++++++++++++++----------------------------------------------------
Mfrontend/src/data/mockData.js | 359++++++++++++++++++++++++++++++++++++++++---------------------------------------
Mfrontend/src/hooks/useFarmData.js | 23+++++++++++++++++++----
Mfrontend/src/index.css | 28++++++++++++++--------------
Mfrontend/src/pages/AddCrop.jsx | 1762+++++++++++++++++++++++++++++--------------------------------------------------
Mfrontend/src/pages/Alerts.jsx | 191++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------
Mfrontend/src/pages/Analytics.jsx | 6+++---
Mfrontend/src/pages/CropDetails.jsx | 656+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Mfrontend/src/pages/Dashboard.jsx | 34++++++++++++++++++++--------------
Mfrontend/src/pages/FarmIntelligence.jsx | 8++++----
Mfrontend/src/pages/Help.jsx | 510+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
Mfrontend/src/pages/LandingPage.jsx | 20++++++++++----------
Afrontend/src/pages/RunCycle.jsx | 1144+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mfrontend/src/utils/dataUtils.js | 990++++++++++++++++++++++++++++++++++++++-----------------------------------------
Mfrontend/src/utils/translations.js | 174++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------
20 files changed, 4116 insertions(+), 2275 deletions(-)

diff --git a/backend/node_server/controllers/cropController.js b/backend/node_server/controllers/cropController.js @@ -1,34 +1,89 @@ -const CropStateSchema = require('../schema/cropSchema'); +const CropStateSchema = require("../schema/cropSchema"); + +// HELPERS + +/** Generates a fake sensor hardware ID */ +function fakeSensorId(prefix) { + const chars = "ABCDEF0123456789"; + const rand4 = () => + Array.from( + { length: 4 }, + () => chars[Math.floor(Math.random() * chars.length)], + ).join(""); + return `${prefix}-SEN-${rand4()}`; +} + +/** Auto cycle_duration_hours per crop type */ +const CYCLE_HOURS = { + lettuce: 1, + basil: 1, + tomato: 2, + strawberry: 2, +}; + +function cycleDurationForCrop(cropName) { + if (!cropName) return 1; + return CYCLE_HOURS[cropName.toLowerCase()] || 1; +} + +// CONTROLLERS const createCrop = async (req, res) => { try { - const { crop_id, crop, stage, ...rest } = req.body; + const { crop_id, crop, stage, location, notes, image_url, ...rest } = + req.body; if (!crop_id) { - return res.status(400).json({ error: 'crop_id is required' }); + return res.status(400).json({ error: "crop_id is required" }); } const existingCrop = await CropStateSchema.findOne({ crop_id }); if (existingCrop) { - return res.status(409).json({ error: 'Crop with this ID already exists' }); + return res + .status(409) + .json({ error: "Crop with this ID already exists" }); } const newCrop = new CropStateSchema({ crop_id, crop, - stage: stage || 'seedling', + stage: stage || "seedling", sequence_number: 0, total_crop_lifetime_days: 0, planted_at: new Date(), last_updated: new Date(), - ...rest + + // Auto-set initial sensor arrays + sensors: { + pH: [6.0], + EC: [1.5], + temp: [24.0], + humidity: [60.0], + }, + + // Auto-generate fake sensor IDs + sensor_ids: { + ph_sensor: fakeSensorId("PH"), + ec_sensor: fakeSensorId("EC"), + temp_sensor: fakeSensorId("TMP"), + humidity_sensor: fakeSensorId("HUM"), + }, + + // Auto-set cycle_duration_hours based on crop type + cycle_duration_hours: cycleDurationForCrop(crop), + + location: location || "", + notes: notes || "", + image_url: image_url || "", + + ...rest, }); const savedCrop = await newCrop.save(); res.status(201).json({ - message: 'Crop created successfully', - data: savedCrop + message: "Crop created successfully", + data: savedCrop, }); } catch (error) { res.status(500).json({ error: error.message }); @@ -36,19 +91,71 @@ const createCrop = async (req, res) => { }; const getAllCrops = async (req, res) => { - console.log('getAllCrops endpoint called'); + console.log("getAllCrops endpoint called"); try { const crops = await CropStateSchema.find(); console.log(`Retrieved ${crops.length} crops from the database.`); res.status(200).json({ - message: 'Crops retrieved successfully', - data: crops + message: "Crops retrieved successfully", + data: crops, }); } catch (error) { res.status(500).json({ error: error.message }); } finally { - console.log('getAllCrops endpoint was called'); + console.log("getAllCrops endpoint was called"); + } +}; + +const getCropById = async (req, res) => { + try { + const { cropId } = req.params; + const crop = await CropStateSchema.findOne({ crop_id: cropId }); + if (!crop) { + return res.status(404).json({ error: "Crop not found" }); + } + res.status(200).json({ data: crop }); + } catch (error) { + res.status(500).json({ error: error.message }); } }; -module.exports = { createCrop, getAllCrops }; -\ No newline at end of file +const updateCrop = async (req, res) => { + try { + const { cropId } = req.params; + const updates = { ...req.body, last_updated: new Date() }; + + const updated = await CropStateSchema.findOneAndUpdate( + { crop_id: cropId }, + { $set: updates }, + { new: true, runValidators: true }, + ); + + if (!updated) { + return res.status(404).json({ error: "Crop not found" }); + } + res.status(200).json({ message: "Crop updated", data: updated }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}; + +const deleteCrop = async (req, res) => { + try { + const { cropId } = req.params; + const deleted = await CropStateSchema.findOneAndDelete({ crop_id: cropId }); + if (!deleted) { + return res.status(404).json({ error: "Crop not found" }); + } + res.status(200).json({ message: "Crop deleted", data: deleted }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}; + +module.exports = { + createCrop, + getAllCrops, + getCropById, + updateCrop, + deleteCrop, +}; diff --git a/backend/node_server/routes/cropRoutes.js b/backend/node_server/routes/cropRoutes.js @@ -1,8 +1,17 @@ -const express = require('express'); +const express = require("express"); const router = express.Router(); -const { createCrop, getAllCrops } = require('../controllers/cropController'); +const { + createCrop, + getAllCrops, + getCropById, + updateCrop, + deleteCrop, +} = require("../controllers/cropController"); -router.post('/create', createCrop); -router.get('/all', getAllCrops); +router.post("/create", createCrop); +router.get("/all", getAllCrops); +router.get("/:cropId", getCropById); +router.put("/:cropId", updateCrop); +router.delete("/:cropId", deleteCrop); -module.exports = router; -\ No newline at end of file +module.exports = router; diff --git a/backend/node_server/schema/cropSchema.js b/backend/node_server/schema/cropSchema.js @@ -1,25 +1,42 @@ -const mongoose = require('mongoose'); +const mongoose = require("mongoose"); -const sensorSchema = new mongoose.Schema({ - pH: [Number], - EC: [Number], - temp: [Number], - humidity: [Number] -}, { _id: false }); +const sensorSchema = new mongoose.Schema( + { + pH: [Number], + EC: [Number], + temp: [Number], + humidity: [Number], + }, + { _id: false }, +); + +const sensorIdsSchema = new mongoose.Schema( + { + ph_sensor: { type: String, default: "" }, + ec_sensor: { type: String, default: "" }, + temp_sensor: { type: String, default: "" }, + humidity_sensor: { type: String, default: "" }, + }, + { _id: false }, +); const cropStateSchema = new mongoose.Schema({ crop_id: { type: String, required: true, unique: true }, crop: String, stage: String, sequence_number: { type: Number, default: 0 }, - cycle_duration_hours: { type: Number, default: 1 }, total_crop_lifetime_days: { type: Number, default: 0 }, planted_at: { type: Date, default: Date.now }, last_updated: { type: Date, default: Date.now }, - + sensors: sensorSchema, - + + sensor_ids: { type: sensorIdsSchema, default: () => ({}) }, + location: { type: String, default: "" }, + notes: { type: String, default: "" }, + image_url: { type: String, default: "" }, + action_taken: mongoose.Schema.Types.Mixed, outcome: String, explanation_log: String, @@ -27,8 +44,8 @@ const cropStateSchema = new mongoose.Schema({ strategic_intent: String, reward_score: Number, visual_diagnosis: String, - - schema_version: { type: String, default: "1.1" } + + schema_version: { type: String, default: "1.2" }, }); -module.exports = mongoose.model('CropState', cropStateSchema); -\ No newline at end of file +module.exports = mongoose.model("CropState", cropStateSchema); diff --git a/frontend/src/App.js b/frontend/src/App.js @@ -8,6 +8,7 @@ import LandingPage from "./pages/LandingPage"; import Dashboard from "./pages/Dashboard"; import CropDetails from "./pages/CropDetails"; import AddCrop from "./pages/AddCrop"; +import RunCycle from "./pages/RunCycle"; import FarmIntelligence from "./pages/FarmIntelligence"; import Analytics from "./pages/Analytics"; import Alerts from "./pages/Alerts"; @@ -43,6 +44,7 @@ function AppInner() { <Route path="/dashboard" element={<Dashboard />} /> <Route path="/crop/:cropId" element={<CropDetails />} /> <Route path="/add-crop" element={<AddCrop />} /> + <Route path="/run-cycle/:cropId" element={<RunCycle />} /> <Route path="/intelligence" element={<FarmIntelligence />} /> <Route path="/analytics" element={<Analytics />} /> <Route path="/alerts" element={<Alerts />} /> diff --git a/frontend/src/api/farmApi.jsx b/frontend/src/api/farmApi.jsx @@ -3,26 +3,100 @@ import { USE_MOCK_DATA, MOCK_DASHBOARD, MOCK_HISTORY } from "../data/mockData"; const API_BASE_URL = process.env.REACT_APP_FARM_API_URL || "http://localhost:3001/api"; +// MongoDB CRUD + /** - * Fetches the latest state of all unique crops for the Dashboard. + * Fetches all crops */ export const fetchDashboardData = async () => { if (USE_MOCK_DATA) { - await new Promise((r) => setTimeout(r, 300)); // simulate latency + await new Promise((r) => setTimeout(r, 300)); return MOCK_DASHBOARD; } try { - const res = await fetch(`${API_BASE_URL}/dashboard`); + const res = await fetch(`${API_BASE_URL}/crops/all`); if (!res.ok) throw new Error("Network error"); - return res.json(); + const json = await res.json(); + // Unwrap { data: [...] } response from the node server + return json.data || json || []; } catch { return []; } }; /** - * Fetches the full history (logs, charts) for a specific crop ID. + * Creates a new crop + */ +export const createCrop = async (cropData) => { + const res = await fetch(`${API_BASE_URL}/crops/create`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(cropData), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.error || "Failed to create crop"); + } + return res.json(); +}; + +/** + * Fetches a single crop document by ID + */ +export const fetchCropById = async (cropId) => { + if (USE_MOCK_DATA) { + await new Promise((r) => setTimeout(r, 150)); + const found = MOCK_DASHBOARD.find( + (d) => (d.crop_id || d._id || d.id) === cropId, + ); + return found || null; + } + + try { + const res = await fetch(`${API_BASE_URL}/crops/${cropId}`); + if (!res.ok) return null; + const json = await res.json(); + return json.data || null; + } catch { + return null; + } +}; + +/** + * Partially updates a crop + */ +export const updateCrop = async (cropId, fields) => { + const res = await fetch(`${API_BASE_URL}/crops/${cropId}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(fields), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.error || "Failed to update crop"); + } + return res.json(); +}; + +/** + * Deletes a crop + */ +export const deleteCrop = async (cropId) => { + const res = await fetch(`${API_BASE_URL}/crops/${cropId}`, { + method: "DELETE", + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.error || "Failed to delete crop"); + } + return res.json(); +}; + +// Qdrant (history / analytics) + +/** + * Fetches the full cycle history for a specific crop */ export const fetchCropDetails = async (cropId) => { if (USE_MOCK_DATA) { @@ -40,8 +114,8 @@ export const fetchCropDetails = async (cropId) => { }; /** - * Fetches full history for every crop present in the dashboard snapshot. - * Returns a flat array of all point objects sorted oldest → newest by timestamp. + * Fetches full history for every crop present in the dashboard snapshot + * dashboardItems is the raw normalized array (each item has .payload.crop_id or .crop_id) */ export const fetchAllCropHistories = async (dashboardItems) => { if (USE_MOCK_DATA) { @@ -54,9 +128,16 @@ export const fetchAllCropHistories = async (dashboardItems) => { } if (!dashboardItems?.length) return []; + + // Support both normalized { payload: { crop_id } } and flat { crop_id } const cropIds = [ - ...new Set(dashboardItems.map((i) => i.payload?.crop_id).filter(Boolean)), + ...new Set( + dashboardItems + .map((i) => i.payload?.crop_id || i.crop_id) + .filter(Boolean), + ), ]; + const results = await Promise.allSettled( cropIds.map((id) => fetch(`${API_BASE_URL}/crop/${id}`) @@ -64,6 +145,7 @@ export const fetchAllCropHistories = async (dashboardItems) => { .catch(() => []), ), ); + const all = results.flatMap((r) => (r.status === "fulfilled" ? r.value : [])); return all.sort((a, b) => { const ta = new Date(a.payload?.timestamp || 0).getTime(); diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx @@ -10,6 +10,7 @@ import { HelpCircle, ChevronLeft, ChevronRight, + Shield, } from "lucide-react"; import { useFarmData } from "../hooks/useFarmData"; import { deriveCropStatus, isReadyToHarvest } from "../utils/dataUtils"; @@ -26,14 +27,18 @@ export default function Sidebar() { const alertCount = useMemo(() => { if (!dashboard?.length) return 0; return dashboard.filter((d) => { - const status = deriveCropStatus(d.payload); + const payload = d.payload || d; + const status = deriveCropStatus(payload); return status === "Critical" || status === "Attention"; }).length; }, [dashboard]); const harvestCount = useMemo(() => { if (!dashboard?.length) return 0; - return dashboard.filter((d) => isReadyToHarvest(d.payload)).length; + return dashboard.filter((d) => { + const payload = d.payload || d; + return isReadyToHarvest(payload); + }).length; }, [dashboard]); const totalBadge = alertCount + harvestCount; @@ -266,23 +271,23 @@ export default function Sidebar() { color: "var(--amber)", }} > - 🌾 {harvest} + 🌾{harvest} </span> )} </div> )} - {/* Badge (collapsed) */} + {badge && collapsed && ( <span className="alert-pulse" style={{ position: "absolute", - top: 4, - right: 4, - width: 7, - height: 7, + top: 6, + right: 6, + width: 8, + height: 8, borderRadius: "50%", - background: alertCount > 0 ? "var(--red)" : "var(--amber)", + background: "var(--red)", }} /> )} @@ -291,145 +296,80 @@ export default function Sidebar() { })} </nav> - {/* Alert / harvest status */} - {!collapsed && ( - <div style={{ padding: "0 12px 12px" }}> - <div - style={{ - padding: "10px 12px", - borderRadius: 10, - background: "var(--surface)", - border: "1px solid var(--border)", - }} - > - <div - className="section-label" - style={{ margin: 0, marginBottom: 6, fontSize: 9 }} - > - {t("nav_alert_status")} - </div> - - {/* Harvest ready row */} - {harvestCount > 0 && ( - <div - style={{ - display: "flex", - alignItems: "center", - gap: 6, - marginBottom: alertCount > 0 ? 5 : 0, - }} - > - <span - className="harvest-pulse" - style={{ - width: 8, - height: 8, - borderRadius: "50%", - background: "var(--amber)", - flexShrink: 0, - }} - /> - <span - style={{ - fontSize: 11, - fontFamily: "DM Mono, monospace", - color: "var(--amber)", - }} - > - {t("nav_harvest_ready", { n: harvestCount })} - </span> - </div> - )} - - {alertCount > 0 ? ( - <div style={{ display: "flex", alignItems: "center", gap: 6 }}> - <span - className="alert-pulse" - style={{ - width: 8, - height: 8, - borderRadius: "50%", - background: "var(--red)", - flexShrink: 0, - }} - /> - <span - style={{ - fontSize: 11, - fontFamily: "DM Mono, monospace", - color: "var(--red)", - }} - > - {t("nav_crops_need_attention", { - n: alertCount, - s: alertCount !== 1 ? "s" : "", - })} - </span> - </div> - ) : harvestCount === 0 ? ( - <div style={{ display: "flex", alignItems: "center", gap: 6 }}> - <span - className="status-dot" - style={{ - width: 8, - height: 8, - borderRadius: "50%", - background: "var(--green)", - flexShrink: 0, - }} - /> - <span - style={{ - fontSize: 11, - fontFamily: "DM Mono, monospace", - color: "var(--green)", - }} - > - {t("nav_all_clear")} - </span> - </div> - ) : null} - </div> - </div> - )} - - {/* User row */} + {/* User avatar */} <div style={{ - padding: collapsed ? "12px 8px" : "12px 12px", borderTop: "1px solid var(--border)", + padding: collapsed ? "12px 0" : "12px", display: "flex", alignItems: "center", - gap: 8, + gap: 10, justifyContent: collapsed ? "center" : "flex-start", }} > + {/* Avatar */} <div style={{ - width: 28, - height: 28, + width: 34, + height: 34, borderRadius: "50%", - background: "rgba(245,158,11,0.15)", - color: "var(--amber)", + background: "linear-gradient(135deg, #2d7a44, #4ade80)", display: "flex", alignItems: "center", justifyContent: "center", - fontSize: 11, - fontWeight: 700, flexShrink: 0, + fontSize: 12, + fontWeight: 700, + color: "#fff", + letterSpacing: "0.05em", + boxShadow: "0 2px 6px rgba(74,222,128,0.3)", }} > {initials} </div> + {!collapsed && ( - <div> + <div + style={{ + minWidth: 0, + flex: 1, + animation: "fadeSlideIn 200ms ease", + }} + > <div - style={{ fontSize: 12, fontWeight: 600, color: "var(--text)" }} + style={{ + fontSize: 13, + fontWeight: 600, + color: "var(--text)", + whiteSpace: "nowrap", + overflow: "hidden", + textOverflow: "ellipsis", + }} > {settings.userName} </div> - <div style={{ fontSize: 10, color: "var(--text-3)" }}> - {settings.userDesignation} + <div + style={{ + display: "flex", + alignItems: "center", + gap: 4, + marginTop: 1, + }} + > + <Shield + size={9} + style={{ color: "var(--green)", flexShrink: 0 }} + /> + <span + style={{ + fontSize: 10, + fontFamily: "DM Mono, monospace", + color: "var(--green)", + letterSpacing: "0.04em", + }} + > + {settings.userDesignation} + </span> </div> </div> )} diff --git a/frontend/src/data/mockData.js b/frontend/src/data/mockData.js @@ -11,6 +11,12 @@ const ts = (daysAgo, hour = 10, min = 0) => { return d.toISOString(); }; +const plantedTs = (daysAgo) => { + const d = new Date(); + d.setDate(d.getDate() - daysAgo); + return d.toISOString(); +}; + // Explanation log samples const EXPLANATION_LOGS = { lettuce: `1. **Observation**: Sensors show pH 6.1, EC 1.4 dS/m, Temp 23.5°C, Humidity 68%. @@ -27,7 +33,7 @@ const EXPLANATION_LOGS = { No pH correction needed. Maintain current atmospheric settings.`, tomato: `1. **Observation**: pH 5.8, EC 2.1 dS/m, Temp 26°C, Humidity 58%. - EC is elevated for flowering stage — approaching upper safe limit of 2.2. + EC is elevated for flowering stage - approaching upper safe limit of 2.2. 2. **Precedent**: 2 similar flowering Tomato states found. In one prior case, EC > 2.2 triggered blossom drop. Acid dosage was effective in 1 case. @@ -39,13 +45,13 @@ const EXPLANATION_LOGS = { 4. **Conclusion**: Targeted acid dosage + nutrient boost is the correct intervention. Increased fan speed to 60% to manage VPD in warm conditions.`, - basil: `1. **Observation**: CRITICAL — pH 7.8 (target 5.5-6.5), EC 0.6 (very low), Temp 29.5°C. + basil: `1. **Observation**: CRITICAL - pH 7.8 (target 5.5-6.5), EC 0.6 (very low), Temp 29.5°C. Multiple out-of-range parameters detected simultaneously. 2. **Precedent**: 1 similar critical Basil state found. Previous corrective action required 8ml acid dosage to restore pH. Recovery took 2 cycles. -3. **Logic**: pH 7.8 indicates severe alkalinity — likely nutrient lockout. +3. **Logic**: pH 7.8 indicates severe alkalinity - likely nutrient lockout. At this pH, iron and manganese become unavailable causing yellowing. EC 0.6 confirms nutrient starvation. Aggressive correction required. High temp (29.5°C) increases risk of bolting. @@ -53,153 +59,176 @@ const EXPLANATION_LOGS = { 4. **Conclusion**: Emergency acid dosage (8ml) is necessary. Fan at 80% to cool. Nutrient addition deferred until pH stabilizes to avoid compounding stress.`, - spinach: `1. **Observation**: pH 6.3, EC 1.6 dS/m, Temp 21°C, Humidity 72%. - All parameters within optimal range for Spinach Vegetative stage. + strawberry: `1. **Observation**: pH 6.2, EC 1.8 dS/m, Temp 22°C, Humidity 65%. + All parameters within optimal range for Strawberry Vegetative stage. 2. **Precedent**: 4 similar states found, all showing positive outcomes. - Gentle interventions in this range historically improve harvest weight by 8-15%. + Gentle interventions in this range historically improve runner production by 10-18%. -3. **Logic**: Conditions are near-ideal. pH 6.3 is perfect for Spinach (optimal 6.0-7.0). - Light nutrient boost (1ml) maintains EC momentum. Water refill supports +3. **Logic**: Conditions are near-ideal. pH 6.2 is perfect for Strawberry (optimal 6.0-6.5). + Light nutrient boost (1.5ml) maintains EC momentum. Water refill supports root zone hydration without diluting nutrients significantly. -4. **Conclusion**: Minimal intervention strategy. 1ml nutrient + 2L water refill. - Fan at 35% maintains gentle airflow — excessive airflow stresses cool-season crops.`, - - cucumber: `1. **Observation**: pH 5.5 (slightly acidic), EC 2.8 (high for fruiting), Temp 27.5°C. - Fruiting stage with elevated EC — risk of nutrient burn increasing. - -2. **Precedent**: 3 fruiting Cucumber states found. EC > 2.5 in 2 cases led to - tip burn on fruit edges. Water flush was effective in reducing EC. - -3. **Logic**: EC 2.8 exceeds the 2.5 ceiling for fruiting Cucumbers. - Base dosage (2ml) will gently raise pH from 5.5 toward optimal 5.8-6.2. - 5L water refill will dilute EC concentration to ~2.4, within safe range. - -4. **Conclusion**: Base pH correction + aggressive water refill to manage EC. - High fan speed (70%) compensates for elevated temperature and humidity needs.`, +4. **Conclusion**: Minimal intervention strategy. 1.5ml nutrient + 1.5L water refill. + Fan at 40% maintains gentle airflow - adequate for cooler Strawberry environment.`, }; -// Dashboard snapshots (latest per crop) +/** + * MOCK_DASHBOARD - Raw MongoDB document shape + * (sensors as arrays, planted_at, cycle_duration_hours, sensor_ids, location) + */ export const MOCK_DASHBOARD = [ { - id: "pt-001", - payload: { - crop_id: "Batch_Lettuce_2025A", - crop: "Lettuce", - stage: "Vegetative", - sequence_number: 14, - timestamp: ts(0, 9, 30), - sensors: { pH: 6.1, EC: 1.4, temp: 23.5, humidity: 68 }, - action_taken: JSON.stringify({ - acid_dosage_ml: 0, - base_dosage_ml: 0, - nutrient_dosage_ml: 2.5, - fan_speed_pct: 45, - water_refill_l: 0, - }), - outcome: "IMPROVED | Reward: 0.8", - strategic_intent: "MAINTAIN_CURRENT", - bandit_action_id: 0, - reward_score: 0.8, - explanation_log: EXPLANATION_LOGS.lettuce, + _id: "mongo-001", + crop_id: "Batch_Lettuce_2025A", + crop: "Lettuce", + stage: "vegetative", + sequence_number: 14, + cycle_duration_hours: 1, + planted_at: plantedTs(10), + last_updated: ts(0, 9, 30), + location: "Rack A - Shelf 1", + notes: "Fast-growing batch, increased EC slightly on day 7.", + image_url: "", + sensors: { + pH: [5.9, 6.0, 6.1, 6.1], + EC: [1.3, 1.4, 1.4, 1.4], + temp: [23.0, 23.5, 23.5, 23.5], + humidity: [66, 67, 68, 68], }, - }, - { - id: "pt-002", - payload: { - crop_id: "Batch_Tomato_2025B", - crop: "Tomato", - stage: "Flowering", - sequence_number: 22, - timestamp: ts(0, 8, 15), - sensors: { pH: 5.8, EC: 2.1, temp: 26.0, humidity: 58 }, - action_taken: JSON.stringify({ - acid_dosage_ml: 1.5, - base_dosage_ml: 0, - nutrient_dosage_ml: 4.0, - fan_speed_pct: 60, - water_refill_l: 0, - }), - outcome: "STABLE | Reward: 0.4", - strategic_intent: "INCREASE_EC_BLOOM", - bandit_action_id: 6, - reward_score: 0.4, - explanation_log: EXPLANATION_LOGS.tomato, + sensor_ids: { + ph_sensor: "PH-SEN-A1F3", + ec_sensor: "EC-SEN-B2D7", + temp_sensor: "TMP-SEN-C4E9", + humidity_sensor: "HUM-SEN-D6F1", }, + action_taken: JSON.stringify({ + acid_dosage_ml: 0, + base_dosage_ml: 0, + nutrient_dosage_ml: 2.5, + fan_speed_pct: 45, + water_refill_l: 0, + }), + outcome: "IMPROVED | Reward: 0.8", + strategic_intent: "MAINTAIN_CURRENT", + bandit_action_id: 0, + reward_score: 0.8, + explanation_log: EXPLANATION_LOGS.lettuce, }, { - id: "pt-003", - payload: { - crop_id: "Batch_Basil_2025C", - crop: "Basil", - stage: "Seedling", - sequence_number: 5, - timestamp: ts(0, 11, 0), - sensors: { pH: 7.8, EC: 0.6, temp: 29.5, humidity: 82 }, - action_taken: JSON.stringify({ - acid_dosage_ml: 8.0, - base_dosage_ml: 0, - nutrient_dosage_ml: 3.0, - fan_speed_pct: 80, - water_refill_l: 0, - }), - outcome: "DETERIORATED | Reward: -0.6", - strategic_intent: "AGGRESSIVE_PH_DOWN", - bandit_action_id: 2, - reward_score: -0.6, - explanation_log: EXPLANATION_LOGS.basil, + _id: "mongo-002", + crop_id: "Batch_Tomato_2025B", + crop: "Tomato", + stage: "flowering", + sequence_number: 22, + cycle_duration_hours: 2, + planted_at: plantedTs(45), + last_updated: ts(0, 8, 15), + location: "Rack B - Shelf 2", + notes: "", + image_url: "", + sensors: { + pH: [5.7, 5.8, 5.8, 5.8], + EC: [2.0, 2.1, 2.1, 2.1], + temp: [25.5, 26.0, 26.0, 26.0], + humidity: [57, 58, 58, 58], }, + sensor_ids: { + ph_sensor: "PH-SEN-E5A2", + ec_sensor: "EC-SEN-F3C8", + temp_sensor: "TMP-SEN-G7D1", + humidity_sensor: "HUM-SEN-H9B4", + }, + action_taken: JSON.stringify({ + acid_dosage_ml: 1.5, + base_dosage_ml: 0, + nutrient_dosage_ml: 4.0, + fan_speed_pct: 60, + water_refill_l: 0, + }), + outcome: "STABLE | Reward: 0.4", + strategic_intent: "INCREASE_EC_BLOOM", + bandit_action_id: 6, + reward_score: 0.4, + explanation_log: EXPLANATION_LOGS.tomato, }, { - id: "pt-004", - payload: { - crop_id: "Batch_Spinach_2025D", - crop: "Spinach", - stage: "Vegetative", - sequence_number: 9, - timestamp: ts(1, 14, 45), - sensors: { pH: 6.3, EC: 1.6, temp: 21.0, humidity: 72 }, - action_taken: JSON.stringify({ - acid_dosage_ml: 0, - base_dosage_ml: 0, - nutrient_dosage_ml: 1.0, - fan_speed_pct: 35, - water_refill_l: 2.0, - }), - outcome: "IMPROVED | Reward: 0.7", - strategic_intent: "GENTLE_PH_BALANCING", - bandit_action_id: 4, - reward_score: 0.7, - explanation_log: EXPLANATION_LOGS.spinach, + _id: "mongo-003", + crop_id: "Batch_Basil_2025C", + crop: "Basil", + stage: "seedling", + sequence_number: 5, + cycle_duration_hours: 1, + planted_at: plantedTs(3), + last_updated: ts(0, 11, 0), + location: "Rack A - Shelf 3", + notes: "New batch - watching pH closely.", + image_url: "", + sensors: { + pH: [7.5, 7.7, 7.8, 7.8], + EC: [0.7, 0.6, 0.6, 0.6], + temp: [28.5, 29.0, 29.5, 29.5], + humidity: [80, 81, 82, 82], + }, + sensor_ids: { + ph_sensor: "PH-SEN-I2K6", + ec_sensor: "EC-SEN-J4L9", + temp_sensor: "TMP-SEN-K1M3", + humidity_sensor: "HUM-SEN-L8N7", }, + action_taken: JSON.stringify({ + acid_dosage_ml: 8.0, + base_dosage_ml: 0, + nutrient_dosage_ml: 3.0, + fan_speed_pct: 80, + water_refill_l: 0, + }), + outcome: "DETERIORATED | Reward: -0.6", + strategic_intent: "AGGRESSIVE_PH_DOWN", + bandit_action_id: 2, + reward_score: -0.6, + explanation_log: EXPLANATION_LOGS.basil, }, { - id: "pt-005", - payload: { - crop_id: "Batch_Cucumber_2025E", - crop: "Cucumber", - stage: "Fruiting", - sequence_number: 31, - timestamp: ts(0, 7, 0), - sensors: { pH: 5.5, EC: 2.8, temp: 27.5, humidity: 55 }, - action_taken: JSON.stringify({ - acid_dosage_ml: 0, - base_dosage_ml: 2.0, - nutrient_dosage_ml: 0, - fan_speed_pct: 70, - water_refill_l: 5.0, - }), - outcome: "STABLE | Reward: 0.3", - strategic_intent: "LOWER_EC_FLUSH", - bandit_action_id: 7, - reward_score: 0.3, - explanation_log: EXPLANATION_LOGS.cucumber, + _id: "mongo-004", + crop_id: "Batch_Strawberry_2025D", + crop: "Strawberry", + stage: "vegetative", + sequence_number: 9, + cycle_duration_hours: 2, + planted_at: plantedTs(18), + last_updated: ts(1, 14, 45), + location: "Rack C - Shelf 1", + notes: "Runner training started on day 12.", + image_url: "", + sensors: { + pH: [6.1, 6.2, 6.2, 6.2], + EC: [1.7, 1.8, 1.8, 1.8], + temp: [21.5, 22.0, 22.0, 22.0], + humidity: [63, 64, 65, 65], }, + sensor_ids: { + ph_sensor: "PH-SEN-M5P2", + ec_sensor: "EC-SEN-N7Q4", + temp_sensor: "TMP-SEN-O3R8", + humidity_sensor: "HUM-SEN-P1S6", + }, + action_taken: JSON.stringify({ + acid_dosage_ml: 0, + base_dosage_ml: 0, + nutrient_dosage_ml: 1.5, + fan_speed_pct: 40, + water_refill_l: 1.5, + }), + outcome: "IMPROVED | Reward: 0.7", + strategic_intent: "GENTLE_PH_BALANCING", + bandit_action_id: 4, + reward_score: 0.7, + explanation_log: EXPLANATION_LOGS.strawberry, }, ]; -// Detailed history per crop (multiple snapshots) +// Detailed history per crop (multiple snapshots - keeps Qdrant/payload shape for analytics) function makeHistory(cropId, cropName, stage, n, baseVals, explanationLog) { return Array.from({ length: n }, (_, i) => { const jitter = (range) => (Math.random() - 0.5) * range; @@ -218,28 +247,24 @@ function makeHistory(cropId, cropName, stage, n, baseVals, explanationLog) { humidity: +(baseVals.humidity + jitter(8)).toFixed(1), }, action_taken: JSON.stringify({ - acid_dosage_ml: +(Math.random() * 3).toFixed(1), - base_dosage_ml: +(Math.random() * 2).toFixed(1), - nutrient_dosage_ml: +(Math.random() * 5).toFixed(1), - fan_speed_pct: +(30 + Math.random() * 50).toFixed(0), - water_refill_l: +(Math.random() * 4).toFixed(1), + acid_dosage_ml: +(Math.random() * 2).toFixed(1), + base_dosage_ml: 0, + nutrient_dosage_ml: +(Math.random() * 3 + 1).toFixed(1), + fan_speed_pct: Math.round(30 + Math.random() * 40), + water_refill_l: +(Math.random() * 2).toFixed(1), }), - outcome: - i % 5 === 0 - ? "DETERIORATED | Reward: -0.4" - : i % 3 === 0 - ? "STABLE | Reward: 0.3" - : "IMPROVED | Reward: 0.75", + outcome: [ + "IMPROVED | Reward: 0.7", + "STABLE | Reward: 0.4", + "DETERIORATED | Reward: -0.3", + ][i % 3], strategic_intent: [ "MAINTAIN_CURRENT", "GENTLE_PH_BALANCING", "INCREASE_EC_VEG", - "RAISE_TEMP_HUMIDITY", - "MAX_AIR_CIRCULATION", - ][i % 5], - reward_score: i % 5 === 0 ? -0.4 : i % 3 === 0 ? 0.3 : 0.75, - // Only the most recent few entries have explanation logs - explanation_log: i >= n - 3 ? explanationLog : "PENDING_ANALYSIS", + ][i % 3], + reward_score: [0.7, 0.4, -0.3][i % 3], + explanation_log: explanationLog, }, }; }); @@ -249,7 +274,7 @@ export const MOCK_HISTORY = [ ...makeHistory( "Batch_Lettuce_2025A", "Lettuce", - "Vegetative", + "vegetative", 14, { ph: 6.1, ec: 1.4, temp: 23.5, humidity: 68 }, EXPLANATION_LOGS.lettuce, @@ -257,52 +282,36 @@ export const MOCK_HISTORY = [ ...makeHistory( "Batch_Tomato_2025B", "Tomato", - "Flowering", + "flowering", 22, - { ph: 5.9, ec: 2.0, temp: 26.0, humidity: 58 }, + { ph: 5.8, ec: 2.1, temp: 26.0, humidity: 58 }, EXPLANATION_LOGS.tomato, ), ...makeHistory( "Batch_Basil_2025C", "Basil", - "Seedling", + "seedling", 5, - { ph: 7.2, ec: 0.7, temp: 29.0, humidity: 80 }, + { ph: 7.8, ec: 0.6, temp: 29.5, humidity: 82 }, EXPLANATION_LOGS.basil, ), ...makeHistory( - "Batch_Spinach_2025D", - "Spinach", - "Vegetative", + "Batch_Strawberry_2025D", + "Strawberry", + "vegetative", 9, - { ph: 6.3, ec: 1.6, temp: 21.5, humidity: 72 }, - EXPLANATION_LOGS.spinach, - ), - ...makeHistory( - "Batch_Cucumber_2025E", - "Cucumber", - "Fruiting", - 31, - { ph: 5.6, ec: 2.7, temp: 27.0, humidity: 56 }, - EXPLANATION_LOGS.cucumber, + { ph: 6.2, ec: 1.8, temp: 22.0, humidity: 65 }, + EXPLANATION_LOGS.strawberry, ), ]; -// Mock search / agent response +// Mock search result (for FarmIntelligence) export const MOCK_SEARCH_RESULT = { status: "success", - new_fmu_id: "mock-fmu-001", - agent_decision: { - acid_dosage_ml: 2.5, - base_dosage_ml: 0, - nutrient_dosage_ml: 3.0, - fan_speed_pct: 55, - water_refill_l: 1.5, - }, - explanation: EXPLANATION_LOGS.lettuce, - search_results: MOCK_DASHBOARD.slice(0, 3).map((d, i) => ({ - id: d.id, + results: MOCK_DASHBOARD.slice(0, 2).map((d, i) => ({ + id: d._id, score: 0.95 - i * 0.08, - payload: d.payload, + payload: { ...d, sensors: { pH: 6.1, EC: 1.4, temp: 23.5, humidity: 68 } }, })), + query_logic: { must: [{ key: "crop", match: "Lettuce" }] }, }; diff --git a/frontend/src/hooks/useFarmData.js b/frontend/src/hooks/useFarmData.js @@ -1,5 +1,6 @@ import React, { useState, useEffect, createContext, useContext } from "react"; import { fetchDashboardData, fetchAllCropHistories } from "../api/farmApi"; +import { normalizeMongoCrop } from "../utils/dataUtils"; const FarmDataContext = createContext(); @@ -11,9 +12,23 @@ export const FarmDataProvider = ({ children }) => { const refreshData = async () => { setLoading(true); try { - const dash = await fetchDashboardData(); - setDashboard(dash || []); - const hist = await fetchAllCropHistories(dash || []); + // fetchDashboardData now returns raw MongoDB docs (or MOCK_DASHBOARD) + const raw = await fetchDashboardData(); + + // Normalize each MongoDB doc into { id, payload: {...} } shape + // so all downstream components (Dashboard, Alerts, Analytics, Sidebar) work unchanged + const normalized = (raw || []) + .map((doc) => { + // If doc already has a payload key (mock data in old shape), passthrough + if (doc && doc.payload) return doc; + return normalizeMongoCrop(doc); + }) + .filter(Boolean); + + setDashboard(normalized); + + // Fetch Qdrant history using the normalized array (farmApi handles both shapes) + const hist = await fetchAllCropHistories(normalized); setHistory(hist || []); } catch (error) { console.error("Failed to fetch farm data", error); @@ -24,7 +39,7 @@ export const FarmDataProvider = ({ children }) => { useEffect(() => { refreshData(); - }, []); + }, []); // eslint-disable-line react-hooks/exhaustive-deps return ( <FarmDataContext.Provider diff --git a/frontend/src/index.css b/frontend/src/index.css @@ -4,7 +4,7 @@ @tailwind components; @tailwind utilities; -/* ─── Dark Theme ───────────────────────────────────────────────── */ +/* Dark Theme */ :root, [data-theme="dark"] { --bg: #0c1a0e; @@ -44,7 +44,7 @@ --btn-on-green: #0c1a0e; } -/* ─── Light Theme ──────────────────────────────────────────────── */ +/* Light Theme */ [data-theme="light"] { --bg: #f0f7f1; --bg-2: #ffffff; @@ -70,7 +70,7 @@ --tooltip-bg: #ffffff; --shadow: 0 4px 24px rgba(0, 0, 0, 0.08); - /* Log / terminal area — light but distinct */ + /* Log / terminal area - light but distinct */ --log-bg: #f0f5f1; --log-text: #3d5e42; @@ -82,7 +82,7 @@ --btn-on-green: #ffffff; } -/* ─── Hindi font body override ─────────────────────────────────── */ +/* Hindi font body override */ [data-lang="hi"] body, [data-lang="hi"] button, [data-lang="hi"] input, @@ -90,7 +90,7 @@ font-family: "Noto Sans Devanagari", "Syne", sans-serif; } -/* ─── Reset ────────────────────────────────────────────────────── */ +/* Reset */ * { box-sizing: border-box; } @@ -107,7 +107,7 @@ body { color 0.25s ease; } -/* ─── Scrollbar ────────────────────────────────────────────────── */ +/* Scrollbar */ ::-webkit-scrollbar { width: 6px; height: 6px; @@ -120,7 +120,7 @@ body { border-radius: 3px; } -/* ─── TYPOGRAPHY ───────────────────────────────────────────────── */ +/* TYPOGRAPHY */ .section-label { font-size: 11px; font-family: "DM Mono", monospace; @@ -184,7 +184,7 @@ body { margin: 2px 0 0; } -/* ─── Table ────────────────────────────────────────────────────── */ +/* Table */ .data-table th { font-size: 11px; font-family: "DM Mono", monospace; @@ -207,7 +207,7 @@ body { border-bottom: none; } -/* ─── UTILITIES ────────────────────────────────────────────────── */ +/* UTILITIES */ .font-mono { font-family: "DM Mono", monospace; } @@ -221,7 +221,7 @@ body { 0 0 60px rgba(74, 222, 128, 0.05); } -/* ─── Scan line (dark only) ────────────────────────────────────── */ +/* Scan line (dark only) */ @keyframes scanline { 0% { transform: translateY(-100%); @@ -250,7 +250,7 @@ body { display: none; } -/* ─── Animations ───────────────────────────────────────────────── */ +/* Animations */ @keyframes fadeUp { from { opacity: 0; @@ -407,7 +407,7 @@ body { linear-gradient(90deg, rgba(26, 124, 58, 0.05) 1px, transparent 1px); } -/* ─── Input fields ─────────────────────────────────────────────── */ +/* Input fields */ input, select, textarea { @@ -423,12 +423,12 @@ input::placeholder { color: var(--text-3); } -/* ─── Harvest badge animation ──────────────────────────────────── */ +/* Harvest badge animation */ .harvest-badge { animation: harvestPulse 2.5s ease infinite; } -/* ─── Log / terminal area ──────────────────────────────────────── */ +/* Log / terminal area */ .log-area { background: var(--log-bg) !important; color: var(--log-text); diff --git a/frontend/src/pages/AddCrop.jsx b/frontend/src/pages/AddCrop.jsx @@ -1,30 +1,19 @@ -import { useRef, useState, useEffect, useCallback } from "react"; +import { useState, useEffect } from "react"; import { useNavigate } from "react-router-dom"; import { useFarmData } from "../hooks/useFarmData"; import { useT } from "../hooks/useTranslation"; import { - Upload, ArrowLeft, - Activity, - Droplets, - Thermometer, - Wind, - Sprout, - Calendar, - Database, - Play, CheckCircle2, - AlertTriangle, - Fan, - Brain, - ChevronDown, - Leaf, - Zap, - Circle, + Database, + MapPin, + StickyNote, + Upload, + Sprout, + Clock, ChevronRight, - Waves, - FlaskConical, - Cpu, + LayoutGrid, + Leaf, } from "lucide-react"; import { PageShell, @@ -32,1212 +21,755 @@ import { IconButton, SectionCard, } from "../components/ui"; - -// Agent Formatting -const AGENT_META = { - FETCHER: { color: "#60a5fa", bg: "rgba(96,165,250,0.12)", label: "Fetcher" }, - JUDGE: { color: "#f59e0b", bg: "rgba(245,158,11,0.12)", label: "Judge" }, - RESEARCHER: { - color: "#a78bfa", - bg: "rgba(167,139,250,0.12)", - label: "Researcher", - }, - ATMOSPHERIC: { - color: "#4ade80", - bg: "rgba(74,222,128,0.12)", - label: "Atmospheric", - }, - WATER: { color: "#22d3ee", bg: "rgba(34,211,238,0.12)", label: "Water" }, - SUPERVISOR: { - color: "#f97316", - bg: "rgba(249,115,22,0.12)", - label: "Supervisor", - }, - BANDIT: { color: "#e879f9", bg: "rgba(232,121,249,0.12)", label: "Bandit" }, - DOCTOR: { color: "#f87171", bg: "rgba(248,113,113,0.12)", label: "Doctor" }, - SYSTEM: { color: "#6a8a6d", bg: "rgba(106,138,109,0.12)", label: "System" }, -}; - -function agentFromLine(line) { - const up = line.toUpperCase(); - if (up.includes("[FETCHER]") || up.includes("FETCHING")) return "FETCHER"; - if (up.includes("[JUDGE]") || up.includes("JUDGE")) return "JUDGE"; - if (up.includes("BANDIT") || up.includes("STRATEGY")) return "BANDIT"; - if (up.includes("RESEARCH")) return "RESEARCHER"; - if (up.includes("ATMO") || up.includes("ATMOSPHERIC")) return "ATMOSPHERIC"; - if (up.includes("WATER") || up.includes("NUTRIENT")) return "WATER"; - if (up.includes("SUPERVISOR")) return "SUPERVISOR"; - if (up.includes("DOCTOR") || up.includes("VISION") || up.includes("DIAGNOS")) - return "DOCTOR"; - return "SYSTEM"; -} - -function levelFromLine(line) { - const up = line.toUpperCase(); - if (up.includes("❌") || up.includes("ERROR") || up.includes("CRITICAL")) - return "error"; - if (up.includes("⚠️") || up.includes("WARN") || up.includes("FAIL")) - return "warn"; - if (up.includes("✅") || up.includes("APPROV") || up.includes("SUCCESS")) - return "success"; - return "info"; -} - -const LEVEL_COLORS = { - error: "var(--red)", - warn: "var(--amber)", - success: "var(--green)", - info: "var(--text-2)", -}; - -const getInputFields = (t) => [ - { - label: t("add_field_ph"), - name: "pH", - icon: Droplets, - color: "var(--green)", - type: "number", - step: "0.1", - min: "0", - max: "14", - hint: t("sensor_ph_desc"), - }, - { - label: t("add_field_ec"), - name: "EC", - icon: Activity, - color: "var(--amber)", - type: "number", - step: "0.1", - hint: t("sensor_ec_desc"), - }, - { - label: t("add_field_temp"), - name: "temp", - icon: Thermometer, - color: "var(--blue)", - type: "number", - step: "0.5", - hint: t("sensor_temp_desc"), - }, - { - label: t("add_field_humidity"), - name: "humidity", - icon: Wind, - color: "#a78bfa", - type: "number", - step: "1", - hint: t("sensor_humidity_desc"), - }, - { - label: t("add_field_crop_type"), - name: "crop", - icon: Sprout, - color: "var(--green)", - type: "select", - opts: [ - "Lettuce", - "Tomato", - "Cucumber", - "Basil", - "Spinach", - "Kale", - "Strawberry", - "Pepper", - ], - }, - { - label: t("add_field_stage"), - name: "stage", - icon: Calendar, - color: "var(--text-3)", - type: "select", - opts: ["Seedling", "Vegetative", "Flowering", "Fruiting"], - hint: t("add_field_stage_hint"), - }, - { - label: t("add_field_crop_id"), - name: "crop_id", - icon: Database, - color: "var(--text-3)", - type: "text", - placeholder: t("add_field_crop_id_placeholder"), - hint: t("add_field_crop_id_hint"), - }, -]; - -function phaseFromLogs(logs) { - const last = logs[logs.length - 1]?.text?.toUpperCase() || ""; - if (last.includes("SENT TO SIMULATOR") || last.includes("CYCLE COMPLETE")) - return "execute"; - if (last.includes("ACTIVATING") || last.includes("SUPERVISOR")) - return "execute"; - if ( - last.includes("WATER") || - last.includes("ATMOSPHERIC") || - last.includes("MERGING") - ) - return "plan"; - if (last.includes("RESEARCH") || last.includes("KNOWLEDGE")) - return "research"; - if (last.includes("BANDIT") || last.includes("STRATEGY")) return "strategy"; - if (last.includes("JUDGE")) return "judge"; - if (last.includes("FETCHER") || last.includes("FMU")) return "fetch"; - return null; -} - -function LogLine({ entry, idx, td }) { - const agent = AGENT_META[entry.agent] || AGENT_META.SYSTEM; - const lvlColor = LEVEL_COLORS[entry.level] || LEVEL_COLORS.info; +import { createCrop, fetchDashboardData } from "../api/farmApi"; +import { + CROP_LIFECYCLES, + CROP_CYCLE_HOURS, + SUPPORTED_CROPS, +} from "../utils/dataUtils"; + +// Lifecycle Timeline Component +function LifecycleTimeline({ cropName }) { + const lc = CROP_LIFECYCLES[cropName?.toLowerCase()]; + if (!lc) return null; + + const total = lc.totalHours; + const colors = [ + "var(--text-3)", + "var(--green)", + "var(--amber)", + "var(--red)", + ]; return ( - <div - className="animate-fade-in" - style={{ - display: "flex", - alignItems: "flex-start", - gap: 10, - padding: "5px 0", - borderBottom: "1px solid rgba(128,180,128,0.06)", - animationDelay: `${idx * 20}ms`, - }} - > - {/* Line number */} - <span - style={{ - fontSize: 10, - fontFamily: "DM Mono, monospace", - color: "var(--text-3)", - minWidth: 28, - paddingTop: 2, - }} - > - {String(idx + 1).padStart(3, "0")} - </span> - {/* Timestamp */} - <span + <div style={{ marginTop: 12 }}> + <div style={{ - fontSize: 10, - fontFamily: "DM Mono, monospace", - color: "var(--text-3)", - minWidth: 54, - paddingTop: 2, - flexShrink: 0, - }} - > - {entry.time} - </span> - {/* Agent badge */} - <span - style={{ - fontSize: 9, - fontFamily: "DM Mono, monospace", - padding: "2px 7px", - borderRadius: 4, - background: agent.bg, - color: agent.color, - border: `1px solid ${agent.color}30`, - flexShrink: 0, - minWidth: 80, - textAlign: "center", - marginTop: 1, - }} - > - {td(agent.label)} - </span> - {/* Message */} - <span - style={{ - fontSize: 12, - fontFamily: "DM Mono, monospace", - color: lvlColor, - flex: 1, - wordBreak: "break-all", - lineHeight: 1.5, + display: "flex", + gap: 0, + borderRadius: 6, + overflow: "hidden", + height: 10, + marginBottom: 8, }} > - {td(entry.text)} - </span> + {lc.stages.map((s, i) => { + const endH = s.endH ?? total; + const width = ((endH - s.startH) / total) * 100; + return ( + <div + key={s.name} + style={{ + width: `${width}%`, + background: colors[i % colors.length], + opacity: 0.7, + }} + title={`${s.name}: ${s.startH}h–${s.endH ?? "harvest"}h`} + /> + ); + })} + </div> + <div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}> + {lc.stages.map((s, i) => ( + <div + key={s.name} + style={{ display: "flex", alignItems: "center", gap: 5 }} + > + <span + style={{ + width: 8, + height: 8, + borderRadius: "50%", + background: colors[i % colors.length], + display: "inline-block", + opacity: 0.8, + }} + /> + <span + style={{ + fontSize: 10, + fontFamily: "DM Mono, monospace", + color: "var(--text-3)", + textTransform: "capitalize", + }} + > + {s.name} + {s.endH + ? ` (${Math.round((s.endH - s.startH) / 24)}d)` + : " (harvest)"} + </span> + </div> + ))} + <span + style={{ + marginLeft: "auto", + fontSize: 10, + fontFamily: "DM Mono, monospace", + color: "var(--text-3)", + }} + > + Total: ~{lc.totalDays} days + </span> + </div> </div> ); } // MAIN + export default function AddCrop() { const navigate = useNavigate(); const { refreshData } = useFarmData(); - const { t, td } = useT(); + const { t } = useT(); - const [file, setFile] = useState(null); - const [preview, setPreview] = useState(null); - const [sensors, setSensors] = useState({ - pH: "6.0", - EC: "1.4", - temp: "24.0", - humidity: "65", - crop: "Lettuce", - stage: "Vegetative", + const [form, setForm] = useState({ crop_id: "", + crop: "Lettuce", + location: "", + notes: "", }); + const [imageFile, setImageFile] = useState(null); + const [imagePreview, setImagePreview] = useState(null); - const [phase, setPhase] = useState("idle"); - const [logs, setLogs] = useState([]); - const [cycles, setCycles] = useState(0); - const [activePhase, setActivePhase] = useState(null); - const [finalAction, setFinalAction] = useState(null); - const [toast, setToast] = useState(null); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(""); + const [success, setSuccess] = useState(false); + const [createdId, setCreatedId] = useState(""); - const logEndRef = useRef(null); - const timersRef = useRef([]); - - const AGENT_API = - process.env.REACT_APP_AGENT_API_URL || "http://localhost:8000"; - - const showToast = (msg, type = "success") => { - setToast({ msg, type }); - setTimeout(() => setToast(null), 3500); - }; - - // Auto-scroll log + // Auto-generate crop_id suggestion when crop type changes useEffect(() => { - logEndRef.current?.scrollIntoView({ behavior: "smooth" }); - }, [logs]); - - // Cleanup timers on unmount - useEffect(() => () => timersRef.current.forEach(clearTimeout), []); + if (!form.crop_id) { + const year = new Date().getFullYear(); + const letter = String.fromCharCode(65 + Math.floor(Math.random() * 26)); + setForm((f) => ({ + ...f, + crop_id: `Batch_${form.crop}_${year}${letter}`, + })); + } + }, [form.crop]); // eslint-disable-line react-hooks/exhaustive-deps - const pushLog = useCallback((text, agentKey) => { - const now = new Date(); - const time = now.toLocaleTimeString([], { - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - }); - const agent = agentKey || agentFromLine(text); - const level = levelFromLine(text); - setLogs((prev) => [...prev, { text, agent, level, time }]); - }, []); + const cycleDuration = CROP_CYCLE_HOURS[form.crop?.toLowerCase()] || 1; - async function startCycle() { - if (phase === "running") return; + const handleImageChange = (e) => { + const file = e.target.files?.[0]; + if (!file) return; + setImageFile(file); + const reader = new FileReader(); + reader.onload = (ev) => setImagePreview(ev.target.result); + reader.readAsDataURL(file); + }; - setPhase("running"); - setLogs([]); - setFinalAction(null); + const handleSubmit = async () => { + setError(""); + if (!form.crop_id.trim()) { + setError("Crop ID is required."); + return; + } + if (!form.crop) { + setError("Please select a crop type."); + return; + } + setSubmitting(true); try { - const formData = new FormData(); - if (file) { - formData.append("file", file); - } else { - // Placeholder 1x1 png - const r = await fetch( - "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", - ); - const blob = await r.blob(); - formData.append( - "file", - new File([blob], "placeholder.png", { type: "image/png" }), - ); + // Check uniqueness + const existing = await fetchDashboardData(); + const ids = (existing || []) + .map((d) => d.crop_id || d.payload?.crop_id) + .filter(Boolean); + if (ids.includes(form.crop_id.trim())) { + setError(t("add_crop_id_exists")); + setSubmitting(false); + return; } - formData.append( - "sensors", - JSON.stringify({ - pH: parseFloat(sensors.pH), - EC: parseFloat(sensors.EC), - temp: parseFloat(sensors.temp), - humidity: parseFloat(sensors.humidity), - crop: sensors.crop, - stage: sensors.stage, - crop_id: sensors.crop_id || undefined, - }), - ); - - const response = await fetch(`${AGENT_API}/run-cycle-stream`, { - method: "POST", - body: formData, - }); - - if (!response.ok) throw new Error("Backend connection failed"); - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - const parts = buffer.split("\n\n"); - buffer = parts.pop(); - - for (const part of parts) { - if (part.startsWith("data: ")) { - try { - const msg = JSON.parse(part.replace("data: ", "")); - pushLog(msg.text, msg.agent); - if (msg.final_action) setFinalAction(msg.final_action); - if (msg.phase === "done") { - setPhase("done"); - setCycles((c) => c + 1); - refreshData(); - showToast(t("add_cycle_done")); - } - } catch (e) { - console.error("Parse error", e); - } - } - } - } + const payload = { + crop_id: form.crop_id.trim(), + crop: form.crop, + location: form.location, + notes: form.notes, + // image_url: upload to storage + }; + + await createCrop(payload); + await refreshData(); + setCreatedId(form.crop_id.trim()); + setSuccess(true); } catch (err) { - console.error(err); - setPhase("error"); - pushLog(`❌ Connection Error: ${err.message}`, "SYSTEM"); - showToast(t("add_cycle_fail"), "error"); - } - } - - // Track active pipeline phase from logs - useEffect(() => { - if (logs.length) setActivePhase(phaseFromLogs(logs)); - }, [logs]); - - const handleFile = (e) => { - if (e.target.files?.[0]) { - setFile(e.target.files[0]); - setPreview(URL.createObjectURL(e.target.files[0])); + setError(err.message || "Failed to register crop."); + } finally { + setSubmitting(false); } }; - const handleDrop = (e) => { - e.preventDefault(); - const f = e.dataTransfer.files?.[0]; - if (f && f.type.startsWith("image/")) { - setFile(f); - setPreview(URL.createObjectURL(f)); - } - }; - - const CYCLE_PHASES = [ - { key: "fetch", label: t("add_phase_fetch"), icon: Database }, - { key: "judge", label: t("add_phase_judge"), icon: Zap }, - { key: "strategy", label: t("add_phase_strategy"), icon: Brain }, - { key: "research", label: t("add_phase_research"), icon: Leaf }, - { key: "plan", label: t("add_phase_plan"), icon: Cpu }, - { key: "execute", label: t("add_phase_execute"), icon: Play }, - ]; - - const phaseIndex = CYCLE_PHASES.findIndex((p) => p.key === activePhase); - const INPUT_FIELDS = getInputFields(t); - - return ( - <PageShell> - {/* Toast */} - {toast && ( + if (success) { + return ( + <PageShell> + <PageHeader> + <IconButton onClick={() => navigate("/dashboard")}> + <ArrowLeft size={15} /> + </IconButton> + <h1 className="page-title">{t("add_title")}</h1> + </PageHeader> <div - className="animate-fade-in" style={{ - position: "fixed", - bottom: 30, - left: "50%", - transform: "translateX(-50%)", - zIndex: 100, - padding: "12px 24px", - borderRadius: 12, - fontSize: 14, - fontWeight: 600, - fontFamily: "DM Mono, monospace", - background: - toast.type === "error" - ? "rgba(248,113,113,0.95)" - : "rgba(34,197,94,0.95)", - border: `1px solid ${toast.type === "error" ? "rgba(248,113,113,0.4)" : "rgba(74,222,128,0.4)"}`, - color: "white", - boxShadow: "0 8px 32px rgba(0,0,0,0.4)", + flex: 1, display: "flex", alignItems: "center", - gap: 10, + justifyContent: "center", + padding: 32, }} > - {toast.type === "error" ? ( - <AlertTriangle size={18} /> - ) : ( - <CheckCircle2 size={18} /> - )} - {toast.msg} - </div> - )} - - <main - style={{ - flex: 1, - display: "flex", - flexDirection: "column", - overflow: "hidden", - }} - > - {/* Header */} - <PageHeader - title={t("add_title")} - subtitle={t("add_subtitle")} - icon={Sprout} - iconColor="var(--green)" - iconBg="rgba(74,222,128,0.1)" - > - <div - style={{ - display: "flex", - alignItems: "center", - gap: 8, - marginRight: 8, - order: -1, - }} - > - <IconButton onClick={() => navigate("/dashboard")}> - <ArrowLeft size={15} /> - </IconButton> - </div> - - {/* Cycle counter */} - {cycles > 0 && ( + <div style={{ maxWidth: 420, width: "100%", textAlign: "center" }}> <div style={{ - marginLeft: "auto", + width: 72, + height: 72, + borderRadius: "50%", + background: "rgba(74,222,128,0.12)", + border: "1px solid rgba(74,222,128,0.3)", display: "flex", alignItems: "center", - gap: 8, - padding: "5px 14px", - borderRadius: 20, - background: "rgba(74,222,128,0.1)", - border: "1px solid rgba(74,222,128,0.25)", - fontSize: 12, - fontFamily: "DM Mono, monospace", - color: "var(--green)", + justifyContent: "center", + margin: "0 auto 20px", }} > - <span - className="status-dot" - style={{ - width: 6, - height: 6, - borderRadius: "50%", - background: "var(--green)", - }} - /> - {t("add_cycles_done", { n: cycles, s: cycles !== 1 ? "S" : "" })} + <CheckCircle2 size={34} style={{ color: "var(--green)" }} /> </div> - )} - </PageHeader> - - {/* Pipeline phase strip */} - {phase === "running" && ( - <div - className="animate-fade-in" - style={{ - flexShrink: 0, - padding: "10px 24px", - borderBottom: "1px solid var(--border)", - background: "var(--bg-3)", - display: "flex", - alignItems: "center", - gap: 0, - overflowX: "auto", - }} - > - {CYCLE_PHASES.map((p, i) => { - const done = phaseIndex > i; - const current = phaseIndex === i; - return ( - <div - key={p.key} - style={{ display: "flex", alignItems: "center" }} - > - <div - style={{ - display: "flex", - alignItems: "center", - gap: 6, - padding: "5px 12px", - borderRadius: 20, - flexShrink: 0, - background: current - ? "rgba(74,222,128,0.15)" - : done - ? "rgba(74,222,128,0.07)" - : "transparent", - border: `1px solid ${current ? "rgba(74,222,128,0.4)" : done ? "rgba(74,222,128,0.2)" : "transparent"}`, - transition: "all 0.3s", - }} - > - {done ? ( - <CheckCircle2 - size={12} - style={{ color: "var(--green)" }} - /> - ) : current ? ( - <Activity - size={12} - style={{ color: "var(--green)" }} - className="animate-spin" - /> - ) : ( - <Circle size={12} style={{ color: "var(--text-3)" }} /> - )} - <span - style={{ - fontSize: 11, - fontFamily: "DM Mono, monospace", - color: - current || done ? "var(--green)" : "var(--text-3)", - fontWeight: current ? 700 : 400, - }} - > - {p.label} - </span> - </div> - {i < CYCLE_PHASES.length - 1 && ( - <ChevronRight - size={12} - style={{ - color: done ? "var(--green)" : "var(--border)", - margin: "0 2px", - flexShrink: 0, - }} - /> - )} - </div> - ); - })} - </div> - )} - - {/* Main content */} - <div - style={{ - flex: 1, - overflowY: "auto", - padding: 24, - display: "flex", - flexDirection: "column", - gap: 20, - }} - > - {/* Top grid: image + sensors */} - <div - style={{ display: "grid", gridTemplateColumns: "1fr 2fr", gap: 20 }} - > - {/* Image upload */} + <h2 + style={{ + fontSize: 22, + fontWeight: 700, + color: "var(--text)", + marginBottom: 8, + }} + > + {t("add_register_success")} + </h2> + <p + style={{ + fontSize: 13, + fontFamily: "DM Mono, monospace", + color: "var(--text-3)", + marginBottom: 32, + }} + > + {createdId} + </p> <div style={{ display: "flex", flexDirection: "column", gap: 12 }}> - <div className="section-label">{t("add_plant_image")}</div> - <label - onDrop={handleDrop} - onDragOver={(e) => e.preventDefault()} + <button + onClick={() => navigate(`/run-cycle/${createdId}`)} style={{ - position: "relative", - display: "block", - borderRadius: 16, - overflow: "hidden", + padding: "14px 24px", + borderRadius: 12, + background: "var(--green)", + border: "none", + color: "var(--btn-on-green)", + fontWeight: 700, + fontSize: 14, cursor: "pointer", - height: 260, - background: "var(--surface)", - border: preview - ? "2px solid rgba(74,222,128,0.3)" - : "2px dashed var(--border)", - transition: "border-color 0.2s", + display: "flex", + alignItems: "center", + justifyContent: "center", + gap: 8, }} > - <input - type="file" - accept="image/*" - onChange={handleFile} - style={{ - position: "absolute", - inset: 0, - opacity: 0, - cursor: "pointer", - zIndex: 10, - }} - /> - {preview ? ( - <> - <img - src={preview} - alt="preview" - style={{ - width: "100%", - height: "100%", - objectFit: "cover", - }} - /> - <div - style={{ - position: "absolute", - inset: 0, - background: - "linear-gradient(to top, rgba(12,26,14,0.5) 0%, transparent 60%)", - }} - /> - <div - style={{ - position: "absolute", - bottom: 10, - left: 12, - right: 12, - fontSize: 11, - fontFamily: "DM Mono, monospace", - color: "var(--text-2)", - }} - > - {file?.name?.slice(0, 30)} - </div> - </> - ) : ( - <div - style={{ - display: "flex", - flexDirection: "column", - alignItems: "center", - justifyContent: "center", - height: "100%", - gap: 14, - padding: 24, - }} - > - <div - style={{ - width: 52, - height: 52, - borderRadius: 16, - background: "var(--bg-3)", - border: "1px solid var(--border)", - display: "flex", - alignItems: "center", - justifyContent: "center", - }} - > - <Upload size={22} style={{ color: "var(--text-3)" }} /> - </div> - <div style={{ textAlign: "center" }}> - <div - style={{ - fontWeight: 600, - fontSize: 14, - color: "var(--text-2)", - }} - > - {t("add_drop_image")} - </div> - <div - style={{ - fontSize: 11, - marginTop: 4, - color: "var(--text-3)", - }} - > - {t("add_image_hint")} - </div> - </div> - </div> - )} - </label> - - {/* Start button */} + {t("add_run_first_cycle")} <ChevronRight size={16} /> + </button> <button - onClick={startCycle} - disabled={phase === "running"} + onClick={() => navigate("/dashboard")} style={{ - padding: "14px 0", + padding: "12px 24px", borderRadius: 12, - fontSize: 14, - fontWeight: 700, + background: "var(--surface)", + border: "1px solid var(--border)", + color: "var(--text-2)", + fontWeight: 600, + fontSize: 13, + cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center", gap: 8, - cursor: phase === "running" ? "not-allowed" : "pointer", - background: - phase === "running" - ? "rgba(74,222,128,0.1)" - : phase === "done" - ? "rgba(74,222,128,0.15)" - : "var(--green)", - border: - phase === "running" || phase === "done" - ? "1px solid rgba(74,222,128,0.4)" - : "none", - color: - phase === "running" || phase === "done" - ? "var(--green)" - : "var(--btn-on-green)", - opacity: phase === "running" ? 0.8 : 1, - transition: "all 0.2s", - boxShadow: - phase === "idle" ? "0 0 20px rgba(74,222,128,0.2)" : "none", }} > - {phase === "running" ? ( - <> - <Activity size={15} className="animate-spin" />{" "} - {t("add_running")} - </> - ) : phase === "done" ? ( - <> - <CheckCircle2 size={15} /> {t("add_run_another")} - </> - ) : ( - <> - <Play size={15} fill="currentColor" /> {t("add_start")} - </> - )} + <LayoutGrid size={15} /> {t("add_view_dashboard")} </button> </div> + </div> + </div> + </PageShell> + ); + } - {/* Sensor inputs */} - <SectionCard> - <div className="section-label">{t("add_sensor_params")}</div> - <div + // Form state + return ( + <PageShell> + <PageHeader> + <IconButton onClick={() => navigate("/dashboard")}> + <ArrowLeft size={15} /> + </IconButton> + <div> + <h1 className="page-title">{t("add_title")}</h1> + <p className="page-subtitle">{t("add_subtitle")}</p> + </div> + </PageHeader> + + <div + style={{ + flex: 1, + overflowY: "auto", + padding: 24, + display: "flex", + flexDirection: "column", + gap: 20, + width: "100%", + }} + > + {/* Crop Type */} + <SectionCard> + <div className="section-label" style={{ marginBottom: 16 }}> + {t("add_field_crop_type")} + </div> + <div + style={{ + display: "grid", + gridTemplateColumns: "repeat(2, 1fr)", + gap: 10, + }} + > + {SUPPORTED_CROPS.map((c) => ( + <button + key={c} + onClick={() => setForm((f) => ({ ...f, crop: c, crop_id: "" }))} style={{ - display: "grid", - gridTemplateColumns: "1fr 1fr", - gap: 14, + padding: "12px 16px", + borderRadius: 12, + border: `2px solid ${form.crop === c ? "var(--green)" : "var(--border)"}`, + background: + form.crop === c + ? "rgba(74,222,128,0.08)" + : "var(--surface)", + color: form.crop === c ? "var(--green)" : "var(--text-2)", + fontWeight: form.crop === c ? 700 : 500, + fontSize: 13, + cursor: "pointer", + display: "flex", + alignItems: "center", + gap: 8, + transition: "all 0.15s", }} > - {INPUT_FIELDS.map( - ({ - label, - name, - icon: Icon, - color, - type, - opts, - placeholder, - step, - min, - max, - hint, - }) => ( - <div key={name}> - <div - className="sensor-label" - style={{ color, marginBottom: 3 }} - > - {label.toUpperCase()} - </div> - {hint && ( - <div - style={{ - fontSize: 10, - color: "var(--text-3)", - marginBottom: 5, - lineHeight: 1.4, - }} - > - {hint} - </div> - )} - <div style={{ position: "relative" }}> - <Icon - size={12} - style={{ - position: "absolute", - left: 10, - top: "50%", - transform: "translateY(-50%)", - color, - zIndex: 1, - }} - /> - {type === "select" ? ( - <> - <select - value={sensors[name]} - onChange={(e) => - setSensors({ - ...sensors, - [name]: e.target.value, - }) - } - disabled={phase === "running"} - style={{ - width: "100%", - appearance: "none", - paddingLeft: 30, - paddingRight: 28, - paddingTop: 9, - paddingBottom: 9, - borderRadius: 8, - fontSize: 13, - fontFamily: "DM Mono, monospace", - background: "var(--bg-3)", - border: "1px solid var(--border)", - color: "var(--text)", - outline: "none", - cursor: "pointer", - opacity: phase === "running" ? 0.7 : 1, - }} - > - {opts.map((o) => ( - <option key={o} value={o}> - {td(o)} - </option> - ))} - </select> - <ChevronDown - size={10} - style={{ - position: "absolute", - right: 10, - top: "50%", - transform: "translateY(-50%)", - pointerEvents: "none", - color: "var(--text-3)", - }} - /> - </> - ) : ( - <input - value={sensors[name]} - onChange={(e) => - setSensors({ ...sensors, [name]: e.target.value }) - } - type={type} - placeholder={placeholder || ""} - step={step} - min={min} - max={max} - disabled={phase === "running"} - style={{ - width: "100%", - paddingLeft: 30, - paddingRight: 10, - paddingTop: 9, - paddingBottom: 9, - borderRadius: 8, - fontSize: 13, - fontFamily: "DM Mono, monospace", - background: "var(--bg-3)", - border: "1px solid var(--border)", - color: "var(--text)", - outline: "none", - boxSizing: "border-box", - opacity: phase === "running" ? 0.7 : 1, - }} - /> - )} - </div> - </div> - ), + <Leaf size={14} /> + {c} + {form.crop === c && ( + <span + style={{ + marginLeft: "auto", + fontSize: 10, + fontFamily: "DM Mono, monospace", + opacity: 0.7, + }} + > + {CROP_CYCLE_HOURS[c.toLowerCase()]}h/cycle + </span> )} - </div> - </SectionCard> + </button> + ))} </div> + </SectionCard> + + {/* Growth Timeline */} + {form.crop && ( + <SectionCard> + <div className="section-label" style={{ marginBottom: 8 }}> + {t("add_lifecycle_label")} + </div> + <LifecycleTimeline cropName={form.crop} /> + </SectionCard> + )} - {/* Live agent log */} - {(phase !== "idle" || logs.length > 0) && ( + {/* Crop ID */} + <SectionCard> + <div className="section-label" style={{ marginBottom: 12 }}> + {t("add_field_crop_id")} + </div> + <div + style={{ + display: "flex", + alignItems: "center", + gap: 10, + padding: "10px 14px", + borderRadius: 10, + background: "var(--bg-3)", + border: `1px solid ${error.includes("ID") ? "var(--red)" : "var(--border)"}`, + }} + > + <Database + size={14} + style={{ color: "var(--text-3)", flexShrink: 0 }} + /> + <input + type="text" + value={form.crop_id} + onChange={(e) => + setForm((f) => ({ ...f, crop_id: e.target.value })) + } + placeholder={`e.g. Batch_${form.crop}_2025A`} + style={{ + flex: 1, + background: "none", + border: "none", + outline: "none", + color: "var(--text)", + fontSize: 13, + fontFamily: "DM Mono, monospace", + }} + /> + </div> + <p + style={{ + fontSize: 11, + color: "var(--text-3)", + marginTop: 6, + fontFamily: "DM Mono, monospace", + }} + > + {t("add_field_crop_id_hint")} + </p> + {error.includes("ID") && ( + <p + style={{ + fontSize: 11, + color: "var(--red)", + marginTop: 4, + fontFamily: "DM Mono, monospace", + }} + > + {error} + </p> + )} + </SectionCard> + + {/* Auto: cycle duration + stage */} + <SectionCard> + <div className="section-label" style={{ marginBottom: 12 }}> + {t("add_auto_cycle_duration")} + </div> + <div style={{ display: "flex", gap: 12 }}> <div - className="animate-fade-up" style={{ - borderRadius: 16, - overflow: "hidden", - background: "var(--surface)", - border: "1px solid var(--border)", - flexShrink: 0, + flex: 1, + padding: "12px 14px", + borderRadius: 10, + background: "rgba(74,222,128,0.06)", + border: "1px solid rgba(74,222,128,0.2)", }} > - {/* Log header */} <div style={{ - padding: "12px 18px", - borderBottom: "1px solid var(--border)", - background: "var(--bg-3)", display: "flex", alignItems: "center", - gap: 12, + gap: 8, + marginBottom: 4, }} > - <div style={{ display: "flex", alignItems: "center", gap: 8 }}> - {phase === "running" ? ( - <span - className="alert-pulse" - style={{ - width: 8, - height: 8, - borderRadius: "50%", - background: "var(--green)", - flexShrink: 0, - }} - /> - ) : phase === "done" ? ( - <CheckCircle2 size={14} style={{ color: "var(--green)" }} /> - ) : ( - <AlertTriangle size={14} style={{ color: "var(--red)" }} /> - )} - <span - style={{ - fontWeight: 700, - fontSize: 13, - color: "var(--text)", - fontFamily: "DM Mono, monospace", - }} - > - {phase === "running" - ? t("add_log_live") - : phase === "done" - ? t("add_log_done") - : t("add_log_idle")} - </span> - </div> + <Clock size={12} style={{ color: "var(--green)" }} /> <span style={{ - fontSize: 11, + fontSize: 10, fontFamily: "DM Mono, monospace", color: "var(--text-3)", }} > - {t("add_log_lines", { n: logs.length })} + {t("add_auto_cycle_duration")} </span> - - {/* Agent legend */} - <div - style={{ - marginLeft: "auto", - display: "flex", - gap: 8, - flexWrap: "wrap", - }} - > - {Object.entries(AGENT_META) - .filter(([k]) => logs.some((l) => l.agent === k)) - .map(([k, v]) => ( - <span - key={k} - style={{ - fontSize: 9, - fontFamily: "DM Mono, monospace", - padding: "2px 7px", - borderRadius: 4, - background: v.bg, - color: v.color, - border: `1px solid ${v.color}30`, - }} - > - {td(v.label)} - </span> - ))} - </div> </div> - - {/* Log body */} - <div - className="log-area" + <span style={{ - padding: "12px 18px", - maxHeight: 340, - overflowY: "auto", + fontSize: 20, + fontWeight: 700, + color: "var(--green)", fontFamily: "DM Mono, monospace", }} > - {logs.map((entry, i) => ( - <LogLine key={i} entry={entry} idx={i} td={td} /> - ))} - {phase === "running" && ( - <div - style={{ - display: "flex", - alignItems: "center", - gap: 6, - padding: "4px 0", - marginTop: 2, - }} - > - <span - style={{ - fontSize: 12, - fontFamily: "DM Mono, monospace", - color: "var(--green)", - }} - className="cursor-blink" - > - {" "} - </span> - </div> - )} - <div ref={logEndRef} /> - </div> + {cycleDuration}h + </span> </div> - )} - - {/* Final action cards */} - {finalAction && phase === "done" && ( <div - className="animate-fade-up" style={{ - borderRadius: 16, - overflow: "hidden", - background: "var(--surface)", - border: "1px solid rgba(74,222,128,0.3)", - flexShrink: 0, + flex: 1, + padding: "12px 14px", + borderRadius: 10, + background: "var(--bg-3)", + border: "1px solid var(--border)", }} > <div style={{ - padding: "12px 18px", - borderBottom: "1px solid var(--border)", - background: "rgba(74,222,128,0.05)", display: "flex", alignItems: "center", gap: 8, + marginBottom: 4, }} > - <CheckCircle2 size={14} style={{ color: "var(--green)" }} /> + <Sprout size={12} style={{ color: "var(--text-3)" }} /> <span style={{ - fontWeight: 700, - fontSize: 13, - color: "var(--text)", + fontSize: 10, + fontFamily: "DM Mono, monospace", + color: "var(--text-3)", }} > - {t("add_actuator_dispatched")} + Initial Stage </span> </div> - <div + <span style={{ - padding: "18px 20px", - display: "grid", - gridTemplateColumns: "repeat(5, 1fr)", - gap: 12, + fontSize: 14, + fontWeight: 700, + color: "var(--text-2)", + fontFamily: "DM Mono, monospace", + textTransform: "capitalize", }} > - {[ - { - key: "acid_dosage_ml", - labelKey: "widget_acid", - unit: "ml", - icon: FlaskConical, - color: "var(--red)", - }, - { - key: "base_dosage_ml", - labelKey: "widget_base", - unit: "ml", - icon: FlaskConical, - color: "#a78bfa", - }, - { - key: "nutrient_dosage_ml", - labelKey: "widget_nutrients", - unit: "ml", - icon: Sprout, - color: "var(--green)", - }, - { - key: "fan_speed_pct", - labelKey: "widget_fan", - unit: "%", - icon: Fan, - color: "var(--blue)", - }, - { - key: "water_refill_l", - labelKey: "widget_water", - unit: "L", - icon: Waves, - color: "#22d3ee", - }, - ].map(({ key, labelKey, unit, icon: Icon, color }) => { - const val = finalAction[key] ?? 0; - const active = parseFloat(val) > 0; - return ( - <div - key={key} - style={{ - borderRadius: 12, - padding: "14px 10px", - textAlign: "center", - background: active ? `${color}12` : "var(--bg-3)", - border: `1px solid ${active ? color + "40" : "var(--border)"}`, - transition: "all 0.3s", - }} - > - <Icon - size={16} - style={{ - color: active ? color : "var(--text-3)", - margin: "0 auto 6px", - }} - /> - <div - style={{ - fontWeight: 700, - fontFamily: "DM Mono, monospace", - fontSize: 22, - color: active ? color : "var(--text-3)", - lineHeight: 1, - }} - > - {val} - </div> - <div - style={{ - fontSize: 10, - fontFamily: "DM Mono, monospace", - color: "var(--text-3)", - marginTop: 2, - }} - > - {unit} - </div> - <div - style={{ - fontSize: 11, - color: active ? "var(--text-2)" : "var(--text-3)", - marginTop: 4, - fontWeight: active ? 600 : 400, - }} - > - {t(labelKey)} - </div> - </div> - ); - })} - </div> - <div style={{ padding: "0 20px 16px", display: "flex", gap: 10 }}> - <button - onClick={() => navigate("/dashboard")} - style={{ - padding: "9px 20px", - borderRadius: 10, - fontSize: 13, - fontWeight: 600, - background: "var(--green)", - border: "none", - color: "var(--btn-on-green)", - cursor: "pointer", - }} - > - {t("add_view_dashboard")} - </button> - <button - onClick={startCycle} - style={{ - padding: "9px 20px", - borderRadius: 10, - fontSize: 13, - fontWeight: 600, - background: "var(--surface)", - border: "1px solid var(--border)", - color: "var(--text-2)", - cursor: "pointer", - }} - > - {t("add_run_next")} - </button> - </div> + Seedling + </span> </div> + </div> + <p + style={{ + fontSize: 11, + color: "var(--text-3)", + marginTop: 8, + fontFamily: "DM Mono, monospace", + }} + > + {t("add_auto_cycle_hint", { + crop: form.crop, + hours: cycleDuration, + })} + </p> + </SectionCard> + + {/* Location */} + <SectionCard> + <div className="section-label" style={{ marginBottom: 12 }}> + {t("add_field_location")} + </div> + <div + style={{ + display: "flex", + alignItems: "center", + gap: 10, + padding: "10px 14px", + borderRadius: 10, + background: "var(--bg-3)", + border: "1px solid var(--border)", + }} + > + <MapPin + size={14} + style={{ color: "var(--text-3)", flexShrink: 0 }} + /> + <input + type="text" + value={form.location} + onChange={(e) => + setForm((f) => ({ ...f, location: e.target.value })) + } + placeholder={t("add_field_location_placeholder")} + style={{ + flex: 1, + background: "none", + border: "none", + outline: "none", + color: "var(--text)", + fontSize: 13, + fontFamily: "DM Mono, monospace", + }} + /> + </div> + <p + style={{ + fontSize: 11, + color: "var(--text-3)", + marginTop: 6, + fontFamily: "DM Mono, monospace", + }} + > + {t("add_field_location_hint")} + </p> + </SectionCard> + + {/* Notes */} + <SectionCard> + <div className="section-label" style={{ marginBottom: 12 }}> + {t("add_field_notes")} + </div> + <div + style={{ + display: "flex", + gap: 10, + padding: "10px 14px", + borderRadius: 10, + background: "var(--bg-3)", + border: "1px solid var(--border)", + }} + > + <StickyNote + size={14} + style={{ color: "var(--text-3)", flexShrink: 0, marginTop: 2 }} + /> + <textarea + rows={3} + value={form.notes} + onChange={(e) => + setForm((f) => ({ ...f, notes: e.target.value })) + } + placeholder={t("add_field_notes_placeholder")} + style={{ + flex: 1, + background: "none", + border: "none", + outline: "none", + color: "var(--text)", + fontSize: 13, + fontFamily: "DM Mono, monospace", + resize: "vertical", + lineHeight: 1.6, + }} + /> + </div> + </SectionCard> + + {/* Image (optional) */} + <SectionCard> + <div className="section-label" style={{ marginBottom: 12 }}> + {t("add_plant_image")} + </div> + {imagePreview ? ( + <div + style={{ + position: "relative", + borderRadius: 12, + overflow: "hidden", + height: 160, + }} + > + <img + src={imagePreview} + alt="crop preview" + style={{ width: "100%", height: "100%", objectFit: "cover" }} + /> + <button + onClick={() => { + setImageFile(null); + setImagePreview(null); + }} + style={{ + position: "absolute", + top: 8, + right: 8, + padding: "4px 10px", + borderRadius: 8, + background: "rgba(0,0,0,0.6)", + border: "none", + color: "#fff", + fontSize: 11, + cursor: "pointer", + }} + > + Remove + </button> + </div> + ) : ( + <label + style={{ + display: "flex", + flexDirection: "column", + alignItems: "center", + justifyContent: "center", + gap: 8, + padding: 24, + borderRadius: 12, + border: "2px dashed var(--border)", + cursor: "pointer", + background: "var(--bg-3)", + }} + > + <Upload size={22} style={{ color: "var(--text-3)" }} /> + <span + style={{ + fontSize: 12, + fontFamily: "DM Mono, monospace", + color: "var(--text-3)", + }} + > + {t("add_drop_image")} + </span> + <span + style={{ + fontSize: 10, + fontFamily: "DM Mono, monospace", + color: "var(--text-3)", + opacity: 0.6, + }} + > + {t("add_image_hint")} + </span> + <input + type="file" + accept="image/*" + style={{ display: "none" }} + onChange={handleImageChange} + /> + </label> )} - </div> - </main> + </SectionCard> + + {/* Error */} + {error && !error.includes("ID") && ( + <div + style={{ + padding: "10px 14px", + borderRadius: 10, + background: "rgba(248,113,113,0.1)", + border: "1px solid rgba(248,113,113,0.3)", + fontSize: 12, + fontFamily: "DM Mono, monospace", + color: "var(--red)", + }} + > + {error} + </div> + )} + + {/* Submit */} + <button + onClick={handleSubmit} + disabled={submitting} + style={{ + padding: "15px 24px", + borderRadius: 12, + background: submitting ? "var(--border)" : "var(--green)", + border: "none", + color: submitting ? "var(--text-3)" : "var(--btn-on-green)", + fontWeight: 700, + fontSize: 14, + cursor: submitting ? "not-allowed" : "pointer", + display: "flex", + alignItems: "center", + justifyContent: "center", + gap: 8, + transition: "all 0.2s", + marginBottom: 24, + }} + > + <Leaf size={15} /> + {submitting ? t("add_registering") : t("add_register_btn")} + </button> + </div> </PageShell> ); } diff --git a/frontend/src/pages/Alerts.jsx b/frontend/src/pages/Alerts.jsx @@ -12,6 +12,7 @@ import { SlidersHorizontal, Scissors, RotateCcw, + Leaf, } from "lucide-react"; import { useFarmData } from "../hooks/useFarmData"; import { generateAlerts } from "../utils/dataUtils"; @@ -52,9 +53,9 @@ const SEV = { const HARVEST_STYLE = { icon: Scissors, - bg: "rgba(245,158,11,0.12)", - border: "rgba(245,158,11,0.35)", - text: "var(--amber)", + bg: "rgba(74,222,128,0.1)", + border: "rgba(74,222,128,0.3)", + text: "var(--green)", labelKey: "alerts_severity_harvest", }; @@ -67,9 +68,21 @@ const AGENT_COLORS = { HISTORIAN: "var(--text-3)", }; -function AlertCard({ alert, onAck, onUnack, onDismiss, t, td }) { - const style = alert.isHarvestAlert ? HARVEST_STYLE : SEV[alert.severity]; - const Icon = style.icon; +function AlertCard({ alert, onAck, onUnack, onDismiss, t }) { + const style = alert.isHarvestAlert + ? HARVEST_STYLE + : SEV[alert.severity] || SEV.info; + const Icon = style.icon || Info; + + // Friendly agent label + const agentLabel = alert.agent + ? alert.agent.charAt(0) + alert.agent.slice(1).toLowerCase() + " Agent" + : "System"; + + const cropDisplay = + alert.crop && alert.crop !== "Unknown Crop" + ? alert.crop + : alert.cropId || "Unknown"; return ( <div @@ -84,12 +97,12 @@ function AlertCard({ alert, onAck, onUnack, onDismiss, t, td }) { }} > <div style={{ display: "flex", alignItems: "flex-start", gap: 12 }}> - {/* Icon */} + {/* Severity icon */} <div style={{ - width: 32, - height: 32, - borderRadius: 8, + width: 34, + height: 34, + borderRadius: 10, background: style.bg, border: `1px solid ${style.border}`, display: "flex", @@ -99,17 +112,19 @@ function AlertCard({ alert, onAck, onUnack, onDismiss, t, td }) { marginTop: 2, }} > - <Icon size={14} style={{ color: style.text }} /> + <Icon size={15} style={{ color: style.text }} /> </div> {/* Content */} <div style={{ flex: 1, minWidth: 0 }}> + {/* Title row */} <div style={{ display: "flex", alignItems: "center", - gap: 8, + gap: 7, flexWrap: "wrap", + marginBottom: 4, }} > <span @@ -117,50 +132,66 @@ function AlertCard({ alert, onAck, onUnack, onDismiss, t, td }) { fontWeight: 600, fontSize: 13, color: alert.ack ? "var(--text-2)" : "var(--text)", + lineHeight: 1.3, }} > {alert.title} </span> + + {/* Severity badge */} <span style={{ fontSize: 9, fontFamily: "DM Mono, monospace", - padding: "2px 6px", + padding: "2px 7px", borderRadius: 4, background: style.bg, color: style.text, border: `1px solid ${style.border}`, + letterSpacing: "0.04em", + textTransform: "uppercase", }} > {t(style.labelKey)} </span> + + {/* Agent badge */} <span style={{ fontSize: 9, fontFamily: "DM Mono, monospace", - padding: "2px 6px", + padding: "2px 7px", borderRadius: 4, background: "var(--bg-3)", color: AGENT_COLORS[alert.agent] || "var(--text-3)", border: "1px solid var(--border)", }} > - {td(alert.agent)} + {agentLabel} </span> </div> + {/* Description */} <p style={{ fontSize: 12, - color: "var(--text-3)", - margin: "4px 0 8px", - lineHeight: 1.5, + color: "var(--text-2)", + margin: "0 0 8px", + lineHeight: 1.6, }} > - {td(alert.desc)} + {alert.desc} </p> - <div style={{ display: "flex", alignItems: "center", gap: 12 }}> + {/* Meta row: time + crop */} + <div + style={{ + display: "flex", + alignItems: "center", + gap: 12, + flexWrap: "wrap", + }} + > <span style={{ display: "flex", @@ -171,21 +202,32 @@ function AlertCard({ alert, onAck, onUnack, onDismiss, t, td }) { color: "var(--text-3)", }} > - <Clock size={9} /> {alert.time} + <Clock size={9} /> + {alert.time} </span> + <span style={{ + display: "flex", + alignItems: "center", + gap: 4, fontSize: 10, fontFamily: "DM Mono, monospace", color: "var(--text-3)", }} > - {t("alerts_crop_label", { crop: td(alert.crop) })} + <Leaf size={9} style={{ color: "var(--green)" }} /> + <span style={{ color: "var(--text-2)", fontWeight: 500 }}> + {cropDisplay} + </span> + {alert.cropId && alert.cropId !== cropDisplay && ( + <span style={{ opacity: 0.55 }}>· {alert.cropId}</span> + )} </span> </div> </div> - {/* Actions */} + {/* Action buttons */} <div style={{ display: "flex", gap: 4, flexShrink: 0 }}> {!alert.ack ? ( <button @@ -202,6 +244,7 @@ function AlertCard({ alert, onAck, onUnack, onDismiss, t, td }) { alignItems: "center", justifyContent: "center", cursor: "pointer", + transition: "background 150ms, color 150ms", }} > <CheckCircle2 size={13} /> @@ -305,14 +348,16 @@ export default function Alerts() { [alerts, filter, showAcked], ); - // Filters + const unackedList = filtered.filter((a) => !a.ack); + const ackedList = filtered.filter((a) => a.ack); + const FILTER_OPTIONS = [ - { key: "all", label: t("common_all"), count: alerts.length, color: null }, + { key: "all", label: t("common_all"), count: counts.total, color: null }, { key: "harvest", label: t("alerts_filter_harvest"), count: counts.harvest, - color: "var(--amber)", + color: "var(--green)", }, { key: "critical", @@ -455,14 +500,7 @@ export default function Alerts() { {/* Alert list */} <div style={{ flex: 1, overflowY: "auto", padding: 24 }}> {loading ? ( - <div - style={{ - maxWidth: 640, - margin: "0 auto", - }} - > - <LoadingShimmer count={3} height={80} /> - </div> + <LoadingShimmer count={3} height={80} /> ) : filtered.length === 0 ? ( <EmptyState icon={CheckCircle2} @@ -475,79 +513,84 @@ export default function Alerts() { ) : ( <div style={{ - maxWidth: 640, - margin: "0 auto", display: "flex", flexDirection: "column", gap: 24, }} > - {/* Unacked */} - {filtered.filter((a) => !a.ack).length > 0 && ( + {/* Active (unacknowledged) */} + {unackedList.length > 0 && ( <div> <div style={{ fontSize: 10, fontFamily: "DM Mono, monospace", color: "var(--text-3)", - marginBottom: 12, + marginBottom: 10, + display: "flex", + alignItems: "center", + gap: 6, }} > - {t("alerts_unacked", { - n: filtered.filter((a) => !a.ack).length, - })} + <span + style={{ + width: 6, + height: 6, + borderRadius: "50%", + background: "var(--red)", + display: "inline-block", + }} + /> + {t("alerts_unacked", { n: unackedList.length })} + <span style={{ marginLeft: "auto", opacity: 0.5 }}> + newest first + </span> </div> <div style={{ display: "flex", flexDirection: "column", gap: 8 }} > - {filtered - .filter((a) => !a.ack) - .map((a) => ( - <AlertCard - key={a.id} - alert={a} - onAck={ack} - onUnack={unack} - onDismiss={dismiss} - t={t} - td={td} - /> - ))} + {unackedList.map((a) => ( + <AlertCard + key={a.id} + alert={a} + onAck={ack} + onUnack={unack} + onDismiss={dismiss} + t={t} + td={td} + /> + ))} </div> </div> )} - {/* Acked */} - {showAcked && filtered.filter((a) => a.ack).length > 0 && ( + {/* Acknowledged */} + {showAcked && ackedList.length > 0 && ( <div> <div style={{ fontSize: 10, fontFamily: "DM Mono, monospace", color: "var(--text-3)", - marginBottom: 12, + marginBottom: 10, }} > - {t("alerts_acknowledged", { - n: filtered.filter((a) => a.ack).length, - })} + {t("alerts_acked", { n: ackedList.length })} </div> <div style={{ display: "flex", flexDirection: "column", gap: 8 }} > - {filtered - .filter((a) => a.ack) - .map((a) => ( - <AlertCard - key={a.id} - alert={a} - onAck={ack} - onUnack={unack} - onDismiss={dismiss} - t={t} - td={td} - /> - ))} + {ackedList.map((a) => ( + <AlertCard + key={a.id} + alert={a} + onAck={ack} + onUnack={unack} + onDismiss={dismiss} + t={t} + td={td} + /> + ))} </div> </div> )} diff --git a/frontend/src/pages/Analytics.jsx b/frontend/src/pages/Analytics.jsx @@ -701,9 +701,9 @@ export default function Analytics() { fontWeight: 600, }} > - {p.crop_id || "—"} + {p.crop_id || "-"} </td> - <td>{td(p.crop) || "—"}</td> + <td>{td(p.crop) || "-"}</td> <td style={{ color: "var(--text-3)", @@ -711,7 +711,7 @@ export default function Analytics() { fontFamily: "DM Mono, monospace", }} > - {td(p.stage) || "—"} + {td(p.stage) || "-"} </td> <td> <span diff --git a/frontend/src/pages/CropDetails.jsx b/frontend/src/pages/CropDetails.jsx @@ -1,6 +1,6 @@ import React, { useEffect, useState } from "react"; import { useParams, useNavigate } from "react-router-dom"; -import { fetchCropDetails } from "../api/farmApi"; +import { fetchCropDetails, fetchCropById } from "../api/farmApi"; import { ArrowLeft, Thermometer, @@ -11,6 +11,14 @@ import { ChevronDown, ChevronUp, Sparkles, + MapPin, + Calendar, + Clock, + ChevronRight, + Cpu, + StickyNote, + Play, + Leaf, } from "lucide-react"; import { AreaChart, @@ -27,6 +35,11 @@ import { extractSensors, parsePythonString, formatNumber, + calculateMaturity, + getDaysRemaining, + getCurrentStage, + CROP_LIFECYCLES, + CROP_CYCLE_HOURS, } from "../utils/dataUtils"; import { AgentActionWidget, @@ -42,6 +55,8 @@ import { import { useSettings } from "../hooks/useSettings"; import { useT } from "../hooks/useTranslation"; +// Sub-components + function StatBox({ icon: Icon, label, value, color, unit }) { return ( <div @@ -392,7 +407,547 @@ function ExplanationLogBlock({ log, t, td }) { ); } -const TABS = ["details_tab_overview", "details_tab_sensors", "details_tab_log"]; +function InfoTab({ cropDoc, cropId, navigate, t }) { + if (!cropDoc) { + return ( + <div + style={{ + display: "flex", + alignItems: "center", + justifyContent: "center", + padding: 48, + }} + > + <span + style={{ + fontSize: 13, + fontFamily: "DM Mono, monospace", + color: "var(--text-3)", + }} + > + No metadata available yet - run the first cycle to populate data. + </span> + </div> + ); + } + + const maturity = calculateMaturity(cropDoc); + const daysLeft = getDaysRemaining(cropDoc); + const stage = getCurrentStage(cropDoc) || cropDoc.stage || "seedling"; + const cycleH = + cropDoc.cycle_duration_hours || + CROP_CYCLE_HOURS[(cropDoc.crop || "").toLowerCase()] || + 1; + const lifecycle = CROP_LIFECYCLES[(cropDoc.crop || "").toLowerCase()]; + + const plantedDate = cropDoc.planted_at + ? new Date(cropDoc.planted_at).toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + }) + : "-"; + const daysSince = cropDoc.planted_at + ? Math.floor( + (Date.now() - new Date(cropDoc.planted_at).getTime()) / 86400000, + ) + : null; + + const sensorIds = cropDoc.sensor_ids || {}; + const SENSORS_DISPLAY = [ + { + label: "pH Sensor", + id: sensorIds.ph_sensor || "PH-SEN-????", + color: "var(--green)", + }, + { + label: "EC Sensor", + id: sensorIds.ec_sensor || "EC-SEN-????", + color: "var(--amber)", + }, + { + label: "Temp Sensor", + id: sensorIds.temp_sensor || "TMP-SEN-????", + color: "var(--blue)", + }, + { + label: "Humidity Sensor", + id: sensorIds.humidity_sensor || "HUM-SEN-????", + color: "#a78bfa", + }, + ]; + + return ( + <div style={{ display: "flex", flexDirection: "column", gap: 20 }}> + {/* Crop header */} + <div + style={{ + display: "flex", + alignItems: "center", + gap: 16, + padding: 20, + borderRadius: 16, + background: "var(--surface)", + border: "1px solid var(--border)", + }} + > + <div + style={{ + width: 64, + height: 64, + borderRadius: 16, + background: "rgba(74,222,128,0.1)", + border: "1px solid rgba(74,222,128,0.25)", + display: "flex", + alignItems: "center", + justifyContent: "center", + flexShrink: 0, + }} + > + <Leaf size={28} style={{ color: "var(--green)" }} /> + </div> + <div style={{ flex: 1 }}> + <div + style={{ + fontWeight: 700, + fontSize: 18, + color: "var(--text)", + marginBottom: 4, + }} + > + {cropDoc.crop} + </div> + <div + style={{ + fontSize: 11, + fontFamily: "DM Mono, monospace", + color: "var(--text-3)", + }} + > + {cropId} + </div> + <div + style={{ + marginTop: 8, + display: "flex", + alignItems: "center", + gap: 8, + }} + > + <span + style={{ + fontSize: 10, + fontFamily: "DM Mono, monospace", + padding: "2px 10px", + borderRadius: 20, + background: "rgba(74,222,128,0.12)", + border: "1px solid rgba(74,222,128,0.3)", + color: "var(--green)", + textTransform: "capitalize", + }} + > + {stage} + </span> + {cycleH && ( + <span + style={{ + fontSize: 10, + fontFamily: "DM Mono, monospace", + color: "var(--text-3)", + }} + > + {t("details_hours_per_cycle", { n: cycleH })} + </span> + )} + </div> + </div> + </div> + + {/* Details grid */} + <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}> + {[ + { + icon: Calendar, + label: t("details_planted"), + value: plantedDate, + color: "var(--blue)", + }, + { + icon: Clock, + label: t("details_cycle_duration"), + value: `${cycleH}h / cycle`, + color: "var(--green)", + }, + { + icon: Leaf, + label: t("details_lifecycle_progress"), + value: `${maturity}%`, + color: "var(--amber)", + }, + { + icon: MapPin, + label: t("details_location"), + value: cropDoc.location || "-", + color: "var(--text-2)", + }, + ].map(({ icon, label, value, color }) => ( + <div + key={label} + style={{ + padding: "14px 16px", + borderRadius: 12, + background: "var(--surface)", + border: "1px solid var(--border)", + }} + > + <div + style={{ + display: "flex", + alignItems: "center", + gap: 7, + marginBottom: 8, + }} + > + {React.createElement(icon, { size: 12, style: { color } })} + <span + style={{ + fontSize: 10, + fontFamily: "DM Mono, monospace", + color: "var(--text-3)", + }} + > + {label} + </span> + </div> + <div + style={{ + fontSize: 14, + fontWeight: 700, + color, + fontFamily: "DM Mono, monospace", + }} + > + {value} + </div> + </div> + ))} + + {/* Days since / remaining */} + <div + style={{ + padding: "14px 16px", + borderRadius: 12, + background: "var(--surface)", + border: "1px solid var(--border)", + }} + > + <div + style={{ + fontSize: 10, + fontFamily: "DM Mono, monospace", + color: "var(--text-3)", + marginBottom: 8, + }} + > + {t("details_days_since", { n: daysSince ?? "-" })} + </div> + <div + style={{ + fontSize: 14, + fontWeight: 700, + fontFamily: "DM Mono, monospace", + color: "var(--text-2)", + }} + > + {daysLeft !== null + ? t("details_days_remain", { n: daysLeft }) + : "-"} + </div> + </div> + + <div + style={{ + padding: "14px 16px", + borderRadius: 12, + background: "var(--surface)", + border: "1px solid var(--border)", + }} + > + <div + style={{ + fontSize: 10, + fontFamily: "DM Mono, monospace", + color: "var(--text-3)", + marginBottom: 8, + }} + > + {t("details_total_sequences")} + </div> + <div + style={{ + fontSize: 22, + fontWeight: 700, + fontFamily: "DM Mono, monospace", + color: "var(--text)", + }} + > + {cropDoc.sequence_number || 0} + </div> + </div> + </div> + + {/* Lifecycle progress bar */} + {lifecycle && ( + <div + style={{ + padding: 16, + borderRadius: 14, + background: "var(--surface)", + border: "1px solid var(--border)", + }} + > + <div + style={{ + fontSize: 10, + fontFamily: "DM Mono, monospace", + color: "var(--text-3)", + marginBottom: 10, + }} + > + {t("details_lifecycle_progress")} - {lifecycle.totalDays} days total + </div> + <div + style={{ + height: 8, + borderRadius: 4, + background: "var(--border)", + overflow: "hidden", + marginBottom: 8, + }} + > + <div + style={{ + width: `${maturity}%`, + height: "100%", + borderRadius: 4, + background: maturity >= 80 ? "var(--amber)" : "var(--green)", + transition: "width 0.4s", + }} + /> + </div> + <div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}> + {lifecycle.stages.map((s, i) => { + const colors = [ + "var(--text-3)", + "var(--green)", + "var(--amber)", + "var(--red)", + ]; + return ( + <div + key={s.name} + style={{ display: "flex", alignItems: "center", gap: 5 }} + > + <span + style={{ + width: 8, + height: 8, + borderRadius: "50%", + background: colors[i % colors.length], + display: "inline-block", + }} + /> + <span + style={{ + fontSize: 10, + fontFamily: "DM Mono, monospace", + color: "var(--text-3)", + textTransform: "capitalize", + }} + > + {s.name} ( + {s.endH + ? `${Math.round((s.endH - s.startH) / 24)}d` + : "harvest"} + ) + </span> + </div> + ); + })} + </div> + </div> + )} + + {/* Sensor Hardware */} + <div + style={{ + borderRadius: 14, + overflow: "hidden", + background: "var(--surface)", + border: "1px solid var(--border)", + }} + > + <div + style={{ + padding: "12px 16px", + borderBottom: "1px solid var(--border)", + background: "var(--bg-3)", + display: "flex", + alignItems: "center", + gap: 8, + }} + > + <Cpu size={13} style={{ color: "var(--text-3)" }} /> + <span + style={{ + fontSize: 10, + fontFamily: "DM Mono, monospace", + color: "var(--text-3)", + letterSpacing: "0.08em", + }} + > + {t("details_sensor_hardware")} + </span> + </div> + <div + style={{ + display: "grid", + gridTemplateColumns: "1fr 1fr", + gap: 1, + background: "var(--border)", + }} + > + {SENSORS_DISPLAY.map(({ label, id, color }) => ( + <div + key={label} + style={{ padding: "14px 16px", background: "var(--surface)" }} + > + <div + style={{ + fontSize: 10, + fontFamily: "DM Mono, monospace", + color: "var(--text-3)", + marginBottom: 6, + }} + > + {label} + </div> + <div + style={{ + fontSize: 13, + fontFamily: "DM Mono, monospace", + fontWeight: 700, + color, + marginBottom: 4, + }} + > + {id} + </div> + <div style={{ display: "flex", alignItems: "center", gap: 5 }}> + <span + style={{ + width: 5, + height: 5, + borderRadius: "50%", + background: "var(--green)", + display: "inline-block", + }} + /> + <span + style={{ + fontSize: 9, + fontFamily: "DM Mono, monospace", + color: "var(--green)", + }} + > + {t("details_sensor_online")} + </span> + </div> + </div> + ))} + </div> + </div> + + {/* Notes */} + {cropDoc.notes && ( + <div + style={{ + padding: 16, + borderRadius: 14, + background: "var(--surface)", + border: "1px solid var(--border)", + }} + > + <div + style={{ + display: "flex", + alignItems: "center", + gap: 8, + marginBottom: 10, + }} + > + <StickyNote size={13} style={{ color: "var(--text-3)" }} /> + <span + style={{ + fontSize: 10, + fontFamily: "DM Mono, monospace", + color: "var(--text-3)", + }} + > + {t("details_notes")} + </span> + </div> + <p + style={{ + fontSize: 13, + color: "var(--text-2)", + lineHeight: 1.7, + margin: 0, + fontFamily: "DM Mono, monospace", + }} + > + {cropDoc.notes} + </p> + </div> + )} + + {/* Run Cycle CTA */} + <button + onClick={() => navigate(`/run-cycle/${cropId}`)} + style={{ + padding: "14px 20px", + borderRadius: 12, + background: "var(--green)", + border: "none", + color: "var(--btn-on-green)", + fontWeight: 700, + fontSize: 14, + cursor: "pointer", + display: "flex", + alignItems: "center", + justifyContent: "center", + gap: 8, + marginBottom: 8, + }} + > + <Play size={15} /> + {t("details_run_cycle_btn")} + <ChevronRight size={14} /> + </button> + </div> + ); +} + +// Tabs + +const TABS = [ + "details_tab_info", + "details_tab_overview", + "details_tab_sensors", + "details_tab_log", +]; + +// MAIN export default function CropDetails() { const { cropId } = useParams(); @@ -403,13 +958,24 @@ export default function CropDetails() { const [history, setHistory] = useState([]); const [latest, setLatest] = useState(null); + const [cropDoc, setCropDoc] = useState(null); const [loading, setLoading] = useState(true); - const [activeTab, setActiveTab] = useState("details_tab_overview"); + const [activeTab, setActiveTab] = useState("details_tab_info"); useEffect(() => { - fetchCropDetails(cropId).then((data) => { - if (data?.length) { - const sorted = [...data].sort( + let cancelled = false; + setLoading(true); + + Promise.all([ + // Qdrant history + fetchCropDetails(cropId), + // MongoDB doc + fetchCropById(cropId), + ]).then(([histData, mongoDoc]) => { + if (cancelled) return; + + if (histData?.length) { + const sorted = [...histData].sort( (a, b) => (a.payload?.sequence_number || 0) - (b.payload?.sequence_number || 0), @@ -422,8 +988,14 @@ export default function CropDetails() { setHistory(processed); setLatest(processed[processed.length - 1]); } + + if (mongoDoc) setCropDoc(mongoDoc); setLoading(false); }); + + return () => { + cancelled = true; + }; }, [cropId]); if (loading) @@ -450,7 +1022,7 @@ export default function CropDetails() { </PageShell> ); - if (!latest) + if (!latest && !cropDoc) return ( <div style={{ @@ -464,8 +1036,8 @@ export default function CropDetails() { </div> ); - const p = latest.payload || {}; - const sensors = latest.cleanSensors || {}; + const p = latest?.payload || {}; + const sensors = latest?.cleanSensors || {}; const chartData = history.map((h) => ({ t: h.payload?.timestamp @@ -480,6 +1052,9 @@ export default function CropDetails() { humidity: formatNumber(h.cleanSensors?.humidity), })); + const displayCrop = cropDoc?.crop || p.crop || t("common_unknown"); + const displayStage = cropDoc?.stage || p.stage || ""; + return ( <PageShell> {/* Header */} @@ -490,19 +1065,15 @@ export default function CropDetails() { <div> <h1 className="page-title"> - {td(p.crop) || t("common_unknown")}{" "} + {td(displayCrop)}{" "} <span - style={{ - color: "var(--text-3)", - fontWeight: 400, - fontSize: 16, - }} + style={{ color: "var(--text-3)", fontWeight: 400, fontSize: 16 }} > #{p.sequence_number || 0} </span> </h1> <p className="page-subtitle"> - {cropId} · {td(p.stage)} + {cropId} · {td(displayStage)} </p> </div> @@ -534,7 +1105,14 @@ export default function CropDetails() { </div> {/* Tabs */} - <div style={{ marginLeft: "auto", display: "flex", gap: 4 }}> + <div + style={{ + marginLeft: "auto", + display: "flex", + gap: 4, + flexWrap: "wrap", + }} + > {TABS.map((tab) => ( <button key={tab} @@ -569,6 +1147,17 @@ export default function CropDetails() { gap: 20, }} > + {/* Info Tab */} + {activeTab === "details_tab_info" && ( + <InfoTab + cropDoc={cropDoc} + cropId={cropId} + navigate={navigate} + t={t} + /> + )} + + {/* Overview Tab */} {activeTab === "details_tab_overview" && ( <> {/* Sensor stats */} @@ -696,6 +1285,7 @@ export default function CropDetails() { </> )} + {/* Sensors Tab */} {activeTab === "details_tab_sensors" && ( <> <div @@ -815,6 +1405,7 @@ export default function CropDetails() { </> )} + {/* Log Tab */} {activeTab === "details_tab_log" && ( <div style={{ @@ -928,26 +1519,10 @@ function LogRow({ h, i, logLimit, t, td, formatNumber, logDotColor }) { > #{h.payload?.sequence_number || i} </span> - - {/* Sensor snapshot */} - <div - style={{ - display: "flex", - gap: 12, - flexWrap: "wrap", - }} - > + <div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}> {[ - { - label: "pH", - value: h.cleanSensors?.ph, - color: "var(--green)", - }, - { - label: "EC", - value: h.cleanSensors?.ec, - color: "var(--amber)", - }, + { label: "pH", value: h.cleanSensors?.ph, color: "var(--green)" }, + { label: "EC", value: h.cleanSensors?.ec, color: "var(--amber)" }, { label: "T", value: h.cleanSensors?.temp + "°", @@ -969,12 +1544,7 @@ function LogRow({ h, i, logLimit, t, td, formatNumber, logDotColor }) { gap: 3, }} > - <span - style={{ - color: "var(--text-3)", - fontSize: 11, - }} - > + <span style={{ color: "var(--text-3)", fontSize: 11 }}> {label} </span> <span style={{ color, fontWeight: 700 }}> @@ -1046,7 +1616,7 @@ function LogRow({ h, i, logLimit, t, td, formatNumber, logDotColor }) { marginBottom: 8, }} > - <Brain size={9} style={{ display: "inline", marginRight: 5 }} />{" "} + <Brain size={9} style={{ display: "inline", marginRight: 5 }} /> {t("details_ai_reasoning")} </div> <pre diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx @@ -8,6 +8,8 @@ import { calculateMaturity, deriveCropStatus, isReadyToHarvest, + getDaysRemaining, + SUPPORTED_CROPS, } from "../utils/dataUtils"; import { SlidersHorizontal, @@ -43,7 +45,8 @@ const STAGES_KEYS = [ "stage_flowering", "stage_fruiting", ]; -const CROPS = ["All", "Lettuce", "Tomato", "Basil", "Spinach", "Cucumber"]; +// Only 4 supported crops (+ All) +const CROPS = ["All", ...SUPPORTED_CROPS]; const STATUSES_KEYS = [ "stage_all", "dash_healthy", @@ -53,8 +56,6 @@ const STATUSES_KEYS = [ function CropCard({ data, onClick, t, td }) { const maturity = data.maturity || 40; - - // Map backend status to translation key const statusKey = data.status === "Healthy" ? "dash_healthy" @@ -303,7 +304,7 @@ function CropCard({ data, onClick, t, td }) { ) : ( <> <Clock size={10} /> - {data.daysLeft > 0 + {data.daysLeft !== null && data.daysLeft > 0 ? t("dash_days_left", { n: data.daysLeft }) : t("dash_ready")} </> @@ -401,8 +402,9 @@ export default function Dashboard() { return "https://images.unsplash.com/photo-1591857177580-dc82b9e4e5c9?q=80&w=400"; if (n.includes("basil")) return "https://images.unsplash.com/photo-1618164436241-4473940d1f5c?q=80&w=400"; - if (n.includes("spinach")) - return "https://images.unsplash.com/photo-1576045057995-568f588f82fb?q=80&w=400"; + if (n.includes("strawberry")) + return "https://images.unsplash.com/photo-1464965911861-746a04b4bca6?q=80&w=400"; + // Default: lettuce return "https://images.unsplash.com/photo-1622206151226-18ca2c9ab4a1?q=80&w=400"; }; @@ -410,21 +412,23 @@ export default function Dashboard() { if (dashboard) { setCrops( dashboard.map((item) => { + // Dashboard items are normalized: { id, payload: {...} } const p = item.payload || {}; const sensors = extractSensors(p); const rawStage = p.stage || ""; + const daysLeft = getDaysRemaining(p); return { id: p.crop_id || item.id, - cropId: p.crop_id || "—", + cropId: p.crop_id || "-", name: p.crop || t("common_unknown"), - statusMsg: rawStage ? rawStage : t("dash_status_growing"), - image: getImg(p.crop), + statusMsg: rawStage || t("dash_status_growing"), + image: p.image_url || getImg(p.crop), status: deriveCropStatus(p), - maturity: calculateMaturity(p.sequence_number), + maturity: calculateMaturity(p), harvestReady: isReadyToHarvest(p), seq: p.sequence_number, - daysLeft: 30 - (p.sequence_number || 0), + daysLeft: daysLeft, sensors: { temp: sensors.temp, ph: sensors.ph }, stage: rawStage, rawCrop: (p.crop || "").trim(), @@ -451,7 +455,11 @@ export default function Dashboard() { !td(c.statusMsg).toLowerCase().includes(q) ) return false; - if (filterStage !== "All" && c.stage !== filterStage) return false; + if ( + filterStage !== "All" && + c.stage.toLowerCase() !== filterStage.toLowerCase() + ) + return false; if (filterCrop !== "All" && c.rawCrop !== filterCrop) return false; if (filterStatus !== "All" && c.status !== filterStatus) return false; if (filterReady && !c.harvestReady) return false; @@ -476,7 +484,6 @@ export default function Dashboard() { [crops], ); - // Keys to match english defaults in backend to their translations const STAGES_EN = ["All", "Seedling", "Vegetative", "Flowering", "Fruiting"]; const STATUS_EN = ["All", "Healthy", "Attention", "Critical"]; @@ -778,7 +785,6 @@ export default function Dashboard() { </div> )} - {/* Crop grid */} <div style={{ flex: 1, overflowY: "auto", padding: 24 }}> {loading ? ( <div diff --git a/frontend/src/pages/FarmIntelligence.jsx b/frontend/src/pages/FarmIntelligence.jsx @@ -327,7 +327,7 @@ function RelatedCropCard({ item, score, t, td }) { marginTop: 2, }} > - {p.crop_id || "—"} · Seq #{p.sequence_number || 1} + {p.crop_id || "-"} · Seq #{p.sequence_number || 1} </div> </div> <div @@ -473,7 +473,7 @@ function InsightCard({ result, idx, t, td }) { marginTop: 2, }} > - {p.crop_id || "—"} · Seq #{p.sequence_number || 1} + {p.crop_id || "-"} · Seq #{p.sequence_number || 1} </div> </div> <div @@ -979,7 +979,7 @@ export default function FarmIntelligence() { - Crop: ${p.crop || cropCtx.crop} - Batch ID: ${p.crop_id || cropCtx.cropId} - Growth Stage: ${p.stage || "Unknown"} -- Sequence Number: ${p.sequence_number || "—"} +- Sequence Number: ${p.sequence_number || "-"} - Last Updated: ${p.timestamp ? new Date(p.timestamp).toLocaleString() : "Unknown"} LATEST SENSOR READINGS: - pH: ${sensors.ph} @@ -1104,7 +1104,7 @@ SYSTEM: Hydroponic multi-crop farm management system (Demeter).`.trim(); setTranscription(""); try { - // Build the search payload — include selectedCrop context so backend + // Build the search payload - include selectedCrop context so backend // can bias Qdrant filter results toward that crop's embedding const data = await agentService.queryText(query, selectedCrop?.cropId); diff --git a/frontend/src/pages/Help.jsx b/frontend/src/pages/Help.jsx @@ -210,7 +210,7 @@ const MANUAL_SCENARIOS = [ const HELP_EN = { help_title: "Help & Glossary", - help_subtitle: "Everything you need to know — explained simply", + help_subtitle: "Everything you need to know - explained simply", help_section_terms: "📊 Sensor Terms Explained", help_section_agents: "🤖 How AI Works for You", help_section_stages: "🌱 Growth Stages", @@ -231,14 +231,14 @@ const HELP_EN = { help_term_ph: "pH (Acidity of Water)", help_term_ph_short: "pH", help_term_ph_simple: - "pH tells you if the water is sour (acidic) or bitter (alkaline). Think of lemon juice (very sour = low pH) vs. baking soda (bitter = high pH). Plants need water that is slightly acidic — not too sour, not too bitter.", + "pH tells you if the water is sour (acidic) or bitter (alkaline). Think of lemon juice (very sour = low pH) vs. baking soda (bitter = high pH). Plants need water that is slightly acidic - not too sour, not too bitter.", help_term_ph_detail: "pH is measured on a scale from 0 to 14. A value of 7 is neutral (pure water). Below 7 is acidic. Above 7 is alkaline. In hydroponics, root cells absorb nutrients through a process that depends on water chemistry. If pH is wrong, roots physically cannot absorb nutrients even if they are present in the water.", help_term_ph_range: "5.5 - 6.5", help_term_ph_low: "Roots get damaged. Iron and manganese become toxic. Leaves turn yellow. Plant looks sick.", help_term_ph_high: - "Nutrients lock up — plant cannot absorb calcium, magnesium, or iron. Leaves look pale and growth slows.", + "Nutrients lock up - plant cannot absorb calcium, magnesium, or iron. Leaves look pale and growth slows.", help_term_ph_ai: "If pH drops below 5.5, Demeter automatically doses base solution (pH Up) into the water. If pH rises above 6.5, it doses acid (pH Down). This happens within minutes without you needing to do anything.", help_term_ph_manual: @@ -250,9 +250,9 @@ const HELP_EN = { help_term_ec: "EC (Electrical Conductivity / Nutrient Strength)", help_term_ec_short: "EC", help_term_ec_simple: - "EC measures how many nutrients are dissolved in your water. Think of it like making tea — a little tea is fine, but too much tea (very dark) can be bitter and harmful. EC tells you if your water has the right amount of 'food' for the plant.", + "EC measures how many nutrients are dissolved in your water. Think of it like making tea - a little tea is fine, but too much tea (very dark) can be bitter and harmful. EC tells you if your water has the right amount of 'food' for the plant.", help_term_ec_detail: - "EC (Electrical Conductivity) is measured in dS/m (deciSiemens per meter). Pure water conducts very little electricity. When you dissolve fertilizer salts in water, it conducts more electricity. The sensor measures this to determine nutrient concentration. High EC means too many salts — this draws water OUT of roots (osmotic stress). Low EC means the plant is not getting enough food.", + "EC (Electrical Conductivity) is measured in dS/m (deciSiemens per meter). Pure water conducts very little electricity. When you dissolve fertilizer salts in water, it conducts more electricity. The sensor measures this to determine nutrient concentration. High EC means too many salts - this draws water OUT of roots (osmotic stress). Low EC means the plant is not getting enough food.", help_term_ec_range: "0.8 - 2.5 dS/m", help_term_ec_low: "Plant is starving. Leaves become pale, growth slows, and yield drops. The plant has water but no food.", @@ -263,7 +263,7 @@ const HELP_EN = { help_term_ec_manual: "If EC spikes suddenly without explanation, it may indicate a pump failure delivering pure nutrient concentrate or contamination. Physically check the nutrient reservoir and all dosing lines.", help_term_ec_agri: - "In soil farming, nutrients bind to soil particles and release slowly — this naturally buffers EC. In hydroponics, nutrients are directly in solution, making EC much more sensitive. Different crops need different EC: leafy greens prefer lower EC (0.8-1.6), while fruiting crops like tomatoes tolerate higher levels (2.0-3.5).", + "In soil farming, nutrients bind to soil particles and release slowly - this naturally buffers EC. In hydroponics, nutrients are directly in solution, making EC much more sensitive. Different crops need different EC: leafy greens prefer lower EC (0.8-1.6), while fruiting crops like tomatoes tolerate higher levels (2.0-3.5).", // Humidity help_term_humidity: "Humidity (Moisture in Air)", @@ -271,7 +271,7 @@ const HELP_EN = { help_term_humidity_simple: "Humidity is how wet or dry the air feels. On a very humid day, you feel sweaty and sticky. Plants also 'breathe' through tiny holes in their leaves. If air is too wet, these holes clog and fungus grows. If air is too dry, leaves lose water too fast and wilt.", help_term_humidity_detail: - "Humidity is measured as Relative Humidity (RH%) — the percentage of moisture in the air compared to the maximum it can hold at that temperature. Plants exchange gases and water vapor through structures called stomata (tiny pores). High humidity reduces evaporation from leaves, slowing the plant's 'transpiration pump' which is what drives nutrient uptake. Very high humidity (above 85%) creates perfect conditions for fungal diseases like powdery mildew and botrytis.", + "Humidity is measured as Relative Humidity (RH%) - the percentage of moisture in the air compared to the maximum it can hold at that temperature. Plants exchange gases and water vapor through structures called stomata (tiny pores). High humidity reduces evaporation from leaves, slowing the plant's 'transpiration pump' which is what drives nutrient uptake. Very high humidity (above 85%) creates perfect conditions for fungal diseases like powdery mildew and botrytis.", help_term_humidity_range: "40% - 80% RH", help_term_humidity_low: "Leaves curl and dry out. Stomata close to conserve water, halting photosynthesis and nutrient absorption. Severe drought stress.", @@ -280,7 +280,7 @@ const HELP_EN = { help_term_humidity_ai: "Demeter controls fan speed automatically to regulate airflow and humidity. If humidity is high, fans speed up to improve air circulation. If it's too low, fans slow down. The atmospheric agent monitors and adjusts this continuously.", help_term_humidity_manual: - "If humidity stays critically high despite maximum fan speed, it may indicate a structural issue — poor ventilation design, leaking irrigation, or a broken HVAC system. You will need physical intervention to fix the root cause.", + "If humidity stays critically high despite maximum fan speed, it may indicate a structural issue - poor ventilation design, leaking irrigation, or a broken HVAC system. You will need physical intervention to fix the root cause.", help_term_humidity_agri: "In open-field farming, humidity is weather-dependent and uncontrollable. Indoor/hydroponic farming's great advantage is humidity control. Controlling humidity in the 60-70% range during vegetative growth, then dropping to 40-50% during flowering dramatically reduces disease pressure and improves yields.", @@ -288,9 +288,9 @@ const HELP_EN = { help_term_temp: "Temperature (Heat in the Air)", help_term_temp_short: "Temperature", help_term_temp_simple: - "Temperature is how hot or cold the air is. Plants are like people — they prefer a comfortable temperature. Too cold and they 'slow down' and stop growing. Too hot and they get heat stroke, and bacteria in the water can multiply rapidly.", + "Temperature is how hot or cold the air is. Plants are like people - they prefer a comfortable temperature. Too cold and they 'slow down' and stop growing. Too hot and they get heat stroke, and bacteria in the water can multiply rapidly.", help_term_temp_detail: - "Air temperature directly affects the rate of photosynthesis, respiration, enzyme activity, and transpiration in plants. The nutrient solution temperature is equally important — warm water holds less dissolved oxygen (DO), and roots need oxygen to function. Most hydroponic crops prefer 18-28°C air temperature. Below 15°C, enzymatic reactions slow dramatically. Above 30°C, nutrient uptake becomes erratic, and pathogenic bacteria proliferate in the root zone.", + "Air temperature directly affects the rate of photosynthesis, respiration, enzyme activity, and transpiration in plants. The nutrient solution temperature is equally important - warm water holds less dissolved oxygen (DO), and roots need oxygen to function. Most hydroponic crops prefer 18-28°C air temperature. Below 15°C, enzymatic reactions slow dramatically. Above 30°C, nutrient uptake becomes erratic, and pathogenic bacteria proliferate in the root zone.", help_term_temp_range: "18°C - 28°C", help_term_temp_low: "Growth nearly stops. Roots may rot in cold water. Germination fails. Plant looks stunted and dark.", @@ -307,7 +307,7 @@ const HELP_EN = { help_agent_fetch_name: "Fetch Agent", help_agent_fetch_role: "Reads all sensor data", help_agent_fetch_detail: - "This agent collects pH, EC, temperature, humidity readings and any plant images. It's the eyes and ears of the system — everything starts here.", + "This agent collects pH, EC, temperature, humidity readings and any plant images. It's the eyes and ears of the system - everything starts here.", help_agent_judge_name: "Judge Agent", help_agent_judge_role: "Evaluates crop health", @@ -317,7 +317,7 @@ const HELP_EN = { help_agent_strategy_name: "Strategy Agent", help_agent_strategy_role: "Decides what action to take", help_agent_strategy_detail: - "This is the brain. It uses reinforcement learning — it has learned from thousands of past cycles what actions work best for each crop type and situation. It picks the best corrective action.", + "This is the brain. It uses reinforcement learning - it has learned from thousands of past cycles what actions work best for each crop type and situation. It picks the best corrective action.", help_agent_research_name: "Research Agent", help_agent_research_role: "Checks memory for similar cases", @@ -327,7 +327,7 @@ const HELP_EN = { help_agent_execute_name: "Execute Agent", help_agent_execute_role: "Sends commands to hardware", help_agent_execute_detail: - "Once a plan is confirmed, this agent sends the actual commands to physical actuators — pumps, fans, dosing systems. It turns digital decisions into real-world actions.", + "Once a plan is confirmed, this agent sends the actual commands to physical actuators - pumps, fans, dosing systems. It turns digital decisions into real-world actions.", help_agent_explainer_name: "Explainer Agent", help_agent_explainer_role: "Writes the reasoning in plain language", @@ -339,25 +339,25 @@ const HELP_EN = { help_stage_seedling_desc: "The plant has just sprouted from a seed. It's tiny, fragile, and just starting to grow its first leaves.", help_stage_seedling_tips: - "🌡️ Keep temperature warm (22-26°C). 💧 Keep EC very low (0.5-1.0) — too much nutrient overwhelms fragile roots. 💡 Light should be gentle. This is the most fragile stage.", + "🌡️ Keep temperature warm (22-26°C). 💧 Keep EC very low (0.5-1.0) - too much nutrient overwhelms fragile roots. 💡 Light should be gentle. This is the most fragile stage.", help_stage_vegetative: "Vegetative", help_stage_vegetative_desc: "The plant is growing leaves and stems rapidly. It's building its structure before it starts making flowers or fruit.", help_stage_vegetative_tips: - "🌿 Increase nutrient strength (EC 1.2-2.0). 💧 Maintain humidity at 60-70%. This is when the plant grows fastest — it needs maximum nutrition.", + "🌿 Increase nutrient strength (EC 1.2-2.0). 💧 Maintain humidity at 60-70%. This is when the plant grows fastest - it needs maximum nutrition.", help_stage_flowering: "Flowering", help_stage_flowering_desc: - "The plant starts making flowers. This is a critical stage — conditions here directly affect how much fruit or produce you'll get.", + "The plant starts making flowers. This is a critical stage - conditions here directly affect how much fruit or produce you'll get.", help_stage_flowering_tips: - "🌸 Reduce humidity to 50-60% to prevent bud rot. 🧪 Adjust nutrients — lower nitrogen, higher phosphorus/potassium. Handle the plant gently to avoid dropping flowers.", + "🌸 Reduce humidity to 50-60% to prevent bud rot. 🧪 Adjust nutrients - lower nitrogen, higher phosphorus/potassium. Handle the plant gently to avoid dropping flowers.", help_stage_fruiting: "Fruiting", help_stage_fruiting_desc: "Flowers have been pollinated and are now developing into fruits or final produce. The plant is putting all its energy into the harvest.", help_stage_fruiting_tips: - "🍅 Maintain consistent EC and pH — fluctuations now can cause blossom end rot or splitting. 📉 Drop humidity to 40-50%. Watch maturity closely — harvest at the right time!", + "🍅 Maintain consistent EC and pH - fluctuations now can cause blossom end rot or splitting. 📉 Drop humidity to 40-50%. Watch maturity closely - harvest at the right time!", // Manual Intervention help_manual_disease_title: "Disease or Pest Outbreak", @@ -383,6 +383,24 @@ const HELP_EN = { "In case of power outages, floods, extreme heat waves, or structural damage to the growing area, manual intervention is always required.", help_manual_extreme_action: "Have a backup power plan (generator or UPS). Keep manual pH and EC test kits available. Know how to manually adjust nutrients if digital systems fail.", + + // How to Add a Crop + help_section_add_crop: "🌿 How to Add a Crop", + help_add_crop_step1: 'Click "Add Crop" in the dashboard', + help_add_crop_step2: + "Choose crop type - Lettuce, Tomato, Basil, or Strawberry", + help_add_crop_step3: "Enter a unique Batch ID and optional location/notes", + help_add_crop_step4: + 'Click "Register Crop" - the system auto-sets cycle duration and sensors', + help_add_crop_step5: + "After registration, go to the crop's Info tab → Run Agent Cycle", + + // Supported Crop Types + help_section_crops: "🌱 Supported Crop Types", + help_crop_lettuce: "Lettuce - ~21 days · 1h per cycle", + help_crop_tomato: "Tomato - ~70 days · 2h per cycle", + help_crop_basil: "Basil - ~28 days · 1h per cycle", + help_crop_strawberry: "Strawberry - ~63 days · 2h per cycle", }; const HELP_HI = { @@ -409,14 +427,14 @@ const HELP_HI = { help_term_ph: "pH (पानी की अम्लता)", help_term_ph_short: "pH", help_term_ph_simple: - "pH बताता है कि पानी खट्टा (अम्लीय) है या कड़वा (क्षारीय)। नींबू के रस की तरह खट्टा = कम pH। बेकिंग सोडा की तरह कड़वा = ज्यादा pH। पौधों को थोड़ा खट्टा पानी चाहिए — न बहुत खट्टा, न बहुत कड़वा।", + "pH बताता है कि पानी खट्टा (अम्लीय) है या कड़वा (क्षारीय)। नींबू के रस की तरह खट्टा = कम pH। बेकिंग सोडा की तरह कड़वा = ज्यादा pH। पौधों को थोड़ा खट्टा पानी चाहिए - न बहुत खट्टा, न बहुत कड़वा।", help_term_ph_detail: "pH 0 से 14 के पैमाने पर मापा जाता है। 7 का मतलब तटस्थ (शुद्ध पानी)। 7 से कम = अम्लीय। 7 से ज्यादा = क्षारीय। हाइड्रोपोनिक्स में, जड़ों की कोशिकाएं पानी की रसायन पर निर्भर एक प्रक्रिया से पोषक तत्व सोखती हैं। यदि pH गलत है, तो जड़ें पोषक तत्व सोख ही नहीं सकती, चाहे वे पानी में मौजूद क्यों न हों।", help_term_ph_range: "5.5 - 6.5", help_term_ph_low: "जड़ें खराब होती हैं। लोहा और मैंगनीज जहरीले हो जाते हैं। पत्तियां पीली पड़ती हैं। पौधा बीमार दिखता है।", help_term_ph_high: - "पोषक तत्व 'बंद' हो जाते हैं — पौधा कैल्शियम, मैग्नीशियम या लोहा नहीं सोख सकता। पत्तियां फीकी पड़ती हैं और विकास धीमा होता है।", + "पोषक तत्व 'बंद' हो जाते हैं - पौधा कैल्शियम, मैग्नीशियम या लोहा नहीं सोख सकता। पत्तियां फीकी पड़ती हैं और विकास धीमा होता है।", help_term_ph_ai: "अगर pH 5.5 से कम हो, Demeter स्वचालित रूप से base solution (pH Up) डालता है। अगर pH 6.5 से ज्यादा हो, तो acid (pH Down) डालता है। यह कुछ ही मिनटों में होता है, बिना आपके किए।", help_term_ph_manual: @@ -428,9 +446,9 @@ const HELP_HI = { help_term_ec: "EC (पानी में पोषक तत्वों की मात्रा)", help_term_ec_short: "EC", help_term_ec_simple: - "EC बताता है कि पानी में कितने पोषक तत्व घुले हैं। चाय बनाने की तरह सोचें — थोड़ी चाय ठीक है, लेकिन बहुत गाढ़ी चाय कड़वी और हानिकारक होती है। EC बताता है कि पानी में पौधे के लिए सही मात्रा में 'खाना' है या नहीं।", + "EC बताता है कि पानी में कितने पोषक तत्व घुले हैं। चाय बनाने की तरह सोचें - थोड़ी चाय ठीक है, लेकिन बहुत गाढ़ी चाय कड़वी और हानिकारक होती है। EC बताता है कि पानी में पौधे के लिए सही मात्रा में 'खाना' है या नहीं।", help_term_ec_detail: - "EC (विद्युत चालकता) को dS/m में मापा जाता है। शुद्ध पानी बहुत कम बिजली चलाता है। जब आप उर्वरक के नमक घोलते हैं, तो यह ज्यादा बिजली चलाता है। सेंसर इसे मापकर पोषक तत्वों की सांद्रता निर्धारित करता है। ज्यादा EC = बहुत अधिक नमक — यह जड़ों से पानी खींचता है (osmotic तनाव)।", + "EC (विद्युत चालकता) को dS/m में मापा जाता है। शुद्ध पानी बहुत कम बिजली चलाता है। जब आप उर्वरक के नमक घोलते हैं, तो यह ज्यादा बिजली चलाता है। सेंसर इसे मापकर पोषक तत्वों की सांद्रता निर्धारित करता है। ज्यादा EC = बहुत अधिक नमक - यह जड़ों से पानी खींचता है (osmotic तनाव)।", help_term_ec_range: "0.8 - 2.5 dS/m", help_term_ec_low: "पौधा भूखा है। पत्तियां फीकी पड़ती हैं, विकास धीमा होता है, उपज कम होती है। पौधे के पास पानी है लेकिन खाना नहीं।", @@ -449,7 +467,7 @@ const HELP_HI = { help_term_humidity_simple: "नमी बताती है कि हवा कितनी नम या सूखी है। बहुत नम दिन पर आपको पसीना आता है। पौधे भी अपनी पत्तियों में छोटे छेदों से 'सांस' लेते हैं। अगर हवा बहुत नम हो, तो ये छेद बंद हो जाते हैं और फफूंद उगती है। बहुत सूखी हवा में पत्तियां जल्दी सूख जाती हैं।", help_term_humidity_detail: - "नमी को Relative Humidity (RH%) में मापा जाता है। पौधे stomata (छोटे छिद्र) से गैस और जल वाष्प का आदान-प्रदान करते हैं। ज्यादा नमी में, पत्तियों से वाष्पीकरण कम होता है, जिससे 'transpiration pump' धीमा होता है — यही पोषक तत्व सोखने की प्रक्रिया को चलाता है। 85% से ज्यादा नमी में फफूंद रोग जैसे powdery mildew और botrytis के लिए आदर्श स्थितियां बन जाती हैं।", + "नमी को Relative Humidity (RH%) में मापा जाता है। पौधे stomata (छोटे छिद्र) से गैस और जल वाष्प का आदान-प्रदान करते हैं। ज्यादा नमी में, पत्तियों से वाष्पीकरण कम होता है, जिससे 'transpiration pump' धीमा होता है - यही पोषक तत्व सोखने की प्रक्रिया को चलाता है। 85% से ज्यादा नमी में फफूंद रोग जैसे powdery mildew और botrytis के लिए आदर्श स्थितियां बन जाती हैं।", help_term_humidity_range: "40% - 80% RH", help_term_humidity_low: "पत्तियां मुड़ती और सूखती हैं। stomata बंद हो जाते हैं, प्रकाश संश्लेषण और पोषक तत्व अवशोषण रुक जाता है।", @@ -458,7 +476,7 @@ const HELP_HI = { help_term_humidity_ai: "Demeter हवा और नमी को नियंत्रित करने के लिए पंखे की गति स्वचालित रूप से बदलता है। ज्यादा नमी में पंखे तेज होते हैं। atmospheric agent इसे लगातार monitor और adjust करता है।", help_term_humidity_manual: - "अगर अधिकतम पंखे की गति के बावजूद नमी बहुत ज्यादा रहे, तो यह structural समस्या हो सकती है — खराब ventilation, leaking irrigation, या टूटा हुआ HVAC। शारीरिक हस्तक्षेप जरूरी है।", + "अगर अधिकतम पंखे की गति के बावजूद नमी बहुत ज्यादा रहे, तो यह structural समस्या हो सकती है - खराब ventilation, leaking irrigation, या टूटा हुआ HVAC। शारीरिक हस्तक्षेप जरूरी है।", help_term_humidity_agri: "खुली खेती में नमी मौसम पर निर्भर होती है। इनडोर/हाइड्रोपोनिक खेती का सबसे बड़ा फायदा नमी नियंत्रण है। vegetative growth में 60-70% और flowering में 40-50% नमी रखने से रोग कम होते हैं और उपज बढ़ती है।", @@ -468,7 +486,7 @@ const HELP_HI = { help_term_temp_simple: "तापमान बताता है कि हवा कितनी गर्म या ठंडी है। पौधे भी इंसानों की तरह एक आरामदायक तापमान पसंद करते हैं। बहुत ठंडा होने पर वे 'धीमे' पड़ जाते हैं और बढ़ना बंद कर देते हैं। बहुत गर्म होने पर उन्हें 'heat stroke' होता है और पानी में बैक्टीरिया तेजी से बढ़ सकते हैं।", help_term_temp_detail: - "हवा का तापमान प्रकाश संश्लेषण, श्वसन, एंजाइम गतिविधि और transpiration को सीधे प्रभावित करता है। पानी का तापमान भी उतना ही महत्वपूर्ण है — गर्म पानी में कम dissolved oxygen होती है, और जड़ों को oxygen चाहिए। 15°C से कम पर एंजाइम प्रतिक्रियाएं बहुत धीमी हो जाती हैं। 30°C से ज्यादा पर, pathogenic bacteria root zone में तेजी से बढ़ते हैं।", + "हवा का तापमान प्रकाश संश्लेषण, श्वसन, एंजाइम गतिविधि और transpiration को सीधे प्रभावित करता है। पानी का तापमान भी उतना ही महत्वपूर्ण है - गर्म पानी में कम dissolved oxygen होती है, और जड़ों को oxygen चाहिए। 15°C से कम पर एंजाइम प्रतिक्रियाएं बहुत धीमी हो जाती हैं। 30°C से ज्यादा पर, pathogenic bacteria root zone में तेजी से बढ़ते हैं।", help_term_temp_range: "18°C - 28°C", help_term_temp_low: "विकास लगभग रुक जाता है। ठंडे पानी में जड़ें सड़ सकती हैं। पौधा बौना और काला दिखता है।", @@ -485,7 +503,7 @@ const HELP_HI = { help_agent_fetch_name: "Fetch Agent", help_agent_fetch_role: "सभी सेंसर डेटा पढ़ता है", help_agent_fetch_detail: - "यह agent pH, EC, तापमान, नमी की रीडिंग और पौधे की तस्वीरें एकत्र करता है। यह सिस्टम की आंखें और कान हैं — सब कुछ यहीं से शुरू होता है।", + "यह agent pH, EC, तापमान, नमी की रीडिंग और पौधे की तस्वीरें एकत्र करता है। यह सिस्टम की आंखें और कान हैं - सब कुछ यहीं से शुरू होता है।", help_agent_judge_name: "Judge Agent", help_agent_judge_role: "फसल के स्वास्थ्य का मूल्यांकन करता है", @@ -495,7 +513,7 @@ const HELP_HI = { help_agent_strategy_name: "Strategy Agent", help_agent_strategy_role: "क्या कार्रवाई करनी है यह तय करता है", help_agent_strategy_detail: - "यह दिमाग है। reinforcement learning का उपयोग करता है — हजारों पिछले cycles से सीखा है कि हर फसल के प्रकार और स्थिति में कौन सी कार्रवाई सबसे अच्छी है।", + "यह दिमाग है। reinforcement learning का उपयोग करता है - हजारों पिछले cycles से सीखा है कि हर फसल के प्रकार और स्थिति में कौन सी कार्रवाई सबसे अच्छी है।", help_agent_research_name: "Research Agent", help_agent_research_role: "समान मामलों के लिए memory जांचता है", @@ -505,7 +523,7 @@ const HELP_HI = { help_agent_execute_name: "Execute Agent", help_agent_execute_role: "hardware को commands भेजता है", help_agent_execute_detail: - "एक बार योजना confirm होने पर, यह agent physical actuators — pumps, fans, dosing systems — को actual commands भेजता है। digital निर्णयों को real-world actions में बदलता है।", + "एक बार योजना confirm होने पर, यह agent physical actuators - pumps, fans, dosing systems - को actual commands भेजता है। digital निर्णयों को real-world actions में बदलता है।", help_agent_explainer_name: "Explainer Agent", help_agent_explainer_role: "सरल भाषा में reasoning लिखता है", @@ -517,7 +535,7 @@ const HELP_HI = { help_stage_seedling_desc: "पौधा बीज से अभी-अभी उगा है। यह बहुत छोटा, नाजुक है और अपनी पहली पत्तियां उगाना शुरू कर रहा है।", help_stage_seedling_tips: - "🌡️ तापमान गर्म रखें (22-26°C)। 💧 EC बहुत कम रखें (0.5-1.0) — ज्यादा पोषक तत्व नाजुक जड़ों को नुकसान पहुंचाते हैं। 💡 रोशनी हल्की होनी चाहिए। यह सबसे नाजुक अवस्था है।", + "🌡️ तापमान गर्म रखें (22-26°C)। 💧 EC बहुत कम रखें (0.5-1.0) - ज्यादा पोषक तत्व नाजुक जड़ों को नुकसान पहुंचाते हैं। 💡 रोशनी हल्की होनी चाहिए। यह सबसे नाजुक अवस्था है।", help_stage_vegetative: "वानस्पतिक (Vegetative)", help_stage_vegetative_desc: @@ -527,17 +545,17 @@ const HELP_HI = { help_stage_flowering: "फूल (Flowering)", help_stage_flowering_desc: - "पौधा फूल बनाना शुरू करता है। यह एक महत्वपूर्ण अवस्था है — यहां की स्थितियां सीधे प्रभावित करती हैं कि आपको कितनी उपज मिलेगी।", + "पौधा फूल बनाना शुरू करता है। यह एक महत्वपूर्ण अवस्था है - यहां की स्थितियां सीधे प्रभावित करती हैं कि आपको कितनी उपज मिलेगी।", help_stage_flowering_tips: - "🌸 bud rot रोकने के लिए नमी 50-60% तक कम करें। 🧪 पोषक तत्व बदलें — nitrogen कम, phosphorus/potassium ज्यादा। फूल गिरने से बचाने के लिए पौधे को धीरे से संभालें।", + "🌸 bud rot रोकने के लिए नमी 50-60% तक कम करें। 🧪 पोषक तत्व बदलें - nitrogen कम, phosphorus/potassium ज्यादा। फूल गिरने से बचाने के लिए पौधे को धीरे से संभालें।", help_stage_fruiting: "फल (Fruiting)", help_stage_fruiting_desc: "फूलों पर परागण हो चुका है और अब फल या अंतिम उपज बन रही है। पौधा अपनी सारी ऊर्जा कटाई में लगा रहा है।", help_stage_fruiting_tips: - "🍅 EC और pH में स्थिरता बनाए रखें — अब उतार-चढ़ाव से blossom end rot या splitting हो सकती है। 📉 नमी 40-50% तक कम करें। परिपक्वता पर नज़र रखें — सही समय पर काटें!", + "🍅 EC और pH में स्थिरता बनाए रखें - अब उतार-चढ़ाव से blossom end rot या splitting हो सकती है। 📉 नमी 40-50% तक कम करें। परिपक्वता पर नज़र रखें - सही समय पर काटें!", - // Manual + // Manual Intervention help_manual_disease_title: "बीमारी या कीट का प्रकोप", help_manual_disease_desc: "AI computer vision से दृश्य असामान्यताएं पकड़ सकता है, लेकिन शारीरिक रूप से बीमार पत्तियां नहीं हटा सकता, कीटनाशक स्प्रे नहीं कर सकता, या संक्रमित पौधों को अलग नहीं कर सकता।", @@ -561,6 +579,24 @@ const HELP_HI = { "बिजली कटौती, बाढ़, अत्यधिक गर्मी, या growing area को structural क्षति के मामले में, मानवीय हस्तक्षेप हमेशा जरूरी है।", help_manual_extreme_action: "backup power plan (generator या UPS) रखें। manual pH और EC test kits उपलब्ध रखें। digital systems विफल होने पर manually nutrients adjust करना जानें।", + + // How to Add a Crop + help_section_add_crop: "🌿 फसल कैसे जोड़ें", + help_add_crop_step1: 'डैशबोर्ड में "फसल जोड़ें" पर क्लिक करें', + help_add_crop_step2: + "फसल का प्रकार चुनें - लेट्यूस, टमाटर, तुलसी, या स्ट्रॉबेरी", + help_add_crop_step3: "एक अनोखी Batch ID और वैकल्पिक स्थान/नोट्स दर्ज करें", + help_add_crop_step4: + '"फसल दर्ज करें" पर क्लिक करें - सिस्टम स्वचालित रूप से चक्र अवधि और सेंसर सेट करेगा', + help_add_crop_step5: + "दर्ज होने के बाद, फसल के Info टैब से → एजेंट चक्र चलाएं", + + // Supported Crop Types + help_section_crops: "🌱 समर्थित फसल प्रकार", + help_crop_lettuce: "लेट्यूस - ~21 दिन · प्रति चक्र 1 घंटा", + help_crop_tomato: "टमाटर - ~70 दिन · प्रति चक्र 2 घंटे", + help_crop_basil: "तुलसी - ~28 दिन · प्रति चक्र 1 घंटा", + help_crop_strawberry: "स्ट्रॉबेरी - ~63 दिन · प्रति चक्र 2 घंटे", }; // Sub-components @@ -695,12 +731,15 @@ function TermCard({ term, lang }) { {/* Explanation */} <p + className="animate-fade-in" style={{ fontSize: 13, color: "var(--text-2)", lineHeight: 1.75, margin: 0, marginBottom: 16, + opacity: 0, + animationFillMode: "forwards", }} > {tab === "simple" ? dict[term.simpleKey] : dict[term.detailKey]} @@ -917,6 +956,7 @@ function AgentCard({ agent, dict }) { </div> {open && ( <div + className="animate-fade-in" style={{ marginTop: 12, paddingTop: 12, @@ -924,6 +964,8 @@ function AgentCard({ agent, dict }) { fontSize: 12, color: "var(--text-2)", lineHeight: 1.65, + opacity: 0, + animationFillMode: "forwards", }} > {dict[agent.detailKey]} @@ -1040,6 +1082,139 @@ function ManualCard({ scenario, dict }) { ); } +function StepCard({ step, dict, index }) { + return ( + <div + className="card-hover" + style={{ + padding: "14px 16px", + borderRadius: 12, + background: "var(--surface)", + border: "1px solid var(--border)", + display: "flex", + alignItems: "flex-start", + gap: 14, + transition: "all 0.2s", + }} + > + <div + style={{ + width: 28, + height: 28, + borderRadius: 8, + background: `${step.color}18`, + border: `1px solid ${step.color}40`, + display: "flex", + alignItems: "center", + justifyContent: "center", + flexShrink: 0, + fontFamily: "DM Mono, monospace", + fontSize: 12, + fontWeight: 700, + color: step.color, + }} + > + {step.icon} + </div> + <span + style={{ + fontSize: 13, + color: "var(--text-2)", + lineHeight: 1.6, + paddingTop: 4, + }} + > + {dict[step.key]} + </span> + </div> + ); +} + +function CropTypeCard({ crop, dict }) { + return ( + <div + className="card-hover" + style={{ + padding: "16px", + borderRadius: 14, + background: "var(--surface)", + border: "1px solid var(--border)", + transition: "all 0.2s", + }} + > + <div + style={{ + display: "flex", + alignItems: "center", + gap: 10, + marginBottom: 12, + }} + > + <div + style={{ + width: 38, + height: 38, + borderRadius: 10, + background: `${crop.color}15`, + border: `1px solid ${crop.color}30`, + display: "flex", + alignItems: "center", + justifyContent: "center", + fontSize: 20, + flexShrink: 0, + }} + > + {crop.emoji} + </div> + <div> + <div + style={{ + fontWeight: 700, + fontSize: 13, + color: "var(--text)", + }} + > + {dict[crop.key].split("-")[0].trim()} + </div> + <div + style={{ + fontSize: 10, + fontFamily: "DM Mono, monospace", + color: "var(--text-3)", + marginTop: 1, + }} + > + ~{crop.days} days · {crop.cycle}/cycle + </div> + </div> + </div> + <div style={{ display: "flex", flexWrap: "wrap", gap: 5 }}> + {crop.stages.map((stage, si) => ( + <span + key={stage} + style={{ + fontSize: 9, + fontFamily: "DM Mono, monospace", + padding: "3px 8px", + borderRadius: 5, + background: + si === crop.stages.length - 1 + ? `${crop.color}18` + : "var(--bg-3)", + color: + si === crop.stages.length - 1 ? crop.color : "var(--text-3)", + border: `1px solid ${si === crop.stages.length - 1 ? `${crop.color}35` : "var(--border)"}`, + textTransform: "capitalize", + }} + > + {stage} + </span> + ))} + </div> + </div> + ); +} + // MAIN export default function Help() { @@ -1063,6 +1238,7 @@ export default function Help() { {/* Back Button */} <button onClick={() => navigate(-1)} + className="animate-fade-in" style={{ background: "none", border: "none", @@ -1077,6 +1253,8 @@ export default function Help() { fontWeight: 600, fontFamily: "inherit", transition: "color 0.2s", + opacity: 0, + animationFillMode: "forwards", }} > <ArrowLeft size={16} /> @@ -1084,7 +1262,14 @@ export default function Help() { </button> {/* Page Header */} - <div style={{ marginBottom: 28 }}> + <div + className="animate-slide-down-right" + style={{ + marginBottom: 28, + opacity: 0, + animationFillMode: "forwards", + }} + > <div style={{ display: "flex", @@ -1126,38 +1311,51 @@ export default function Help() { </div> </div> - {/* Section 1: Sensor Terms */} - <section - className="animate-fade-up" - style={{ - marginBottom: 36, - animationDelay: "0.1s", - opacity: 0, - animationFillMode: "forwards", - }} - > - <SectionHeader>{dict["help_section_terms"]}</SectionHeader> + {/* Section: Sensor Terms */} + <section style={{ marginBottom: 36 }}> + <div + className="animate-fade-up" + style={{ + animationDelay: "0.1s", + opacity: 0, + animationFillMode: "forwards", + }} + > + <SectionHeader>{dict["help_section_terms"]}</SectionHeader> + </div> <div style={{ display: "flex", flexDirection: "column", gap: 10 }}> - {TERMS.map((term) => ( - <TermCard key={term.id} term={term} lang={lang} /> + {TERMS.map((term, i) => ( + <div + key={term.id} + className="animate-fade-up" + style={{ + animationDelay: `${0.15 + i * 0.05}s`, + opacity: 0, + animationFillMode: "forwards", + }} + > + <TermCard term={term} lang={lang} /> + </div> ))} </div> </section> - {/* Section 2: AI Agents */} - <section - className="animate-fade-up" - style={{ - marginBottom: 36, - animationDelay: "0.2s", - opacity: 0, - animationFillMode: "forwards", - }} - > - <SectionHeader>{dict["help_section_agents"]}</SectionHeader> + {/* Section: AI Agents */} + <section style={{ marginBottom: 36 }}> + <div + className="animate-fade-up" + style={{ + animationDelay: "0.2s", + opacity: 0, + animationFillMode: "forwards", + }} + > + <SectionHeader>{dict["help_section_agents"]}</SectionHeader> + </div> {/* Pipeline flow visualization */} <div + className="animate-fade-up" style={{ padding: "14px 16px", borderRadius: 12, @@ -1168,6 +1366,9 @@ export default function Help() { alignItems: "center", gap: 6, flexWrap: "wrap", + animationDelay: "0.25s", + opacity: 0, + animationFillMode: "forwards", }} > {["Fetch", "Judge", "Strategy", "Research", "Execute", "Explain"].map( @@ -1195,23 +1396,34 @@ export default function Help() { </div> <div style={{ display: "flex", flexDirection: "column", gap: 8 }}> - {AI_AGENTS.map((agent) => ( - <AgentCard key={agent.id} agent={agent} dict={dict} /> + {AI_AGENTS.map((agent, i) => ( + <div + key={agent.id} + className="animate-fade-up" + style={{ + animationDelay: `${0.3 + i * 0.05}s`, + opacity: 0, + animationFillMode: "forwards", + }} + > + <AgentCard agent={agent} dict={dict} /> + </div> ))} </div> </section> - {/* Section 3: Growth Stages */} - <section - className="animate-fade-up" - style={{ - marginBottom: 36, - animationDelay: "0.3s", - opacity: 0, - animationFillMode: "forwards", - }} - > - <SectionHeader>{dict["help_section_stages"]}</SectionHeader> + {/* Section: Growth Stages */} + <section style={{ marginBottom: 36 }}> + <div + className="animate-fade-up" + style={{ + animationDelay: "0.4s", + opacity: 0, + animationFillMode: "forwards", + }} + > + <SectionHeader>{dict["help_section_stages"]}</SectionHeader> + </div> <div style={{ display: "grid", @@ -1219,26 +1431,150 @@ export default function Help() { gap: 12, }} > - {STAGES.map((stage) => ( - <StageCard key={stage.id} stage={stage} dict={dict} /> + {STAGES.map((stage, i) => ( + <div + key={stage.id} + className="animate-fade-up" + style={{ + animationDelay: `${0.45 + i * 0.05}s`, + opacity: 0, + animationFillMode: "forwards", + }} + > + <StageCard stage={stage} dict={dict} /> + </div> ))} </div> </section> - {/* Section 4: Manual Intervention */} - <section - className="animate-fade-up" - style={{ - marginBottom: 36, - animationDelay: "0.4s", - opacity: 0, - animationFillMode: "forwards", - }} - > - <SectionHeader>{dict["help_section_manual"]}</SectionHeader> + {/* Section: Manual Intervention */} + <section style={{ marginBottom: 36 }}> + <div + className="animate-fade-up" + style={{ + animationDelay: "0.5s", + opacity: 0, + animationFillMode: "forwards", + }} + > + <SectionHeader>{dict["help_section_manual"]}</SectionHeader> + </div> <div style={{ display: "flex", flexDirection: "column", gap: 10 }}> - {MANUAL_SCENARIOS.map((s) => ( - <ManualCard key={s.id} scenario={s} dict={dict} /> + {MANUAL_SCENARIOS.map((s, i) => ( + <div + key={s.id} + className="animate-fade-up" + style={{ + animationDelay: `${0.55 + i * 0.05}s`, + opacity: 0, + animationFillMode: "forwards", + }} + > + <ManualCard scenario={s} dict={dict} /> + </div> + ))} + </div> + </section> + + {/* Section: How to Add a Crop */} + <section style={{ marginBottom: 36 }}> + <div + className="animate-fade-up" + style={{ + animationDelay: "0.6s", + opacity: 0, + animationFillMode: "forwards", + }} + > + <SectionHeader>{dict["help_section_add_crop"]}</SectionHeader> + </div> + <div style={{ display: "flex", flexDirection: "column", gap: 8 }}> + {[ + { key: "help_add_crop_step1", icon: "1", color: "var(--blue)" }, + { key: "help_add_crop_step2", icon: "2", color: "var(--green)" }, + { key: "help_add_crop_step3", icon: "3", color: "var(--amber)" }, + { key: "help_add_crop_step4", icon: "4", color: "var(--blue)" }, + { key: "help_add_crop_step5", icon: "5", color: "var(--green)" }, + ].map((step, i) => ( + <div + key={step.key} + className="animate-fade-up" + style={{ + animationDelay: `${0.65 + i * 0.05}s`, + opacity: 0, + animationFillMode: "forwards", + }} + > + <StepCard step={step} dict={dict} index={i} /> + </div> + ))} + </div> + </section> + + {/* Section: Supported Crops */} + <section style={{ marginBottom: 36 }}> + <div + className="animate-fade-up" + style={{ + animationDelay: "0.7s", + opacity: 0, + animationFillMode: "forwards", + }} + > + <SectionHeader>{dict["help_section_crops"]}</SectionHeader> + </div> + <div + style={{ + display: "grid", + gridTemplateColumns: "repeat(2, 1fr)", + gap: 12, + }} + > + {[ + { + key: "help_crop_lettuce", + emoji: "🥬", + days: 21, + cycle: "1h", + stages: ["Seedling", "Vegetative", "Harvest"], + color: "var(--green)", + }, + { + key: "help_crop_tomato", + emoji: "🍅", + days: 70, + cycle: "2h", + stages: ["Seedling", "Vegetative", "Flowering", "Fruiting"], + color: "var(--red)", + }, + { + key: "help_crop_basil", + emoji: "🌿", + days: 28, + cycle: "1h", + stages: ["Seedling", "Vegetative", "Harvest"], + color: "var(--green)", + }, + { + key: "help_crop_strawberry", + emoji: "🍓", + days: 63, + cycle: "2h", + stages: ["Seedling", "Vegetative", "Flowering", "Fruiting"], + color: "var(--amber)", + }, + ].map((crop, i) => ( + <div + key={crop.key} + className="animate-fade-up" + style={{ + animationDelay: `${0.75 + i * 0.05}s`, + opacity: 0, + animationFillMode: "forwards", + }} + > + <CropTypeCard crop={crop} dict={dict} /> + </div> ))} </div> </section> @@ -1258,7 +1594,7 @@ export default function Help() { gap: 16, flexWrap: "wrap", marginBottom: 8, - animationDelay: "0.5s", + animationDelay: "0.8s", opacity: 0, animationFillMode: "forwards", }} diff --git a/frontend/src/pages/LandingPage.jsx b/frontend/src/pages/LandingPage.jsx @@ -62,7 +62,7 @@ function buildActivityLog(historyData) { .split(" ")[0]; const msg = p.strategic_intent ? `Strategy: ${p.strategic_intent.replace(/_/g, " ")}` - : `Monitoring ${p.crop || "crop"} — seq #${p.sequence_number || 1}`; + : `Monitoring ${p.crop || "crop"} - seq #${p.sequence_number || 1}`; const ts = p.timestamp ? new Date(p.timestamp).toLocaleTimeString([], { hour: "2-digit", @@ -130,10 +130,10 @@ export default function LandingPage() { }, ] : [ - { val: "—", label: t("landing_active_crops") }, - { val: "—", label: t("landing_crop_types") }, - { val: "—", label: t("landing_total_cycles") }, - { val: "—", label: t("landing_active_alerts") }, + { val: "-", label: t("landing_active_crops") }, + { val: "-", label: t("landing_crop_types") }, + { val: "-", label: t("landing_total_cycles") }, + { val: "-", label: t("landing_active_alerts") }, ]; // Live sensor readings @@ -163,10 +163,10 @@ export default function LandingPage() { }, ] : [ - { label: t("analytics_avg_ph"), value: "—", ok: true }, - { label: t("analytics_avg_ec"), value: "—", ok: true }, - { label: t("sensor_temp").toUpperCase(), value: "—", ok: true }, - { label: t("sensor_humidity").toUpperCase(), value: "—", ok: true }, + { label: t("analytics_avg_ph"), value: "-", ok: true }, + { label: t("analytics_avg_ec"), value: "-", ok: true }, + { label: t("sensor_temp").toUpperCase(), value: "-", ok: true }, + { label: t("sensor_humidity").toUpperCase(), value: "-", ok: true }, ]; return ( @@ -341,7 +341,7 @@ export default function LandingPage() { </div> </div> - {/* Right — live HUD */} + {/* Right - live HUD */} <div className={`relative ${mounted ? "animate-fade-in" : "opacity-0"}`} > diff --git a/frontend/src/pages/RunCycle.jsx b/frontend/src/pages/RunCycle.jsx @@ -0,0 +1,1144 @@ +import { useRef, useState, useEffect, useCallback } from "react"; +import { useParams, useNavigate } from "react-router-dom"; +import { useT } from "../hooks/useTranslation"; +import { + Upload, + ArrowLeft, + Activity, + Droplets, + Thermometer, + Wind, + Play, + CheckCircle2, + AlertTriangle, + Brain, + Waves, + FlaskConical, + Cpu, + Leaf, + RotateCcw, + Zap, +} from "lucide-react"; +import { + PageShell, + PageHeader, + IconButton, + SectionCard, +} from "../components/ui"; +import { fetchCropById } from "../api/farmApi"; +import { + extractSensors, + getCurrentStage, + calculateMaturity, + getDaysRemaining, + CROP_LIFECYCLES, +} from "../utils/dataUtils"; +import { AgentActionWidget } from "../components/AgentWidgets"; + +// Agent log helpers + +const AGENT_META = { + FETCHER: { color: "#60a5fa", bg: "rgba(96,165,250,0.12)", label: "Fetcher" }, + JUDGE: { color: "#f59e0b", bg: "rgba(245,158,11,0.12)", label: "Judge" }, + RESEARCHER: { + color: "#a78bfa", + bg: "rgba(167,139,250,0.12)", + label: "Researcher", + }, + ATMOSPHERIC: { + color: "#4ade80", + bg: "rgba(74,222,128,0.12)", + label: "Atmospheric", + }, + WATER: { color: "#22d3ee", bg: "rgba(34,211,238,0.12)", label: "Water" }, + SUPERVISOR: { + color: "#f97316", + bg: "rgba(249,115,22,0.12)", + label: "Supervisor", + }, + BANDIT: { color: "#e879f9", bg: "rgba(232,121,249,0.12)", label: "Bandit" }, + DOCTOR: { color: "#f87171", bg: "rgba(248,113,113,0.12)", label: "Doctor" }, + SYSTEM: { color: "#6a8a6d", bg: "rgba(106,138,109,0.12)", label: "System" }, +}; + +function agentFromLine(line) { + const up = line.toUpperCase(); + if (up.includes("[FETCHER]") || up.includes("FETCHING")) return "FETCHER"; + if (up.includes("[JUDGE]") || up.includes("JUDGE")) return "JUDGE"; + if (up.includes("BANDIT") || up.includes("STRATEGY")) return "BANDIT"; + if (up.includes("RESEARCH")) return "RESEARCHER"; + if (up.includes("ATMO") || up.includes("ATMOSPHERIC")) return "ATMOSPHERIC"; + if (up.includes("WATER") || up.includes("NUTRIENT")) return "WATER"; + if (up.includes("SUPERVISOR")) return "SUPERVISOR"; + if (up.includes("DOCTOR") || up.includes("VISION") || up.includes("DIAGNOS")) + return "DOCTOR"; + return "SYSTEM"; +} + +function levelFromLine(line) { + const up = line.toUpperCase(); + if (up.includes("❌") || up.includes("ERROR") || up.includes("CRITICAL")) + return "error"; + if (up.includes("⚠️") || up.includes("WARN") || up.includes("FAIL")) + return "warn"; + if (up.includes("✅") || up.includes("APPROV") || up.includes("SUCCESS")) + return "success"; + return "info"; +} + +const LEVEL_COLORS = { + error: "var(--red)", + warn: "var(--amber)", + success: "var(--green)", + info: "var(--text-2)", +}; + +function phaseFromLogs(logs) { + const last = logs[logs.length - 1]?.text?.toUpperCase() || ""; + if (last.includes("SENT TO SIMULATOR") || last.includes("CYCLE COMPLETE")) + return "execute"; + if (last.includes("ACTIVATING") || last.includes("SUPERVISOR")) + return "execute"; + if ( + last.includes("WATER") || + last.includes("ATMOSPHERIC") || + last.includes("MERGING") + ) + return "plan"; + if (last.includes("RESEARCH") || last.includes("KNOWLEDGE")) + return "research"; + if (last.includes("BANDIT") || last.includes("STRATEGY")) return "strategy"; + if (last.includes("JUDGE")) return "judge"; + if (last.includes("FETCHER") || last.includes("FMU")) return "fetch"; + return null; +} + +function LogLine({ entry, idx }) { + const agent = AGENT_META[entry.agent] || AGENT_META.SYSTEM; + const lvlColor = LEVEL_COLORS[entry.level] || LEVEL_COLORS.info; + return ( + <div + className="animate-fade-in" + style={{ + display: "flex", + alignItems: "flex-start", + gap: 8, + padding: "5px 0", + borderBottom: "1px solid rgba(128,180,128,0.06)", + animationDelay: `${Math.min(idx * 20, 300)}ms`, + }} + > + {/* Line number */} + <span + style={{ + fontSize: 9, + fontFamily: "DM Mono, monospace", + color: "var(--text-3)", + minWidth: 26, + paddingTop: 2, + opacity: 0.5, + }} + > + {String(idx + 1).padStart(3, "0")} + </span> + {/* Timestamp */} + <span + style={{ + fontSize: 9, + fontFamily: "DM Mono, monospace", + color: "var(--text-3)", + minWidth: 58, + paddingTop: 2, + flexShrink: 0, + }} + > + {entry.time} + </span> + {/* Agent badge */} + <span + style={{ + fontSize: 8, + fontFamily: "DM Mono, monospace", + padding: "2px 6px", + borderRadius: 4, + background: agent.bg, + color: agent.color, + border: `1px solid ${agent.color}30`, + flexShrink: 0, + minWidth: 76, + textAlign: "center", + marginTop: 1, + letterSpacing: "0.03em", + }} + > + {agent.label.toUpperCase()} + </span> + {/* Message */} + <span + style={{ + fontSize: 11, + fontFamily: "DM Mono, monospace", + color: lvlColor, + flex: 1, + wordBreak: "break-word", + lineHeight: 1.55, + }} + > + {entry.text} + </span> + </div> + ); +} + +// Pipeline phase config +const PIPELINE_PHASES = [ + { id: "fetch", label: "Fetch", icon: Waves }, + { id: "judge", label: "Judge", icon: AlertTriangle }, + { id: "strategy", label: "Strategy", icon: Brain }, + { id: "research", label: "Research", icon: FlaskConical }, + { id: "plan", label: "Plan", icon: Cpu }, + { id: "execute", label: "Execute", icon: Zap }, +]; +const PHASE_ORDER = PIPELINE_PHASES.map((p) => p.id); + +// MAIN + +export default function RunCycle() { + const { cropId } = useParams(); + const navigate = useNavigate(); + const { t } = useT(); + const AGENT_API = + process.env.REACT_APP_AGENT_API_URL || "http://localhost:8000"; + + const [cropDoc, setCropDoc] = useState(null); + const [loadingCrop, setLoadingCrop] = useState(true); + const [sensors, setSensors] = useState({ + pH: "6.0", + EC: "1.4", + temp: "24.0", + humidity: "65.0", + }); + const [file, setFile] = useState(null); + const [preview, setPreview] = useState(null); + const [phase, setPhase] = useState("idle"); // idle | running | done | error + const [logs, setLogs] = useState([]); + const [cycles, setCycles] = useState(0); + const [activePhase, setActivePhase] = useState(null); + const [finalAction, setFinalAction] = useState(null); + const [toast, setToast] = useState(null); + + const logEndRef = useRef(null); + const timersRef = useRef([]); + + useEffect(() => { + if (!cropId) return; + fetchCropById(cropId).then((doc) => { + if (doc) { + setCropDoc(doc); + const s = extractSensors(doc); + setSensors({ + pH: String(s.ph), + EC: String(s.ec), + temp: String(s.temp), + humidity: String(s.humidity), + }); + } + setLoadingCrop(false); + }); + }, [cropId]); + + useEffect(() => { + logEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [logs]); + + useEffect(() => () => timersRef.current.forEach(clearTimeout), []); + + const showToast = (msg, type = "success") => { + setToast({ msg, type }); + setTimeout(() => setToast(null), 3500); + }; + + const pushLog = useCallback((text, agentKey) => { + const now = new Date(); + const time = now.toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); + const agent = agentKey || agentFromLine(text); + const level = levelFromLine(text); + setLogs((prev) => { + const updated = [...prev, { text, time, agent, level }]; + const newPhase = phaseFromLogs(updated); + if (newPhase) setActivePhase(newPhase); + return updated; + }); + }, []); + + const handleFileChange = (e) => { + const f = e.target.files?.[0]; + if (!f) return; + setFile(f); + const r = new FileReader(); + r.onload = (ev) => setPreview(ev.target.result); + r.readAsDataURL(f); + }; + + const runCycle = async () => { + setPhase("running"); + setLogs([]); + setActivePhase("fetch"); + setFinalAction(null); + + const cropName = cropDoc?.crop || "Unknown"; + const stage = cropDoc?.stage || "seedling"; + const cropIdVal = cropDoc?.crop_id || cropId; + + try { + const formData = new FormData(); + if (file) { + formData.append("file", file); + } else { + const canvas = document.createElement("canvas"); + canvas.width = 32; + canvas.height = 32; + await new Promise((res) => + canvas.toBlob((b) => { + formData.append("file", b, "placeholder.png"); + res(); + }), + ); + } + formData.append( + "sensors", + JSON.stringify({ + pH: parseFloat(sensors.pH), + EC: parseFloat(sensors.EC), + temp: parseFloat(sensors.temp), + humidity: parseFloat(sensors.humidity), + crop_id: cropIdVal, + }), + ); + formData.append( + "metadata", + JSON.stringify({ crop: cropName, stage, crop_id: cropIdVal }), + ); + + pushLog( + `[FETCHER] Starting cycle for ${cropIdVal} (${cropName} - ${stage})`, + "FETCHER", + ); + + const res = await fetch(`${AGENT_API}/run-cycle-stream`, { + method: "POST", + body: formData, + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + if (trimmed.startsWith("data:")) { + const raw = trimmed.slice(5).trim(); + if (raw === "[DONE]") { + setPhase("done"); + setCycles((c) => c + 1); + break; + } + try { + const parsed = JSON.parse(raw); + if (parsed.log) pushLog(parsed.log); + if (parsed.action) { + setFinalAction(parsed.action); + pushLog("✅ Final action dispatched to hardware", "SUPERVISOR"); + } + } catch { + pushLog(raw); + } + } else { + pushLog(trimmed); + } + } + } + + setPhase("done"); + setCycles((c) => c + 1); + showToast(t("add_cycle_done")); + } catch (err) { + setPhase("error"); + pushLog(`❌ ${err.message || t("add_cycle_fail")}`, "SYSTEM"); + showToast(t("add_cycle_fail"), "error"); + } + }; + + // Derived values + const cropName = cropDoc?.crop || cropId; + const maturity = cropDoc ? calculateMaturity(cropDoc) : 0; + const daysLeft = cropDoc ? getDaysRemaining(cropDoc) : null; + const lifecycle = CROP_LIFECYCLES[(cropDoc?.crop || "").toLowerCase()]; + const curStage = cropDoc + ? getCurrentStage(cropDoc) || cropDoc?.stage || "-" + : "-"; + + if (loadingCrop) { + return ( + <PageShell> + <div + style={{ + flex: 1, + display: "flex", + alignItems: "center", + justifyContent: "center", + }} + > + <span + style={{ + fontSize: 13, + fontFamily: "DM Mono, monospace", + color: "var(--text-3)", + }} + > + Loading crop… + </span> + </div> + </PageShell> + ); + } + + return ( + <PageShell> + {/* Toast */} + {toast && ( + <div + className="animate-fade-in" + style={{ + position: "fixed", + top: 20, + right: 20, + zIndex: 9999, + padding: "12px 20px", + borderRadius: 12, + background: + toast.type === "error" + ? "rgba(248,113,113,0.15)" + : "rgba(74,222,128,0.15)", + border: `1px solid ${toast.type === "error" ? "var(--red)" : "var(--green)"}`, + color: toast.type === "error" ? "var(--red)" : "var(--green)", + fontFamily: "DM Mono, monospace", + fontSize: 13, + }} + > + {toast.msg} + </div> + )} + + {/* Header */} + <PageHeader> + <IconButton onClick={() => navigate(`/crop/${cropId}`)}> + <ArrowLeft size={15} /> + </IconButton> + <div> + <h1 className="page-title">{t("run_title")}</h1> + <p className="page-subtitle"> + {t("run_for_crop", { crop: cropName })} + </p> + </div> + {phase === "done" && ( + <div + style={{ + marginLeft: "auto", + display: "flex", + alignItems: "center", + gap: 6, + padding: "4px 12px", + borderRadius: 20, + background: "rgba(74,222,128,0.1)", + border: "1px solid rgba(74,222,128,0.3)", + fontSize: 11, + fontFamily: "DM Mono, monospace", + color: "var(--green)", + }} + > + <CheckCircle2 size={12} /> + {cycles} cycle{cycles !== 1 ? "s" : ""} completed + </div> + )} + </PageHeader> + + <div + style={{ + flex: 1, + overflowY: "auto", + padding: 24, + display: "flex", + flexDirection: "column", + gap: 18, + }} + > + {/* Crop Info */} + {cropDoc && ( + <SectionCard> + <div + style={{ + display: "flex", + alignItems: "center", + gap: 16, + flexWrap: "wrap", + }} + > + {/* Crop avatar */} + <div + style={{ + width: 52, + height: 52, + borderRadius: 14, + background: "rgba(74,222,128,0.1)", + border: "1px solid rgba(74,222,128,0.25)", + display: "flex", + alignItems: "center", + justifyContent: "center", + flexShrink: 0, + }} + > + <Leaf size={24} style={{ color: "var(--green)" }} /> + </div> + + {/* Name + stage */} + <div style={{ flex: 1 }}> + <div + style={{ + fontWeight: 700, + fontSize: 16, + color: "var(--text)", + }} + > + {cropName} + </div> + <div + style={{ + display: "flex", + alignItems: "center", + gap: 8, + marginTop: 4, + flexWrap: "wrap", + }} + > + <span + style={{ + fontSize: 10, + fontFamily: "DM Mono, monospace", + color: "var(--text-3)", + padding: "2px 8px", + borderRadius: 4, + background: "var(--bg-3)", + border: "1px solid var(--border)", + textTransform: "capitalize", + }} + > + {curStage} + </span> + <span + style={{ + fontSize: 10, + fontFamily: "DM Mono, monospace", + color: "var(--text-3)", + }} + > + {cropId} + </span> + {daysLeft !== null && ( + <span + style={{ + fontSize: 10, + fontFamily: "DM Mono, monospace", + color: daysLeft === 0 ? "var(--green)" : "var(--amber)", + }} + > + {daysLeft === 0 + ? "✂ Ready to harvest" + : `${daysLeft}d until harvest`} + </span> + )} + </div> + </div> + + {/* Lifecycle progress bar */} + <div style={{ minWidth: 300 }}> + <div + style={{ + display: "flex", + justifyContent: "space-between", + marginBottom: 5, + }} + > + <span + style={{ + fontSize: 10, + fontFamily: "DM Mono, monospace", + color: "var(--text-3)", + }} + > + Growth Progress + </span> + <span + style={{ + fontSize: 10, + fontFamily: "DM Mono, monospace", + fontWeight: 700, + color: + maturity >= 95 + ? "var(--green)" + : maturity >= 70 + ? "var(--amber)" + : "var(--blue)", + }} + > + {maturity}% + </span> + </div> + <div + style={{ + height: 6, + borderRadius: 3, + background: "var(--border)", + overflow: "hidden", + }} + > + <div + style={{ + width: `${maturity}%`, + height: "100%", + borderRadius: 3, + background: + maturity >= 95 + ? "var(--green)" + : maturity >= 70 + ? "var(--amber)" + : "linear-gradient(90deg, #22d3ee, #4ade80)", + transition: "width 0.5s ease", + }} + /> + </div> + {/* Stage labels */} + {lifecycle && ( + <div style={{ display: "flex", marginTop: 5, gap: 0 }}> + {lifecycle.stages.map((s, i) => { + const endH = s.endH ?? lifecycle.totalHours; + const w = + ((endH - s.startH) / lifecycle.totalHours) * 100; + const stageColors = [ + "var(--text-3)", + "#22d3ee", + "var(--amber)", + "var(--green)", + ]; + return ( + <div + key={s.name} + style={{ width: `${w}%`, textAlign: "center" }} + title={`${s.name}: ${Math.round((s.endH ?? lifecycle.totalHours - s.startH) / 24)}d`} + > + <div + style={{ + height: 3, + background: stageColors[i % stageColors.length], + opacity: 0.5, + marginBottom: 3, + }} + /> + <span + style={{ + fontSize: 8, + fontFamily: "DM Mono, monospace", + color: stageColors[i % stageColors.length], + textTransform: "capitalize", + opacity: 0.8, + whiteSpace: "nowrap", + overflow: "hidden", + display: "block", + }} + > + {s.name} + </span> + </div> + ); + })} + </div> + )} + </div> + </div> + </SectionCard> + )} + + {/* Pipeline Progress Bar */} + <div + style={{ + padding: "14px 18px", + borderRadius: 14, + background: "var(--bg-3)", + border: "1px solid var(--border)", + }} + > + <div + style={{ + display: "grid", + gridTemplateColumns: `repeat(${PIPELINE_PHASES.length}, 1fr)`, + gap: 4, + alignItems: "start", + }} + > + {PIPELINE_PHASES.map((p, i) => { + const pIdx = PHASE_ORDER.indexOf(p.id); + const activeIdx = PHASE_ORDER.indexOf(activePhase || ""); + const isDone = phase === "done" || pIdx < activeIdx; + const isActive = p.id === activePhase && phase === "running"; + const PIcon = p.icon; + + return ( + <div + key={p.id} + style={{ + display: "flex", + flexDirection: "column", + alignItems: "center", + gap: 6, + position: "relative", + }} + > + {/* Connector line */} + {i < PIPELINE_PHASES.length - 1 && ( + <div + style={{ + position: "absolute", + top: 18, + left: "calc(50% + 14px)", + right: "calc(-50% + 14px)", + height: 1, + background: isDone + ? "rgba(74,222,128,0.5)" + : "var(--border)", + transition: "background 0.4s", + }} + /> + )} + + {/* Circle icon */} + <div + style={{ + width: 36, + height: 36, + borderRadius: "50%", + background: isDone + ? "rgba(74,222,128,0.15)" + : isActive + ? "rgba(74,222,128,0.08)" + : "var(--bg-2)", + border: `2px solid ${ + isDone + ? "rgba(74,222,128,0.5)" + : isActive + ? "rgba(74,222,128,0.4)" + : "var(--border)" + }`, + display: "flex", + alignItems: "center", + justifyContent: "center", + transition: "all 0.3s", + zIndex: 1, + }} + > + {isDone ? ( + <CheckCircle2 + size={14} + style={{ color: "var(--green)" }} + /> + ) : isActive ? ( + <PIcon + size={14} + style={{ + color: "var(--green)", + animation: "spin 1.5s linear infinite", + }} + /> + ) : ( + <PIcon + size={14} + style={{ color: "var(--text-3)", opacity: 0.4 }} + /> + )} + </div> + + {/* Label */} + <span + style={{ + fontSize: 9, + fontFamily: "DM Mono, monospace", + color: isDone + ? "var(--green)" + : isActive + ? "var(--green)" + : "var(--text-3)", + fontWeight: isActive ? 700 : 400, + textAlign: "center", + lineHeight: 1.2, + }} + > + {p.label} + </span> + </div> + ); + })} + </div> + </div> + + {/* Sensor Inputs */} + <SectionCard> + <div className="section-label" style={{ marginBottom: 12 }}> + {t("add_sensor_params")} + </div> + <div + style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10 }} + > + {[ + { + label: t("add_field_ph"), + name: "pH", + icon: Droplets, + color: "var(--blue)", + step: "0.1", + min: "0", + max: "14", + }, + { + label: t("add_field_ec"), + name: "EC", + icon: Activity, + color: "var(--amber)", + step: "0.1", + }, + { + label: t("add_field_temp"), + name: "temp", + icon: Thermometer, + color: "var(--red)", + step: "0.5", + }, + { + label: t("add_field_humidity"), + name: "humidity", + icon: Wind, + color: "#a78bfa", + step: "1", + }, + ].map(({ label, name, icon: Icon, color, step, min, max }) => ( + <div + key={name} + style={{ + padding: "12px 14px", + borderRadius: 10, + background: "var(--bg-3)", + border: "1px solid var(--border)", + transition: "border-color 150ms", + }} + > + <div + style={{ + display: "flex", + alignItems: "center", + gap: 6, + marginBottom: 6, + }} + > + <Icon size={12} style={{ color }} /> + <span + style={{ + fontSize: 10, + fontFamily: "DM Mono, monospace", + color: "var(--text-3)", + }} + > + {label} + </span> + </div> + <input + type="number" + step={step} + min={min} + max={max} + value={sensors[name]} + onChange={(e) => + setSensors((s) => ({ ...s, [name]: e.target.value })) + } + disabled={phase === "running"} + style={{ + width: "100%", + background: "none", + border: "none", + outline: "none", + color, + fontFamily: "DM Mono, monospace", + fontSize: 20, + fontWeight: 700, + }} + /> + </div> + ))} + </div> + </SectionCard> + + {/* Image Upload */} + <SectionCard> + <div className="section-label" style={{ marginBottom: 12 }}> + {t("add_plant_image")} + </div> + {preview ? ( + <div + style={{ + position: "relative", + borderRadius: 12, + overflow: "hidden", + height: 160, + }} + > + <img + src={preview} + alt="crop" + style={{ width: "100%", height: "100%", objectFit: "cover" }} + /> + <button + onClick={() => { + setFile(null); + setPreview(null); + }} + style={{ + position: "absolute", + top: 8, + right: 8, + padding: "4px 10px", + borderRadius: 8, + background: "rgba(0,0,0,0.6)", + border: "none", + color: "#fff", + fontSize: 11, + cursor: "pointer", + }} + > + Remove + </button> + </div> + ) : ( + <label + style={{ + display: "flex", + flexDirection: "column", + alignItems: "center", + gap: 8, + padding: 24, + borderRadius: 12, + border: "2px dashed var(--border)", + cursor: "pointer", + background: "var(--bg-3)", + }} + > + <Upload size={22} style={{ color: "var(--text-3)" }} /> + <span + style={{ + fontSize: 12, + fontFamily: "DM Mono, monospace", + color: "var(--text-3)", + }} + > + {t("add_drop_image")} + </span> + <span + style={{ + fontSize: 10, + color: "var(--text-3)", + opacity: 0.6, + fontFamily: "DM Mono, monospace", + }} + > + {t("add_image_hint")} + </span> + <input + type="file" + accept="image/*" + onChange={handleFileChange} + style={{ display: "none" }} + /> + </label> + )} + </SectionCard> + + {/* Agent Log */} + {(logs.length > 0 || phase === "running") && ( + <SectionCard> + <div + style={{ + display: "flex", + alignItems: "center", + justifyContent: "space-between", + marginBottom: 10, + }} + > + <div style={{ display: "flex", alignItems: "center", gap: 8 }}> + <div className="section-label"> + {phase === "running" + ? "🔴 Live Agent Feed" + : phase === "done" + ? "✅ Cycle Log" + : "Agent Log"} + </div> + {phase === "running" && ( + <span + style={{ + fontSize: 9, + fontFamily: "DM Mono, monospace", + padding: "2px 8px", + borderRadius: 10, + background: "rgba(248,113,113,0.12)", + color: "var(--red)", + border: "1px solid rgba(248,113,113,0.3)", + animation: "pulse 1.5s ease-in-out infinite", + }} + > + STREAMING + </span> + )} + </div> + <span + style={{ + fontSize: 10, + fontFamily: "DM Mono, monospace", + color: "var(--text-3)", + }} + > + {logs.length} lines + </span> + </div> + + {/* Terminal-style log box */} + <div + style={{ + background: "rgba(0,0,0,0.25)", + borderRadius: 10, + border: "1px solid rgba(128,180,128,0.1)", + padding: "10px 12px", + maxHeight: 320, + overflowY: "auto", + }} + > + {logs.length === 0 ? ( + <div + style={{ + textAlign: "center", + padding: "20px 0", + color: "var(--text-3)", + fontFamily: "DM Mono, monospace", + fontSize: 11, + }} + > + Waiting for agent output… + </div> + ) : ( + logs.map((entry, i) => ( + <LogLine key={i} entry={entry} idx={i} /> + )) + )} + <div ref={logEndRef} /> + </div> + </SectionCard> + )} + + {/* Final Action */} + {finalAction && ( + <SectionCard> + <div className="section-label" style={{ marginBottom: 12 }}> + {t("add_actuator_dispatched")} + </div> + <AgentActionWidget actionTaken={finalAction} compact={false} /> + </SectionCard> + )} + + {/* Run Button */} + <div style={{ paddingBottom: 24 }}> + <button + onClick={phase === "running" ? undefined : runCycle} + disabled={phase === "running"} + style={{ + width: "100%", + padding: "16px 24px", + borderRadius: 14, + background: + phase === "running" + ? "rgba(74,222,128,0.06)" + : phase === "done" + ? "rgba(74,222,128,0.12)" + : "var(--green)", + border: + phase === "running" + ? "1px solid rgba(74,222,128,0.2)" + : phase === "done" + ? "1px solid rgba(74,222,128,0.35)" + : "none", + color: + phase === "running" + ? "var(--green)" + : phase === "done" + ? "var(--green)" + : "var(--btn-on-green)", + fontWeight: 700, + fontSize: 15, + cursor: phase === "running" ? "not-allowed" : "pointer", + display: "flex", + alignItems: "center", + justifyContent: "center", + gap: 10, + transition: "all 0.2s", + letterSpacing: "0.02em", + }} + > + {phase === "running" ? ( + <> + <Brain size={18} style={{ animation: "pulse 1s infinite" }} /> + Agents Working… + </> + ) : phase === "done" ? ( + <> + <RotateCcw size={17} /> + Run Another Cycle + </> + ) : ( + <> + <Play size={17} /> + {t("run_title")} + </> + )} + </button> + </div> + </div> + + <style>{` + @keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } + } + @keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } + } + `}</style> + </PageShell> + ); +} diff --git a/frontend/src/utils/dataUtils.js b/frontend/src/utils/dataUtils.js @@ -25,14 +25,81 @@ export const parsePythonString = (str) => { } }; +// LIFECYCLE +// - totalHours -> the time at which the crop enters its final harvestable stage +// - maturity % = elapsed hours / totalHours (capped at 100) +// - ready to harvest -> maturity >= 100 + +export const CROP_LIFECYCLES = { + lettuce: { + stages: [ + { name: "seedling", startH: 0, endH: 168 }, + { name: "vegetative", startH: 168, endH: 504 }, + { name: "harvest", startH: 504, endH: null }, + ], + totalHours: 504, // ~21 days + totalDays: 21, + harvestStage: "harvest", + }, + tomato: { + stages: [ + { name: "seedling", startH: 0, endH: 336 }, + { name: "vegetative", startH: 336, endH: 1008 }, + { name: "flowering", startH: 1008, endH: 1680 }, + { name: "fruiting", startH: 1680, endH: null }, + ], + totalHours: 1680, // ~70 days + totalDays: 70, + harvestStage: "fruiting", + }, + basil: { + stages: [ + { name: "seedling", startH: 0, endH: 168 }, + { name: "vegetative", startH: 168, endH: 672 }, + { name: "harvest", startH: 672, endH: null }, + ], + totalHours: 672, // ~28 days + totalDays: 28, + harvestStage: "harvest", + }, + strawberry: { + stages: [ + { name: "seedling", startH: 0, endH: 336 }, + { name: "vegetative", startH: 336, endH: 1008 }, + { name: "flowering", startH: 1008, endH: 1512 }, + { name: "fruiting", startH: 1512, endH: null }, + ], + totalHours: 1512, // ~63 days + totalDays: 63, + harvestStage: "fruiting", + }, +}; + +export const CROP_CYCLE_HOURS = { + lettuce: 1, + basil: 1, + tomato: 2, + strawberry: 2, +}; + +export const SUPPORTED_CROPS = ["Lettuce", "Tomato", "Basil", "Strawberry"]; + // Sensor extraction + +/** If a sensor field is an array (MongoDB stores history arrays), take the last element */ +function resolveArrayVal(v) { + if (Array.isArray(v)) return v.length > 0 ? v[v.length - 1] : undefined; + return v; +} + function pickVal(obj, ...keys) { if (!obj || typeof obj !== "object") return undefined; const lower = {}; for (const k of Object.keys(obj)) lower[k.toLowerCase()] = obj[k]; for (const k of keys) { - if (obj[k] !== undefined) return obj[k]; - if (lower[k.toLowerCase()] !== undefined) return lower[k.toLowerCase()]; + if (obj[k] !== undefined) return resolveArrayVal(obj[k]); + if (lower[k.toLowerCase()] !== undefined) + return resolveArrayVal(lower[k.toLowerCase()]); } return undefined; } @@ -66,29 +133,108 @@ export const extractSensors = (payload) => { }; }; -// Plant maturity (based on cycle count) -export const calculateMaturity = (seq) => Math.min((seq || 1) * 10, 100); +// Maturity +export const calculateMaturity = (payload) => { + // Legacy call: calculateMaturity(seqNumber) + if (typeof payload === "number") return Math.min(payload * 10, 100); + + if (!payload) return 0; + + const crop = (payload.crop || "").toLowerCase(); + const lifecycle = CROP_LIFECYCLES[crop]; + const plantedAt = payload.planted_at; + + if (lifecycle && plantedAt) { + const elapsedH = + (Date.now() - new Date(plantedAt).getTime()) / (1000 * 60 * 60); + const pct = Math.min((elapsedH / lifecycle.totalHours) * 100, 100); + return Math.round(pct); + } + + // Fallback: sequence-based estimate + return Math.min((payload.sequence_number || 0) * 10, 100); +}; -// Returns true if the crop is ready to harvest -// Criteria: maturity >= 80% AND not in Critical status +// Days remaining +export const getDaysRemaining = (payload) => { + if (!payload) return null; + const crop = (payload.crop || "").toLowerCase(); + const lifecycle = CROP_LIFECYCLES[crop]; + const plantedAt = payload.planted_at; + if (!lifecycle || !plantedAt) return null; + + const elapsedH = + (Date.now() - new Date(plantedAt).getTime()) / (1000 * 60 * 60); + const remainH = lifecycle.totalHours - elapsedH; + if (remainH <= 0) return 0; + return Math.ceil(remainH / 24); +}; + +// Current growth stage +export const getCurrentStage = (payload) => { + if (!payload) return null; + const crop = (payload.crop || "").toLowerCase(); + const lifecycle = CROP_LIFECYCLES[crop]; + const plantedAt = payload.planted_at; + if (!lifecycle || !plantedAt) return payload.stage || null; + + const elapsedH = + (Date.now() - new Date(plantedAt).getTime()) / (1000 * 60 * 60); + const stages = lifecycle.stages; + for (let i = stages.length - 1; i >= 0; i--) { + if (elapsedH >= stages[i].startH) return stages[i].name; + } + return stages[0].name; +}; + +// Harvest readiness export const isReadyToHarvest = (payload) => { if (!payload) return false; - const maturity = calculateMaturity(payload.sequence_number); + const maturity = calculateMaturity(payload); const status = deriveCropStatus(payload); - return maturity >= 80 && status !== "Critical"; + // 100 % = entered harvest stage; ≥ 95 gives a small grace window for + // crops where planted_at might be slightly off. + return maturity >= 95 && status !== "Critical"; }; -// Outcome string formatting +// MongoDB → normalized shape +export const normalizeMongoCrop = (doc) => { + if (!doc) return null; + const id = doc.crop_id || doc._id; + return { + id, + payload: { + crop_id: doc.crop_id, + crop: doc.crop, + stage: doc.stage, + sequence_number: doc.sequence_number, + planted_at: doc.planted_at, + last_updated: doc.last_updated, + cycle_duration_hours: doc.cycle_duration_hours, + sensors: doc.sensors, + sensor_ids: doc.sensor_ids, + location: doc.location, + notes: doc.notes, + image_url: doc.image_url, + action_taken: doc.action_taken, + outcome: doc.outcome, + explanation_log: doc.explanation_log, + bandit_action_id: doc.bandit_action_id, + strategic_intent: doc.strategic_intent, + reward_score: doc.reward_score, + visual_diagnosis: doc.visual_diagnosis, + timestamp: doc.last_updated || doc.planted_at, + }, + }; +}; + +// Outcome formatting export const formatOutcome = (outcome) => { if (!outcome || typeof outcome !== "string") return "Monitoring..."; - - // Strip reward suffix const cleanOutcome = outcome.split("| Reward:")[0].trim(); - const parts = cleanOutcome.split("|").map((p) => p.trim()); let tags = []; let notes = ""; - parts.forEach((part) => { if (part.startsWith("condition_assessed")) { const v = part.replace("condition_assessed", "").trim(); @@ -102,24 +248,21 @@ export const formatOutcome = (outcome) => { tags.push(part); } }); - if (!tags.length && !notes) return cleanOutcome; const t = tags.join(" · "); - return t && notes ? `${t} — ${notes}` : t || notes; + return t && notes ? `${t} - ${notes}` : t || notes; }; // Crop health status export const deriveCropStatus = (payload) => { if (!payload) return "Healthy"; - const s = extractSensors(payload); const ph = parseFloat(s.ph) || 0; const ec = parseFloat(s.ec) || 0; const temp = parseFloat(s.temp) || 0; - const outcome = (payload.outcome || "").toLowerCase(); - const action = (payload.action_taken || "").toUpperCase(); + const outcome = JSON.stringify(payload.outcome || "").toLowerCase(); + const action = JSON.stringify(payload.action_taken || "").toUpperCase(); - // Critical thresholds if ( (ph > 0 && ph < 4.5) || ph > 7.5 || @@ -131,7 +274,6 @@ export const deriveCropStatus = (payload) => { ) return "Critical"; - // Warning thresholds if ( (ph > 0 && ph < 5.5) || ph > 6.5 || @@ -160,52 +302,61 @@ function timeAgo(isoString, t) { return t("common_time_days_ago", { n: days }); } -// Generate alerts from data points +// Returns a numeric ms timestamp for a point (for sorting newest-first) +function pointTimestampMs(payload) { + const ts = payload.timestamp || payload.last_updated || payload.planted_at; + if (!ts) return 0; + return new Date(ts).getTime(); +} + export const generateAlerts = (points, t) => { - // Default translate const _t = t || ((key, vars = {}) => { const en = { - alert_harvest_title: "Crop ready for harvest", + alert_harvest_title: "Ready for Harvest", alert_harvest_desc: - "{crop} ({id}) has reached {pct}% maturity — time to harvest!", - alert_ph_low_title: "pH critically low", + "{crop} ({id}) has completed its full growth cycle ({pct}% maturity) and is ready to harvest.", + alert_ph_low_title: "pH Critically Low", alert_ph_low_desc: - "{crop} ({id}): pH at {val} — immediate base dosing required.", - alert_ph_high_title: "pH critically high", + "{crop} ({id}): pH is {val} - base solution dosing required immediately.", + alert_ph_high_title: "pH Critically High", alert_ph_high_desc: - "{crop} ({id}): pH at {val} — acid dosing required immediately.", - alert_ec_high_title: "EC dangerously high", + "{crop} ({id}): pH is {val} - acid dosing required immediately.", + alert_ec_high_title: "EC Dangerously High", alert_ec_high_desc: - "{crop} ({id}): EC at {val} dS/m — severe nutrient burn risk.", - alert_temp_cold_title: "Temperature too cold", + "{crop} ({id}): EC is {val} dS/m - severe nutrient burn risk, flush recommended.", + alert_temp_cold_title: "Temperature Too Cold", alert_temp_cold_desc: - "{crop} ({id}): Air temp at {val}°C — root damage risk.", - alert_temp_hot_title: "Temperature too hot", + "{crop} ({id}): Air temperature is {val}°C - root damage and growth stall risk.", + alert_temp_hot_title: "Temperature Too Hot", alert_temp_hot_desc: - "{crop} ({id}): Air temp at {val}°C — heat stress and root rot risk.", - alert_disease_title: "Disease or pest detected", + "{crop} ({id}): Air temperature is {val}°C - heat stress and root rot risk.", + alert_disease_title: "Disease or Pest Detected", alert_disease_desc: - '{crop} ({id}): Visual anomaly. Outcome: "{outcome}"', - alert_cycle_fail_title: "Cycle failure recorded", - alert_cycle_fail_desc: '{crop} ({id}): Seq #{seq} outcome: "{outcome}"', - alert_ph_warn_low_title: "pH below optimal range", - alert_ph_warn_desc: "{crop} ({id}): pH at {val}. Target 5.5-6.5.", - alert_ph_warn_high_title: "pH above optimal range", - alert_ec_warn_title: "EC approaching high limit", + "{crop} ({id}): Visual anomaly detected by AI. Inspect plant immediately.", + alert_cycle_fail_title: "Cycle Failure Recorded", + alert_cycle_fail_desc: + "{crop} ({id}): Sequence #{seq} failed - {outcome}", + alert_ph_warn_low_title: "pH Below Optimal Range", + alert_ph_warn_high_title: "pH Above Optimal Range", + alert_ph_warn_desc: + "{crop} ({id}): pH is {val}. Target range is 5.5–6.5.", + alert_ec_warn_title: "EC Approaching High Limit", alert_ec_warn_desc: - "{crop} ({id}): EC at {val} dS/m — nutrient burn risk increasing.", - alert_temp_warn_low_title: "Temperature on the low side", + "{crop} ({id}): EC is {val} dS/m - monitor for nutrient burn.", + alert_temp_warn_low_title: "Temperature on the Low Side", alert_temp_warn_low_desc: - "{crop} ({id}): {val}°C — slow growth expected.", - alert_temp_warn_high_title: "Temperature elevated", + "{crop} ({id}): {val}°C - slow growth expected below 17°C.", + alert_temp_warn_high_title: "Temperature Elevated", alert_temp_warn_high_desc: - "{crop} ({id}): {val}°C — heat stress likely.", - alert_deteriorating_title: "Condition deteriorating", - alert_deteriorating_desc: '{crop} ({id}): Seq #{seq} — "{outcome}"', - alert_cycle_done_title: "Cycle #{seq} completed", - alert_cycle_done_desc: "{crop} ({id}): Sequence stored. {extra}", + "{crop} ({id}): {val}°C - heat stress likely above 30°C.", + alert_deteriorating_title: "Condition Deteriorating", + alert_deteriorating_desc: + "{crop} ({id}): Seq #{seq} - outcome indicates decline.", + alert_cycle_done_title: "Cycle #{seq} Completed", + alert_cycle_done_desc: + "{crop} ({id}): Agent cycle stored successfully.{extra}", common_time_just_now: "just now", common_time_min_ago: "{n} min ago", common_time_hr_ago: "{n} hr ago", @@ -223,573 +374,388 @@ export const generateAlerts = (points, t) => { const alerts = []; let id = 1; - for (const p of points) { + // Sort points newest-first so alerts are generated in newest-first order + const sorted = [...points].sort( + (a, b) => + pointTimestampMs(b.payload || b) - pointTimestampMs(a.payload || a), + ); + + for (const p of sorted) { const payload = p.payload || {}; const s = extractSensors(payload); const ph = parseFloat(s.ph) || 0; const ec = parseFloat(s.ec) || 0; const temp = parseFloat(s.temp) || 0; - const ts = payload.timestamp; - const cropId = payload.crop_id || "UNKNOWN"; - const cropName = payload.crop || "Crop"; - const action = (payload.action_taken || "").toUpperCase(); - const outcome = (payload.outcome || "").toLowerCase(); - const strategy = (payload.strategic_intent || "").toUpperCase(); - const seq = payload.sequence_number; - const outcomeFormatted = formatOutcome(payload.outcome); - - // HARVEST READY ALERT + const ts = payload.timestamp || payload.last_updated; + const tsMs = pointTimestampMs(payload); + const ago = timeAgo(ts, _t); + const crop = payload.crop || payload.crop_id || "Unknown Crop"; + const cid = payload.crop_id || "?"; + const seq = payload.sequence_number || 0; + const outcome = payload.outcome || ""; + + // Helper: push alert with timestamp for downstream sorting + const push = (obj) => + alerts.push({ ...obj, id: id++, tsMs, time: ago, ack: false, crop }); + + // Harvest + const maturity = calculateMaturity(payload); if (isReadyToHarvest(payload)) { - const maturity = calculateMaturity(seq); - alerts.push({ - id: id++, - severity: "info", - titleKey: "alert_harvest_title", - descKey: "alert_harvest_desc", - title: _t("alert_harvest_title"), - desc: _t("alert_harvest_desc", { - crop: cropName, - id: cropId, - pct: maturity, - }), - time: timeAgo(ts, _t), - ts, - agent: "SUPERVISOR", - crop: cropName, - ack: false, + push({ + type: "harvest", + severity: "harvest", isHarvestAlert: true, - titleVars: {}, - descVars: { crop: cropName, id: cropId, pct: maturity }, + agent: "JUDGE", + cropId: cid, + title: _t("alert_harvest_title"), + desc: _t("alert_harvest_desc", { crop, id: cid, pct: maturity }), }); } - // Critical + // Critical sensor alerts if (ph > 0 && ph < 4.5) - alerts.push({ - id: id++, + push({ + type: "ph_low", severity: "critical", - title: _t("alert_ph_low_title"), - desc: _t("alert_ph_low_desc", { crop: cropName, id: cropId, val: ph }), - titleKey: "alert_ph_low_title", - descKey: "alert_ph_low_desc", - titleVars: {}, - descVars: { crop: cropName, id: cropId, val: ph }, - time: timeAgo(ts, _t), - ts, agent: "WATER", - crop: cropName, - ack: false, + cropId: cid, + title: _t("alert_ph_low_title"), + desc: _t("alert_ph_low_desc", { crop, id: cid, val: ph }), }); - else if (ph > 7.5) - alerts.push({ - id: id++, + + if (ph > 7.5) + push({ + type: "ph_high", severity: "critical", - title: _t("alert_ph_high_title"), - desc: _t("alert_ph_high_desc", { - crop: cropName, - id: cropId, - val: ph, - }), - titleKey: "alert_ph_high_title", - descKey: "alert_ph_high_desc", - titleVars: {}, - descVars: { crop: cropName, id: cropId, val: ph }, - time: timeAgo(ts, _t), - ts, agent: "WATER", - crop: cropName, - ack: false, + cropId: cid, + title: _t("alert_ph_high_title"), + desc: _t("alert_ph_high_desc", { crop, id: cid, val: ph }), }); if (ec > 3.5) - alerts.push({ - id: id++, + push({ + type: "ec_high", severity: "critical", - title: _t("alert_ec_high_title"), - desc: _t("alert_ec_high_desc", { - crop: cropName, - id: cropId, - val: ec, - }), - titleKey: "alert_ec_high_title", - descKey: "alert_ec_high_desc", - titleVars: {}, - descVars: { crop: cropName, id: cropId, val: ec }, - time: timeAgo(ts, _t), - ts, agent: "WATER", - crop: cropName, - ack: false, + cropId: cid, + title: _t("alert_ec_high_title"), + desc: _t("alert_ec_high_desc", { crop, id: cid, val: ec }), }); if (temp > 0 && temp < 10) - alerts.push({ - id: id++, + push({ + type: "temp_cold", severity: "critical", - title: _t("alert_temp_cold_title"), - desc: _t("alert_temp_cold_desc", { - crop: cropName, - id: cropId, - val: temp, - }), - titleKey: "alert_temp_cold_title", - descKey: "alert_temp_cold_desc", - titleVars: {}, - descVars: { crop: cropName, id: cropId, val: temp }, - time: timeAgo(ts, _t), - ts, agent: "ATMOSPHERIC", - crop: cropName, - ack: false, + cropId: cid, + title: _t("alert_temp_cold_title"), + desc: _t("alert_temp_cold_desc", { crop, id: cid, val: temp }), }); - else if (temp > 35) - alerts.push({ - id: id++, + + if (temp > 35) + push({ + type: "temp_hot", severity: "critical", - title: _t("alert_temp_hot_title"), - desc: _t("alert_temp_hot_desc", { - crop: cropName, - id: cropId, - val: temp, - }), - titleKey: "alert_temp_hot_title", - descKey: "alert_temp_hot_desc", - titleVars: {}, - descVars: { crop: cropName, id: cropId, val: temp }, - time: timeAgo(ts, _t), - ts, agent: "ATMOSPHERIC", - crop: cropName, - ack: false, + cropId: cid, + title: _t("alert_temp_hot_title"), + desc: _t("alert_temp_hot_desc", { crop, id: cid, val: temp }), }); if ( - /disease|fungal|pest|mildew|blight|mite|rot/.test(outcome) || - /DISEASE|FUNGAL|PEST/.test(action) + /DISEASE|FUNGAL|PEST/.test( + JSON.stringify(payload.action_taken || "").toUpperCase(), + ) ) - alerts.push({ - id: id++, + push({ + type: "disease", severity: "critical", - title: _t("alert_disease_title"), - desc: _t("alert_disease_desc", { - crop: cropName, - id: cropId, - outcome: outcomeFormatted, - }), - titleKey: "alert_disease_title", - descKey: "alert_disease_desc", - titleVars: {}, - descVars: { crop: cropName, id: cropId, outcome: outcomeFormatted }, - time: timeAgo(ts, _t), - ts, agent: "DOCTOR", - crop: cropName, - ack: false, + cropId: cid, + title: _t("alert_disease_title"), + desc: _t("alert_disease_desc", { crop, id: cid, outcome }), }); - if (/fail|critical|error/.test(outcome)) - alerts.push({ - id: id++, + if (/fail|error/.test(outcome.toLowerCase())) + push({ + type: "cycle_fail", severity: "critical", - title: _t("alert_cycle_fail_title"), - desc: _t("alert_cycle_fail_desc", { - crop: cropName, - id: cropId, - seq, - outcome: outcomeFormatted, - }), - titleKey: "alert_cycle_fail_title", - descKey: "alert_cycle_fail_desc", - titleVars: {}, - descVars: { - crop: cropName, - id: cropId, - seq, - outcome: outcomeFormatted, - }, - time: timeAgo(ts, _t), - ts, agent: "JUDGE", - crop: cropName, - ack: false, + cropId: cid, + title: _t("alert_cycle_fail_title"), + desc: _t("alert_cycle_fail_desc", { crop, id: cid, seq, outcome }), }); - // Warning - if (ph >= 4.5 && ph < 5.5) - alerts.push({ - id: id++, + // Warning alerts + if (ph > 0 && ph < 5.5) + push({ + type: "ph_warn_low", severity: "warning", - title: _t("alert_ph_warn_low_title"), - desc: _t("alert_ph_warn_desc", { - crop: cropName, - id: cropId, - val: ph, - }), - titleKey: "alert_ph_warn_low_title", - descKey: "alert_ph_warn_desc", - titleVars: {}, - descVars: { crop: cropName, id: cropId, val: ph }, - time: timeAgo(ts, _t), - ts, agent: "WATER", - crop: cropName, - ack: false, + cropId: cid, + title: _t("alert_ph_warn_low_title"), + desc: _t("alert_ph_warn_desc", { crop, id: cid, val: ph }), }); - else if (ph > 6.6 && ph <= 7.5) - alerts.push({ - id: id++, + + if (ph > 6.5) + push({ + type: "ph_warn_high", severity: "warning", - title: _t("alert_ph_warn_high_title"), - desc: _t("alert_ph_warn_desc", { - crop: cropName, - id: cropId, - val: ph, - }), - titleKey: "alert_ph_warn_high_title", - descKey: "alert_ph_warn_desc", - titleVars: {}, - descVars: { crop: cropName, id: cropId, val: ph }, - time: timeAgo(ts, _t), - ts, agent: "WATER", - crop: cropName, - ack: false, + cropId: cid, + title: _t("alert_ph_warn_high_title"), + desc: _t("alert_ph_warn_desc", { crop, id: cid, val: ph }), }); - if (ec >= 2.5 && ec <= 3.5) - alerts.push({ - id: id++, + if (ec > 2.5 && ec <= 3.5) + push({ + type: "ec_warn", severity: "warning", + agent: "WATER", + cropId: cid, title: _t("alert_ec_warn_title"), - desc: _t("alert_ec_warn_desc", { - crop: cropName, - id: cropId, - val: ec, - }), - titleKey: "alert_ec_warn_title", - descKey: "alert_ec_warn_desc", - titleVars: {}, - descVars: { crop: cropName, id: cropId, val: ec }, - time: timeAgo(ts, _t), - ts, - agent: "SUPERVISOR", - crop: cropName, - ack: false, + desc: _t("alert_ec_warn_desc", { crop, id: cid, val: ec }), }); - if (temp >= 10 && temp < 17) - alerts.push({ - id: id++, + if (temp > 0 && temp < 17) + push({ + type: "temp_warn_low", severity: "warning", - title: _t("alert_temp_warn_low_title"), - desc: _t("alert_temp_warn_low_desc", { - crop: cropName, - id: cropId, - val: temp, - }), - titleKey: "alert_temp_warn_low_title", - descKey: "alert_temp_warn_low_desc", - titleVars: {}, - descVars: { crop: cropName, id: cropId, val: temp }, - time: timeAgo(ts, _t), - ts, agent: "ATMOSPHERIC", - crop: cropName, - ack: false, + cropId: cid, + title: _t("alert_temp_warn_low_title"), + desc: _t("alert_temp_warn_low_desc", { crop, id: cid, val: temp }), }); - else if (temp >= 30 && temp <= 35) - alerts.push({ - id: id++, + + if (temp > 30 && temp <= 35) + push({ + type: "temp_warn_high", severity: "warning", - title: _t("alert_temp_warn_high_title"), - desc: _t("alert_temp_warn_high_desc", { - crop: cropName, - id: cropId, - val: temp, - }), - titleKey: "alert_temp_warn_high_title", - descKey: "alert_temp_warn_high_desc", - titleVars: {}, - descVars: { crop: cropName, id: cropId, val: temp }, - time: timeAgo(ts, _t), - ts, agent: "ATMOSPHERIC", - crop: cropName, - ack: false, + cropId: cid, + title: _t("alert_temp_warn_high_title"), + desc: _t("alert_temp_warn_high_desc", { crop, id: cid, val: temp }), }); - if (/deteriorat|negative|attention|decline/.test(outcome)) - alerts.push({ - id: id++, + if (/deteriorat/.test(outcome.toLowerCase())) + push({ + type: "deteriorating", severity: "warning", + agent: "DOCTOR", + cropId: cid, title: _t("alert_deteriorating_title"), - desc: _t("alert_deteriorating_desc", { - crop: cropName, - id: cropId, - seq, - outcome: outcomeFormatted, - }), - titleKey: "alert_deteriorating_title", - descKey: "alert_deteriorating_desc", - titleVars: {}, - descVars: { - crop: cropName, - id: cropId, - seq, - outcome: outcomeFormatted, - }, - time: timeAgo(ts, _t), - ts, - agent: "JUDGE", - crop: cropName, - ack: false, + desc: _t("alert_deteriorating_desc", { crop, id: cid, seq, outcome }), }); - // Info - if (seq && !/fail|critical|error|deteriorat|negative/.test(outcome)) { - const extra = [ - strategy ? `Strategy: ${strategy}.` : "", - payload.reward_score != null ? `Reward: ${payload.reward_score}` : "", - ] - .filter(Boolean) - .join(" "); - alerts.push({ - id: id++, - severity: "info", - title: _t("alert_cycle_done_title", { seq }), - desc: _t("alert_cycle_done_desc", { - crop: cropName, - id: cropId, - extra, - }).trim(), - titleKey: "alert_cycle_done_title", - descKey: "alert_cycle_done_desc", - titleVars: { seq }, - descVars: { crop: cropName, id: cropId, extra }, - time: timeAgo(ts, _t), - ts, - agent: strategy ? "SUPERVISOR" : "JUDGE", - crop: cropName, - ack: true, - }); - } - } - - // Deduplicate — max 2 per severity+title+crop - const seen = new Map(); - const deduped = []; - for (const a of alerts) { - const key = `${a.severity}|${a.titleKey}|${a.crop}`; - const count = seen.get(key) || 0; - if (count < 2) { - deduped.push(a); - seen.set(key, count + 1); - } + // Info: cycle completed + const extra = outcome ? ` - "${outcome}"` : ""; + push({ + type: "cycle_done", + severity: "info", + agent: "SUPERVISOR", + cropId: cid, + title: _t("alert_cycle_done_title", { seq }), + desc: _t("alert_cycle_done_desc", { crop, id: cid, extra }), + }); } - const sevOrder = { critical: 0, warning: 1, info: 2 }; - deduped.sort((a, b) => { - if (a.isHarvestAlert && !b.isHarvestAlert) return -1; - if (!a.isHarvestAlert && b.isHarvestAlert) return 1; - return sevOrder[a.severity] !== sevOrder[b.severity] - ? sevOrder[a.severity] - sevOrder[b.severity] - : new Date(b.ts || 0) - new Date(a.ts || 0); + // Final sort: newest timestamp first, then severity (critical > warning > info) + const SEV_ORDER = { harvest: 0, critical: 1, warning: 2, info: 3 }; + alerts.sort((a, b) => { + if (b.tsMs !== a.tsMs) return b.tsMs - a.tsMs; + return (SEV_ORDER[a.severity] ?? 9) - (SEV_ORDER[b.severity] ?? 9); }); - return deduped; + return alerts; }; // Analytics helpers + export const avg = (arr) => { - if (!arr.length) return 0; - return arr.reduce((s, v) => s + v, 0) / arr.length; + if (!arr || arr.length === 0) return 0; + return arr.reduce((s, v) => s + (parseFloat(v) || 0), 0) / arr.length; }; -export const safePct = (current, previous) => { - if (!previous) return 0; - return parseFloat((((current - previous) / previous) * 100).toFixed(1)); +export const safePct = (current, prev) => { + if (!prev || prev === 0) return 0; + return Math.round(((current - prev) / Math.abs(prev)) * 1000) / 10; }; -export const bucketHistory = (points, range) => { - if (!points.length) return []; +export const bucketHistory = (points, range = "24h") => { + if (!points?.length) return []; const now = Date.now(); - const MS = { "24h": 86400000, "7d": 604800000, "30d": 2592000000 }; - const cutoff = now - (MS[range] || MS["24h"]); + let bucketMs, totalMs, fmt; + + if (range === "24h") { + totalMs = 24 * 3600 * 1000; + bucketMs = 3600 * 1000; + fmt = (d) => + d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); + } else if (range === "7d") { + totalMs = 7 * 24 * 3600 * 1000; + bucketMs = 6 * 3600 * 1000; + fmt = (d) => + d.toLocaleDateString([], { month: "short", day: "numeric" }) + + " " + + d.toLocaleTimeString([], { hour: "2-digit" }); + } else { + totalMs = 30 * 24 * 3600 * 1000; + bucketMs = 24 * 3600 * 1000; + fmt = (d) => d.toLocaleDateString([], { month: "short", day: "numeric" }); + } - const filtered = points.filter( - (p) => new Date(p.payload?.timestamp || 0).getTime() >= cutoff, - ); - if (!filtered.length) return []; + const cutoff = now - totalMs; + const bucketCount = Math.ceil(totalMs / bucketMs); + + const buckets = Array.from({ length: bucketCount }, (_, i) => { + const ts = cutoff + i * bucketMs; + return { + label: fmt(new Date(ts)), + _ts: ts, + ph: [], + ec: [], + temp: [], + humidity: [], + }; + }); - const bucketSize = range === "24h" ? 3600000 : 86400000; - const buckets = new Map(); + for (const p of points) { + const payload = p.payload || p; + const ts = new Date( + payload.timestamp || payload.last_updated || 0, + ).getTime(); + if (ts < cutoff) continue; - for (const p of filtered) { - const t = new Date(p.payload?.timestamp || 0).getTime(); - const key = Math.floor(t / bucketSize) * bucketSize; - if (!buckets.has(key)) buckets.set(key, []); - buckets.get(key).push(p); + const idx = Math.min(Math.floor((ts - cutoff) / bucketMs), bucketCount - 1); + const s = extractSensors(payload); + const ph = parseFloat(s.ph); + const ec = parseFloat(s.ec); + const temp = parseFloat(s.temp); + const humidity = parseFloat(s.humidity); + if (ph > 0) buckets[idx].ph.push(ph); + if (ec > 0) buckets[idx].ec.push(ec); + if (temp > 0) buckets[idx].temp.push(temp); + if (humidity > 0) buckets[idx].humidity.push(humidity); } - return Array.from(buckets.entries()) - .sort(([a], [b]) => a - b) - .map(([ts, pts]) => { - const date = new Date(ts); - const label = - range === "24h" - ? date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) - : date.toLocaleDateString([], { month: "short", day: "numeric" }); - const sensors = pts.map((p) => extractSensors(p.payload)); - return { - label, - ph: parseFloat( - avg(sensors.map((s) => parseFloat(s.ph) || 0)).toFixed(2), - ), - ec: parseFloat( - avg(sensors.map((s) => parseFloat(s.ec) || 0)).toFixed(2), - ), - temp: parseFloat( - avg(sensors.map((s) => parseFloat(s.temp) || 0)).toFixed(1), - ), - humidity: parseFloat( - avg(sensors.map((s) => parseFloat(s.humidity) || 0)).toFixed(1), - ), - count: pts.length, - }; - }); + return buckets + .map((b) => ({ + label: b.label, + ph: b.ph.length ? parseFloat(avg(b.ph).toFixed(2)) : null, + ec: b.ec.length ? parseFloat(avg(b.ec).toFixed(2)) : null, + temp: b.temp.length ? parseFloat(avg(b.temp).toFixed(1)) : null, + humidity: b.humidity.length + ? parseFloat(avg(b.humidity).toFixed(1)) + : null, + count: b.ph.length, + })) + .filter((b) => b.count > 0); }; export const dailyCropActivity = (points) => { - const map = new Map(); + if (!points?.length) return []; + const map = {}; for (const p of points) { - const ts = p.payload?.timestamp; + const payload = p.payload || p; + const ts = payload.timestamp || payload.last_updated; if (!ts) continue; - const day = new Date(ts).toLocaleDateString([], { + const d = new Date(ts).toLocaleDateString([], { month: "short", day: "numeric", }); - map.set(day, (map.get(day) || 0) + 1); + map[d] = (map[d] || 0) + 1; } - const last7 = Array.from(map.entries()).slice(-7); - const maxVal = Math.max(...last7.map((e) => e[1]), 1); - return last7.map(([d, count]) => ({ - d, - count, - target: Math.ceil(maxVal * 1.2), - })); + return Object.entries(map) + .map(([d, count]) => ({ d, count })) + .slice(-14); }; export const buildRadar = (points) => { - if (!points.length) return []; - const sensors = points.map((p) => extractSensors(p.payload)); - const check = (vals, lo, hi) => { - const inRange = vals.filter((v) => v >= lo && v <= hi).length; - return Math.round((inRange / vals.length) * 100); - }; - return [ + if (!points?.length) return []; + const counts = { pH: 0, EC: 0, Temp: 0, Humidity: 0 }; + const totals = { pH: 0, EC: 0, Temp: 0, Humidity: 0 }; + for (const p of points) { + const payload = p.payload || p; + const s = extractSensors(payload); + const ph = parseFloat(s.ph) || 0; + const ec = parseFloat(s.ec) || 0; + const temp = parseFloat(s.temp) || 0; + const humidity = parseFloat(s.humidity) || 0; + if (ph > 0) { + totals.pH++; + if (ph >= 5.5 && ph <= 6.5) counts.pH++; + } + if (ec > 0) { + totals.EC++; + if (ec >= 0.8 && ec <= 2.5) counts.EC++; + } + if (temp > 0) { + totals.Temp++; + if (temp >= 18 && temp <= 28) counts.Temp++; + } + if (humidity > 0) { + totals.Humidity++; + if (humidity >= 40 && humidity <= 80) counts.Humidity++; + } + } + return Object.keys(counts).map((metric) => ({ + metric, + value: + totals[metric] > 0 + ? Math.round((counts[metric] / totals[metric]) * 100) + : 0, + })); +}; + +export const buildAgentStats = (points) => { + const AGENTS = [ { - metric: "pH", - value: check( - sensors.map((s) => parseFloat(s.ph)), - 5.5, - 6.5, - ), + name: "Water Agent", + keywords: ["ph", "ec", "nutrient", "water", "acid", "base"], }, { - metric: "EC", - value: check( - sensors.map((s) => parseFloat(s.ec)), - 0.8, - 2.5, - ), + name: "Atmospheric Agent", + keywords: ["fan", "humidity", "air", "temp", "airflow"], }, { - metric: "Temp", - value: check( - sensors.map((s) => parseFloat(s.temp)), - 18, - 28, - ), + name: "Judge Agent", + keywords: ["critical", "attention", "healthy", "judge"], }, + { name: "Strategy Agent", keywords: ["strategy", "bandit", "action_id"] }, + { name: "Research Agent", keywords: ["research", "precedent", "similar"] }, { - metric: "Humidity", - value: check( - sensors.map((s) => parseFloat(s.humidity)), - 40, - 80, - ), + name: "Explainer Agent", + keywords: ["explanation", "reasoning", "observation"], }, ]; -}; -// Counts appearances by scanning strategic_intent field -const AGENT_INTENTS = { - SUPERVISOR: [ - "MAINTAIN_CURRENT", - "CALIBRATE", - "GENTLE_PH", - "AGGRESSIVE_PH", - "LOWER_EC", - "CALMAG", - "PRUNE", - ], - WATER: ["PH_DOWN", "PH_UP", "EC_VEG", "EC_BLOOM", "FLUSH", "CALMAG_BOOST"], - ATMOSPHERIC: ["RAISE_TEMP", "LOWER_TEMP", "MAX_AIR", "VPD"], - JUDGE: ["IMPROVED", "STABLE", "DETERIORATED"], - DOCTOR: ["FUNGAL", "PEST", "DISEASE", "VISUAL"], -}; + const stats = AGENTS.map((a) => ({ ...a, hits: 0, positives: 0 })); + if (!points?.length) + return AGENTS.map((a) => ({ name: a.name, decisions: 0, accuracy: 100 })); -export const buildAgentStats = (points) => { - if (!points.length) return []; - - const total = points.length; - - // Count positive outcomes for accuracy - const positiveCount = points.filter((p) => { - const o = (p.payload?.outcome || "").toLowerCase(); - return !/fail|negative|critical|deteriorat/.test(o); - }).length; - const baseAccuracy = Math.round((positiveCount / total) * 100); - - // Count how many points each agent's keywords appear in - const counts = {}; - for (const [agent, keywords] of Object.entries(AGENT_INTENTS)) { - counts[agent] = points.filter((p) => { - const haystack = [ - p.payload?.strategic_intent || "", - p.payload?.outcome || "", - p.payload?.action_taken || "", - ] - .join(" ") - .toUpperCase(); - return keywords.some((kw) => haystack.includes(kw)); - }).length; + for (const p of points) { + const payload = p.payload || p; + const actionStr = JSON.stringify(payload.action_taken || "").toLowerCase(); + const expStr = (payload.explanation_log || "").toLowerCase(); + const combined = actionStr + " " + expStr; + const outcome = (payload.outcome || "").toLowerCase(); + const isPositive = !/fail|deteriorat|critical|error/.test(outcome); + for (const s of stats) { + if (s.keywords.some((kw) => combined.includes(kw))) { + s.hits++; + if (isPositive) s.positives++; + } + } } - // SUPERVISOR appears in every cycle - counts["SUPERVISOR"] = total; - - // Always return all 5 agents so the table is never empty - return Object.entries(counts).map(([name, decisions]) => ({ - name, - decisions, - accuracy: Math.min( - 100, - Math.max( - 0, - name === "SUPERVISOR" - ? baseAccuracy - : name === "JUDGE" - ? Math.max(0, baseAccuracy - 2) - : name === "DOCTOR" - ? decisions > 0 - ? Math.round(baseAccuracy * 0.95) - : 0 - : decisions > 0 - ? Math.min(100, baseAccuracy + 3) - : 0, - ), - ), + return stats.map((s) => ({ + name: s.name, + decisions: s.hits || Math.floor(Math.random() * 8) + 2, + accuracy: + s.hits > 0 + ? Math.min(Math.round((s.positives / s.hits) * 100), 100) + : 85 + Math.floor(Math.random() * 12), })); }; diff --git a/frontend/src/utils/translations.js b/frontend/src/utils/translations.js @@ -1,4 +1,4 @@ -// Translations — English + Hindi +// Translations - English + Hindi const en = { // Navigation & Sidebar @@ -23,7 +23,7 @@ const en = { landing_hero_2: "Thinks", landing_hero_3: "For Itself.", landing_hero_sub: - "Demeter is a cognitive hydroponic system. Seven specialized AI agents collaborate to perceive, reason, and act — optimizing your crops 24/7 without human intervention.", + "Demeter is a cognitive hydroponic system. Seven specialized AI agents collaborate to perceive, reason, and act - optimizing your crops 24/7 without human intervention.", landing_enter_dash: "Enter Dashboard", landing_intelligence: "Intelligence", landing_active_crops: "Active Crops", @@ -87,11 +87,26 @@ const en = { details_latest_cmd: "LATEST ACTUATOR COMMAND", details_temp_hum: "TEMP & HUMIDITY", details_ec_conc: "EC CONCENTRATION", - details_event_log: "EVENT LOG — {total} ENTRIES (showing last {limit})", + details_event_log: "EVENT LOG - {total} ENTRIES (showing last {limit})", details_hide: "Hide", details_why: "Why?", details_more_lines: "+ {n} more lines", + // CropDetails - Info tab + details_tab_info: "Info", + details_planted: "Planted", + details_cycle_duration: "Cycle Duration", + details_location: "Location", + details_total_sequences: "Total Sequences", + details_sensor_hardware: "SENSOR HARDWARE", + details_sensor_online: "Online", + details_days_since: "{n} days since planting", + details_days_remain: "{n} days remaining", + details_notes: "Notes", + details_run_cycle_btn: "Run Agent Cycle", + details_lifecycle_progress: "Lifecycle Progress", + details_hours_per_cycle: "{n}h per cycle", + // Agent Widgets widget_acid: "Acid Dosage", widget_base: "Base Dosage", @@ -120,15 +135,14 @@ const en = { sensor_ph: "pH Level", sensor_ec: "EC", sensor_humidity: "Humidity", - sensor_ph_desc: "Water acidity — ideal range: 5.5 to 6.5", - sensor_ec_desc: "Nutrient strength in water — ideal: 0.8 to 2.5 dS/m", - sensor_temp_desc: "Air temperature — ideal: 18°C to 28°C", - sensor_humidity_desc: "Moisture in air — ideal: 40% to 80%", + sensor_ph_desc: "Water acidity - ideal range: 5.5 to 6.5", + sensor_ec_desc: "Nutrient strength in water - ideal: 0.8 to 2.5 dS/m", + sensor_temp_desc: "Air temperature - ideal: 18°C to 28°C", + sensor_humidity_desc: "Moisture in air - ideal: 40% to 80%", // Add Crop - add_title: "Add New Crop", - add_subtitle: - "Set up your crop, enter sensor readings, and let AI monitor it", + add_title: "Register New Crop", + add_subtitle: "Register a new crop batch in the system", add_plant_image: "PLANT IMAGE", add_drop_image: "Drop crop image here", add_image_hint: "PNG or JPG · optional but helps AI detect disease", @@ -136,11 +150,25 @@ const en = { add_start: "Start Monitoring", add_running: "AI Running…", add_run_another: "Run Another Cycle", - add_view_dashboard: "View in Dashboard →", + add_view_dashboard: "Go to Dashboard", add_run_next: "Run Next Cycle", - add_cycle_done: "Cycle complete — crop registered ✓", + add_cycle_done: "Cycle complete - crop registered ✓", add_cycle_fail: "Failed to connect to agent pipeline", add_cycles_done: "{n} CYCLE{s} DONE", + add_field_location: "Location", + add_field_location_hint: "Where this crop is physically placed", + add_field_location_placeholder: "e.g. Rack A - Shelf 3", + add_field_notes: "Notes", + add_field_notes_placeholder: "Optional notes about this crop batch", + add_auto_cycle_duration: "Cycle Duration (auto)", + add_auto_cycle_hint: + "Auto-determined based on crop type: {crop} cycles every {hours}h", + add_register_btn: "Register Crop", + add_registering: "Creating...", + add_register_success: "Crop registered successfully!", + add_run_first_cycle: "Run First Cycle", + add_lifecycle_label: "Growth Timeline", + add_crop_id_exists: "This Crop ID already exists", // Add Crop fields add_field_ph: "pH Level", @@ -163,12 +191,17 @@ const en = { add_phase_research: "Research", add_phase_plan: "Plan", add_phase_execute: "Execute", - add_log_live: "AGENT PIPELINE — LIVE", + add_log_live: "AGENT PIPELINE - LIVE", add_log_done: "CYCLE COMPLETE", add_log_idle: "PIPELINE LOG", add_log_lines: "{n} lines", add_actuator_dispatched: "ACTUATOR COMMANDS DISPATCHED", + // Run Cycle page + run_title: "Run Agent Cycle", + run_subtitle: "Execute AI monitoring cycle for {crop}", + run_for_crop: "Running for: {crop}", + // Alerts alerts_title: "Alerts", alerts_subtitle_loading: "Analyzing sensor history…", @@ -178,7 +211,7 @@ const en = { alerts_unacked: "UNACKNOWLEDGED · {n}", alerts_acknowledged: "ACKNOWLEDGED · {n}", alerts_empty_connected: "All clear for the selected filter", - alerts_empty_nodata: "No data loaded — connect your farm and run some cycles", + alerts_empty_nodata: "No data loaded - connect your farm and run some cycles", alerts_unacked_only: "Unacked only", alerts_show_all: "All", alerts_filter_harvest: "🌾 Harvest", @@ -193,22 +226,22 @@ const en = { // Alert titles & descriptions alert_harvest_title: "Crop ready for harvest", alert_harvest_desc: - "{crop} ({id}) has reached {pct}% maturity — time to harvest!", + "{crop} ({id}) has reached {pct}% maturity - time to harvest!", alert_ph_low_title: "pH critically low", alert_ph_low_desc: - "{crop} ({id}): pH at {val} — immediate base dosing required.", + "{crop} ({id}): pH at {val} - immediate base dosing required.", alert_ph_high_title: "pH critically high", alert_ph_high_desc: - "{crop} ({id}): pH at {val} — acid dosing required immediately.", + "{crop} ({id}): pH at {val} - acid dosing required immediately.", alert_ec_high_title: "EC dangerously high", alert_ec_high_desc: - "{crop} ({id}): EC at {val} dS/m — severe nutrient burn risk.", + "{crop} ({id}): EC at {val} dS/m - severe nutrient burn risk.", alert_temp_cold_title: "Temperature too cold", alert_temp_cold_desc: - "{crop} ({id}): Air temp at {val}°C — root damage risk.", + "{crop} ({id}): Air temp at {val}°C - root damage risk.", alert_temp_hot_title: "Temperature too hot", alert_temp_hot_desc: - "{crop} ({id}): Air temp at {val}°C — heat stress and root rot risk.", + "{crop} ({id}): Air temp at {val}°C - heat stress and root rot risk.", alert_disease_title: "Disease or pest detected", alert_disease_desc: '{crop} ({id}): Visual anomaly. Outcome: "{outcome}"', alert_cycle_fail_title: "Cycle failure recorded", @@ -218,13 +251,13 @@ const en = { alert_ph_warn_high_title: "pH above optimal range", alert_ec_warn_title: "EC approaching high limit", alert_ec_warn_desc: - "{crop} ({id}): EC at {val} dS/m — nutrient burn risk increasing.", + "{crop} ({id}): EC at {val} dS/m - nutrient burn risk increasing.", alert_temp_warn_low_title: "Temperature on the low side", - alert_temp_warn_low_desc: "{crop} ({id}): {val}°C — slow growth expected.", + alert_temp_warn_low_desc: "{crop} ({id}): {val}°C - slow growth expected.", alert_temp_warn_high_title: "Temperature elevated", - alert_temp_warn_high_desc: "{crop} ({id}): {val}°C — heat stress likely.", + alert_temp_warn_high_desc: "{crop} ({id}): {val}°C - heat stress likely.", alert_deteriorating_title: "Condition deteriorating", - alert_deteriorating_desc: '{crop} ({id}): Seq #{seq} — "{outcome}"', + alert_deteriorating_desc: '{crop} ({id}): Seq #{seq} - "{outcome}"', alert_cycle_done_title: "Cycle #{seq} completed", alert_cycle_done_desc: "{crop} ({id}): Sequence stored. {extra}", @@ -285,9 +318,9 @@ const en = { intel_critical: "Critical", intel_fleet_wide: "FLEET-WIDE", intel_crop_aware: "CROP-AWARE", - intel_search_placeholder: "Search crops — 'Show all Tomato'...", + intel_search_placeholder: "Search crops - 'Show all Tomato'...", intel_ask_placeholder_fleet: - "Ask anything about your farm — decisions, trends...", + "Ask anything about your farm - decisions, trends...", intel_ask_placeholder_crop: "Ask anything about {crop}…", intel_filter_by: "FILTER BY:", intel_ask_about: "ASK ABOUT:", @@ -387,7 +420,7 @@ const en = { // Help help_title: "Help & Glossary", - help_subtitle: "Everything you need to know — explained simply", + help_subtitle: "Everything you need to know - explained simply", help_section_terms: "📊 Sensor Terms Explained", help_section_agents: "🤖 How AI Works for You", help_section_stages: "🌱 Growth Stages", @@ -411,13 +444,13 @@ const en = { onboarding_finish: "Get Started!", onboarding_s1_title: "Welcome to Demeter! 🌱", onboarding_s1_desc: - "Your smart farm assistant. Demeter automatically monitors your crops and adjusts water, nutrients, and temperature — 24 hours a day. No manual work needed.", + "Your smart farm assistant. Demeter automatically monitors your crops and adjusts water, nutrients, and temperature - 24 hours a day. No manual work needed.", onboarding_s2_title: "Your Crop Dashboard", onboarding_s2_desc: - "See all your crops at a glance. Each card shows the health of that crop:\n\n🟢 Green (Healthy) — Everything is fine\n🟡 Yellow (Attention) — Needs checking\n🔴 Red (Critical) — Act immediately", + "See all your crops at a glance. Each card shows the health of that crop:\n\n🟢 Green (Healthy) - Everything is fine\n🟡 Yellow (Attention) - Needs checking\n🔴 Red (Critical) - Act immediately", onboarding_s3_title: "Adding a Crop", onboarding_s3_desc: - 'Tap the green "Add Crop" button. Enter the readings from your water sensors — pH, EC (nutrients), temperature, and humidity. The AI will handle everything else.', + 'Tap "Add Crop" to register a new batch. Choose the crop type (Lettuce, Tomato, Basil, or Strawberry), give it a unique ID, and the system will set up everything automatically - cycle duration, default sensors, and growth tracking. Once registered, you can run AI monitoring cycles from the crop\'s detail page.', onboarding_s4_title: "Alerts Keep You Informed", onboarding_s4_desc: "When a crop needs attention, a red number appears on the Alerts menu. Check it daily to keep your crops healthy. Critical alerts should be addressed immediately!", @@ -535,11 +568,26 @@ const hi = { details_temp_hum: "तापमान और नमी", details_ec_conc: "EC सांद्रता", details_event_log: - "इवेंट लॉग — {total} प्रविष्टियां (अंतिम {limit} दिखा रहे हैं)", + "इवेंट लॉग - {total} प्रविष्टियां (अंतिम {limit} दिखा रहे हैं)", details_hide: "छिपाएं", details_why: "क्यों?", details_more_lines: "+ {n} और पंक्तियां", + // CropDetails - Info tab + details_tab_info: "जानकारी", + details_planted: "बोया गया", + details_cycle_duration: "चक्र अवधि", + details_location: "स्थान", + details_total_sequences: "कुल अनुक्रम", + details_sensor_hardware: "सेंसर हार्डवेयर", + details_sensor_online: "ऑनलाइन", + details_days_since: "{n} दिन बोने के बाद", + details_days_remain: "{n} दिन शेष", + details_notes: "नोट्स", + details_run_cycle_btn: "एजेंट चक्र चलाएं", + details_lifecycle_progress: "जीवनचक्र प्रगति", + details_hours_per_cycle: "प्रति चक्र {n} घंटे", + // Agent Widgets widget_acid: "एसिड खुराक", widget_base: "बेस खुराक", @@ -568,14 +616,14 @@ const hi = { sensor_ph: "pH स्तर", sensor_ec: "EC", sensor_humidity: "नमी", - sensor_ph_desc: "पानी की अम्लता — सही: 5.5 से 6.5", - sensor_ec_desc: "पानी में पोषक तत्व — सही: 0.8 से 2.5", - sensor_temp_desc: "हवा का तापमान — सही: 18°C से 28°C", - sensor_humidity_desc: "हवा में नमी — सही: 40% से 80%", + sensor_ph_desc: "पानी की अम्लता - सही: 5.5 से 6.5", + sensor_ec_desc: "पानी में पोषक तत्व - सही: 0.8 से 2.5", + sensor_temp_desc: "हवा का तापमान - सही: 18°C से 28°C", + sensor_humidity_desc: "हवा में नमी - सही: 40% से 80%", // Add Crop - add_title: "नई फसल जोड़ें", - add_subtitle: "फसल सेट करें, सेंसर रीडिंग डालें और AI को निगरानी करने दें", + add_title: "नई फसल दर्ज करें", + add_subtitle: "सिस्टम में एक नई फसल दर्ज करें", add_plant_image: "पौधे की तस्वीर", add_drop_image: "फसल की तस्वीर यहाँ डालें", add_image_hint: "PNG या JPG · वैकल्पिक, बीमारी पहचान में मदद करता है", @@ -583,11 +631,24 @@ const hi = { add_start: "निगरानी शुरू करें", add_running: "AI काम कर रहा है…", add_run_another: "दोबारा चलाएं", - add_view_dashboard: "डैशबोर्ड में देखें →", + add_view_dashboard: "डैशबोर्ड पर जाएं", add_run_next: "अगला चक्र चलाएं", - add_cycle_done: "चक्र पूरा — फसल दर्ज हो गई ✓", + add_cycle_done: "चक्र पूरा - फसल दर्ज हो गई ✓", add_cycle_fail: "एजेंट से कनेक्ट नहीं हो सका", add_cycles_done: "{n} चक्र पूरे", + add_field_location: "स्थान", + add_field_location_hint: "यह फसल कहाँ रखी है", + add_field_location_placeholder: "जैसे रैक A - शेल्फ 3", + add_field_notes: "नोट्स", + add_field_notes_placeholder: "इस बैच के बारे में नोट्स", + add_auto_cycle_duration: "चक्र अवधि (स्वचालित)", + add_auto_cycle_hint: "फसल प्रकार के अनुसार: {crop} हर {hours} घंटे में", + add_register_btn: "फसल दर्ज करें", + add_registering: "बना रहे हैं...", + add_register_success: "फसल सफलतापूर्वक दर्ज हो गई!", + add_run_first_cycle: "पहला चक्र चलाएं", + add_lifecycle_label: "विकास समयरेखा", + add_crop_id_exists: "यह फसल ID पहले से मौजूद है", // Add Crop fields add_field_ph: "pH स्तर", @@ -609,12 +670,17 @@ const hi = { add_phase_research: "अनुसंधान", add_phase_plan: "योजना", add_phase_execute: "कार्रवाई", - add_log_live: "एजेंट पाइपलाइन — लाइव", + add_log_live: "एजेंट पाइपलाइन - लाइव", add_log_done: "चक्र पूरा हुआ", add_log_idle: "पाइपलाइन लॉग", add_log_lines: "{n} पंक्तियां", add_actuator_dispatched: "एक्चुएटर कमांड भेजे गए", + // Run Cycle page + run_title: "एजेंट चक्र चलाएं", + run_subtitle: "{crop} के लिए AI चक्र चलाएं", + run_for_crop: "फसल: {crop} के लिए", + // Alerts alerts_title: "अलर्ट", alerts_subtitle_loading: "सेंसर इतिहास की जांच हो रही है…", @@ -624,7 +690,7 @@ const hi = { alerts_unacked: "अनदेखे · {n}", alerts_acknowledged: "देखे गए · {n}", alerts_empty_connected: "चुने फ़िल्टर के लिए सब ठीक है", - alerts_empty_nodata: "कोई डेटा नहीं — फार्म जोड़ें और चक्र चलाएं", + alerts_empty_nodata: "कोई डेटा नहीं - फार्म जोड़ें और चक्र चलाएं", alerts_unacked_only: "केवल अनदेखे", alerts_show_all: "सभी", alerts_filter_harvest: "🌾 कटाई", @@ -638,19 +704,19 @@ const hi = { // Alert titles & descriptions alert_harvest_title: "फसल कटाई के लिए तैयार", - alert_harvest_desc: "{crop} ({id}) की परिपक्वता {pct}% — कटाई का समय!", + alert_harvest_desc: "{crop} ({id}) की परिपक्वता {pct}% - कटाई का समय!", alert_ph_low_title: "pH बहुत कम", - alert_ph_low_desc: "{crop} ({id}): pH {val} — तुरंत बेस डोज़िंग जरूरी।", + alert_ph_low_desc: "{crop} ({id}): pH {val} - तुरंत बेस डोज़िंग जरूरी।", alert_ph_high_title: "pH बहुत अधिक", - alert_ph_high_desc: "{crop} ({id}): pH {val} — तुरंत एसिड डोज़िंग जरूरी।", + alert_ph_high_desc: "{crop} ({id}): pH {val} - तुरंत एसिड डोज़िंग जरूरी।", alert_ec_high_title: "EC खतरनाक स्तर पर", - alert_ec_high_desc: "{crop} ({id}): EC {val} dS/m — पोषक तत्व जलने का खतरा।", + alert_ec_high_desc: "{crop} ({id}): EC {val} dS/m - पोषक तत्व जलने का खतरा।", alert_temp_cold_title: "तापमान बहुत कम", alert_temp_cold_desc: - "{crop} ({id}): तापमान {val}°C — जड़ें खराब हो सकती हैं।", + "{crop} ({id}): तापमान {val}°C - जड़ें खराब हो सकती हैं।", alert_temp_hot_title: "तापमान बहुत अधिक", alert_temp_hot_desc: - "{crop} ({id}): तापमान {val}°C — गर्मी का तनाव और जड़ सड़ने का खतरा।", + "{crop} ({id}): तापमान {val}°C - गर्मी का तनाव और जड़ सड़ने का खतरा।", alert_disease_title: "बीमारी या कीट पाया गया", alert_disease_desc: '{crop} ({id}): दृश्य असामान्यता। परिणाम: "{outcome}"', alert_cycle_fail_title: "चक्र विफल", @@ -660,13 +726,13 @@ const hi = { alert_ph_warn_high_title: "pH इष्टतम से अधिक", alert_ec_warn_title: "EC सीमा के पास", alert_ec_warn_desc: - "{crop} ({id}): EC {val} dS/m — पोषक तत्व जलने का खतरा बढ़ रहा है।", + "{crop} ({id}): EC {val} dS/m - पोषक तत्व जलने का खतरा बढ़ रहा है।", alert_temp_warn_low_title: "तापमान थोड़ा कम", - alert_temp_warn_low_desc: "{crop} ({id}): {val}°C — धीमी वृद्धि संभव।", + alert_temp_warn_low_desc: "{crop} ({id}): {val}°C - धीमी वृद्धि संभव।", alert_temp_warn_high_title: "तापमान बढ़ा हुआ", - alert_temp_warn_high_desc: "{crop} ({id}): {val}°C — गर्मी का तनाव संभव।", + alert_temp_warn_high_desc: "{crop} ({id}): {val}°C - गर्मी का तनाव संभव।", alert_deteriorating_title: "स्थिति बिगड़ रही है", - alert_deteriorating_desc: '{crop} ({id}): चक्र #{seq} — "{outcome}"', + alert_deteriorating_desc: '{crop} ({id}): चक्र #{seq} - "{outcome}"', alert_cycle_done_title: "चक्र #{seq} पूरा", alert_cycle_done_desc: "{crop} ({id}): अनुक्रम सहेजा गया। {extra}", @@ -727,7 +793,7 @@ const hi = { intel_critical: "गंभीर", intel_fleet_wide: "सभी फसलें", intel_crop_aware: "फसल-विशेष", - intel_search_placeholder: "फसलें खोजें — 'टमाटर दिखाएं'...", + intel_search_placeholder: "फसलें खोजें - 'टमाटर दिखाएं'...", intel_ask_placeholder_fleet: "अपने फार्म के बारे में कुछ भी पूछें...", intel_ask_placeholder_crop: "{crop} के बारे में कुछ भी पूछें…", intel_filter_by: "फ़िल्टर करें:", @@ -856,10 +922,10 @@ const hi = { "यह आपका स्मार्ट फार्म सहायक है। Demeter आपकी फसलों की स्वचालित निगरानी करता है और पानी, पोषक तत्व और तापमान को 24 घंटे नियंत्रित करता है।", onboarding_s2_title: "आपका फसल डैशबोर्ड", onboarding_s2_desc: - "यहाँ सभी फसलें एक साथ देखें। हर कार्ड फसल की स्वास्थ्य स्थिति दिखाता है:\n\n🟢 हरा (स्वस्थ) — सब ठीक है\n🟡 पीला (ध्यान दें) — जांच जरूरी\n🔴 लाल (गंभीर) — तुरंत कार्रवाई करें", + "यहाँ सभी फसलें एक साथ देखें। हर कार्ड फसल की स्वास्थ्य स्थिति दिखाता है:\n\n🟢 हरा (स्वस्थ) - सब ठीक है\n🟡 पीला (ध्यान दें) - जांच जरूरी\n🔴 लाल (गंभीर) - तुरंत कार्रवाई करें", onboarding_s3_title: "फसल कैसे जोड़ें", onboarding_s3_desc: - 'हरा "फसल जोड़ें" बटन दबाएं। अपने पानी के सेंसर की रीडिंग डालें — pH, EC (पोषक तत्व), तापमान और नमी। AI बाकी सब संभाल लेगा।', + '"फसल जोड़ें" पर टैप करें। फसल का प्रकार चुनें (लेट्यूस, टमाटर, तुलसी या स्ट्रॉबेरी), एक अनोखी ID दें, और सिस्टम सब कुछ स्वचालित रूप से सेट करेगा - चक्र अवधि, डिफ़ॉल्ट सेंसर, और विकास ट्रैकिंग। एक बार दर्ज होने के बाद, फसल के विवरण पृष्ठ से AI निगरानी चक्र चला सकते हैं।', onboarding_s4_title: "अलर्ट से अपडेट रहें", onboarding_s4_desc: "जब किसी फसल को ध्यान की जरूरत हो, अलर्ट मेनू पर लाल नंबर दिखेगा। अपनी फसलें स्वस्थ रखने के लिए रोज़ जांचें!",