demeter
Autonomous Hydroponic Intelligencegit clone https://github.com/maydayv7/demeter
frontend/src/pages/CropDetails.jsx (46767B)
1import React, { useEffect, useState } from "react";
2import { useParams, useNavigate } from "react-router-dom";
3import { fetchCropDetails, fetchCropById } from "../api/farmApi";
4import {
5 ArrowLeft,
6 Thermometer,
7 Droplet,
8 Wind,
9 Activity,
10 Brain,
11 ChevronDown,
12 ChevronUp,
13 Sparkles,
14 MapPin,
15 Calendar,
16 Clock,
17 ChevronRight,
18 Cpu,
19 StickyNote,
20 Play,
21 Leaf,
22} from "lucide-react";
23import {
24 AreaChart,
25 Area,
26 LineChart,
27 Line,
28 XAxis,
29 YAxis,
30 CartesianGrid,
31 Tooltip,
32 ResponsiveContainer,
33} from "recharts";
34import {
35 extractSensors,
36 parsePythonString,
37 formatNumber,
38 calculateMaturity,
39 getDaysRemaining,
40 getCurrentStage,
41 getEffectiveElapsedHours,
42 CROP_LIFECYCLES,
43 CROP_CYCLE_HOURS,
44} from "../utils/dataUtils";
45import {
46 AgentActionWidget,
47 AgentOutcomeWidget,
48} from "../components/AgentWidgets";
49import {
50 PageShell,
51 PageHeader,
52 IconButton,
53 ChartTooltip,
54 InlineLabel,
55} from "../components/ui";
56import { useSettings } from "../hooks/useSettings";
57import { useT } from "../hooks/useTranslation";
58
59// Sub-components
60
61function StatBox({ icon: Icon, label, value, color, unit }) {
62 return (
63 <div
64 className="card-hover"
65 style={{
66 borderRadius: 14,
67 padding: "18px 20px",
68 background: "var(--surface)",
69 border: "1px solid var(--border)",
70 }}
71 >
72 <div
73 style={{
74 display: "flex",
75 alignItems: "center",
76 gap: 8,
77 marginBottom: 12,
78 }}
79 >
80 <div
81 style={{
82 width: 32,
83 height: 32,
84 borderRadius: 8,
85 background: `${color}18`,
86 border: `1px solid ${color}30`,
87 display: "flex",
88 alignItems: "center",
89 justifyContent: "center",
90 }}
91 >
92 <Icon size={14} style={{ color }} />
93 </div>
94 <span className="sensor-label">{label}</span>
95 </div>
96 <div style={{ display: "flex", alignItems: "baseline", gap: 4 }}>
97 <span className="sensor-value" style={{ color }}>
98 {value}
99 </span>
100 {unit && <span className="sensor-unit">{unit}</span>}
101 </div>
102 </div>
103 );
104}
105
106// Severity dot for logs
107function logDotColor(payload) {
108 const outcome = (payload?.outcome || "").toLowerCase();
109 if (/fail|critical|disease|error/.test(outcome)) return "var(--red)";
110 if (/deteriorat|negative|attention/.test(outcome)) return "var(--amber)";
111 return "var(--green)";
112}
113
114function ExplanationLogBlock({ log, t, td }) {
115 const [expanded, setExpanded] = useState(false);
116
117 const isPending =
118 !log || log === "PENDING_ANALYSIS" || log.trim().length === 0;
119
120 if (isPending) {
121 return (
122 <div
123 style={{
124 borderRadius: 14,
125 padding: "18px 20px",
126 background: "var(--surface)",
127 border: "1px solid var(--border)",
128 }}
129 >
130 <div
131 style={{
132 display: "flex",
133 alignItems: "center",
134 gap: 10,
135 marginBottom: 12,
136 }}
137 >
138 <div
139 style={{
140 width: 32,
141 height: 32,
142 borderRadius: 8,
143 background: "rgba(167,139,250,0.08)",
144 border: "1px solid rgba(167,139,250,0.15)",
145 display: "flex",
146 alignItems: "center",
147 justifyContent: "center",
148 }}
149 >
150 <Brain size={14} style={{ color: "#a78bfa" }} />
151 </div>
152 <InlineLabel style={{ marginBottom: 0 }}>
153 {t("details_ai_reasoning")}
154 </InlineLabel>
155 <span
156 style={{
157 fontSize: 9,
158 fontFamily: "DM Mono, monospace",
159 padding: "2px 8px",
160 borderRadius: 20,
161 background: "var(--bg-3)",
162 border: "1px solid var(--border)",
163 color: "var(--text-3)",
164 }}
165 >
166 PENDING
167 </span>
168 </div>
169 <div
170 style={{
171 fontSize: 12,
172 fontFamily: "DM Mono, monospace",
173 color: "var(--text-3)",
174 fontStyle: "italic",
175 }}
176 >
177 {t("details_pending_exp")}
178 </div>
179 </div>
180 );
181 }
182
183 const translatedLog = td(log);
184 const lines = translatedLog.split("\n").filter((l) => l.trim());
185 const isStructured = lines.some((l) => /^\d+\./.test(l.trim()));
186
187 // Preview: first 3 lines
188 const previewLines = lines.slice(0, 3);
189 const hasMore = lines.length > 3;
190
191 return (
192 <div
193 style={{
194 borderRadius: 14,
195 background: "var(--surface)",
196 border: "1px solid rgba(167,139,250,0.2)",
197 overflow: "hidden",
198 flexShrink: 0,
199 }}
200 >
201 {/* Header */}
202 <div
203 style={{
204 padding: "14px 20px",
205 borderBottom: "1px solid var(--border)",
206 background: "rgba(167,139,250,0.05)",
207 display: "flex",
208 alignItems: "center",
209 gap: 10,
210 }}
211 >
212 <div
213 style={{
214 width: 28,
215 height: 28,
216 borderRadius: 7,
217 background: "rgba(167,139,250,0.12)",
218 border: "1px solid rgba(167,139,250,0.25)",
219 display: "flex",
220 alignItems: "center",
221 justifyContent: "center",
222 flexShrink: 0,
223 }}
224 >
225 <Sparkles size={12} style={{ color: "#a78bfa" }} />
226 </div>
227 <div style={{ flex: 1 }}>
228 <InlineLabel style={{ marginBottom: 0 }}>
229 {t("details_ai_reasoning")}
230 </InlineLabel>
231 <div
232 style={{
233 fontSize: 10,
234 fontFamily: "DM Mono, monospace",
235 color: "var(--text-3)",
236 marginTop: 2,
237 }}
238 >
239 {t("details_chain_thought")}
240 </div>
241 </div>
242 {hasMore && (
243 <button
244 onClick={() => setExpanded((e) => !e)}
245 style={{
246 display: "flex",
247 alignItems: "center",
248 gap: 5,
249 padding: "4px 10px",
250 borderRadius: 7,
251 fontSize: 10,
252 fontFamily: "DM Mono, monospace",
253 cursor: "pointer",
254 background: "var(--bg-3)",
255 border: "1px solid var(--border)",
256 color: "var(--text-3)",
257 }}
258 >
259 {expanded ? (
260 <>
261 <ChevronUp size={11} /> {t("details_collapse")}
262 </>
263 ) : (
264 <>
265 <ChevronDown size={11} /> {t("details_expand")}
266 </>
267 )}
268 </button>
269 )}
270 </div>
271
272 {/* Content */}
273 <div style={{ padding: "16px 20px" }}>
274 {isStructured ? (
275 // Render numbered steps as styled blocks
276 <div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
277 {(expanded ? lines : previewLines).map((line, i) => {
278 const stepMatch = line
279 .trim()
280 .match(/^(\d+)\.\s+\*\*(.+?)\*\*:?\s*(.*)/);
281
282 if (stepMatch) {
283 const [, num, title, body] = stepMatch;
284 const stepColors = [
285 "var(--blue)",
286 "#a78bfa",
287 "var(--amber)",
288 "var(--green)",
289 ];
290 const col = stepColors[(parseInt(num) - 1) % stepColors.length];
291 return (
292 <div
293 key={i}
294 style={{
295 display: "flex",
296 gap: 12,
297 padding: "10px 14px",
298 borderRadius: 10,
299 background: "var(--bg-3)",
300 border: `1px solid ${col}18`,
301 }}
302 >
303 <div
304 style={{
305 width: 22,
306 height: 22,
307 borderRadius: "50%",
308 background: `${col}18`,
309 border: `1px solid ${col}40`,
310 display: "flex",
311 alignItems: "center",
312 justifyContent: "center",
313 flexShrink: 0,
314 fontSize: 10,
315 fontFamily: "DM Mono, monospace",
316 color: col,
317 fontWeight: 700,
318 }}
319 >
320 {num}
321 </div>
322 <div>
323 <div
324 style={{
325 fontSize: 12,
326 fontWeight: 700,
327 color: col,
328 fontFamily: "DM Mono, monospace",
329 marginBottom: 3,
330 }}
331 >
332 {title}
333 </div>
334 {body && (
335 <div
336 style={{
337 fontSize: 12,
338 fontFamily: "DM Mono, monospace",
339 color: "var(--text-2)",
340 lineHeight: 1.7,
341 }}
342 >
343 {body}
344 </div>
345 )}
346 </div>
347 </div>
348 );
349 }
350
351 // Regular line
352 return (
353 <div
354 key={i}
355 style={{
356 fontSize: 12,
357 fontFamily: "DM Mono, monospace",
358 color: "var(--text-2)",
359 lineHeight: 1.7,
360 padding: "2px 0",
361 }}
362 >
363 {line}
364 </div>
365 );
366 })}
367
368 {!expanded && hasMore && (
369 <button
370 onClick={() => setExpanded(true)}
371 style={{
372 alignSelf: "flex-start",
373 padding: "5px 12px",
374 borderRadius: 7,
375 fontSize: 11,
376 fontFamily: "DM Mono, monospace",
377 cursor: "pointer",
378 background: "rgba(167,139,250,0.08)",
379 border: "1px solid rgba(167,139,250,0.2)",
380 color: "#a78bfa",
381 }}
382 >
383 {t("details_more_lines", {
384 n: lines.length - previewLines.length,
385 })}
386 </button>
387 )}
388 </div>
389 ) : (
390 // Plain text fallback
391 <pre
392 style={{
393 fontSize: 12,
394 fontFamily: "DM Mono, monospace",
395 color: "var(--text-2)",
396 lineHeight: 1.75,
397 whiteSpace: "pre-wrap",
398 margin: 0,
399 maxHeight: expanded ? "none" : 140,
400 overflow: expanded ? "visible" : "hidden",
401 }}
402 >
403 {translatedLog}
404 </pre>
405 )}
406 </div>
407 </div>
408 );
409}
410
411function InfoTab({ cropDoc, cropId, navigate, t }) {
412 if (!cropDoc) {
413 return (
414 <div
415 style={{
416 display: "flex",
417 alignItems: "center",
418 justifyContent: "center",
419 padding: 48,
420 }}
421 >
422 <span
423 style={{
424 fontSize: 13,
425 fontFamily: "DM Mono, monospace",
426 color: "var(--text-3)",
427 }}
428 >
429 No metadata available yet - run the first cycle to populate data.
430 </span>
431 </div>
432 );
433 }
434
435 const maturity = calculateMaturity(cropDoc);
436 const daysLeft = getDaysRemaining(cropDoc);
437 const stage = getCurrentStage(cropDoc) || cropDoc.stage || "seedling";
438 const cycleH =
439 cropDoc.cycle_duration_hours ||
440 CROP_CYCLE_HOURS[(cropDoc.crop || "").toLowerCase()] ||
441 1;
442 const lifecycle = CROP_LIFECYCLES[(cropDoc.crop || "").toLowerCase()];
443
444 const plantedDate = cropDoc.planted_at
445 ? new Date(cropDoc.planted_at).toLocaleDateString(undefined, {
446 year: "numeric",
447 month: "short",
448 day: "numeric",
449 })
450 : "-";
451 const daysSince = Math.floor(getEffectiveElapsedHours(cropDoc) / 24) || null;
452
453 const sensorIds = cropDoc.sensor_ids || {};
454 const SENSORS_DISPLAY = [
455 {
456 label: "pH Sensor",
457 id: sensorIds.ph_sensor || "PH-SEN-????",
458 color: "var(--green)",
459 },
460 {
461 label: "EC Sensor",
462 id: sensorIds.ec_sensor || "EC-SEN-????",
463 color: "var(--amber)",
464 },
465 {
466 label: "Temp Sensor",
467 id: sensorIds.temp_sensor || "TMP-SEN-????",
468 color: "var(--blue)",
469 },
470 {
471 label: "Humidity Sensor",
472 id: sensorIds.humidity_sensor || "HUM-SEN-????",
473 color: "#a78bfa",
474 },
475 ];
476
477 return (
478 <div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
479 {/* Crop header */}
480 <div
481 style={{
482 display: "flex",
483 alignItems: "center",
484 gap: 16,
485 padding: 20,
486 borderRadius: 16,
487 background: "var(--surface)",
488 border: "1px solid var(--border)",
489 }}
490 >
491 <div
492 style={{
493 width: 64,
494 height: 64,
495 borderRadius: 16,
496 background: "rgba(74,222,128,0.1)",
497 border: "1px solid rgba(74,222,128,0.25)",
498 display: "flex",
499 alignItems: "center",
500 justifyContent: "center",
501 flexShrink: 0,
502 }}
503 >
504 <Leaf size={28} style={{ color: "var(--green)" }} />
505 </div>
506 <div style={{ flex: 1 }}>
507 <div
508 style={{
509 fontWeight: 700,
510 fontSize: 18,
511 color: "var(--text)",
512 marginBottom: 4,
513 }}
514 >
515 {cropDoc.crop}
516 </div>
517 <div
518 style={{
519 fontSize: 11,
520 fontFamily: "DM Mono, monospace",
521 color: "var(--text-3)",
522 }}
523 >
524 {cropId}
525 </div>
526 <div
527 style={{
528 marginTop: 8,
529 display: "flex",
530 alignItems: "center",
531 gap: 8,
532 }}
533 >
534 <span
535 style={{
536 fontSize: 10,
537 fontFamily: "DM Mono, monospace",
538 padding: "2px 10px",
539 borderRadius: 20,
540 background: "rgba(74,222,128,0.12)",
541 border: "1px solid rgba(74,222,128,0.3)",
542 color: "var(--green)",
543 textTransform: "capitalize",
544 }}
545 >
546 {stage}
547 </span>
548 {cycleH && (
549 <span
550 style={{
551 fontSize: 10,
552 fontFamily: "DM Mono, monospace",
553 color: "var(--text-3)",
554 }}
555 >
556 {t("details_hours_per_cycle", { n: cycleH })}
557 </span>
558 )}
559 </div>
560 </div>
561 </div>
562
563 {/* Details grid */}
564 <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
565 {[
566 {
567 icon: Calendar,
568 label: t("details_planted"),
569 value: plantedDate,
570 color: "var(--blue)",
571 },
572 {
573 icon: Clock,
574 label: t("details_cycle_duration"),
575 value: `${cycleH}h / cycle`,
576 color: "var(--green)",
577 },
578 {
579 icon: Leaf,
580 label: t("details_lifecycle_progress"),
581 value: `${maturity}%`,
582 color: "var(--amber)",
583 },
584 {
585 icon: MapPin,
586 label: t("details_location"),
587 value: cropDoc.location || "-",
588 color: "var(--text-2)",
589 },
590 ].map(({ icon, label, value, color }) => (
591 <div
592 key={label}
593 style={{
594 padding: "14px 16px",
595 borderRadius: 12,
596 background: "var(--surface)",
597 border: "1px solid var(--border)",
598 }}
599 >
600 <div
601 style={{
602 display: "flex",
603 alignItems: "center",
604 gap: 7,
605 marginBottom: 8,
606 }}
607 >
608 {React.createElement(icon, { size: 12, style: { color } })}
609 <span
610 style={{
611 fontSize: 10,
612 fontFamily: "DM Mono, monospace",
613 color: "var(--text-3)",
614 }}
615 >
616 {label}
617 </span>
618 </div>
619 <div
620 style={{
621 fontSize: 14,
622 fontWeight: 700,
623 color,
624 fontFamily: "DM Mono, monospace",
625 }}
626 >
627 {value}
628 </div>
629 </div>
630 ))}
631
632 {/* Days since / remaining */}
633 <div
634 style={{
635 padding: "14px 16px",
636 borderRadius: 12,
637 background: "var(--surface)",
638 border: "1px solid var(--border)",
639 }}
640 >
641 <div
642 style={{
643 fontSize: 10,
644 fontFamily: "DM Mono, monospace",
645 color: "var(--text-3)",
646 marginBottom: 8,
647 }}
648 >
649 {t("details_days_since", { n: daysSince ?? "-" })}
650 </div>
651 <div
652 style={{
653 fontSize: 14,
654 fontWeight: 700,
655 fontFamily: "DM Mono, monospace",
656 color: "var(--text-2)",
657 }}
658 >
659 {daysLeft !== null
660 ? t("details_days_remain", { n: daysLeft })
661 : "-"}
662 </div>
663 </div>
664
665 <div
666 style={{
667 padding: "14px 16px",
668 borderRadius: 12,
669 background: "var(--surface)",
670 border: "1px solid var(--border)",
671 }}
672 >
673 <div
674 style={{
675 fontSize: 10,
676 fontFamily: "DM Mono, monospace",
677 color: "var(--text-3)",
678 marginBottom: 8,
679 }}
680 >
681 {t("details_total_sequences")}
682 </div>
683 <div
684 style={{
685 fontSize: 22,
686 fontWeight: 700,
687 fontFamily: "DM Mono, monospace",
688 color: "var(--text)",
689 }}
690 >
691 {cropDoc.sequence_number || 0}
692 </div>
693 </div>
694 </div>
695
696 {/* Lifecycle progress bar */}
697 {lifecycle && (
698 <div
699 style={{
700 padding: 16,
701 borderRadius: 14,
702 background: "var(--surface)",
703 border: "1px solid var(--border)",
704 }}
705 >
706 <div
707 style={{
708 fontSize: 10,
709 fontFamily: "DM Mono, monospace",
710 color: "var(--text-3)",
711 marginBottom: 10,
712 }}
713 >
714 {t("details_lifecycle_progress")} - {lifecycle.totalDays} days total
715 </div>
716 <div
717 style={{
718 height: 8,
719 borderRadius: 4,
720 background: "var(--border)",
721 overflow: "hidden",
722 marginBottom: 8,
723 }}
724 >
725 <div
726 style={{
727 width: `${maturity}%`,
728 height: "100%",
729 borderRadius: 4,
730 background: maturity >= 80 ? "var(--amber)" : "var(--green)",
731 transition: "width 0.4s",
732 }}
733 />
734 </div>
735 <div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
736 {lifecycle.stages.map((s, i) => {
737 const colors = [
738 "var(--text-3)",
739 "var(--green)",
740 "var(--amber)",
741 "var(--red)",
742 ];
743 return (
744 <div
745 key={s.name}
746 style={{ display: "flex", alignItems: "center", gap: 5 }}
747 >
748 <span
749 style={{
750 width: 8,
751 height: 8,
752 borderRadius: "50%",
753 background: colors[i % colors.length],
754 display: "inline-block",
755 }}
756 />
757 <span
758 style={{
759 fontSize: 10,
760 fontFamily: "DM Mono, monospace",
761 color: "var(--text-3)",
762 textTransform: "capitalize",
763 }}
764 >
765 {s.name} (
766 {s.endH
767 ? `${Math.round((s.endH - s.startH) / 24)}d`
768 : "harvest"}
769 )
770 </span>
771 </div>
772 );
773 })}
774 </div>
775 </div>
776 )}
777
778 {/* Sensor Hardware */}
779 <div
780 style={{
781 borderRadius: 14,
782 overflow: "hidden",
783 background: "var(--surface)",
784 border: "1px solid var(--border)",
785 }}
786 >
787 <div
788 style={{
789 padding: "12px 16px",
790 borderBottom: "1px solid var(--border)",
791 background: "var(--bg-3)",
792 display: "flex",
793 alignItems: "center",
794 gap: 8,
795 }}
796 >
797 <Cpu size={13} style={{ color: "var(--text-3)" }} />
798 <span
799 style={{
800 fontSize: 10,
801 fontFamily: "DM Mono, monospace",
802 color: "var(--text-3)",
803 letterSpacing: "0.08em",
804 }}
805 >
806 {t("details_sensor_hardware")}
807 </span>
808 </div>
809 <div
810 style={{
811 display: "grid",
812 gridTemplateColumns: "1fr 1fr",
813 gap: 1,
814 background: "var(--border)",
815 }}
816 >
817 {SENSORS_DISPLAY.map(({ label, id, color }) => (
818 <div
819 key={label}
820 style={{ padding: "14px 16px", background: "var(--surface)" }}
821 >
822 <div
823 style={{
824 fontSize: 10,
825 fontFamily: "DM Mono, monospace",
826 color: "var(--text-3)",
827 marginBottom: 6,
828 }}
829 >
830 {label}
831 </div>
832 <div
833 style={{
834 fontSize: 13,
835 fontFamily: "DM Mono, monospace",
836 fontWeight: 700,
837 color,
838 marginBottom: 4,
839 }}
840 >
841 {id}
842 </div>
843 <div style={{ display: "flex", alignItems: "center", gap: 5 }}>
844 <span
845 style={{
846 width: 5,
847 height: 5,
848 borderRadius: "50%",
849 background: "var(--green)",
850 display: "inline-block",
851 }}
852 />
853 <span
854 style={{
855 fontSize: 9,
856 fontFamily: "DM Mono, monospace",
857 color: "var(--green)",
858 }}
859 >
860 {t("details_sensor_online")}
861 </span>
862 </div>
863 </div>
864 ))}
865 </div>
866 </div>
867
868 {/* Notes */}
869 {cropDoc.notes && (
870 <div
871 style={{
872 padding: 16,
873 borderRadius: 14,
874 background: "var(--surface)",
875 border: "1px solid var(--border)",
876 }}
877 >
878 <div
879 style={{
880 display: "flex",
881 alignItems: "center",
882 gap: 8,
883 marginBottom: 10,
884 }}
885 >
886 <StickyNote size={13} style={{ color: "var(--text-3)" }} />
887 <span
888 style={{
889 fontSize: 10,
890 fontFamily: "DM Mono, monospace",
891 color: "var(--text-3)",
892 }}
893 >
894 {t("details_notes")}
895 </span>
896 </div>
897 <p
898 style={{
899 fontSize: 13,
900 color: "var(--text-2)",
901 lineHeight: 1.7,
902 margin: 0,
903 fontFamily: "DM Mono, monospace",
904 }}
905 >
906 {cropDoc.notes}
907 </p>
908 </div>
909 )}
910
911 {/* Run Cycle CTA */}
912 <button
913 onClick={() => navigate(`/run-cycle/${cropId}`)}
914 style={{
915 padding: "14px 20px",
916 borderRadius: 12,
917 background: "var(--green)",
918 border: "none",
919 color: "var(--btn-on-green)",
920 fontWeight: 700,
921 fontSize: 14,
922 cursor: "pointer",
923 display: "flex",
924 alignItems: "center",
925 justifyContent: "center",
926 gap: 8,
927 marginBottom: 8,
928 }}
929 >
930 <Play size={15} />
931 {t("details_run_cycle_btn")}
932 <ChevronRight size={14} />
933 </button>
934 </div>
935 );
936}
937
938// Tabs
939
940const TABS = [
941 "details_tab_info",
942 "details_tab_overview",
943 "details_tab_sensors",
944 "details_tab_log",
945];
946
947// MAIN
948
949export default function CropDetails() {
950 const { cropId } = useParams();
951 const navigate = useNavigate();
952 const { settings } = useSettings();
953 const { t, td } = useT();
954 const logLimit = settings.historyLogLimit ?? 20;
955
956 const [history, setHistory] = useState([]);
957 const [latest, setLatest] = useState(null);
958 const [cropDoc, setCropDoc] = useState(null);
959 const [loading, setLoading] = useState(true);
960 const [lastExplainedLog, setLastExplainedLog] = useState(null);
961 const [activeTab, setActiveTab] = useState("details_tab_info");
962
963 useEffect(() => {
964 let cancelled = false;
965 setLoading(true);
966
967 Promise.all([
968 // Qdrant history
969 fetchCropDetails(cropId),
970 // MongoDB doc
971 fetchCropById(cropId),
972 ]).then(([histData, mongoDoc]) => {
973 if (cancelled) return;
974
975 if (histData?.length) {
976 const sorted = [...histData].sort(
977 (a, b) =>
978 (a.payload?.sequence_number || 0) -
979 (b.payload?.sequence_number || 0),
980 );
981 const processed = sorted.map((item) => ({
982 ...item,
983 cleanSensors: extractSensors(item.payload || {}),
984 parsedAction: parsePythonString((item.payload || {}).action_taken),
985 }));
986 setHistory(processed);
987 setLatest(processed[processed.length - 1]);
988 const lastExplained = [...processed]
989 .reverse()
990 .find(
991 (h) =>
992 h.payload?.explanation_log &&
993 h.payload.explanation_log !== "PENDING_ANALYSIS" &&
994 h.payload.explanation_log.trim().length > 0,
995 );
996 setLastExplainedLog(lastExplained?.payload?.explanation_log || null);
997 }
998
999 if (mongoDoc) setCropDoc(mongoDoc);
1000 setLoading(false);
1001 });
1002
1003 return () => {
1004 cancelled = true;
1005 };
1006 }, [cropId]);
1007
1008 if (loading)
1009 return (
1010 <PageShell>
1011 <div
1012 style={{
1013 flex: 1,
1014 display: "flex",
1015 alignItems: "center",
1016 justifyContent: "center",
1017 }}
1018 >
1019 <span
1020 style={{
1021 fontSize: 13,
1022 fontFamily: "DM Mono, monospace",
1023 color: "var(--text-3)",
1024 }}
1025 >
1026 {t("common_loading")}
1027 </span>
1028 </div>
1029 </PageShell>
1030 );
1031
1032 if (!latest && !cropDoc)
1033 return (
1034 <div
1035 style={{
1036 flex: 1,
1037 display: "flex",
1038 alignItems: "center",
1039 justifyContent: "center",
1040 }}
1041 >
1042 <span style={{ color: "var(--text-3)" }}>{t("details_not_found")}</span>
1043 </div>
1044 );
1045
1046 const p = latest?.payload || {};
1047 const sensors = latest?.cleanSensors || {};
1048
1049 const chartData = history.map((h) => ({
1050 t: h.payload?.timestamp
1051 ? new Date(h.payload.timestamp).toLocaleTimeString([], {
1052 hour: "2-digit",
1053 minute: "2-digit",
1054 })
1055 : "--",
1056 temp: formatNumber(h.cleanSensors?.temp),
1057 ph: formatNumber(h.cleanSensors?.ph),
1058 ec: formatNumber(h.cleanSensors?.ec),
1059 humidity: formatNumber(h.cleanSensors?.humidity),
1060 }));
1061
1062 const displayCrop = cropDoc?.crop || p.crop || t("common_unknown");
1063 const displayStage = cropDoc?.stage || p.stage || "";
1064
1065 return (
1066 <PageShell>
1067 {/* Header */}
1068 <PageHeader>
1069 <IconButton onClick={() => navigate("/dashboard")}>
1070 <ArrowLeft size={15} />
1071 </IconButton>
1072
1073 <div>
1074 <h1 className="page-title">
1075 {td(displayCrop)}{" "}
1076 <span
1077 style={{ color: "var(--text-3)", fontWeight: 400, fontSize: 16 }}
1078 >
1079 #{p.sequence_number || 0}
1080 </span>
1081 </h1>
1082 <p className="page-subtitle">
1083 {cropId} · {td(displayStage)}
1084 </p>
1085 </div>
1086
1087 {/* Live badge */}
1088 <div
1089 style={{
1090 display: "flex",
1091 alignItems: "center",
1092 gap: 6,
1093 padding: "4px 10px",
1094 borderRadius: 20,
1095 background: "rgba(74,222,128,0.1)",
1096 border: "1px solid rgba(74,222,128,0.25)",
1097 fontSize: 11,
1098 fontFamily: "DM Mono, monospace",
1099 color: "var(--green)",
1100 }}
1101 >
1102 <span
1103 className="status-dot"
1104 style={{
1105 width: 6,
1106 height: 6,
1107 borderRadius: "50%",
1108 background: "var(--green)",
1109 }}
1110 />
1111 {t("details_live")}
1112 </div>
1113
1114 {/* Tabs */}
1115 <div
1116 style={{
1117 marginLeft: "auto",
1118 display: "flex",
1119 gap: 4,
1120 flexWrap: "wrap",
1121 }}
1122 >
1123 {TABS.map((tab) => (
1124 <button
1125 key={tab}
1126 onClick={() => setActiveTab(tab)}
1127 style={{
1128 padding: "5px 14px",
1129 borderRadius: 8,
1130 fontSize: 12,
1131 fontFamily: "DM Mono, monospace",
1132 textTransform: "capitalize",
1133 cursor: "pointer",
1134 background:
1135 activeTab === tab ? "var(--surface-2)" : "transparent",
1136 color: activeTab === tab ? "var(--text)" : "var(--text-3)",
1137 border: `1px solid ${activeTab === tab ? "var(--border-bright)" : "transparent"}`,
1138 }}
1139 >
1140 {t(tab)}
1141 </button>
1142 ))}
1143 </div>
1144 </PageHeader>
1145
1146 {/* Content */}
1147 <div
1148 style={{
1149 flex: 1,
1150 overflowY: "auto",
1151 padding: 24,
1152 display: "flex",
1153 flexDirection: "column",
1154 gap: 20,
1155 }}
1156 >
1157 {/* Info Tab */}
1158 {activeTab === "details_tab_info" && (
1159 <InfoTab
1160 cropDoc={cropDoc}
1161 cropId={cropId}
1162 navigate={navigate}
1163 t={t}
1164 />
1165 )}
1166
1167 {/* Overview Tab */}
1168 {activeTab === "details_tab_overview" && (
1169 <>
1170 {/* Sensor stats */}
1171 <div
1172 style={{
1173 display: "grid",
1174 gridTemplateColumns: "repeat(4,1fr)",
1175 gap: 12,
1176 }}
1177 >
1178 <StatBox
1179 icon={Thermometer}
1180 label={t("sensor_temp")}
1181 value={formatNumber(sensors.temp)}
1182 unit="°C"
1183 color="var(--blue)"
1184 />
1185 <StatBox
1186 icon={Droplet}
1187 label={t("sensor_ph")}
1188 value={formatNumber(sensors.ph)}
1189 unit=""
1190 color="var(--green)"
1191 />
1192 <StatBox
1193 icon={Activity}
1194 label={t("sensor_ec")}
1195 value={formatNumber(sensors.ec)}
1196 unit="dS/m"
1197 color="var(--amber)"
1198 />
1199 <StatBox
1200 icon={Wind}
1201 label={t("sensor_humidity")}
1202 value={formatNumber(sensors.humidity)}
1203 unit="%"
1204 color="#a78bfa"
1205 />
1206 </div>
1207
1208 {/* Outcome */}
1209 {p.outcome && p.outcome !== "PENDING_OBSERVATION" && (
1210 <AgentOutcomeWidget
1211 outcome={p.outcome}
1212 rewardScore={p.reward_score}
1213 strategicIntent={p.strategic_intent}
1214 />
1215 )}
1216 <ExplanationLogBlock log={lastExplainedLog} t={t} td={td} />
1217 <div
1218 style={{
1219 borderRadius: 14,
1220 padding: 20,
1221 background: "var(--surface)",
1222 border: "1px solid var(--border)",
1223 }}
1224 >
1225 <InlineLabel>{t("details_hist_ph")}</InlineLabel>
1226 <ResponsiveContainer width="100%" height={200}>
1227 <AreaChart data={chartData}>
1228 <defs>
1229 <linearGradient id="phGradCD" x1="0" y1="0" x2="0" y2="1">
1230 <stop offset="0%" stopColor="#4ade80" stopOpacity={0.3} />
1231 <stop offset="100%" stopColor="#4ade80" stopOpacity={0} />
1232 </linearGradient>
1233 </defs>
1234 <CartesianGrid
1235 stroke="var(--border)"
1236 strokeDasharray="3 3"
1237 vertical={false}
1238 />
1239 <XAxis
1240 dataKey="t"
1241 tick={{
1242 fontSize: 10,
1243 fill: "var(--text-3)",
1244 fontFamily: "DM Mono",
1245 }}
1246 axisLine={false}
1247 tickLine={false}
1248 />
1249 <YAxis
1250 domain={["auto", "auto"]}
1251 tick={{
1252 fontSize: 10,
1253 fill: "var(--text-3)",
1254 fontFamily: "DM Mono",
1255 }}
1256 axisLine={false}
1257 tickLine={false}
1258 />
1259 <Tooltip content={<ChartTooltip />} />
1260 <Area
1261 type="monotone"
1262 dataKey="ph"
1263 stroke="var(--green)"
1264 fill="url(#phGradCD)"
1265 strokeWidth={2}
1266 dot={false}
1267 name={t("chart_ph")}
1268 />
1269 </AreaChart>
1270 </ResponsiveContainer>
1271 </div>
1272
1273 {/* Latest Action */}
1274 {p.action_taken && p.action_taken !== "PENDING_ACTION" && (
1275 <div
1276 style={{
1277 borderRadius: 14,
1278 padding: 20,
1279 background: "var(--surface)",
1280 border: "1px solid var(--border)",
1281 }}
1282 >
1283 <InlineLabel style={{ marginBottom: 16 }}>
1284 {t("details_latest_cmd")}
1285 </InlineLabel>
1286 <AgentActionWidget
1287 actionTaken={p.action_taken}
1288 compact={false}
1289 />
1290 </div>
1291 )}
1292 </>
1293 )}
1294
1295 {/* Sensors Tab */}
1296 {activeTab === "details_tab_sensors" && (
1297 <>
1298 <div
1299 style={{
1300 borderRadius: 14,
1301 padding: 20,
1302 background: "var(--surface)",
1303 border: "1px solid var(--border)",
1304 }}
1305 >
1306 <InlineLabel>{t("details_temp_hum")}</InlineLabel>
1307 <ResponsiveContainer width="100%" height={200}>
1308 <LineChart data={chartData}>
1309 <CartesianGrid
1310 stroke="var(--border)"
1311 strokeDasharray="3 3"
1312 vertical={false}
1313 />
1314 <XAxis
1315 dataKey="t"
1316 tick={{
1317 fontSize: 10,
1318 fill: "var(--text-3)",
1319 fontFamily: "DM Mono",
1320 }}
1321 axisLine={false}
1322 tickLine={false}
1323 />
1324 <YAxis
1325 tick={{
1326 fontSize: 10,
1327 fill: "var(--text-3)",
1328 fontFamily: "DM Mono",
1329 }}
1330 axisLine={false}
1331 tickLine={false}
1332 />
1333 <Tooltip content={<ChartTooltip />} />
1334 <Line
1335 type="monotone"
1336 dataKey="temp"
1337 stroke="#60a5fa"
1338 strokeWidth={2}
1339 dot={false}
1340 name={t("chart_temp")}
1341 />
1342 <Line
1343 type="monotone"
1344 dataKey="humidity"
1345 stroke="#a78bfa"
1346 strokeWidth={2}
1347 dot={false}
1348 name={t("chart_humidity")}
1349 />
1350 </LineChart>
1351 </ResponsiveContainer>
1352 </div>
1353
1354 <div
1355 style={{
1356 borderRadius: 14,
1357 padding: 20,
1358 background: "var(--surface)",
1359 border: "1px solid var(--border)",
1360 }}
1361 >
1362 <div className="section-label">{t("details_ec_conc")}</div>
1363 <ResponsiveContainer width="100%" height={180}>
1364 <AreaChart data={chartData}>
1365 <defs>
1366 <linearGradient id="ecGradCD" x1="0" y1="0" x2="0" y2="1">
1367 <stop
1368 offset="0%"
1369 stopColor="#f59e0b"
1370 stopOpacity={0.25}
1371 />
1372 <stop offset="100%" stopColor="#f59e0b" stopOpacity={0} />
1373 </linearGradient>
1374 </defs>
1375 <CartesianGrid
1376 stroke="var(--border)"
1377 strokeDasharray="3 3"
1378 vertical={false}
1379 />
1380 <XAxis
1381 dataKey="t"
1382 tick={{
1383 fontSize: 10,
1384 fill: "var(--text-3)",
1385 fontFamily: "DM Mono",
1386 }}
1387 axisLine={false}
1388 tickLine={false}
1389 />
1390 <YAxis
1391 tick={{
1392 fontSize: 10,
1393 fill: "var(--text-3)",
1394 fontFamily: "DM Mono",
1395 }}
1396 axisLine={false}
1397 tickLine={false}
1398 />
1399 <Tooltip content={<ChartTooltip />} />
1400 <Area
1401 type="monotone"
1402 dataKey="ec"
1403 stroke="var(--amber)"
1404 fill="url(#ecGradCD)"
1405 strokeWidth={2}
1406 dot={false}
1407 name={t("chart_ec")}
1408 />
1409 </AreaChart>
1410 </ResponsiveContainer>
1411 </div>
1412 </>
1413 )}
1414
1415 {/* Log Tab */}
1416 {activeTab === "details_tab_log" && (
1417 <div
1418 style={{
1419 borderRadius: 14,
1420 overflow: "hidden",
1421 background: "var(--surface)",
1422 border: "1px solid var(--border)",
1423 flexShrink: 0,
1424 }}
1425 >
1426 {/* Header row */}
1427 <div
1428 style={{
1429 padding: "14px 20px",
1430 borderBottom: "1px solid var(--border)",
1431 }}
1432 >
1433 <div className="section-label">
1434 {t("details_event_log", {
1435 total: history.length,
1436 limit: logLimit,
1437 })}
1438 </div>
1439 </div>
1440
1441 {/* Log rows */}
1442 <div>
1443 {[...history]
1444 .reverse()
1445 .slice(0, logLimit)
1446 .map((h, i) => (
1447 <LogRow
1448 key={i}
1449 h={h}
1450 i={i}
1451 logLimit={logLimit}
1452 t={t}
1453 td={td}
1454 formatNumber={formatNumber}
1455 logDotColor={logDotColor}
1456 />
1457 ))}
1458 </div>
1459 </div>
1460 )}
1461 </div>
1462 </PageShell>
1463 );
1464}
1465
1466function LogRow({ h, i, logLimit, t, td, formatNumber, logDotColor }) {
1467 const [expanded, setExpanded] = useState(false);
1468 const hasExplanation =
1469 h.payload?.explanation_log &&
1470 h.payload.explanation_log !== "PENDING_ANALYSIS";
1471
1472 return (
1473 <div
1474 style={{
1475 borderBottom: "1px solid var(--border)",
1476 transition: "background 0.12s",
1477 cursor: "default",
1478 }}
1479 onMouseEnter={(e) =>
1480 (e.currentTarget.style.background = "rgba(74,222,128,0.04)")
1481 }
1482 onMouseLeave={(e) => (e.currentTarget.style.background = "transparent")}
1483 >
1484 {/* Row header */}
1485 <div
1486 style={{
1487 display: "flex",
1488 alignItems: "center",
1489 gap: 10,
1490 padding: "10px 20px",
1491 }}
1492 >
1493 <span
1494 style={{
1495 width: 7,
1496 height: 7,
1497 borderRadius: "50%",
1498 background: logDotColor(h.payload),
1499 flexShrink: 0,
1500 }}
1501 />
1502 <span
1503 style={{
1504 fontSize: 11,
1505 fontFamily: "DM Mono, monospace",
1506 color: "var(--text-3)",
1507 flexShrink: 0,
1508 width: 52,
1509 }}
1510 >
1511 {h.payload?.timestamp
1512 ? new Date(h.payload.timestamp).toLocaleTimeString([], {
1513 hour: "2-digit",
1514 minute: "2-digit",
1515 })
1516 : "--"}
1517 </span>
1518 <span
1519 style={{
1520 fontSize: 11,
1521 fontFamily: "DM Mono, monospace",
1522 color: "var(--text-3)",
1523 width: 36,
1524 flexShrink: 0,
1525 }}
1526 >
1527 #{h.payload?.sequence_number || i}
1528 </span>
1529 <div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
1530 {[
1531 { label: "pH", value: h.cleanSensors?.ph, color: "var(--green)" },
1532 { label: "EC", value: h.cleanSensors?.ec, color: "var(--amber)" },
1533 {
1534 label: "T",
1535 value: h.cleanSensors?.temp + "°",
1536 color: "var(--blue)",
1537 },
1538 {
1539 label: "H",
1540 value: h.cleanSensors?.humidity + "%",
1541 color: "#a78bfa",
1542 },
1543 ].map(({ label, value, color }) => (
1544 <span
1545 key={label}
1546 style={{
1547 fontSize: 13,
1548 fontFamily: "DM Mono, monospace",
1549 display: "flex",
1550 alignItems: "baseline",
1551 gap: 3,
1552 }}
1553 >
1554 <span style={{ color: "var(--text-3)", fontSize: 11 }}>
1555 {label}
1556 </span>
1557 <span style={{ color, fontWeight: 700 }}>
1558 {formatNumber(value)}
1559 </span>
1560 </span>
1561 ))}
1562 </div>
1563
1564 {/* Outcome badge */}
1565 {h.payload?.outcome && (
1566 <span style={{ marginLeft: "auto", flexShrink: 0 }}>
1567 <AgentOutcomeWidget
1568 outcome={h.payload.outcome}
1569 rewardScore={h.payload.reward_score}
1570 />
1571 </span>
1572 )}
1573
1574 {/* Explanation toggle */}
1575 {hasExplanation && (
1576 <button
1577 onClick={() => setExpanded((v) => !v)}
1578 style={{
1579 flexShrink: 0,
1580 display: "flex",
1581 alignItems: "center",
1582 gap: 4,
1583 padding: "3px 8px",
1584 borderRadius: 6,
1585 fontSize: 9,
1586 fontFamily: "DM Mono, monospace",
1587 cursor: "pointer",
1588 background: expanded ? "rgba(167,139,250,0.12)" : "var(--bg-3)",
1589 border: `1px solid ${expanded ? "rgba(167,139,250,0.3)" : "var(--border)"}`,
1590 color: expanded ? "#a78bfa" : "var(--text-3)",
1591 }}
1592 >
1593 <Brain size={9} /> {expanded ? t("details_hide") : t("details_why")}
1594 </button>
1595 )}
1596 </div>
1597
1598 {/* Action row */}
1599 {h.payload?.action_taken &&
1600 h.payload.action_taken !== "PENDING_ACTION" && (
1601 <div style={{ padding: "0 20px 10px 46px" }}>
1602 <AgentActionWidget actionTaken={h.payload.action_taken} compact />
1603 </div>
1604 )}
1605
1606 {/* Explanation log inline */}
1607 {hasExplanation && expanded && (
1608 <div
1609 className="animate-fade-in"
1610 style={{
1611 margin: "0 20px 12px 46px",
1612 padding: "12px 14px",
1613 borderRadius: 10,
1614 background: "rgba(167,139,250,0.05)",
1615 border: "1px solid rgba(167,139,250,0.15)",
1616 }}
1617 >
1618 <div
1619 style={{
1620 fontSize: 9,
1621 fontFamily: "DM Mono, monospace",
1622 color: "#a78bfa",
1623 marginBottom: 8,
1624 }}
1625 >
1626 <Brain size={9} style={{ display: "inline", marginRight: 5 }} />
1627 {t("details_ai_reasoning")}
1628 </div>
1629 <pre
1630 style={{
1631 fontSize: 11,
1632 fontFamily: "DM Mono, monospace",
1633 color: "var(--text-2)",
1634 lineHeight: 1.7,
1635 whiteSpace: "pre-wrap",
1636 margin: 0,
1637 }}
1638 >
1639 {td(h.payload.explanation_log)}
1640 </pre>
1641 </div>
1642 )}
1643 </div>
1644 );
1645}