Implement Smart Timers, Power & Water Schedule Dashboard, and tighten UI layout spacing
This commit is contained in:
@@ -20,6 +20,7 @@ import { LoginModal } from './auth/LoginModal';
|
||||
import { ToastContainer, addToast } from './components/Toast';
|
||||
import { useSaveStatus } from './store/saveStatusStore';
|
||||
import { TestimonialScroller } from './components/TestimonialScroller';
|
||||
import { ScheduleDashboard } from './components/ScheduleDashboard';
|
||||
|
||||
/** Restore the autosaved design, or seed the demo system on first visit. */
|
||||
/** Restore the autosaved design, or seed the demo system on first visit. */
|
||||
@@ -206,6 +207,7 @@ function BuilderApp() {
|
||||
const activeRoomId = useBuilder((s) => s.activeRoomId);
|
||||
const sim = useSimulation();
|
||||
const [bomOpen, setBomOpen] = useState(false);
|
||||
const [dashboardOpen, setDashboardOpen] = useState(false);
|
||||
const [checkFindings, setCheckFindings] = useState<Finding[] | null>(null);
|
||||
|
||||
const roomParts = useMemo(() => {
|
||||
@@ -384,7 +386,11 @@ function BuilderApp() {
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col bg-zinc-950 text-zinc-100 select-none">
|
||||
<Toolbar onOpenBom={() => setBomOpen(true)} onCheckBuild={runDesignCheck} />
|
||||
<Toolbar
|
||||
onOpenBom={() => setBomOpen(true)}
|
||||
onCheckBuild={runDesignCheck}
|
||||
onOpenDashboard={() => setDashboardOpen(true)}
|
||||
/>
|
||||
<div className="flex min-h-0 flex-1">
|
||||
<PartLibrary />
|
||||
<main
|
||||
@@ -443,6 +449,7 @@ function BuilderApp() {
|
||||
</div>
|
||||
<StatusPanel />
|
||||
{bomOpen && <BomPanel onClose={() => setBomOpen(false)} />}
|
||||
{dashboardOpen && <ScheduleDashboard onClose={() => setDashboardOpen(false)} />}
|
||||
{checkFindings && (
|
||||
<DesignCheckPanel
|
||||
findings={checkFindings}
|
||||
|
||||
@@ -52,7 +52,7 @@ export function PartLibrary() {
|
||||
|
||||
return (
|
||||
<aside className="flex w-56 shrink-0 flex-col border-r border-zinc-800 bg-zinc-950/80 backdrop-blur">
|
||||
<div className="border-b border-zinc-800 px-4 py-3">
|
||||
<div className="border-b border-zinc-800 px-3.5 py-2">
|
||||
<h2 className="text-xs font-semibold tracking-widest text-zinc-400 uppercase">
|
||||
Parts Library
|
||||
</h2>
|
||||
@@ -60,14 +60,14 @@ export function PartLibrary() {
|
||||
Click then place in scene, or drag onto the canvas. Shift-click to stamp copies.
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-3 pt-2">
|
||||
<div className="px-3 pt-1.5">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search parts…"
|
||||
className="w-full rounded-md border border-zinc-800 bg-zinc-900 px-2.5 py-1.5 text-xs text-zinc-200 placeholder:text-zinc-600 outline-none focus:border-zinc-600 focus:ring-1 focus:ring-zinc-700"
|
||||
className="w-full rounded border border-zinc-800 bg-zinc-900 px-2 py-1 text-xs text-zinc-200 placeholder:text-zinc-600 outline-none focus:border-zinc-600 focus:ring-1 focus:ring-zinc-700"
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
@@ -79,10 +79,10 @@ export function PartLibrary() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-3 py-2">
|
||||
<div className="flex-1 overflow-y-auto px-3 py-1.5">
|
||||
{filteredHotbar.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<h3 className="px-1 py-1.5 text-[10px] font-bold tracking-wider text-zinc-500 uppercase">
|
||||
<div className="mb-3">
|
||||
<h3 className="px-1 py-1 text-[10px] font-bold tracking-wider text-zinc-500 uppercase">
|
||||
Tool Belt
|
||||
</h3>
|
||||
<div className="grid grid-cols-3 gap-1.5">
|
||||
@@ -100,8 +100,8 @@ export function PartLibrary() {
|
||||
</div>
|
||||
)}
|
||||
{filteredQuickBuilds.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<h3 className="px-1 py-1.5 text-[10px] font-bold tracking-wider text-zinc-500 uppercase">
|
||||
<div className="mb-3">
|
||||
<h3 className="px-1 py-1 text-[10px] font-bold tracking-wider text-zinc-500 uppercase">
|
||||
Quick Builds
|
||||
</h3>
|
||||
<div className="grid gap-1.5">
|
||||
@@ -117,8 +117,8 @@ export function PartLibrary() {
|
||||
</div>
|
||||
)}
|
||||
{filteredCategories.map((cat) => (
|
||||
<div key={cat.name} className="mb-3">
|
||||
<h3 className="px-1 py-1.5 text-[10px] font-bold tracking-wider text-zinc-500 uppercase">
|
||||
<div key={cat.name} className="mb-2.5">
|
||||
<h3 className="px-1 py-1 text-[10px] font-bold tracking-wider text-zinc-500 uppercase">
|
||||
{cat.name}
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
@@ -151,7 +151,7 @@ function QuickBuildButton({
|
||||
<button
|
||||
onClick={onClick}
|
||||
title={description}
|
||||
className="flex items-center justify-between gap-2 rounded-lg border border-zinc-800 bg-zinc-900/60 px-3 py-2 text-left text-zinc-200 transition hover:border-emerald-500/60 hover:bg-emerald-500/10 hover:text-emerald-100"
|
||||
className="flex items-center justify-between gap-2 rounded border border-zinc-800 bg-zinc-900/60 px-2 py-1 text-left text-zinc-200 transition hover:border-emerald-500/60 hover:bg-emerald-500/10 hover:text-emerald-100"
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-[12px] leading-tight font-semibold">{label}</span>
|
||||
@@ -191,12 +191,12 @@ function LibraryItem({
|
||||
? `${def.description} — click again to cancel${badge ? ` (${badge})` : ''}`
|
||||
: `${def.description}${badge ? ` (${badge})` : ''}`
|
||||
}
|
||||
className={`group relative flex flex-col items-center gap-1 rounded-lg border px-1.5 text-center transition
|
||||
className={`group relative flex flex-col items-center gap-1 rounded border px-1 text-center transition
|
||||
${
|
||||
active
|
||||
? 'border-sky-400 bg-sky-500/15 text-sky-300 shadow-[0_0_10px_-2px_rgba(56,189,248,0.6)] ring-1 ring-sky-400/40'
|
||||
: 'border-zinc-800 bg-zinc-900/60 text-zinc-300 hover:border-zinc-600 hover:bg-zinc-800/80'
|
||||
} ${compact ? 'py-1.5' : 'py-2'}`}
|
||||
} ${compact ? 'py-1' : 'py-1.5'}`}
|
||||
>
|
||||
{badge && (
|
||||
<span className="absolute top-1 left-1 rounded bg-zinc-950/90 px-1 py-0.5 text-[9px] font-bold leading-none text-zinc-400 ring-1 ring-zinc-800">
|
||||
@@ -205,11 +205,11 @@ function LibraryItem({
|
||||
)}
|
||||
<PartIcon
|
||||
type={type}
|
||||
className={`${compact ? 'h-7 w-7' : 'h-9 w-9'} transition-transform duration-150 group-hover:scale-110 ${
|
||||
className={`${compact ? 'h-6 w-6' : 'h-8 w-8'} transition-transform duration-150 group-hover:scale-110 ${
|
||||
active ? 'scale-110 drop-shadow-[0_0_4px_rgba(56,189,248,0.5)]' : ''
|
||||
}`}
|
||||
/>
|
||||
<span className={`w-full font-medium break-words ${compact ? 'text-[10px]' : 'text-[11px] leading-tight'}`}>
|
||||
<span className={`w-full font-medium break-words ${compact ? 'text-[9px]' : 'text-[10px] leading-tight'}`}>
|
||||
{def.label}
|
||||
</span>
|
||||
{active && (
|
||||
|
||||
@@ -9,6 +9,22 @@ import { ColorSwatchRow } from './ColorPopover';
|
||||
const rad2deg = (r: number) => Math.round((r * 180) / Math.PI);
|
||||
const deg2rad = (d: number) => (d * Math.PI) / 180;
|
||||
|
||||
const ELECTRICAL_PARTS = new Set<string>([
|
||||
'pump',
|
||||
'airCompressor',
|
||||
'waterChiller',
|
||||
'growLight',
|
||||
'inlineFan',
|
||||
'circulationFan',
|
||||
]);
|
||||
|
||||
function formatDurationHelper(mins: number) {
|
||||
if (mins < 60) return `${mins}m`;
|
||||
const hrs = Math.floor(mins / 60);
|
||||
const remaining = mins % 60;
|
||||
return remaining > 0 ? `${hrs}h ${remaining}m` : `${hrs}h`;
|
||||
}
|
||||
|
||||
/**
|
||||
* PropertiesPanel — right sidebar inspector.
|
||||
* One part selected: full inspector (pump quick-settings first for pumps).
|
||||
@@ -105,7 +121,7 @@ function MultiInspector({ ids }: { ids: string[] }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 space-y-4 overflow-y-auto px-4 py-3">
|
||||
<div className="flex-1 space-y-3 overflow-y-auto px-3.5 py-2.5">
|
||||
<Section title="Selection">
|
||||
<ul className="space-y-1">
|
||||
{[...counts.entries()].map(([label, n]) => (
|
||||
@@ -128,14 +144,14 @@ function MultiInspector({ ids }: { ids: string[] }) {
|
||||
<button
|
||||
onClick={() => duplicateParts(ids)}
|
||||
title="Duplicate the whole selection (D)"
|
||||
className="flex-1 rounded-md border border-zinc-700 bg-zinc-800 px-2 py-1.5 text-xs font-medium text-zinc-200 hover:bg-zinc-700"
|
||||
className="flex-1 rounded-md border border-zinc-700 bg-zinc-800 px-2 py-1 text-[11px] font-medium text-zinc-200 hover:bg-zinc-700"
|
||||
>
|
||||
Duplicate
|
||||
</button>
|
||||
<button
|
||||
onClick={() => removeParts(ids)}
|
||||
title="Delete every selected part (⌫)"
|
||||
className="flex-1 rounded-md border border-rose-900 bg-rose-950 px-2 py-1.5 text-xs font-medium text-rose-300 hover:bg-rose-900"
|
||||
className="flex-1 rounded-md border border-rose-900 bg-rose-950 px-2 py-1 text-[11px] font-medium text-rose-300 hover:bg-rose-900"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
@@ -709,7 +725,7 @@ function PartInspector({ partId }: { partId: string }) {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex-1 space-y-4 overflow-y-auto px-4 py-3">
|
||||
<div className="flex-1 space-y-3 overflow-y-auto px-3.5 py-2.5">
|
||||
<div>
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<span
|
||||
@@ -814,7 +830,7 @@ function PartInspector({ partId }: { partId: string }) {
|
||||
<select
|
||||
value={String(snapPipeSize(part.params[key] ?? def.defaults[key]))}
|
||||
onChange={(e) => setParam(partId, key, parseFloat(e.target.value))}
|
||||
className="w-full rounded-md border border-zinc-800 bg-zinc-900 px-2 py-1.5 text-[11px] text-zinc-200 outline-none focus:border-sky-600"
|
||||
className="w-full rounded-md border border-zinc-800 bg-zinc-900 px-2 py-1 text-[11px] text-zinc-200 outline-none focus:border-sky-600"
|
||||
>
|
||||
{STANDARD_PIPE_SIZES.map((size) => (
|
||||
<option key={size} value={size}>
|
||||
@@ -839,6 +855,65 @@ function PartInspector({ partId }: { partId: string }) {
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{ELECTRICAL_PARTS.has(part.type) && (
|
||||
<Section title="Smart Timer Schedule">
|
||||
<div className="space-y-2 rounded-lg border border-zinc-800 bg-zinc-900/20 p-2.5">
|
||||
<label className="flex items-center justify-between text-[11px] cursor-pointer">
|
||||
<span className="text-zinc-400 font-semibold">Enable Timer</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(part.params.timerEnabled ?? 0) === 1}
|
||||
onChange={(e) => setParam(partId, 'timerEnabled', e.target.checked ? 1 : 0)}
|
||||
className="h-3.5 w-3.5 accent-sky-500 rounded border-zinc-800 bg-zinc-900 cursor-pointer"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{(part.params.timerEnabled ?? 0) === 1 && (
|
||||
<div className="grid grid-cols-2 gap-2 pt-1 border-t border-zinc-900/50">
|
||||
<div>
|
||||
<div className="mb-1 text-[9px] font-bold text-zinc-500 uppercase">ON Duration</div>
|
||||
<label className="flex items-center gap-1 rounded-md border border-zinc-800 bg-zinc-900 px-1.5 py-0.5">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={part.params.timerOnMin ?? 15}
|
||||
onChange={(e) => {
|
||||
const v = parseInt(e.target.value, 10);
|
||||
if (!Number.isNaN(v) && v > 0) setParam(partId, 'timerOnMin', v);
|
||||
}}
|
||||
className="w-full bg-transparent text-left font-mono text-[11px] text-zinc-200 outline-none"
|
||||
/>
|
||||
<span className="text-[10px] text-zinc-500">min</span>
|
||||
</label>
|
||||
<span className="text-[9px] text-zinc-500 block mt-0.5">
|
||||
{formatDurationHelper(part.params.timerOnMin ?? 15)}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 text-[9px] font-bold text-zinc-500 uppercase">OFF Duration</div>
|
||||
<label className="flex items-center gap-1 rounded-md border border-zinc-800 bg-zinc-900 px-1.5 py-0.5">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={part.params.timerOffMin ?? 45}
|
||||
onChange={(e) => {
|
||||
const v = parseInt(e.target.value, 10);
|
||||
if (!Number.isNaN(v) && v > 0) setParam(partId, 'timerOffMin', v);
|
||||
}}
|
||||
className="w-full bg-transparent text-left font-mono text-[11px] text-zinc-200 outline-none"
|
||||
/>
|
||||
<span className="text-[10px] text-zinc-500">min</span>
|
||||
</label>
|
||||
<span className="text-[9px] text-zinc-500 block mt-0.5">
|
||||
{formatDurationHelper(part.params.timerOffMin ?? 45)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<Section title="Color">
|
||||
<ColorSwatchRow ids={[partId]} />
|
||||
</Section>
|
||||
@@ -847,14 +922,14 @@ function PartInspector({ partId }: { partId: string }) {
|
||||
<button
|
||||
onClick={() => duplicateParts([partId])}
|
||||
title="Duplicate this part (D)"
|
||||
className="flex-1 rounded-md border border-zinc-700 bg-zinc-800 px-2 py-1.5 text-xs font-medium text-zinc-200 hover:bg-zinc-700"
|
||||
className="flex-1 rounded-md border border-zinc-700 bg-zinc-800 px-2 py-1 text-[11px] font-medium text-zinc-200 hover:bg-zinc-700"
|
||||
>
|
||||
Duplicate
|
||||
</button>
|
||||
<button
|
||||
onClick={() => removeParts([partId])}
|
||||
title="Delete this part (⌫)"
|
||||
className="flex-1 rounded-md border border-rose-900 bg-rose-950 px-2 py-1.5 text-xs font-medium text-rose-300 hover:bg-rose-900"
|
||||
className="flex-1 rounded-md border border-rose-900 bg-rose-950 px-2 py-1 text-[11px] font-medium text-rose-300 hover:bg-rose-900"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
@@ -896,7 +971,7 @@ function NumberField({
|
||||
const v = parseFloat(e.target.value);
|
||||
if (!Number.isNaN(v)) onChange(v);
|
||||
}}
|
||||
className="w-full bg-transparent py-1.5 text-right font-mono text-[11px] text-zinc-200 outline-none"
|
||||
className="w-full bg-transparent py-1 text-right font-mono text-[11px] text-zinc-200 outline-none"
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
@@ -906,7 +981,7 @@ function MiniBtn({ children, onClick }: { children: React.ReactNode; onClick: ()
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="flex-1 rounded border border-zinc-800 bg-zinc-900 px-1.5 py-1 text-[10px] text-zinc-300 hover:bg-zinc-800"
|
||||
className="flex-1 rounded border border-zinc-800 bg-zinc-900 px-1 py-0.5 text-[10px] text-zinc-300 hover:bg-zinc-800"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
|
||||
417
src/components/ScheduleDashboard.tsx
Normal file
417
src/components/ScheduleDashboard.tsx
Normal file
@@ -0,0 +1,417 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useBuilder } from '../store/builderStore';
|
||||
import { PART_DEFS } from '../parts/catalog';
|
||||
|
||||
export function ScheduleDashboard({ onClose }: { onClose: () => void }) {
|
||||
const parts = useBuilder((s) => s.parts);
|
||||
const activeRoomId = useBuilder((s) => s.activeRoomId);
|
||||
const [rate, setRate] = useState<number>(0.15); // Default electricity rate ($/kWh)
|
||||
|
||||
const ELECTRICAL_PARTS = new Set<string>([
|
||||
'pump',
|
||||
'airCompressor',
|
||||
'waterChiller',
|
||||
'growLight',
|
||||
'inlineFan',
|
||||
'circulationFan',
|
||||
]);
|
||||
|
||||
// Filter electrical parts in the active room
|
||||
const activeEquip = useMemo(() => {
|
||||
return Object.values(parts).filter(
|
||||
(p) => p.roomId === activeRoomId && ELECTRICAL_PARTS.has(p.type)
|
||||
);
|
||||
}, [parts, activeRoomId]);
|
||||
|
||||
// Calculate specifications and stats for each piece of equipment
|
||||
const equipStats = useMemo(() => {
|
||||
return activeEquip.map((p) => {
|
||||
const def = PART_DEFS[p.type];
|
||||
const name = p.label || def.label;
|
||||
const isTimer = (p.params.timerEnabled ?? 0) === 1;
|
||||
const onMin = p.params.timerOnMin ?? 15;
|
||||
const offMin = p.params.timerOffMin ?? 45;
|
||||
const period = onMin + offMin;
|
||||
|
||||
// Determine nominal power rating (Watts)
|
||||
let watts = 0;
|
||||
if (p.type === 'growLight') {
|
||||
watts = p.params.watts ?? 650;
|
||||
} else if (p.type === 'pump') {
|
||||
// Estimate pump wattage dynamically based on GPH rating
|
||||
const gphVal = p.params.gph ?? 400;
|
||||
watts = Math.round(gphVal * 0.075 + 5);
|
||||
} else if (p.type === 'inlineFan') {
|
||||
const cfmVal = p.params.cfm ?? 600;
|
||||
watts = Math.round(cfmVal * 0.15);
|
||||
} else if (p.type === 'circulationFan') {
|
||||
const cfmVal = p.params.cfm ?? 250;
|
||||
watts = Math.round(cfmVal * 0.15);
|
||||
} else if (p.type === 'waterChiller') {
|
||||
watts = 250;
|
||||
} else if (p.type === 'airCompressor') {
|
||||
const gphVal = p.params.gph ?? 120;
|
||||
watts = Math.round(gphVal * 0.05 + 2);
|
||||
}
|
||||
|
||||
// Check if manually switched ON/OFF
|
||||
const isSwitchedOn = (p.params.on ?? 1) > 0;
|
||||
|
||||
// Calculate operational fraction
|
||||
let runFraction = 0;
|
||||
if (isTimer) {
|
||||
runFraction = period > 0 ? onMin / period : 0;
|
||||
} else {
|
||||
runFraction = isSwitchedOn ? 1 : 0;
|
||||
}
|
||||
|
||||
const dailyRunMin = Math.round(runFraction * 1440);
|
||||
const dailyKwh = (watts / 1000) * 24 * runFraction;
|
||||
const dailyCost = dailyKwh * rate;
|
||||
|
||||
// Water stats (for pumps only)
|
||||
let dailyWaterGal = 0;
|
||||
if (p.type === 'pump') {
|
||||
const nominalGph = p.params.gph ?? 400;
|
||||
dailyWaterGal = nominalGph * 24 * runFraction;
|
||||
}
|
||||
|
||||
return {
|
||||
id: p.id,
|
||||
type: p.type,
|
||||
name,
|
||||
color: p.color || def.color,
|
||||
watts,
|
||||
isTimer,
|
||||
onMin,
|
||||
offMin,
|
||||
isSwitchedOn,
|
||||
runFraction,
|
||||
dailyRunMin,
|
||||
dailyKwh,
|
||||
dailyCost,
|
||||
dailyWaterGal,
|
||||
period,
|
||||
};
|
||||
});
|
||||
}, [activeEquip, rate]);
|
||||
|
||||
// Aggregate totals
|
||||
const totals = useMemo(() => {
|
||||
let totalWatts = 0;
|
||||
let totalKwh = 0;
|
||||
let totalWaterGal = 0;
|
||||
|
||||
for (const eq of equipStats) {
|
||||
totalWatts += eq.watts;
|
||||
totalKwh += eq.dailyKwh;
|
||||
totalWaterGal += eq.dailyWaterGal;
|
||||
}
|
||||
|
||||
const monthlyKwh = totalKwh * 30.4;
|
||||
const monthlyCost = totalKwh * 30.4 * rate;
|
||||
|
||||
return {
|
||||
totalWatts,
|
||||
totalKwh,
|
||||
totalWaterGal,
|
||||
monthlyKwh,
|
||||
monthlyCost,
|
||||
};
|
||||
}, [equipStats, rate]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/75 p-4 backdrop-blur-md">
|
||||
<div className="flex max-h-[90vh] w-full max-w-4xl flex-col overflow-hidden rounded-2xl border border-zinc-800 bg-zinc-950 text-zinc-100 shadow-2xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-zinc-800 bg-zinc-900/40 px-6 py-4">
|
||||
<div>
|
||||
<h2 className="text-base font-bold text-sky-400">📊 Power & Water Schedule Dashboard</h2>
|
||||
<p className="text-[11px] text-zinc-400 mt-0.5">
|
||||
Comprehensive load profiling, utility estimations, and cyclic scheduling timelines.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-lg bg-zinc-900 px-3 py-1.5 text-xs text-zinc-400 border border-zinc-800 hover:bg-zinc-800 hover:text-zinc-200 transition cursor-pointer"
|
||||
>
|
||||
✕ Close
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Dashboard Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-6">
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="rounded-xl border border-zinc-800 bg-zinc-900/30 p-4">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider text-zinc-500">Connected Load</span>
|
||||
<div className="mt-1 text-2xl font-bold text-zinc-200">
|
||||
{totals.totalWatts} <span className="text-xs text-zinc-500">W</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-zinc-800 bg-zinc-900/30 p-4">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider text-zinc-500">Daily Energy</span>
|
||||
<div className="mt-1 text-2xl font-bold text-sky-400">
|
||||
{totals.totalKwh.toFixed(2)} <span className="text-xs text-sky-600">kWh</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-zinc-800 bg-zinc-900/30 p-4">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider text-zinc-500">Monthly Utility Cost</span>
|
||||
<div className="mt-1 text-2xl font-bold text-emerald-400">
|
||||
${totals.monthlyCost.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-zinc-800 bg-zinc-900/30 p-4">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider text-zinc-500">Daily Water Pumped</span>
|
||||
<div className="mt-1 text-2xl font-bold text-teal-400">
|
||||
{totals.totalWaterGal.toFixed(0)} <span className="text-xs text-teal-600">gal</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rate Editor & Helper */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 rounded-xl border border-zinc-800 bg-zinc-900/20 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-zinc-400 font-semibold">Electricity Cost Rate:</span>
|
||||
<label className="flex items-center gap-1.5 rounded-lg border border-zinc-800 bg-zinc-900 px-3 py-1">
|
||||
<span className="text-xs text-zinc-500">$</span>
|
||||
<input
|
||||
type="number"
|
||||
step={0.01}
|
||||
min={0.01}
|
||||
value={rate}
|
||||
onChange={(e) => {
|
||||
const val = parseFloat(e.target.value);
|
||||
if (!Number.isNaN(val) && val > 0) setRate(val);
|
||||
}}
|
||||
className="w-16 bg-transparent font-mono text-xs text-zinc-200 outline-none"
|
||||
/>
|
||||
<span className="text-xs text-zinc-500">/ kWh</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="text-[11px] text-zinc-500 leading-normal max-w-md">
|
||||
💡 <span className="font-semibold text-zinc-400">drjonesbot says:</span> Using duty timers on pumps and cycling lights off-peak can significantly reduce heat spikes and monthly energy draw.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tables and timelines */}
|
||||
{equipStats.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-10 border border-dashed border-zinc-800 rounded-xl">
|
||||
<p className="text-sm text-zinc-500 font-medium">No electrical parts in this workspace</p>
|
||||
<p className="text-xs text-zinc-600 mt-1">Add pumps, grow lights, inline fans, or chillers to generate a schedule.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* Table */}
|
||||
<div className="overflow-x-auto rounded-xl border border-zinc-800 bg-zinc-900/10">
|
||||
<table className="w-full border-collapse text-left text-xs">
|
||||
<thead className="border-b border-zinc-800 bg-zinc-900/50 text-[10px] font-bold uppercase tracking-wider text-zinc-500">
|
||||
<tr>
|
||||
<th className="px-4 py-3">Equipment</th>
|
||||
<th className="px-4 py-3">Load (Watts)</th>
|
||||
<th className="px-4 py-3">Schedules</th>
|
||||
<th className="px-4 py-3">Daily Runtime</th>
|
||||
<th className="px-4 py-3">Daily kWh</th>
|
||||
<th className="px-4 py-3 text-right">Daily Cost</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-900">
|
||||
{equipStats.map((eq) => (
|
||||
<tr key={eq.id} className="hover:bg-zinc-900/20">
|
||||
<td className="flex items-center gap-2 px-4 py-3 font-semibold text-zinc-200">
|
||||
<span
|
||||
className="h-2 w-2 rounded"
|
||||
style={{ backgroundColor: eq.color }}
|
||||
/>
|
||||
{eq.name}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-zinc-300">{eq.watts} W</td>
|
||||
<td className="px-4 py-3">
|
||||
{eq.isTimer ? (
|
||||
<span className="rounded bg-sky-500/10 px-2 py-0.5 text-[10px] font-bold text-sky-400 border border-sky-500/10">
|
||||
⏱️ Cycle: {eq.onMin}m On / {eq.offMin}m Off
|
||||
</span>
|
||||
) : eq.isSwitchedOn ? (
|
||||
<span className="rounded bg-emerald-500/10 px-2 py-0.5 text-[10px] font-bold text-emerald-400 border border-emerald-500/10">
|
||||
🟢 Constant ON
|
||||
</span>
|
||||
) : (
|
||||
<span className="rounded bg-zinc-800 px-2 py-0.5 text-[10px] font-bold text-zinc-500">
|
||||
⚪ Off
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-zinc-400 font-mono">
|
||||
{Math.floor(eq.dailyRunMin / 60)}h {eq.dailyRunMin % 60}m
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-sky-400">{eq.dailyKwh.toFixed(2)} kWh</td>
|
||||
<td className="px-4 py-3 text-right font-mono text-emerald-400">${eq.dailyCost.toFixed(2)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Timeline Chart */}
|
||||
<div className="rounded-xl border border-zinc-800 bg-zinc-900/20 p-5 space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-zinc-200">24-Hour Timeline Analysis</h3>
|
||||
<p className="text-[10px] text-zinc-500">Visual breakdown of cyclic run schedules across a 24-hour day starting from midnight.</p>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<TimelineSvg equip={equipStats} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TimelineSvg({ equip }: { equip: any[] }) {
|
||||
const rowHeight = 36;
|
||||
const labelWidth = 120;
|
||||
const chartWidth = 640;
|
||||
const height = equip.length * rowHeight + 30;
|
||||
const width = labelWidth + chartWidth;
|
||||
|
||||
// Render ON/OFF blocks for a 24h timeline
|
||||
const renderRowTimeline = (eq: any, rowIndex: number) => {
|
||||
const y = rowIndex * rowHeight + 10;
|
||||
const barHeight = 16;
|
||||
|
||||
if (!eq.isTimer) {
|
||||
// Solid bar if constant
|
||||
return (
|
||||
<rect
|
||||
key={eq.id}
|
||||
x={labelWidth}
|
||||
y={y}
|
||||
width={chartWidth}
|
||||
height={barHeight}
|
||||
rx={4}
|
||||
fill={eq.isSwitchedOn ? eq.color : '#1f2937'}
|
||||
opacity={eq.isSwitchedOn ? 0.75 : 0.2}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Generate repeat intervals
|
||||
const rects: React.ReactNode[] = [];
|
||||
const totalMins = 1440;
|
||||
const period = eq.period;
|
||||
let t = 0;
|
||||
let index = 0;
|
||||
|
||||
while (t < totalMins) {
|
||||
const onDuration = Math.min(eq.onMin, totalMins - t);
|
||||
const offDuration = Math.min(eq.offMin, totalMins - (t + onDuration));
|
||||
|
||||
const xOn = labelWidth + (t / totalMins) * chartWidth;
|
||||
const widthOn = (onDuration / totalMins) * chartWidth;
|
||||
|
||||
if (widthOn > 0.5) {
|
||||
rects.push(
|
||||
<rect
|
||||
key={`${eq.id}-on-${index}`}
|
||||
x={xOn}
|
||||
y={y}
|
||||
width={widthOn}
|
||||
height={barHeight}
|
||||
rx={2}
|
||||
fill={eq.color}
|
||||
opacity={0.8}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (offDuration > 0) {
|
||||
const xOff = labelWidth + ((t + onDuration) / totalMins) * chartWidth;
|
||||
const widthOff = (offDuration / totalMins) * chartWidth;
|
||||
if (widthOff > 0.5) {
|
||||
rects.push(
|
||||
<rect
|
||||
key={`${eq.id}-off-${index}`}
|
||||
x={xOff}
|
||||
y={y}
|
||||
width={widthOff}
|
||||
height={barHeight}
|
||||
rx={2}
|
||||
fill="#18181b"
|
||||
stroke="#27272a"
|
||||
strokeWidth={0.5}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
t += period;
|
||||
index++;
|
||||
}
|
||||
|
||||
return <g key={eq.id}>{rects}</g>;
|
||||
};
|
||||
|
||||
return (
|
||||
<svg viewBox={`0 0 ${width} ${height}`} className="w-full h-auto text-[10px] select-none font-mono">
|
||||
{/* Background grids */}
|
||||
<g>
|
||||
{[0, 4, 8, 12, 16, 20, 24].map((h) => {
|
||||
const x = labelWidth + (h / 24) * chartWidth;
|
||||
return (
|
||||
<g key={h}>
|
||||
<line
|
||||
x1={x}
|
||||
y1={0}
|
||||
x2={x}
|
||||
y2={height - 25}
|
||||
stroke="#27272a"
|
||||
strokeWidth={1}
|
||||
strokeDasharray="4 4"
|
||||
/>
|
||||
<text
|
||||
x={x}
|
||||
y={height - 10}
|
||||
textAnchor="middle"
|
||||
fill="#71717a"
|
||||
className="text-[9px]"
|
||||
>
|
||||
{h === 0 ? '00:00' : h === 12 ? '12:00' : `${h}:00`}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
|
||||
{/* Row Labels and bars */}
|
||||
{equip.map((eq, idx) => {
|
||||
const yLabel = idx * rowHeight + 22;
|
||||
return (
|
||||
<g key={eq.id}>
|
||||
<text
|
||||
x={10}
|
||||
y={yLabel}
|
||||
fill="#e4e4e7"
|
||||
className="font-sans font-semibold text-[10px] text-zinc-300"
|
||||
>
|
||||
{eq.name.length > 15 ? `${eq.name.substring(0, 14)}…` : eq.name}
|
||||
</text>
|
||||
{renderRowTimeline(eq, idx)}
|
||||
{/* Divider line under row */}
|
||||
<line
|
||||
x1={0}
|
||||
y1={idx * rowHeight + rowHeight + 2}
|
||||
x2={width}
|
||||
y2={idx * rowHeight + rowHeight + 2}
|
||||
stroke="#18181b"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -60,9 +60,9 @@ export function StatusPanel() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<footer className="flex h-36 shrink-0 border-t border-zinc-800 bg-zinc-950/90 backdrop-blur">
|
||||
<footer className="flex h-28 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">
|
||||
<div className="flex w-44 shrink-0 flex-col justify-center gap-0.5 border-r border-zinc-800 px-3">
|
||||
<h3 className="text-[10px] font-bold tracking-wider text-zinc-500 uppercase">
|
||||
Simulation
|
||||
</h3>
|
||||
@@ -110,7 +110,7 @@ export function StatusPanel() {
|
||||
</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">
|
||||
<div className="flex w-64 shrink-0 flex-col gap-1 overflow-y-auto border-r border-zinc-800 px-3 py-1.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>
|
||||
@@ -119,7 +119,7 @@ export function StatusPanel() {
|
||||
<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"
|
||||
className="rounded border border-zinc-850 bg-zinc-900/70 px-2 py-1 text-left hover:border-zinc-650"
|
||||
>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<span className="text-xs font-semibold text-zinc-200">
|
||||
@@ -144,7 +144,7 @@ export function StatusPanel() {
|
||||
</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">
|
||||
<div className="flex w-72 shrink-0 flex-col gap-0.5 overflow-y-auto border-r border-zinc-800 px-3 py-1.5">
|
||||
<h3 className="text-[10px] font-bold tracking-wider text-zinc-500 uppercase">
|
||||
Environment
|
||||
</h3>
|
||||
@@ -155,7 +155,7 @@ export function StatusPanel() {
|
||||
<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"
|
||||
className="rounded border border-zinc-850 bg-zinc-900/70 px-2 py-1 text-left hover:border-zinc-650"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs font-semibold text-zinc-200">{room.label}</span>
|
||||
@@ -195,7 +195,7 @@ export function StatusPanel() {
|
||||
</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 w-48 shrink-0 flex-col justify-center gap-1 border-r border-zinc-800 px-3 py-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-[10px] font-bold tracking-wider text-zinc-500 uppercase">
|
||||
Room Readout
|
||||
@@ -237,7 +237,7 @@ export function StatusPanel() {
|
||||
</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">
|
||||
<div className="flex w-64 shrink-0 flex-col gap-1 overflow-y-auto border-r border-zinc-800 px-3 py-1.5">
|
||||
<h3 className="text-[10px] font-bold tracking-wider text-zinc-500 uppercase">
|
||||
Aeration & Water
|
||||
</h3>
|
||||
@@ -248,7 +248,7 @@ export function StatusPanel() {
|
||||
<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"
|
||||
className="rounded border border-zinc-850 bg-zinc-900/70 px-2 py-1 text-left hover:border-zinc-650"
|
||||
>
|
||||
{(() => {
|
||||
const runtime = sim.reservoirRuntime[stats.id];
|
||||
@@ -310,7 +310,7 @@ export function StatusPanel() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-1 overflow-y-auto px-4 py-2.5">
|
||||
<div className="flex flex-1 flex-col gap-0.5 overflow-y-auto px-3 py-1.5">
|
||||
<h3 className="text-[10px] font-bold tracking-wider text-zinc-500 uppercase">
|
||||
Diagnostics
|
||||
</h3>
|
||||
@@ -323,7 +323,7 @@ export function StatusPanel() {
|
||||
<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]} ${
|
||||
className={`rounded border px-2 py-0.5 text-left text-[10px] leading-snug ${LEVEL_STYLE[w.level]} ${
|
||||
w.partId ? 'cursor-pointer hover:brightness-125' : 'cursor-default'
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -43,9 +43,11 @@ function SaveIndicator() {
|
||||
export function Toolbar({
|
||||
onOpenBom,
|
||||
onCheckBuild,
|
||||
onOpenDashboard,
|
||||
}: {
|
||||
onOpenBom: () => void;
|
||||
onCheckBuild: () => void;
|
||||
onOpenDashboard: () => void;
|
||||
}) {
|
||||
const { token, user, logout, refreshProfile } = useAuth();
|
||||
const [showPaywall, setShowPaywall] = useState(false);
|
||||
@@ -207,15 +209,15 @@ export function Toolbar({
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="flex h-12 shrink-0 items-center gap-2 border-b border-zinc-800 bg-zinc-950 px-3">
|
||||
<div className="flex items-center gap-2 pr-2">
|
||||
<span className="grid h-7 w-7 place-items-center rounded-lg bg-gradient-to-br from-sky-500 to-blue-700 text-sm font-black text-white">
|
||||
<header className="flex h-10 shrink-0 items-center gap-1.5 border-b border-zinc-800 bg-zinc-950 px-2.5">
|
||||
<div className="flex items-center gap-1.5 pr-1.5">
|
||||
<span className="grid h-6 w-6 place-items-center rounded bg-gradient-to-br from-sky-500 to-blue-700 text-xs font-black text-white">
|
||||
H
|
||||
</span>
|
||||
<input
|
||||
value={projectName}
|
||||
onChange={(e) => setProjectName(e.target.value)}
|
||||
className="w-44 rounded-md border border-transparent bg-transparent px-1.5 py-1 text-sm font-semibold text-zinc-100 outline-none hover:border-zinc-800 focus:border-sky-700 focus:bg-zinc-900"
|
||||
className="w-36 rounded border border-transparent bg-transparent px-1 py-0.5 text-xs font-semibold text-zinc-100 outline-none hover:border-zinc-800 focus:border-sky-700 focus:bg-zinc-900"
|
||||
/>
|
||||
<SaveIndicator />
|
||||
</div>
|
||||
@@ -311,6 +313,7 @@ export function Toolbar({
|
||||
<Divider />
|
||||
|
||||
<TBtn onClick={onOpenBom} title="Open bill of materials">BOM</TBtn>
|
||||
<TBtn onClick={onOpenDashboard} title="Open Power & Water Schedule Dashboard">📊 Dashboard</TBtn>
|
||||
<TBtn onClick={onCheckBuild} title="Run design validation">Check Build</TBtn>
|
||||
<TBtn onClick={() => autoCapOpenEnds()} title="Add caps to every loose plumbing end">
|
||||
Auto Cap
|
||||
@@ -503,14 +506,14 @@ export function Toolbar({
|
||||
</header>
|
||||
|
||||
{/* Tabbed room bar */}
|
||||
<div className="flex h-10 shrink-0 items-center justify-between border-b border-zinc-850 bg-zinc-900/60 px-3">
|
||||
<div className="flex h-8 shrink-0 items-center justify-between border-b border-zinc-850 bg-zinc-900/60 px-2.5">
|
||||
<div className="flex items-center gap-1.5 overflow-x-auto">
|
||||
{rooms.map((room) => {
|
||||
const isActive = room.id === activeRoomId;
|
||||
return (
|
||||
<div
|
||||
key={room.id}
|
||||
className={`group flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-semibold transition cursor-pointer select-none ${
|
||||
className={`group flex items-center gap-1 rounded px-2 py-0.5 text-[11px] font-semibold transition cursor-pointer select-none ${
|
||||
isActive
|
||||
? 'bg-sky-500/15 text-sky-400 border border-sky-500/30'
|
||||
: 'text-zinc-400 hover:text-zinc-200 border border-transparent'
|
||||
@@ -542,7 +545,7 @@ export function Toolbar({
|
||||
addRoom(name.trim());
|
||||
}
|
||||
}}
|
||||
className="rounded border border-zinc-700 bg-zinc-850 hover:bg-zinc-800 text-zinc-300 px-2.5 py-1 text-[11px] font-semibold transition flex items-center gap-1 cursor-pointer"
|
||||
className="rounded border border-zinc-700 bg-zinc-855 hover:bg-zinc-800 text-zinc-300 px-2 py-0.5 text-[10px] font-semibold transition flex items-center gap-1 cursor-pointer"
|
||||
>
|
||||
+ Add Room
|
||||
</button>
|
||||
@@ -573,7 +576,7 @@ function TBtn({
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
disabled={disabled}
|
||||
className={`rounded-md px-2.5 py-1.5 text-xs font-medium transition disabled:cursor-default disabled:opacity-30
|
||||
className={`rounded px-2 py-1 text-[11px] font-medium transition disabled:cursor-default disabled:opacity-30
|
||||
${
|
||||
active
|
||||
? 'bg-sky-500/15 text-sky-300 ring-1 ring-sky-700'
|
||||
|
||||
@@ -170,7 +170,7 @@ export const PART_DEFS: Record<PartType, PartDefinition> = {
|
||||
color: '#3d7bfa',
|
||||
// `on` (0|1) is intentionally NOT in params meta — the inspector renders a
|
||||
// custom power toggle for it. The simulator treats on === 0 as "switched off".
|
||||
defaults: { gph: 400, maxHeadFt: 8, on: 1 },
|
||||
defaults: { gph: 400, maxHeadFt: 8, on: 1, timerEnabled: 0, timerOnMin: 15, timerOffMin: 45 },
|
||||
params: {
|
||||
gph: { label: 'Rated flow', min: 50, max: 2000, step: 25, unit: 'GPH' },
|
||||
maxHeadFt: { label: 'Max head', min: 2, max: 30, step: 1, unit: 'ft' },
|
||||
@@ -334,7 +334,7 @@ export const PART_DEFS: Record<PartType, PartDefinition> = {
|
||||
category: 'Environment',
|
||||
description: 'Duct fan for room exhaust/intake calculations',
|
||||
color: '#0f766e',
|
||||
defaults: { cfm: 600, diameter: 6, exhaust: 1 },
|
||||
defaults: { cfm: 600, diameter: 6, exhaust: 1, timerEnabled: 0, timerOnMin: 15, timerOffMin: 45 },
|
||||
params: {
|
||||
cfm: { label: 'Airflow', min: 50, max: 4000, step: 25, unit: 'CFM' },
|
||||
diameter: { label: 'Duct size', min: 4, max: 14, step: 2, unit: 'in' },
|
||||
@@ -349,7 +349,7 @@ export const PART_DEFS: Record<PartType, PartDefinition> = {
|
||||
category: 'Environment',
|
||||
description: 'Internal airflow fan for canopy mixing',
|
||||
color: '#2563eb',
|
||||
defaults: { cfm: 250, sweep: 90 },
|
||||
defaults: { cfm: 250, sweep: 90, timerEnabled: 0, timerOnMin: 15, timerOffMin: 45 },
|
||||
params: {
|
||||
cfm: { label: 'Airflow', min: 50, max: 1500, step: 25, unit: 'CFM' },
|
||||
sweep: { label: 'Sweep', min: 0, max: 180, step: 15, unit: '°' },
|
||||
@@ -363,7 +363,7 @@ export const PART_DEFS: Record<PartType, PartDefinition> = {
|
||||
category: 'Environment',
|
||||
description: 'Overhead grow fixture for room planning',
|
||||
color: '#fbbf24',
|
||||
defaults: { width: 2, depth: 2, watts: 650, hangingHeight: 1.5, photoperiod: 18 },
|
||||
defaults: { width: 2, depth: 2, watts: 650, hangingHeight: 1.5, photoperiod: 18, timerEnabled: 0, timerOnMin: 1080, timerOffMin: 360 },
|
||||
params: {
|
||||
width: { label: 'Width', min: 0.5, max: 6, step: 0.25, unit: 'ft' },
|
||||
depth: { label: 'Depth', min: 0.5, max: 6, step: 0.25, unit: 'ft' },
|
||||
@@ -394,7 +394,7 @@ export const PART_DEFS: Record<PartType, PartDefinition> = {
|
||||
category: 'Flow Control',
|
||||
description: 'Aerate reservoir water with an attached airstone',
|
||||
color: '#0ea5e9',
|
||||
defaults: { gph: 120, frequency: 5, on: 1 },
|
||||
defaults: { gph: 120, frequency: 5, on: 1, timerEnabled: 0, timerOnMin: 15, timerOffMin: 45 },
|
||||
params: {
|
||||
gph: { label: 'Air Flow', min: 20, max: 400, step: 10, unit: 'GPH' },
|
||||
frequency: { label: 'Bubble Freq', min: 1, max: 10, step: 1, unit: 'Hz' },
|
||||
@@ -459,7 +459,7 @@ export const PART_DEFS: Record<PartType, PartDefinition> = {
|
||||
category: 'Flow Control',
|
||||
description: 'Thermoelectric water chiller to keep reservoirs cool',
|
||||
color: '#0891b2',
|
||||
defaults: { targetTemp: 68, on: 1, diameter: 1.0 },
|
||||
defaults: { targetTemp: 68, on: 1, diameter: 1.0, timerEnabled: 0, timerOnMin: 15, timerOffMin: 45 },
|
||||
params: {
|
||||
targetTemp: { label: 'Target Temp', min: 45, max: 80, step: 1, unit: '°F' },
|
||||
diameter: { label: 'Port Size', min: 0.5, max: 2.0, step: 0.25, unit: 'in' },
|
||||
|
||||
Reference in New Issue
Block a user