demeter
Autonomous Hydroponic Intelligencegit clone https://github.com/maydayv7/demeter
frontend/src/pages/Analytics.jsx (27724B)
1import { useState, useMemo } from "react";
2import {
3 AreaChart,
4 Area,
5 LineChart,
6 Line,
7 BarChart,
8 Bar,
9 XAxis,
10 YAxis,
11 CartesianGrid,
12 Tooltip,
13 ResponsiveContainer,
14 RadarChart,
15 Radar,
16 PolarGrid,
17 PolarAngleAxis,
18} from "recharts";
19import { useFarmData } from "../hooks/useFarmData";
20import { useT } from "../hooks/useTranslation";
21import {
22 extractSensors,
23 avg,
24 safePct,
25 bucketHistory,
26 dailyCropActivity,
27 buildRadar,
28 buildAgentStats,
29} from "../utils/dataUtils";
30import { TrendingUp, TrendingDown, Minus, Download } from "lucide-react";
31import {
32 PageShell,
33 PageHeader,
34 ChartTooltip,
35 InlineLabel,
36} from "../components/ui";
37
38function MetricCard({ label, value, unit, change, color, loading, t }) {
39 const up = change > 0;
40 const flat = change === 0;
41 return (
42 <div
43 className="card-hover"
44 style={{
45 borderRadius: 14,
46 padding: "20px 22px",
47 background: "var(--surface)",
48 border: "1px solid var(--border)",
49 }}
50 >
51 <div className="sensor-label">{label}</div>
52 {loading ? (
53 <div
54 className="shimmer"
55 style={{ height: 36, width: 100, borderRadius: 6 }}
56 />
57 ) : (
58 <div
59 style={{
60 display: "flex",
61 alignItems: "baseline",
62 gap: 4,
63 marginTop: 6,
64 }}
65 >
66 <span
67 className="sensor-value"
68 style={{ color: color || "var(--text)" }}
69 >
70 {value}
71 </span>
72 {unit && <span className="sensor-unit">{unit}</span>}
73 </div>
74 )}
75 <div
76 style={{
77 display: "flex",
78 alignItems: "center",
79 gap: 4,
80 marginTop: 10,
81 fontSize: 12,
82 fontFamily: "DM Mono, monospace",
83 }}
84 >
85 {flat ? (
86 <Minus size={11} style={{ color: "var(--text-3)" }} />
87 ) : up ? (
88 <TrendingUp size={11} style={{ color: "var(--green)" }} />
89 ) : (
90 <TrendingDown size={11} style={{ color: "var(--red)" }} />
91 )}
92 <span
93 style={{
94 color: flat ? "var(--text-3)" : up ? "var(--green)" : "var(--red)",
95 }}
96 >
97 {Math.abs(change)}
98 {t("analytics_vs_prior")}
99 </span>
100 </div>
101 </div>
102 );
103}
104
105function SectionHead({ label, title }) {
106 return (
107 <div style={{ marginBottom: 16 }}>
108 <InlineLabel>{label}</InlineLabel>
109 <h2
110 style={{
111 fontWeight: 700,
112 fontSize: 16,
113 color: "var(--text)",
114 margin: "4px 0 0",
115 }}
116 >
117 {title}
118 </h2>
119 </div>
120 );
121}
122
123function EmptyChart({ height = 180, message }) {
124 return (
125 <div
126 style={{
127 height,
128 display: "flex",
129 alignItems: "center",
130 justifyContent: "center",
131 borderRadius: 8,
132 background: "var(--bg-3)",
133 border: "1px dashed var(--border)",
134 }}
135 >
136 <span
137 style={{
138 fontSize: 12,
139 fontFamily: "DM Mono, monospace",
140 color: "var(--text-3)",
141 }}
142 >
143 {message}
144 </span>
145 </div>
146 );
147}
148
149// MAIN
150
151export default function Analytics() {
152 const { t, td } = useT();
153 const [range, setRange] = useState("24h");
154 const { dashboard, history: allPoints, loading } = useFarmData();
155
156 const buckets = useMemo(
157 () => bucketHistory(allPoints, range),
158 [allPoints, range],
159 );
160
161 // Latest fleet-wide averages
162 const latestSensors = useMemo(() => {
163 if (!dashboard.length) return { ph: 0, ec: 0, temp: 0 };
164 const s = dashboard.map((d) => extractSensors(d.payload));
165 return {
166 ph: parseFloat(avg(s.map((x) => parseFloat(x.ph) || 0)).toFixed(2)),
167 ec: parseFloat(avg(s.map((x) => parseFloat(x.ec) || 0)).toFixed(2)),
168 temp: parseFloat(avg(s.map((x) => parseFloat(x.temp) || 0)).toFixed(1)),
169 };
170 }, [dashboard]);
171
172 // First-half vs second-half for % change
173 const prevSensors = useMemo(() => {
174 if (allPoints.length < 2) return latestSensors;
175 const older = allPoints
176 .slice(0, Math.floor(allPoints.length / 2))
177 .map((p) => extractSensors(p.payload));
178 return {
179 ph: parseFloat(avg(older.map((x) => parseFloat(x.ph) || 0)).toFixed(2)),
180 ec: parseFloat(avg(older.map((x) => parseFloat(x.ec) || 0)).toFixed(2)),
181 temp: parseFloat(
182 avg(older.map((x) => parseFloat(x.temp) || 0)).toFixed(1),
183 ),
184 };
185 }, [allPoints, latestSensors]);
186
187 const activityData = useMemo(() => dailyCropActivity(allPoints), [allPoints]);
188 const radarData = useMemo(() => buildRadar(allPoints), [allPoints]);
189 const cropSummaryRows = useMemo(() => {
190 if (!dashboard?.length) return [];
191 return dashboard.map((item) => {
192 const p = item.payload || {};
193 const s = extractSensors(p);
194 return { p, s, key: p.crop_id || item.id };
195 });
196 }, [dashboard]);
197
198 const agentRows = useMemo(() => buildAgentStats(allPoints), [allPoints]);
199
200 const handleExport = () => {
201 if (!buckets.length) return;
202 const header = "time,ph,ec,temp,humidity,entries";
203 const rows = buckets.map(
204 (b) => `${b.label},${b.ph},${b.ec},${b.temp},${b.humidity},${b.count}`,
205 );
206 const blob = new Blob([[header, ...rows].join("\n")], { type: "text/csv" });
207 const a = document.createElement("a");
208 a.href = URL.createObjectURL(blob);
209 a.download = `demeter-analytics-${range}.csv`;
210 a.click();
211 };
212
213 return (
214 <PageShell>
215 {/* Header */}
216 <PageHeader
217 title={t("analytics_title")}
218 subtitle={
219 loading
220 ? t("common_loading")
221 : t("analytics_subtitle", {
222 points: allPoints.length,
223 crops: dashboard.length,
224 })
225 }
226 >
227 <div
228 style={{
229 marginLeft: "auto",
230 display: "flex",
231 alignItems: "center",
232 gap: 8,
233 }}
234 >
235 {["24h", "7d", "30d"].map((r) => (
236 <button
237 key={r}
238 onClick={() => setRange(r)}
239 style={{
240 padding: "5px 12px",
241 borderRadius: 8,
242 fontSize: 12,
243 fontFamily: "DM Mono, monospace",
244 cursor: "pointer",
245 background:
246 range === r ? "rgba(74,222,128,0.12)" : "var(--surface)",
247 border: `1px solid ${range === r ? "rgba(74,222,128,0.3)" : "var(--border)"}`,
248 color: range === r ? "var(--green)" : "var(--text-3)",
249 }}
250 >
251 {r}
252 </button>
253 ))}
254 <button
255 onClick={handleExport}
256 style={{
257 display: "flex",
258 alignItems: "center",
259 gap: 6,
260 padding: "5px 12px",
261 borderRadius: 8,
262 fontSize: 12,
263 fontFamily: "DM Mono, monospace",
264 background: "var(--surface)",
265 border: "1px solid var(--border)",
266 color: "var(--text-3)",
267 cursor: "pointer",
268 }}
269 >
270 <Download size={12} /> {t("analytics_export")}
271 </button>
272 </div>
273 </PageHeader>
274
275 {/* Scrollable content */}
276 <div
277 style={{
278 flex: 1,
279 overflowY: "auto",
280 padding: 24,
281 display: "flex",
282 flexDirection: "column",
283 gap: 28,
284 }}
285 >
286 {/* Metric Cards */}
287 <div
288 style={{
289 display: "grid",
290 gridTemplateColumns: "repeat(4,1fr)",
291 gap: 12,
292 }}
293 >
294 <MetricCard
295 loading={loading}
296 label={t("analytics_avg_ph")}
297 value={latestSensors.ph}
298 unit=""
299 change={safePct(latestSensors.ph, prevSensors.ph)}
300 color="var(--green)"
301 t={t}
302 />
303 <MetricCard
304 loading={loading}
305 label={t("analytics_avg_ec")}
306 value={latestSensors.ec}
307 unit="dS/m"
308 change={safePct(latestSensors.ec, prevSensors.ec)}
309 color="var(--amber)"
310 t={t}
311 />
312 <MetricCard
313 loading={loading}
314 label={t("analytics_avg_temp")}
315 value={latestSensors.temp}
316 unit="°C"
317 change={safePct(latestSensors.temp, prevSensors.temp)}
318 color="var(--blue)"
319 t={t}
320 />
321 <MetricCard
322 loading={loading}
323 label={t("analytics_total_seq")}
324 value={allPoints.length}
325 unit=""
326 change={safePct(
327 allPoints.length,
328 Math.max(allPoints.length - dashboard.length, 1),
329 )}
330 color="var(--text)"
331 t={t}
332 />
333 </div>
334
335 {/* pH + EC Charts */}
336 <div
337 style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }}
338 >
339 {[
340 {
341 title: t("analytics_ph_over_time"),
342 key: "ph",
343 stroke: "var(--green)",
344 gradId: "phGradA",
345 gradColor: "#4ade80",
346 name: t("chart_ph"),
347 },
348 {
349 title: t("analytics_ec_conc"),
350 key: "ec",
351 stroke: "var(--amber)",
352 gradId: "ecGradA",
353 gradColor: "#f59e0b",
354 name: t("chart_ec"),
355 },
356 ].map(({ title, key, stroke, gradId, gradColor, name }) => (
357 <div
358 key={key}
359 style={{
360 borderRadius: 14,
361 padding: 20,
362 background: "var(--surface)",
363 border: "1px solid var(--border)",
364 }}
365 >
366 <SectionHead
367 label={t("analytics_trace", { range: range.toUpperCase() })}
368 title={title}
369 />
370 {buckets.length < 2 ? (
371 <EmptyChart message={t("analytics_no_data_range")} />
372 ) : (
373 <ResponsiveContainer width="100%" height={180}>
374 <AreaChart data={buckets}>
375 <defs>
376 <linearGradient id={gradId} x1="0" y1="0" x2="0" y2="1">
377 <stop
378 offset="0%"
379 stopColor={gradColor}
380 stopOpacity={0.3}
381 />
382 <stop
383 offset="100%"
384 stopColor={gradColor}
385 stopOpacity={0}
386 />
387 </linearGradient>
388 </defs>
389 <CartesianGrid
390 stroke="var(--border)"
391 strokeDasharray="3 3"
392 vertical={false}
393 />
394 <XAxis
395 dataKey="label"
396 tick={{
397 fontSize: 10,
398 fill: "var(--text-3)",
399 fontFamily: "DM Mono",
400 }}
401 axisLine={false}
402 tickLine={false}
403 interval="preserveStartEnd"
404 />
405 <YAxis
406 domain={["auto", "auto"]}
407 tick={{
408 fontSize: 10,
409 fill: "var(--text-3)",
410 fontFamily: "DM Mono",
411 }}
412 axisLine={false}
413 tickLine={false}
414 />
415 <Tooltip content={<ChartTooltip />} />
416 <Area
417 type="monotone"
418 dataKey={key}
419 stroke={stroke}
420 fill={`url(#${gradId})`}
421 strokeWidth={2}
422 dot={false}
423 name={name}
424 />
425 </AreaChart>
426 </ResponsiveContainer>
427 )}
428 </div>
429 ))}
430 </div>
431
432 {/* Temp + Humidity */}
433 <div
434 style={{
435 borderRadius: 14,
436 padding: 20,
437 background: "var(--surface)",
438 border: "1px solid var(--border)",
439 }}
440 >
441 <SectionHead
442 label={t("analytics_trace", { range: range.toUpperCase() })}
443 title={t("analytics_temp_hum")}
444 />
445 {buckets.length < 2 ? (
446 <EmptyChart message={t("analytics_no_data_range")} />
447 ) : (
448 <ResponsiveContainer width="100%" height={180}>
449 <LineChart data={buckets}>
450 <CartesianGrid
451 stroke="var(--border)"
452 strokeDasharray="3 3"
453 vertical={false}
454 />
455 <XAxis
456 dataKey="label"
457 tick={{
458 fontSize: 10,
459 fill: "var(--text-3)",
460 fontFamily: "DM Mono",
461 }}
462 axisLine={false}
463 tickLine={false}
464 interval="preserveStartEnd"
465 />
466 <YAxis
467 yAxisId="left"
468 domain={["auto", "auto"]}
469 tick={{
470 fontSize: 10,
471 fill: "var(--text-3)",
472 fontFamily: "DM Mono",
473 }}
474 axisLine={false}
475 tickLine={false}
476 />
477 <YAxis
478 yAxisId="right"
479 orientation="right"
480 domain={["auto", "auto"]}
481 tick={{
482 fontSize: 10,
483 fill: "var(--text-3)",
484 fontFamily: "DM Mono",
485 }}
486 axisLine={false}
487 tickLine={false}
488 />
489 <Tooltip content={<ChartTooltip />} />
490 <Line
491 yAxisId="left"
492 type="monotone"
493 dataKey="temp"
494 stroke="#60a5fa"
495 strokeWidth={2}
496 dot={false}
497 name={t("chart_temp")}
498 />
499 <Line
500 yAxisId="right"
501 type="monotone"
502 dataKey="humidity"
503 stroke="#a78bfa"
504 strokeWidth={2}
505 dot={false}
506 name={t("chart_humidity")}
507 />
508 </LineChart>
509 </ResponsiveContainer>
510 )}
511 </div>
512
513 {/* Activity + Radar */}
514 <div
515 style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }}
516 >
517 <div
518 style={{
519 borderRadius: 14,
520 padding: 20,
521 background: "var(--surface)",
522 border: "1px solid var(--border)",
523 }}
524 >
525 <SectionHead
526 label={t("analytics_daily_act")}
527 title={t("analytics_seq_per_day")}
528 />
529 {activityData.length < 2 ? (
530 <EmptyChart height={180} message={t("analytics_no_data_days")} />
531 ) : (
532 <ResponsiveContainer width="100%" height={180}>
533 <BarChart data={activityData} barGap={4}>
534 <CartesianGrid
535 stroke="var(--border)"
536 strokeDasharray="3 3"
537 vertical={false}
538 />
539 <XAxis
540 dataKey="d"
541 tick={{
542 fontSize: 10,
543 fill: "var(--text-3)",
544 fontFamily: "DM Mono",
545 }}
546 axisLine={false}
547 tickLine={false}
548 />
549 <YAxis
550 tick={{
551 fontSize: 10,
552 fill: "var(--text-3)",
553 fontFamily: "DM Mono",
554 }}
555 axisLine={false}
556 tickLine={false}
557 allowDecimals={false}
558 />
559 <Tooltip content={<ChartTooltip />} />
560 <Bar
561 dataKey="count"
562 fill="#2d7a44"
563 radius={[4, 4, 0, 0]}
564 name={t("chart_sequences")}
565 />
566 </BarChart>
567 </ResponsiveContainer>
568 )}
569 </div>
570
571 <div
572 style={{
573 borderRadius: 14,
574 padding: 20,
575 background: "var(--surface)",
576 border: "1px solid var(--border)",
577 }}
578 >
579 <SectionHead
580 label={t("analytics_param_health")}
581 title={t("analytics_in_range_score")}
582 />
583 {radarData.length < 2 ? (
584 <EmptyChart
585 height={180}
586 message={t("analytics_no_data_points")}
587 />
588 ) : (
589 <ResponsiveContainer width="100%" height={180}>
590 <RadarChart
591 data={radarData}
592 cx="50%"
593 cy="50%"
594 outerRadius="65%"
595 >
596 <PolarGrid stroke="var(--border)" />
597 <PolarAngleAxis
598 dataKey="metric"
599 tick={{
600 fontSize: 11,
601 fill: "var(--text-3)",
602 fontFamily: "DM Mono",
603 }}
604 />
605 <Radar
606 dataKey="value"
607 stroke="var(--green)"
608 fill="rgba(74,222,128,0.15)"
609 strokeWidth={2}
610 name={t("chart_in_range")}
611 />
612 <Tooltip content={<ChartTooltip />} />
613 </RadarChart>
614 </ResponsiveContainer>
615 )}
616 </div>
617 </div>
618
619 {/* Crop Summary Table */}
620 <div
621 style={{
622 borderRadius: 14,
623 overflow: "hidden",
624 background: "var(--surface)",
625 border: "1px solid var(--border)",
626 flexShrink: 0,
627 }}
628 >
629 <div
630 style={{
631 padding: "16px 20px",
632 borderBottom: "1px solid var(--border)",
633 }}
634 >
635 <SectionHead
636 label={t("analytics_per_crop")}
637 title={t("analytics_latest_sensor")}
638 />
639 </div>
640 {loading ? (
641 <div style={{ padding: 32, textAlign: "center" }}>
642 <span
643 style={{
644 fontSize: 12,
645 fontFamily: "DM Mono, monospace",
646 color: "var(--text-3)",
647 }}
648 >
649 {t("common_loading")}
650 </span>
651 </div>
652 ) : cropSummaryRows.length === 0 ? (
653 <div
654 style={{
655 padding: 32,
656 textAlign: "center",
657 fontSize: 12,
658 fontFamily: "DM Mono, monospace",
659 color: "var(--text-3)",
660 }}
661 >
662 {t("dash_no_crops")}
663 </div>
664 ) : (
665 <table
666 className="data-table"
667 style={{ width: "100%", borderCollapse: "collapse" }}
668 >
669 <thead>
670 <tr>
671 {[
672 t("analytics_th_crop_id"),
673 t("analytics_th_type"),
674 t("analytics_th_stage"),
675 t("analytics_th_ph"),
676 t("analytics_th_ec"),
677 t("analytics_th_temp"),
678 t("analytics_th_seq"),
679 ].map((h) => (
680 <th key={h}>{h}</th>
681 ))}
682 </tr>
683 </thead>
684 <tbody>
685 {cropSummaryRows.map(({ p, s, key }) => (
686 <tr
687 key={key}
688 style={{ transition: "background 0.12s" }}
689 onMouseEnter={(e) =>
690 (e.currentTarget.style.background = "var(--bg-3)")
691 }
692 onMouseLeave={(e) =>
693 (e.currentTarget.style.background = "transparent")
694 }
695 >
696 <td
697 style={{
698 fontFamily: "DM Mono, monospace",
699 fontSize: 12,
700 color: "var(--text)",
701 fontWeight: 600,
702 }}
703 >
704 {p.crop_id || "-"}
705 </td>
706 <td>{td(p.crop) || "-"}</td>
707 <td
708 style={{
709 color: "var(--text-3)",
710 fontSize: 12,
711 fontFamily: "DM Mono, monospace",
712 }}
713 >
714 {td(p.stage) || "-"}
715 </td>
716 <td>
717 <span
718 className="sensor-value-xs"
719 style={{ color: "var(--green)" }}
720 >
721 {s.ph}
722 </span>
723 </td>
724 <td>
725 <span
726 className="sensor-value-xs"
727 style={{ color: "var(--amber)" }}
728 >
729 {s.ec}
730 </span>
731 <span
732 style={{
733 fontSize: 11,
734 color: "var(--text-3)",
735 marginLeft: 3,
736 }}
737 >
738 dS/m
739 </span>
740 </td>
741 <td>
742 <span
743 className="sensor-value-xs"
744 style={{ color: "var(--blue)" }}
745 >
746 {s.temp}
747 </span>
748 <span
749 style={{
750 fontSize: 11,
751 color: "var(--text-3)",
752 marginLeft: 2,
753 }}
754 >
755 °C
756 </span>
757 </td>
758 <td
759 style={{
760 fontFamily: "DM Mono, monospace",
761 fontSize: 13,
762 color: "var(--text-2)",
763 }}
764 >
765 {p.sequence_number || 1}
766 </td>
767 </tr>
768 ))}
769 </tbody>
770 </table>
771 )}
772 </div>
773
774 {/* Agent Activity Table */}
775 <div
776 style={{
777 borderRadius: 14,
778 overflow: "hidden",
779 background: "var(--surface)",
780 border: "1px solid var(--border)",
781 flexShrink: 0,
782 }}
783 >
784 <div
785 style={{
786 padding: "16px 20px",
787 borderBottom: "1px solid var(--border)",
788 }}
789 >
790 <SectionHead
791 label={t("analytics_derived_act")}
792 title={t("analytics_agent_act")}
793 />
794 </div>
795 <table
796 className="data-table"
797 style={{ width: "100%", borderCollapse: "collapse" }}
798 >
799 <thead>
800 <tr>
801 {[
802 t("analytics_th_agent"),
803 t("analytics_th_apps"),
804 t("analytics_th_success"),
805 t("analytics_th_status"),
806 ].map((h) => (
807 <th key={h}>{h}</th>
808 ))}
809 </tr>
810 </thead>
811 <tbody>
812 {agentRows.map(({ name, decisions, accuracy }) => (
813 <tr
814 key={name}
815 onMouseEnter={(e) =>
816 (e.currentTarget.style.background = "var(--bg-3)")
817 }
818 onMouseLeave={(e) =>
819 (e.currentTarget.style.background = "transparent")
820 }
821 style={{ transition: "background 0.12s" }}
822 >
823 <td
824 style={{
825 fontFamily: "DM Mono, monospace",
826 fontWeight: 600,
827 fontSize: 13,
828 color: "var(--text)",
829 }}
830 >
831 {name}
832 </td>
833 <td
834 style={{ fontFamily: "DM Mono, monospace", fontSize: 13 }}
835 >
836 {decisions}
837 </td>
838 <td>
839 <div
840 style={{
841 display: "flex",
842 alignItems: "center",
843 gap: 10,
844 }}
845 >
846 <div
847 style={{
848 height: 6,
849 width: 100,
850 borderRadius: 3,
851 background: "var(--border)",
852 }}
853 >
854 <div
855 style={{
856 height: "100%",
857 borderRadius: 3,
858 width: `${accuracy}%`,
859 background:
860 accuracy > 80
861 ? "var(--green)"
862 : accuracy > 50
863 ? "var(--amber)"
864 : "var(--red)",
865 transition: "width 0.6s ease",
866 }}
867 />
868 </div>
869 <span
870 style={{
871 fontFamily: "DM Mono, monospace",
872 fontSize: 13,
873 color: "var(--text-2)",
874 minWidth: 36,
875 }}
876 >
877 {accuracy}%
878 </span>
879 </div>
880 </td>
881 <td>
882 <span
883 style={{
884 fontSize: 11,
885 fontFamily: "DM Mono, monospace",
886 padding: "3px 10px",
887 borderRadius: 20,
888 background: "rgba(74,222,128,0.1)",
889 color: "var(--green)",
890 border: "1px solid rgba(74,222,128,0.2)",
891 }}
892 >
893 {t("analytics_online")}
894 </span>
895 </td>
896 </tr>
897 ))}
898 </tbody>
899 </table>
900 </div>
901 </div>
902 </PageShell>
903 );
904}