demeter

Autonomous Hydroponic Intelligence
commit fd991066bf8c2045fa41aedce5b2400c9d662fa5
parent c7ef3924cce81c4dc65378794623a2f238b02279
Author: maydayv7 <maydayv7@gmail.com>
Date:   Thu, 19 Mar 2026 23:24:08 +0530

Update Frontend Styling

Diffstat:
Mfrontend/src/components/Sidebar.jsx | 354+++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------
Mfrontend/src/pages/AgentControl.jsx | 575+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
Mfrontend/src/pages/Alerts.jsx | 625+++++++++++++++++++++++++++++++++++--------------------------------------------
Mfrontend/src/pages/Analytics.jsx | 818+++++++++++++++++++++++++++++++++++++++----------------------------------------
Mfrontend/src/pages/CropDetails.jsx | 450+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
Mfrontend/src/pages/Dashboard.jsx | 479+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
Mfrontend/src/utils/dataUtils.js | 424++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
7 files changed, 2451 insertions(+), 1274 deletions(-)

diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx @@ -9,7 +9,6 @@ import { Brain, ChevronLeft, ChevronRight, - Zap, } from "lucide-react"; import { useFarmData } from "../hooks/useFarmData"; import { deriveCropStatus } from "../utils/dataUtils"; @@ -37,33 +36,64 @@ export default function Sidebar() { return ( <aside - className="flex flex-col border-r transition-all duration-300 relative" style={{ width: collapsed ? 64 : 220, background: "var(--bg-2)", - borderColor: "var(--border)", + borderRight: "1px solid var(--border)", flexShrink: 0, + display: "flex", + flexDirection: "column", + position: "relative", + transition: "width 300ms ease", + overflow: "visible", }} > {/* Logo */} <div - className="flex items-center gap-3 px-4 py-5 border-b" - style={{ borderColor: "var(--border)" }} + style={{ + display: "flex", + alignItems: "center", + gap: 12, + padding: collapsed ? "20px 0" : "20px 16px", + justifyContent: collapsed ? "center" : "flex-start", + borderBottom: "1px solid var(--border)", + height: 64, + boxSizing: "border-box", + }} > <div - className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0" - style={{ background: "linear-gradient(135deg, #2d7a44, #4ade80)" }} + style={{ + width: 32, + height: 32, + borderRadius: 10, + background: "linear-gradient(135deg, #2d7a44, #4ade80)", + display: "flex", + alignItems: "center", + justifyContent: "center", + flexShrink: 0, + }} > - <Leaf size={16} fill="white" color="white" /> + <Leaf size={15} fill="white" color="white" /> </div> {!collapsed && ( - <div className="overflow-hidden"> - <div className="font-bold text-sm" style={{ color: "var(--text)" }}> + <div style={{ overflow: "hidden" }}> + <div + style={{ + fontWeight: 700, + fontSize: 14, + color: "var(--text)", + lineHeight: 1.2, + }} + > Demeter </div> <div - className="text-[10px] font-mono" - style={{ color: "var(--text-3)" }} + style={{ + fontSize: 9, + fontFamily: "DM Mono, monospace", + color: "var(--text-3)", + letterSpacing: "0.1em", + }} > AGRI·AI·v2 </div> @@ -71,53 +101,120 @@ export default function Sidebar() { )} </div> - {/* Status bar */} + {/* Collapse toggle */} + <button + onClick={() => setCollapsed(!collapsed)} + style={{ + position: "absolute", + top: 20, + right: -12, + width: 24, + height: 24, + borderRadius: "50%", + background: "var(--bg-3)", + border: "1px solid var(--border)", + color: "var(--text-3)", + display: "flex", + alignItems: "center", + justifyContent: "center", + cursor: "pointer", + zIndex: 10, + transition: "background 0.15s", + }} + onMouseEnter={(e) => + (e.currentTarget.style.background = "var(--surface-2)") + } + onMouseLeave={(e) => (e.currentTarget.style.background = "var(--bg-3)")} + title={collapsed ? "Expand sidebar" : "Collapse sidebar"} + > + {collapsed ? <ChevronRight size={11} /> : <ChevronLeft size={11} />} + </button> + + {/* Status pill */} {!collapsed && ( - <div - className="mx-3 mt-3 px-3 py-2 rounded-lg flex items-center gap-2" - style={{ - background: "rgba(74,222,128,0.08)", - border: "1px solid rgba(74,222,128,0.2)", - }} - > - <span - className="status-dot w-1.5 h-1.5 rounded-full flex-shrink-0" - style={{ background: "var(--green)" }} - /> - <span - className="text-[10px] font-mono" - style={{ color: "var(--green)" }} + <div style={{ padding: "12px 12px 0" }}> + <div + style={{ + display: "flex", + alignItems: "center", + gap: 6, + padding: "6px 10px", + borderRadius: 8, + background: "rgba(74,222,128,0.08)", + border: "1px solid rgba(74,222,128,0.2)", + }} > - SYSTEM ONLINE - </span> + <span + className="status-dot" + style={{ + width: 6, + height: 6, + borderRadius: "50%", + background: "var(--green)", + flexShrink: 0, + }} + /> + <span + style={{ + fontSize: 10, + fontFamily: "DM Mono, monospace", + color: "var(--green)", + }} + > + SYSTEM ONLINE + </span> + </div> </div> )} {/* Nav */} - <nav className="flex-1 px-2 py-4 space-y-1"> + <nav + style={{ + flex: 1, + padding: "12px 8px", + display: "flex", + flexDirection: "column", + gap: 2, + }} + > {NAV.map(({ label, icon: Icon, path, badge }) => { const active = loc.pathname === path; return ( <Link key={label} to={path} - className="flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all relative group" + title={collapsed ? label : undefined} style={{ + display: "flex", + alignItems: "center", + gap: 10, + padding: collapsed ? "10px 0" : "10px 12px", + justifyContent: collapsed ? "center" : "flex-start", + borderRadius: 10, background: active ? "rgba(74,222,128,0.12)" : "transparent", color: active ? "var(--green)" : "var(--text-2)", border: active ? "1px solid rgba(74,222,128,0.25)" : "1px solid transparent", + textDecoration: "none", + position: "relative", + transition: "all 0.15s", }} > - <Icon size={18} className="flex-shrink-0" /> + <Icon size={17} style={{ flexShrink: 0 }} /> {!collapsed && ( - <span className="text-sm font-medium">{label}</span> + <span style={{ fontSize: 13, fontWeight: 500 }}>{label}</span> )} + {/* Badge — unread count */} {badge && !collapsed && ( <span - className="ml-auto text-[10px] font-mono px-1.5 py-0.5 rounded alert-pulse" + className="alert-pulse" style={{ + marginLeft: "auto", + fontSize: 10, + fontFamily: "DM Mono, monospace", + padding: "1px 5px", + borderRadius: 4, background: "rgba(248,113,113,0.2)", color: "var(--red)", }} @@ -127,111 +224,134 @@ export default function Sidebar() { )} {badge && collapsed && ( <span - className="absolute top-1 right-1 w-2 h-2 rounded-full alert-pulse" - style={{ background: "var(--red)" }} - /> - )} - {collapsed && ( - <div - className="absolute left-full ml-2 px-2 py-1 rounded text-xs font-medium opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-50 whitespace-nowrap" + className="alert-pulse" style={{ - background: "var(--surface-2)", - border: "1px solid var(--border)", - color: "var(--text)", + position: "absolute", + top: 4, + right: 4, + width: 7, + height: 7, + borderRadius: "50%", + background: "var(--red)", }} - > - {label} - </div> + /> )} </Link> ); })} </nav> - {/* System health */} + {/* Alert count summary */} {!collapsed && ( - <div className="p-3 border-t" style={{ borderColor: "var(--border)" }}> + <div style={{ padding: "0 12px 12px" }}> <div - className="px-3 py-2 rounded-lg" - style={{ background: "var(--surface)" }} + style={{ + padding: "10px 12px", + borderRadius: 10, + background: "var(--surface)", + border: "1px solid var(--border)", + }} > - <div className="flex items-center justify-between mb-1"> - <span - className="text-[10px] font-mono" - style={{ color: "var(--text-3)" }} - > - ALERT STATUS - </span> - <Zap - size={10} - style={{ - color: alertCount > 0 ? "var(--amber)" : "var(--green)", - }} - /> - </div> <div - className="h-1 rounded-full" - style={{ background: "var(--border)" }} - > - <div - className="h-full rounded-full" - style={{ - width: - alertCount > 0 - ? `${Math.min(alertCount * 20, 100)}%` - : "5%", - background: alertCount > 0 ? "var(--amber)" : "var(--green)", - }} - /> - </div> - <div - className="text-[10px] font-mono mt-1" - style={{ color: "var(--text-3)" }} + style={{ + fontSize: 9, + fontFamily: "DM Mono, monospace", + color: "var(--text-3)", + marginBottom: 6, + letterSpacing: "0.05em", + }} > - {alertCount > 0 - ? `${alertCount} crop(s) need attention` - : "All clear"} + ALERT STATUS </div> + {alertCount > 0 ? ( + <div style={{ display: "flex", alignItems: "center", gap: 6 }}> + <span + className="alert-pulse" + style={{ + width: 8, + height: 8, + borderRadius: "50%", + background: "var(--red)", + flexShrink: 0, + }} + /> + <span + style={{ + fontSize: 11, + fontFamily: "DM Mono, monospace", + color: "var(--red)", + }} + > + {alertCount} crop{alertCount !== 1 ? "s" : ""} need attention + </span> + </div> + ) : ( + <div style={{ display: "flex", alignItems: "center", gap: 6 }}> + <span + className="status-dot" + style={{ + width: 8, + height: 8, + borderRadius: "50%", + background: "var(--green)", + flexShrink: 0, + }} + /> + <span + style={{ + fontSize: 11, + fontFamily: "DM Mono, monospace", + color: "var(--green)", + }} + > + All clear + </span> + </div> + )} </div> </div> )} - {/* Collapse toggle */} - <button - onClick={() => setCollapsed(!collapsed)} - className="absolute -right-3 top-20 w-6 h-6 rounded-full flex items-center justify-center z-10 transition-colors" + {/* User row */} + <div style={{ - background: "var(--surface-2)", - border: "1px solid var(--border)", - color: "var(--text-3)", + padding: collapsed ? "12px 8px" : "12px 12px", + borderTop: "1px solid var(--border)", + display: "flex", + alignItems: "center", + gap: 8, + justifyContent: collapsed ? "center" : "flex-start", }} > - {collapsed ? <ChevronRight size={12} /> : <ChevronLeft size={12} />} - </button> - - {/* User */} - <div className="p-3 border-t" style={{ borderColor: "var(--border)" }}> - <div className="flex items-center gap-2"> - <div - className="w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0" - style={{ background: "var(--amber-dim)", color: "var(--amber)" }} - > - R - </div> - {!collapsed && ( - <div> - <div - className="text-xs font-semibold" - style={{ color: "var(--text)" }} - > - Rajesh Rai - </div> - <div className="text-[10px]" style={{ color: "var(--text-3)" }}> - Farm Owner - </div> - </div> - )} + <div + style={{ + width: 28, + height: 28, + borderRadius: "50%", + background: "var(--amber-dim)", + color: "var(--amber)", + display: "flex", + alignItems: "center", + justifyContent: "center", + fontSize: 11, + fontWeight: 700, + flexShrink: 0, + }} + > + R </div> + {!collapsed && ( + <div> + <div + style={{ fontSize: 12, fontWeight: 600, color: "var(--text)" }} + > + Rajesh Rai + </div> + <div style={{ fontSize: 10, color: "var(--text-3)" }}> + Farm Owner + </div> + </div> + )} </div> </aside> ); diff --git a/frontend/src/pages/AgentControl.jsx b/frontend/src/pages/AgentControl.jsx @@ -247,16 +247,28 @@ export default function AgentControl() { return ( <div - className="flex h-screen overflow-hidden" - style={{ background: "var(--bg)" }} + style={{ + display: "flex", + height: "100vh", + overflow: "hidden", + background: "var(--bg)", + }} > <Sidebar /> {/* Toast */} {toast && ( <div - className="fixed top-4 right-4 z-50 px-4 py-3 rounded-xl text-sm font-mono animate-fade-in" + className="animate-fade-in" style={{ + position: "fixed", + top: 16, + right: 16, + zIndex: 50, + padding: "10px 16px", + borderRadius: 12, + fontSize: 12, + fontFamily: "DM Mono, monospace", background: toast.type === "error" ? "rgba(248,113,113,0.15)" @@ -269,84 +281,153 @@ export default function AgentControl() { </div> )} - <main className="flex-1 flex flex-col overflow-hidden"> + <main + style={{ + flex: 1, + display: "flex", + flexDirection: "column", + overflow: "hidden", + }} + > {/* Header */} <header - className="flex-shrink-0 px-6 py-4 border-b flex items-center gap-3" - style={{ borderColor: "var(--border)", background: "var(--bg-2)" }} + style={{ + flexShrink: 0, + padding: "0 24px", + height: 64, + borderBottom: "1px solid var(--border)", + background: "var(--bg-2)", + display: "flex", + alignItems: "center", + gap: 12, + }} > <div - className="w-8 h-8 rounded-lg flex items-center justify-center" style={{ + width: 32, + height: 32, + borderRadius: 8, background: "rgba(74,222,128,0.1)", border: "1px solid rgba(74,222,128,0.2)", + display: "flex", + alignItems: "center", + justifyContent: "center", }} > <Brain size={15} style={{ color: "var(--green)" }} /> </div> <div> <h1 - className="font-bold text-base" - style={{ color: "var(--text)" }} + style={{ + fontWeight: 700, + fontSize: 15, + color: "var(--text)", + margin: 0, + }} > Agent Control </h1> <p - className="text-[11px] font-mono" - style={{ color: "var(--text-3)" }} + style={{ + fontSize: 11, + fontFamily: "DM Mono, monospace", + color: "var(--text-3)", + margin: 0, + }} > Ingest memories · Query the Supervisor · Run analysis </p> </div> </header> - <div className="flex-1 overflow-y-auto p-6"> + <div + style={{ + flex: 1, + overflowY: "auto", + padding: 24, + display: "flex", + flexDirection: "column", + gap: 20, + }} + > {/* Search bar */} <div - className="flex gap-2 mb-6 p-2 rounded-xl" style={{ + display: "flex", + gap: 8, + padding: 8, + borderRadius: 12, background: "var(--surface)", border: "1px solid var(--border)", }} > <button onClick={isRecording ? stopRecording : startRecording} - className="p-2 rounded-lg transition-all" style={{ + width: 34, + height: 34, + borderRadius: 8, + flexShrink: 0, + cursor: "pointer", background: isRecording ? "rgba(248,113,113,0.15)" : "var(--bg-3)", border: `1px solid ${isRecording ? "rgba(248,113,113,0.4)" : "var(--border)"}`, color: isRecording ? "var(--red)" : "var(--text-3)", + display: "flex", + alignItems: "center", + justifyContent: "center", }} > - {isRecording ? <Square size={14} /> : <Mic size={14} />} + {isRecording ? <Square size={13} /> : <Mic size={13} />} </button> <input value={textQuery} onChange={(e) => setTextQuery(e.target.value)} onKeyDown={(e) => e.key === "Enter" && handleTextQuery()} placeholder="Ask Demeter: 'Show all failed Lettuce crops'…" - className="flex-1 bg-transparent border-none outline-none text-sm font-mono px-2" - style={{ color: "var(--text)", caretColor: "var(--green)" }} + style={{ + flex: 1, + background: "transparent", + border: "none", + outline: "none", + fontSize: 13, + fontFamily: "DM Mono, monospace", + color: "var(--text)", + caretColor: "var(--green)", + }} /> <button onClick={handleTextQuery} - className="px-4 py-2 rounded-lg text-sm font-semibold transition-all" - style={{ background: "var(--green)", color: "#0c1a0e" }} + style={{ + padding: "6px 16px", + borderRadius: 8, + fontSize: 13, + fontWeight: 600, + background: "var(--green)", + color: "#0c1a0e", + border: "none", + cursor: "pointer", + }} > Ask </button> </div> {/* Main grid */} - <div className="grid grid-cols-1 lg:grid-cols-5 gap-6"> + <div + style={{ display: "grid", gridTemplateColumns: "2fr 3fr", gap: 20 }} + > {/* Image upload */} - <div className="lg:col-span-2"> + <div style={{ display: "flex", flexDirection: "column", gap: 10 }}> <label - className="relative block rounded-2xl overflow-hidden cursor-pointer" style={{ - height: 320, + position: "relative", + display: "block", + borderRadius: 16, + overflow: "hidden", + cursor: "pointer", + height: 300, background: "var(--surface)", border: "2px dashed var(--border)", }} @@ -354,84 +435,142 @@ export default function AgentControl() { <input type="file" onChange={handleFile} - className="absolute inset-0 opacity-0 cursor-pointer z-10" + style={{ + position: "absolute", + inset: 0, + opacity: 0, + cursor: "pointer", + zIndex: 10, + }} /> {preview ? ( - <img - src={preview} - alt="preview" - className="w-full h-full object-cover" - /> + <> + <img + src={preview} + alt="preview" + style={{ + width: "100%", + height: "100%", + objectFit: "cover", + }} + /> + <div + style={{ + position: "absolute", + inset: 0, + background: + "linear-gradient(to top, rgba(12,26,14,0.6) 0%, transparent 60%)", + }} + /> + </> ) : ( - <div className="flex flex-col items-center justify-center h-full gap-3 p-6"> + <div + style={{ + display: "flex", + flexDirection: "column", + alignItems: "center", + justifyContent: "center", + height: "100%", + gap: 12, + padding: 24, + }} + > <div - className="w-14 h-14 rounded-2xl flex items-center justify-center" style={{ + width: 48, + height: 48, + borderRadius: 16, background: "var(--bg-3)", border: "1px solid var(--border)", + display: "flex", + alignItems: "center", + justifyContent: "center", }} > - <Upload size={22} style={{ color: "var(--text-3)" }} /> + <Upload size={20} style={{ color: "var(--text-3)" }} /> </div> - <div className="text-center"> + <div style={{ textAlign: "center" }}> <div - className="font-semibold text-sm" - style={{ color: "var(--text-2)" }} + style={{ + fontWeight: 600, + fontSize: 13, + color: "var(--text-2)", + }} > Drop crop image </div> <div - className="text-xs mt-1" - style={{ color: "var(--text-3)" }} + style={{ + fontSize: 11, + marginTop: 4, + color: "var(--text-3)", + }} > PNG, JPG up to 10MB </div> </div> </div> )} - {/* Overlay gradient */} - {preview && ( - <div - className="absolute inset-0" - style={{ - background: - "linear-gradient(to top, rgba(12,26,14,0.6) 0%, transparent 60%)", - }} - /> - )} </label> - {/* Action buttons */} - <div className="grid grid-cols-2 gap-3 mt-3"> + <div + style={{ + display: "grid", + gridTemplateColumns: "1fr 1fr", + gap: 10, + }} + > <button onClick={handleIngest} disabled={loadingIngest || loadingSearch} - className="py-3 rounded-xl text-sm font-semibold flex items-center justify-center gap-2 transition-all" style={{ + padding: "10px 0", + borderRadius: 12, + fontSize: 13, + fontWeight: 600, + display: "flex", + alignItems: "center", + justifyContent: "center", + gap: 6, + cursor: "pointer", background: "var(--surface)", border: "1px solid var(--border)", color: "var(--text-2)", + opacity: loadingIngest || loadingSearch ? 0.6 : 1, }} > {loadingIngest ? ( - <Activity size={14} className="animate-spin" /> + <Activity size={13} className="animate-spin" /> ) : ( <> - <Save size={14} /> Store + <Save size={13} /> Store </> )} </button> <button onClick={handleSearch} disabled={loadingIngest || loadingSearch} - className="py-3 rounded-xl text-sm font-semibold flex items-center justify-center gap-2 transition-all" - style={{ background: "var(--green)", color: "#0c1a0e" }} + style={{ + padding: "10px 0", + borderRadius: 12, + fontSize: 13, + fontWeight: 600, + display: "flex", + alignItems: "center", + justifyContent: "center", + gap: 6, + cursor: "pointer", + background: "var(--green)", + border: "none", + color: "#0c1a0e", + opacity: loadingIngest || loadingSearch ? 0.6 : 1, + }} > {loadingSearch ? ( - <Activity size={14} className="animate-spin" /> + <Activity size={13} className="animate-spin" /> ) : ( <> - <Brain size={14} /> Analyze + <Brain size={13} /> Analyze </> )} </button> @@ -440,19 +579,30 @@ export default function AgentControl() { {/* Sensor inputs */} <div - className="lg:col-span-3 rounded-2xl p-5" style={{ + borderRadius: 16, + padding: 20, background: "var(--surface)", border: "1px solid var(--border)", }} > <div - className="text-[10px] font-mono mb-4" - style={{ color: "var(--text-3)" }} + style={{ + fontSize: 10, + fontFamily: "DM Mono, monospace", + color: "var(--text-3)", + marginBottom: 16, + }} > // SENSOR PARAMETERS </div> - <div className="grid grid-cols-2 gap-4"> + <div + style={{ + display: "grid", + gridTemplateColumns: "1fr 1fr", + gap: 14, + }} + > {INPUT_FIELDS.map( ({ label, @@ -465,17 +615,30 @@ export default function AgentControl() { }) => ( <div key={name}> <label - className="text-[10px] font-mono mb-1 block" - style={{ color }} + style={{ + fontSize: 10, + fontFamily: "DM Mono, monospace", + display: "block", + marginBottom: 5, + color, + }} > {label.toUpperCase()} </label> - <div className="relative"> - <div className="absolute left-3 top-1/2 -translate-y-1/2 z-10"> - <Icon size={13} style={{ color }} /> - </div> + <div style={{ position: "relative" }}> + <Icon + size={12} + style={{ + position: "absolute", + left: 10, + top: "50%", + transform: "translateY(-50%)", + color, + zIndex: 1, + }} + /> {type === "select" ? ( - <div className="relative"> + <> <select value={sensors[name]} onChange={(e) => @@ -484,11 +647,21 @@ export default function AgentControl() { [name]: e.target.value, }) } - className="w-full appearance-none pl-8 pr-8 py-2.5 rounded-lg text-sm font-mono outline-none" style={{ + width: "100%", + appearance: "none", + paddingLeft: 30, + paddingRight: 28, + paddingTop: 8, + paddingBottom: 8, + borderRadius: 8, + fontSize: 12, + fontFamily: "DM Mono, monospace", background: "var(--bg-3)", border: "1px solid var(--border)", color: "var(--text)", + outline: "none", + cursor: "pointer", }} > {opts.map((o) => ( @@ -498,11 +671,17 @@ export default function AgentControl() { ))} </select> <ChevronDown - size={11} - className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none" - style={{ color: "var(--text-3)" }} + size={10} + style={{ + position: "absolute", + right: 10, + top: "50%", + transform: "translateY(-50%)", + pointerEvents: "none", + color: "var(--text-3)", + }} /> - </div> + </> ) : ( <input value={sensors[name]} @@ -512,11 +691,20 @@ export default function AgentControl() { type={type} placeholder={placeholder || ""} step={type === "number" ? "0.1" : undefined} - className="w-full pl-8 pr-3 py-2.5 rounded-lg text-sm font-mono outline-none" style={{ + width: "100%", + paddingLeft: 30, + paddingRight: 10, + paddingTop: 8, + paddingBottom: 8, + borderRadius: 8, + fontSize: 12, + fontFamily: "DM Mono, monospace", background: "var(--bg-3)", border: "1px solid var(--border)", color: "var(--text)", + outline: "none", + boxSizing: "border-box", }} /> )} @@ -531,32 +719,47 @@ export default function AgentControl() { {/* Decision output */} {decision && ( <div - className="mt-6 rounded-2xl overflow-hidden animate-fade-up" + className="animate-fade-up" style={{ + borderRadius: 16, + overflow: "hidden", background: "var(--surface)", border: "1px solid rgba(74,222,128,0.3)", }} > <div - className="p-4 border-b flex items-center justify-between" style={{ - borderColor: "var(--border)", + padding: 16, + borderBottom: "1px solid var(--border)", background: "rgba(74,222,128,0.05)", + display: "flex", + alignItems: "center", + justifyContent: "space-between", }} > - <div className="flex items-center gap-2"> - <Brain size={16} style={{ color: "var(--green)" }} /> + <div style={{ display: "flex", alignItems: "center", gap: 8 }}> + <Brain size={15} style={{ color: "var(--green)" }} /> <span - className="font-semibold text-sm" - style={{ color: "var(--text)" }} + style={{ + fontWeight: 600, + fontSize: 13, + color: "var(--text)", + }} > Supervisor Command </span> </div> <button onClick={() => setShowExplanation(!showExplanation)} - className="flex items-center gap-1.5 text-[11px] font-mono px-2.5 py-1.5 rounded-lg transition-colors" style={{ + display: "flex", + alignItems: "center", + gap: 6, + fontSize: 11, + fontFamily: "DM Mono, monospace", + padding: "5px 10px", + borderRadius: 8, + cursor: "pointer", background: "var(--surface-2)", border: "1px solid var(--border)", color: "var(--text-3)", @@ -566,7 +769,14 @@ export default function AgentControl() { </button> </div> - <div className="p-5 grid grid-cols-3 md:grid-cols-5 gap-3"> + <div + style={{ + padding: 20, + display: "grid", + gridTemplateColumns: "repeat(5, 1fr)", + gap: 12, + }} + > {Object.entries(decision).map(([key, value]) => { const meta = ACTION_MAP[key] || { label: key, @@ -578,33 +788,54 @@ export default function AgentControl() { return ( <div key={key} - className="rounded-xl p-4 text-center" style={{ + borderRadius: 12, + padding: 14, + textAlign: "center", background: "var(--bg-3)", border: "1px solid var(--border)", }} > <div - className="w-8 h-8 rounded-lg flex items-center justify-center mx-auto mb-2" - style={{ background: `${meta.color}15` }} + style={{ + width: 30, + height: 30, + borderRadius: 8, + background: `${meta.color}15`, + display: "flex", + alignItems: "center", + justifyContent: "center", + margin: "0 auto 8px", + }} > - <Icon size={14} style={{ color: meta.color }} /> + <Icon size={13} style={{ color: meta.color }} /> </div> <div - className="font-bold font-mono text-xl" - style={{ color: meta.color }} + style={{ + fontWeight: 700, + fontFamily: "DM Mono, monospace", + fontSize: 20, + color: meta.color, + }} > {value} </div> <div - className="text-[9px] font-mono mt-0.5" - style={{ color: "var(--text-3)" }} + style={{ + fontSize: 9, + fontFamily: "DM Mono, monospace", + marginTop: 2, + color: "var(--text-3)", + }} > {meta.unit} </div> <div - className="text-[10px] mt-1" - style={{ color: "var(--text-3)" }} + style={{ + fontSize: 10, + marginTop: 4, + color: "var(--text-3)", + }} > {meta.label} </div> @@ -615,21 +846,31 @@ export default function AgentControl() { {showExplanation && explanationText && ( <div - className="border-t p-5" style={{ - borderColor: "var(--border)", + borderTop: "1px solid var(--border)", + padding: 20, background: "var(--bg-3)", }} > <div - className="text-[10px] font-mono mb-2" - style={{ color: "var(--text-3)" }} + style={{ + fontSize: 10, + fontFamily: "DM Mono, monospace", + color: "var(--text-3)", + marginBottom: 8, + }} > // SUPERVISOR REASONING </div> <pre - className="text-xs font-mono leading-relaxed whitespace-pre-wrap" - style={{ color: "var(--text-2)" }} + style={{ + fontSize: 11, + fontFamily: "DM Mono, monospace", + lineHeight: 1.7, + whiteSpace: "pre-wrap", + color: "var(--text-2)", + margin: 0, + }} > {explanationText} </pre> @@ -640,41 +881,78 @@ export default function AgentControl() { {/* Search results */} {searchResults.length > 0 && ( - <div className="mt-6"> - <div className="flex items-center gap-2 mb-4"> - <Database size={14} style={{ color: "var(--text-3)" }} /> + <div> + <div + style={{ + display: "flex", + alignItems: "center", + gap: 8, + marginBottom: 14, + }} + > + <Database size={13} style={{ color: "var(--text-3)" }} /> <span - className="text-[11px] font-mono" - style={{ color: "var(--text-3)" }} + style={{ + fontSize: 11, + fontFamily: "DM Mono, monospace", + color: "var(--text-3)", + }} > MEMORY MATCHES · {searchResults.length} FOUND </span> </div> - <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> + <div + style={{ + display: "grid", + gridTemplateColumns: "repeat(3, 1fr)", + gap: 14, + }} + > {searchResults.map((res) => { const s = extractSensors(res.payload); return ( <div key={res.id} - className="rounded-xl p-4 card-hover" + className="card-hover" style={{ + borderRadius: 12, + padding: 14, background: "var(--surface)", border: "1px solid var(--border)", }} > - <div className="flex items-center justify-between mb-3"> - <div className="flex items-center gap-2"> + <div + style={{ + display: "flex", + alignItems: "center", + justifyContent: "space-between", + marginBottom: 10, + }} + > + <div + style={{ + display: "flex", + alignItems: "center", + gap: 6, + }} + > <Leaf size={13} style={{ color: "var(--green)" }} /> <span - className="font-semibold text-sm" - style={{ color: "var(--text)" }} + style={{ + fontWeight: 600, + fontSize: 13, + color: "var(--text)", + }} > {res.payload.crop || "Unknown"} </span> </div> <span - className="text-[10px] font-mono px-2 py-0.5 rounded" style={{ + fontSize: 10, + fontFamily: "DM Mono, monospace", + padding: "2px 6px", + borderRadius: 4, background: "rgba(74,222,128,0.1)", color: "var(--green)", }} @@ -682,46 +960,55 @@ export default function AgentControl() { {((res.score || 1) * 100).toFixed(0)}% </span> </div> - <div className="grid grid-cols-2 gap-2"> - <div - className="rounded-lg p-2 text-center" - style={{ background: "var(--bg-3)" }} - > - <div - className="text-[9px] font-mono" - style={{ color: "var(--text-3)" }} - > - pH - </div> - <div - className="font-mono font-bold text-sm" - style={{ color: "var(--green)" }} - > - {s.ph} - </div> - </div> - <div - className="rounded-lg p-2 text-center" - style={{ background: "var(--bg-3)" }} - > - <div - className="text-[9px] font-mono" - style={{ color: "var(--text-3)" }} - > - EC - </div> + <div + style={{ + display: "grid", + gridTemplateColumns: "1fr 1fr", + gap: 8, + }} + > + {[ + { label: "pH", value: s.ph, color: "var(--green)" }, + { label: "EC", value: s.ec, color: "var(--amber)" }, + ].map(({ label, value, color }) => ( <div - className="font-mono font-bold text-sm" - style={{ color: "var(--amber)" }} + key={label} + style={{ + borderRadius: 8, + padding: "6px 10px", + textAlign: "center", + background: "var(--bg-3)", + }} > - {s.ec} + <div + style={{ + fontSize: 9, + fontFamily: "DM Mono, monospace", + color: "var(--text-3)", + }} + > + {label} + </div> + <div + style={{ + fontWeight: 700, + fontFamily: "DM Mono, monospace", + fontSize: 14, + color, + }} + > + {value} + </div> </div> - </div> + ))} </div> {res.payload.outcome && ( <div - className="mt-2 text-[11px]" - style={{ color: "var(--text-3)" }} + style={{ + marginTop: 8, + fontSize: 11, + color: "var(--text-3)", + }} > {formatOutcome(res.payload.outcome)?.substring(0, 80)} … diff --git a/frontend/src/pages/Alerts.jsx b/frontend/src/pages/Alerts.jsx @@ -9,277 +9,13 @@ import { BellOff, Clock, RefreshCw, + SlidersHorizontal, } from "lucide-react"; import { useFarmData } from "../hooks/useFarmData"; -import { extractSensors, formatOutcome } from "../utils/dataUtils"; +import { generateAlerts } from "../utils/dataUtils"; import Sidebar from "../components/Sidebar"; -function timeAgo(isoString) { - if (!isoString) return "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`; - const hrs = Math.floor(mins / 60); - if (hrs < 24) return `${hrs} hr ago`; - return `${Math.floor(hrs / 24)} days ago`; -} - -/** - * Derive real alerts from stored Qdrant points. - * Rules: - * critical — pH < 4.5 | pH > 7.5 | EC > 3.5 | temp < 10 | temp > 35 - * outcome contains 'fail' / 'critical' / 'disease' / 'error' - * action_taken contains 'DISEASE' / 'PEST' / 'FUNGAL' - * warning — pH 4.5–5.4 | pH 6.6–7.5 | EC 2.5–3.5 | temp 10–17 | temp 30–35 - * outcome contains 'deteriorat' / 'negative' / 'attention' - * action_taken contains 'FLUSH' / 'PRUNE' / 'BOOST' - * info — completed cycles (sequence_number present), strategy changes - */ -function generateAlerts(points) { - const alerts = []; - let id = 1; - - for (const p of points) { - const payload = p.payload || {}; - const s = extractSensors(payload); - const ph = parseFloat(s.ph) || 0; - const ec = parseFloat(s.ec) || 0; - const temp = parseFloat(s.temp) || 0; - const ts = payload.timestamp; - const cropId = payload.crop_id || "UNKNOWN"; - const cropName = payload.crop || "Crop"; - const action = (payload.action_taken || "").toUpperCase(); - const outcome = (payload.outcome || "").toLowerCase(); - const strategy = (payload.strategic_intent || "").toUpperCase(); - const seq = payload.sequence_number; - - // Critical: pH - if (ph > 0 && ph < 4.5) { - alerts.push({ - id: id++, - severity: "critical", - title: "pH critically low", - desc: `${cropName} (${cropId}): pH at ${ph} — well below safe range. Immediate base dosing required.`, - time: timeAgo(ts), - ts, - agent: "WATER", - crop: cropName, - ack: false, - }); - } else if (ph > 7.5) { - alerts.push({ - id: id++, - severity: "critical", - title: "pH critically high", - desc: `${cropName} (${cropId}): pH at ${ph} — far above optimal. Acid dosing required immediately.`, - time: timeAgo(ts), - ts, - agent: "WATER", - crop: cropName, - ack: false, - }); - } - - // Critical: EC - if (ec > 3.5) { - alerts.push({ - id: id++, - severity: "critical", - title: "EC dangerously high", - desc: `${cropName} (${cropId}): EC at ${ec} dS/m — severe nutrient burn risk. Flush immediately.`, - time: timeAgo(ts), - ts, - agent: "WATER", - crop: cropName, - ack: false, - }); - } - - // Critical: Temp - if (temp > 0 && temp < 10) { - alerts.push({ - id: id++, - severity: "critical", - title: "Temperature too cold", - desc: `${cropName} (${cropId}): Air temp at ${temp}°C — plant stress and root damage risk.`, - time: timeAgo(ts), - ts, - agent: "ATMOSPHERIC", - crop: cropName, - ack: false, - }); - } else if (temp > 35) { - 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), - ts, - agent: "ATMOSPHERIC", - crop: cropName, - ack: false, - }); - } - - // Critical: disease keywords in outcome/action - if ( - /disease|fungal|pest|mildew|blight|mite|rot/.test(outcome) || - /DISEASE|FUNGAL|PEST/.test(action) - ) { - alerts.push({ - id: id++, - severity: "critical", - title: "Disease or pest detected", - desc: `${cropName} (${cropId}): Visual anomaly in stored record. Outcome: "${formatOutcome(payload.outcome)}"`, - time: timeAgo(ts), - ts, - agent: "DOCTOR", - crop: cropName, - ack: false, - }); - } - - // Critical: fail/critical outcome - if (/fail|critical|error/.test(outcome)) { - alerts.push({ - id: id++, - severity: "critical", - title: "Cycle failure recorded", - desc: `${cropName} (${cropId}): Sequence #${seq} outcome: "${formatOutcome(payload.outcome)}"`, - time: timeAgo(ts), - ts, - agent: "JUDGE", - crop: cropName, - ack: false, - }); - } - - // Warning: pH mild drift - if (ph >= 4.5 && ph < 5.5) { - alerts.push({ - id: id++, - severity: "warning", - title: "pH below optimal range", - desc: `${cropName} (${cropId}): pH at ${ph}. Target 5.5–6.5 — gentle base adjustment recommended.`, - time: timeAgo(ts), - ts, - agent: "WATER", - crop: cropName, - ack: false, - }); - } else if (ph > 6.6 && ph <= 7.5) { - alerts.push({ - id: id++, - severity: "warning", - title: "pH above optimal range", - desc: `${cropName} (${cropId}): pH at ${ph}. Target 5.5–6.5 — gentle acid adjustment recommended.`, - time: timeAgo(ts), - ts, - agent: "WATER", - crop: cropName, - ack: false, - }); - } - - // Warning: EC elevated - if (ec >= 2.5 && ec <= 3.5) { - 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), - ts, - agent: "SUPERVISOR", - crop: cropName, - ack: false, - }); - } - - // Warning: temp mild - if (temp >= 10 && temp < 17) { - alerts.push({ - id: id++, - severity: "warning", - title: "Temperature on the low side", - desc: `${cropName} (${cropId}): ${temp}°C — slow growth and reduced nutrient uptake expected.`, - time: timeAgo(ts), - ts, - agent: "ATMOSPHERIC", - crop: cropName, - ack: false, - }); - } else if (temp >= 30 && temp <= 35) { - alerts.push({ - id: id++, - severity: "warning", - title: "Temperature elevated", - desc: `${cropName} (${cropId}): ${temp}°C — heat stress likely. Increase ventilation.`, - time: timeAgo(ts), - ts, - agent: "ATMOSPHERIC", - crop: cropName, - ack: false, - }); - } - - // Warning: deterioration outcome - if (/deteriorat|negative|attention|decline/.test(outcome)) { - alerts.push({ - id: id++, - severity: "warning", - title: "Condition deteriorating", - desc: `${cropName} (${cropId}): Sequence #${seq} — "${formatOutcome(payload.outcome)}"`, - time: timeAgo(ts), - ts, - agent: "JUDGE", - crop: cropName, - ack: false, - }); - } - - // Info: completed cycle - if (seq && !/fail|critical|error|deteriorat|negative/.test(outcome)) { - 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), - ts, - agent: strategy ? "SUPERVISOR" : "JUDGE", - crop: cropName, - ack: true, - }); - } - } - - // Deduplicate: limit to 2 for cleaner UI - const seen = new Map(); - const deduped = []; - for (const a of alerts) { - const key = `${a.severity}|${a.title}|${a.crop}`; - const count = seen.get(key) || 0; - if (count < 2) { - deduped.push(a); - seen.set(key, count + 1); - } - } - - deduped.sort((a, b) => { - const sevOrder = { critical: 0, warning: 1, info: 2 }; - if (sevOrder[a.severity] !== sevOrder[b.severity]) - return sevOrder[a.severity] - sevOrder[b.severity]; - return new Date(b.ts || 0) - new Date(a.ts || 0); - }); - - return deduped; -} - -// STYLES - +// Severity const SEV = { critical: { icon: AlertTriangle, @@ -313,38 +49,67 @@ const AGENT_COLORS = { HISTORIAN: "var(--text-3)", }; +// Alert Card function AlertCard({ alert, onAck, onDismiss }) { const s = SEV[alert.severity]; const Icon = s.icon; return ( <div - className="rounded-xl p-4 transition-all card-hover" + className="card-hover" style={{ + borderRadius: 12, + padding: 16, background: alert.ack ? "var(--surface)" : s.bg, border: `1px solid ${alert.ack ? "var(--border)" : s.border}`, - opacity: alert.ack ? 0.6 : 1, + opacity: alert.ack ? 0.55 : 1, + transition: "opacity 0.2s", }} > - <div className="flex items-start gap-3"> + <div style={{ display: "flex", alignItems: "flex-start", gap: 12 }}> + {/* Icon */} <div - className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 mt-0.5" - style={{ background: s.bg, border: `1px solid ${s.border}` }} + style={{ + width: 32, + height: 32, + borderRadius: 8, + background: s.bg, + border: `1px solid ${s.border}`, + display: "flex", + alignItems: "center", + justifyContent: "center", + flexShrink: 0, + marginTop: 2, + }} > - <Icon size={15} style={{ color: s.text }} /> + <Icon size={14} style={{ color: s.text }} /> </div> - <div className="flex-1 min-w-0"> - <div className="flex items-center gap-2 flex-wrap"> + {/* Content */} + <div style={{ flex: 1, minWidth: 0 }}> + <div + style={{ + display: "flex", + alignItems: "center", + gap: 8, + flexWrap: "wrap", + }} + > <span - className="font-semibold text-sm" - style={{ color: alert.ack ? "var(--text-2)" : "var(--text)" }} + style={{ + fontWeight: 600, + fontSize: 13, + color: alert.ack ? "var(--text-2)" : "var(--text)", + }} > {alert.title} </span> <span - className="text-[9px] font-mono px-1.5 py-0.5 rounded" style={{ + fontSize: 9, + fontFamily: "DM Mono, monospace", + padding: "2px 6px", + borderRadius: 4, background: s.bg, color: s.text, border: `1px solid ${s.border}`, @@ -353,8 +118,11 @@ function AlertCard({ alert, onAck, onDismiss }) { {s.label} </span> <span - className="text-[10px] font-mono px-1.5 py-0.5 rounded" style={{ + fontSize: 9, + fontFamily: "DM Mono, monospace", + padding: "2px 6px", + borderRadius: 4, background: "rgba(0,0,0,0.3)", color: AGENT_COLORS[alert.agent] || "var(--text-3)", }} @@ -362,38 +130,60 @@ function AlertCard({ alert, onAck, onDismiss }) { {alert.agent} </span> </div> + <p - className="text-xs mt-1 leading-relaxed" - style={{ color: "var(--text-3)" }} + style={{ + fontSize: 12, + color: "var(--text-3)", + margin: "4px 0 8px", + lineHeight: 1.5, + }} > {alert.desc} </p> - <div className="flex items-center gap-3 mt-2"> - <div - className="flex items-center gap-1 text-[10px] font-mono" - style={{ color: "var(--text-3)" }} + + <div style={{ display: "flex", alignItems: "center", gap: 12 }}> + <span + style={{ + display: "flex", + alignItems: "center", + gap: 4, + fontSize: 10, + fontFamily: "DM Mono, monospace", + color: "var(--text-3)", + }} > <Clock size={9} /> {alert.time} - </div> - <div - className="text-[10px] font-mono" - style={{ color: "var(--text-3)" }} + </span> + <span + style={{ + fontSize: 10, + fontFamily: "DM Mono, monospace", + color: "var(--text-3)", + }} > Crop: {alert.crop} - </div> + </span> </div> </div> - <div className="flex items-center gap-1 flex-shrink-0"> + {/* Actions */} + <div style={{ display: "flex", gap: 4, flexShrink: 0 }}> {!alert.ack && ( <button onClick={() => onAck(alert.id)} title="Acknowledge" - className="p-1.5 rounded-lg transition-colors" style={{ + width: 28, + height: 28, + borderRadius: 8, background: "var(--surface)", border: "1px solid var(--border)", color: "var(--text-3)", + display: "flex", + alignItems: "center", + justifyContent: "center", + cursor: "pointer", }} > <CheckCircle2 size={13} /> @@ -402,11 +192,17 @@ function AlertCard({ alert, onAck, onDismiss }) { <button onClick={() => onDismiss(alert.id)} title="Dismiss" - className="p-1.5 rounded-lg transition-colors" style={{ + width: 28, + height: 28, + borderRadius: 8, background: "var(--surface)", border: "1px solid var(--border)", color: "var(--text-3)", + display: "flex", + alignItems: "center", + justifyContent: "center", + cursor: "pointer", }} > <X size={13} /> @@ -426,9 +222,7 @@ export default function Alerts() { const [showAcked, setShowAcked] = useState(false); useEffect(() => { - if (!loading) { - setAlerts(generateAlerts(history)); - } + if (!loading) setAlerts(generateAlerts(history)); }, [history, loading]); const ack = (id) => @@ -457,62 +251,144 @@ export default function Alerts() { [alerts, filter, showAcked], ); + // Filters + const FILTER_OPTIONS = [ + { key: "all", label: "All", count: alerts.length, color: null }, + { + key: "critical", + label: "Critical", + count: counts.critical, + color: "var(--red)", + }, + { + key: "warning", + label: "Warning", + count: counts.warning, + color: "var(--amber)", + }, + { key: "info", label: "Info", count: counts.info, color: "var(--blue)" }, + ]; + return ( <div - className="flex h-screen overflow-hidden" - style={{ background: "var(--bg)" }} + style={{ + display: "flex", + height: "100vh", + overflow: "hidden", + background: "var(--bg)", + }} > <Sidebar /> - <main className="flex-1 flex flex-col overflow-hidden"> + + <main + style={{ + flex: 1, + display: "flex", + flexDirection: "column", + overflow: "hidden", + }} + > {/* Header */} <header - className="flex-shrink-0 px-6 py-4 border-b flex items-center gap-4" - style={{ borderColor: "var(--border)", background: "var(--bg-2)" }} + style={{ + flexShrink: 0, + padding: "0 24px", + height: 64, + borderBottom: "1px solid var(--border)", + background: "var(--bg-2)", + display: "flex", + alignItems: "center", + gap: 16, + }} > <div> - <h1 className="font-bold text-lg" style={{ color: "var(--text)" }}> + <h1 + style={{ + fontWeight: 700, + fontSize: 18, + color: "var(--text)", + margin: 0, + }} + > Alerts </h1> - <p className="text-xs font-mono" style={{ color: "var(--text-3)" }}> + <p + style={{ + fontSize: 11, + fontFamily: "DM Mono, monospace", + color: "var(--text-3)", + margin: 0, + }} + > {loading ? "Analyzing sensor history…" : `${counts.total} unacknowledged · ${alerts.length} total`} </p> </div> - <div className="ml-auto flex items-center gap-2"> + + <div + style={{ + marginLeft: "auto", + display: "flex", + alignItems: "center", + gap: 8, + }} + > <button onClick={refreshData} - className="p-2 rounded-lg transition-colors" + title="Reload" style={{ + width: 34, + height: 34, + borderRadius: 8, background: "var(--surface)", border: "1px solid var(--border)", color: "var(--text-3)", + display: "flex", + alignItems: "center", + justifyContent: "center", + cursor: "pointer", }} - title="Reload" > <RefreshCw size={14} className={loading ? "animate-spin" : ""} /> </button> + <button onClick={() => setShowAcked(!showAcked)} - className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[11px] font-mono transition-all" style={{ + display: "flex", + alignItems: "center", + gap: 6, + padding: "6px 12px", + borderRadius: 8, + fontSize: 11, + fontFamily: "DM Mono, monospace", background: showAcked ? "rgba(74,222,128,0.1)" : "var(--surface)", border: `1px solid ${showAcked ? "rgba(74,222,128,0.3)" : "var(--border)"}`, color: showAcked ? "var(--green)" : "var(--text-3)", + cursor: "pointer", }} > {showAcked ? <Bell size={12} /> : <BellOff size={12} />} {showAcked ? "All" : "Unacked only"} </button> + <button onClick={ackAll} - className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[11px] font-mono" style={{ + display: "flex", + alignItems: "center", + gap: 6, + padding: "6px 12px", + borderRadius: 8, + fontSize: 11, + fontFamily: "DM Mono, monospace", background: "var(--surface)", border: "1px solid var(--border)", color: "var(--text-3)", + cursor: "pointer", }} > <CheckCircle2 size={12} /> Ack all @@ -522,45 +398,52 @@ export default function Alerts() { {/* Filter bar */} <div - className="flex-shrink-0 px-6 py-3 border-b flex items-center gap-3 overflow-x-auto" - style={{ borderColor: "var(--border)", background: "var(--bg-3)" }} + style={{ + flexShrink: 0, + padding: "8px 24px", + borderBottom: "1px solid var(--border)", + background: "var(--bg-2)", + display: "flex", + alignItems: "center", + gap: 8, + overflowX: "auto", + }} > - {[ - { key: "all", label: "All", count: alerts.length, color: null }, - { - key: "critical", - label: "Critical", - count: counts.critical, - color: "var(--red)", - }, - { - key: "warning", - label: "Warning", - count: counts.warning, - color: "var(--amber)", - }, - { - key: "info", - label: "Info", - count: counts.info, - color: "var(--blue)", - }, - ].map(({ key, label, count, color }) => ( + <SlidersHorizontal + size={14} + style={{ color: "var(--text-3)", flexShrink: 0 }} + /> + {FILTER_OPTIONS.map(({ key, label, count, color }) => ( <button key={key} onClick={() => setFilter(key)} - className="flex items-center gap-2 px-3 py-1.5 rounded-full text-[11px] font-mono flex-shrink-0 transition-all" style={{ + display: "flex", + alignItems: "center", + gap: 6, + padding: "6px 12px", + borderRadius: 20, + fontSize: 12, + fontFamily: "DM Mono, monospace", + flexShrink: 0, + cursor: "pointer", background: filter === key ? "var(--surface-2)" : "transparent", border: `1px solid ${filter === key ? "var(--border-bright)" : "transparent"}`, color: filter === key ? color || "var(--text)" : "var(--text-3)", + transition: "all 0.15s", }} > {count > 0 && ( <span - className="w-4 h-4 rounded-full flex items-center justify-center text-[9px]" style={{ + width: 18, + height: 18, + borderRadius: "50%", + display: "flex", + alignItems: "center", + justifyContent: "center", + fontSize: 9, background: color ? `${color}30` : "var(--border)", color: color || "var(--text-3)", }} @@ -574,27 +457,53 @@ export default function Alerts() { </div> {/* Alert list */} - <div className="flex-1 overflow-y-auto p-6"> + <div style={{ flex: 1, overflowY: "auto", padding: 24 }}> {loading ? ( - <div className="space-y-3 max-w-2xl mx-auto"> + <div + style={{ + maxWidth: 640, + margin: "0 auto", + display: "flex", + flexDirection: "column", + gap: 12, + }} + > {[1, 2, 3].map((i) => ( <div key={i} - className="h-20 rounded-xl shimmer" - style={{ border: "1px solid var(--border)" }} + className="shimmer" + style={{ + height: 80, + borderRadius: 12, + border: "1px solid var(--border)", + }} /> ))} </div> ) : filtered.length === 0 ? ( - <div className="flex flex-col items-center justify-center h-full gap-4"> + <div + style={{ + display: "flex", + flexDirection: "column", + alignItems: "center", + justifyContent: "center", + height: "100%", + gap: 16, + }} + > <div - className="w-16 h-16 rounded-2xl flex items-center justify-center" style={{ + width: 56, + height: 56, + borderRadius: 16, background: "var(--surface)", border: "1px solid var(--border)", + display: "flex", + alignItems: "center", + justifyContent: "center", }} > - <CheckCircle2 size={28} style={{ color: "var(--green)" }} /> + <CheckCircle2 size={24} style={{ color: "var(--green)" }} /> </div> <div style={{ color: "var(--text-2)" }}> {alerts.length === 0 @@ -603,17 +512,31 @@ export default function Alerts() { </div> </div> ) : ( - <div className="space-y-3 max-w-2xl mx-auto"> + <div + style={{ + maxWidth: 640, + margin: "0 auto", + display: "flex", + flexDirection: "column", + gap: 24, + }} + > {/* Unacked */} {filtered.filter((a) => !a.ack).length > 0 && ( <div> <div - className="text-[10px] font-mono mb-3" - style={{ color: "var(--text-3)" }} + style={{ + fontSize: 10, + fontFamily: "DM Mono, monospace", + color: "var(--text-3)", + marginBottom: 12, + }} > UNACKNOWLEDGED · {filtered.filter((a) => !a.ack).length} </div> - <div className="space-y-2"> + <div + style={{ display: "flex", flexDirection: "column", gap: 8 }} + > {filtered .filter((a) => !a.ack) .map((a) => ( @@ -630,14 +553,20 @@ export default function Alerts() { {/* Acked */} {showAcked && filtered.filter((a) => a.ack).length > 0 && ( - <div className="mt-6"> + <div> <div - className="text-[10px] font-mono mb-3" - style={{ color: "var(--text-3)" }} + style={{ + fontSize: 10, + fontFamily: "DM Mono, monospace", + color: "var(--text-3)", + marginBottom: 12, + }} > ACKNOWLEDGED · {filtered.filter((a) => a.ack).length} </div> - <div className="space-y-2"> + <div + style={{ display: "flex", flexDirection: "column", gap: 8 }} + > {filtered .filter((a) => a.ack) .map((a) => ( diff --git a/frontend/src/pages/Analytics.jsx b/frontend/src/pages/Analytics.jsx @@ -17,196 +17,28 @@ import { PolarAngleAxis, } from "recharts"; import { useFarmData } from "../hooks/useFarmData"; -import { extractSensors } from "../utils/dataUtils"; +import { + extractSensors, + avg, + safePct, + bucketHistory, + dailyCropActivity, + buildRadar, + buildAgentStats, +} from "../utils/dataUtils"; import { TrendingUp, TrendingDown, Minus, Download } from "lucide-react"; import Sidebar from "../components/Sidebar"; -// HELPERS - -function avg(arr) { - if (!arr.length) return 0; - return arr.reduce((s, v) => s + v, 0) / arr.length; -} - -function safePct(current, previous) { - if (!previous) return 0; - return parseFloat((((current - previous) / previous) * 100).toFixed(1)); -} - -// Group history points into buckets. -// range = '24h' → group by hour (last 24 entries) -// range = '7d' → group by day (last 7 days) -// range = '30d' → group by day (last 30 days) -function bucketHistory(points, range) { - if (!points.length) return []; - - const now = Date.now(); - const MS = { - "24h": 24 * 60 * 60 * 1000, - "7d": 7 * 24 * 60 * 60 * 1000, - "30d": 30 * 24 * 60 * 60 * 1000, - }; - const cutoff = now - (MS[range] || MS["24h"]); - - const filtered = points.filter( - (p) => new Date(p.payload?.timestamp || 0).getTime() >= cutoff, - ); - if (!filtered.length) return []; - - // Build buckets - const bucketSize = - range === "24h" - ? 60 * 60 * 1000 // 1 hour - : 24 * 60 * 60 * 1000; // 1 day - - const buckets = new Map(); - for (const p of filtered) { - const t = new Date(p.payload?.timestamp || 0).getTime(); - const key = Math.floor(t / bucketSize) * bucketSize; - if (!buckets.has(key)) buckets.set(key, []); - buckets.get(key).push(p); - } - - return Array.from(buckets.entries()) - .sort((a, b) => a[0] - b[0]) - .map(([ts, pts]) => { - const date = new Date(ts); - const label = - range === "24h" - ? date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) - : date.toLocaleDateString([], { month: "short", day: "numeric" }); - - const sensors = pts.map((p) => extractSensors(p.payload)); - return { - label, - ph: parseFloat( - avg(sensors.map((s) => parseFloat(s.ph) || 0)).toFixed(2), - ), - ec: parseFloat( - avg(sensors.map((s) => parseFloat(s.ec) || 0)).toFixed(2), - ), - temp: parseFloat( - avg(sensors.map((s) => parseFloat(s.temp) || 0)).toFixed(1), - ), - humidity: parseFloat( - avg(sensors.map((s) => parseFloat(s.humidity) || 0)).toFixed(1), - ), - count: pts.length, - }; - }); -} - -// Build per-day crop count for "sequences per day" bar chart -function dailyCropActivity(points) { - const map = new Map(); - for (const p of points) { - const ts = p.payload?.timestamp; - if (!ts) continue; - const day = new Date(ts).toLocaleDateString([], { - month: "short", - day: "numeric", - }); - map.set(day, (map.get(day) || 0) + 1); - } - const last7 = Array.from(map.entries()).slice(-7); - const maxVal = Math.max(...last7.map((e) => e[1]), 1); - return last7.map(([d, count]) => ({ - d, - count, - target: Math.ceil(maxVal * 1.2), - })); -} - -// How close each param is to ideal range -function buildRadar(points) { - if (!points.length) return []; - const sensors = points.map((p) => extractSensors(p.payload)); - - const check = (vals, lo, hi) => { - const inRange = vals.filter((v) => v >= lo && v <= hi).length; - return Math.round((inRange / vals.length) * 100); - }; - - return [ - { - metric: "pH", - value: check( - sensors.map((s) => parseFloat(s.ph)), - 5.5, - 6.5, - ), - }, - { - metric: "EC", - value: check( - sensors.map((s) => parseFloat(s.ec)), - 0.8, - 2.5, - ), - }, - { - metric: "Temp", - value: check( - sensors.map((s) => parseFloat(s.temp)), - 18, - 28, - ), - }, - { - metric: "Humidity", - value: check( - sensors.map((s) => parseFloat(s.humidity)), - 40, - 80, - ), - }, - ]; -} - -// Derive which agents appear in action_taken fields and how many times -function buildAgentStats(points) { - const AGENTS = ["SUPERVISOR", "WATER", "ATMOSPHERIC", "JUDGE", "DOCTOR"]; - const counts = Object.fromEntries(AGENTS.map((a) => [a, 0])); - - for (const p of points) { - const action = - (p.payload?.action_taken || "") + - " " + - (p.payload?.strategic_intent || ""); - for (const agent of AGENTS) { - if (action.toUpperCase().includes(agent)) counts[agent]++; - } - counts["SUPERVISOR"]++; - } - - const total = Math.max(points.length, 1); - return AGENTS.map((name) => ({ - name, - decisions: counts[name], - // accuracy = % of points where outcome is NOT negative - accuracy: points.length - ? Math.round( - (points.filter( - (p) => - !/fail|negative|critical/.test( - (p.payload?.outcome || "").toLowerCase(), - ), - ).length / - total) * - 100, - ) - : 0, - })).filter((a) => a.decisions > 0); -} - -// COMPONENTS - +// Shared Tooltip const CustomTooltip = ({ active, payload, label }) => { if (!active || !payload?.length) return null; return ( <div - className="px-3 py-2 rounded-lg text-xs font-mono" style={{ + padding: "8px 12px", + borderRadius: 8, + fontSize: 11, + fontFamily: "DM Mono, monospace", background: "var(--surface-2)", border: "1px solid var(--border)", color: "var(--text)", @@ -222,40 +54,67 @@ const CustomTooltip = ({ active, payload, label }) => { ); }; +// Metric Card function MetricCard({ label, value, unit, change, color, loading }) { - const up = change > 0, - flat = change === 0; + const up = change > 0; + const flat = change === 0; return ( <div - className="rounded-xl p-5 card-hover" + className="card-hover" style={{ + borderRadius: 12, + padding: 20, background: "var(--surface)", border: "1px solid var(--border)", }} > <div - className="text-[11px] font-mono mb-3" - style={{ color: "var(--text-3)" }} + style={{ + fontSize: 10, + fontFamily: "DM Mono, monospace", + color: "var(--text-3)", + marginBottom: 10, + }} > {label} </div> {loading ? ( - <div className="h-8 w-24 rounded shimmer" /> + <div + className="shimmer" + style={{ height: 32, width: 96, borderRadius: 6 }} + /> ) : ( <div - className="text-3xl font-bold font-mono" - style={{ color: color || "var(--text)" }} + style={{ + fontSize: 28, + fontWeight: 700, + fontFamily: "DM Mono, monospace", + color: color || "var(--text)", + }} > {value} <span - className="text-base font-normal ml-1" - style={{ color: "var(--text-3)" }} + style={{ + fontSize: 14, + fontWeight: 400, + marginLeft: 4, + color: "var(--text-3)", + }} > {unit} </span> </div> )} - <div className="flex items-center gap-1 mt-2 text-[11px] font-mono"> + <div + style={{ + display: "flex", + alignItems: "center", + gap: 4, + marginTop: 8, + fontSize: 11, + fontFamily: "DM Mono, monospace", + }} + > {flat ? ( <Minus size={11} style={{ color: "var(--text-3)" }} /> ) : up ? ( @@ -277,13 +136,23 @@ function MetricCard({ label, value, unit, change, color, loading }) { function SectionTitle({ children, sub }) { return ( - <div className="mb-4"> - <div className="text-[10px] font-mono" style={{ color: "var(--text-3)" }}> + <div style={{ marginBottom: 16 }}> + <div + style={{ + fontSize: 10, + fontFamily: "DM Mono, monospace", + color: "var(--text-3)", + }} + > // {sub} </div> <h2 - className="font-bold text-base mt-0.5" - style={{ color: "var(--text)" }} + style={{ + fontWeight: 700, + fontSize: 15, + color: "var(--text)", + margin: "4px 0 0", + }} > {children} </h2> @@ -294,14 +163,23 @@ function SectionTitle({ children, sub }) { function EmptyChart({ height = 180, message = "No data yet" }) { return ( <div - className="flex items-center justify-center rounded-lg" style={{ height, + display: "flex", + alignItems: "center", + justifyContent: "center", + borderRadius: 8, background: "var(--bg-3)", border: "1px dashed var(--border)", }} > - <span className="text-xs font-mono" style={{ color: "var(--text-3)" }}> + <span + style={{ + fontSize: 11, + fontFamily: "DM Mono, monospace", + color: "var(--text-3)", + }} + > {message} </span> </div> @@ -319,7 +197,7 @@ export default function Analytics() { [allPoints, range], ); - // Compute averages from latest snapshot + // Latest fleet-wide averages const latestSensors = useMemo(() => { if (!dashboard.length) return { ph: 0, ec: 0, temp: 0 }; const sensors = dashboard.map((d) => extractSensors(d.payload)); @@ -332,12 +210,11 @@ export default function Analytics() { }; }, [dashboard]); - // Compare first half vs second half of history to get "change" + // First-half vs second-half for % change const prevSensors = useMemo(() => { if (allPoints.length < 2) return latestSensors; - const half = Math.floor(allPoints.length / 2); const older = allPoints - .slice(0, half) + .slice(0, Math.floor(allPoints.length / 2)) .map((p) => extractSensors(p.payload)); return { ph: parseFloat(avg(older.map((s) => parseFloat(s.ph) || 0)).toFixed(2)), @@ -352,7 +229,7 @@ export default function Analytics() { const radarData = useMemo(() => buildRadar(allPoints), [allPoints]); const agentStats = useMemo(() => buildAgentStats(allPoints), [allPoints]); - // Export CSV of bucketed data + // CSV export const handleExport = () => { if (!buckets.length) return; const header = "time,ph,ec,temp,humidity,entries"; @@ -368,37 +245,83 @@ export default function Analytics() { return ( <div - className="flex h-screen overflow-hidden" - style={{ background: "var(--bg)" }} + style={{ + display: "flex", + height: "100vh", + overflow: "hidden", + background: "var(--bg)", + }} > <Sidebar /> - <main className="flex-1 flex flex-col overflow-hidden"> + + <main + style={{ + flex: 1, + display: "flex", + flexDirection: "column", + overflow: "hidden", + }} + > {/* Header */} <header - className="flex-shrink-0 px-6 py-4 border-b flex items-center gap-4" - style={{ borderColor: "var(--border)", background: "var(--bg-2)" }} + style={{ + flexShrink: 0, + padding: "0 24px", + height: 64, + borderBottom: "1px solid var(--border)", + background: "var(--bg-2)", + display: "flex", + alignItems: "center", + gap: 16, + }} > <div> - <h1 className="font-bold text-lg" style={{ color: "var(--text)" }}> + <h1 + style={{ + fontWeight: 700, + fontSize: 18, + color: "var(--text)", + margin: 0, + }} + > Analytics </h1> - <p className="text-xs font-mono" style={{ color: "var(--text-3)" }}> + <p + style={{ + fontSize: 11, + fontFamily: "DM Mono, monospace", + color: "var(--text-3)", + margin: 0, + }} + > {loading ? "Loading…" : `${allPoints.length} data points across ${dashboard.length} crops`} </p> </div> - <div className="ml-auto flex items-center gap-2"> + <div + style={{ + marginLeft: "auto", + display: "flex", + alignItems: "center", + gap: 8, + }} + > {["24h", "7d", "30d"].map((r) => ( <button key={r} onClick={() => setRange(r)} - className="px-3 py-1.5 rounded-lg text-[11px] font-mono transition-all" style={{ + padding: "5px 12px", + borderRadius: 8, + fontSize: 11, + fontFamily: "DM Mono, monospace", + cursor: "pointer", background: range === r ? "rgba(74,222,128,0.12)" : "var(--surface)", border: `1px solid ${range === r ? "rgba(74,222,128,0.3)" : "var(--border)"}`, color: range === r ? "var(--green)" : "var(--text-3)", + transition: "all 0.15s", }} > {r} @@ -406,11 +329,18 @@ export default function Analytics() { ))} <button onClick={handleExport} - className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[11px] font-mono" style={{ + display: "flex", + alignItems: "center", + gap: 6, + padding: "5px 12px", + borderRadius: 8, + fontSize: 11, + fontFamily: "DM Mono, monospace", background: "var(--surface)", border: "1px solid var(--border)", color: "var(--text-3)", + cursor: "pointer", }} > <Download size={12} /> Export CSV @@ -418,9 +348,25 @@ export default function Analytics() { </div> </header> - <div className="flex-1 overflow-y-auto p-6 space-y-8"> + {/* Scrollable content */} + <div + style={{ + flex: 1, + overflowY: "auto", + padding: 24, + display: "flex", + flexDirection: "column", + gap: 28, + }} + > {/* Metric cards */} - <div className="grid grid-cols-2 md:grid-cols-4 gap-4"> + <div + style={{ + display: "grid", + gridTemplateColumns: "repeat(4, 1fr)", + gap: 12, + }} + > <MetricCard loading={loading} label="AVG pH" @@ -459,158 +405,104 @@ export default function Analytics() { </div> {/* pH + EC */} - <div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> - <div - className="rounded-xl p-5" - style={{ - background: "var(--surface)", - border: "1px solid var(--border)", - }} - > - <SectionTitle sub={`${range.toUpperCase()} TRACE`}> - pH Over Time - </SectionTitle> - {buckets.length < 2 ? ( - <EmptyChart - height={180} - message="Not enough data for this range" - /> - ) : ( - <ResponsiveContainer width="100%" height={180}> - <AreaChart data={buckets}> - <defs> - <linearGradient id="phGrad" x1="0" y1="0" x2="0" y2="1"> - <stop - offset="0%" - stopColor="#4ade80" - stopOpacity={0.3} - /> - <stop - offset="100%" - stopColor="#4ade80" - stopOpacity={0} - /> - </linearGradient> - </defs> - <CartesianGrid - stroke="var(--border)" - strokeDasharray="3 3" - vertical={false} - /> - <XAxis - dataKey="label" - tick={{ - fontSize: 9, - fill: "var(--text-3)", - fontFamily: "DM Mono", - }} - axisLine={false} - tickLine={false} - interval="preserveStartEnd" - /> - <YAxis - domain={["auto", "auto"]} - tick={{ - fontSize: 9, - fill: "var(--text-3)", - fontFamily: "DM Mono", - }} - axisLine={false} - tickLine={false} - /> - <Tooltip content={<CustomTooltip />} /> - <Area - type="monotone" - dataKey="ph" - stroke="var(--green)" - fill="url(#phGrad)" - strokeWidth={2} - dot={false} - name="pH" - /> - </AreaChart> - </ResponsiveContainer> - )} - </div> - - <div - className="rounded-xl p-5" - style={{ - background: "var(--surface)", - border: "1px solid var(--border)", - }} - > - <SectionTitle sub={`${range.toUpperCase()} TRACE`}> - EC Concentration - </SectionTitle> - {buckets.length < 2 ? ( - <EmptyChart - height={180} - message="Not enough data for this range" - /> - ) : ( - <ResponsiveContainer width="100%" height={180}> - <AreaChart data={buckets}> - <defs> - <linearGradient id="ecGrad" x1="0" y1="0" x2="0" y2="1"> - <stop - offset="0%" - stopColor="#f59e0b" - stopOpacity={0.25} - /> - <stop - offset="100%" - stopColor="#f59e0b" - stopOpacity={0} - /> - </linearGradient> - </defs> - <CartesianGrid - stroke="var(--border)" - strokeDasharray="3 3" - vertical={false} - /> - <XAxis - dataKey="label" - tick={{ - fontSize: 9, - fill: "var(--text-3)", - fontFamily: "DM Mono", - }} - axisLine={false} - tickLine={false} - interval="preserveStartEnd" - /> - <YAxis - domain={["auto", "auto"]} - tick={{ - fontSize: 9, - fill: "var(--text-3)", - fontFamily: "DM Mono", - }} - axisLine={false} - tickLine={false} - /> - <Tooltip content={<CustomTooltip />} /> - <Area - type="monotone" - dataKey="ec" - stroke="var(--amber)" - fill="url(#ecGrad)" - strokeWidth={2} - dot={false} - name="EC" - /> - </AreaChart> - </ResponsiveContainer> - )} - </div> + <div + style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }} + > + {[ + { + title: "pH Over Time", + key: "ph", + stroke: "var(--green)", + gradId: "phGradA", + gradColor: "#4ade80", + }, + { + title: "EC Concentration", + key: "ec", + stroke: "var(--amber)", + gradId: "ecGradA", + gradColor: "#f59e0b", + }, + ].map(({ title, key, stroke, gradId, gradColor }) => ( + <div + key={key} + style={{ + borderRadius: 12, + padding: 20, + background: "var(--surface)", + border: "1px solid var(--border)", + }} + > + <SectionTitle sub={`${range.toUpperCase()} TRACE`}> + {title} + </SectionTitle> + {buckets.length < 2 ? ( + <EmptyChart message="Not enough data for this range" /> + ) : ( + <ResponsiveContainer width="100%" height={180}> + <AreaChart data={buckets}> + <defs> + <linearGradient id={gradId} x1="0" y1="0" x2="0" y2="1"> + <stop + offset="0%" + stopColor={gradColor} + stopOpacity={0.3} + /> + <stop + offset="100%" + stopColor={gradColor} + stopOpacity={0} + /> + </linearGradient> + </defs> + <CartesianGrid + stroke="var(--border)" + strokeDasharray="3 3" + vertical={false} + /> + <XAxis + dataKey="label" + tick={{ + fontSize: 9, + fill: "var(--text-3)", + fontFamily: "DM Mono", + }} + axisLine={false} + tickLine={false} + interval="preserveStartEnd" + /> + <YAxis + domain={["auto", "auto"]} + tick={{ + fontSize: 9, + fill: "var(--text-3)", + fontFamily: "DM Mono", + }} + axisLine={false} + tickLine={false} + /> + <Tooltip content={<CustomTooltip />} /> + <Area + type="monotone" + dataKey={key} + stroke={stroke} + fill={`url(#${gradId})`} + strokeWidth={2} + dot={false} + name={key.toUpperCase()} + /> + </AreaChart> + </ResponsiveContainer> + )} + </div> + ))} </div> {/* Temp + Humidity */} <div - className="rounded-xl p-5" style={{ + borderRadius: 12, + padding: 20, background: "var(--surface)", border: "1px solid var(--border)", }} @@ -619,10 +511,7 @@ export default function Analytics() { Temperature & Humidity </SectionTitle> {buckets.length < 2 ? ( - <EmptyChart - height={180} - message="Not enough data for this range" - /> + <EmptyChart message="Not enough data for this range" /> ) : ( <ResponsiveContainer width="100%" height={180}> <LineChart data={buckets}> @@ -689,11 +578,14 @@ export default function Analytics() { )} </div> - {/* Activity bar + Radar */} - <div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> + {/* Activity + Radar */} + <div + style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }} + > <div - className="rounded-xl p-5" style={{ + borderRadius: 12, + padding: 20, background: "var(--surface)", border: "1px solid var(--border)", }} @@ -744,8 +636,9 @@ export default function Analytics() { </div> <div - className="rounded-xl p-5" style={{ + borderRadius: 12, + padding: 20, background: "var(--surface)", border: "1px solid var(--border)", }} @@ -786,38 +679,58 @@ export default function Analytics() { </div> </div> - {/* Crop breakdown table */} + {/* Crop summary table */} <div - className="rounded-xl overflow-hidden" style={{ + borderRadius: 12, + overflow: "hidden", background: "var(--surface)", border: "1px solid var(--border)", }} > <div - className="p-5 border-b" - style={{ borderColor: "var(--border)" }} + style={{ padding: 20, borderBottom: "1px solid var(--border)" }} > <SectionTitle sub="PER CROP">Latest Sensor Summary</SectionTitle> </div> {loading ? ( - <div className="p-8 flex justify-center"> + <div + style={{ + padding: 32, + display: "flex", + justifyContent: "center", + }} + > <span - className="text-xs font-mono" - style={{ color: "var(--text-3)" }} + style={{ + fontSize: 11, + fontFamily: "DM Mono, monospace", + color: "var(--text-3)", + }} > Loading… </span> </div> ) : dashboard.length === 0 ? ( <div - className="p-8 text-center text-xs font-mono" - style={{ color: "var(--text-3)" }} + style={{ + padding: 32, + textAlign: "center", + fontSize: 11, + fontFamily: "DM Mono, monospace", + color: "var(--text-3)", + }} > No crops in database </div> ) : ( - <table className="w-full text-sm"> + <table + style={{ + width: "100%", + borderCollapse: "collapse", + fontSize: 12, + }} + > <thead> <tr style={{ borderBottom: "1px solid var(--border)" }}> {[ @@ -831,8 +744,14 @@ export default function Analytics() { ].map((h) => ( <th key={h} - className="px-5 py-3 text-left text-[10px] font-mono" - style={{ color: "var(--text-3)" }} + style={{ + padding: "10px 20px", + textAlign: "left", + fontSize: 10, + fontFamily: "DM Mono, monospace", + color: "var(--text-3)", + fontWeight: 400, + }} > {h} </th> @@ -849,44 +768,72 @@ export default function Analytics() { style={{ borderBottom: "1px solid var(--border)" }} > <td - className="px-5 py-3 font-mono text-xs" - style={{ color: "var(--text)" }} + style={{ + padding: "10px 20px", + fontFamily: "DM Mono, monospace", + fontSize: 11, + color: "var(--text)", + }} > {p.crop_id || "—"} </td> <td - className="px-5 py-3 font-mono text-xs" - style={{ color: "var(--text-2)" }} + style={{ + padding: "10px 20px", + fontFamily: "DM Mono, monospace", + fontSize: 11, + color: "var(--text-2)", + }} > {p.crop || "—"} </td> <td - className="px-5 py-3 font-mono text-xs" - style={{ color: "var(--text-2)" }} + style={{ + padding: "10px 20px", + fontFamily: "DM Mono, monospace", + fontSize: 11, + color: "var(--text-2)", + }} > {p.stage || "—"} </td> <td - className="px-5 py-3 font-mono text-xs" - style={{ color: "var(--green)" }} + style={{ + padding: "10px 20px", + fontFamily: "DM Mono, monospace", + fontSize: 11, + color: "var(--green)", + }} > {s.ph} </td> <td - className="px-5 py-3 font-mono text-xs" - style={{ color: "var(--amber)" }} + style={{ + padding: "10px 20px", + fontFamily: "DM Mono, monospace", + fontSize: 11, + color: "var(--amber)", + }} > {s.ec} </td> <td - className="px-5 py-3 font-mono text-xs" - style={{ color: "var(--blue)" }} + style={{ + padding: "10px 20px", + fontFamily: "DM Mono, monospace", + fontSize: 11, + color: "var(--blue)", + }} > {s.temp}°C </td> <td - className="px-5 py-3 font-mono text-xs" - style={{ color: "var(--text-2)" }} + style={{ + padding: "10px 20px", + fontFamily: "DM Mono, monospace", + fontSize: 11, + color: "var(--text-2)", + }} > {p.sequence_number || 1} </td> @@ -898,24 +845,30 @@ export default function Analytics() { )} </div> - {/* Agent activity derived from action_taken fields */} + {/* Agent activity */} {agentStats.length > 0 && ( <div - className="rounded-xl overflow-hidden" style={{ + borderRadius: 12, + overflow: "hidden", background: "var(--surface)", border: "1px solid var(--border)", }} > <div - className="p-5 border-b" - style={{ borderColor: "var(--border)" }} + style={{ padding: 20, borderBottom: "1px solid var(--border)" }} > <SectionTitle sub="DERIVED FROM STORED ACTIONS"> Agent Activity </SectionTitle> </div> - <table className="w-full text-sm"> + <table + style={{ + width: "100%", + borderCollapse: "collapse", + fontSize: 12, + }} + > <thead> <tr style={{ borderBottom: "1px solid var(--border)" }}> {[ @@ -926,8 +879,14 @@ export default function Analytics() { ].map((h) => ( <th key={h} - className="px-5 py-3 text-left text-[10px] font-mono" - style={{ color: "var(--text-3)" }} + style={{ + padding: "10px 20px", + textAlign: "left", + fontSize: 10, + fontFamily: "DM Mono, monospace", + color: "var(--text-3)", + fontWeight: 400, + }} > {h} </th> @@ -941,26 +900,45 @@ export default function Analytics() { style={{ borderBottom: "1px solid var(--border)" }} > <td - className="px-5 py-3 font-mono text-xs" - style={{ color: "var(--text)" }} + style={{ + padding: "10px 20px", + fontFamily: "DM Mono, monospace", + fontSize: 11, + color: "var(--text)", + }} > {name} </td> <td - className="px-5 py-3 font-mono text-xs" - style={{ color: "var(--text-2)" }} + style={{ + padding: "10px 20px", + fontFamily: "DM Mono, monospace", + fontSize: 11, + color: "var(--text-2)", + }} > {decisions} </td> - <td className="px-5 py-3"> - <div className="flex items-center gap-2"> + <td style={{ padding: "10px 20px" }}> + <div + style={{ + display: "flex", + alignItems: "center", + gap: 8, + }} + > <div - className="h-1.5 w-24 rounded-full" - style={{ background: "var(--border)" }} + style={{ + height: 6, + width: 96, + borderRadius: 3, + background: "var(--border)", + }} > <div - className="h-full rounded-full" style={{ + height: "100%", + borderRadius: 3, width: `${accuracy}%`, background: accuracy > 80 @@ -972,17 +950,23 @@ export default function Analytics() { /> </div> <span - className="font-mono text-xs" - style={{ color: "var(--text-2)" }} + style={{ + fontFamily: "DM Mono, monospace", + fontSize: 11, + color: "var(--text-2)", + }} > {accuracy}% </span> </div> </td> - <td className="px-5 py-3"> + <td style={{ padding: "10px 20px" }}> <span - className="text-[10px] font-mono px-2 py-0.5 rounded-full" style={{ + fontSize: 10, + fontFamily: "DM Mono, monospace", + padding: "3px 8px", + borderRadius: 20, background: "rgba(74,222,128,0.1)", color: "var(--green)", border: "1px solid rgba(74,222,128,0.2)", diff --git a/frontend/src/pages/CropDetails.jsx b/frontend/src/pages/CropDetails.jsx @@ -8,7 +8,6 @@ import { Wind, Zap, Activity, - Clock, } from "lucide-react"; import { AreaChart, @@ -33,8 +32,11 @@ const CustomTooltip = ({ active, payload, label }) => { if (!active || !payload?.length) return null; return ( <div - className="px-3 py-2 rounded-lg text-xs font-mono" style={{ + padding: "8px 12px", + borderRadius: 8, + fontSize: 11, + fontFamily: "DM Mono, monospace", background: "var(--surface-2)", border: "1px solid var(--border)", color: "var(--text)", @@ -53,31 +55,63 @@ const CustomTooltip = ({ active, payload, label }) => { function StatBox({ icon: Icon, label, value, color, unit }) { return ( <div - className="rounded-xl p-4" + className="card-hover" style={{ + borderRadius: 12, + padding: 16, background: "var(--surface)", border: "1px solid var(--border)", }} > - <div className="flex items-center gap-2 mb-3"> + <div + style={{ + display: "flex", + alignItems: "center", + gap: 8, + marginBottom: 10, + }} + > <div - className="w-7 h-7 rounded-lg flex items-center justify-center" - style={{ background: `${color}15`, border: `1px solid ${color}30` }} + style={{ + width: 28, + height: 28, + borderRadius: 8, + background: `${color}15`, + border: `1px solid ${color}30`, + display: "flex", + alignItems: "center", + justifyContent: "center", + }} > <Icon size={13} style={{ color }} /> </div> <span - className="text-[10px] font-mono uppercase" - style={{ color: "var(--text-3)" }} + style={{ + fontSize: 10, + fontFamily: "DM Mono, monospace", + textTransform: "uppercase", + color: "var(--text-3)", + }} > {label} </span> </div> - <div className="text-2xl font-bold font-mono" style={{ color }}> + <div + style={{ + fontSize: 22, + fontWeight: 700, + fontFamily: "DM Mono, monospace", + color, + }} + > {value} <span - className="text-sm font-normal ml-0.5" - style={{ color: "var(--text-3)" }} + style={{ + fontSize: 13, + fontWeight: 400, + marginLeft: 2, + color: "var(--text-3)", + }} > {unit} </span> @@ -86,6 +120,16 @@ function StatBox({ icon: Icon, label, value, color, unit }) { ); } +// Severity dot for logs +function logDotColor(payload) { + const outcome = (payload?.outcome || "").toLowerCase(); + if (/fail|critical|disease|error/.test(outcome)) return "var(--red)"; + if (/deteriorat|negative|attention/.test(outcome)) return "var(--amber)"; + return "var(--green)"; +} + +const TABS = ["overview", "sensors", "log"]; + export default function CropDetails() { const { cropId } = useParams(); const navigate = useNavigate(); @@ -102,14 +146,11 @@ export default function CropDetails() { (a.payload?.sequence_number || 0) - (b.payload?.sequence_number || 0), ); - const processed = sorted.map((item) => { - const p = item.payload || {}; - return { - ...item, - cleanSensors: extractSensors(p), - parsedAction: parsePythonString(p.action_taken), - }; - }); + const processed = sorted.map((item) => ({ + ...item, + cleanSensors: extractSensors(item.payload || {}), + parsedAction: parsePythonString((item.payload || {}).action_taken), + })); setHistory(processed); setLatest(processed[processed.length - 1]); } @@ -119,22 +160,46 @@ export default function CropDetails() { if (loading) return ( - <div className="flex h-screen" style={{ background: "var(--bg)" }}> + <div + style={{ display: "flex", height: "100vh", background: "var(--bg)" }} + > <Sidebar /> - <div className="flex-1 flex items-center justify-center"> - <div className="text-xs font-mono" style={{ color: "var(--text-3)" }}> + <div + style={{ + flex: 1, + display: "flex", + alignItems: "center", + justifyContent: "center", + }} + > + <span + style={{ + fontSize: 12, + fontFamily: "DM Mono, monospace", + color: "var(--text-3)", + }} + > Loading crop data… - </div> + </span> </div> </div> ); if (!latest) return ( - <div className="flex h-screen" style={{ background: "var(--bg)" }}> + <div + style={{ display: "flex", height: "100vh", background: "var(--bg)" }} + > <Sidebar /> - <div className="flex-1 flex items-center justify-center"> - <div style={{ color: "var(--text-3)" }}>Crop not found</div> + <div + style={{ + flex: 1, + display: "flex", + alignItems: "center", + justifyContent: "center", + }} + > + <span style={{ color: "var(--text-3)" }}>Crop not found</span> </div> </div> ); @@ -155,77 +220,127 @@ export default function CropDetails() { humidity: formatNumber(h.cleanSensors?.humidity), })); - const TABS = ["overview", "sensors", "log"]; - return ( <div - className="flex h-screen overflow-hidden" - style={{ background: "var(--bg)" }} + style={{ + display: "flex", + height: "100vh", + overflow: "hidden", + background: "var(--bg)", + }} > <Sidebar /> - <main className="flex-1 flex flex-col overflow-hidden"> + + <main + style={{ + flex: 1, + display: "flex", + flexDirection: "column", + overflow: "hidden", + }} + > {/* Header */} <header - className="flex-shrink-0 px-6 py-4 border-b flex items-center gap-4" - style={{ borderColor: "var(--border)", background: "var(--bg-2)" }} + style={{ + flexShrink: 0, + padding: "0 24px", + height: 64, + borderBottom: "1px solid var(--border)", + background: "var(--bg-2)", + display: "flex", + alignItems: "center", + gap: 16, + }} > <button onClick={() => navigate("/dashboard")} - className="p-2 rounded-lg transition-colors" style={{ + width: 34, + height: 34, + borderRadius: 8, background: "var(--surface)", border: "1px solid var(--border)", color: "var(--text-3)", + display: "flex", + alignItems: "center", + justifyContent: "center", + cursor: "pointer", }} > <ArrowLeft size={15} /> </button> + <div> <h1 - className="font-bold text-base" - style={{ color: "var(--text)" }} + style={{ + fontWeight: 700, + fontSize: 15, + color: "var(--text)", + margin: 0, + }} > {p.crop || "Unknown"}{" "} - <span style={{ color: "var(--text-3)" }}> + <span style={{ color: "var(--text-3)", fontWeight: 400 }}> #{p.sequence_number || 0} </span> </h1> <p - className="text-[11px] font-mono" - style={{ color: "var(--text-3)" }} + style={{ + fontSize: 11, + fontFamily: "DM Mono, monospace", + color: "var(--text-3)", + margin: 0, + }} > {cropId} · {p.stage} </p> </div> - {/* Status */} + {/* Live badge */} <div - className="flex items-center gap-1.5 ml-4 px-3 py-1.5 rounded-full text-[11px] font-mono" style={{ + display: "flex", + alignItems: "center", + gap: 6, + padding: "4px 10px", + borderRadius: 20, background: "rgba(74,222,128,0.1)", border: "1px solid rgba(74,222,128,0.25)", + fontSize: 11, + fontFamily: "DM Mono, monospace", color: "var(--green)", }} > <span - className="status-dot w-1.5 h-1.5 rounded-full" - style={{ background: "var(--green)" }} - />{" "} + className="status-dot" + style={{ + width: 6, + height: 6, + borderRadius: "50%", + background: "var(--green)", + }} + /> LIVE </div> {/* Tabs */} - <div className="ml-auto flex items-center gap-1"> + <div style={{ marginLeft: "auto", display: "flex", gap: 4 }}> {TABS.map((tab) => ( <button key={tab} onClick={() => setActiveTab(tab)} - className="px-3 py-1.5 rounded-lg text-[11px] font-mono capitalize transition-all" style={{ + padding: "5px 12px", + borderRadius: 8, + fontSize: 11, + fontFamily: "DM Mono, monospace", + textTransform: "capitalize", + cursor: "pointer", background: activeTab === tab ? "var(--surface-2)" : "transparent", color: activeTab === tab ? "var(--text)" : "var(--text-3)", border: `1px solid ${activeTab === tab ? "var(--border-bright)" : "transparent"}`, + transition: "all 0.15s", }} > {tab} @@ -234,11 +349,27 @@ export default function CropDetails() { </div> </header> - <div className="flex-1 overflow-y-auto p-6 space-y-6"> + {/* Content */} + <div + style={{ + flex: 1, + overflowY: "auto", + padding: 24, + display: "flex", + flexDirection: "column", + gap: 20, + }} + > + {/* OVERVIEW */} {activeTab === "overview" && ( <> - {/* Stat grid */} - <div className="grid grid-cols-2 md:grid-cols-4 gap-4"> + <div + style={{ + display: "grid", + gridTemplateColumns: "repeat(4, 1fr)", + gap: 12, + }} + > <StatBox icon={Thermometer} label="Temperature" @@ -269,24 +400,29 @@ export default function CropDetails() { /> </div> - {/* Main chart */} + {/* pH chart */} <div - className="rounded-xl p-5" style={{ + borderRadius: 12, + padding: 20, background: "var(--surface)", border: "1px solid var(--border)", }} > <div - className="text-[10px] font-mono mb-4" - style={{ color: "var(--text-3)" }} + style={{ + fontSize: 10, + fontFamily: "DM Mono, monospace", + color: "var(--text-3)", + marginBottom: 16, + }} > // HISTORICAL pH TRACE </div> <ResponsiveContainer width="100%" height={200}> <AreaChart data={chartData}> <defs> - <linearGradient id="phGrad2" x1="0" y1="0" x2="0" y2="1"> + <linearGradient id="phGradCD" x1="0" y1="0" x2="0" y2="1"> <stop offset="0%" stopColor="#4ade80" @@ -329,7 +465,7 @@ export default function CropDetails() { type="monotone" dataKey="ph" stroke="var(--green)" - fill="url(#phGrad2)" + fill="url(#phGradCD)" strokeWidth={2} dot={false} name="pH" @@ -338,33 +474,49 @@ export default function CropDetails() { </ResponsiveContainer> </div> - {/* AI Analysis */} + {/* AI analysis */} <div - className="rounded-xl p-5" style={{ + borderRadius: 12, + padding: 20, background: "var(--surface)", border: "1px solid var(--border)", }} > <div - className="text-[10px] font-mono mb-3" - style={{ color: "var(--text-3)" }} + style={{ + fontSize: 10, + fontFamily: "DM Mono, monospace", + color: "var(--text-3)", + marginBottom: 12, + }} > // LATEST AI ANALYSIS </div> - <div className="flex items-start gap-3"> + <div + style={{ display: "flex", alignItems: "flex-start", gap: 12 }} + > <div - className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0" style={{ + width: 32, + height: 32, + borderRadius: 8, background: "rgba(74,222,128,0.1)", border: "1px solid rgba(74,222,128,0.2)", + display: "flex", + alignItems: "center", + justifyContent: "center", + flexShrink: 0, }} > <Zap size={14} style={{ color: "var(--green)" }} /> </div> <div - className="text-sm leading-relaxed" - style={{ color: "var(--text-2)" }} + style={{ + fontSize: 13, + lineHeight: 1.6, + color: "var(--text-2)", + }} > {formatOutcome(p.outcome) || "System monitoring active. No anomalies detected."} @@ -372,34 +524,43 @@ export default function CropDetails() { </div> {p.action_taken && p.action_taken !== "PENDING_ACTION" && ( <div - className="mt-3 p-3 rounded-lg font-mono text-xs" style={{ + marginTop: 12, + padding: 10, + borderRadius: 8, + fontFamily: "DM Mono, monospace", + fontSize: 11, background: "var(--bg-3)", border: "1px solid var(--border)", color: "var(--text-3)", }} > <span style={{ color: "var(--green)" }}>ACTION: </span> - {p.action_taken?.substring(0, 200)}... + {p.action_taken?.substring(0, 200)}… </div> )} </div> </> )} + {/* SENSORS */} {activeTab === "sensors" && ( - <div className="space-y-6"> - {/* Temp + Humidity */} + <> <div - className="rounded-xl p-5" style={{ + borderRadius: 12, + padding: 20, background: "var(--surface)", border: "1px solid var(--border)", }} > <div - className="text-[10px] font-mono mb-4" - style={{ color: "var(--text-3)" }} + style={{ + fontSize: 10, + fontFamily: "DM Mono, monospace", + color: "var(--text-3)", + marginBottom: 16, + }} > // TEMP & HUMIDITY </div> @@ -450,24 +611,28 @@ export default function CropDetails() { </ResponsiveContainer> </div> - {/* EC */} <div - className="rounded-xl p-5" style={{ + borderRadius: 12, + padding: 20, background: "var(--surface)", border: "1px solid var(--border)", }} > <div - className="text-[10px] font-mono mb-4" - style={{ color: "var(--text-3)" }} + style={{ + fontSize: 10, + fontFamily: "DM Mono, monospace", + color: "var(--text-3)", + marginBottom: 16, + }} > // EC CONCENTRATION </div> <ResponsiveContainer width="100%" height={180}> <AreaChart data={chartData}> <defs> - <linearGradient id="ecGrad2" x1="0" y1="0" x2="0" y2="1"> + <linearGradient id="ecGradCD" x1="0" y1="0" x2="0" y2="1"> <stop offset="0%" stopColor="#f59e0b" @@ -509,7 +674,7 @@ export default function CropDetails() { type="monotone" dataKey="ec" stroke="var(--amber)" - fill="url(#ecGrad2)" + fill="url(#ecGradCD)" strokeWidth={2} dot={false} name="EC dS/m" @@ -517,82 +682,137 @@ export default function CropDetails() { </AreaChart> </ResponsiveContainer> </div> - </div> + </> )} + {/* LOG */} {activeTab === "log" && ( <div - className="rounded-xl overflow-hidden" style={{ + borderRadius: 12, + overflow: "hidden", background: "var(--surface)", border: "1px solid var(--border)", }} > + {/* Header row */} <div - className="p-4 border-b" - style={{ borderColor: "var(--border)" }} + style={{ + padding: "12px 20px", + borderBottom: "1px solid var(--border)", + }} > <div - className="text-[10px] font-mono" - style={{ color: "var(--text-3)" }} + style={{ + fontSize: 10, + fontFamily: "DM Mono, monospace", + color: "var(--text-3)", + }} > // EVENT LOG — {history.length} ENTRIES </div> </div> - <div - className="divide-y" - style={{ borderColor: "var(--border)" }} - > + + {/* Log rows */} + <div> {[...history] .reverse() .slice(0, 20) .map((h, i) => ( <div key={i} - className="flex gap-4 px-5 py-3 hover:bg-opacity-50 transition-colors" style={{ + display: "flex", + alignItems: "center", + gap: 12, + padding: "10px 20px", background: i % 2 === 0 ? "transparent" - : "rgba(255,255,255,0.01)", + : "rgba(255,255,255,0.015)", + transition: "background 0.15s", }} + onMouseEnter={(e) => + (e.currentTarget.style.background = + "rgba(74,222,128,0.04)") + } + onMouseLeave={(e) => + (e.currentTarget.style.background = + i % 2 === 0 + ? "transparent" + : "rgba(255,255,255,0.015)") + } > - <div - className="flex items-center gap-1 text-[10px] font-mono flex-shrink-0 w-16" - style={{ color: "var(--text-3)" }} + {/* Severity dot */} + <span + style={{ + width: 7, + height: 7, + borderRadius: "50%", + background: logDotColor(h.payload), + flexShrink: 0, + }} + /> + + {/* Timestamp */} + <span + style={{ + fontSize: 10, + fontFamily: "DM Mono, monospace", + color: "var(--text-3)", + flexShrink: 0, + width: 50, + }} > - <Clock size={9} /> {h.payload?.timestamp ? new Date(h.payload.timestamp).toLocaleTimeString( [], { hour: "2-digit", minute: "2-digit" }, ) : "--"} - </div> - <div className="flex-1 min-w-0"> - <div - className="text-xs font-mono" - style={{ color: "var(--text-2)" }} - > - pH {formatNumber(h.cleanSensors?.ph)} ·{" "} - {formatNumber(h.cleanSensors?.temp)}°C · EC{" "} - {formatNumber(h.cleanSensors?.ec)} - </div> - <div - className="text-[11px] mt-0.5 truncate" - style={{ color: "var(--text-3)" }} - > - {formatOutcome(h.payload?.outcome) || - h.payload?.action_taken || - "Routine check"} - </div> - </div> - <div - className="text-[10px] font-mono flex-shrink-0" - style={{ color: "var(--text-3)" }} + </span> + + {/* Seq # */} + <span + style={{ + fontSize: 10, + fontFamily: "DM Mono, monospace", + color: "var(--text-3)", + width: 34, + flexShrink: 0, + }} > #{h.payload?.sequence_number || i} - </div> + </span> + + {/* Sensor snapshot */} + <span + style={{ + fontSize: 11, + fontFamily: "DM Mono, monospace", + color: "var(--text-2)", + flexShrink: 0, + }} + > + pH {formatNumber(h.cleanSensors?.ph)} ·{" "} + {formatNumber(h.cleanSensors?.temp)}°C · EC{" "} + {formatNumber(h.cleanSensors?.ec)} + </span> + + {/* Outcome / action */} + <span + style={{ + fontSize: 11, + color: "var(--text-3)", + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + }} + > + {formatOutcome(h.payload?.outcome) || + h.payload?.action_taken || + "Routine check"} + </span> </div> ))} </div> diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx @@ -50,18 +50,28 @@ function CropCard({ data, onClick }) { return ( <div onClick={onClick} - className="rounded-2xl overflow-hidden cursor-pointer card-hover" + className="card-hover" style={{ + borderRadius: 16, + overflow: "hidden", + cursor: "pointer", background: "var(--surface)", border: "1px solid var(--border)", }} > - {/* Image / Gradient header */} + {/* Image header */} <div - className="relative h-36 overflow-hidden" - style={{ background: "var(--bg-3)" }} + style={{ position: "relative", height: 136, background: "var(--bg-3)" }} > - <div className="absolute inset-0 flex items-center justify-center"> + <div + style={{ + position: "absolute", + inset: 0, + display: "flex", + alignItems: "center", + justifyContent: "center", + }} + > <Leaf size={40} style={{ color: "var(--border-bright)", opacity: 0.4 }} @@ -71,24 +81,39 @@ function CropCard({ data, onClick }) { <img src={data.image} alt={data.name} - className="absolute inset-0 w-full h-full object-cover opacity-70" + style={{ + position: "absolute", + inset: 0, + width: "100%", + height: "100%", + objectFit: "cover", + opacity: 0.7, + }} onError={(e) => { e.target.style.display = "none"; }} /> )} - {/* Overlay */} + {/* Bottom fade */} <div - className="absolute inset-0" style={{ + position: "absolute", + inset: 0, background: "linear-gradient(to top, var(--surface) 0%, transparent 60%)", }} /> + {/* Status badge */} <div - className="absolute top-3 right-3 text-[10px] font-mono px-2 py-1 rounded-full" style={{ + position: "absolute", + top: 10, + right: 10, + fontSize: 10, + fontFamily: "DM Mono, monospace", + padding: "3px 8px", + borderRadius: 20, background: st.bg, color: st.text, border: `1px solid ${st.border}`, @@ -101,24 +126,44 @@ function CropCard({ data, onClick }) { : "◆ "} {data.status.toUpperCase()} </div> - {/* Seq number */} + {/* Seq badge */} <div - className="absolute top-3 left-3 text-[10px] font-mono px-2 py-0.5 rounded" - style={{ background: "rgba(0,0,0,0.5)", color: "var(--text-3)" }} + style={{ + position: "absolute", + top: 10, + left: 10, + fontSize: 10, + fontFamily: "DM Mono, monospace", + padding: "2px 6px", + borderRadius: 4, + background: "rgba(0,0,0,0.5)", + color: "var(--text-3)", + }} > #{data.seq || 1} </div> </div> - <div className="p-4 space-y-3"> - {/* Name + stage */} + <div + style={{ + padding: 14, + display: "flex", + flexDirection: "column", + gap: 10, + }} + > + {/* Name */} <div> - <div className="font-bold text-sm" style={{ color: "var(--text)" }}> + <div style={{ fontWeight: 700, fontSize: 13, color: "var(--text)" }}> {data.name} </div> <div - className="text-[11px] mt-0.5 font-mono" - style={{ color: "var(--text-3)" }} + style={{ + fontSize: 10, + marginTop: 2, + fontFamily: "DM Mono, monospace", + color: "var(--text-3)", + }} > {data.cropId} · {data.statusMsg} </div> @@ -127,20 +172,27 @@ function CropCard({ data, onClick }) { {/* Maturity bar */} <div> <div - className="flex justify-between text-[10px] font-mono mb-1" - style={{ color: "var(--text-3)" }} + style={{ + display: "flex", + justifyContent: "space-between", + fontSize: 10, + fontFamily: "DM Mono, monospace", + color: "var(--text-3)", + marginBottom: 4, + }} > <span>Maturity</span> <span style={{ color: "var(--green)" }}>{maturity}%</span> </div> <div - className="h-1 rounded-full" - style={{ background: "var(--border)" }} + style={{ height: 3, borderRadius: 2, background: "var(--border)" }} > <div - className="h-full rounded-full progress-fill" + className="progress-fill" style={{ width: `${maturity}%`, + height: "100%", + borderRadius: 2, background: maturity > 70 ? "var(--green)" @@ -152,22 +204,30 @@ function CropCard({ data, onClick }) { </div> </div> - {/* Sensor row */} - <div className="grid grid-cols-2 gap-2 pt-1"> - <div className="flex items-center gap-1.5"> + {/* Sensor pair */} + <div + style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }} + > + <div style={{ display: "flex", alignItems: "center", gap: 5 }}> <Thermometer size={12} style={{ color: "var(--text-3)" }} /> <span - className="text-xs font-mono" - style={{ color: "var(--text-2)" }} + style={{ + fontSize: 11, + fontFamily: "DM Mono, monospace", + color: "var(--text-2)", + }} > {data.sensors.temp}°C </span> </div> - <div className="flex items-center gap-1.5"> + <div style={{ display: "flex", alignItems: "center", gap: 5 }}> <Droplet size={12} style={{ color: "var(--text-3)" }} /> <span - className="text-xs font-mono" - style={{ color: "var(--text-2)" }} + style={{ + fontSize: 11, + fontFamily: "DM Mono, monospace", + color: "var(--text-2)", + }} > pH {data.sensors.ph} </span> @@ -176,12 +236,22 @@ function CropCard({ data, onClick }) { {/* Footer */} <div - className="flex items-center justify-between pt-1 border-t" - style={{ borderColor: "var(--border)" }} + style={{ + display: "flex", + alignItems: "center", + justifyContent: "space-between", + paddingTop: 8, + borderTop: "1px solid var(--border)", + }} > <div - className="flex items-center gap-1 text-[10px]" - style={{ color: "var(--text-3)" }} + style={{ + display: "flex", + alignItems: "center", + gap: 4, + fontSize: 10, + color: "var(--text-3)", + }} > <Clock size={10} /> {data.daysLeft > 0 ? `${data.daysLeft}d left` : "Ready"} @@ -203,6 +273,18 @@ export default function Dashboard() { const [filterStatus, setFilterStatus] = useState("All"); const [showFilters, setShowFilters] = useState(false); + const getImg = (name) => { + if (!name) return null; + const n = name.toLowerCase(); + if (n.includes("tomato")) + return "https://images.unsplash.com/photo-1591857177580-dc82b9e4e5c9?q=80&w=400"; + if (n.includes("basil")) + return "https://images.unsplash.com/photo-1618164436241-4473940d1f5c?q=80&w=400"; + if (n.includes("spinach")) + return "https://images.unsplash.com/photo-1576045057995-568f588f82fb?q=80&w=400"; + return "https://images.unsplash.com/photo-1622206151226-18ca2c9ab4a1?q=80&w=400"; + }; + useEffect(() => { if (dashboard) { setCrops( @@ -228,34 +310,24 @@ export default function Dashboard() { } }, [dashboard]); - const getImg = (name) => { - if (!name) return null; - const n = name.toLowerCase(); - if (n.includes("tomato")) - return "https://images.unsplash.com/photo-1591857177580-dc82b9e4e5c9?q=80&w=400"; - if (n.includes("basil")) - return "https://images.unsplash.com/photo-1618164436241-4473940d1f5c?q=80&w=400"; - if (n.includes("spinach")) - return "https://images.unsplash.com/photo-1576045057995-568f588f82fb?q=80&w=400"; - return "https://images.unsplash.com/photo-1622206151226-18ca2c9ab4a1?q=80&w=400"; - }; - - const filtered = useMemo(() => { - return crops.filter((c) => { - const q = search.toLowerCase(); - if ( - q && - !c.name.toLowerCase().includes(q) && - !c.cropId.toLowerCase().includes(q) && - !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; - return true; - }); - }, [crops, search, filterStage, filterCrop, filterStatus]); + const filtered = useMemo( + () => + crops.filter((c) => { + const q = search.toLowerCase(); + if ( + q && + !c.name.toLowerCase().includes(q) && + !c.cropId.toLowerCase().includes(q) && + !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; + return true; + }), + [crops, search, filterStage, filterCrop, filterStatus], + ); const activeFilters = [filterStage, filterCrop, filterStatus].filter( (f) => f !== "All", @@ -273,28 +345,68 @@ export default function Dashboard() { return ( <div - className="flex h-screen overflow-hidden" - style={{ background: "var(--bg)" }} + style={{ + display: "flex", + height: "100vh", + overflow: "hidden", + background: "var(--bg)", + }} > <Sidebar /> - <main className="flex-1 flex flex-col overflow-hidden"> - {/* Header */} + <main + style={{ + flex: 1, + display: "flex", + flexDirection: "column", + overflow: "hidden", + }} + > + {/* ── Header — 64px, border aligns with sidebar ── */} <header - className="flex-shrink-0 px-6 py-4 border-b flex items-center gap-4" - style={{ borderColor: "var(--border)", background: "var(--bg-2)" }} + style={{ + flexShrink: 0, + padding: "0 24px", + height: 64, + borderBottom: "1px solid var(--border)", + background: "var(--bg-2)", + display: "flex", + alignItems: "center", + gap: 16, + }} > <div> - <h1 className="font-bold text-lg" style={{ color: "var(--text)" }}> + <h1 + style={{ + fontWeight: 700, + fontSize: 18, + color: "var(--text)", + margin: 0, + }} + > Crops Overview </h1> - <p className="text-xs font-mono" style={{ color: "var(--text-3)" }}> + <p + style={{ + fontSize: 11, + fontFamily: "DM Mono, monospace", + color: "var(--text-3)", + margin: 0, + }} + > {filtered.length} of {crops.length} crops shown </p> </div> {/* Summary chips */} - <div className="hidden md:flex items-center gap-2 ml-4"> + <div + style={{ + display: "flex", + alignItems: "center", + gap: 8, + marginLeft: 16, + }} + > {[ { label: "Healthy", @@ -314,10 +426,16 @@ export default function Dashboard() { ].map(({ label, count, color }) => ( <div key={label} - className="flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] font-mono" style={{ + display: "flex", + alignItems: "center", + gap: 5, + padding: "3px 10px", + borderRadius: 20, background: "var(--surface)", border: "1px solid var(--border)", + fontSize: 11, + fontFamily: "DM Mono, monospace", color, }} > @@ -327,51 +445,86 @@ export default function Dashboard() { ))} </div> - <div className="ml-auto flex items-center gap-2"> - <button - onClick={refreshData} - className="p-2 rounded-lg transition-colors" - style={{ - background: "var(--surface)", - border: "1px solid var(--border)", - color: "var(--text-3)", - }} - > - <RefreshCw size={15} className={loading ? "animate-spin" : ""} /> - </button> - </div> + <button + onClick={refreshData} + style={{ + marginLeft: "auto", + width: 34, + height: 34, + borderRadius: 8, + background: "var(--surface)", + border: "1px solid var(--border)", + color: "var(--text-3)", + display: "flex", + alignItems: "center", + justifyContent: "center", + cursor: "pointer", + }} + > + <RefreshCw size={15} className={loading ? "animate-spin" : ""} /> + </button> </header> {/* Search + Filter bar */} <div - className="flex-shrink-0 px-6 py-3 border-b flex items-center gap-3" - style={{ borderColor: "var(--border)", background: "var(--bg-2)" }} + style={{ + flexShrink: 0, + padding: "8px 24px", + borderBottom: "1px solid var(--border)", + background: "var(--bg-2)", + display: "flex", + alignItems: "center", + gap: 12, + }} > {/* Search */} - <div className="relative flex-1 max-w-md"> + <div style={{ position: "relative", flex: 1, maxWidth: 360 }}> <Search size={14} - className="absolute left-3 top-1/2 -translate-y-1/2" - style={{ color: "var(--text-3)" }} + style={{ + position: "absolute", + left: 10, + top: "50%", + transform: "translateY(-50%)", + color: "var(--text-3)", + }} /> <input value={search} onChange={(e) => setSearch(e.target.value)} placeholder="Search crops, IDs, stages…" - className="w-full pl-9 pr-4 py-2 rounded-lg text-sm font-mono outline-none transition-all" style={{ + width: "100%", + paddingLeft: 32, + paddingRight: search ? 28 : 12, + paddingTop: 7, + paddingBottom: 7, + borderRadius: 8, + fontSize: 12, + fontFamily: "DM Mono, monospace", background: "var(--surface)", border: "1px solid var(--border)", color: "var(--text)", + outline: "none", caretColor: "var(--green)", }} /> {search && ( <button onClick={() => setSearch("")} - className="absolute right-3 top-1/2 -translate-y-1/2" + style={{ + position: "absolute", + right: 8, + top: "50%", + transform: "translateY(-50%)", + background: "none", + border: "none", + cursor: "pointer", + padding: 0, + color: "var(--text-3)", + }} > - <X size={12} style={{ color: "var(--text-3)" }} /> + <X size={12} /> </button> )} </div> @@ -379,40 +532,59 @@ export default function Dashboard() { {/* Filter toggle */} <button onClick={() => setShowFilters(!showFilters)} - className="flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-all" style={{ + display: "flex", + alignItems: "center", + gap: 6, + padding: "7px 12px", + borderRadius: 8, + fontSize: 12, + cursor: "pointer", background: showFilters ? "rgba(74,222,128,0.1)" : "var(--surface)", border: `1px solid ${showFilters ? "rgba(74,222,128,0.3)" : "var(--border)"}`, color: showFilters ? "var(--green)" : "var(--text-2)", + transition: "all 0.15s", }} > - <SlidersHorizontal size={14} /> Filters + <SlidersHorizontal size={13} /> + Filters {activeFilters > 0 && ( <span - className="px-1.5 py-0.5 rounded text-[10px] font-mono" - style={{ background: "var(--green)", color: "#0c1a0e" }} + style={{ + padding: "0 5px", + borderRadius: 4, + fontSize: 10, + fontFamily: "DM Mono, monospace", + background: "var(--green)", + color: "#0c1a0e", + }} > {activeFilters} </span> )} </button> - {/* Quick category pills */} - <div className="hidden lg:flex items-center gap-2"> + {/* Quick stage pills */} + <div style={{ display: "flex", alignItems: "center", gap: 6 }}> {STAGES.slice(0, 4).map((s) => ( <button key={s} onClick={() => setFilterStage(filterStage === s ? "All" : s)} - className="px-3 py-1.5 rounded-full text-[11px] font-mono transition-all" style={{ + padding: "5px 12px", + borderRadius: 20, + fontSize: 11, + fontFamily: "DM Mono, monospace", + cursor: "pointer", background: filterStage === s ? "rgba(74,222,128,0.15)" : "var(--surface)", border: `1px solid ${filterStage === s ? "rgba(74,222,128,0.4)" : "var(--border)"}`, color: filterStage === s ? "var(--green)" : "var(--text-3)", + transition: "all 0.15s", }} > {s} @@ -424,8 +596,16 @@ export default function Dashboard() { {/* Expanded filters */} {showFilters && ( <div - className="flex-shrink-0 px-6 py-3 border-b flex items-center gap-6 animate-fade-in" - style={{ borderColor: "var(--border)", background: "var(--bg-3)" }} + className="animate-fade-in" + style={{ + flexShrink: 0, + padding: "8px 24px", + borderBottom: "1px solid var(--border)", + background: "var(--bg-3)", + display: "flex", + alignItems: "center", + gap: 24, + }} > {[ { @@ -447,22 +627,34 @@ export default function Dashboard() { opts: STATUSES, }, ].map(({ label, value, set, opts }) => ( - <div key={label} className="flex items-center gap-2"> + <div + key={label} + style={{ display: "flex", alignItems: "center", gap: 8 }} + > <span - className="text-[11px] font-mono" - style={{ color: "var(--text-3)" }} + style={{ + fontSize: 11, + fontFamily: "DM Mono, monospace", + color: "var(--text-3)", + }} > {label} </span> - <div className="relative"> + <div style={{ position: "relative" }}> <select value={value} onChange={(e) => set(e.target.value)} - className="appearance-none pl-3 pr-7 py-1.5 rounded-lg text-xs font-mono outline-none cursor-pointer" style={{ + appearance: "none", + padding: "5px 24px 5px 10px", + borderRadius: 8, + fontSize: 11, + fontFamily: "DM Mono, monospace", background: "var(--surface)", border: "1px solid var(--border)", color: "var(--text-2)", + cursor: "pointer", + outline: "none", }} > {opts.map((o) => ( @@ -473,8 +665,14 @@ export default function Dashboard() { </select> <ChevronDown size={10} - className="absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none" - style={{ color: "var(--text-3)" }} + style={{ + position: "absolute", + right: 8, + top: "50%", + transform: "translateY(-50%)", + pointerEvents: "none", + color: "var(--text-3)", + }} /> </div> </div> @@ -486,8 +684,15 @@ export default function Dashboard() { setFilterStatus("All"); setSearch(""); }} - className="ml-auto text-[11px] font-mono transition-colors" - style={{ color: "var(--text-3)" }} + style={{ + marginLeft: "auto", + fontSize: 11, + fontFamily: "DM Mono, monospace", + background: "none", + border: "none", + cursor: "pointer", + color: "var(--text-3)", + }} > Clear all </button> @@ -495,21 +700,37 @@ export default function Dashboard() { )} {/* Crop grid */} - <div className="flex-1 overflow-y-auto p-6"> + <div style={{ flex: 1, overflowY: "auto", padding: 24 }}> {loading ? ( - <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4"> + <div + style={{ + display: "grid", + gridTemplateColumns: "repeat(auto-fill, minmax(200px, 1fr))", + gap: 16, + }} + > {Array(8) .fill(0) .map((_, i) => ( <div key={i} - className="h-64 rounded-2xl shimmer" - style={{ border: "1px solid var(--border)" }} + className="shimmer" + style={{ + height: 260, + borderRadius: 16, + border: "1px solid var(--border)", + }} /> ))} </div> ) : filtered.length > 0 ? ( - <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4"> + <div + style={{ + display: "grid", + gridTemplateColumns: "repeat(auto-fill, minmax(200px, 1fr))", + gap: 16, + }} + > {filtered.map((crop) => ( <CropCard key={crop.id} @@ -519,15 +740,29 @@ export default function Dashboard() { ))} </div> ) : ( - <div className="flex flex-col items-center justify-center h-full gap-4"> + <div + style={{ + display: "flex", + flexDirection: "column", + alignItems: "center", + justifyContent: "center", + height: "100%", + gap: 16, + }} + > <div - className="w-16 h-16 rounded-2xl flex items-center justify-center" style={{ + width: 56, + height: 56, + borderRadius: 16, background: "var(--surface)", border: "1px solid var(--border)", + display: "flex", + alignItems: "center", + justifyContent: "center", }} > - <Activity size={28} style={{ color: "var(--text-3)" }} /> + <Activity size={24} style={{ color: "var(--text-3)" }} /> </div> <div style={{ color: "var(--text-2)" }}> No crops match your filters @@ -539,11 +774,15 @@ export default function Dashboard() { setFilterCrop("All"); setFilterStatus("All"); }} - className="text-xs font-mono px-4 py-2 rounded-lg" style={{ + fontSize: 11, + fontFamily: "DM Mono, monospace", + padding: "6px 16px", + borderRadius: 8, background: "var(--surface)", border: "1px solid var(--border)", color: "var(--text-3)", + cursor: "pointer", }} > Clear filters diff --git a/frontend/src/utils/dataUtils.js b/frontend/src/utils/dataUtils.js @@ -1,8 +1,11 @@ -export const formatNumber = (val) => { +// Number formatting +export const formatNumber = (val, decimals = 2) => { if (val === undefined || val === null || isNaN(parseFloat(val))) return "0"; - return Math.round(parseFloat(val) * 100) / 100; + const factor = Math.pow(10, decimals); + return Math.round(parseFloat(val) * factor) / factor; }; +// Python dict / JSON string parser export const parsePythonString = (str) => { if (!str) return null; if (typeof str === "object") return str; @@ -22,9 +25,12 @@ export const parsePythonString = (str) => { } }; +// Sensor extraction export const extractSensors = (payload) => { if (!payload) return { temp: 0, ph: 0, humidity: 0, ec: 0 }; + let raw = payload.sensors || payload.sensor_data; + if (!raw) { const action = parsePythonString(payload.action_taken); if (action) { @@ -38,6 +44,7 @@ export const extractSensors = (payload) => { raw = {}; } } + return { temp: formatNumber(raw.temp ?? raw.Temp ?? 0), ph: formatNumber(raw.ph ?? raw.pH ?? 7.0), @@ -46,10 +53,10 @@ export const extractSensors = (payload) => { }; }; -export const calculateMaturity = (seq) => { - return Math.min((seq || 1) * 10, 100); -}; +// Plant maturity (based on cycle count) +export const calculateMaturity = (seq) => Math.min((seq || 1) * 10, 100); +// Outcome string formatting export const formatOutcome = (outcome) => { if (!outcome || typeof outcome !== "string") return "Monitoring..."; @@ -57,8 +64,10 @@ export const formatOutcome = (outcome) => { const cleanOutcome = outcome.split("| Reward:")[0].trim(); const parts = cleanOutcome.split("|").map((p) => p.trim()); - let tags = [], - notes = ""; + + let tags = []; + let notes = ""; + parts.forEach((part) => { if (part.startsWith("condition_assessed")) { const v = part.replace("condition_assessed", "").trim(); @@ -72,6 +81,7 @@ export const formatOutcome = (outcome) => { tags.push(part); } }); + if (!tags.length && !notes) return cleanOutcome; const t = tags.join(" · "); return t && notes ? `${t} — ${notes}` : t || notes; @@ -80,6 +90,7 @@ export const formatOutcome = (outcome) => { // Crop health status export const deriveCropStatus = (payload) => { if (!payload) return "Healthy"; + const s = extractSensors(payload); const ph = parseFloat(s.ph) || 0; const ec = parseFloat(s.ec) || 0; @@ -87,7 +98,7 @@ export const deriveCropStatus = (payload) => { const outcome = (payload.outcome || "").toLowerCase(); const action = (payload.action_taken || "").toUpperCase(); - // Critical Thresholds + // Critical thresholds if ( (ph > 0 && ph < 4.5) || ph > 7.5 || @@ -96,11 +107,10 @@ export const deriveCropStatus = (payload) => { temp > 35 || /fail|critical|disease|error/.test(outcome) || /DISEASE|FUNGAL|PEST/.test(action) - ) { + ) return "Critical"; - } - // Warning/Attention Thresholds + // Warning thresholds if ( (ph > 0 && ph < 5.5) || ph > 6.5 || @@ -109,9 +119,397 @@ export const deriveCropStatus = (payload) => { temp > 30 || /deteriorat|negative|attention|decline/.test(outcome) || /FLUSH|PRUNE|BOOST/.test(action) - ) { + ) return "Attention"; - } return "Healthy"; }; + +// Alert generation +function timeAgo(isoString) { + if (!isoString) return "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`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `${hrs} hr ago`; + return `${Math.floor(hrs / 24)} days ago`; +} + +export const generateAlerts = (points) => { + const alerts = []; + let id = 1; + + for (const p of points) { + const payload = p.payload || {}; + const s = extractSensors(payload); + const ph = parseFloat(s.ph) || 0; + const ec = parseFloat(s.ec) || 0; + const temp = parseFloat(s.temp) || 0; + const ts = payload.timestamp; + const cropId = payload.crop_id || "UNKNOWN"; + const cropName = payload.crop || "Crop"; + const action = (payload.action_taken || "").toUpperCase(); + const outcome = (payload.outcome || "").toLowerCase(); + const strategy = (payload.strategic_intent || "").toUpperCase(); + const seq = payload.sequence_number; + + // 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), + ts, + agent: "WATER", + crop: cropName, + ack: false, + }); + else if (ph > 7.5) + alerts.push({ + id: id++, + severity: "critical", + title: "pH critically high", + desc: `${cropName} (${cropId}): pH at ${ph} — acid dosing required immediately.`, + time: timeAgo(ts), + ts, + agent: "WATER", + crop: cropName, + ack: false, + }); + + if (ec > 3.5) + 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), + ts, + agent: "WATER", + crop: cropName, + ack: false, + }); + + if (temp > 0 && temp < 10) + alerts.push({ + id: id++, + severity: "critical", + title: "Temperature too cold", + desc: `${cropName} (${cropId}): Air temp at ${temp}°C — root damage risk.`, + time: timeAgo(ts), + ts, + agent: "ATMOSPHERIC", + crop: cropName, + ack: false, + }); + else if (temp > 35) + 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), + ts, + agent: "ATMOSPHERIC", + crop: cropName, + ack: false, + }); + + if ( + /disease|fungal|pest|mildew|blight|mite|rot/.test(outcome) || + /DISEASE|FUNGAL|PEST/.test(action) + ) + alerts.push({ + id: id++, + severity: "critical", + title: "Disease or pest detected", + desc: `${cropName} (${cropId}): Visual anomaly. Outcome: "${formatOutcome(payload.outcome)}"`, + time: timeAgo(ts), + ts, + agent: "DOCTOR", + crop: cropName, + ack: false, + }); + + if (/fail|critical|error/.test(outcome)) + alerts.push({ + id: id++, + severity: "critical", + title: "Cycle failure recorded", + desc: `${cropName} (${cropId}): Seq #${seq} outcome: "${formatOutcome(payload.outcome)}"`, + time: timeAgo(ts), + ts, + agent: "JUDGE", + crop: cropName, + ack: false, + }); + + // Warning + if (ph >= 4.5 && ph < 5.5) + 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), + ts, + agent: "WATER", + crop: cropName, + ack: false, + }); + else if (ph > 6.6 && ph <= 7.5) + 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), + ts, + agent: "WATER", + crop: cropName, + ack: false, + }); + + if (ec >= 2.5 && ec <= 3.5) + 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), + ts, + agent: "SUPERVISOR", + crop: cropName, + ack: false, + }); + + if (temp >= 10 && temp < 17) + alerts.push({ + id: id++, + severity: "warning", + title: "Temperature on the low side", + desc: `${cropName} (${cropId}): ${temp}°C — slow growth expected.`, + time: timeAgo(ts), + ts, + agent: "ATMOSPHERIC", + crop: cropName, + ack: false, + }); + else if (temp >= 30 && temp <= 35) + alerts.push({ + id: id++, + severity: "warning", + title: "Temperature elevated", + desc: `${cropName} (${cropId}): ${temp}°C — heat stress likely.`, + time: timeAgo(ts), + ts, + agent: "ATMOSPHERIC", + crop: cropName, + ack: false, + }); + + if (/deteriorat|negative|attention|decline/.test(outcome)) + alerts.push({ + id: id++, + severity: "warning", + title: "Condition deteriorating", + desc: `${cropName} (${cropId}): Seq #${seq} — "${formatOutcome(payload.outcome)}"`, + time: timeAgo(ts), + ts, + agent: "JUDGE", + crop: cropName, + ack: false, + }); + + // Info + if (seq && !/fail|critical|error|deteriorat|negative/.test(outcome)) + 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), + 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 count = seen.get(key) || 0; + if (count < 2) { + deduped.push(a); + seen.set(key, count + 1); + } + } + + const sevOrder = { critical: 0, warning: 1, info: 2 }; + deduped.sort((a, b) => + sevOrder[a.severity] !== sevOrder[b.severity] + ? sevOrder[a.severity] - sevOrder[b.severity] + : new Date(b.ts || 0) - new Date(a.ts || 0), + ); + + return deduped; +}; + +// Analytics helpers +export const avg = (arr) => { + if (!arr.length) return 0; + return arr.reduce((s, v) => s + v, 0) / arr.length; +}; + +export const safePct = (current, previous) => { + if (!previous) return 0; + return parseFloat((((current - previous) / previous) * 100).toFixed(1)); +}; + +export const bucketHistory = (points, range) => { + if (!points.length) return []; + + const now = Date.now(); + const MS = { "24h": 86400000, "7d": 604800000, "30d": 2592000000 }; + const cutoff = now - (MS[range] || MS["24h"]); + + const filtered = points.filter( + (p) => new Date(p.payload?.timestamp || 0).getTime() >= cutoff, + ); + if (!filtered.length) return []; + + const bucketSize = range === "24h" ? 3600000 : 86400000; + const buckets = new Map(); + + for (const p of filtered) { + const t = new Date(p.payload?.timestamp || 0).getTime(); + const key = Math.floor(t / bucketSize) * bucketSize; + if (!buckets.has(key)) buckets.set(key, []); + buckets.get(key).push(p); + } + + return Array.from(buckets.entries()) + .sort(([a], [b]) => a - b) + .map(([ts, pts]) => { + const date = new Date(ts); + const label = + range === "24h" + ? date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) + : date.toLocaleDateString([], { month: "short", day: "numeric" }); + const sensors = pts.map((p) => extractSensors(p.payload)); + return { + label, + ph: parseFloat( + avg(sensors.map((s) => parseFloat(s.ph) || 0)).toFixed(2), + ), + ec: parseFloat( + avg(sensors.map((s) => parseFloat(s.ec) || 0)).toFixed(2), + ), + temp: parseFloat( + avg(sensors.map((s) => parseFloat(s.temp) || 0)).toFixed(1), + ), + humidity: parseFloat( + avg(sensors.map((s) => parseFloat(s.humidity) || 0)).toFixed(1), + ), + count: pts.length, + }; + }); +}; + +export const dailyCropActivity = (points) => { + const map = new Map(); + for (const p of points) { + const ts = p.payload?.timestamp; + if (!ts) continue; + const day = new Date(ts).toLocaleDateString([], { + month: "short", + day: "numeric", + }); + map.set(day, (map.get(day) || 0) + 1); + } + const last7 = Array.from(map.entries()).slice(-7); + const maxVal = Math.max(...last7.map((e) => e[1]), 1); + return last7.map(([d, count]) => ({ + d, + count, + target: Math.ceil(maxVal * 1.2), + })); +}; + +export const buildRadar = (points) => { + if (!points.length) return []; + const sensors = points.map((p) => extractSensors(p.payload)); + const check = (vals, lo, hi) => { + const inRange = vals.filter((v) => v >= lo && v <= hi).length; + return Math.round((inRange / vals.length) * 100); + }; + return [ + { + metric: "pH", + value: check( + sensors.map((s) => parseFloat(s.ph)), + 5.5, + 6.5, + ), + }, + { + metric: "EC", + value: check( + sensors.map((s) => parseFloat(s.ec)), + 0.8, + 2.5, + ), + }, + { + metric: "Temp", + value: check( + sensors.map((s) => parseFloat(s.temp)), + 18, + 28, + ), + }, + { + metric: "Humidity", + value: check( + sensors.map((s) => parseFloat(s.humidity)), + 40, + 80, + ), + }, + ]; +}; + +export const buildAgentStats = (points) => { + const AGENTS = ["SUPERVISOR", "WATER", "ATMOSPHERIC", "JUDGE", "DOCTOR"]; + const counts = Object.fromEntries(AGENTS.map((a) => [a, 0])); + for (const p of points) { + const text = + `${p.payload?.action_taken || ""} ${p.payload?.strategic_intent || ""}`.toUpperCase(); + for (const agent of AGENTS) if (text.includes(agent)) counts[agent]++; + counts["SUPERVISOR"]++; + } + const total = Math.max(points.length, 1); + return AGENTS.map((name) => ({ + name, + decisions: counts[name], + accuracy: points.length + ? Math.round( + (points.filter( + (p) => + !/fail|negative|critical/.test( + (p.payload?.outcome || "").toLowerCase(), + ), + ).length / + total) * + 100, + ) + : 0, + })).filter((a) => a.decisions > 0); +};