resolveGoverningBooking() was letting an upcoming (not-today) booking govern colour, so any room with something booked within the 7-day lookahead showed green 'Booked' regardless of how far off the arrival was. 'booked' should only apply when someone's actually arriving tonight. Now a booking only governs state/colour when it covers today (arriving today, mid-stay, or departing today) — otherwise the room is genuinely vacant. The soonest upcoming booking (if any) is surfaced separately as vacant_nights, a countdown to the next arrival, purely informational and no longer tied to colour. Dashboard shows 'Vacant N nights' when something's booked within the window, or 'Vacant 7+ nights' when nothing was found in the fetched window at all. New zone_state.vacant_nights column, threaded through saveZoneState/pollOnce/status.js. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
134 lines
5.7 KiB
TypeScript
134 lines
5.7 KiB
TypeScript
import { Thermometer, BatteryLow, TriangleAlert, Snowflake, Flame, Fan, Droplet, Lock, Heater, PlaneTakeoff, PlaneLanding, BedDouble } from 'lucide-react'
|
|
import type { ZoneStatus } from '../types'
|
|
import { ROOM_STATE_LABELS, MHI_FAN_SPEEDS } from '../types'
|
|
|
|
const AC_DEVICE_TYPES = ['mhi_modbus', 'mhi_mqtt', 'midea', 'daikin', 'home_assistant']
|
|
|
|
// Departure = takeoff, arrival = landing, stayover = bed (lucide has no "person in
|
|
// bed" icon, so BedDouble is the closest stand-in). A departure also shows a second
|
|
// landing icon when has_next_booking — same-day-turnover hint (someone's due in later).
|
|
// A genuinely vacant room (no icon, since nothing's arriving today) instead gets a
|
|
// plain night-count-until-next-arrival, or "7+ nights" when nothing's booked at all
|
|
// within the scheduler's lookahead window.
|
|
function BookingIcons({ zone }: { zone: ZoneStatus }) {
|
|
const textStyle = { display: 'inline-flex', alignItems: 'center', gap: 2, marginLeft: 6, fontSize: 11.5, fontWeight: 600, color: 'var(--text-mid)' } as const
|
|
|
|
if (zone.booking_kind) {
|
|
const iconProps = { size: 13, strokeWidth: 1.75 }
|
|
return (
|
|
<span style={textStyle}>
|
|
{zone.booking_kind === 'departure' && <PlaneTakeoff {...iconProps} />}
|
|
{zone.booking_kind === 'arrival' && <PlaneLanding {...iconProps} />}
|
|
{zone.booking_kind === 'stayover' && <BedDouble {...iconProps} />}
|
|
{zone.booking_kind === 'departure' && zone.has_next_booking && <PlaneLanding {...iconProps} />}
|
|
{zone.total_nights != null && <span>{zone.current_night}/{zone.total_nights}</span>}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
if (zone.room_state === 'vacant' && zone.zone_type === 'room') {
|
|
return (
|
|
<span style={textStyle}>
|
|
Vacant {zone.vacant_nights != null ? `${zone.vacant_nights} night${zone.vacant_nights === 1 ? '' : 's'}` : '7+ nights'}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
const MODE_ICONS: Record<string, typeof Snowflake> = {
|
|
cool: Snowflake,
|
|
heat: Flame,
|
|
fan: Fan,
|
|
dry: Droplet,
|
|
auto: Fan,
|
|
}
|
|
|
|
// bed -> living/lounge -> bathroom, matching the order in the sketch; anything else last.
|
|
function locationRank(location: string | null) {
|
|
const l = (location || '').toLowerCase()
|
|
if (l.includes('bed')) return 0
|
|
if (l.includes('living') || l.includes('lounge')) return 1
|
|
if (l.includes('bath')) return 2
|
|
return 3
|
|
}
|
|
|
|
function fmtTemp(t: string | number | null | undefined) {
|
|
return t != null ? `${Number(t).toFixed(1)}°C` : '—'
|
|
}
|
|
|
|
export default function ZoneCard({ zone, onClick }: { zone: ZoneStatus; onClick: () => void }) {
|
|
const unhealthy = zone.devices.filter(d => d.health_state !== 'healthy').length
|
|
const worstHealth = zone.devices.some(d => d.health_state === 'unresponsive' || d.health_state === 'poor')
|
|
const lowBattery = zone.devices.some(d => d.battery_pct != null && d.battery_pct < 30)
|
|
|
|
const trvs = zone.devices
|
|
.filter(d => d.device_type === 'shelly_trv')
|
|
.sort((a, b) => locationRank(a.location) - locationRank(b.location))
|
|
const trvReadings = trvs.map(d => d.current_room_temp).filter((t): t is string => t != null).map(Number)
|
|
const avgRoomTemp = trvReadings.length ? trvReadings.reduce((a, b) => a + b, 0) / trvReadings.length : null
|
|
|
|
const ac = zone.devices.find(d => AC_DEVICE_TYPES.includes(d.device_type))
|
|
const acOn = ac?.power_state === 'on'
|
|
const ModeIcon = MODE_ICONS[ac?.current_mode || ''] || Fan
|
|
const fanLevel = ac ? (MHI_FAN_SPEEDS as readonly string[]).indexOf(ac.fan_speed || '') + 1 : 0
|
|
|
|
return (
|
|
<div className={`card zone-card st-${zone.room_state}`} onClick={onClick} title={ROOM_STATE_LABELS[zone.room_state]}>
|
|
<div className="zone-card-title">
|
|
<span>
|
|
{zone.name}
|
|
<BookingIcons zone={zone} />
|
|
{unhealthy > 0 && (
|
|
<TriangleAlert
|
|
size={14}
|
|
strokeWidth={1.75}
|
|
color={worstHealth ? 'var(--health-unresponsive)' : 'var(--health-degraded)'}
|
|
style={{ verticalAlign: '-2px', marginLeft: 6 }}
|
|
/>
|
|
)}
|
|
</span>
|
|
{!zone.auto_mode && <span className="badge badge-outline">Manual</span>}
|
|
</div>
|
|
|
|
<div className="zone-card-body">
|
|
<div className="zone-card-main">
|
|
<div className="zone-card-temp">
|
|
<Thermometer size={16} strokeWidth={1.75} style={{ verticalAlign: '-2px', marginRight: 4 }} />
|
|
{Number(zone.target_temp).toFixed(1)}°C / {fmtTemp(avgRoomTemp)}
|
|
</div>
|
|
|
|
{trvs.length > 0 && (
|
|
<div className="rad-row">
|
|
{trvs.slice(0, 3).map(d => (
|
|
<div key={d.id} className={`rad-icon ${d.heating_active ? 'heating' : 'idle'}`}>
|
|
<Heater size={16} strokeWidth={1.75} />
|
|
<span>{fmtTemp(d.current_target_temp)}/{fmtTemp(d.current_room_temp)}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{ac && (
|
|
<div className={`ac-block${acOn ? '' : ' off'}`}>
|
|
{ac.remote_locked && <Lock size={12} strokeWidth={1.75} className="ac-lock" />}
|
|
<div className="ac-mode-row">
|
|
<ModeIcon size={20} strokeWidth={1.75} />
|
|
<div className="fan-bar">
|
|
{[1, 2, 3, 4].map(n => <span key={n} className={n <= fanLevel ? 'on' : ''} />)}
|
|
</div>
|
|
</div>
|
|
<div className="ac-temp">{fmtTemp(ac.current_target_temp)} / {fmtTemp(ac.current_room_temp)}</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="zone-card-meta">
|
|
<span>{zone.device_count ?? zone.devices.length} device{(zone.device_count ?? zone.devices.length) === 1 ? '' : 's'}</span>
|
|
{lowBattery && <BatteryLow size={13} strokeWidth={1.75} color="var(--health-degraded)" />}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|