demeter

Autonomous Hydroponic Intelligence

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}