demeter

Autonomous Hydroponic Intelligence
commit 62fe4c2b15d697626d2c266d23b8a963a504dee1
parent 49c94bc8c9ee07299af04245a087f10dd71c7102
Author: maydayv7 <maydayv7@gmail.com>
Date:   Thu, 26 Mar 2026 00:29:32 +0530

Add Hindi Translation

Diffstat:
Mfrontend/src/App.js | 55+++++++++++++++++++++++++++++++++++++++++++------------
Mfrontend/src/components/AgentWidgets.jsx | 50+++++++++++++++++++++++++++-----------------------
Afrontend/src/components/Onboarding.jsx | 254+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mfrontend/src/components/Sidebar.jsx | 134++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
Mfrontend/src/hooks/useSettings.js | 20+++++++++++++++++++-
Afrontend/src/hooks/useTranslation.js | 14++++++++++++++
Mfrontend/src/index.css | 78++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
Mfrontend/src/pages/AddCrop.jsx | 158+++++++++++++++++++++++++++++++++++++++++--------------------------------------
Mfrontend/src/pages/Alerts.jsx | 117++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
Mfrontend/src/pages/Analytics.jsx | 128++++++++++++++++++++++++++++++++++++++++++++++---------------------------------
Mfrontend/src/pages/CropDetails.jsx | 101+++++++++++++++++++++++++++++++++++++++----------------------------------------
Mfrontend/src/pages/Dashboard.jsx | 296++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
Mfrontend/src/pages/FarmIntelligence.jsx | 442+++++++++++++++++++++++++++++--------------------------------------------------
Mfrontend/src/pages/LandingPage.jsx | 129+++++++++++++++++++++++++++++++++++++++++--------------------------------------
Mfrontend/src/pages/Settings.jsx | 349+++++++++++++++++++++++++++++++++++++++++++++++++------------------------------
Mfrontend/src/utils/dataUtils.js | 335+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
Afrontend/src/utils/translations.js | 902+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
17 files changed, 2671 insertions(+), 891 deletions(-)

diff --git a/frontend/src/App.js b/frontend/src/App.js @@ -1,6 +1,8 @@ +import { useState, useEffect } from "react"; import { BrowserRouter as Router, Routes, Route } from "react-router-dom"; import { FarmDataProvider } from "./hooks/useFarmData"; import { SettingsProvider } from "./hooks/useSettings"; +import { useSettings } from "./hooks/useSettings"; import LandingPage from "./pages/LandingPage"; import Dashboard from "./pages/Dashboard"; @@ -10,23 +12,52 @@ import FarmIntelligence from "./pages/FarmIntelligence"; import Analytics from "./pages/Analytics"; import Alerts from "./pages/Alerts"; import SettingsPage from "./pages/Settings"; +import Onboarding from "./components/Onboarding"; + +// Inner component so it can access SettingsProvider context +function AppInner() { + const { settings, update } = useSettings(); + const [showOnboarding, setShowOnboarding] = useState(false); + + useEffect(() => { + if (!settings.onboardingDone) { + // Small delay so the page renders first + const t = setTimeout(() => setShowOnboarding(true), 600); + return () => clearTimeout(t); + } + }, [settings.onboardingDone]); + + return ( + <> + {showOnboarding && ( + <Onboarding + onDone={() => { + update("onboardingDone", true); + setShowOnboarding(false); + }} + /> + )} + <Router> + <Routes> + <Route path="/" element={<LandingPage />} /> + <Route path="/dashboard" element={<Dashboard />} /> + <Route path="/crop/:cropId" element={<CropDetails />} /> + <Route path="/add-crop" element={<AddCrop />} /> + <Route path="/intelligence" element={<FarmIntelligence />} /> + <Route path="/analytics" element={<Analytics />} /> + <Route path="/alerts" element={<Alerts />} /> + <Route path="/settings" element={<SettingsPage />} /> + </Routes> + </Router> + </> + ); +} function App() { return ( <SettingsProvider> <FarmDataProvider> - <Router> - <Routes> - <Route path="/" element={<LandingPage />} /> - <Route path="/dashboard" element={<Dashboard />} /> - <Route path="/crop/:cropId" element={<CropDetails />} /> - <Route path="/add-crop" element={<AddCrop />} /> - <Route path="/intelligence" element={<FarmIntelligence />} /> - <Route path="/analytics" element={<Analytics />} /> - <Route path="/alerts" element={<Alerts />} /> - <Route path="/settings" element={<SettingsPage />} /> - </Routes> - </Router> + <AppInner /> </FarmDataProvider> </SettingsProvider> ); diff --git a/frontend/src/components/AgentWidgets.jsx b/frontend/src/components/AgentWidgets.jsx @@ -1,46 +1,47 @@ import { Fan, FlaskConical, Sprout, Waves } from "lucide-react"; +import { useT } from "../hooks/useTranslation"; -// Shared action metadata +// Shared action metadata config const ACTION_META = { acid_dosage_ml: { - label: "Acid Dosage", + labelKey: "widget_acid", icon: FlaskConical, unit: "ml", color: "var(--red)", bg: "rgba(248,113,113,0.1)", - desc: "pH Down", + descKey: "widget_ph_down", }, base_dosage_ml: { - label: "Base Dosage", + labelKey: "widget_base", icon: FlaskConical, unit: "ml", color: "#a78bfa", bg: "rgba(167,139,250,0.1)", - desc: "pH Up", + descKey: "widget_ph_up", }, nutrient_dosage_ml: { - label: "Nutrients", + labelKey: "widget_nutrients", icon: Sprout, unit: "ml", color: "var(--green)", bg: "rgba(74,222,128,0.1)", - desc: "EC Boost", + descKey: "widget_ec_boost", }, fan_speed_pct: { - label: "Fan Speed", + labelKey: "widget_fan", icon: Fan, unit: "%", color: "var(--blue)", bg: "rgba(96,165,250,0.1)", - desc: "Airflow", + descKey: "widget_airflow", }, water_refill_l: { - label: "Water Refill", + labelKey: "widget_water", icon: Waves, unit: "L", color: "#22d3ee", bg: "rgba(34,211,238,0.1)", - desc: "Dilution", + descKey: "widget_dilution", }, }; @@ -67,6 +68,7 @@ function parseAction(raw) { // Show actuator commands as cards export function AgentActionWidget({ actionTaken, compact = false }) { + const { t } = useT(); const action = parseAction(actionTaken); if (!action) return null; @@ -81,13 +83,7 @@ export function AgentActionWidget({ actionTaken, compact = false }) { if (compact) { return ( - <div - style={{ - display: "flex", - flexWrap: "wrap", - gap: 6, - }} - > + <div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}> {display.map(({ key, meta, value }) => { const Icon = meta.icon; return ( @@ -122,7 +118,7 @@ export function AgentActionWidget({ actionTaken, compact = false }) { color: "var(--text-3)", }} > - {meta.desc} + {t(meta.descKey)} </span> </div> ); @@ -200,7 +196,7 @@ export function AgentActionWidget({ actionTaken, compact = false }) { fontWeight: isActive ? 600 : 400, }} > - {meta.label} + {t(meta.labelKey)} </div> </div> ); @@ -211,6 +207,7 @@ export function AgentActionWidget({ actionTaken, compact = false }) { // Agent Outcome export function AgentOutcomeWidget({ outcome, rewardScore, strategicIntent }) { + const { t, td } = useT(); if (!outcome || outcome === "PENDING_OBSERVATION") return null; const raw = outcome.split("| Reward:")[0].trim(); @@ -243,6 +240,13 @@ export function AgentOutcomeWidget({ outcome, rewardScore, strategicIntent }) { const emoji = isNegative ? "▼" : isPositive ? "▲" : "●"; + // Try to use a static translation for known outcomes, else dynamic fallback + let translatedRaw = raw; + if (raw === "IMPROVED") translatedRaw = t("outcome_improved"); + else if (raw === "DETERIORATED") translatedRaw = t("outcome_deteriorated"); + else if (raw === "STABLE") translatedRaw = t("outcome_stable"); + else translatedRaw = td(raw); + return ( <div style={{ @@ -265,7 +269,7 @@ export function AgentOutcomeWidget({ outcome, rewardScore, strategicIntent }) { flexShrink: 0, }} > - {emoji} {raw} + {emoji} {translatedRaw} </span> {reward != null && ( @@ -281,7 +285,7 @@ export function AgentOutcomeWidget({ outcome, rewardScore, strategicIntent }) { flexShrink: 0, }} > - Reward: {reward > 0 ? "+" : ""} + {t("reward_label")}: {reward > 0 ? "+" : ""} {reward.toFixed(2)} </span> )} @@ -299,7 +303,7 @@ export function AgentOutcomeWidget({ outcome, rewardScore, strategicIntent }) { flexShrink: 0, }} > - {strategicIntent.replace(/_/g, " ")} + {td(strategicIntent.replace(/_/g, " "))} </span> )} </div> diff --git a/frontend/src/components/Onboarding.jsx b/frontend/src/components/Onboarding.jsx @@ -0,0 +1,254 @@ +import React, { useState } from "react"; +import { + Leaf, + LayoutGrid, + PlusCircle, + Bell, + ChevronRight, + X, + CheckCircle2, +} from "lucide-react"; +import { useSettings } from "../hooks/useSettings"; +import { useT } from "../hooks/useTranslation"; + +const STEP_ICONS = [Leaf, LayoutGrid, PlusCircle, Bell, CheckCircle2]; +const STEP_COLORS = [ + "var(--green)", + "var(--blue)", + "var(--green)", + "var(--red)", + "#f59e0b", +]; +const STEP_KEYS = [ + { title: "onboarding_s1_title", desc: "onboarding_s1_desc" }, + { title: "onboarding_s2_title", desc: "onboarding_s2_desc" }, + { title: "onboarding_s3_title", desc: "onboarding_s3_desc" }, + { title: "onboarding_s4_title", desc: "onboarding_s4_desc" }, + { title: "onboarding_s5_title", desc: "onboarding_s5_desc" }, +]; + +export default function Onboarding({ onDone }) { + const [step, setStep] = useState(0); + const { update } = useSettings(); + const { t } = useT(); + + const total = STEP_KEYS.length; + const isLast = step === total - 1; + const Icon = STEP_ICONS[step]; + const color = STEP_COLORS[step]; + const { title: titleKey, desc: descKey } = STEP_KEYS[step]; + + const finish = () => { + update("onboardingDone", true); + onDone?.(); + }; + + return ( + /* Backdrop */ + <div + style={{ + position: "fixed", + inset: 0, + zIndex: 9999, + background: "rgba(0,0,0,0.65)", + display: "flex", + alignItems: "center", + justifyContent: "center", + padding: 24, + }} + > + {/* Modal */} + <div + className="animate-fade-up" + style={{ + width: "100%", + maxWidth: 480, + borderRadius: 20, + background: "var(--bg-2)", + border: "1px solid var(--border)", + overflow: "hidden", + boxShadow: "0 24px 80px rgba(0,0,0,0.5)", + }} + > + {/* Header strip */} + <div + style={{ + height: 4, + background: "var(--border)", + position: "relative", + }} + > + <div + style={{ + position: "absolute", + left: 0, + top: 0, + height: "100%", + width: `${((step + 1) / total) * 100}%`, + background: color, + borderRadius: 4, + transition: "width 0.4s ease", + }} + /> + </div> + + {/* Top row */} + <div + style={{ + display: "flex", + alignItems: "center", + justifyContent: "space-between", + padding: "16px 20px 0", + }} + > + <span + style={{ + fontSize: 11, + fontFamily: "DM Mono, monospace", + color: "var(--text-3)", + }} + > + {t("onboarding_step", { n: step + 1, total })} + </span> + <button + onClick={finish} + style={{ + background: "none", + border: "none", + cursor: "pointer", + color: "var(--text-3)", + display: "flex", + padding: 4, + }} + > + <X size={16} /> + </button> + </div> + + {/* Body */} + <div + style={{ + padding: "24px 28px 28px", + display: "flex", + flexDirection: "column", + alignItems: "flex-start", + gap: 16, + }} + > + {/* Icon */} + <div + style={{ + width: 56, + height: 56, + borderRadius: 16, + background: `${color}18`, + border: `1px solid ${color}35`, + display: "flex", + alignItems: "center", + justifyContent: "center", + }} + > + <Icon size={26} style={{ color }} /> + </div> + + {/* Title */} + <div + style={{ + fontSize: 22, + fontWeight: 700, + color: "var(--text)", + lineHeight: 1.2, + }} + > + {t(titleKey)} + </div> + + {/* Description */} + <div + style={{ + fontSize: 14, + color: "var(--text-2)", + lineHeight: 1.75, + whiteSpace: "pre-line", + }} + > + {t(descKey)} + </div> + + {/* Step dots */} + <div + style={{ + display: "flex", + gap: 6, + marginTop: 4, + alignSelf: "center", + }} + > + {STEP_KEYS.map((_, i) => ( + <div + key={i} + onClick={() => setStep(i)} + style={{ + width: i === step ? 18 : 7, + height: 7, + borderRadius: 4, + background: i === step ? color : "var(--border)", + cursor: "pointer", + transition: "all 0.3s", + }} + /> + ))} + </div> + + {/* Buttons */} + <div + style={{ + display: "flex", + gap: 10, + width: "100%", + marginTop: 4, + }} + > + <button + onClick={finish} + style={{ + flex: 1, + padding: "10px 0", + borderRadius: 10, + fontSize: 13, + fontWeight: 500, + background: "var(--surface)", + border: "1px solid var(--border)", + color: "var(--text-3)", + cursor: "pointer", + }} + > + {t("onboarding_skip")} + </button> + <button + onClick={() => (isLast ? finish() : setStep((s) => s + 1))} + style={{ + flex: 2, + padding: "10px 0", + borderRadius: 10, + fontSize: 14, + fontWeight: 700, + background: color, + border: "none", + color: isLast ? "#0c1a0e" : "#0c1a0e", + cursor: "pointer", + display: "flex", + alignItems: "center", + justifyContent: "center", + gap: 6, + }} + > + {isLast ? t("onboarding_finish") : t("onboarding_next")} + {!isLast && <ChevronRight size={14} />} + </button> + </div> + </div> + </div> + </div> + ); +} diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx @@ -11,14 +11,16 @@ import { ChevronRight, } from "lucide-react"; import { useFarmData } from "../hooks/useFarmData"; -import { deriveCropStatus } from "../utils/dataUtils"; +import { deriveCropStatus, isReadyToHarvest } from "../utils/dataUtils"; import { useSettings } from "../hooks/useSettings"; +import { useT } from "../hooks/useTranslation"; export default function Sidebar() { const [collapsed, setCollapsed] = useState(false); const loc = useLocation(); const { dashboard } = useFarmData(); const { settings } = useSettings(); + const { t } = useT(); const alertCount = useMemo(() => { if (!dashboard?.length) return 0; @@ -28,12 +30,25 @@ export default function Sidebar() { }).length; }, [dashboard]); + const harvestCount = useMemo(() => { + if (!dashboard?.length) return 0; + return dashboard.filter((d) => isReadyToHarvest(d.payload)).length; + }, [dashboard]); + + const totalBadge = alertCount + harvestCount; + const NAV = [ - { label: "Crops", icon: LayoutGrid, path: "/dashboard" }, - { label: "Alerts", icon: Bell, path: "/alerts", badge: alertCount || null }, - { label: "Analytics", icon: BarChart3, path: "/analytics" }, - { label: "Intelligence", icon: Sparkles, path: "/intelligence" }, - { label: "Settings", icon: Settings, path: "/settings" }, + { labelKey: "nav_crops", icon: LayoutGrid, path: "/dashboard" }, + { + labelKey: "nav_alerts", + icon: Bell, + path: "/alerts", + badge: totalBadge || null, + harvest: harvestCount, + }, + { labelKey: "nav_analytics", icon: BarChart3, path: "/analytics" }, + { labelKey: "nav_intelligence", icon: Sparkles, path: "/intelligence" }, + { labelKey: "nav_settings", icon: Settings, path: "/settings" }, ]; const initials = @@ -106,7 +121,7 @@ export default function Sidebar() { letterSpacing: "0.1em", }} > - AGRI·AI·v2 + {t("sidebar_agri_ai")} </div> </div> )} @@ -166,7 +181,7 @@ export default function Sidebar() { color: "var(--green)", }} > - SYSTEM ONLINE + {t("nav_system_online")} </span> </div> </div> @@ -182,11 +197,12 @@ export default function Sidebar() { gap: 2, }} > - {NAV.map(({ label, icon: Icon, path, badge }) => { + {NAV.map(({ labelKey, icon: Icon, path, badge, harvest }) => { const active = loc.pathname === path; + const label = t(labelKey); return ( <Link - key={label} + key={labelKey} to={path} title={collapsed ? label : undefined} style={{ @@ -211,23 +227,49 @@ export default function Sidebar() { <span style={{ fontSize: 13, fontWeight: 500 }}>{label}</span> )} - {/* Badge */} + {/* Badge (expanded) */} {badge && !collapsed && ( - <span - className="alert-pulse" + <div style={{ marginLeft: "auto", - fontSize: 10, - fontFamily: "DM Mono, monospace", - padding: "1px 5px", - borderRadius: 4, - background: "rgba(248,113,113,0.2)", - color: "var(--red)", + display: "flex", + alignItems: "center", + gap: 4, }} > - {badge} - </span> + {alertCount > 0 && ( + <span + className="alert-pulse" + style={{ + fontSize: 10, + fontFamily: "DM Mono, monospace", + padding: "1px 5px", + borderRadius: 4, + background: "rgba(248,113,113,0.2)", + color: "var(--red)", + }} + > + {alertCount} + </span> + )} + {harvest > 0 && ( + <span + className="harvest-pulse" + style={{ + fontSize: 10, + fontFamily: "DM Mono, monospace", + padding: "1px 5px", + borderRadius: 4, + background: "rgba(245,158,11,0.2)", + color: "var(--amber)", + }} + > + 🌾 {harvest} + </span> + )} + </div> )} + {/* Badge (collapsed) */} {badge && collapsed && ( <span className="alert-pulse" @@ -238,7 +280,7 @@ export default function Sidebar() { width: 7, height: 7, borderRadius: "50%", - background: "var(--red)", + background: alertCount > 0 ? "var(--red)" : "var(--amber)", }} /> )} @@ -247,7 +289,7 @@ export default function Sidebar() { })} </nav> - {/* Alert status */} + {/* Alert / harvest status */} {!collapsed && ( <div style={{ padding: "0 12px 12px" }}> <div @@ -262,8 +304,41 @@ export default function Sidebar() { className="section-label" style={{ margin: 0, marginBottom: 6, fontSize: 9 }} > - ALERT STATUS + {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 @@ -283,10 +358,13 @@ export default function Sidebar() { color: "var(--red)", }} > - {alertCount} crop{alertCount !== 1 ? "s" : ""} need attention + {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" @@ -305,10 +383,10 @@ export default function Sidebar() { color: "var(--green)", }} > - All clear + {t("nav_all_clear")} </span> </div> - )} + ) : null} </div> </div> )} diff --git a/frontend/src/hooks/useSettings.js b/frontend/src/hooks/useSettings.js @@ -5,9 +5,12 @@ const DEFAULTS = { userDesignation: "Farm Owner", userInitials: "R", theme: "dark", + language: "en", maxResultsPerPage: 12, alertsShowAcked: false, compactMode: false, + onboardingDone: false, + historyLogLimit: 20, }; const SettingsContext = createContext(); @@ -31,9 +34,24 @@ export function SettingsProvider({ children }) { // Apply theme to <html> useEffect(() => { - document.documentElement.setAttribute("data-theme", settings.theme); + const theme = + settings.theme === "auto" + ? window.matchMedia("(prefers-color-scheme: dark)").matches + ? "dark" + : "light" + : settings.theme; + document.documentElement.setAttribute("data-theme", theme); }, [settings.theme]); + // Use Noto Sans for Hindi + useEffect(() => { + if (settings.language === "hi") { + document.documentElement.setAttribute("data-lang", "hi"); + } else { + document.documentElement.setAttribute("data-lang", "en"); + } + }, [settings.language]); + const update = (key, value) => setSettings((prev) => ({ ...prev, [key]: value })); diff --git a/frontend/src/hooks/useTranslation.js b/frontend/src/hooks/useTranslation.js @@ -0,0 +1,14 @@ +import { useCallback } from "react"; +import { useSettings } from "./useSettings"; +import { translate, translateDynamic } from "../utils/translations"; + +// Returns a translation helper bound to the current language setting +export function useT() { + const { settings } = useSettings(); + const lang = settings.language || "en"; + + const t = useCallback((key, vars) => translate(lang, key, vars), [lang]); + const td = useCallback((text) => translateDynamic(text, lang), [lang]); + + return { t, td, lang }; +} diff --git a/frontend/src/index.css b/frontend/src/index.css @@ -1,10 +1,10 @@ -@import url("https://fonts.googleapis.com/css2?family=Syne:wght@400;500;600;700;800&family=DM+Mono:ital,wght@0,300;0,400;0,500;1,300&family=Instrument+Serif:ital@0;1&display=swap"); +@import url("https://fonts.googleapis.com/css2?family=Syne:wght@400;500;600;700;800&family=DM+Mono:ital,wght@0,300;0,400;0,500;1,300&family=Instrument+Serif:ital@0;1&family=Noto+Sans+Devanagari:wght@400;500;600;700&display=swap"); @tailwind base; @tailwind components; @tailwind utilities; -/* Dark Theme */ +/* ─── Dark Theme ───────────────────────────────────────────────── */ :root, [data-theme="dark"] { --bg: #0c1a0e; @@ -31,9 +31,20 @@ --scrollbar-thumb: #3d6040; --tooltip-bg: #1a2b1c; --shadow: 0 4px 24px rgba(0, 0, 0, 0.4); + + /* Log / terminal area */ + --log-bg: #080f09; + --log-text: #a8bfaa; + + /* Hover backgrounds */ + --hover-bg: rgba(255, 255, 255, 0.04); + --hover-bg-alt: rgba(74, 222, 128, 0.06); + + /* Button text on green background */ + --btn-on-green: #0c1a0e; } -/* Light Theme */ +/* ─── Light Theme ──────────────────────────────────────────────── */ [data-theme="light"] { --bg: #f0f7f1; --bg-2: #ffffff; @@ -58,9 +69,28 @@ --scrollbar-thumb: #7db688; --tooltip-bg: #ffffff; --shadow: 0 4px 24px rgba(0, 0, 0, 0.08); + + /* Log / terminal area — light but distinct */ + --log-bg: #f0f5f1; + --log-text: #3d5e42; + + /* Hover backgrounds */ + --hover-bg: rgba(0, 0, 0, 0.04); + --hover-bg-alt: rgba(26, 124, 58, 0.06); + + /* Button text on green background */ + --btn-on-green: #ffffff; +} + +/* ─── Hindi font body override ─────────────────────────────────── */ +[data-lang="hi"] body, +[data-lang="hi"] button, +[data-lang="hi"] input, +[data-lang="hi"] select { + font-family: "Noto Sans Devanagari", "Syne", sans-serif; } -/* Reset */ +/* ─── Reset ────────────────────────────────────────────────────── */ * { box-sizing: border-box; } @@ -77,7 +107,7 @@ body { color 0.25s ease; } -/* Scrollbar */ +/* ─── Scrollbar ────────────────────────────────────────────────── */ ::-webkit-scrollbar { width: 6px; height: 6px; @@ -90,8 +120,7 @@ body { border-radius: 3px; } -/* TYPOGRAPHY */ -/* Section headings (// COMMENT style) */ +/* ─── TYPOGRAPHY ───────────────────────────────────────────────── */ .section-label { font-size: 11px; font-family: "DM Mono", monospace; @@ -110,7 +139,6 @@ body { font-weight: 700; } -/* Sensor value */ .sensor-value { font-size: 28px; font-weight: 700; @@ -143,7 +171,6 @@ body { margin-left: 3px; } -/* Page heading */ .page-title { font-size: 20px; font-weight: 700; @@ -157,7 +184,7 @@ body { margin: 2px 0 0; } -/* Table */ +/* ─── Table ────────────────────────────────────────────────────── */ .data-table th { font-size: 11px; font-family: "DM Mono", monospace; @@ -180,7 +207,7 @@ body { border-bottom: none; } -/* UTILITIES */ +/* ─── UTILITIES ────────────────────────────────────────────────── */ .font-mono { font-family: "DM Mono", monospace; } @@ -194,7 +221,7 @@ body { 0 0 60px rgba(74, 222, 128, 0.05); } -/* Scan line */ +/* ─── Scan line (dark only) ────────────────────────────────────── */ @keyframes scanline { 0% { transform: translateY(-100%); @@ -223,7 +250,7 @@ body { display: none; } -/* Animations */ +/* ─── Animations ───────────────────────────────────────────────── */ @keyframes fadeUp { from { opacity: 0; @@ -262,6 +289,15 @@ body { box-shadow: 0 0 0 8px rgba(248, 113, 113, 0); } } +@keyframes harvestPulse { + 0%, + 100% { + box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.35); + } + 70% { + box-shadow: 0 0 0 10px rgba(245, 158, 11, 0); + } +} @keyframes shimmer { 0% { background-position: -200% 0; @@ -297,6 +333,9 @@ body { .alert-pulse { animation: alertPulse 2s ease infinite; } +.harvest-pulse { + animation: harvestPulse 2.5s ease infinite; +} .progress-fill { animation: progressFill 1s ease forwards; } @@ -341,7 +380,7 @@ body { linear-gradient(90deg, rgba(26, 124, 58, 0.05) 1px, transparent 1px); } -/* Input fields */ +/* ─── Input fields ─────────────────────────────────────────────── */ input, select, textarea { @@ -356,3 +395,14 @@ textarea { input::placeholder { color: var(--text-3); } + +/* ─── Harvest badge animation ──────────────────────────────────── */ +.harvest-badge { + animation: harvestPulse 2.5s ease infinite; +} + +/* ─── 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,6 +1,7 @@ import { useRef, useState, useEffect, useCallback } from "react"; import { useNavigate } from "react-router-dom"; import { useFarmData } from "../hooks/useFarmData"; +import { useT } from "../hooks/useTranslation"; import { Upload, ArrowLeft, @@ -14,9 +15,6 @@ import { Play, CheckCircle2, AlertTriangle, - Cpu, - Waves, - FlaskConical, Fan, Brain, ChevronDown, @@ -24,6 +22,9 @@ import { Zap, Circle, ChevronRight, + Waves, + FlaskConical, + Cpu, } from "lucide-react"; import Sidebar from "../components/Sidebar"; @@ -84,10 +85,9 @@ const LEVEL_COLORS = { info: "var(--text-2)", }; -// Input field definitions -const INPUT_FIELDS = [ +const getInputFields = (t) => [ { - label: "pH Level", + label: t("add_field_ph"), name: "pH", icon: Droplets, color: "var(--green)", @@ -95,33 +95,37 @@ const INPUT_FIELDS = [ step: "0.1", min: "0", max: "14", + hint: t("sensor_ph_desc"), }, { - label: "EC (mS/cm)", + label: t("add_field_ec"), name: "EC", icon: Activity, color: "var(--amber)", type: "number", step: "0.1", + hint: t("sensor_ec_desc"), }, { - label: "Temp (°C)", + label: t("add_field_temp"), name: "temp", icon: Thermometer, color: "var(--blue)", type: "number", step: "0.5", + hint: t("sensor_temp_desc"), }, { - label: "Humidity (%)", + label: t("add_field_humidity"), name: "humidity", icon: Wind, color: "#a78bfa", type: "number", step: "1", + hint: t("sensor_humidity_desc"), }, { - label: "Crop Type", + label: t("add_field_crop_type"), name: "crop", icon: Sprout, color: "var(--green)", @@ -138,33 +142,25 @@ const INPUT_FIELDS = [ ], }, { - label: "Growth Stage", + 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: "Batch / Crop ID", + label: t("add_field_crop_id"), name: "crop_id", icon: Database, color: "var(--text-3)", type: "text", - placeholder: "e.g. Batch_A1 (optional)", + placeholder: t("add_field_crop_id_placeholder"), + hint: t("add_field_crop_id_hint"), }, ]; -// Cycle status strip -const CYCLE_PHASES = [ - { key: "fetch", label: "Fetch", icon: Database }, - { key: "judge", label: "Judge", icon: Zap }, - { key: "strategy", label: "Strategy", icon: Brain }, - { key: "research", label: "Research", icon: Leaf }, - { key: "plan", label: "Plan", icon: Cpu }, - { key: "execute", label: "Execute", icon: Play }, -]; - function phaseFromLogs(logs) { const last = logs[logs.length - 1]?.text?.toUpperCase() || ""; if (last.includes("SENT TO SIMULATOR") || last.includes("CYCLE COMPLETE")) @@ -185,8 +181,7 @@ function phaseFromLogs(logs) { return null; } -// Single log line -function LogLine({ entry, idx }) { +function LogLine({ entry, idx, td }) { const agent = AGENT_META[entry.agent] || AGENT_META.SYSTEM; const lvlColor = LEVEL_COLORS[entry.level] || LEVEL_COLORS.info; @@ -198,7 +193,7 @@ function LogLine({ entry, idx }) { alignItems: "flex-start", gap: 10, padding: "5px 0", - borderBottom: "1px solid rgba(255,255,255,0.03)", + borderBottom: "1px solid rgba(128,180,128,0.06)", animationDelay: `${idx * 20}ms`, }} > @@ -243,7 +238,7 @@ function LogLine({ entry, idx }) { marginTop: 1, }} > - {agent.label} + {td(agent.label)} </span> {/* Message */} <span @@ -256,7 +251,7 @@ function LogLine({ entry, idx }) { lineHeight: 1.5, }} > - {entry.text} + {td(entry.text)} </span> </div> ); @@ -266,6 +261,7 @@ function LogLine({ entry, idx }) { export default function AddCrop() { const navigate = useNavigate(); const { refreshData } = useFarmData(); + const { t, td } = useT(); const [file, setFile] = useState(null); const [preview, setPreview] = useState(null); @@ -279,7 +275,7 @@ export default function AddCrop() { crop_id: "", }); - const [phase, setPhase] = useState("idle"); // idle | running | done | error + const [phase, setPhase] = useState("idle"); const [logs, setLogs] = useState([]); const [cycles, setCycles] = useState(0); const [activePhase, setActivePhase] = useState(null); @@ -380,8 +376,8 @@ export default function AddCrop() { if (msg.phase === "done") { setPhase("done"); setCycles((c) => c + 1); - refreshData(); // Refresh global state - showToast("Cycle complete — crop registered ✓"); + refreshData(); + showToast(t("add_cycle_done")); } } catch (e) { console.error("Parse error", e); @@ -393,7 +389,7 @@ export default function AddCrop() { console.error(err); setPhase("error"); pushLog(`❌ Connection Error: ${err.message}`, "SYSTEM"); - showToast("Failed to connect to agent pipeline", "error"); + showToast(t("add_cycle_fail"), "error"); } } @@ -418,7 +414,17 @@ export default function AddCrop() { } }; + 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 ( <div @@ -523,10 +529,8 @@ export default function AddCrop() { </div> <div> - <h1 className="page-title">Add New Crop</h1> - <p className="page-subtitle"> - Configure parameters · Start cycle · Watch agents reason live - </p> + <h1 className="page-title">{t("add_title")}</h1> + <p className="page-subtitle">{t("add_subtitle")}</p> </div> {/* Cycle counter */} @@ -555,7 +559,7 @@ export default function AddCrop() { background: "var(--green)", }} /> - {cycles} CYCLE{cycles !== 1 ? "S" : ""} DONE + {t("add_cycles_done", { n: cycles, s: cycles !== 1 ? "S" : "" })} </div> )} </header> @@ -578,7 +582,6 @@ export default function AddCrop() { {CYCLE_PHASES.map((p, i) => { const done = phaseIndex > i; const current = phaseIndex === i; - const Icon = p.icon; return ( <div key={p.key} @@ -660,7 +663,7 @@ export default function AddCrop() { > {/* Image upload */} <div style={{ display: "flex", flexDirection: "column", gap: 12 }}> - <div className="section-label">PLANT IMAGE</div> + <div className="section-label">{t("add_plant_image")}</div> <label onDrop={handleDrop} onDragOver={(e) => e.preventDefault()} @@ -757,7 +760,7 @@ export default function AddCrop() { color: "var(--text-2)", }} > - Drop crop image + {t("add_drop_image")} </div> <div style={{ @@ -766,7 +769,7 @@ export default function AddCrop() { color: "var(--text-3)", }} > - PNG, JPG · optional but recommended + {t("add_image_hint")} </div> </div> </div> @@ -800,7 +803,7 @@ export default function AddCrop() { color: phase === "running" || phase === "done" ? "var(--green)" - : "#0c1a0e", + : "var(--btn-on-green)", opacity: phase === "running" ? 0.8 : 1, transition: "all 0.2s", boxShadow: @@ -809,16 +812,16 @@ export default function AddCrop() { > {phase === "running" ? ( <> - <Activity size={15} className="animate-spin" /> Running - Agents... + <Activity size={15} className="animate-spin" />{" "} + {t("add_running")} </> ) : phase === "done" ? ( <> - <CheckCircle2 size={15} /> Run Another Cycle + <CheckCircle2 size={15} /> {t("add_run_another")} </> ) : ( <> - <Play size={15} fill="currentColor" /> Start Monitoring + <Play size={15} fill="currentColor" /> {t("add_start")} </> )} </button> @@ -833,7 +836,7 @@ export default function AddCrop() { border: "1px solid var(--border)", }} > - <div className="section-label">SENSOR PARAMETERS</div> + <div className="section-label">{t("add_sensor_params")}</div> <div style={{ display: "grid", @@ -853,14 +856,27 @@ export default function AddCrop() { step, min, max, + hint, }) => ( <div key={name}> <div className="sensor-label" - style={{ color, marginBottom: 5 }} + 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} @@ -904,7 +920,7 @@ export default function AddCrop() { > {opts.map((o) => ( <option key={o} value={o}> - {o} + {td(o)} </option> ))} </select> @@ -1007,10 +1023,10 @@ export default function AddCrop() { }} > {phase === "running" - ? "AGENT PIPELINE — LIVE" + ? t("add_log_live") : phase === "done" - ? "CYCLE COMPLETE" - : "PIPELINE LOG"} + ? t("add_log_done") + : t("add_log_idle")} </span> </div> <span @@ -1020,7 +1036,7 @@ export default function AddCrop() { color: "var(--text-3)", }} > - {logs.length} lines + {t("add_log_lines", { n: logs.length })} </span> {/* Agent legend */} @@ -1047,7 +1063,7 @@ export default function AddCrop() { border: `1px solid ${v.color}30`, }} > - {v.label} + {td(v.label)} </span> ))} </div> @@ -1055,16 +1071,16 @@ export default function AddCrop() { {/* Log body */} <div + className="log-area" style={{ padding: "12px 18px", maxHeight: 340, overflowY: "auto", - background: "#0a1509", fontFamily: "DM Mono, monospace", }} > {logs.map((entry, i) => ( - <LogLine key={i} entry={entry} idx={i} /> + <LogLine key={i} entry={entry} idx={i} td={td} /> ))} {phase === "running" && ( <div @@ -1078,14 +1094,6 @@ export default function AddCrop() { > <span style={{ - fontSize: 10, - fontFamily: "DM Mono, monospace", - color: "var(--text-3)", - minWidth: 28, - }} - /> - <span - style={{ fontSize: 12, fontFamily: "DM Mono, monospace", color: "var(--green)", @@ -1131,7 +1139,7 @@ export default function AddCrop() { color: "var(--text)", }} > - ACTUATOR COMMANDS DISPATCHED + {t("add_actuator_dispatched")} </span> </div> <div @@ -1145,40 +1153,40 @@ export default function AddCrop() { {[ { key: "acid_dosage_ml", - label: "Acid", + labelKey: "widget_acid", unit: "ml", icon: FlaskConical, color: "var(--red)", }, { key: "base_dosage_ml", - label: "Base", + labelKey: "widget_base", unit: "ml", icon: FlaskConical, color: "#a78bfa", }, { key: "nutrient_dosage_ml", - label: "Nutrients", + labelKey: "widget_nutrients", unit: "ml", icon: Sprout, color: "var(--green)", }, { key: "fan_speed_pct", - label: "Fan", + labelKey: "widget_fan", unit: "%", icon: Fan, color: "var(--blue)", }, { key: "water_refill_l", - label: "Water", + labelKey: "widget_water", unit: "L", icon: Waves, color: "#22d3ee", }, - ].map(({ key, label, unit, icon: Icon, color }) => { + ].map(({ key, labelKey, unit, icon: Icon, color }) => { const val = finalAction[key] ?? 0; const active = parseFloat(val) > 0; return ( @@ -1229,7 +1237,7 @@ export default function AddCrop() { fontWeight: active ? 600 : 400, }} > - {label} + {t(labelKey)} </div> </div> ); @@ -1245,11 +1253,11 @@ export default function AddCrop() { fontWeight: 600, background: "var(--green)", border: "none", - color: "#0c1a0e", + color: "var(--btn-on-green)", cursor: "pointer", }} > - View in Dashboard → + {t("add_view_dashboard")} </button> <button onClick={startCycle} @@ -1264,7 +1272,7 @@ export default function AddCrop() { cursor: "pointer", }} > - Run Next Cycle + {t("add_run_next")} </button> </div> </div> diff --git a/frontend/src/pages/Alerts.jsx b/frontend/src/pages/Alerts.jsx @@ -10,10 +10,12 @@ import { Clock, RefreshCw, SlidersHorizontal, + Scissors, } from "lucide-react"; import { useFarmData } from "../hooks/useFarmData"; import { generateAlerts } from "../utils/dataUtils"; import Sidebar from "../components/Sidebar"; +import { useT } from "../hooks/useTranslation"; // Severity const SEV = { @@ -22,24 +24,32 @@ const SEV = { bg: "rgba(248,113,113,0.1)", border: "rgba(248,113,113,0.3)", text: "var(--red)", - label: "CRITICAL", + labelKey: "alerts_severity_critical", }, warning: { icon: Zap, bg: "rgba(245,158,11,0.1)", border: "rgba(245,158,11,0.25)", text: "var(--amber)", - label: "WARNING", + labelKey: "alerts_severity_warning", }, info: { icon: Info, bg: "rgba(96,165,250,0.1)", border: "rgba(96,165,250,0.25)", text: "var(--blue)", - label: "INFO", + labelKey: "alerts_severity_info", }, }; +const HARVEST_STYLE = { + icon: Scissors, + bg: "rgba(245,158,11,0.12)", + border: "rgba(245,158,11,0.35)", + text: "var(--amber)", + labelKey: "alerts_severity_harvest", +}; + const AGENT_COLORS = { WATER: "var(--blue)", ATMOSPHERIC: "#a78bfa", @@ -49,10 +59,9 @@ const AGENT_COLORS = { HISTORIAN: "var(--text-3)", }; -// Alert Card -function AlertCard({ alert, onAck, onDismiss }) { - const s = SEV[alert.severity]; - const Icon = s.icon; +function AlertCard({ alert, onAck, onDismiss, t, td }) { + const style = alert.isHarvestAlert ? HARVEST_STYLE : SEV[alert.severity]; + const Icon = style.icon; return ( <div @@ -60,8 +69,8 @@ function AlertCard({ alert, onAck, onDismiss }) { style={{ borderRadius: 12, padding: 16, - background: alert.ack ? "var(--surface)" : s.bg, - border: `1px solid ${alert.ack ? "var(--border)" : s.border}`, + background: alert.ack ? "var(--surface)" : style.bg, + border: `1px solid ${alert.ack ? "var(--border)" : style.border}`, opacity: alert.ack ? 0.55 : 1, transition: "opacity 0.2s", }} @@ -73,8 +82,8 @@ function AlertCard({ alert, onAck, onDismiss }) { width: 32, height: 32, borderRadius: 8, - background: s.bg, - border: `1px solid ${s.border}`, + background: style.bg, + border: `1px solid ${style.border}`, display: "flex", alignItems: "center", justifyContent: "center", @@ -82,7 +91,7 @@ function AlertCard({ alert, onAck, onDismiss }) { marginTop: 2, }} > - <Icon size={14} style={{ color: s.text }} /> + <Icon size={14} style={{ color: style.text }} /> </div> {/* Content */} @@ -110,12 +119,12 @@ function AlertCard({ alert, onAck, onDismiss }) { fontFamily: "DM Mono, monospace", padding: "2px 6px", borderRadius: 4, - background: s.bg, - color: s.text, - border: `1px solid ${s.border}`, + background: style.bg, + color: style.text, + border: `1px solid ${style.border}`, }} > - {s.label} + {t(style.labelKey)} </span> <span style={{ @@ -123,11 +132,12 @@ function AlertCard({ alert, onAck, onDismiss }) { fontFamily: "DM Mono, monospace", padding: "2px 6px", borderRadius: 4, - background: "rgba(0,0,0,0.3)", + background: "var(--bg-3)", color: AGENT_COLORS[alert.agent] || "var(--text-3)", + border: "1px solid var(--border)", }} > - {alert.agent} + {td(alert.agent)} </span> </div> @@ -139,7 +149,7 @@ function AlertCard({ alert, onAck, onDismiss }) { lineHeight: 1.5, }} > - {alert.desc} + {td(alert.desc)} </p> <div style={{ display: "flex", alignItems: "center", gap: 12 }}> @@ -162,7 +172,7 @@ function AlertCard({ alert, onAck, onDismiss }) { color: "var(--text-3)", }} > - Crop: {alert.crop} + {t("alerts_crop_label", { crop: td(alert.crop) })} </span> </div> </div> @@ -217,13 +227,14 @@ function AlertCard({ alert, onAck, onDismiss }) { export default function Alerts() { const { history, loading, refreshData } = useFarmData(); + const { t, td } = useT(); const [alerts, setAlerts] = useState([]); const [filter, setFilter] = useState("all"); const [showAcked, setShowAcked] = useState(false); useEffect(() => { - if (!loading) setAlerts(generateAlerts(history)); - }, [history, loading]); + if (!loading) setAlerts(generateAlerts(history, t)); + }, [history, loading, t]); const ack = (id) => setAlerts((a) => a.map((al) => (al.id === id ? { ...al, ack: true } : al))); @@ -232,10 +243,14 @@ export default function Alerts() { const counts = useMemo( () => ({ - critical: alerts.filter((a) => a.severity === "critical" && !a.ack) - .length, + harvest: alerts.filter((a) => a.isHarvestAlert && !a.ack).length, + critical: alerts.filter( + (a) => a.severity === "critical" && !a.ack && !a.isHarvestAlert, + ).length, warning: alerts.filter((a) => a.severity === "warning" && !a.ack).length, - info: alerts.filter((a) => a.severity === "info" && !a.ack).length, + info: alerts.filter( + (a) => a.severity === "info" && !a.ack && !a.isHarvestAlert, + ).length, total: alerts.filter((a) => !a.ack).length, }), [alerts], @@ -245,6 +260,7 @@ export default function Alerts() { () => alerts.filter((a) => { if (!showAcked && a.ack) return false; + if (filter === "harvest") return a.isHarvestAlert; if (filter !== "all" && a.severity !== filter) return false; return true; }), @@ -253,20 +269,31 @@ export default function Alerts() { // Filters const FILTER_OPTIONS = [ - { key: "all", label: "All", count: alerts.length, color: null }, + { key: "all", label: t("common_all"), count: alerts.length, color: null }, + { + key: "harvest", + label: t("alerts_filter_harvest"), + count: counts.harvest, + color: "var(--amber)", + }, { key: "critical", - label: "Critical", + label: t("dash_critical"), count: counts.critical, color: "var(--red)", }, { key: "warning", - label: "Warning", + label: t("alerts_filter_warning"), count: counts.warning, color: "var(--amber)", }, - { key: "info", label: "Info", count: counts.info, color: "var(--blue)" }, + { + key: "info", + label: t("alerts_filter_info"), + count: counts.info, + color: "var(--blue)", + }, ]; return ( @@ -302,11 +329,14 @@ export default function Alerts() { }} > <div> - <h1 className="page-title">Alerts</h1> + <h1 className="page-title">{t("alerts_title")}</h1> <p className="page-subtitle"> {loading - ? "Analyzing sensor history…" - : `${counts.total} unacknowledged · ${alerts.length} total`} + ? t("alerts_subtitle_loading") + : t("alerts_subtitle", { + unacked: counts.total, + total: alerts.length, + })} </p> </div> @@ -320,7 +350,6 @@ export default function Alerts() { > <button onClick={refreshData} - title="Reload" style={{ width: 34, height: 34, @@ -356,7 +385,7 @@ export default function Alerts() { }} > {showAcked ? <Bell size={12} /> : <BellOff size={12} />} - {showAcked ? "All" : "Unacked only"} + {showAcked ? t("alerts_show_all") : t("alerts_unacked_only")} </button> <button @@ -375,7 +404,7 @@ export default function Alerts() { cursor: "pointer", }} > - <CheckCircle2 size={12} /> Ack all + <CheckCircle2 size={12} /> {t("alerts_ack_all")} </button> </div> </header> @@ -411,7 +440,7 @@ export default function Alerts() { fontFamily: "DM Mono, monospace", flexShrink: 0, cursor: "pointer", - background: filter === key ? "var(--surface-2)" : "transparent", + background: filter === key ? "var(--surface)" : "transparent", border: `1px solid ${filter === key ? "var(--border-bright)" : "transparent"}`, color: filter === key ? color || "var(--text)" : "var(--text-3)", @@ -491,8 +520,8 @@ export default function Alerts() { </div> <div style={{ color: "var(--text-2)" }}> {alerts.length === 0 - ? "No data loaded — connect your farm and run some cycles" - : "All clear for the selected filter"} + ? t("alerts_empty_nodata") + : t("alerts_empty_connected")} </div> </div> ) : ( @@ -516,7 +545,9 @@ export default function Alerts() { marginBottom: 12, }} > - UNACKNOWLEDGED · {filtered.filter((a) => !a.ack).length} + {t("alerts_unacked", { + n: filtered.filter((a) => !a.ack).length, + })} </div> <div style={{ display: "flex", flexDirection: "column", gap: 8 }} @@ -529,6 +560,8 @@ export default function Alerts() { alert={a} onAck={ack} onDismiss={dismiss} + t={t} + td={td} /> ))} </div> @@ -546,7 +579,9 @@ export default function Alerts() { marginBottom: 12, }} > - ACKNOWLEDGED · {filtered.filter((a) => a.ack).length} + {t("alerts_acknowledged", { + n: filtered.filter((a) => a.ack).length, + })} </div> <div style={{ display: "flex", flexDirection: "column", gap: 8 }} @@ -559,6 +594,8 @@ export default function Alerts() { alert={a} onAck={ack} onDismiss={dismiss} + t={t} + td={td} /> ))} </div> diff --git a/frontend/src/pages/Analytics.jsx b/frontend/src/pages/Analytics.jsx @@ -17,6 +17,7 @@ import { PolarAngleAxis, } from "recharts"; import { useFarmData } from "../hooks/useFarmData"; +import { useT } from "../hooks/useTranslation"; import { extractSensors, avg, @@ -55,8 +56,7 @@ const CustomTooltip = ({ active, payload, label }) => { ); }; -// Metric Card -function MetricCard({ label, value, unit, change, color, loading }) { +function MetricCard({ label, value, unit, change, color, loading, t }) { const up = change > 0; const flat = change === 0; return ( @@ -115,7 +115,8 @@ function MetricCard({ label, value, unit, change, color, loading }) { color: flat ? "var(--text-3)" : up ? "var(--green)" : "var(--red)", }} > - {Math.abs(change)}% vs prior + {Math.abs(change)} + {t("analytics_vs_prior")} </span> </div> </div> @@ -140,7 +141,7 @@ function SectionHead({ label, title }) { ); } -function EmptyChart({ height = 180, message = "No data yet" }) { +function EmptyChart({ height = 180, message }) { return ( <div style={{ @@ -169,6 +170,7 @@ function EmptyChart({ height = 180, message = "No data yet" }) { // MAIN export default function Analytics() { + const { t, td } = useT(); const [range, setRange] = useState("24h"); const { dashboard, history: allPoints, loading } = useFarmData(); @@ -205,7 +207,6 @@ export default function Analytics() { const activityData = useMemo(() => dailyCropActivity(allPoints), [allPoints]); const radarData = useMemo(() => buildRadar(allPoints), [allPoints]); - const agentStats = useMemo(() => buildAgentStats(allPoints), [allPoints]); const cropSummaryRows = useMemo(() => { if (!dashboard?.length) return []; return dashboard.map((item) => { @@ -215,7 +216,7 @@ export default function Analytics() { }); }, [dashboard]); - const agentRows = agentStats; + const agentRows = useMemo(() => buildAgentStats(allPoints), [allPoints]); const handleExport = () => { if (!buckets.length) return; @@ -263,11 +264,14 @@ export default function Analytics() { }} > <div> - <h1 className="page-title">Analytics</h1> + <h1 className="page-title">{t("analytics_title")}</h1> <p className="page-subtitle"> {loading - ? "Loading…" - : `${allPoints.length} data points across ${dashboard.length} crops`} + ? t("common_loading") + : t("analytics_subtitle", { + points: allPoints.length, + crops: dashboard.length, + })} </p> </div> <div @@ -313,7 +317,7 @@ export default function Analytics() { cursor: "pointer", }} > - <Download size={12} /> Export CSV + <Download size={12} /> {t("analytics_export")} </button> </div> </header> @@ -339,31 +343,34 @@ export default function Analytics() { > <MetricCard loading={loading} - label="AVG pH" + label={t("analytics_avg_ph")} value={latestSensors.ph} unit="" change={safePct(latestSensors.ph, prevSensors.ph)} color="var(--green)" + t={t} /> <MetricCard loading={loading} - label="AVG EC" + label={t("analytics_avg_ec")} value={latestSensors.ec} unit="dS/m" change={safePct(latestSensors.ec, prevSensors.ec)} color="var(--amber)" + t={t} /> <MetricCard loading={loading} - label="AVG TEMP" + label={t("analytics_avg_temp")} value={latestSensors.temp} unit="°C" change={safePct(latestSensors.temp, prevSensors.temp)} color="var(--blue)" + t={t} /> <MetricCard loading={loading} - label="TOTAL SEQUENCES" + label={t("analytics_total_seq")} value={allPoints.length} unit="" change={safePct( @@ -371,6 +378,7 @@ export default function Analytics() { Math.max(allPoints.length - dashboard.length, 1), )} color="var(--text)" + t={t} /> </div> @@ -380,20 +388,22 @@ export default function Analytics() { > {[ { - title: "pH Over Time", + title: t("analytics_ph_over_time"), key: "ph", stroke: "var(--green)", gradId: "phGradA", gradColor: "#4ade80", + name: t("chart_ph"), }, { - title: "EC Concentration", + title: t("analytics_ec_conc"), key: "ec", stroke: "var(--amber)", gradId: "ecGradA", gradColor: "#f59e0b", + name: t("chart_ec"), }, - ].map(({ title, key, stroke, gradId, gradColor }) => ( + ].map(({ title, key, stroke, gradId, gradColor, name }) => ( <div key={key} style={{ @@ -404,11 +414,11 @@ export default function Analytics() { }} > <SectionHead - label={`${range.toUpperCase()} TRACE`} + label={t("analytics_trace", { range: range.toUpperCase() })} title={title} /> {buckets.length < 2 ? ( - <EmptyChart message="Not enough data for this range" /> + <EmptyChart message={t("analytics_no_data_range")} /> ) : ( <ResponsiveContainer width="100%" height={180}> <AreaChart data={buckets}> @@ -460,7 +470,7 @@ export default function Analytics() { fill={`url(#${gradId})`} strokeWidth={2} dot={false} - name={key.toUpperCase()} + name={name} /> </AreaChart> </ResponsiveContainer> @@ -479,11 +489,11 @@ export default function Analytics() { }} > <SectionHead - label={`${range.toUpperCase()} TRACE`} - title="Temperature & Humidity" + label={t("analytics_trace", { range: range.toUpperCase() })} + title={t("analytics_temp_hum")} /> {buckets.length < 2 ? ( - <EmptyChart message="Not enough data for this range" /> + <EmptyChart message={t("analytics_no_data_range")} /> ) : ( <ResponsiveContainer width="100%" height={180}> <LineChart data={buckets}> @@ -534,7 +544,7 @@ export default function Analytics() { stroke="#60a5fa" strokeWidth={2} dot={false} - name="Temp °C" + name={t("chart_temp")} /> <Line yAxisId="right" @@ -543,7 +553,7 @@ export default function Analytics() { stroke="#a78bfa" strokeWidth={2} dot={false} - name="Humidity %" + name={t("chart_humidity")} /> </LineChart> </ResponsiveContainer> @@ -563,11 +573,14 @@ export default function Analytics() { }} > <SectionHead - label="DAILY ACTIVITY" - title="Sequences Logged per Day" + label={t("analytics_daily_act")} + title={t("analytics_seq_per_day")} /> {activityData.length < 2 ? ( - <EmptyChart height={180} message="Need 2+ days of data" /> + <EmptyChart + height={180} + message={t("analytics_no_data_days")} + /> ) : ( <ResponsiveContainer width="100%" height={180}> <BarChart data={activityData} barGap={4}> @@ -601,7 +614,7 @@ export default function Analytics() { dataKey="count" fill="#2d7a44" radius={[4, 4, 0, 0]} - name="Sequences" + name={t("chart_sequences")} /> </BarChart> </ResponsiveContainer> @@ -617,11 +630,14 @@ export default function Analytics() { }} > <SectionHead - label="PARAMETER HEALTH" - title="In-Range Score (%)" + label={t("analytics_param_health")} + title={t("analytics_in_range_score")} /> {radarData.length < 2 ? ( - <EmptyChart height={180} message="Not enough data points" /> + <EmptyChart + height={180} + message={t("analytics_no_data_points")} + /> ) : ( <ResponsiveContainer width="100%" height={180}> <RadarChart @@ -644,7 +660,7 @@ export default function Analytics() { stroke="var(--green)" fill="rgba(74,222,128,0.15)" strokeWidth={2} - name="In-range %" + name={t("chart_in_range")} /> <Tooltip content={<CustomTooltip />} /> </RadarChart> @@ -669,7 +685,10 @@ export default function Analytics() { borderBottom: "1px solid var(--border)", }} > - <SectionHead label="PER CROP" title="Latest Sensor Summary" /> + <SectionHead + label={t("analytics_per_crop")} + title={t("analytics_latest_sensor")} + /> </div> {loading ? ( <div style={{ padding: 32, textAlign: "center" }}> @@ -680,7 +699,7 @@ export default function Analytics() { color: "var(--text-3)", }} > - Loading… + {t("common_loading")} </span> </div> ) : cropSummaryRows.length === 0 ? ( @@ -693,7 +712,7 @@ export default function Analytics() { color: "var(--text-3)", }} > - No crops found in database + {t("dash_no_crops")} </div> ) : ( <table @@ -703,13 +722,13 @@ export default function Analytics() { <thead> <tr> {[ - "Crop ID", - "Type", - "Stage", - "pH", - "EC", - "Temp", - "Sequences", + t("analytics_th_crop_id"), + t("analytics_th_type"), + t("analytics_th_stage"), + t("analytics_th_ph"), + t("analytics_th_ec"), + t("analytics_th_temp"), + t("analytics_th_seq"), ].map((h) => ( <th key={h}>{h}</th> ))} @@ -737,7 +756,7 @@ export default function Analytics() { > {p.crop_id || "—"} </td> - <td>{p.crop || "—"}</td> + <td>{td(p.crop) || "—"}</td> <td style={{ color: "var(--text-3)", @@ -745,7 +764,7 @@ export default function Analytics() { fontFamily: "DM Mono, monospace", }} > - {p.stage || "—"} + {td(p.stage) || "—"} </td> <td> <span @@ -822,8 +841,8 @@ export default function Analytics() { }} > <SectionHead - label="DERIVED FROM STORED ACTIONS" - title="Agent Activity" + label={t("analytics_derived_act")} + title={t("analytics_agent_act")} /> </div> <table @@ -832,11 +851,14 @@ export default function Analytics() { > <thead> <tr> - {["Agent", "Appearances", "Success Rate", "Status"].map( - (h) => ( - <th key={h}>{h}</th> - ), - )} + {[ + t("analytics_th_agent"), + t("analytics_th_apps"), + t("analytics_th_success"), + t("analytics_th_status"), + ].map((h) => ( + <th key={h}>{h}</th> + ))} </tr> </thead> <tbody> @@ -921,7 +943,7 @@ export default function Analytics() { border: "1px solid rgba(74,222,128,0.2)", }} > - ONLINE + {t("analytics_online")} </span> </td> </tr> diff --git a/frontend/src/pages/CropDetails.jsx b/frontend/src/pages/CropDetails.jsx @@ -34,6 +34,7 @@ import { } from "../components/AgentWidgets"; import Sidebar from "../components/Sidebar"; import { useSettings } from "../hooks/useSettings"; +import { useT } from "../hooks/useTranslation"; const CustomTooltip = ({ active, payload, label }) => { if (!active || !payload?.length) return null; @@ -113,8 +114,7 @@ function logDotColor(payload) { return "var(--green)"; } -// Explanation block -function ExplanationLogBlock({ log }) { +function ExplanationLogBlock({ log, t, td }) { const [expanded, setExpanded] = useState(false); const isPending = @@ -153,7 +153,7 @@ function ExplanationLogBlock({ log }) { <Brain size={14} style={{ color: "#a78bfa" }} /> </div> <div className="section-label" style={{ marginBottom: 0 }}> - AI DECISION REASONING + {t("details_ai_reasoning")} </div> <span style={{ @@ -177,15 +177,14 @@ function ExplanationLogBlock({ log }) { fontStyle: "italic", }} > - Explanation will be generated after the agent completes its first - analysis cycle for this crop. + {t("details_pending_exp")} </div> </div> ); } - // Parse log to detect numbered steps for highlighting - const lines = log.split("\n").filter((l) => l.trim()); + const translatedLog = td(log); + const lines = translatedLog.split("\n").filter((l) => l.trim()); const isStructured = lines.some((l) => /^\d+\./.test(l.trim())); // Preview: first 3 lines @@ -230,7 +229,7 @@ function ExplanationLogBlock({ log }) { </div> <div style={{ flex: 1 }}> <div className="section-label" style={{ marginBottom: 0 }}> - AI DECISION REASONING + {t("details_ai_reasoning")} </div> <div style={{ @@ -240,7 +239,7 @@ function ExplanationLogBlock({ log }) { marginTop: 2, }} > - Chain-of-thought log generated by the Explainer agent + {t("details_chain_thought")} </div> </div> {hasMore && ( @@ -262,11 +261,11 @@ function ExplanationLogBlock({ log }) { > {expanded ? ( <> - <ChevronUp size={11} /> Collapse + <ChevronUp size={11} /> {t("details_collapse")} </> ) : ( <> - <ChevronDown size={11} /> Expand + <ChevronDown size={11} /> {t("details_expand")} </> )} </button> @@ -384,7 +383,9 @@ function ExplanationLogBlock({ log }) { color: "#a78bfa", }} > - + {lines.length - previewLines.length} more lines + {t("details_more_lines", { + n: lines.length - previewLines.length, + })} </button> )} </div> @@ -402,7 +403,7 @@ function ExplanationLogBlock({ log }) { overflow: expanded ? "visible" : "hidden", }} > - {log} + {translatedLog} </pre> )} </div> @@ -410,18 +411,19 @@ function ExplanationLogBlock({ log }) { ); } -const TABS = ["overview", "sensors", "log"]; +const TABS = ["details_tab_overview", "details_tab_sensors", "details_tab_log"]; export default function CropDetails() { const { cropId } = useParams(); const navigate = useNavigate(); const { settings } = useSettings(); + const { t, td } = useT(); const logLimit = settings.historyLogLimit ?? 20; const [history, setHistory] = useState([]); const [latest, setLatest] = useState(null); const [loading, setLoading] = useState(true); - const [activeTab, setActiveTab] = useState("overview"); + const [activeTab, setActiveTab] = useState("details_tab_overview"); const [showExp, setShowExp] = useState(false); useEffect(() => { @@ -465,7 +467,7 @@ export default function CropDetails() { color: "var(--text-3)", }} > - Loading crop data… + {t("common_loading")} </span> </div> </div> @@ -485,7 +487,9 @@ export default function CropDetails() { justifyContent: "center", }} > - <span style={{ color: "var(--text-3)" }}>Crop not found</span> + <span style={{ color: "var(--text-3)" }}> + {t("details_not_found")} + </span> </div> </div> ); @@ -558,7 +562,7 @@ export default function CropDetails() { <div> <h1 className="page-title"> - {p.crop || "Unknown"}{" "} + {td(p.crop) || t("common_unknown")}{" "} <span style={{ color: "var(--text-3)", @@ -570,7 +574,7 @@ export default function CropDetails() { </span> </h1> <p className="page-subtitle"> - {cropId} · {p.stage} + {cropId} · {td(p.stage)} </p> </div> @@ -598,7 +602,7 @@ export default function CropDetails() { background: "var(--green)", }} /> - LIVE + {t("details_live")} </div> {/* Tabs */} @@ -620,7 +624,7 @@ export default function CropDetails() { border: `1px solid ${activeTab === tab ? "var(--border-bright)" : "transparent"}`, }} > - {tab} + {t(tab)} </button> ))} </div> @@ -637,8 +641,7 @@ export default function CropDetails() { gap: 20, }} > - {/* OVERVIEW */} - {activeTab === "overview" && ( + {activeTab === "details_tab_overview" && ( <> {/* Sensor stats */} <div @@ -650,28 +653,28 @@ export default function CropDetails() { > <StatBox icon={Thermometer} - label="Temperature" + label={t("sensor_temp")} value={formatNumber(sensors.temp)} unit="°C" color="var(--blue)" /> <StatBox icon={Droplet} - label="pH Level" + label={t("sensor_ph")} value={formatNumber(sensors.ph)} unit="" color="var(--green)" /> <StatBox icon={Activity} - label="EC" + label={t("sensor_ec")} value={formatNumber(sensors.ec)} unit="dS/m" color="var(--amber)" /> <StatBox icon={Wind} - label="Humidity" + label={t("sensor_humidity")} value={formatNumber(sensors.humidity)} unit="%" color="#a78bfa" @@ -686,11 +689,7 @@ export default function CropDetails() { strategicIntent={p.strategic_intent} /> )} - - {/* Explanation Log */} - <ExplanationLogBlock log={p.explanation_log} /> - - {/* pH chart */} + <ExplanationLogBlock log={p.explanation_log} t={t} td={td} /> <div style={{ borderRadius: 14, @@ -699,7 +698,7 @@ export default function CropDetails() { border: "1px solid var(--border)", }} > - <div className="section-label">HISTORICAL pH TRACE</div> + <div className="section-label">{t("details_hist_ph")}</div> <ResponsiveContainer width="100%" height={200}> <AreaChart data={chartData}> <defs> @@ -749,7 +748,7 @@ export default function CropDetails() { fill="url(#phGradCD)" strokeWidth={2} dot={false} - name="pH" + name={t("chart_ph")} /> </AreaChart> </ResponsiveContainer> @@ -766,7 +765,7 @@ export default function CropDetails() { }} > <div className="section-label" style={{ marginBottom: 16 }}> - LATEST ACTUATOR COMMAND + {t("details_latest_cmd")} </div> <AgentActionWidget actionTaken={p.action_taken} @@ -777,8 +776,7 @@ export default function CropDetails() { </> )} - {/* SENSORS */} - {activeTab === "sensors" && ( + {activeTab === "details_tab_sensors" && ( <> <div style={{ @@ -788,7 +786,7 @@ export default function CropDetails() { border: "1px solid var(--border)", }} > - <div className="section-label">TEMP &amp; HUMIDITY</div> + <div className="section-label">{t("details_temp_hum")}</div> <ResponsiveContainer width="100%" height={200}> <LineChart data={chartData}> <CartesianGrid @@ -822,7 +820,7 @@ export default function CropDetails() { stroke="#60a5fa" strokeWidth={2} dot={false} - name="Temp °C" + name={t("chart_temp")} /> <Line type="monotone" @@ -830,7 +828,7 @@ export default function CropDetails() { stroke="#a78bfa" strokeWidth={2} dot={false} - name="Humidity %" + name={t("chart_humidity")} /> </LineChart> </ResponsiveContainer> @@ -844,7 +842,7 @@ export default function CropDetails() { border: "1px solid var(--border)", }} > - <div className="section-label">EC CONCENTRATION</div> + <div className="section-label">{t("details_ec_conc")}</div> <ResponsiveContainer width="100%" height={180}> <AreaChart data={chartData}> <defs> @@ -893,7 +891,7 @@ export default function CropDetails() { fill="url(#ecGradCD)" strokeWidth={2} dot={false} - name="EC dS/m" + name={t("chart_ec")} /> </AreaChart> </ResponsiveContainer> @@ -901,8 +899,7 @@ export default function CropDetails() { </> )} - {/* LOG */} - {activeTab === "log" && ( + {activeTab === "details_tab_log" && ( <div style={{ borderRadius: 14, @@ -920,7 +917,10 @@ export default function CropDetails() { }} > <div className="section-label"> - EVENT LOG — {history.length} ENTRIES (showing last {logLimit}) + {t("details_event_log", { + total: history.length, + limit: logLimit, + })} </div> </div> @@ -933,7 +933,6 @@ export default function CropDetails() { const hasExplanation = h.payload?.explanation_log && h.payload.explanation_log !== "PENDING_ANALYSIS"; - return ( <div key={i} @@ -1084,8 +1083,8 @@ export default function CropDetails() { color: showExp ? "#a78bfa" : "var(--text-3)", }} > - <Brain size={9} /> - {showExp ? "Hide" : "Why?"} + <Brain size={9} />{" "} + {showExp ? t("details_hide") : t("details_why")} </button> )} </div> @@ -1124,8 +1123,8 @@ export default function CropDetails() { <Brain size={9} style={{ display: "inline", marginRight: 5 }} - /> - AI REASONING FOR THIS DECISION + />{" "} + {t("details_ai_reasoning")} </div> <pre style={{ @@ -1137,7 +1136,7 @@ export default function CropDetails() { margin: 0, }} > - {h.payload.explanation_log} + {td(h.payload.explanation_log)} </pre> </div> )} diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx @@ -2,10 +2,12 @@ import React, { useState, useEffect, useMemo } from "react"; import { useNavigate } from "react-router-dom"; import { useFarmData } from "../hooks/useFarmData"; import { useSettings } from "../hooks/useSettings"; +import { useT } from "../hooks/useTranslation"; import { extractSensors, calculateMaturity, deriveCropStatus, + isReadyToHarvest, } from "../utils/dataUtils"; import { Search, @@ -22,12 +24,24 @@ import { ChevronLeft, ChevronRight, PlusCircle, + Scissors, } from "lucide-react"; import Sidebar from "../components/Sidebar"; -const STAGES = ["All", "Seedling", "Vegetative", "Flowering", "Fruiting"]; +const STAGES_KEYS = [ + "stage_all", + "stage_seedling", + "stage_vegetative", + "stage_flowering", + "stage_fruiting", +]; const CROPS = ["All", "Lettuce", "Tomato", "Basil", "Spinach", "Cucumber"]; -const STATUSES = ["All", "Healthy", "Attention", "Critical"]; +const STATUSES_KEYS = [ + "stage_all", + "dash_healthy", + "dash_attention", + "dash_critical", +]; const STATUS_COLORS = { Healthy: { @@ -47,10 +61,18 @@ const STATUS_COLORS = { }, }; -function CropCard({ data, onClick }) { +function CropCard({ data, onClick, t, td }) { const st = STATUS_COLORS[data.status] || STATUS_COLORS.Healthy; const maturity = data.maturity || 40; + // Map backend status to translation key + const statusKey = + data.status === "Healthy" + ? "dash_healthy" + : data.status === "Attention" + ? "dash_attention" + : "dash_critical"; + return ( <div onClick={onClick} @@ -60,12 +82,47 @@ function CropCard({ data, onClick }) { overflow: "hidden", cursor: "pointer", background: "var(--surface)", - border: "1px solid var(--border)", + border: data.harvestReady + ? "2px solid rgba(245,158,11,0.5)" + : "1px solid var(--border)", + position: "relative", }} > + {/* Harvest Ready Banner */} + {data.harvestReady && ( + <div + className="harvest-badge" + style={{ + position: "absolute", + top: 0, + left: 0, + right: 0, + zIndex: 5, + padding: "5px 10px", + background: "rgba(245,158,11,0.92)", + display: "flex", + alignItems: "center", + justifyContent: "center", + gap: 6, + fontSize: 11, + fontFamily: "DM Mono, monospace", + fontWeight: 700, + color: "#1a0a00", + }} + > + <Scissors size={11} /> + {t("dash_harvest_badge")} + </div> + )} + {/* Image header */} <div - style={{ position: "relative", height: 130, background: "var(--bg-3)" }} + style={{ + position: "relative", + height: 130, + background: "var(--bg-3)", + marginTop: data.harvestReady ? 27 : 0, + }} > <div style={{ @@ -123,7 +180,7 @@ function CropCard({ data, onClick }) { border: `1px solid ${st.border}`, }} > - {data.status.toUpperCase()} + {t(statusKey).toUpperCase()} </div> {/* Seq badge */} <div @@ -154,7 +211,7 @@ function CropCard({ data, onClick }) { {/* Name */} <div> <div style={{ fontWeight: 700, fontSize: 14, color: "var(--text)" }}> - {data.name} + {td(data.name)} </div> <div style={{ @@ -164,7 +221,7 @@ function CropCard({ data, onClick }) { color: "var(--text-3)", }} > - {data.cropId} · {data.statusMsg} + {data.cropId} · {td(data.statusMsg)} </div> </div> @@ -184,13 +241,13 @@ function CropCard({ data, onClick }) { color: "var(--text-3)", }} > - Maturity + {t("dash_maturity")} </span> <span style={{ fontSize: 11, fontFamily: "DM Mono, monospace", - color: "var(--green)", + color: maturity >= 80 ? "var(--amber)" : "var(--green)", fontWeight: 600, }} > @@ -207,10 +264,10 @@ function CropCard({ data, onClick }) { height: "100%", borderRadius: 2, background: - maturity > 70 - ? "var(--green)" + maturity >= 80 + ? "var(--amber)" : maturity > 40 - ? "var(--amber)" + ? "var(--green)" : "var(--text-3)", }} /> @@ -257,11 +314,23 @@ function CropCard({ data, onClick }) { alignItems: "center", gap: 4, fontSize: 11, - color: "var(--text-3)", + color: data.harvestReady ? "var(--amber)" : "var(--text-3)", + fontWeight: data.harvestReady ? 600 : 400, }} > - <Clock size={10} /> - {data.daysLeft > 0 ? `${data.daysLeft}d left` : "Ready"} + {data.harvestReady ? ( + <> + <Scissors size={10} /> + {t("dash_ready")} + </> + ) : ( + <> + <Clock size={10} /> + {data.daysLeft > 0 + ? t("dash_days_left", { n: data.daysLeft }) + : t("dash_ready")} + </> + )} </div> <ArrowUpRight size={14} style={{ color: "var(--text-3)" }} /> </div> @@ -270,7 +339,7 @@ function CropCard({ data, onClick }) { ); } -function AddCropCard({ onClick }) { +function AddCropCard({ onClick, t }) { return ( <div onClick={onClick} @@ -315,7 +384,7 @@ function AddCropCard({ onClick }) { </div> <div style={{ textAlign: "center" }}> <div style={{ fontWeight: 700, fontSize: 14, color: "var(--green)" }}> - Add New Crop + {t("dash_add_crop")} </div> <div style={{ @@ -325,7 +394,7 @@ function AddCropCard({ onClick }) { fontFamily: "DM Mono, monospace", }} > - Start a new cycle · configure sensors + {t("add_subtitle")} </div> </div> </div> @@ -336,6 +405,7 @@ export default function Dashboard() { const navigate = useNavigate(); const { dashboard, loading, refreshData } = useFarmData(); const { settings } = useSettings(); + const { t, td } = useT(); const pageSize = settings.maxResultsPerPage || 12; const [crops, setCrops] = useState([]); @@ -343,6 +413,7 @@ export default function Dashboard() { const [filterStage, setFilterStage] = useState("All"); const [filterCrop, setFilterCrop] = useState("All"); const [filterStatus, setFilterStatus] = useState("All"); + const [filterReady, setFilterReady] = useState(false); const [showFilters, setShowFilters] = useState(false); const [page, setPage] = useState(1); @@ -364,25 +435,33 @@ export default function Dashboard() { dashboard.map((item) => { const p = item.payload || {}; const sensors = extractSensors(p); + const rawStage = p.stage || ""; + return { id: p.crop_id || item.id, cropId: p.crop_id || "—", - name: p.crop || "Unknown", - statusMsg: p.stage || "Growing", + name: p.crop || t("common_unknown"), + statusMsg: rawStage ? rawStage : t("dash_status_growing"), image: getImg(p.crop), status: deriveCropStatus(p), maturity: calculateMaturity(p.sequence_number), + harvestReady: isReadyToHarvest(p), seq: p.sequence_number, daysLeft: 30 - (p.sequence_number || 0), sensors: { temp: sensors.temp, ph: sensors.ph }, - stage: p.stage || "", + stage: rawStage, rawCrop: (p.crop || "").trim(), }; }), ); setPage(1); } - }, [dashboard]); + }, [dashboard, t]); + + const harvestReadyCrops = useMemo( + () => crops.filter((c) => c.harvestReady), + [crops], + ); const filtered = useMemo( () => @@ -392,22 +471,23 @@ export default function Dashboard() { q && !c.name.toLowerCase().includes(q) && !c.cropId.toLowerCase().includes(q) && - !c.statusMsg.toLowerCase().includes(q) + !td(c.statusMsg).toLowerCase().includes(q) ) return false; if (filterStage !== "All" && c.stage !== filterStage) return false; if (filterCrop !== "All" && c.rawCrop !== filterCrop) return false; if (filterStatus !== "All" && c.status !== filterStatus) return false; + if (filterReady && !c.harvestReady) return false; return true; }), - [crops, search, filterStage, filterCrop, filterStatus], + [crops, search, filterStage, filterCrop, filterStatus, filterReady, td], ); const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize)); const paginated = filtered.slice((page - 1) * pageSize, page * pageSize); - const activeFilters = [filterStage, filterCrop, filterStatus].filter( - (f) => f !== "All", - ).length; + const activeFilters = + [filterStage, filterCrop, filterStatus].filter((f) => f !== "All").length + + (filterReady ? 1 : 0); const summary = useMemo( () => ({ @@ -419,6 +499,10 @@ 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"]; + return ( <div style={{ @@ -452,9 +536,12 @@ export default function Dashboard() { }} > <div> - <h1 className="page-title">Crops Overview</h1> + <h1 className="page-title">{t("dash_title")}</h1> <p className="page-subtitle"> - {filtered.length} of {crops.length} crops shown + {t("dash_subtitle", { + filtered: filtered.length, + total: crops.length, + })} </p> </div> @@ -469,23 +556,23 @@ export default function Dashboard() { > {[ { - label: "Healthy", + labelKey: "dash_healthy", count: summary.healthy, color: "var(--green)", }, { - label: "Attention", + labelKey: "dash_attention", count: summary.attention, color: "var(--amber)", }, { - label: "Critical", + labelKey: "dash_critical", count: summary.critical, color: "var(--red)", }, - ].map(({ label, count, color }) => ( + ].map(({ labelKey, count, color }) => ( <div - key={label} + key={labelKey} style={{ display: "flex", alignItems: "center", @@ -500,7 +587,7 @@ export default function Dashboard() { }} > <span style={{ fontWeight: 700 }}>{count}</span> - <span style={{ opacity: 0.7 }}>{label}</span> + <span style={{ opacity: 0.7 }}>{t(labelKey)}</span> </div> ))} </div> @@ -519,12 +606,12 @@ export default function Dashboard() { fontWeight: 600, background: "var(--green)", border: "none", - color: "#0c1a0e", + color: "var(--btn-on-green)", cursor: "pointer", boxShadow: "0 0 16px rgba(74,222,128,0.2)", }} > - <PlusCircle size={15} /> Add Crop + <PlusCircle size={15} /> {t("dash_add_crop")} </button> <button @@ -546,7 +633,79 @@ export default function Dashboard() { </button> </header> - {/* Search + filter bar */} + {harvestReadyCrops.length > 0 && ( + <div + className="animate-fade-in" + style={{ + flexShrink: 0, + padding: "10px 24px", + background: "rgba(245,158,11,0.12)", + borderBottom: "1px solid rgba(245,158,11,0.3)", + display: "flex", + alignItems: "center", + gap: 12, + }} + > + <span + className="harvest-pulse" + style={{ + width: 10, + height: 10, + borderRadius: "50%", + background: "var(--amber)", + flexShrink: 0, + }} + /> + <Scissors + size={16} + style={{ color: "var(--amber)", flexShrink: 0 }} + /> + <div style={{ flex: 1 }}> + <span + style={{ fontWeight: 700, fontSize: 13, color: "var(--amber)" }} + > + {t("dash_harvest_banner", { + n: harvestReadyCrops.length, + s: harvestReadyCrops.length !== 1 ? "s" : "", + })} + </span> + <span + style={{ + fontSize: 12, + color: "var(--text-3)", + marginLeft: 10, + fontFamily: "DM Mono, monospace", + }} + > + {t("dash_harvest_banner_sub")} + </span> + </div> + <button + onClick={() => { + setFilterReady(true); + setFilterStatus("All"); + setFilterStage("All"); + setFilterCrop("All"); + setSearch(""); + setPage(1); + }} + style={{ + padding: "5px 14px", + borderRadius: 8, + fontSize: 12, + fontFamily: "DM Mono, monospace", + background: "rgba(245,158,11,0.2)", + border: "1px solid rgba(245,158,11,0.4)", + color: "var(--amber)", + cursor: "pointer", + fontWeight: 600, + }} + > + {t("dash_harvest_action")} + </button> + </div> + )} + <div style={{ flexShrink: 0, @@ -576,7 +735,7 @@ export default function Dashboard() { setSearch(e.target.value); setPage(1); }} - placeholder="Search crops, IDs, stages…" + placeholder={t("dash_search_placeholder")} style={{ width: "100%", paddingLeft: 32, @@ -631,7 +790,7 @@ export default function Dashboard() { color: showFilters ? "var(--green)" : "var(--text-2)", }} > - <SlidersHorizontal size={13} /> Filters + <SlidersHorizontal size={13} /> {t("dash_filters")} {activeFilters > 0 && ( <span style={{ @@ -640,7 +799,7 @@ export default function Dashboard() { fontSize: 10, fontFamily: "DM Mono, monospace", background: "var(--green)", - color: "#0c1a0e", + color: "var(--btn-on-green)", }} > {activeFilters} @@ -650,7 +809,7 @@ export default function Dashboard() { {/* Quick stage pills */} <div style={{ display: "flex", alignItems: "center", gap: 6 }}> - {STAGES.slice(0, 4).map((s) => ( + {STAGES_EN.slice(0, 4).map((s, i) => ( <button key={s} onClick={() => { @@ -671,7 +830,7 @@ export default function Dashboard() { color: filterStage === s ? "var(--green)" : "var(--text-3)", }} > - {s} + {t(STAGES_KEYS[i])} </button> ))} </div> @@ -693,33 +852,38 @@ export default function Dashboard() { > {[ { - label: "Crop Type", + label: t("add_field_crop_type"), value: filterCrop, set: (v) => { setFilterCrop(v); setPage(1); }, opts: CROPS, + optLabels: CROPS.map((o) => + o === "All" ? t("stage_all") : td(o), + ), }, { - label: "Stage", + label: t("add_field_stage"), value: filterStage, set: (v) => { setFilterStage(v); setPage(1); }, - opts: STAGES, + opts: STAGES_EN, + optLabels: STAGES_KEYS.map((k) => t(k)), }, { - label: "Status", + label: t("analytics_th_status"), value: filterStatus, set: (v) => { setFilterStatus(v); setPage(1); }, - opts: STATUSES, + opts: STATUS_EN, + optLabels: STATUSES_KEYS.map((k) => t(k)), }, - ].map(({ label, value, set, opts }) => ( + ].map(({ label, value, set, opts, optLabels }) => ( <div key={label} style={{ display: "flex", alignItems: "center", gap: 8 }} @@ -750,9 +914,9 @@ export default function Dashboard() { outline: "none", }} > - {opts.map((o) => ( + {opts.map((o, i) => ( <option key={o} value={o}> - {o} + {optLabels ? optLabels[i] : o} </option> ))} </select> @@ -776,6 +940,7 @@ export default function Dashboard() { setFilterStage("All"); setFilterCrop("All"); setFilterStatus("All"); + setFilterReady(false); setPage(1); }} style={{ @@ -788,7 +953,7 @@ export default function Dashboard() { color: "var(--text-3)", }} > - Clear all + {t("dash_clear_all")} </button> </div> )} @@ -830,12 +995,14 @@ export default function Dashboard() { <CropCard key={crop.id} data={crop} + t={t} + td={td} onClick={() => navigate(`/crop/${crop.id}`)} /> ))} {/* Always visible at end of first page */} - {page === 1 && ( - <AddCropCard onClick={() => navigate("/add-crop")} /> + {page === 1 && !filterReady && ( + <AddCropCard onClick={() => navigate("/add-crop")} t={t} /> )} </div> @@ -883,7 +1050,10 @@ export default function Dashboard() { background: n === page ? "var(--green)" : "var(--surface)", border: `1px solid ${n === page ? "transparent" : "var(--border)"}`, - color: n === page ? "#0c1a0e" : "var(--text-2)", + color: + n === page + ? "var(--btn-on-green)" + : "var(--text-2)", fontWeight: n === page ? 700 : 400, }} > @@ -948,7 +1118,7 @@ export default function Dashboard() { color: "var(--text-2)", }} > - No crops yet + {t("dash_no_crops")} </div> <div style={{ @@ -957,7 +1127,7 @@ export default function Dashboard() { marginTop: 6, }} > - Start by adding your first crop batch + {t("dash_no_crops_sub")} </div> </div> <button @@ -972,11 +1142,11 @@ export default function Dashboard() { fontWeight: 600, background: "var(--green)", border: "none", - color: "#0c1a0e", + color: "var(--btn-on-green)", cursor: "pointer", }} > - <PlusCircle size={15} /> Add Your First Crop + <PlusCircle size={15} /> {t("dash_add_first")} </button> </> ) : ( @@ -996,7 +1166,7 @@ export default function Dashboard() { <Activity size={24} style={{ color: "var(--text-3)" }} /> </div> <div style={{ color: "var(--text-2)", fontSize: 14 }}> - No crops match your filters + {t("dash_no_match")} </div> <button onClick={() => { @@ -1004,6 +1174,8 @@ export default function Dashboard() { setFilterStage("All"); setFilterCrop("All"); setFilterStatus("All"); + setFilterReady(false); + setPage(1); }} style={{ fontSize: 12, @@ -1016,7 +1188,7 @@ export default function Dashboard() { cursor: "pointer", }} > - Clear filters + {t("dash_clear_filters")} </button> </> )} diff --git a/frontend/src/pages/FarmIntelligence.jsx b/frontend/src/pages/FarmIntelligence.jsx @@ -1,4 +1,5 @@ import { useRef, useState, useMemo, useEffect } from "react"; +import { useT } from "../hooks/useTranslation"; import { Activity, Mic, @@ -18,42 +19,37 @@ import { ChevronDown, Leaf, X, - Cpu, GitBranch, - Zap, - BarChart2, } from "lucide-react"; import { agentService } from "../api/agentApi"; -import { extractSensors } from "../utils/dataUtils"; +import { extractSensors, deriveCropStatus } from "../utils/dataUtils"; import { AgentActionWidget, AgentOutcomeWidget, } from "../components/AgentWidgets"; import Sidebar from "../components/Sidebar"; import { useFarmData } from "../hooks/useFarmData"; -import { deriveCropStatus } from "../utils/dataUtils"; // Suggestion banks -const GLOBAL_SUGGESTIONS = [ - "Show all crops", - "Which crops are critical?", - "Find crops in flowering stage", - "List recent negative outcomes", - "Show Tomato batches", - "Find crops with high EC", +const getGlobalSuggestions = (t) => [ + t("sug_g1"), + t("sug_g2"), + t("sug_g3"), + t("sug_g4"), + t("sug_g5"), + t("sug_g6"), ]; - -const CROP_SUGGESTIONS = (crop, cropId) => [ - `Why did we take the last decision for ${crop}?`, - `Explain the current action for ${cropId}`, - `Is ${crop} performing well?`, - `What should I watch out for with ${crop}?`, - `How has ${crop} been trending lately?`, - `Compare ${crop} to similar crops`, +const getCropSuggestions = (crop, t) => [ + t("sug_c1", { crop }), + t("sug_c2", { crop }), + t("sug_c3", { crop }), + t("sug_c4", { crop }), + t("sug_c5", { crop }), + t("sug_c6", { crop }), ]; // Thinking block renderer -function ThinkingBlock({ text }) { +function ThinkingBlock({ text, t }) { const [open, setOpen] = useState(false); if (!text) return null; return ( @@ -89,7 +85,7 @@ function ThinkingBlock({ text }) { flex: 1, }} > - THINKING PROCESS {open ? "▲" : "▼"} + {t("intel_thinking")} {open ? "▲" : "▼"} </span> <span style={{ @@ -98,7 +94,7 @@ function ThinkingBlock({ text }) { color: "var(--text-3)", }} > - {text.split("\n").filter(Boolean).length} steps + {t("intel_steps", { n: text.split("\n").filter(Boolean).length })} </span> </button> {open && ( @@ -127,7 +123,7 @@ function ThinkingBlock({ text }) { } // LLM Answer block -function LLMAnswerBlock({ answer, thinking, query, cropContext }) { +function LLMAnswerBlock({ answer, thinking, query, cropContext, t, td }) { if (!answer) return null; return ( @@ -175,7 +171,7 @@ function LLMAnswerBlock({ answer, thinking, query, cropContext }) { fontWeight: 600, }} > - DEMETER INTELLIGENCE + {t("intel_demeter")} </div> {cropContext && ( <div @@ -186,7 +182,8 @@ function LLMAnswerBlock({ answer, thinking, query, cropContext }) { marginTop: 1, }} > - Context: {cropContext.crop} · {cropContext.cropId} + {t("intel_context")}: {td(cropContext.crop)} ·{" "} + {cropContext.cropId} </div> )} </div> @@ -201,15 +198,12 @@ function LLMAnswerBlock({ answer, thinking, query, cropContext }) { border: "1px solid var(--border)", }} > - {cropContext ? "CROP-AWARE" : "FLEET-WIDE"} + {cropContext ? t("intel_crop_aware") : t("intel_fleet_wide")} </div> </div> <div style={{ padding: 18 }}> - {/* Thinking */} - <ThinkingBlock text={thinking} /> - - {/* Answer */} + <ThinkingBlock text={thinking} t={t} /> <div style={{ fontSize: 13, @@ -227,7 +221,7 @@ function LLMAnswerBlock({ answer, thinking, query, cropContext }) { } // Related crops card -function RelatedCropCard({ item, score }) { +function RelatedCropCard({ item, score, t, td }) { const p = item.payload || {}; const s = extractSensors(p); const status = deriveCropStatus(p); @@ -267,7 +261,7 @@ function RelatedCropCard({ item, score }) { > <div> <div style={{ fontWeight: 700, fontSize: 13, color: "var(--text)" }}> - {p.crop || "Unknown"} + {td(p.crop) || t("common_unknown")} </div> <div style={{ @@ -299,7 +293,7 @@ function RelatedCropCard({ item, score }) { border: `1px solid ${statusColor}30`, }} > - {status.toUpperCase()} + {td(status.toUpperCase())} </span> {score !== undefined && ( <span @@ -309,7 +303,7 @@ function RelatedCropCard({ item, score }) { color: scoreColor, }} > - {(score * 100).toFixed(0)}% match + {t("intel_match", { n: (score * 100).toFixed(0) })} </span> )} </div> @@ -367,7 +361,7 @@ function RelatedCropCard({ item, score }) { } // Main insight card (search results) -function InsightCard({ result, idx }) { +function InsightCard({ result, idx, t, td }) { const p = result.payload || {}; const s = extractSensors(p); const status = deriveCropStatus(p); @@ -413,7 +407,7 @@ function InsightCard({ result, idx }) { > <div> <div style={{ fontWeight: 700, fontSize: 15, color: "var(--text)" }}> - {p.crop || "Unknown"} + {td(p.crop) || t("common_unknown")} </div> <div style={{ @@ -445,7 +439,7 @@ function InsightCard({ result, idx }) { border: `1px solid ${sc.border}`, }} > - {status.toUpperCase()} + {td(status.toUpperCase())} </span> {p.stage && ( <span @@ -459,7 +453,7 @@ function InsightCard({ result, idx }) { border: "1px solid var(--border)", }} > - {p.stage} + {td(p.stage)} </span> )} </div> @@ -536,7 +530,7 @@ function InsightCard({ result, idx }) { className="section-label" style={{ fontSize: 9, marginBottom: 8 }} > - LAST COMMAND + {t("intel_last_cmd")} </div> <AgentActionWidget actionTaken={p.action_taken} compact /> </div> @@ -597,7 +591,7 @@ function FleetStat({ label, value, color, icon: Icon }) { } // Crop selector dropdown -function CropSelector({ crops, selectedCrop, onSelect, onClear }) { +function CropSelector({ crops, selectedCrop, onSelect, onClear, t, td }) { const [open, setOpen] = useState(false); const ref = useRef(null); @@ -642,8 +636,8 @@ function CropSelector({ crops, selectedCrop, onSelect, onClear }) { }} > {selectedCrop - ? `${selectedCrop.crop} · ${selectedCrop.cropId}` - : "All Crops"} + ? `${td(selectedCrop.crop)} · ${selectedCrop.cropId}` + : t("intel_all_crops_filter")} </span> </div> <div style={{ display: "flex", alignItems: "center", gap: 4 }}> @@ -696,7 +690,7 @@ function CropSelector({ crops, selectedCrop, onSelect, onClear }) { color: "var(--text-3)", }} > - SELECT CROP — {crops.length} available + {t("intel_select_crop", { n: crops.length })} </div> </div> @@ -736,10 +730,10 @@ function CropSelector({ crops, selectedCrop, onSelect, onClear }) { color: "var(--text-2)", }} > - All Crops + {t("intel_all_crops_filter")} </div> <div style={{ fontSize: 10, color: "var(--text-3)" }}> - Fleet-wide query + {t("intel_fleet_query")} </div> </div> </div> @@ -798,7 +792,7 @@ function CropSelector({ crops, selectedCrop, onSelect, onClear }) { fontWeight: isSelected ? 700 : 400, }} > - {c.crop} + {td(c.crop)} </div> <div style={{ @@ -808,7 +802,7 @@ function CropSelector({ crops, selectedCrop, onSelect, onClear }) { marginTop: 1, }} > - {c.cropId} · {c.stage} + {c.cropId} · {td(c.stage)} </div> </div> <span @@ -818,7 +812,7 @@ function CropSelector({ crops, selectedCrop, onSelect, onClear }) { color: statusColor, }} > - {c.status.toUpperCase()} + {td(c.status.toUpperCase())} </span> </div> ); @@ -832,6 +826,7 @@ function CropSelector({ crops, selectedCrop, onSelect, onClear }) { // MAIN export default function FarmIntelligence() { + const { t, td, lang } = useT(); const [textQuery, setTextQuery] = useState(""); const [loading, setLoading] = useState(false); const [results, setResults] = useState([]); @@ -856,13 +851,13 @@ export default function FarmIntelligence() { const cropList = useMemo(() => { if (!dashboard?.length) return []; return dashboard.map((d) => ({ - crop: d.payload?.crop || "Unknown", + crop: d.payload?.crop || t("common_unknown"), cropId: d.payload?.crop_id || d.id, stage: d.payload?.stage || "", status: deriveCropStatus(d.payload), payload: d.payload, })); - }, [dashboard]); + }, [dashboard, t]); const showToast = (msg, type = "success") => { setToast({ msg, type }); @@ -888,39 +883,27 @@ export default function FarmIntelligence() { // Specific crop context const p = cropCtx.payload || {}; const sensors = extractSensors(p); - - // Find history from dashboard for same cropId - const cropDashboardItems = (dashboard || []).filter( - (d) => d.payload?.crop_id === cropCtx.cropId, - ); - - return ` -CROP CONTEXT: + return `CROP CONTEXT: - Crop: ${p.crop || cropCtx.crop} - Batch ID: ${p.crop_id || cropCtx.cropId} - Growth Stage: ${p.stage || "Unknown"} - Sequence Number: ${p.sequence_number || "—"} - Last Updated: ${p.timestamp ? new Date(p.timestamp).toLocaleString() : "Unknown"} - LATEST SENSOR READINGS: - pH: ${sensors.ph} - EC: ${sensors.ec} dS/m - Temperature: ${sensors.temp}°C - Humidity: ${sensors.humidity}% - LATEST AGENT DECISION: - Action Taken: ${p.action_taken && p.action_taken !== "PENDING_ACTION" ? p.action_taken : "None recorded"} - Outcome: ${p.outcome && p.outcome !== "PENDING_OBSERVATION" ? p.outcome : "Pending"} - Reward Score: ${p.reward_score ?? "N/A"} - Strategic Intent: ${p.strategic_intent || "N/A"} - -EXPLANATION LOG (AI Decision Reasoning): +EXPLANATION LOG: ${p.explanation_log && p.explanation_log !== "PENDING_ANALYSIS" ? p.explanation_log : "Not yet generated."} - FLEET OVERVIEW (for comparison): - Total crops: ${fleetStats.total} -- Healthy: ${fleetStats.healthy}, Needs Attention: ${fleetStats.attention}, Critical: ${fleetStats.critical} -`.trim(); +- Healthy: ${fleetStats.healthy}, Needs Attention: ${fleetStats.attention}, Critical: ${fleetStats.critical}`.trim(); } else { // Fleet-wide context const cropSummaries = (dashboard || []) @@ -931,17 +914,12 @@ FLEET OVERVIEW (for comparison): return ` - ${p.crop || "?"} (${p.crop_id || d.id}): Stage=${p.stage}, pH=${s.ph}, EC=${s.ec}, Status=${deriveCropStatus(p)}, Outcome=${p.outcome || "Pending"}`; }) .join("\n"); - - return ` -FLEET OVERVIEW: + return `FLEET OVERVIEW: - Total crops: ${fleetStats.total} - Healthy: ${fleetStats.healthy}, Needs Attention: ${fleetStats.attention}, Critical: ${fleetStats.critical} - CURRENT CROPS: ${cropSummaries || "No crops in database."} - -SYSTEM: Hydroponic multi-crop farm management system (Demeter). -`.trim(); +SYSTEM: Hydroponic multi-crop farm management system (Demeter).`.trim(); } }; @@ -959,39 +937,59 @@ SYSTEM: Hydroponic multi-crop farm management system (Demeter). try { const context = buildLLMContext(selectedCrop); + const languageInstruction = + lang === "hi" + ? "Respond entirely in Hindi. Use agricultural terminology appropriate for Hindi speakers." + : "Respond entirely in English."; - const response = await fetch("https://api.anthropic.com/v1/messages", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - model: "claude-sonnet-4-20250514", - max_tokens: 1000, - thinking: { type: "enabled", budget_tokens: 5000 }, - system: `You are Demeter Intelligence, an expert AI agronomist and data analyst for a hydroponic farm management system. + const systemPrompt = `You are Demeter Intelligence, an expert AI agronomist and data analyst for a hydroponic farm management system. You have access to live farm data and must answer questions about crop health, agent decisions, and farm performance. Be specific, cite the actual numbers from the data, and be practical. Keep answers concise but thorough. -When explaining agent decisions, reference the explanation_log if available.`, +When explaining agent decisions, reference the explanation_log if available. +Before answering, wrap your step-by-step reasoning in <thinking>...</thinking> tags. +CRITICAL INSTRUCTION: ${languageInstruction}`; + + const AZURE_ENDPOINT = process.env.AZURE_OPENAI_ENDPOINT; + const AZURE_DEPLOYMENT = process.env.AZURE_OPENAI_DEPLOYMENT_NAME; + const AZURE_API_VERSION = process.env.AZURE_OPENAI_API_VERSION; + const AZURE_API_KEY = process.env.AZURE_OPENAI_API_KEY; + + const url = `${AZURE_ENDPOINT}/openai/deployments/${AZURE_DEPLOYMENT}/chat/completions?api-version=${AZURE_API_VERSION}`; + + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "api-key": AZURE_API_KEY, + }, + body: JSON.stringify({ messages: [ + { role: "system", content: systemPrompt }, { role: "user", content: `FARM DATA:\n${context}\n\nQUESTION: ${query}`, }, ], + max_tokens: 1000, + temperature: 0.2, }), }); + if (!response.ok) throw new Error("Azure OpenAI request failed"); const data = await response.json(); + const rawText = data.choices?.[0]?.message?.content || ""; + // Parse <thinking> tags to split reasoning and answer let thinking = ""; - let answer = ""; - - for (const block of data.content || []) { - if (block.type === "thinking") thinking = block.thinking; - if (block.type === "text") answer = block.text; + let answer = rawText; + const thinkingMatch = rawText.match(/<thinking>([\s\S]*?)<\/thinking>/); + if (thinkingMatch) { + thinking = thinkingMatch[1].trim(); + answer = rawText.replace(/<thinking>[\s\S]*?<\/thinking>/, "").trim(); } setLlmThinking(thinking); - setLlmAnswer(answer || "No response generated."); + setLlmAnswer(answer || t("intel_no_response")); // Also run a search to show related crops if (selectedCrop) { @@ -1010,14 +1008,12 @@ When explaining agent decisions, reference the explanation_log if available.`, })), ); } - } catch { - // Related crops are optional - } + } catch {} } } catch (e) { console.error(e); - showToast("LLM query failed", "error"); - setLlmAnswer("Failed to get a response. Please check your connection."); + showToast(t("intel_llm_fail_toast"), "error"); + setLlmAnswer(t("intel_llm_fail")); } finally { setLoading(false); } @@ -1051,7 +1047,7 @@ When explaining agent decisions, reference the explanation_log if available.`, if (data.query_logic) setQueryLogic(JSON.stringify(data.query_logic, null, 2)); } catch { - showToast("Search failed", "error"); + showToast(t("intel_search_fail_toast"), "error"); } finally { setLoading(false); } @@ -1110,8 +1106,8 @@ When explaining agent decisions, reference the explanation_log if available.`, }; const suggestions = selectedCrop - ? CROP_SUGGESTIONS(selectedCrop.crop, selectedCrop.cropId) - : GLOBAL_SUGGESTIONS; + ? getCropSuggestions(td(selectedCrop.crop), t) + : getGlobalSuggestions(t); return ( <div @@ -1184,10 +1180,8 @@ When explaining agent decisions, reference the explanation_log if available.`, <Sparkles size={15} style={{ color: "#a78bfa" }} /> </div> <div> - <h1 className="page-title">Farm Intelligence</h1> - <p className="page-subtitle"> - Query your crops · Ask Demeter anything · Explore patterns - </p> + <h1 className="page-title">{t("intel_title")}</h1> + <p className="page-subtitle">{t("intel_subtitle")}</p> </div> {/* Mode toggle */} @@ -1203,8 +1197,8 @@ When explaining agent decisions, reference the explanation_log if available.`, }} > {[ - { key: "search", label: "Search", icon: Search }, - { key: "ask", label: "Ask AI", icon: MessageSquare }, + { key: "search", label: t("intel_search"), icon: Search }, + { key: "ask", label: t("intel_ask_ai"), icon: MessageSquare }, ].map(({ key, label, icon: Icon }) => ( <button key={key} @@ -1249,25 +1243,25 @@ When explaining agent decisions, reference the explanation_log if available.`, {/* Fleet stats */} <div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}> <FleetStat - label="Total Crops" + label={t("intel_total_crops")} value={fleetStats.total} color="var(--text-2)" icon={Database} /> <FleetStat - label="Healthy" + label={t("intel_healthy")} value={fleetStats.healthy} color="var(--green)" icon={TrendingUp} /> <FleetStat - label="Needs Attention" + label={t("intel_needs_attention")} value={fleetStats.attention} color="var(--amber)" icon={Minus} /> <FleetStat - label="Critical" + label={t("intel_critical")} value={fleetStats.critical} color="var(--red)" icon={TrendingDown} @@ -1292,38 +1286,16 @@ When explaining agent decisions, reference the explanation_log if available.`, color: "var(--text-3)", }} > - {mode === "ask" ? "ASK ABOUT:" : "FILTER BY:"} + {mode === "ask" ? t("intel_ask_about") : t("intel_filter_by")} </div> <CropSelector crops={cropList} selectedCrop={selectedCrop} onSelect={setSelectedCrop} onClear={() => setSelectedCrop(null)} + t={t} + td={td} /> - {selectedCrop && ( - <div - style={{ - display: "flex", - alignItems: "center", - gap: 6, - padding: "4px 10px", - borderRadius: 20, - background: "rgba(74,222,128,0.08)", - border: "1px solid rgba(74,222,128,0.2)", - fontSize: 10, - fontFamily: "DM Mono, monospace", - color: "var(--green)", - }} - > - <Leaf size={10} /> - Context loaded — {selectedCrop.stage} ·{" "} - {selectedCrop.status === "Healthy" - ? "✓ Healthy" - : selectedCrop.status === "Attention" - ? "⚠ Attention" - : "✕ Critical"} - </div> - )} </div> {/* Input bar */} @@ -1365,9 +1337,11 @@ When explaining agent decisions, reference the explanation_log if available.`, placeholder={ mode === "ask" ? selectedCrop - ? `Ask anything about ${selectedCrop.crop}…` - : "Ask anything about your farm — decisions, trends, comparisons…" - : "Search crops — 'Show all Tomato', 'Which are critical?', 'Find flowering stage'…" + ? t("intel_ask_placeholder_crop", { + crop: td(selectedCrop.crop), + }) + : t("intel_ask_placeholder_fleet") + : t("intel_search_placeholder") } style={{ flex: 1, @@ -1428,11 +1402,11 @@ When explaining agent decisions, reference the explanation_log if available.`, <Activity size={13} className="animate-spin" /> ) : mode === "ask" ? ( <> - <Sparkles size={12} /> Ask + <Sparkles size={12} /> {t("intel_ask_ai").split(" ")[0]} </> ) : ( <> - <Search size={12} /> Search + <Search size={12} /> {t("intel_search")} </> )} </button> @@ -1462,8 +1436,10 @@ When explaining agent decisions, reference the explanation_log if available.`, <div> <div className="section-label"> {selectedCrop - ? `SUGGESTED QUESTIONS FOR ${selectedCrop.crop.toUpperCase()}` - : "QUICK QUERIES"} + ? t("intel_suggested_crop", { + crop: td(selectedCrop.crop).toUpperCase(), + }) + : t("intel_suggested_global")} </div> <div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}> {suggestions.map((s) => ( @@ -1569,6 +1545,8 @@ When explaining agent decisions, reference the explanation_log if available.`, thinking={llmThinking} query={textQuery} cropContext={selectedCrop} + t={t} + td={td} /> {/* Related crops */} @@ -1582,7 +1560,7 @@ When explaining agent decisions, reference the explanation_log if available.`, size={10} style={{ display: "inline", marginRight: 5 }} /> - SIMILAR CROPS IN DATABASE + {t("intel_similar_crops")} </div> <div style={{ @@ -1597,6 +1575,8 @@ When explaining agent decisions, reference the explanation_log if available.`, key={r.id || i} item={r} score={r.score} + t={t} + td={td} /> ))} </div> @@ -1613,20 +1593,9 @@ When explaining agent decisions, reference the explanation_log if available.`, > <div className="section-label" style={{ margin: 0 }}> {results.length > 0 - ? `${results.length} RESULT${results.length !== 1 ? "S" : ""} FOUND` - : "NO RESULTS"} + ? t("intel_results_found", { n: results.length }) + : t("intel_no_results")} </div> - {results.length > 0 && ( - <span - style={{ - fontSize: 11, - fontFamily: "DM Mono, monospace", - color: "var(--text-3)", - }} - > - for "{textQuery}" - </span> - )} {queryLogic && ( <button onClick={() => setShowQueryLogic(!showQueryLogic)} @@ -1646,11 +1615,12 @@ When explaining agent decisions, reference the explanation_log if available.`, }} > <BookOpen size={11} />{" "} - {showQueryLogic ? "Hide" : "View"} query logic + {showQueryLogic + ? t("intel_hide_logic") + : t("intel_view_logic")} </button> )} </div> - {showQueryLogic && queryLogic && ( <div className="animate-fade-in" @@ -1661,7 +1631,9 @@ When explaining agent decisions, reference the explanation_log if available.`, border: "1px solid var(--border)", }} > - <div className="section-label">QDRANT FILTER</div> + <div className="section-label"> + {t("intel_logic_header")} + </div> <pre style={{ fontSize: 12, @@ -1676,7 +1648,6 @@ When explaining agent decisions, reference the explanation_log if available.`, </pre> </div> )} - {results.length === 0 ? ( <div style={{ @@ -1691,41 +1662,6 @@ When explaining agent decisions, reference the explanation_log if available.`, border: "1px dashed var(--border)", }} > - <div - style={{ - width: 52, - height: 52, - borderRadius: 16, - background: "var(--bg-3)", - border: "1px solid var(--border)", - display: "flex", - alignItems: "center", - justifyContent: "center", - }} - > - <Brain size={22} style={{ color: "var(--text-3)" }} /> - </div> - <div style={{ textAlign: "center" }}> - <div - style={{ - fontSize: 14, - fontWeight: 600, - color: "var(--text-2)", - }} - > - No crops matched - </div> - <div - style={{ - fontSize: 12, - color: "var(--text-3)", - marginTop: 6, - }} - > - Try a different query or switch to Ask AI mode for - natural language questions. - </div> - </div> <button onClick={() => setMode("ask")} style={{ @@ -1742,7 +1678,7 @@ When explaining agent decisions, reference the explanation_log if available.`, color: "#a78bfa", }} > - <Sparkles size={11} /> Try Ask AI instead + <Sparkles size={11} /> {t("intel_try_ask")} </button> </div> ) : ( @@ -1755,7 +1691,13 @@ When explaining agent decisions, reference the explanation_log if available.`, }} > {results.map((r, i) => ( - <InsightCard key={r.id} result={r} idx={i} /> + <InsightCard + key={r.id} + result={r} + idx={i} + t={t} + td={td} + /> ))} </div> )} @@ -1765,133 +1707,67 @@ When explaining agent decisions, reference the explanation_log if available.`, )} {/* Empty state */} - {!hasQueried && !loading && ( + {!hasQueried && !loading && results.length === 0 && ( <div style={{ - flex: 1, display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", - padding: 48, - gap: 20, - borderRadius: 16, + padding: 64, + gap: 16, + borderRadius: 14, background: "var(--surface)", border: "1px dashed var(--border)", + marginTop: 20, }} > <div style={{ - width: 64, - height: 64, - borderRadius: 20, - background: "rgba(167,139,250,0.1)", - border: "1px solid rgba(167,139,250,0.2)", + width: 56, + height: 56, + borderRadius: 16, + background: + mode === "ask" + ? "rgba(167,139,250,0.12)" + : "rgba(74,222,128,0.12)", + border: `1px solid ${mode === "ask" ? "rgba(167,139,250,0.3)" : "rgba(74,222,128,0.3)"}`, display: "flex", alignItems: "center", justifyContent: "center", }} > - <Sparkles size={28} style={{ color: "#a78bfa" }} /> + {mode === "ask" ? ( + <Sparkles size={24} style={{ color: "#a78bfa" }} /> + ) : ( + <Search size={24} style={{ color: "var(--green)" }} /> + )} </div> - <div style={{ textAlign: "center", maxWidth: 420 }}> + <div style={{ textAlign: "center", maxWidth: 400 }}> <div style={{ fontWeight: 700, fontSize: 16, color: "var(--text)", + marginBottom: 8, }} > {mode === "ask" - ? "Ask Demeter anything about your farm" - : "Search your crop database"} + ? t("intel_empty_ask_title") + : t("intel_empty_search_title")} </div> <div style={{ fontSize: 13, color: "var(--text-3)", - marginTop: 8, lineHeight: 1.6, }} > {mode === "ask" - ? "Select a specific crop for targeted questions, or ask fleet-wide questions. The AI uses live sensor data, agent decisions, and explanation logs to answer." - : "Use natural language to filter crops by type, stage, status or outcome. The supervisor translates your query into precise database filters."} + ? t("intel_empty_ask_desc") + : t("intel_empty_search_desc")} </div> </div> - - {mode === "ask" && ( - <div - style={{ - display: "flex", - flexDirection: "column", - gap: 10, - width: "100%", - maxWidth: 440, - }} - > - {[ - { - icon: Cpu, - label: "Decision Reasoning", - desc: "Why did the agent take this action?", - }, - { - icon: BarChart2, - label: "Performance Analysis", - desc: "How is my crop trending?", - }, - { - icon: GitBranch, - label: "Comparative Insights", - desc: "How does this compare to other crops?", - }, - { - icon: Zap, - label: "Actionable Advice", - desc: "What should I do next?", - }, - ].map(({ icon: Icon, label, desc }) => ( - <div - key={label} - style={{ - display: "flex", - alignItems: "center", - gap: 12, - padding: "10px 16px", - borderRadius: 10, - background: "var(--bg-3)", - border: "1px solid var(--border)", - }} - > - <Icon - size={14} - style={{ color: "#a78bfa", flexShrink: 0 }} - /> - <div> - <div - style={{ - fontSize: 12, - fontWeight: 600, - color: "var(--text-2)", - }} - > - {label} - </div> - <div - style={{ - fontSize: 11, - color: "var(--text-3)", - marginTop: 1, - }} - > - {desc} - </div> - </div> - </div> - ))} - </div> - )} </div> )} </div> diff --git a/frontend/src/pages/LandingPage.jsx b/frontend/src/pages/LandingPage.jsx @@ -12,31 +12,8 @@ import { } from "lucide-react"; import { useFarmData } from "../hooks/useFarmData"; import { extractSensors, deriveCropStatus } from "../utils/dataUtils"; +import { useT } from "../hooks/useTranslation"; -const FEATURES = [ - { - icon: Cpu, - label: "Reinforcement Learning", - desc: "Contextual bandit that learns with every cycle", - }, - { - icon: Eye, - label: "Computer Vision", - desc: "Azure CV-powered disease detection", - }, - { - icon: Database, - label: "Vector Memory", - desc: "Qdrant-backed long-term plant biographies", - }, - { - icon: Zap, - label: "Physics Simulation", - desc: "LLM-based digital twin before every action", - }, -]; - -// Compute fleet-wide averages from dashboard data function computeFleetStats(dashData) { if (!dashData?.length) return null; const sensors = dashData.map((d) => extractSensors(d.payload)); @@ -98,6 +75,7 @@ function buildActivityLog(historyData) { export default function LandingPage() { const navigate = useNavigate(); + const { t, td } = useT(); const [mounted, setMounted] = useState(false); const { dashboard, history, loading } = useFarmData(); @@ -115,43 +93,69 @@ export default function LandingPage() { } }, [dashboard, history, loading]); + const FEATURES = [ + { + icon: Cpu, + label: t("landing_feature_rl"), + desc: t("landing_feature_rl_desc"), + }, + { + icon: Eye, + label: t("landing_feature_cv"), + desc: t("landing_feature_cv_desc"), + }, + { + icon: Database, + label: t("landing_feature_vector"), + desc: t("landing_feature_vector_desc"), + }, + { + icon: Zap, + label: t("landing_feature_sim"), + desc: t("landing_feature_sim_desc"), + }, + ]; + const headlineStats = stats ? [ - { val: stats.cropCount.toString(), label: "Active Crops" }, - { val: stats.cropTypes.length.toString(), label: "Crop Types" }, - { val: stats.totalSeqs.toString(), label: "Total Cycles" }, + { val: stats.cropCount.toString(), label: t("landing_active_crops") }, + { + val: stats.cropTypes.length.toString(), + label: t("landing_crop_types"), + }, + { val: stats.totalSeqs.toString(), label: t("landing_total_cycles") }, { val: stats.alerts > 0 ? stats.alerts.toString() : "0", - label: "Active Alerts", + label: t("landing_active_alerts"), }, ] : [ - { val: "—", label: "Active Crops" }, - { val: "—", label: "Crop Types" }, - { val: "—", label: "Total Cycles" }, - { val: "—", label: "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 const readings = stats ? [ { - label: "AVG pH", + label: t("analytics_avg_ph"), value: stats.ph, ok: parseFloat(stats.ph) >= 5.5 && parseFloat(stats.ph) <= 6.5, }, { - label: "AVG EC", + label: t("analytics_avg_ec"), value: `${stats.ec}`, ok: parseFloat(stats.ec) <= 2.5, }, { - label: "TEMP", + label: t("sensor_temp").toUpperCase(), value: `${stats.temp}°C`, ok: parseFloat(stats.temp) >= 18 && parseFloat(stats.temp) <= 30, }, { - label: "HUMIDITY", + label: t("sensor_humidity").toUpperCase(), value: `${stats.humidity}%`, ok: parseFloat(stats.humidity) >= 40 && @@ -159,10 +163,10 @@ export default function LandingPage() { }, ] : [ - { label: "AVG pH", value: "—", ok: true }, - { label: "AVG EC", value: "—", ok: true }, - { label: "TEMP", value: "—", ok: true }, - { label: "HUMIDITY", 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 ( @@ -204,7 +208,7 @@ export default function LandingPage() { className="text-[9px] font-mono tracking-[0.2em]" style={{ color: "var(--text-3)" }} > - AUTONOMOUS FARM INTELLIGENCE + {td("AUTONOMOUS FARM INTELLIGENCE")} </div> </div> </div> @@ -222,7 +226,11 @@ export default function LandingPage() { className="status-dot w-1.5 h-1.5 rounded-full" style={{ background: "var(--green)" }} /> - {loading ? "CONNECTING…" : stats ? "FARM ONLINE" : "NO DATA"} + {loading + ? t("sidebar_connecting") + : stats + ? t("sidebar_farm_online") + : t("sidebar_no_data")} </div> <button onClick={() => navigate("/dashboard")} @@ -233,7 +241,7 @@ export default function LandingPage() { border: "1px solid var(--border)", }} > - Dashboard + {t("landing_enter_dash")} </button> </div> </nav> @@ -254,32 +262,30 @@ export default function LandingPage() { }} > <Zap size={10} fill="currentColor" /> - MULTI-AGENT SYSTEM · LANGGRAPH · QDRANT + {td("MULTI-AGENT SYSTEM · LANGGRAPH · AZURE")} </div> <h1 className="text-6xl lg:text-7xl font-bold leading-[1.0] tracking-tight" style={{ color: "var(--text)" }} > - The Farm + {t("landing_hero_1")} <br /> <span className="font-serif italic" style={{ color: "var(--green)" }} > - Thinks + {t("landing_hero_2")} </span> <br /> - For Itself. + {t("landing_hero_3")} </h1> <p className="text-lg leading-relaxed max-w-md" style={{ color: "var(--text-2)", fontWeight: 300 }} > - Demeter is a cognitive hydroponic system. Seven specialized AI - agents collaborate to perceive, reason, and act — optimizing your - crops 24/7 without human intervention. + {t("landing_hero_sub")} </p> <div className="flex gap-4"> @@ -288,7 +294,7 @@ export default function LandingPage() { className="group flex items-center gap-3 px-7 py-3.5 rounded-xl font-semibold text-sm transition-all glow-green" style={{ background: "var(--green)", color: "#0c1a0e" }} > - Enter Dashboard{" "} + {t("landing_enter_dash")}{" "} <ArrowRight size={16} className="group-hover:translate-x-1 transition-transform" @@ -303,7 +309,7 @@ export default function LandingPage() { background: "var(--surface)", }} > - <Sparkles size={16} /> Intelligence + <Sparkles size={16} /> {t("landing_intelligence")} </button> </div> @@ -370,7 +376,7 @@ export default function LandingPage() { className="ml-2 text-[11px] font-mono" style={{ color: "var(--text-3)" }} > - demeter://live-feed + {t("landing_live_feed")} </span> <Activity size={11} @@ -415,7 +421,7 @@ export default function LandingPage() { : "rgba(248,113,113,0.6)", }} > - {ok ? "● OPTIMAL" : "● ALERT"} + {ok ? t("landing_optimal") : t("landing_alert")} </div> </div> ))} @@ -433,7 +439,7 @@ export default function LandingPage() { className="text-[10px] font-mono mb-3" style={{ color: "var(--text-3)" }} > - RECENT AGENT ACTIVITY + {t("landing_recent_activity")} </div> <div className="space-y-2"> {loading ? ( @@ -457,10 +463,10 @@ export default function LandingPage() { whiteSpace: "nowrap", }} > - {agent.substring(0, 12)} + {td(agent.substring(0, 12))} </span> <span style={{ color: "var(--text-2)" }}> - {msg.substring(0, 40)} + {td(msg.substring(0, 40))} {msg.length > 40 ? "…" : ""} </span> </div> @@ -470,8 +476,7 @@ export default function LandingPage() { className="text-[11px] font-mono" style={{ color: "var(--text-3)" }} > - No cycles recorded yet. Run the agent loop to see - activity. + {t("landing_no_cycles")} </div> )} <div @@ -495,8 +500,8 @@ export default function LandingPage() { color: "var(--amber)", }} > - ⬆ {stats.cropCount} crop{stats.cropCount !== 1 ? "s" : ""}{" "} - monitored + ⬆ {stats.cropCount} {t("common_crop").toLowerCase()} + {stats.cropCount !== 1 ? "s" : ""} </div> )} </div> @@ -511,7 +516,7 @@ export default function LandingPage() { className="text-[11px] font-mono mb-8" style={{ color: "var(--text-3)" }} > - // CORE CAPABILITIES + {t("landing_capabilities")} </div> <div className="grid grid-cols-2 lg:grid-cols-4 gap-4"> {FEATURES.map(({ icon: Icon, label, desc }) => ( diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx @@ -11,10 +11,14 @@ import { Bell, LayoutGrid, Zap, + Globe, + HelpCircle, } from "lucide-react"; import Sidebar from "../components/Sidebar"; import { useSettings } from "../hooks/useSettings"; +import { useT } from "../hooks/useTranslation"; import { USE_MOCK_DATA } from "../data/mockData"; +import Onboarding from "../components/Onboarding"; function SectionHeader({ icon: Icon, title, sub }) { return ( @@ -78,7 +82,9 @@ function FieldRow({ label, hint, children }) { {label} </label> {hint && ( - <div style={{ fontSize: 11, color: "var(--text-3)" }}>{hint}</div> + <div style={{ fontSize: 11, color: "var(--text-3)", lineHeight: 1.5 }}> + {hint} + </div> )} {children} </div> @@ -98,13 +104,58 @@ const inputStyle = { boxSizing: "border-box", }; +function Toggle({ value, onChange, enabledLabel, disabledLabel }) { + return ( + <label + style={{ + display: "flex", + alignItems: "center", + gap: 10, + cursor: "pointer", + marginTop: 6, + }} + > + <div + onClick={() => onChange(!value)} + style={{ + width: 44, + height: 24, + borderRadius: 12, + background: value ? "var(--green)" : "var(--border)", + position: "relative", + cursor: "pointer", + transition: "background 0.2s", + flexShrink: 0, + }} + > + <div + style={{ + position: "absolute", + top: 3, + left: value ? 23 : 3, + width: 18, + height: 18, + borderRadius: "50%", + background: "white", + transition: "left 0.2s", + boxShadow: "0 1px 4px rgba(0,0,0,0.3)", + }} + /> + </div> + <span style={{ fontSize: 13, color: "var(--text-2)" }}> + {value ? enabledLabel : disabledLabel} + </span> + </label> + ); +} + export default function SettingsPage() { const { settings, update, reset } = useSettings(); + const { t } = useT(); const [saved, setSaved] = useState(false); + const [showOnboarding, setShowOnboarding] = useState(false); - // Local draft so we can save all at once const [draft, setDraft] = useState({ ...settings }); - const set = (key, val) => setDraft((d) => ({ ...d, [key]: val })); const handleSave = () => { @@ -142,6 +193,39 @@ export default function SettingsPage() { </button> ); + const LangButton = ({ value, label }) => ( + <button + onClick={() => set("language", value)} + style={{ + flex: 1, + padding: "14px 12px", + borderRadius: 12, + cursor: "pointer", + display: "flex", + flexDirection: "column", + alignItems: "center", + gap: 4, + background: + draft.language === value ? "rgba(74,222,128,0.12)" : "var(--bg-3)", + border: `2px solid ${draft.language === value ? "var(--green)" : "var(--border)"}`, + color: draft.language === value ? "var(--green)" : "var(--text-3)", + transition: "all 0.15s", + }} + > + <Globe size={18} /> + <span style={{ fontSize: 13, fontWeight: 600 }}>{label}</span> + <span + style={{ + fontSize: 10, + fontFamily: "DM Mono, monospace", + opacity: 0.6, + }} + > + {value.toUpperCase()} + </span> + </button> + ); + return ( <div style={{ @@ -153,6 +237,15 @@ export default function SettingsPage() { > <Sidebar /> + {showOnboarding && ( + <Onboarding + onDone={() => { + update("onboardingDone", true); + setShowOnboarding(false); + }} + /> + )} + <main style={{ flex: 1, @@ -175,10 +268,8 @@ export default function SettingsPage() { }} > <div> - <h1 className="page-title">Settings</h1> - <p className="page-subtitle"> - Preferences, appearance &amp; account - </p> + <h1 className="page-title">{t("settings_title")}</h1> + <p className="page-subtitle">{t("settings_subtitle")}</p> </div> <div style={{ display: "flex", gap: 10 }}> <button @@ -196,7 +287,7 @@ export default function SettingsPage() { cursor: "pointer", }} > - <RotateCcw size={13} /> Reset + <RotateCcw size={13} /> {t("settings_reset")} </button> <button onClick={handleSave} @@ -210,18 +301,18 @@ export default function SettingsPage() { fontWeight: 600, background: saved ? "rgba(74,222,128,0.2)" : "var(--green)", border: saved ? "1px solid var(--green)" : "none", - color: saved ? "var(--green)" : "#0c1a0e", + color: saved ? "var(--green)" : "var(--btn-on-green)", cursor: "pointer", transition: "all 0.2s", }} > {saved ? ( <> - <Check size={13} /> Saved! + <Check size={13} /> {t("settings_saved")} </> ) : ( <> - <Save size={13} /> Save Changes + <Save size={13} /> {t("settings_save")} </> )} </button> @@ -241,8 +332,8 @@ export default function SettingsPage() { <Card> <SectionHeader icon={User} - title="Profile" - sub="Your name and role shown in the sidebar" + title={t("settings_profile")} + sub={t("settings_profile_sub")} /> <div style={{ @@ -251,7 +342,7 @@ export default function SettingsPage() { gap: 16, }} > - <FieldRow label="Display Name"> + <FieldRow label={t("settings_display_name")}> <input style={inputStyle} value={draft.userName} @@ -259,7 +350,7 @@ export default function SettingsPage() { placeholder="Your name" /> </FieldRow> - <FieldRow label="Designation"> + <FieldRow label={t("settings_designation")}> <input style={inputStyle} value={draft.userDesignation} @@ -268,8 +359,8 @@ export default function SettingsPage() { /> </FieldRow> <FieldRow - label="Initials" - hint="Shown in the sidebar avatar (max 2 chars)" + label={t("settings_initials")} + hint={t("settings_initials_hint")} > <input style={{ ...inputStyle, maxWidth: 100 }} @@ -291,77 +382,113 @@ export default function SettingsPage() { <Card> <SectionHeader icon={Sun} - title="Appearance" - sub="Theme and display options" + title={t("settings_appearance")} + sub={t("settings_appearance_sub")} /> <FieldRow - label="Theme" - hint="Controls the overall color scheme of the application" + label={t("settings_theme")} + hint={t("settings_theme_hint")} > <div style={{ display: "flex", gap: 10, marginTop: 4 }}> - <ThemeButton value="dark" label="Dark" Icon={Moon} /> - <ThemeButton value="light" label="Light" Icon={Sun} /> - <ThemeButton value="auto" label="System" Icon={Monitor} /> + <ThemeButton + value="dark" + label={t("settings_dark")} + Icon={Moon} + /> + <ThemeButton + value="light" + label={t("settings_light")} + Icon={Sun} + /> + <ThemeButton + value="auto" + label={t("settings_system")} + Icon={Monitor} + /> </div> </FieldRow> <div style={{ marginTop: 20 }}> <FieldRow - label="Compact Mode" - hint="Reduces spacing for denser information display" + label={t("settings_compact")} + hint={t("settings_compact_hint")} > - <label - style={{ - display: "flex", - alignItems: "center", - gap: 10, - cursor: "pointer", - marginTop: 6, - }} - > - <div - onClick={() => set("compactMode", !draft.compactMode)} - style={{ - width: 44, - height: 24, - borderRadius: 12, - background: draft.compactMode - ? "var(--green)" - : "var(--border)", - position: "relative", - cursor: "pointer", - transition: "background 0.2s", - flexShrink: 0, - }} - > - <div - style={{ - position: "absolute", - top: 3, - left: draft.compactMode ? 23 : 3, - width: 18, - height: 18, - borderRadius: "50%", - background: "white", - transition: "left 0.2s", - boxShadow: "0 1px 4px rgba(0,0,0,0.3)", - }} - /> - </div> - <span style={{ fontSize: 13, color: "var(--text-2)" }}> - {draft.compactMode ? "Enabled" : "Disabled"} - </span> - </label> + <Toggle + value={draft.compactMode} + onChange={(v) => set("compactMode", v)} + enabledLabel={t("common_enabled")} + disabledLabel={t("common_disabled")} + /> </FieldRow> </div> </Card> - {/* Data */} + {/* Language */} + <Card> + <SectionHeader + icon={Globe} + title={t("settings_language")} + sub={t("settings_language_sub")} + /> + <div style={{ display: "flex", gap: 12 }}> + <LangButton value="en" label={t("settings_lang_en")} /> + <LangButton value="hi" label={t("settings_lang_hi")} /> + </div> + {draft.language === "hi" && ( + <div + style={{ + marginTop: 12, + padding: "10px 14px", + borderRadius: 10, + background: "rgba(74,222,128,0.07)", + border: "1px solid rgba(74,222,128,0.2)", + fontSize: 12, + color: "var(--text-2)", + fontFamily: "Noto Sans Devanagari, sans-serif", + }} + > + हिंदी भाषा चुनी गई है। सहेजने के बाद पूरा ऐप हिंदी में दिखेगा। + </div> + )} + </Card> + + {/* Help & Onboarding */} + <Card> + <SectionHeader + icon={HelpCircle} + title={t("settings_onboarding")} + sub={t("settings_onboarding_sub")} + /> + <button + onClick={() => { + update("onboardingDone", false); + setShowOnboarding(true); + }} + style={{ + display: "flex", + alignItems: "center", + gap: 8, + padding: "10px 20px", + borderRadius: 10, + fontSize: 13, + fontWeight: 600, + background: "rgba(74,222,128,0.1)", + border: "1px solid rgba(74,222,128,0.3)", + color: "var(--green)", + cursor: "pointer", + }} + > + <HelpCircle size={14} /> + {t("settings_restart_onboarding")} + </button> + </Card> + + {/* Display */} <Card> <SectionHeader icon={LayoutGrid} - title="Display" - sub="Pagination and results" + title={t("settings_display_section")} + sub={t("settings_display_sub")} /> <div style={{ @@ -371,8 +498,8 @@ export default function SettingsPage() { }} > <FieldRow - label="Max Crops Per Page" - hint="Dashboard grid page size" + label={t("settings_max_crops")} + hint={t("settings_max_crops_hint")} > <select style={inputStyle} @@ -383,14 +510,14 @@ export default function SettingsPage() { > {[6, 8, 12, 16, 24].map((n) => ( <option key={n} value={n}> - {n} per page + {n} {t("common_per_page")} </option> ))} </select> </FieldRow> <FieldRow - label="History Log Limit" - hint="Max entries shown in crop event log" + label={t("settings_history_limit")} + hint={t("settings_history_hint")} > <select style={inputStyle} @@ -401,7 +528,7 @@ export default function SettingsPage() { > {[10, 20, 50, 100].map((n) => ( <option key={n} value={n}> - Last {n} entries + {t("common_last")} {n} {t("common_entries")} </option> ))} </select> @@ -413,56 +540,16 @@ export default function SettingsPage() { <Card> <SectionHeader icon={Bell} - title="Alerts" - sub="Notification preferences" + title={t("settings_alerts_section")} + sub={t("settings_alerts_sub")} /> - <FieldRow label="Show Acknowledged Alerts by Default"> - <label - style={{ - display: "flex", - alignItems: "center", - gap: 10, - cursor: "pointer", - marginTop: 6, - }} - > - <div - onClick={() => - set("alertsShowAcked", !draft.alertsShowAcked) - } - style={{ - width: 44, - height: 24, - borderRadius: 12, - background: draft.alertsShowAcked - ? "var(--green)" - : "var(--border)", - position: "relative", - cursor: "pointer", - transition: "background 0.2s", - flexShrink: 0, - }} - > - <div - style={{ - position: "absolute", - top: 3, - left: draft.alertsShowAcked ? 23 : 3, - width: 18, - height: 18, - borderRadius: "50%", - background: "white", - transition: "left 0.2s", - boxShadow: "0 1px 4px rgba(0,0,0,0.3)", - }} - /> - </div> - <span style={{ fontSize: 13, color: "var(--text-2)" }}> - {draft.alertsShowAcked - ? "Showing all" - : "Hiding acknowledged"} - </span> - </label> + <FieldRow label={t("settings_show_acked")}> + <Toggle + value={draft.alertsShowAcked} + onChange={(v) => set("alertsShowAcked", v)} + enabledLabel={t("common_showing_all")} + disabledLabel={t("common_hiding_acked")} + /> </FieldRow> </Card> @@ -470,8 +557,8 @@ export default function SettingsPage() { <Card> <SectionHeader icon={Database} - title="Data Source" - sub="Backend connection settings" + title={t("settings_data_source")} + sub={t("settings_data_sub")} /> <div style={{ @@ -502,8 +589,8 @@ export default function SettingsPage() { }} > {USE_MOCK_DATA - ? "Mock Data Mode" - : "Live Backend Connected"} + ? t("settings_mock_mode") + : t("settings_live_mode")} </div> <div style={{ @@ -513,7 +600,7 @@ export default function SettingsPage() { }} > {USE_MOCK_DATA - ? "To connect to live data, set USE_MOCK_DATA = false in src/data/mockData.js" + ? t("settings_mock_hint") : `Connected to ${process.env.REACT_APP_FARM_API_URL || "http://localhost:3001/api"}`} </div> </div> diff --git a/frontend/src/utils/dataUtils.js b/frontend/src/utils/dataUtils.js @@ -69,6 +69,15 @@ export const extractSensors = (payload) => { // Plant maturity (based on cycle count) export const calculateMaturity = (seq) => Math.min((seq || 1) * 10, 100); +// Returns true if the crop is ready to harvest +// Criteria: maturity >= 80% AND not in Critical status +export const isReadyToHarvest = (payload) => { + if (!payload) return false; + const maturity = calculateMaturity(payload.sequence_number); + const status = deriveCropStatus(payload); + return maturity >= 80 && status !== "Critical"; +}; + // Outcome string formatting export const formatOutcome = (outcome) => { if (!outcome || typeof outcome !== "string") return "Monitoring..."; @@ -138,20 +147,79 @@ export const deriveCropStatus = (payload) => { }; // Alert generation -function timeAgo(isoString) { - if (!isoString) return "unknown"; +function timeAgo(isoString, t) { + if (!isoString) return t("common_unknown"); const diff = Date.now() - new Date(isoString).getTime(); const mins = Math.floor(diff / 60000); - if (mins < 1) return "just now"; - if (mins < 60) return `${mins} min ago`; + if (mins < 1) return t("common_time_just_now"); + if (mins < 60) return t("common_time_min_ago", { n: mins }); const hrs = Math.floor(mins / 60); - if (hrs < 24) return `${hrs} hr ago`; + if (hrs < 24) return t("common_time_hr_ago", { n: hrs }); const days = Math.floor(hrs / 24); - if (days === 1) return "1 day ago"; - return `${days} days ago`; + if (days === 1) return t("common_time_day_ago", { n: 1 }); + return t("common_time_days_ago", { n: days }); } -export const generateAlerts = (points) => { +// Generate alerts from data points +export const generateAlerts = (points, t) => { + // Default translate + const _t = + t || + ((key, vars = {}) => { + const en = { + alert_harvest_title: "Crop ready for harvest", + alert_harvest_desc: + "{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.", + 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", + alert_ec_high_desc: + "{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.", + 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", + 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", + 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", + 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_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}", + common_time_just_now: "just now", + common_time_min_ago: "{n} min ago", + common_time_hr_ago: "{n} hr ago", + common_time_day_ago: "1 day ago", + common_time_days_ago: "{n} days ago", + common_unknown: "Unknown", + }; + let str = en[key] ?? key; + Object.entries(vars).forEach(([k, v]) => { + str = str.replace(new RegExp(`\\{${k}\\}`, "g"), String(v)); + }); + return str; + }); + const alerts = []; let id = 1; @@ -168,15 +236,45 @@ export const generateAlerts = (points) => { const outcome = (payload.outcome || "").toLowerCase(); const strategy = (payload.strategic_intent || "").toUpperCase(); const seq = payload.sequence_number; + const outcomeFormatted = formatOutcome(payload.outcome); + + // HARVEST READY ALERT + 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, + isHarvestAlert: true, + titleVars: {}, + descVars: { crop: cropName, id: cropId, pct: maturity }, + }); + } // Critical if (ph > 0 && ph < 4.5) alerts.push({ id: id++, severity: "critical", - title: "pH critically low", - desc: `${cropName} (${cropId}): pH at ${ph} — immediate base dosing required.`, - time: timeAgo(ts), + 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, @@ -186,9 +284,17 @@ export const generateAlerts = (points) => { alerts.push({ id: id++, severity: "critical", - title: "pH critically high", - desc: `${cropName} (${cropId}): pH at ${ph} — acid dosing required immediately.`, - time: timeAgo(ts), + 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, @@ -199,9 +305,17 @@ export const generateAlerts = (points) => { alerts.push({ id: id++, severity: "critical", - title: "EC dangerously high", - desc: `${cropName} (${cropId}): EC at ${ec} dS/m — severe nutrient burn risk.`, - time: timeAgo(ts), + 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, @@ -212,9 +326,17 @@ export const generateAlerts = (points) => { alerts.push({ id: id++, severity: "critical", - title: "Temperature too cold", - desc: `${cropName} (${cropId}): Air temp at ${temp}°C — root damage risk.`, - time: timeAgo(ts), + 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, @@ -224,9 +346,17 @@ export const generateAlerts = (points) => { alerts.push({ id: id++, severity: "critical", - title: "Temperature too hot", - desc: `${cropName} (${cropId}): Air temp at ${temp}°C — heat stress and root rot risk.`, - time: timeAgo(ts), + 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, @@ -240,9 +370,17 @@ export const generateAlerts = (points) => { alerts.push({ id: id++, severity: "critical", - title: "Disease or pest detected", - desc: `${cropName} (${cropId}): Visual anomaly. Outcome: "${formatOutcome(payload.outcome)}"`, - time: timeAgo(ts), + 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, @@ -253,9 +391,23 @@ export const generateAlerts = (points) => { alerts.push({ id: id++, severity: "critical", - title: "Cycle failure recorded", - desc: `${cropName} (${cropId}): Seq #${seq} outcome: "${formatOutcome(payload.outcome)}"`, - time: timeAgo(ts), + 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, @@ -267,9 +419,17 @@ export const generateAlerts = (points) => { alerts.push({ id: id++, severity: "warning", - title: "pH below optimal range", - desc: `${cropName} (${cropId}): pH at ${ph}. Target 5.5–6.5.`, - time: timeAgo(ts), + 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, @@ -279,9 +439,17 @@ export const generateAlerts = (points) => { alerts.push({ id: id++, severity: "warning", - title: "pH above optimal range", - desc: `${cropName} (${cropId}): pH at ${ph}. Target 5.5–6.5.`, - time: timeAgo(ts), + 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, @@ -292,9 +460,17 @@ export const generateAlerts = (points) => { alerts.push({ id: id++, severity: "warning", - title: "EC approaching high limit", - desc: `${cropName} (${cropId}): EC at ${ec} dS/m — nutrient burn risk increasing.`, - time: timeAgo(ts), + 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, @@ -305,9 +481,17 @@ export const generateAlerts = (points) => { alerts.push({ id: id++, severity: "warning", - title: "Temperature on the low side", - desc: `${cropName} (${cropId}): ${temp}°C — slow growth expected.`, - time: timeAgo(ts), + 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, @@ -317,9 +501,17 @@ export const generateAlerts = (points) => { alerts.push({ id: id++, severity: "warning", - title: "Temperature elevated", - desc: `${cropName} (${cropId}): ${temp}°C — heat stress likely.`, - time: timeAgo(ts), + 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, @@ -330,9 +522,23 @@ export const generateAlerts = (points) => { alerts.push({ id: id++, severity: "warning", - title: "Condition deteriorating", - desc: `${cropName} (${cropId}): Seq #${seq} — "${formatOutcome(payload.outcome)}"`, - time: timeAgo(ts), + 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, @@ -340,25 +546,40 @@ export const generateAlerts = (points) => { }); // Info - if (seq && !/fail|critical|error|deteriorat|negative/.test(outcome)) + 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: `Cycle #${seq} completed`, - desc: `${cropName} (${cropId}): Sequence stored. ${strategy ? `Strategy: ${strategy}.` : ""} ${payload.reward_score != null ? `Reward: ${payload.reward_score}` : ""}`.trim(), - time: timeAgo(ts), + 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.title}|${a.crop}`; + const key = `${a.severity}|${a.titleKey}|${a.crop}`; const count = seen.get(key) || 0; if (count < 2) { deduped.push(a); @@ -367,11 +588,13 @@ export const generateAlerts = (points) => { } const sevOrder = { critical: 0, warning: 1, info: 2 }; - deduped.sort((a, b) => - sevOrder[a.severity] !== sevOrder[b.severity] + 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), - ); + : new Date(b.ts || 0) - new Date(a.ts || 0); + }); return deduped; }; diff --git a/frontend/src/utils/translations.js b/frontend/src/utils/translations.js @@ -0,0 +1,902 @@ +// Translations — English + Hindi + +const en = { + // Navigation & Sidebar + nav_crops: "Crops", + nav_alerts: "Alerts", + nav_analytics: "Analytics", + nav_intelligence: "Intelligence", + nav_settings: "Settings", + nav_system_online: "SYSTEM ONLINE", + nav_alert_status: "ALERT STATUS", + nav_crops_need_attention: "{n} crop{s} need attention", + nav_all_clear: "All clear", + nav_harvest_ready: "🌾 {n} ready to harvest", + sidebar_agri_ai: "AGRI·AI·v2", + sidebar_connecting: "CONNECTING…", + sidebar_farm_online: "FARM ONLINE", + sidebar_no_data: "NO DATA", + + // Landing Page + landing_hero_1: "The Farm", + 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.", + landing_enter_dash: "Enter Dashboard", + landing_intelligence: "Intelligence", + landing_active_crops: "Active Crops", + landing_crop_types: "Crop Types", + landing_total_cycles: "Total Cycles", + landing_active_alerts: "Active Alerts", + landing_capabilities: "// CORE CAPABILITIES", + landing_live_feed: "demeter://live-feed", + landing_feature_rl: "Reinforcement Learning", + landing_feature_rl_desc: "Contextual bandit that learns with every cycle", + landing_feature_cv: "Computer Vision", + landing_feature_cv_desc: "Azure CV-powered disease detection", + landing_feature_vector: "Vector Memory", + landing_feature_vector_desc: "Qdrant-backed long-term plant biographies", + landing_feature_sim: "Physics Simulation", + landing_feature_sim_desc: "LLM-based digital twin before every action", + landing_optimal: "● OPTIMAL", + landing_alert: "● ALERT", + landing_recent_activity: "RECENT AGENT ACTIVITY", + landing_no_cycles: + "No cycles recorded yet. Run the agent loop to see activity.", + + // Dashboard + dash_title: "Crops Overview", + dash_subtitle: "{filtered} of {total} crops shown", + dash_add_crop: "Add Crop", + dash_search_placeholder: "Search crops, IDs, stages…", + dash_filters: "Filters", + dash_clear_all: "Clear all", + dash_healthy: "Healthy", + dash_attention: "Attention", + dash_critical: "Critical", + dash_maturity: "Maturity", + dash_days_left: "{n}d left", + dash_ready: "Ready", + dash_no_crops: "No crops yet", + dash_no_crops_sub: "Start by adding your first crop batch", + dash_add_first: "Add Your First Crop", + dash_no_match: "No crops match your filters", + dash_clear_filters: "Clear filters", + dash_harvest_banner: "{n} crop{s} ready to harvest!", + dash_harvest_badge: "HARVEST READY", + dash_harvest_banner_sub: "Tap a crop card to view details and plan harvest", + dash_harvest_action: "View Ready Crops", + dash_status_growing: "Growing", + + // Crop Details + details_not_found: "Crop not found", + details_live: "LIVE", + details_tab_overview: "Overview", + details_tab_sensors: "Sensors", + details_tab_log: "Log", + details_ai_reasoning: "AI DECISION REASONING", + details_chain_thought: + "Chain-of-thought log generated by the Explainer agent", + details_expand: "Expand", + details_collapse: "Collapse", + details_pending_exp: + "Explanation will be generated after the agent completes its first analysis cycle for this crop.", + details_hist_ph: "HISTORICAL pH TRACE", + 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_hide: "Hide", + details_why: "Why?", + details_more_lines: "+ {n} more lines", + + // Agent Widgets + widget_acid: "Acid Dosage", + widget_base: "Base Dosage", + widget_nutrients: "Nutrients", + widget_fan: "Fan Speed", + widget_water: "Water Refill", + widget_ph_down: "pH Down", + widget_ph_up: "pH Up", + widget_ec_boost: "EC Boost", + widget_airflow: "Airflow", + widget_dilution: "Dilution", + outcome_improved: "IMPROVED", + outcome_deteriorated: "DETERIORATED", + outcome_stable: "STABLE", + reward_label: "Reward", + + // Stages + stage_all: "All", + stage_seedling: "Seedling", + stage_vegetative: "Vegetative", + stage_flowering: "Flowering", + stage_fruiting: "Fruiting", + + // Sensor readings + sensor_temp: "Temperature", + 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%", + + // Add Crop + add_title: "Add New Crop", + add_subtitle: + "Set up your crop, enter sensor readings, and let AI monitor it", + add_plant_image: "PLANT IMAGE", + add_drop_image: "Drop crop image here", + add_image_hint: "PNG or JPG · optional but helps AI detect disease", + add_sensor_params: "SENSOR READINGS", + add_start: "Start Monitoring", + add_running: "AI Running…", + add_run_another: "Run Another Cycle", + add_view_dashboard: "View in Dashboard →", + add_run_next: "Run Next Cycle", + add_cycle_done: "Cycle complete — crop registered ✓", + add_cycle_fail: "Failed to connect to agent pipeline", + add_cycles_done: "{n} CYCLE{s} DONE", + + // Add Crop fields + add_field_ph: "pH Level", + add_field_ec: "EC (Nutrient Strength)", + add_field_temp: "Temperature (°C)", + add_field_humidity: "Humidity (%)", + add_field_crop_type: "Crop Type", + add_field_stage: "Growth Stage", + add_field_crop_id: "Batch / Crop ID", + add_field_stage_hint: + "Seedling = just planted · Vegetative = growing leaves · Flowering = making flowers · Fruiting = making fruit", + add_field_crop_id_hint: + "Give this crop batch a unique name so you can track it easily", + add_field_crop_id_placeholder: "e.g. Batch_A1 (optional)", + + // Add Crop pipeline phases & logs + add_phase_fetch: "Fetch", + add_phase_judge: "Judge", + add_phase_strategy: "Strategy", + add_phase_research: "Research", + add_phase_plan: "Plan", + add_phase_execute: "Execute", + 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", + + // Alerts + alerts_title: "Alerts", + alerts_subtitle_loading: "Analyzing sensor history…", + alerts_subtitle: "{unacked} unacknowledged · {total} total", + alerts_ack_all: "Ack all", + 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_unacked_only: "Unacked only", + alerts_show_all: "All", + alerts_filter_harvest: "🌾 Harvest", + alerts_filter_warning: "Warning", + alerts_filter_info: "Info", + alerts_crop_label: "Crop: {crop}", + alerts_severity_critical: "CRITICAL", + alerts_severity_warning: "WARNING", + alerts_severity_info: "INFO", + alerts_severity_harvest: "HARVEST", + + // Alert titles & descriptions + alert_harvest_title: "Crop ready for harvest", + alert_harvest_desc: + "{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.", + 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", + alert_ec_high_desc: + "{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.", + 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", + 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", + 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", + 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_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}", + + // Analytics + analytics_title: "Analytics", + analytics_subtitle_loading: "Loading…", + analytics_subtitle: "{points} data points across {crops} crops", + analytics_export: "Export CSV", + analytics_avg_ph: "AVG pH", + analytics_avg_ec: "AVG EC", + analytics_avg_temp: "AVG TEMP", + analytics_total_seq: "TOTAL SEQUENCES", + analytics_vs_prior: "% vs prior", + analytics_trace: "{range} TRACE", + analytics_ph_over_time: "pH Over Time", + analytics_ec_conc: "EC Concentration", + analytics_temp_hum: "Temperature & Humidity", + analytics_daily_act: "DAILY ACTIVITY", + analytics_seq_per_day: "Sequences Logged per Day", + analytics_param_health: "PARAMETER HEALTH", + analytics_in_range_score: "In-Range Score (%)", + analytics_per_crop: "PER CROP", + analytics_latest_sensor: "Latest Sensor Summary", + analytics_derived_act: "DERIVED FROM STORED ACTIONS", + analytics_agent_act: "Agent Activity", + analytics_no_data_range: "Not enough data for this range", + analytics_no_data_days: "Need 2+ days of data", + analytics_no_data_points: "Not enough data points", + analytics_th_crop_id: "Crop ID", + analytics_th_type: "Type", + analytics_th_stage: "Stage", + analytics_th_ph: "pH", + analytics_th_ec: "EC", + analytics_th_temp: "Temp", + analytics_th_seq: "Sequences", + analytics_th_agent: "Agent", + analytics_th_apps: "Appearances", + analytics_th_success: "Success Rate", + analytics_th_status: "Status", + analytics_online: "ONLINE", + + // Charts + chart_temp: "Temp °C", + chart_humidity: "Humidity %", + chart_ec: "EC dS/m", + chart_ph: "pH", + chart_sequences: "Sequences", + chart_in_range: "In-range %", + + // Intelligence + intel_title: "Farm Intelligence", + intel_subtitle: "Query your crops · Ask Demeter anything · Explore patterns", + intel_search: "Search", + intel_ask_ai: "Ask AI", + intel_total_crops: "Total Crops", + intel_healthy: "Healthy", + intel_needs_attention: "Needs Attention", + intel_critical: "Critical", + intel_fleet_wide: "FLEET-WIDE", + intel_crop_aware: "CROP-AWARE", + intel_search_placeholder: "Search crops — 'Show all Tomato'...", + intel_ask_placeholder_fleet: + "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:", + intel_context_loaded: "Context loaded", + intel_heard: "Heard:", + intel_suggested_global: "QUICK QUERIES", + intel_suggested_crop: "SUGGESTED QUESTIONS FOR {crop}", + intel_results_found: "{n} RESULT{s} FOUND", + intel_no_results: "NO RESULTS", + intel_view_logic: "View query logic", + intel_hide_logic: "Hide query logic", + intel_qdrant_filter: "QDRANT FILTER", + intel_no_match: "No crops matched", + intel_try_ask: "Try Ask AI instead", + intel_empty_ask_title: "Ask Demeter anything about your farm", + intel_empty_search_title: "Search your crop database", + intel_empty_ask_desc: + "Select a specific crop for targeted questions, or ask fleet-wide questions. The AI uses live sensor data, agent decisions, and explanation logs to answer.", + intel_empty_search_desc: + "Use natural language to filter crops by type, stage, status or outcome. The supervisor translates your query into precise database filters.", + intel_ai_reasoning: "THINKING PROCESS", + intel_steps: "{n} steps", + intel_similar_crops: "SIMILAR CROPS IN DATABASE", + intel_match: "{n}% match", + intel_last_cmd: "LAST COMMAND", + intel_all_crops_filter: "All Crops", + intel_all_crops_desc: "Fleet-wide query", + intel_thinking: "Thinking", + intel_demeter: "Demeter", + intel_select_crop: "Select a crop ({n} total)", + intel_fleet_query: "Query across all crops", + intel_logic_header: "Query Translation Logic", + intel_query_logic: "Query Logic", + intel_no_response: "No response generated.", + intel_llm_fail: "Failed to get a response. Please check your connection.", + intel_llm_fail_toast: "LLM query failed", + intel_search_fail_toast: "Search failed", + intel_context: "Context", + + // Suggested questions + sug_g1: "Show all crops", + sug_g2: "Which crops are critical?", + sug_g3: "Find crops in flowering stage", + sug_g4: "List recent negative outcomes", + sug_g5: "Show Tomato batches", + sug_g6: "Find crops with high EC", + sug_c1: "Why did we take the last decision for {crop}?", + sug_c2: "Explain the current action for {crop}", + sug_c3: "Is {crop} performing well?", + sug_c4: "What should I watch out for with {crop}?", + sug_c5: "How has {crop} been trending lately?", + sug_c6: "Compare {crop} to similar crops", + + // Settings + settings_title: "Settings", + settings_subtitle: "Preferences, appearance & account", + settings_save: "Save Changes", + settings_reset: "Reset", + settings_saved: "Saved!", + settings_profile: "Profile", + settings_profile_sub: "Your name and role shown in the sidebar", + settings_display_name: "Display Name", + settings_designation: "Designation", + settings_initials: "Initials", + settings_initials_hint: "Shown in the sidebar avatar (max 2 chars)", + settings_appearance: "Appearance", + settings_appearance_sub: "Theme and display options", + settings_theme: "Theme", + settings_theme_hint: "Controls the overall color scheme of the application", + settings_dark: "Dark", + settings_light: "Light", + settings_system: "System", + settings_compact: "Compact Mode", + settings_compact_hint: "Reduces spacing for denser information display", + settings_language: "Language / भाषा", + settings_language_sub: "Choose your preferred display language", + settings_lang_en: "English", + settings_lang_hi: "हिंदी (Hindi)", + settings_display_section: "Display", + settings_display_sub: "Pagination and results", + settings_max_crops: "Max Crops Per Page", + settings_max_crops_hint: "Dashboard grid page size", + settings_history_limit: "History Log Limit", + settings_history_hint: "Max entries shown in crop event log", + settings_alerts_section: "Alerts", + settings_alerts_sub: "Notification preferences", + settings_show_acked: "Show Acknowledged Alerts by Default", + settings_data_source: "Data Source", + settings_data_sub: "Backend connection settings", + settings_mock_mode: "Mock Data Mode", + settings_live_mode: "Live Backend Connected", + settings_mock_hint: + "To connect to live data, set USE_MOCK_DATA = false in src/data/mockData.js", + settings_onboarding: "Help & Onboarding", + settings_onboarding_sub: "Restart the welcome guide", + settings_restart_onboarding: "Restart Guide", + + // Onboarding + onboarding_step: "Step {n} of {total}", + onboarding_skip: "Skip", + onboarding_next: "Next", + 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.", + 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", + 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.', + 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!", + onboarding_s5_title: "Harvest Time 🌾", + onboarding_s5_desc: + 'When a crop is ready to harvest, a special banner will appear on your dashboard. The crop card will show a "HARVEST READY" badge so you never miss the right moment.', + + // Common + common_loading: "Loading…", + common_back: "Back", + common_enabled: "Enabled", + common_disabled: "Disabled", + common_showing_all: "Showing all", + common_hiding_acked: "Hiding acknowledged", + common_per_page: "per page", + common_last: "Last", + common_entries: "entries", + common_live: "LIVE", + common_all: "All", + common_crop: "Crop", + common_unknown: "Unknown", + common_time_just_now: "just now", + common_time_min_ago: "{n} min ago", + common_time_hr_ago: "{n} hr ago", + common_time_day_ago: "1 day ago", + common_time_days_ago: "{n} days ago", +}; + +const hi = { + // Navigation & Sidebar + nav_crops: "फसलें", + nav_alerts: "अलर्ट", + nav_analytics: "विश्लेषण", + nav_intelligence: "बुद्धिमत्ता", + nav_settings: "सेटिंग्स", + nav_system_online: "सिस्टम चालू है", + nav_alert_status: "अलर्ट स्थिति", + nav_crops_need_attention: "{n} फसल{s} पर ध्यान दें", + nav_all_clear: "सब ठीक है", + nav_harvest_ready: "🌾 {n} कटाई के लिए तैयार", + sidebar_agri_ai: "कृषि·एआई·v2", + sidebar_connecting: "जुड़ रहा है…", + sidebar_farm_online: "फार्म ऑनलाइन", + sidebar_no_data: "कोई डेटा नहीं", + + // Landing Page + landing_hero_1: "यह फार्म", + landing_hero_2: "खुद", + landing_hero_3: "सोचता है।", + landing_hero_sub: + "डेमीटर एक संज्ञानात्मक हाइड्रोपोनिक प्रणाली है। सात विशेषज्ञ AI एजेंट एक साथ मिलकर सोचते हैं, समझते हैं और 24/7 आपकी फसलों को इष्टतम करते हैं - बिना किसी मानवीय हस्तक्षेप के।", + landing_enter_dash: "डैशबोर्ड खोलें", + landing_intelligence: "बुद्धिमत्ता", + landing_active_crops: "सक्रिय फसलें", + landing_crop_types: "फसलों के प्रकार", + landing_total_cycles: "कुल चक्र", + landing_active_alerts: "सक्रिय अलर्ट", + landing_capabilities: "// मुख्य क्षमताएं", + landing_live_feed: "डेमीटर://लाइव-फीड", + landing_feature_rl: "सुदृढीकरण सीखना (RL)", + landing_feature_rl_desc: "प्रासंगिक बैंडिट जो हर चक्र के साथ सीखता है", + landing_feature_cv: "कंप्यूटर विजन", + landing_feature_cv_desc: "Azure CV द्वारा संचालित बीमारी पहचान", + landing_feature_vector: "वेक्टर मेमोरी", + landing_feature_vector_desc: "Qdrant आधारित दीर्घकालिक पौधे की जीवनियां", + landing_feature_sim: "भौतिकी सिमुलेशन", + landing_feature_sim_desc: "हर कार्रवाई से पहले LLM-आधारित डिजिटल जुड़वां", + landing_optimal: "● इष्टतम", + landing_alert: "● अलर्ट", + landing_recent_activity: "हाल की एजेंट गतिविधि", + landing_no_cycles: "अभी तक कोई चक्र दर्ज नहीं किया गया।", + + // Dashboard + dash_title: "फसलों का अवलोकन", + dash_subtitle: "{total} में से {filtered} फसलें दिख रही हैं", + dash_add_crop: "फसल जोड़ें", + dash_search_placeholder: "फसल खोजें...", + dash_filters: "फ़िल्टर", + dash_clear_all: "सभी हटाएं", + dash_healthy: "स्वस्थ", + dash_attention: "ध्यान दें", + dash_critical: "गंभीर", + dash_maturity: "परिपक्वता", + dash_days_left: "{n} दिन बाकी", + dash_ready: "तैयार", + dash_no_crops: "कोई फसल नहीं", + dash_no_crops_sub: "अपनी पहली फसल जोड़कर शुरू करें", + dash_add_first: "पहली फसल जोड़ें", + dash_no_match: "कोई फसल नहीं मिली", + dash_clear_filters: "फ़िल्टर हटाएं", + dash_harvest_banner: "{n} फसल{s} कटाई के लिए तैयार!", + dash_harvest_badge: "कटाई तैयार", + dash_harvest_banner_sub: "फसल कार्ड दबाएं और कटाई की योजना बनाएं", + dash_harvest_action: "तैयार फसलें देखें", + dash_status_growing: "बढ़ रही है", + + // Crop Details + details_not_found: "फसल नहीं मिली", + details_live: "लाइव", + details_tab_overview: "अवलोकन", + details_tab_sensors: "सेंसर", + details_tab_log: "लॉग", + details_ai_reasoning: "AI निर्णय तर्क", + details_chain_thought: "एक्स्प्लेनर एजेंट द्वारा उत्पन्न विचार-श्रृंखला", + details_expand: "विस्तार करें", + details_collapse: "संक्षिप्त करें", + details_pending_exp: + "एजेंट के पहले विश्लेषण के बाद स्पष्टीकरण उत्पन्न किया जाएगा।", + details_hist_ph: "ऐतिहासिक pH ग्राफ", + details_latest_cmd: "नवीनतम एक्चुएटर कमांड", + details_temp_hum: "तापमान और नमी", + details_ec_conc: "EC सांद्रता", + details_event_log: + "इवेंट लॉग — {total} प्रविष्टियां (अंतिम {limit} दिखा रहे हैं)", + details_hide: "छिपाएं", + details_why: "क्यों?", + details_more_lines: "+ {n} और पंक्तियां", + + // Agent Widgets + widget_acid: "एसिड खुराक", + widget_base: "बेस खुराक", + widget_nutrients: "पोषक तत्व", + widget_fan: "पंखे की गति", + widget_water: "पानी भरना", + widget_ph_down: "pH कम करें", + widget_ph_up: "pH बढ़ाएं", + widget_ec_boost: "EC बढ़ाएं", + widget_airflow: "हवा का बहाव", + widget_dilution: "पतलापन", + outcome_improved: "सुधार हुआ", + outcome_deteriorated: "स्थिति बिगड़ी", + outcome_stable: "स्थिर", + reward_label: "इनाम", + + // Stages + stage_all: "सभी", + stage_seedling: "अंकुर", + stage_vegetative: "वानस्पतिक", + stage_flowering: "फूल", + stage_fruiting: "फल", + + // Sensor readings + sensor_temp: "तापमान", + 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%", + + // Add Crop + add_title: "नई फसल जोड़ें", + add_subtitle: "फसल सेट करें, सेंसर रीडिंग डालें और AI को निगरानी करने दें", + add_plant_image: "पौधे की तस्वीर", + add_drop_image: "फसल की तस्वीर यहाँ डालें", + add_image_hint: "PNG या JPG · वैकल्पिक, बीमारी पहचान में मदद करता है", + add_sensor_params: "सेंसर रीडिंग", + add_start: "निगरानी शुरू करें", + add_running: "AI काम कर रहा है…", + add_run_another: "दोबारा चलाएं", + add_view_dashboard: "डैशबोर्ड में देखें →", + add_run_next: "अगला चक्र चलाएं", + add_cycle_done: "चक्र पूरा — फसल दर्ज हो गई ✓", + add_cycle_fail: "एजेंट से कनेक्ट नहीं हो सका", + add_cycles_done: "{n} चक्र पूरे", + + // Add Crop fields + add_field_ph: "pH स्तर", + add_field_ec: "EC (पोषक तत्व)", + add_field_temp: "तापमान (°C)", + add_field_humidity: "नमी (%)", + add_field_crop_type: "फसल का प्रकार", + add_field_stage: "विकास अवस्था", + add_field_crop_id: "बैच / ID", + add_field_stage_hint: + "अंकुर = अभी बोया · वानस्पतिक = पत्तियां · फूल = फूल · फल = फल", + add_field_crop_id_hint: "इस फसल बैच को आसानी से ट्रैक करने के लिए एक नाम दें", + add_field_crop_id_placeholder: "जैसे Batch_A1 (वैकल्पिक)", + + // Add Crop pipeline phases + add_phase_fetch: "डेटा लाएं", + add_phase_judge: "जांच", + add_phase_strategy: "रणनीति", + add_phase_research: "अनुसंधान", + add_phase_plan: "योजना", + add_phase_execute: "कार्रवाई", + add_log_live: "एजेंट पाइपलाइन — लाइव", + add_log_done: "चक्र पूरा हुआ", + add_log_idle: "पाइपलाइन लॉग", + add_log_lines: "{n} पंक्तियां", + add_actuator_dispatched: "एक्चुएटर कमांड भेजे गए", + + // Alerts + alerts_title: "अलर्ट", + alerts_subtitle_loading: "सेंसर इतिहास की जांच हो रही है…", + alerts_subtitle: "{unacked} अनदेखे · {total} कुल", + alerts_ack_all: "सभी देखे", + alerts_unacked: "अनदेखे · {n}", + alerts_acknowledged: "देखे गए · {n}", + alerts_empty_connected: "चुने फ़िल्टर के लिए सब ठीक है", + alerts_empty_nodata: "कोई डेटा नहीं — फार्म जोड़ें और चक्र चलाएं", + alerts_unacked_only: "केवल अनदेखे", + alerts_show_all: "सभी", + alerts_filter_harvest: "🌾 कटाई", + alerts_filter_warning: "चेतावनी", + alerts_filter_info: "जानकारी", + alerts_crop_label: "फसल: {crop}", + alerts_severity_critical: "गंभीर", + alerts_severity_warning: "चेतावनी", + alerts_severity_info: "जानकारी", + alerts_severity_harvest: "कटाई", + + // Alert titles & descriptions + alert_harvest_title: "फसल कटाई के लिए तैयार", + alert_harvest_desc: "{crop} ({id}) की परिपक्वता {pct}% — कटाई का समय!", + alert_ph_low_title: "pH बहुत कम", + alert_ph_low_desc: "{crop} ({id}): pH {val} — तुरंत बेस डोज़िंग जरूरी।", + alert_ph_high_title: "pH बहुत अधिक", + alert_ph_high_desc: "{crop} ({id}): pH {val} — तुरंत एसिड डोज़िंग जरूरी।", + alert_ec_high_title: "EC खतरनाक स्तर पर", + alert_ec_high_desc: "{crop} ({id}): EC {val} dS/m — पोषक तत्व जलने का खतरा।", + alert_temp_cold_title: "तापमान बहुत कम", + alert_temp_cold_desc: + "{crop} ({id}): तापमान {val}°C — जड़ें खराब हो सकती हैं।", + alert_temp_hot_title: "तापमान बहुत अधिक", + alert_temp_hot_desc: + "{crop} ({id}): तापमान {val}°C — गर्मी का तनाव और जड़ सड़ने का खतरा।", + alert_disease_title: "बीमारी या कीट पाया गया", + alert_disease_desc: '{crop} ({id}): दृश्य असामान्यता। परिणाम: "{outcome}"', + alert_cycle_fail_title: "चक्र विफल", + alert_cycle_fail_desc: '{crop} ({id}): चक्र #{seq} परिणाम: "{outcome}"', + alert_ph_warn_low_title: "pH इष्टतम से कम", + alert_ph_warn_desc: "{crop} ({id}): pH {val}। लक्ष्य: 5.5–6.5।", + alert_ph_warn_high_title: "pH इष्टतम से अधिक", + alert_ec_warn_title: "EC सीमा के पास", + alert_ec_warn_desc: + "{crop} ({id}): EC {val} dS/m — पोषक तत्व जलने का खतरा बढ़ रहा है।", + alert_temp_warn_low_title: "तापमान थोड़ा कम", + alert_temp_warn_low_desc: "{crop} ({id}): {val}°C — धीमी वृद्धि संभव।", + alert_temp_warn_high_title: "तापमान बढ़ा हुआ", + alert_temp_warn_high_desc: "{crop} ({id}): {val}°C — गर्मी का तनाव संभव।", + alert_deteriorating_title: "स्थिति बिगड़ रही है", + alert_deteriorating_desc: '{crop} ({id}): चक्र #{seq} — "{outcome}"', + alert_cycle_done_title: "चक्र #{seq} पूरा", + alert_cycle_done_desc: "{crop} ({id}): अनुक्रम सहेजा गया। {extra}", + + // Analytics + analytics_title: "विश्लेषण", + analytics_subtitle_loading: "लोड हो रहा है…", + analytics_subtitle: "{crops} फसलों के {points} डेटा", + analytics_export: "CSV निर्यात करें", + analytics_avg_ph: "औसत pH", + analytics_avg_ec: "औसत EC", + analytics_avg_temp: "औसत तापमान", + analytics_total_seq: "कुल चक्र", + analytics_vs_prior: "% पहले से", + analytics_trace: "{range} ग्राफ", + analytics_ph_over_time: "pH समय के साथ", + analytics_ec_conc: "EC सांद्रता", + analytics_temp_hum: "तापमान और नमी", + analytics_daily_act: "दैनिक गतिविधि", + analytics_seq_per_day: "प्रति दिन दर्ज किए गए चक्र", + analytics_param_health: "पैरामीटर स्वास्थ्य", + analytics_in_range_score: "सीमा में स्कोर (%)", + analytics_per_crop: "प्रति फसल", + analytics_latest_sensor: "नवीनतम सेंसर सारांश", + analytics_derived_act: "संग्रहीत क्रियाओं से प्राप्त", + analytics_agent_act: "एजेंट गतिविधि", + analytics_no_data_range: "इस सीमा के लिए पर्याप्त डेटा नहीं है", + analytics_no_data_days: "कम से कम 2 दिन का डेटा चाहिए", + analytics_no_data_points: "पर्याप्त डेटा बिंदु नहीं हैं", + analytics_th_crop_id: "फसल ID", + analytics_th_type: "प्रकार", + analytics_th_stage: "अवस्था", + analytics_th_ph: "pH", + analytics_th_ec: "EC", + analytics_th_temp: "तापमान", + analytics_th_seq: "चक्र", + analytics_th_agent: "एजेंट", + analytics_th_apps: "उपस्थिति", + analytics_th_success: "सफलता दर", + analytics_th_status: "स्थिति", + analytics_online: "ऑनलाइन", + + // Charts + chart_temp: "तापमान °C", + chart_humidity: "नमी %", + chart_ec: "EC dS/m", + chart_ph: "pH", + chart_sequences: "चक्र", + chart_in_range: "सीमा में %", + + // Intelligence + intel_title: "फार्म बुद्धिमत्ता", + intel_subtitle: "फसलें खोजें · Demeter से पूछें · पैटर्न देखें", + intel_search: "खोजें", + intel_ask_ai: "AI से पूछें", + intel_total_crops: "कुल फसलें", + intel_healthy: "स्वस्थ", + intel_needs_attention: "ध्यान चाहिए", + intel_critical: "गंभीर", + intel_fleet_wide: "सभी फसलें", + intel_crop_aware: "फसल-विशेष", + intel_search_placeholder: "फसलें खोजें — 'टमाटर दिखाएं'...", + intel_ask_placeholder_fleet: "अपने फार्म के बारे में कुछ भी पूछें...", + intel_ask_placeholder_crop: "{crop} के बारे में कुछ भी पूछें…", + intel_filter_by: "फ़िल्टर करें:", + intel_ask_about: "इसके बारे में पूछें:", + intel_context_loaded: "संदर्भ लोड किया गया", + intel_heard: "सुना:", + intel_suggested_global: "त्वरित प्रश्न", + intel_suggested_crop: "{crop} के लिए सुझाए गए प्रश्न", + intel_results_found: "{n} परिणाम मिले", + intel_no_results: "कोई परिणाम नहीं", + intel_view_logic: "क्वेरी तर्क देखें", + intel_hide_logic: "क्वेरी तर्क छिपाएं", + intel_qdrant_filter: "Qdrant फ़िल्टर", + intel_no_match: "कोई फसल मेल नहीं खाती", + intel_try_ask: "इसके बजाय AI से पूछें", + intel_empty_ask_title: "Demeter से अपने फार्म के बारे में कुछ भी पूछें", + intel_empty_search_title: "अपने फसल डेटाबेस में खोजें", + intel_empty_ask_desc: + "लक्षित प्रश्नों के लिए एक विशिष्ट फसल का चयन करें, या पूरे फार्म के बारे में प्रश्न पूछें। AI लाइव सेंसर डेटा, एजेंट निर्णयों और स्पष्टीकरण लॉग का उपयोग करता है।", + intel_empty_search_desc: + "प्रकार, अवस्था, स्थिति या परिणाम के आधार पर फसलों को फ़िल्टर करने के लिए प्राकृतिक भाषा का उपयोग करें।", + intel_ai_reasoning: "सोचने की प्रक्रिया", + intel_steps: "{n} चरण", + intel_similar_crops: "डेटाबेस में समान फसलें", + intel_match: "{n}% मेल", + intel_last_cmd: "अंतिम कमांड", + intel_all_crops_filter: "सभी फसलें", + intel_all_crops_desc: "पूरे फार्म में खोजें", + intel_thinking: "सोच रहा है", + intel_demeter: "डेमीटर", + intel_select_crop: "फसल चुनें (कुल {n})", + intel_fleet_query: "सभी फसलों में खोजें", + intel_logic_header: "क्वेरी अनुवाद तर्क", + intel_query_logic: "क्वेरी तर्क", + intel_no_response: "कोई उत्तर उत्पन्न नहीं हुआ।", + intel_llm_fail: "उत्तर प्राप्त करने में विफल। कृपया अपना कनेक्शन जांचें।", + intel_llm_fail_toast: "LLM क्वेरी विफल", + intel_search_fail_toast: "खोज विफल", + intel_context: "संदर्भ", + + // Suggested questions + sug_g1: "सभी फसलें दिखाएं", + sug_g2: "कौन सी फसलें गंभीर स्थिति में हैं?", + sug_g3: "फूल आने की अवस्था वाली फसलें खोजें", + sug_g4: "हाल के नकारात्मक परिणाम सूचीबद्ध करें", + sug_g5: "टमाटर के बैच दिखाएं", + sug_g6: "उच्च EC वाली फसलें खोजें", + sug_c1: "हमने {crop} के लिए अंतिम निर्णय क्यों लिया?", + sug_c2: "{crop} की वर्तमान कार्रवाई की व्याख्या करें", + sug_c3: "क्या {crop} अच्छा प्रदर्शन कर रही है?", + sug_c4: "{crop} के साथ मुझे किस बात का ध्यान रखना चाहिए?", + sug_c5: "हाल ही में {crop} का रुझान कैसा रहा है?", + sug_c6: "{crop} की तुलना समान फसलों से करें", + + // Settings + settings_title: "सेटिंग्स", + settings_subtitle: "प्राथमिकताएं, दिखावट और खाता", + settings_save: "बदलाव सहेजें", + settings_reset: "रीसेट करें", + settings_saved: "सहेजा गया!", + settings_profile: "प्रोफ़ाइल", + settings_profile_sub: "साइडबार में दिखने वाला नाम और पद", + settings_display_name: "नाम", + settings_designation: "पद", + settings_initials: "संक्षिप्त नाम", + settings_initials_hint: "साइडबार में दिखता है (अधिकतम 2 अक्षर)", + settings_appearance: "दिखावट", + settings_appearance_sub: "थीम और डिस्प्ले विकल्प", + settings_theme: "थीम", + settings_theme_hint: "ऐप का रंग बदलें", + settings_dark: "गहरा", + settings_light: "हल्का", + settings_system: "सिस्टम", + settings_compact: "कॉम्पैक्ट मोड", + settings_compact_hint: "कम जगह में ज़्यादा जानकारी दिखाएं", + settings_language: "भाषा / Language", + settings_language_sub: "अपनी पसंदीदा भाषा चुनें", + settings_lang_en: "English", + settings_lang_hi: "हिंदी (Hindi)", + settings_display_section: "डिस्प्ले", + settings_display_sub: "पेज और परिणाम", + settings_max_crops: "प्रति पेज अधिकतम फसलें", + settings_max_crops_hint: "डैशबोर्ड पेज साइज़", + settings_history_limit: "इतिहास लॉग सीमा", + settings_history_hint: "फसल लॉग में अधिकतम एंट्री", + settings_alerts_section: "अलर्ट", + settings_alerts_sub: "सूचना प्राथमिकताएं", + settings_show_acked: "देखे गए अलर्ट भी दिखाएं", + settings_data_source: "डेटा स्रोत", + settings_data_sub: "बैकएंड कनेक्शन", + settings_mock_mode: "मॉक डेटा मोड", + settings_live_mode: "लाइव बैकएंड जुड़ा है", + settings_mock_hint: + "लाइव डेटा के लिए mockData.js में USE_MOCK_DATA = false करें", + settings_onboarding: "सहायता और गाइड", + settings_onboarding_sub: "स्वागत गाइड दोबारा देखें", + settings_restart_onboarding: "गाइड दोबारा शुरू करें", + + // Onboarding + onboarding_step: "चरण {n} / {total}", + onboarding_skip: "छोड़ें", + onboarding_next: "अगला", + onboarding_finish: "शुरू करें!", + onboarding_s1_title: "Demeter में आपका स्वागत है! 🌱", + onboarding_s1_desc: + "यह आपका स्मार्ट फार्म सहायक है। Demeter आपकी फसलों की स्वचालित निगरानी करता है और पानी, पोषक तत्व और तापमान को 24 घंटे नियंत्रित करता है।", + onboarding_s2_title: "आपका फसल डैशबोर्ड", + onboarding_s2_desc: + "यहाँ सभी फसलें एक साथ देखें। हर कार्ड फसल की स्वास्थ्य स्थिति दिखाता है:\n\n🟢 हरा (स्वस्थ) — सब ठीक है\n🟡 पीला (ध्यान दें) — जांच जरूरी\n🔴 लाल (गंभीर) — तुरंत कार्रवाई करें", + onboarding_s3_title: "फसल कैसे जोड़ें", + onboarding_s3_desc: + 'हरा "फसल जोड़ें" बटन दबाएं। अपने पानी के सेंसर की रीडिंग डालें — pH, EC (पोषक तत्व), तापमान और नमी। AI बाकी सब संभाल लेगा।', + onboarding_s4_title: "अलर्ट से अपडेट रहें", + onboarding_s4_desc: + "जब किसी फसल को ध्यान की जरूरत हो, अलर्ट मेनू पर लाल नंबर दिखेगा। अपनी फसलें स्वस्थ रखने के लिए रोज़ जांचें!", + onboarding_s5_title: "कटाई का समय 🌾", + onboarding_s5_desc: + "जब फसल कटाई के लिए तैयार हो, डैशबोर्ड पर एक बैनर और फसल कार्ड पर 'कटाई तैयार' बैज दिखेगा। सही समय पर कटाई कभी न चूकें!", + + // Common + common_loading: "लोड हो रहा है…", + common_back: "वापस", + common_enabled: "चालू", + common_disabled: "बंद", + common_showing_all: "सभी दिखाए", + common_hiding_acked: "देखे हुए छुपाए", + common_per_page: "प्रति पेज", + common_last: "अंतिम", + common_entries: "एंट्री", + common_live: "लाइव", + common_all: "सभी", + common_crop: "फसल", + common_unknown: "अज्ञात", + common_time_just_now: "अभी-अभी", + common_time_min_ago: "{n} मिनट पहले", + common_time_hr_ago: "{n} घंटे पहले", + common_time_day_ago: "1 दिन पहले", + common_time_days_ago: "{n} दिन पहले", +}; + +export const TRANSLATIONS = { en, hi }; + +// Translates a key, replacing {placeholder} tokens +export function translate(lang, key, vars = {}) { + const dict = TRANSLATIONS[lang] || TRANSLATIONS.en; + let str = dict[key] ?? TRANSLATIONS.en[key] ?? key; + Object.entries(vars).forEach(([k, v]) => { + str = str.replace(new RegExp(`\\{${k}\\}`, "g"), String(v)); + }); + return str; +} + +// Dynamically translates known agent explanation strings, outcomes, and crops to Hindi +export function translateDynamic(text, lang) { + if (!text || lang !== "hi") return text; + let translated = text; + + const replacements = [ + [/Observation/gi, "अवलोकन (Observation)"], + [/Precedent/gi, "पूर्व उदाहरण (Precedent)"], + [/Logic/gi, "तर्क (Logic)"], + [/Conclusion/gi, "निष्कर्ष (Conclusion)"], + [/Sensors show/gi, "सेंसर दिखा रहे हैं"], + [/within acceptable range/gi, "स्वीकार्य सीमा के भीतर"], + [/disease was detected/gi, "बीमारी पाई गई"], + [/Action Taken:/gi, "की गई कार्रवाई:"], + [/Outcome:/gi, "परिणाम:"], + [/PENDING_ANALYSIS/gi, "विश्लेषण लंबित है..."], + [/IMPROVED/gi, "सुधार हुआ"], + [/DETERIORATED/gi, "स्थिति बिगड़ी"], + [/STABLE/gi, "स्थिर"], + [/Reward:/gi, "इनाम:"], + + // Crops and Stages + [/\bLettuce\b/gi, "लेट्यूस (Lettuce)"], + [/\bTomato\b/gi, "टमाटर (Tomato)"], + [/\bCucumber\b/gi, "खीरा (Cucumber)"], + [/\bBasil\b/gi, "तुलसी (Basil)"], + [/\bSpinach\b/gi, "पालक (Spinach)"], + [/\bKale\b/gi, "केल (Kale)"], + [/\bStrawberry\b/gi, "स्ट्रॉबेरी (Strawberry)"], + [/\bPepper\b/gi, "शिमला मिर्च (Pepper)"], + [/\bSeedling\b/gi, "अंकुर (Seedling)"], + [/\bVegetative\b/gi, "वानस्पतिक (Vegetative)"], + [/\bFlowering\b/gi, "फूल (Flowering)"], + [/\bFruiting\b/gi, "फल (Fruiting)"], + [/\bUnknown\b/gi, "अज्ञात"], + [/\bHealthy\b/gi, "स्वस्थ"], + [/\bAttention\b/gi, "ध्यान दें"], + [/\bCritical\b/gi, "गंभीर"], + ]; + + replacements.forEach(([regex, replacement]) => { + translated = translated.replace(regex, replacement); + }); + + return translated; +}