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

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