Implement Smart Timers, Power & Water Schedule Dashboard, and tighten UI layout spacing

This commit is contained in:
drjones
2026-06-13 21:55:37 -07:00
parent 031139c1aa
commit 84e2c6416d
7 changed files with 552 additions and 50 deletions

View File

@@ -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}

View File

@@ -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 && (

View File

@@ -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>

View 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>
);
}

View File

@@ -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'
}`}
>

View File

@@ -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'

View File

@@ -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' },