// Vip Lift Card // Purpose: Renders a shared lift controller panel synced from server session state. // Scope: Presents verified-user controls while reflecting global busy/position/cooldown state. import { useEffect, useMemo, useState } from 'react'; import CardFrame from '../CardFrame/index.jsx'; function badgeClass(tone) { if (tone === 'good') return 'bg-emerald-600 text-white'; if (tone === 'warn') return 'bg-amber-500 text-slate-900'; if (tone === 'danger') return 'bg-rose-600 text-white'; return 'bg-slate-700 text-slate-100'; } function positionLabel(value) { if (value === 'up') return 'Up'; if (value === 'down') return 'Down'; if (value === 'stopped') return 'Transitioning...'; if (value === 'conflict') return 'Conflict'; return '--'; } export default function VipLiftCard({ lift, onUp, onDown, fullWidth = false }) { const [working, setWorking] = useState(''); const [nowMs, setNowMs] = useState(() => Date.now()); const wrapClass = fullWidth ? 'w-full' : 'w-full max-w-xl'; const configured = Boolean(lift?.configured); const connected = Boolean(lift?.enabled && lift?.connected); const busy = Boolean(lift?.busy); const activeTarget = String(lift?.target || '').toLowerCase(); const position = String(lift?.position || '').toLowerCase(); const upAvailable = Boolean(lift?.availability?.upSwitch); const downAvailable = Boolean(lift?.availability?.downSwitch); const lastActionAt = Number(lift?.lastActionAt || 0); const commandCooldownMs = Math.max(0, Number(lift?.commandCooldownMs || 0)); const cooldownRemainingMs = Math.max(0, lastActionAt + commandCooldownMs - nowMs); const cooldownActive = !busy && cooldownRemainingMs > 0; const blocked = busy || cooldownActive; useEffect(() => { if (!blocked) return undefined; const t = setInterval(() => { setNowMs(Date.now()); }, 100); return () => clearInterval(t); }, [blocked]); useEffect(() => { setNowMs(Date.now()); }, [lastActionAt, commandCooldownMs, busy]); const status = !configured ? 'Not configured' : !connected ? 'Offline' : blocked ? 'Busy' : 'Ready'; const statusTone = !configured ? 'warn' : !connected ? 'danger' : blocked ? 'warn' : 'good'; const canRun = configured && connected && !blocked && !working && upAvailable && downAvailable; const cooldownSeconds = useMemo(() => Math.ceil(cooldownRemainingMs / 1000), [cooldownRemainingMs]); const run = async (dir, fn) => { if (!fn) return; setWorking(dir); try { await fn(); } catch { // Errors are surfaced by shared state and command ack handling. } finally { setWorking(''); } }; return ( {status} } > {blocked ? (

{busy ? 'Preparing to move' : 'Moving!'}

Controls are disabled while the lift is moving, otherwise its tiny brain would get confused.

{!busy && cooldownActive ? (

About {cooldownSeconds}s remaining.

) : null}
) : null}

Move the lift up and down. Please don't break anything...

Position
{positionLabel(position)}
{!configured || !connected || !upAvailable || !downAvailable ? (

{!configured ? 'Set homeAssistant.lift.upSwitch and homeAssistant.lift.downSwitch in server config.' : !connected ? 'Home Assistant is offline.' : 'Waiting for required lift switch entities in Home Assistant.'}

) : null} {lift?.lastError ?

Last error: {lift.lastError}

: null}
); }