commit c7ef3924cce81c4dc65378794623a2f238b02279
parent 782803d81fe1396e74c3d7990f5c2f99569a8d0b
Author: maydayv7 <maydayv7@gmail.com>
Date: Thu, 19 Mar 2026 22:49:46 +0530
Optimize Frontend
Diffstat:
11 files changed, 208 insertions(+), 169 deletions(-)
diff --git a/frontend/src/App.js b/frontend/src/App.js
@@ -1,5 +1,5 @@
-import React from "react";
import { BrowserRouter as Router, Routes, Route } from "react-router-dom";
+import { FarmDataProvider } from "./hooks/useFarmData";
import LandingPage from "./pages/LandingPage";
import Dashboard from "./pages/Dashboard";
@@ -10,16 +10,18 @@ import Alerts from "./pages/Alerts";
function App() {
return (
- <Router>
- <Routes>
- <Route path="/" element={<LandingPage />} />
- <Route path="/control" element={<AgentControl />} />
- <Route path="/dashboard" element={<Dashboard />} />
- <Route path="/crop/:cropId" element={<CropDetails />} />
- <Route path="/analytics" element={<Analytics />} />
- <Route path="/alerts" element={<Alerts />} />
- </Routes>
- </Router>
+ <FarmDataProvider>
+ <Router>
+ <Routes>
+ <Route path="/" element={<LandingPage />} />
+ <Route path="/control" element={<AgentControl />} />
+ <Route path="/dashboard" element={<Dashboard />} />
+ <Route path="/crop/:cropId" element={<CropDetails />} />
+ <Route path="/analytics" element={<Analytics />} />
+ <Route path="/alerts" element={<Alerts />} />
+ </Routes>
+ </Router>
+ </FarmDataProvider>
);
}
diff --git a/frontend/src/api/agentApi.js b/frontend/src/api/agentApi.js
@@ -14,11 +14,16 @@ export const agentService = {
EC: parseFloat(sensors.EC),
temp: parseFloat(sensors.temp),
humidity: parseFloat(sensors.humidity),
+ crop_id: sensors.crop_id || undefined,
}),
);
formData.append(
"metadata",
- JSON.stringify({ crop: sensors.crop, stage: sensors.stage }),
+ JSON.stringify({
+ crop: sensors.crop,
+ stage: sensors.stage,
+ crop_id: sensors.crop_id || undefined,
+ }),
);
const res = await fetch(`${API_URL}/ingest`, {
method: "POST",
diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx
@@ -1,4 +1,4 @@
-import React, { useState, useEffect } from "react";
+import React, { useState, useMemo } from "react";
import { Link, useLocation } from "react-router-dom";
import {
Leaf,
@@ -11,38 +11,21 @@ import {
ChevronRight,
Zap,
} from "lucide-react";
-import { fetchDashboardData } from "../api/farmApi";
-import { extractSensors } from "../utils/dataUtils";
-
-function countLiveAlerts(dashData) {
- if (!dashData?.length) return 0;
- return dashData.filter((d) => {
- const s = extractSensors(d.payload);
- const ph = parseFloat(s.ph);
- const ec = parseFloat(s.ec);
- const temp = parseFloat(s.temp);
- const outcome = (d.payload?.outcome || "").toLowerCase();
- return (
- ph < 5.0 ||
- ph > 7.5 ||
- ec > 3.0 ||
- temp > 34 ||
- (temp > 0 && temp < 12) ||
- /fail|critical|disease|pest|fungal/.test(outcome)
- );
- }).length;
-}
+import { useFarmData } from "../hooks/useFarmData";
+import { deriveCropStatus } from "../utils/dataUtils";
export default function Sidebar() {
const [collapsed, setCollapsed] = useState(false);
- const [alertCount, setAlertCount] = useState(0);
const loc = useLocation();
+ const { dashboard } = useFarmData();
- useEffect(() => {
- fetchDashboardData().then((data) => {
- setAlertCount(countLiveAlerts(data));
- });
- }, []);
+ const alertCount = useMemo(() => {
+ if (!dashboard?.length) return 0;
+ return dashboard.filter((d) => {
+ const status = deriveCropStatus(d.payload);
+ return status === "Critical" || status === "Attention";
+ }).length;
+ }, [dashboard]);
const NAV = [
{ label: "Crops", icon: LayoutGrid, path: "/dashboard" },
diff --git a/frontend/src/hooks/useFarmData.js b/frontend/src/hooks/useFarmData.js
@@ -0,0 +1,38 @@
+import React, { useState, useEffect, createContext, useContext } from "react";
+import { fetchDashboardData, fetchAllCropHistories } from "../api/farmApi";
+
+const FarmDataContext = createContext();
+
+export const FarmDataProvider = ({ children }) => {
+ const [dashboard, setDashboard] = useState([]);
+ const [history, setHistory] = useState([]);
+ const [loading, setLoading] = useState(true);
+
+ const refreshData = async () => {
+ setLoading(true);
+ try {
+ const dash = await fetchDashboardData();
+ setDashboard(dash || []);
+ const hist = await fetchAllCropHistories(dash || []);
+ setHistory(hist || []);
+ } catch (error) {
+ console.error("Failed to fetch farm data", error);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ refreshData();
+ }, []);
+
+ return (
+ <FarmDataContext.Provider
+ value={{ dashboard, history, loading, refreshData }}
+ >
+ {children}
+ </FarmDataContext.Provider>
+ );
+};
+
+export const useFarmData = () => useContext(FarmDataContext);
diff --git a/frontend/src/pages/AgentControl.jsx b/frontend/src/pages/AgentControl.jsx
@@ -54,7 +54,7 @@ const INPUT_FIELDS = [
type: "number",
},
{
- label: "Crop",
+ label: "Crop Type",
name: "crop",
icon: Sprout,
color: "var(--green)",
@@ -69,6 +69,14 @@ const INPUT_FIELDS = [
type: "select",
opts: ["Seedling", "Vegetative", "Flowering", "Fruiting"],
},
+ {
+ label: "Crop ID (Opt)",
+ name: "crop_id",
+ icon: Database,
+ color: "var(--text-3)",
+ type: "text",
+ placeholder: "e.g., Batch_A1",
+ },
];
const ACTION_MAP = {
@@ -131,6 +139,7 @@ export default function AgentControl() {
humidity: "60",
crop: "Lettuce",
stage: "Vegetative",
+ crop_id: "",
});
const showToast = (msg, type = "success") => {
@@ -445,7 +454,15 @@ export default function AgentControl() {
</div>
<div className="grid grid-cols-2 gap-4">
{INPUT_FIELDS.map(
- ({ label, name, icon: Icon, color, type, opts }) => (
+ ({
+ label,
+ name,
+ icon: Icon,
+ color,
+ type,
+ opts,
+ placeholder,
+ }) => (
<div key={name}>
<label
className="text-[10px] font-mono mb-1 block"
@@ -492,8 +509,9 @@ export default function AgentControl() {
onChange={(e) =>
setSensors({ ...sensors, [name]: e.target.value })
}
- type="number"
- step="0.1"
+ 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={{
background: "var(--bg-3)",
diff --git a/frontend/src/pages/Alerts.jsx b/frontend/src/pages/Alerts.jsx
@@ -10,7 +10,7 @@ import {
Clock,
RefreshCw,
} from "lucide-react";
-import { fetchDashboardData, fetchAllCropHistories } from "../api/farmApi";
+import { useFarmData } from "../hooks/useFarmData";
import { extractSensors, formatOutcome } from "../utils/dataUtils";
import Sidebar from "../components/Sidebar";
@@ -246,9 +246,7 @@ function generateAlerts(points) {
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(),
+ 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",
@@ -258,19 +256,18 @@ function generateAlerts(points) {
}
}
- // De-duplicate: keep at most 3 alerts of same severity+title+crop
+ // 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 < 3) {
+ if (count < 2) {
deduped.push(a);
seen.set(key, count + 1);
}
}
- // Sort: critical first, then by time descending
deduped.sort((a, b) => {
const sevOrder = { critical: 0, warning: 1, info: 2 };
if (sevOrder[a.severity] !== sevOrder[b.severity])
@@ -423,23 +420,16 @@ function AlertCard({ alert, onAck, onDismiss }) {
// MAIN
export default function Alerts() {
+ const { history, loading, refreshData } = useFarmData();
const [alerts, setAlerts] = useState([]);
- const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState("all");
const [showAcked, setShowAcked] = useState(false);
- const load = async () => {
- setLoading(true);
- const dash = await fetchDashboardData();
- const hist = await fetchAllCropHistories(dash);
- const generated = generateAlerts(hist);
- setAlerts(generated);
- setLoading(false);
- };
-
useEffect(() => {
- load();
- }, []);
+ if (!loading) {
+ setAlerts(generateAlerts(history));
+ }
+ }, [history, loading]);
const ack = (id) =>
setAlerts((a) => a.map((al) => (al.id === id ? { ...al, ack: true } : al)));
@@ -491,7 +481,7 @@ export default function Alerts() {
</div>
<div className="ml-auto flex items-center gap-2">
<button
- onClick={load}
+ onClick={refreshData}
className="p-2 rounded-lg transition-colors"
style={{
background: "var(--surface)",
diff --git a/frontend/src/pages/Analytics.jsx b/frontend/src/pages/Analytics.jsx
@@ -1,4 +1,4 @@
-import { useState, useEffect, useMemo } from "react";
+import { useState, useMemo } from "react";
import {
AreaChart,
Area,
@@ -16,7 +16,7 @@ import {
PolarGrid,
PolarAngleAxis,
} from "recharts";
-import { fetchDashboardData, fetchAllCropHistories } from "../api/farmApi";
+import { useFarmData } from "../hooks/useFarmData";
import { extractSensors } from "../utils/dataUtils";
import { TrendingUp, TrendingDown, Minus, Download } from "lucide-react";
import Sidebar from "../components/Sidebar";
@@ -48,11 +48,9 @@ function bucketHistory(points, range) {
};
const cutoff = now - (MS[range] || MS["24h"]);
- const filtered = points.filter((p) => {
- const t = new Date(p.payload?.timestamp || 0).getTime();
- return t >= cutoff;
- });
-
+ const filtered = points.filter(
+ (p) => new Date(p.payload?.timestamp || 0).getTime() >= cutoff,
+ );
if (!filtered.length) return [];
// Build buckets
@@ -119,7 +117,7 @@ function dailyCropActivity(points) {
}));
}
-// Build radar: how close each param is to ideal range
+// How close each param is to ideal range
function buildRadar(points) {
if (!points.length) return [];
const sensors = points.map((p) => extractSensors(p.payload));
@@ -178,7 +176,6 @@ function buildAgentStats(points) {
for (const agent of AGENTS) {
if (action.toUpperCase().includes(agent)) counts[agent]++;
}
- // every stored point = at least one supervisor decision
counts["SUPERVISOR"]++;
}
@@ -189,14 +186,12 @@ function buildAgentStats(points) {
// accuracy = % of points where outcome is NOT negative
accuracy: points.length
? Math.round(
- (points.filter((p) => {
- const o = (p.payload?.outcome || "").toLowerCase();
- return (
- !o.includes("fail") &&
- !o.includes("negative") &&
- !o.includes("critical")
- );
- }).length /
+ (points.filter(
+ (p) =>
+ !/fail|negative|critical/.test(
+ (p.payload?.outcome || "").toLowerCase(),
+ ),
+ ).length /
total) *
100,
)
@@ -317,21 +312,7 @@ function EmptyChart({ height = 180, message = "No data yet" }) {
export default function Analytics() {
const [range, setRange] = useState("24h");
- const [loading, setLoading] = useState(true);
- const [allPoints, setAllPoints] = useState([]); // every stored point from all crops
- const [dashboard, setDashboard] = useState([]); // latest snapshot per crop
-
- useEffect(() => {
- setLoading(true);
- fetchDashboardData().then(async (dash) => {
- setDashboard(dash || []);
- const hist = await fetchAllCropHistories(dash);
- setAllPoints(hist);
- setLoading(false);
- });
- }, []);
-
- // STATS
+ const { dashboard, history: allPoints, loading } = useFarmData();
const buckets = useMemo(
() => bucketHistory(allPoints, range),
@@ -392,7 +373,7 @@ export default function Analytics() {
>
<Sidebar />
<main className="flex-1 flex flex-col overflow-hidden">
- {/* ── Header ── */}
+ {/* 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)" }}
@@ -438,7 +419,7 @@ export default function Analytics() {
</header>
<div className="flex-1 overflow-y-auto p-6 space-y-8">
- {/* ── Metric cards ── */}
+ {/* Metric cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<MetricCard
loading={loading}
@@ -477,7 +458,7 @@ export default function Analytics() {
/>
</div>
- {/* ── pH + EC ── */}
+ {/* pH + EC */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div
className="rounded-xl p-5"
@@ -626,7 +607,7 @@ export default function Analytics() {
</div>
</div>
- {/* ── Temp + Humidity ── */}
+ {/* Temp + Humidity */}
<div
className="rounded-xl p-5"
style={{
@@ -708,7 +689,7 @@ export default function Analytics() {
)}
</div>
- {/* ── Activity bar + Radar ── */}
+ {/* Activity bar + Radar */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div
className="rounded-xl p-5"
@@ -805,7 +786,7 @@ export default function Analytics() {
</div>
</div>
- {/* ── Crop breakdown table ── */}
+ {/* Crop breakdown table */}
<div
className="rounded-xl overflow-hidden"
style={{
diff --git a/frontend/src/pages/CropDetails.jsx b/frontend/src/pages/CropDetails.jsx
@@ -104,10 +104,9 @@ export default function CropDetails() {
);
const processed = sorted.map((item) => {
const p = item.payload || {};
- const sensors = extractSensors(p);
return {
...item,
- cleanSensors: sensors,
+ cleanSensors: extractSensors(p),
parsedAction: parsePythonString(p.action_taken),
};
});
@@ -211,7 +210,7 @@ export default function CropDetails() {
<span
className="status-dot w-1.5 h-1.5 rounded-full"
style={{ background: "var(--green)" }}
- />
+ />{" "}
LIVE
</div>
@@ -316,7 +315,7 @@ export default function CropDetails() {
tickLine={false}
/>
<YAxis
- domain={[5, 7.5]}
+ domain={["auto", "auto"]}
tick={{
fontSize: 9,
fill: "var(--text-3)",
diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx
@@ -1,7 +1,11 @@
import React, { useState, useEffect, useMemo } from "react";
import { useNavigate } from "react-router-dom";
-import { fetchDashboardData } from "../api/farmApi";
-import { extractSensors, calculateMaturity } from "../utils/dataUtils";
+import { useFarmData } from "../hooks/useFarmData";
+import {
+ extractSensors,
+ calculateMaturity,
+ deriveCropStatus,
+} from "../utils/dataUtils";
import {
Search,
SlidersHorizontal,
@@ -191,21 +195,18 @@ function CropCard({ data, onClick }) {
export default function Dashboard() {
const navigate = useNavigate();
+ const { dashboard, loading, refreshData } = useFarmData();
const [crops, setCrops] = useState([]);
- const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const [filterStage, setFilterStage] = useState("All");
const [filterCrop, setFilterCrop] = useState("All");
const [filterStatus, setFilterStatus] = useState("All");
const [showFilters, setShowFilters] = useState(false);
- const [refreshing, setRefreshing] = useState(false);
- const loadData = async () => {
- setRefreshing(true);
- const data = await fetchDashboardData();
- if (data?.length > 0) {
+ useEffect(() => {
+ if (dashboard) {
setCrops(
- data.map((item) => {
+ dashboard.map((item) => {
const p = item.payload || {};
const sensors = extractSensors(p);
return {
@@ -214,12 +215,7 @@ export default function Dashboard() {
name: p.crop || "Unknown",
statusMsg: p.stage || "Growing",
image: getImg(p.crop),
- status:
- p.outcome === "CRITICAL"
- ? "Critical"
- : p.action_taken === "PENDING_ACTION"
- ? "Attention"
- : "Healthy",
+ status: deriveCropStatus(p),
maturity: calculateMaturity(p.sequence_number),
seq: p.sequence_number,
daysLeft: 30 - (p.sequence_number || 0),
@@ -230,13 +226,7 @@ export default function Dashboard() {
}),
);
}
- setLoading(false);
- setRefreshing(false);
- };
-
- useEffect(() => {
- loadData();
- }, []);
+ }, [dashboard]);
const getImg = (name) => {
if (!name) return null;
@@ -339,7 +329,7 @@ export default function Dashboard() {
<div className="ml-auto flex items-center gap-2">
<button
- onClick={loadData}
+ onClick={refreshData}
className="p-2 rounded-lg transition-colors"
style={{
background: "var(--surface)",
@@ -347,10 +337,7 @@ export default function Dashboard() {
color: "var(--text-3)",
}}
>
- <RefreshCw
- size={15}
- className={refreshing ? "animate-spin" : ""}
- />
+ <RefreshCw size={15} className={loading ? "animate-spin" : ""} />
</button>
</div>
</header>
@@ -401,8 +388,7 @@ export default function Dashboard() {
color: showFilters ? "var(--green)" : "var(--text-2)",
}}
>
- <SlidersHorizontal size={14} />
- Filters
+ <SlidersHorizontal size={14} /> Filters
{activeFilters > 0 && (
<span
className="px-1.5 py-0.5 rounded text-[10px] font-mono"
diff --git a/frontend/src/pages/LandingPage.jsx b/frontend/src/pages/LandingPage.jsx
@@ -9,8 +9,8 @@ import {
Zap,
Activity,
} from "lucide-react";
-import { fetchDashboardData } from "../api/farmApi";
-import { extractSensors } from "../utils/dataUtils";
+import { useFarmData } from "../hooks/useFarmData";
+import { extractSensors, deriveCropStatus } from "../utils/dataUtils";
const FEATURES = [
{
@@ -55,15 +55,10 @@ function computeFleetStats(dashData) {
...new Set(dashData.map((d) => d.payload?.crop).filter(Boolean)),
];
- // Any active alert conditions?
- const alerts = sensors.filter(
- (s) =>
- parseFloat(s.ph) < 5.5 ||
- parseFloat(s.ph) > 6.5 ||
- parseFloat(s.ec) > 2.5 ||
- parseFloat(s.temp) > 30 ||
- parseFloat(s.temp) < 15,
- ).length;
+ const alerts = dashData.filter((d) => {
+ const status = deriveCropStatus(d.payload);
+ return status === "Critical" || status === "Attention";
+ }).length;
return {
ph: ph.toFixed(2),
@@ -77,10 +72,9 @@ function computeFleetStats(dashData) {
};
}
-// Build a real activity log from the last N payloads
-function buildActivityLog(dashData) {
- if (!dashData?.length) return [];
- return dashData
+function buildActivityLog(historyData) {
+ if (!historyData?.length) return [];
+ return historyData
.slice(-5)
.reverse()
.map((d, i) => {
@@ -104,20 +98,22 @@ function buildActivityLog(dashData) {
export default function LandingPage() {
const navigate = useNavigate();
const [mounted, setMounted] = useState(false);
+ const { dashboard, history, loading } = useFarmData();
+
const [stats, setStats] = useState(null);
const [log, setLog] = useState([]);
- const [loading, setLoading] = useState(true);
useEffect(() => {
setMounted(true);
- fetchDashboardData().then((data) => {
- setStats(computeFleetStats(data));
- setLog(buildActivityLog(data));
- setLoading(false);
- });
}, []);
- // Headline stats derived from real data, with friendly fallbacks
+ useEffect(() => {
+ if (!loading) {
+ setStats(computeFleetStats(dashboard));
+ setLog(buildActivityLog(history));
+ }
+ }, [dashboard, history, loading]);
+
const headlineStats = stats
? [
{ val: stats.cropCount.toString(), label: "Active Crops" },
@@ -135,7 +131,7 @@ export default function LandingPage() {
{ val: "—", label: "Active Alerts" },
];
- // Live sensor readings from real data
+ // Live sensor readings
const readings = stats
? [
{
@@ -291,7 +287,7 @@ export default function LandingPage() {
className="group flex items-center gap-3 px-7 py-3.5 rounded-xl font-semibold text-sm transition-all glow-green"
style={{ background: "var(--green)", color: "#0c1a0e" }}
>
- Enter Dashboard
+ Enter Dashboard{" "}
<ArrowRight
size={16}
className="group-hover:translate-x-1 transition-transform"
@@ -306,8 +302,7 @@ export default function LandingPage() {
background: "var(--surface)",
}}
>
- <Cpu size={16} />
- Agent Control
+ <Cpu size={16} /> Agent Control
</button>
</div>
diff --git a/frontend/src/utils/dataUtils.js b/frontend/src/utils/dataUtils.js
@@ -39,21 +39,24 @@ export const extractSensors = (payload) => {
}
}
return {
- temp: formatNumber(raw.temp ?? 0),
- ph: formatNumber(raw.pH ?? 7.0),
- humidity: formatNumber(raw.humidity ?? 0),
- ec: formatNumber(raw.EC ?? 0),
+ temp: formatNumber(raw.temp ?? raw.Temp ?? 0),
+ ph: formatNumber(raw.ph ?? raw.pH ?? 7.0),
+ humidity: formatNumber(raw.humidity ?? raw.Humidity ?? 0),
+ ec: formatNumber(raw.ec ?? raw.EC ?? 0),
};
};
export const calculateMaturity = (seq) => {
- const val = (seq || 1) * 10;
- return val > 100 ? 100 : val;
+ return Math.min((seq || 1) * 10, 100);
};
export const formatOutcome = (outcome) => {
if (!outcome || typeof outcome !== "string") return "Monitoring...";
- const parts = outcome.split("|").map((p) => p.trim());
+
+ // Strip reward suffix
+ const cleanOutcome = outcome.split("| Reward:")[0].trim();
+
+ const parts = cleanOutcome.split("|").map((p) => p.trim());
let tags = [],
notes = "";
parts.forEach((part) => {
@@ -69,7 +72,46 @@ export const formatOutcome = (outcome) => {
tags.push(part);
}
});
- if (!tags.length && !notes) return outcome;
+ if (!tags.length && !notes) return cleanOutcome;
const t = tags.join(" · ");
return t && notes ? `${t} — ${notes}` : t || notes;
};
+
+// Crop health status
+export const deriveCropStatus = (payload) => {
+ if (!payload) return "Healthy";
+ const s = extractSensors(payload);
+ const ph = parseFloat(s.ph) || 0;
+ const ec = parseFloat(s.ec) || 0;
+ const temp = parseFloat(s.temp) || 0;
+ const outcome = (payload.outcome || "").toLowerCase();
+ const action = (payload.action_taken || "").toUpperCase();
+
+ // Critical Thresholds
+ if (
+ (ph > 0 && ph < 4.5) ||
+ ph > 7.5 ||
+ ec > 3.5 ||
+ (temp > 0 && temp < 10) ||
+ temp > 35 ||
+ /fail|critical|disease|error/.test(outcome) ||
+ /DISEASE|FUNGAL|PEST/.test(action)
+ ) {
+ return "Critical";
+ }
+
+ // Warning/Attention Thresholds
+ if (
+ (ph > 0 && ph < 5.5) ||
+ ph > 6.5 ||
+ ec > 2.5 ||
+ (temp > 0 && temp < 17) ||
+ temp > 30 ||
+ /deteriorat|negative|attention|decline/.test(outcome) ||
+ /FLUSH|PRUNE|BOOST/.test(action)
+ ) {
+ return "Attention";
+ }
+
+ return "Healthy";
+};