Files
hydro-builder-app/src/components/StatusPanel.tsx
2026-06-13 21:01:39 -07:00

396 lines
18 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useMemo } from 'react';
import { useSimulation, computeReservoirWaterStats } from '../simulation/flowSimulator';
import { useBuilder } from '../store/builderStore';
import { computeRoomMetrics } from '../environment/roomMetrics';
import type { WarningLevel } from '../types';
import { useAuth } from '../auth/AuthContext';
import { PaywallModal } from '../payments/PaywallModal';
import { useState } from 'react';
const LEVEL_STYLE: Record<WarningLevel, string> = {
error: 'border-rose-900/60 bg-rose-950/60 text-rose-300',
warning: 'border-amber-900/60 bg-amber-950/50 text-amber-300',
info: 'border-sky-900/60 bg-sky-950/50 text-sky-300',
};
const LEVEL_ICON: Record<WarningLevel, string> = {
error: '⛔',
warning: '⚠️',
info: '',
};
/**
* StatusPanel — bottom bar showing live simulation results:
* per-pump delivered flow, system totals, and diagnostics.
*/
export function StatusPanel() {
const { user } = useAuth();
const [showPaywall, setShowPaywall] = useState(false);
const [showCoach, setShowCoach] = useState(false);
const sim = useSimulation();
const partCount = useBuilder((s) => Object.keys(s.parts).length);
const parts = useBuilder((s) => s.parts);
const setSelected = useBuilder((s) => s.setSelected);
const simRunning = useBuilder((s) => s.simRunning);
const toggleSimRunning = useBuilder((s) => s.toggleSimRunning);
const heatmapMode = useBuilder((s) => s.heatmapMode);
const toggleHeatmapMode = useBuilder((s) => s.toggleHeatmapMode);
const roomMetrics = useMemo(() => computeRoomMetrics(parts), [parts]);
const requirePremiumCoach = () => {
if (!user || user.purchased_slots === 0) {
setShowPaywall(true);
} else {
setShowCoach(true);
}
};
const reservoirs = useMemo(() => {
return Object.values(parts).filter((p) => p.type === 'reservoir');
}, [parts]);
const reservoirStats = useMemo(() => {
return reservoirs.map((res) => computeReservoirWaterStats(res, parts));
}, [reservoirs, parts]);
const lph = sim.totalGph * 3.785;
const flowing = sim.pumps.some((p) => p.running);
const peakVelocity = Math.max(0, ...Object.values(sim.velocities));
const peakPressure = Math.max(0, ...Object.values(sim.pressures));
return (
<>
<footer className="flex h-36 shrink-0 border-t border-zinc-800 bg-zinc-950/90 backdrop-blur">
{/* System stats */}
<div className="flex w-56 shrink-0 flex-col justify-center gap-1 border-r border-zinc-800 px-4">
<h3 className="text-[10px] font-bold tracking-wider text-zinc-500 uppercase">
Simulation
</h3>
<div className="text-2xl font-bold text-sky-400 tabular-nums">
{sim.totalGph.toFixed(0)}
<span className="ml-1 text-xs font-medium text-zinc-500">GPH</span>
</div>
<div className="text-[11px] text-zinc-500 tabular-nums">
{lph.toFixed(0)} L/h · {partCount} parts · {sim.connectionCount} connections
</div>
<div className="text-[10px] text-zinc-600 tabular-nums">
peak {peakVelocity.toFixed(1)} ft/s · {peakPressure.toFixed(1)} psi
</div>
<div className="flex items-center gap-2 mt-1">
<button
onClick={toggleSimRunning}
title={simRunning ? 'Pause simulation (Space)' : 'Resume simulation (Space)'}
className={`inline-flex w-fit cursor-pointer items-center gap-1.5 rounded-full px-2 py-0.5 text-[10px] font-semibold transition hover:brightness-125 ${
!simRunning
? 'bg-amber-950 text-amber-400 ring-1 ring-amber-800'
: flowing
? 'bg-emerald-950 text-emerald-400 ring-1 ring-emerald-800'
: 'bg-zinc-900 text-zinc-500 ring-1 ring-zinc-800'
}`}
>
<span className="relative flex h-1.5 w-1.5">
{simRunning && flowing && (
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-60" />
)}
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-current" />
</span>
{!simRunning ? '⏸ Paused' : flowing ? 'Water flowing' : 'System idle'}
</button>
<label className="inline-flex cursor-pointer items-center gap-1 text-[10px] font-semibold text-zinc-400 hover:text-zinc-200">
<input
type="checkbox"
checked={heatmapMode}
onChange={toggleHeatmapMode}
className="accent-sky-500 cursor-pointer h-3 w-3 rounded border-zinc-700 bg-zinc-850"
/>
<span>Heatmap</span>
</label>
</div>
</div>
{/* Pumps */}
<div className="flex w-72 shrink-0 flex-col gap-1.5 overflow-y-auto border-r border-zinc-800 px-4 py-2.5">
<h3 className="text-[10px] font-bold tracking-wider text-zinc-500 uppercase">Pumps</h3>
{sim.pumps.length === 0 && (
<p className="text-xs text-zinc-600">No pumps in the system.</p>
)}
{sim.pumps.map((p) => (
<button
key={p.pumpId}
onClick={() => setSelected(p.pumpId)}
className="rounded-md border border-zinc-800 bg-zinc-900/70 px-2.5 py-1.5 text-left hover:border-zinc-600"
>
<div className="flex items-baseline justify-between">
<span className="text-xs font-semibold text-zinc-200">
{p.gph > 0 ? `${p.gph.toFixed(0)} GPH` : 'No flow'}
<span className="ml-1 text-[10px] font-normal text-zinc-500">
/ {p.ratedGph} rated
</span>
</span>
<span className="font-mono text-[10px] text-zinc-500">
head {p.headFt.toFixed(1)}/{p.maxHeadFt} ft
</span>
</div>
{/* Efficiency bar */}
<div className="mt-1 h-1 overflow-hidden rounded-full bg-zinc-800">
<div
className={`h-full rounded-full ${p.gph / p.ratedGph > 0.5 ? 'bg-emerald-500' : p.gph > 0 ? 'bg-amber-500' : 'bg-rose-600'}`}
style={{ width: `${Math.max(2, (p.gph / p.ratedGph) * 100)}%` }}
/>
</div>
</button>
))}
</div>
{/* Diagnostics */}
<div className="flex w-80 shrink-0 flex-col gap-1 overflow-y-auto border-r border-zinc-800 px-4 py-2.5">
<h3 className="text-[10px] font-bold tracking-wider text-zinc-500 uppercase">
Environment
</h3>
{roomMetrics.length === 0 ? (
<p className="text-xs text-zinc-600">Add a grow tent/room to track air exchange.</p>
) : (
roomMetrics.map((room) => (
<button
key={room.roomId}
onClick={() => setSelected(room.roomId)}
className="rounded-md border border-zinc-800 bg-zinc-900/70 px-2.5 py-1.5 text-left hover:border-zinc-600"
>
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-semibold text-zinc-200">{room.label}</span>
<span
className={`rounded-full px-2 py-0.5 text-[10px] font-semibold ${
room.sealed
? 'bg-emerald-950 text-emerald-300 ring-1 ring-emerald-800'
: 'bg-sky-950 text-sky-300 ring-1 ring-sky-800'
}`}
>
{room.sealed ? 'Sealed' : 'Open air'}
</span>
</div>
<div className="mt-1 text-[11px] text-zinc-400 tabular-nums">
{room.volumeFt3.toFixed(0)} ft3 · {room.exhaustCfm.toFixed(0)} exhaust · {room.intakeCfm.toFixed(0)} intake CFM
</div>
<div className="text-[10px] text-zinc-500 tabular-nums">
{room.airChangesPerMinute.toFixed(2)} exchanges/min · {room.airChangesPerHour.toFixed(1)} ACH · {room.netFreshAirCfm.toFixed(0)} fresh-air CFM
</div>
<div className="mt-1 text-[10px] text-zinc-600 tabular-nums">
circ {room.circulationCfm.toFixed(0)} CFM · light {room.lightWatts.toFixed(0)} W · CO2 {room.co2TankCount} tank{room.co2TankCount === 1 ? '' : 's'}
</div>
<div className="mt-1 flex gap-1.5 text-[9px] font-mono">
<span className="rounded bg-zinc-900 px-1 py-0.5 text-emerald-400 border border-zinc-800">
VPD {room.roomVPD.toFixed(2)} kPa
</span>
<span className="rounded bg-zinc-900 px-1 py-0.5 text-amber-400 border border-zinc-800">
DLI {room.roomDLI.toFixed(1)}
</span>
<span className="rounded bg-zinc-900 px-1 py-0.5 text-sky-400 border border-zinc-800">
CO {room.roomCo2} ppm
</span>
</div>
</button>
))
)}
</div>
{/* Room Readout */}
<div className="flex w-56 shrink-0 flex-col justify-center gap-1.5 border-r border-zinc-800 px-4 py-2.5">
<div className="flex items-center justify-between">
<h3 className="text-[10px] font-bold tracking-wider text-zinc-500 uppercase">
Room Readout
</h3>
<button
onClick={requirePremiumCoach}
className="text-[9px] font-bold uppercase tracking-wider text-sky-400 hover:text-sky-300 bg-sky-950/50 hover:bg-sky-900/50 px-1.5 py-0.5 rounded border border-sky-900/50 transition-colors"
>
drjonesbot 🤖
</button>
</div>
{roomMetrics.length === 0 ? (
<p className="text-xs text-zinc-600">No room placed</p>
) : (() => {
const m = roomMetrics[0];
return (
<div className="flex flex-wrap gap-1 text-[10px] font-mono tabular-nums">
<span className="rounded bg-zinc-900 px-1.5 py-0.5 border border-zinc-800 text-rose-400">
🌡 {m.roomTempF.toFixed(0)}°F
</span>
<span className="rounded bg-zinc-900 px-1.5 py-0.5 border border-zinc-800 text-sky-400">
💧 {m.roomRH.toFixed(0)}%
</span>
<span className="rounded bg-zinc-900 px-1.5 py-0.5 border border-zinc-800 text-emerald-400">
VPD {m.roomVPD.toFixed(2)}
</span>
<span className="rounded bg-zinc-900 px-1.5 py-0.5 border border-zinc-800 text-sky-400">
CO {m.roomCo2}
</span>
<span className="rounded bg-zinc-900 px-1.5 py-0.5 border border-zinc-800 text-amber-400">
{m.roomPPFD.toFixed(0)}
</span>
<span className="rounded bg-zinc-900 px-1.5 py-0.5 border border-zinc-800 text-zinc-300">
ACH {m.airChangesPerHour.toFixed(1)}
</span>
</div>
);
})()}
</div>
{/* Aeration & Water */}
<div className="flex w-72 shrink-0 flex-col gap-1.5 overflow-y-auto border-r border-zinc-800 px-4 py-2.5">
<h3 className="text-[10px] font-bold tracking-wider text-zinc-500 uppercase">
Aeration & Water
</h3>
{reservoirStats.length === 0 && (
<p className="text-xs text-zinc-600">No reservoirs in the system.</p>
)}
{reservoirStats.map((stats) => (
<button
key={stats.id}
onClick={() => setSelected(stats.id)}
className="rounded-md border border-zinc-800 bg-zinc-900/70 px-2.5 py-1.5 text-left hover:border-zinc-600"
>
{(() => {
const runtime = sim.reservoirRuntime[stats.id];
return runtime ? (
<div className="mb-1 flex items-center justify-between text-[9px] font-mono">
<span className={`${runtime.empty ? 'text-rose-400' : 'text-sky-400'}`}>
{runtime.gallonsCurrent.toFixed(1)} / {runtime.gallonsCapacity.toFixed(1)} gal
</span>
<span className="text-zinc-500">
{runtime.minutesRemaining === null
? 'steady'
: `${runtime.minutesRemaining.toFixed(1)} min left`}
</span>
</div>
) : null;
})()}
{(() => {
const runtime = sim.reservoirRuntime[stats.id];
return runtime && runtime.gallonsHeldInSystem > 0.02 ? (
<div className="mb-1 text-[9px] font-mono text-zinc-500">
{runtime.gallonsHeldInSystem.toFixed(2)} gal held in primed lines
</div>
) : null;
})()}
<div className="flex items-baseline justify-between">
<span className="text-xs font-semibold text-zinc-200">
{stats.label}
</span>
<span className="font-mono text-[10px] text-zinc-500">
{stats.volGal.toFixed(1)} Gal
</span>
</div>
<div className="mt-1 flex items-center justify-between text-[10px] text-zinc-400">
<span>Aeration: {stats.activeAerationGph} GPH</span>
<span
className={`rounded px-1.5 py-0.5 text-[9px] font-bold ${
stats.tdo >= 8.0
? 'bg-emerald-950 text-emerald-400 border border-emerald-800/30'
: stats.tdo >= 7.0
? 'bg-sky-950 text-sky-300 border border-sky-800/30'
: 'bg-amber-950 text-amber-400 border border-amber-800/30 animate-pulse'
}`}
>
{stats.tdo.toFixed(1)} mg/L
</span>
</div>
<div className="mt-1.5 flex justify-between text-[9px] text-zinc-500 font-mono">
<span className={stats.tempF > 73.0 ? 'text-amber-400 font-semibold' : 'text-zinc-500'}>
Temp: {stats.tempF.toFixed(1)}°F
</span>
<span className={stats.ph < 5.5 || stats.ph > 6.5 ? 'text-amber-400 font-semibold' : 'text-zinc-500'}>
pH: {stats.ph.toFixed(1)}
</span>
<span className={stats.ec < 0.8 || stats.ec > 2.0 ? 'text-amber-400 font-semibold' : 'text-zinc-500'}>
EC: {stats.ec.toFixed(1)}
</span>
</div>
</button>
))}
</div>
<div className="flex flex-1 flex-col gap-1 overflow-y-auto px-4 py-2.5">
<h3 className="text-[10px] font-bold tracking-wider text-zinc-500 uppercase">
Diagnostics
</h3>
{sim.warnings.length === 0 ? (
<p className="flex items-center gap-1.5 text-xs text-emerald-400">
System healthy no issues detected.
</p>
) : (
sim.warnings.map((w, i) => (
<button
key={i}
onClick={() => w.partId && setSelected(w.partId)}
className={`rounded-md border px-2.5 py-1 text-left text-[11px] leading-snug ${LEVEL_STYLE[w.level]} ${
w.partId ? 'cursor-pointer hover:brightness-125' : 'cursor-default'
}`}
>
{LEVEL_ICON[w.level]} {w.message}
</button>
))
)}
</div>
</footer>
{showPaywall && <PaywallModal onClose={() => setShowPaywall(false)} />}
{showCoach && <GrowCoachModal metrics={roomMetrics} onClose={() => setShowCoach(false)} />}
</>
);
}
function GrowCoachModal({ metrics, onClose }: { metrics: any[]; onClose: () => void }) {
const m = metrics[0];
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="w-full max-w-md overflow-hidden rounded-xl border border-sky-900 bg-zinc-950 shadow-2xl">
<div className="border-b border-zinc-800 bg-zinc-900 px-6 py-4 flex justify-between items-center">
<h2 className="text-lg font-bold text-sky-400 flex items-center gap-2">
🤖 drjonesbot
</h2>
<button onClick={onClose} className="text-zinc-500 hover:text-zinc-300"></button>
</div>
<div className="p-6 text-sm text-zinc-300 space-y-4">
{!m ? (
<p>Please add a Grow Room/Tent to your scene so I can analyze the environment.</p>
) : (
<>
<div className="p-3 bg-zinc-900 rounded-lg border border-zinc-800">
<span className="font-bold text-sky-400">Humidity Analysis:</span>
<p className="mt-1">
{m.roomRH > 65 ? "Your relative humidity is dangerously high for late-stage growth. Consider adding a dehumidifier or increasing exhaust CFM to prevent bud rot." :
m.roomRH < 40 ? "Your humidity is too low. Seedlings and vegetative plants will struggle to transpire. Consider adding a humidifier." :
"Your humidity is right in the sweet spot for vegetative growth!"}
</p>
</div>
<div className="p-3 bg-zinc-900 rounded-lg border border-zinc-800">
<span className="font-bold text-emerald-400">VPD Target:</span>
<p className="mt-1">
{m.roomVPD < 0.8 ? "VPD is low. Plants are not transpiring enough water, which can stunt nutrient uptake. Increase temperature or decrease humidity." :
m.roomVPD > 1.2 ? "VPD is high. Plants may experience water stress and close their stomata. Decrease temperature or increase humidity." :
"Perfect! A VPD of around 1.0 kPa is optimal for most stages of growth."}
</p>
</div>
<div className="p-3 bg-zinc-900 rounded-lg border border-zinc-800">
<span className="font-bold text-amber-400">Air Exchange:</span>
<p className="mt-1">
{m.airChangesPerMinute < 1.0 ? "Your Air Changes Per Minute (ACH) is quite low. Stagnant air invites pests and mold. Add more inline exhaust fans." :
"Your air exchange rate is solid, ensuring fresh CO2 is continuously supplied to the canopy."}
</p>
</div>
</>
)}
</div>
<div className="border-t border-zinc-800 bg-zinc-900 px-6 py-4 flex justify-end">
<button
onClick={onClose}
className="rounded-md bg-zinc-800 px-4 py-2 font-semibold text-zinc-200 hover:bg-zinc-700"
>
Got it
</button>
</div>
</div>
</div>
);
}