// frontend/src/pages/bellcloud/devices/tabs/ClockTab.jsx import { useState, useEffect, useRef } from 'react' import api from '@/lib/api' import Card from '@/components/ui/Card' import Button from '@/components/ui/Button' import StatusBadge from '@/components/ui/StatusBadge' import SyncClockModal from '@/modals/bellcloud/devices/SyncClockModal' // ─── Helpers ────────────────────────────────────────────────────────────────── function extractTime(tsStr) { if (!tsStr) return null const d = new Date( tsStr.replace(' at ', ' ').replace('UTC+0000', 'UTC').replace(/UTC\+(\d{4})/, 'UTC') ) if (!isNaN(d.getTime())) { return `${d.getUTCHours().toString().padStart(2, '0')}:${d.getUTCMinutes().toString().padStart(2, '0')}` } const match = tsStr.match(/(\d{1,2}):(\d{2})/) return match ? `${match[1].padStart(2, '0')}:${match[2]}` : null } function msToSeconds(ms) { if (ms == null || ms === 0) return null return `${(ms / 1000).toFixed(1)}s` } function bellLabel(n) { if (!n || n === 0) return 'Disabled' return `Bell ${n}` } function outputLabel(n) { if (!n || n === 0) return 'Disabled' return `Output ${n}` } function silenceLabel(isOn, from, to) { if (!isOn) return 'Off' const f = extractTime(from) const t = extractTime(to) if (f && t) return `${f} – ${t}` return 'On' } function formatUtcOffset(offsetSec) { if (offsetSec == null) return null const totalMinutes = offsetSec / 60 const sign = totalMinutes >= 0 ? '+' : '-' const abs = Math.abs(totalMinutes) const hours = Math.floor(abs / 60) const minutes = abs % 60 return minutes === 0 ? `UTC${sign}${hours}` : `UTC${sign}${hours}:${String(minutes).padStart(2, '0')}` } // ─── Analog Clock Face ──────────────────────────────────────────────────────── function AnalogClock({ time, label, variant = 'primary' }) { const canvasRef = useRef(null) useEffect(() => { const canvas = canvasRef.current if (!canvas) return const ctx = canvas.getContext('2d') const size = canvas.width const cx = size / 2 const cy = size / 2 const r = size / 2 - 4 const h = time.getHours() % 12 const m = time.getMinutes() const s = time.getSeconds() const isPrimary = variant === 'primary' const accentColor = isPrimary ? 'rgba(192,193,255,0.9)' : 'rgba(192,193,255,0.35)' const faceColor = isPrimary ? 'rgba(192,193,255,0.06)' : 'rgba(192,193,255,0.03)' const rimColor = isPrimary ? 'rgba(192,193,255,0.25)' : 'rgba(192,193,255,0.10)' const tickColor = isPrimary ? 'rgba(192,193,255,0.30)' : 'rgba(192,193,255,0.12)' const hourHandColor = isPrimary ? 'rgba(192,193,255,1)' : 'rgba(192,193,255,0.45)' const minuteHandColor = isPrimary ? 'rgba(210,187,255,0.85)' : 'rgba(210,187,255,0.35)' const secondHandColor = isPrimary ? 'rgba(255,92,92,0.85)' : 'rgba(255,92,92,0.4)' const centerDotColor = isPrimary ? '#c0c1ff' : 'rgba(192,193,255,0.4)' ctx.clearRect(0, 0, size, size) // Face ctx.beginPath() ctx.arc(cx, cy, r, 0, Math.PI * 2) ctx.fillStyle = faceColor ctx.fill() // Outer glow for primary if (isPrimary) { const grad = ctx.createRadialGradient(cx, cy, r - 2, cx, cy, r + 8) grad.addColorStop(0, 'rgba(192,193,255,0.12)') grad.addColorStop(1, 'rgba(192,193,255,0)') ctx.beginPath() ctx.arc(cx, cy, r + 4, 0, Math.PI * 2) ctx.fillStyle = grad ctx.fill() } // Rim ctx.beginPath() ctx.arc(cx, cy, r, 0, Math.PI * 2) ctx.strokeStyle = rimColor ctx.lineWidth = isPrimary ? 1.5 : 1 ctx.stroke() // Ticks for (let i = 0; i < 60; i++) { const angle = (i / 60) * Math.PI * 2 - Math.PI / 2 const isHour = i % 5 === 0 const inner = isHour ? r - 12 : r - 7 ctx.beginPath() ctx.moveTo(cx + Math.cos(angle) * inner, cy + Math.sin(angle) * inner) ctx.lineTo(cx + Math.cos(angle) * (r - 2), cy + Math.sin(angle) * (r - 2)) ctx.strokeStyle = isHour ? accentColor : tickColor ctx.lineWidth = isHour ? 1.5 : 0.75 ctx.stroke() } // Hour hand const hourAngle = ((h + m / 60) / 12) * Math.PI * 2 - Math.PI / 2 ctx.beginPath() ctx.moveTo(cx, cy) ctx.lineTo(cx + Math.cos(hourAngle) * r * 0.52, cy + Math.sin(hourAngle) * r * 0.52) ctx.strokeStyle = hourHandColor ctx.lineWidth = isPrimary ? 3 : 2 ctx.lineCap = 'round' ctx.stroke() // Minute hand const minAngle = ((m + s / 60) / 60) * Math.PI * 2 - Math.PI / 2 ctx.beginPath() ctx.moveTo(cx, cy) ctx.lineTo(cx + Math.cos(minAngle) * r * 0.72, cy + Math.sin(minAngle) * r * 0.72) ctx.strokeStyle = minuteHandColor ctx.lineWidth = isPrimary ? 2 : 1.5 ctx.lineCap = 'round' ctx.stroke() // Second hand const secAngle = (s / 60) * Math.PI * 2 - Math.PI / 2 ctx.beginPath() ctx.moveTo(cx, cy) ctx.lineTo(cx + Math.cos(secAngle) * r * 0.82, cy + Math.sin(secAngle) * r * 0.82) ctx.strokeStyle = secondHandColor ctx.lineWidth = 1 ctx.lineCap = 'round' ctx.stroke() // Center dot ctx.beginPath() ctx.arc(cx, cy, isPrimary ? 4 : 3, 0, Math.PI * 2) ctx.fillStyle = centerDotColor ctx.fill() }, [time, variant]) const digitalTime = `${time.getHours().toString().padStart(2, '0')}:${time.getMinutes().toString().padStart(2, '0')}:${time.getSeconds().toString().padStart(2, '0')}` return (
{label}
{digitalTime}
) } // ─── SettingRow ─────────────────────────────────────────────────────────────── function SettingRow({ label, value, mono = false, badge }) { return (
{label} {badge ? badge : value ?? '—'}
) } function Divider() { return (
) } // ─── HeroTile ───────────────────────────────────────────────────────────────── function HeroTile({ label, value, flash = false }) { return (
{label} {value ?? 'not set'}
) } // ─── ClockTab ───────────────────────────────────────────────────────────────── export default function ClockTab({ device, canEdit, clock, attr, onEditClockSettings, onEditAlerts, onEditBacklight, sendMqttCommand, sendMqttCommandSilent, loadDevice, reloadDeviceSilent, }) { const [now, setNow] = useState(new Date()) const [showSync, setShowSync] = useState(false) const [flashFields, setFlashFields] = useState(false) const [togglingClock, setTogglingClock] = useState(false) useEffect(() => { const id = setInterval(() => setNow(new Date()), 1000) return () => clearInterval(id) }, []) // ── Background refresh: pull true state from the device right after load ── // Fires at most once per mount (hasSyncedRef guard — belt-and-suspenders // against StrictMode's double-invoke or any parent re-render retriggering // this effect). Reconciles enabled/outputs/timings/alerts/backlight/silence // against Firestore using semantic (not raw-string) comparison, and silently // corrects any real drift — no toast. const hasSyncedRef = useRef(false) useEffect(() => { if (!sendMqttCommandSilent || !device) return if (hasSyncedRef.current) return hasSyncedRef.current = true const deviceId = device.id || device.device_id sendMqttCommandSilent('clock.get_config').then((reply) => { const data = reply?.data || {} if (!data || Object.keys(data).length === 0) return const trueAlertType = { OFF: 'disabled', SINGLE: 'single', HOURS: 'multi' }[data.alert_type] ?? 'disabled' const trueHourBell = data.hour_bell === 255 ? 0 : (data.hour_bell || 0) const trueHalfBell = data.half_bell === 255 ? 0 : (data.half_bell || 0) const trueQuarterBell = data.quarter_bell === 255 ? 0 : (data.quarter_bell || 0) const trueC1 = data.c1output || 0 const trueC2 = data.c2output || 0 const truePulse = data.pulse_duration || 0 const truePause = data.pause_duration || 0 const trueBacklightOutput = data.backlight_output === 255 ? 0 : (data.backlight_output || 0) // Semantic comparison — normalize times to HH:MM via extractTime so format // differences (timezone suffix, seconds, etc.) never register as false drift. const hasDrift = !!data.enabled !== (attr?.hasClock ?? false) || trueC1 !== (clock?.clockOutputs?.[0] || 0) || trueC2 !== (clock?.clockOutputs?.[1] || 0) || truePulse !== (clock?.clockTimings?.[0] || 0) || truePause !== (clock?.clockTimings?.[1] || 0) || trueAlertType !== (clock?.ringAlerts || 'disabled') || (trueAlertType === 'multi' && data.alert_interval !== clock?.ringIntervals) || trueHourBell !== (clock?.hourAlertsBell || 0) || trueHalfBell !== (clock?.halfhourAlertsBell || 0) || trueQuarterBell !== (clock?.quarterAlertsBell || 0) || !!data.daytime_silence !== (clock?.isDaySilenceOn ?? false) || data.daytime_on !== extractTime(clock?.daySilenceFrom) || data.daytime_off !== extractTime(clock?.daySilenceTo) || !!data.night_silence !== (clock?.isNightSilenceOn ?? false) || data.night_on !== extractTime(clock?.nightSilenceFrom) || data.night_off !== extractTime(clock?.nightSilenceTo) || !!data.backlight !== (clock?.isBacklightAutomationOn ?? false) || trueBacklightOutput !== (clock?.backlightOutput || 0) || (!!data.backlight && data.backlight_on !== extractTime(clock?.backlightTurnOnTime)) || (!!data.backlight && data.backlight_off !== extractTime(clock?.backlightTurnOffTime)) || (data.gmt_offset_sec != null && data.gmt_offset_sec !== clock?.gmtOffsetSec) || (data.dst_offset_sec != null && data.dst_offset_sec !== clock?.dstOffsetSec) || (data.ntp_server != null && data.ntp_server !== clock?.ntpServer) if (!hasDrift) return api.put(`/devices/${deviceId}`, { device_attributes: { hasClock: !!data.enabled, clockSettings: { ...clock, clockOutputs: [trueC1, trueC2], clockTimings: [truePulse, truePause], ringAlerts: trueAlertType, ringIntervals: data.alert_interval, hourAlertsBell: trueHourBell, halfhourAlertsBell: trueHalfBell, quarterAlertsBell: trueQuarterBell, isDaySilenceOn: !!data.daytime_silence, daySilenceFrom: data.daytime_on ? `2000-01-01T${data.daytime_on}:00Z` : (clock?.daySilenceFrom || ''), daySilenceTo: data.daytime_off ? `2000-01-01T${data.daytime_off}:00Z` : (clock?.daySilenceTo || ''), isNightSilenceOn: !!data.night_silence, nightSilenceFrom: data.night_on ? `2000-01-01T${data.night_on}:00Z` : (clock?.nightSilenceFrom || ''), nightSilenceTo: data.night_off ? `2000-01-01T${data.night_off}:00Z` : (clock?.nightSilenceTo || ''), isBacklightAutomationOn: !!data.backlight, backlightOutput: trueBacklightOutput, backlightTurnOnTime: data.backlight_on ? `2000-01-01T${data.backlight_on}:00Z` : '', backlightTurnOffTime: data.backlight_off ? `2000-01-01T${data.backlight_off}:00Z` : '', gmtOffsetSec: data.gmt_offset_sec ?? clock?.gmtOffsetSec, dstOffsetSec: data.dst_offset_sec ?? clock?.dstOffsetSec, ntpServer: data.ntp_server ?? clock?.ntpServer, }, }, }).then(() => reloadDeviceSilent?.()) }).catch(() => { // Device offline or didn't reply — keep showing the last-known Firestore values. }) // eslint-disable-next-line react-hooks/exhaustive-deps }, [device?.id, device?.device_id]) // ── Derived values ──────────────────────────────────────────────────────── const hasClock = attr?.hasClock ?? false const clockOutputs = clock?.clockOutputs || [] const clockTimings = clock?.clockTimings || [] const oddOut = clockOutputs[0] || 0 const evenOut = clockOutputs[1] || 0 const runPulse = clockTimings[0] const pauseTime = clockTimings[1] const alertType = clock?.ringAlerts || 'disabled' const alertMasterOn = alertType !== 'disabled' // "master on" = alert_type is not OFF const ringIntervals = clock?.ringIntervals const hourBell = clock?.hourAlertsBell || 0 const halfBell = clock?.halfhourAlertsBell || 0 const quarterBell = clock?.quarterAlertsBell || 0 const isDaySilenceOn = clock?.isDaySilenceOn ?? false const isNightSilenceOn = clock?.isNightSilenceOn ?? false const backlightOn = clock?.isBacklightAutomationOn ?? false const backlightOutput = clock?.backlightOutput || 0 const backlightOnTime = extractTime(clock?.backlightTurnOnTime) const backlightOffTime = extractTime(clock?.backlightTurnOffTime) const timezone = formatUtcOffset(clock?.gmtOffsetSec) const ntpServer = clock?.ntpServer || null const dstActive = !!clock?.dstOffsetSec const alertTypeLabel = alertType === 'single' ? 'Single Fire' : alertType === 'multi' ? 'Hour Indicating' : 'Disabled' const alertVariant = alertMasterOn ? 'success' : 'neutral' // All 4 hero fields must be set before ENABLING the master switch const allFieldsSet = oddOut > 0 && evenOut > 0 && runPulse > 0 && pauseTime > 0 // Sends clock.enable/clock.disable to the device first; Firestore (hasClock) // is only updated once the device confirms SUCCESS. sendMqttCommand already // shows the blue→green/red toast. const handleMasterToggleClick = async () => { if (!canEdit || togglingClock) return // Trying to enable but required fields are missing — flash tiles and block if (!hasClock && !allFieldsSet) { setFlashFields(true) setTimeout(() => setFlashFields(false), 2200) return } const deviceId = device?.id || device?.device_id const newValue = !hasClock setTogglingClock(true) try { await sendMqttCommand(newValue ? 'clock.enable' : 'clock.disable') await api.put(`/devices/${deviceId}`, { device_attributes: { hasClock: newValue }, }) await loadDevice?.() } catch { // sendMqttCommand's toast already reported the failure — nothing further to do. } finally { setTogglingClock(false) } } // Backlight period string const backlightPeriod = backlightOn && backlightOnTime && backlightOffTime ? `${backlightOnTime} – ${backlightOffTime}` : null // ── Render ───────────────────────────────────────────────────────────────── return ( <> {/* Flash keyframe injected once */}
{/* ── Hero ──────────────────────────────────────────────────────────── */}
{/* ── Cards grid — 3 columns ────────────────────────────────────────── */}
{/* Alert Settings */} Edit )} >
{/* Master + type inline */}
{alertMasterOn ? 'Enabled' : 'Disabled'} · {alertTypeLabel} {alertType === 'multi' && ringIntervals && ( <> · Tempo {msToSeconds(ringIntervals)} )}
{/* Bell assignments + silence periods — shared 3-column grid */}
{[ { label: 'Hours', value: bellLabel(hourBell) }, { label: 'Half-Hours', value: bellLabel(halfBell) }, { label: 'Quarters', value: bellLabel(quarterBell) }, ].map(({ label, value }) => (
{label} {value}
))} {/* Silence periods in cols 1–2, col 3 intentionally empty */} {[ { label: 'Day Silence', isOn: isDaySilenceOn, from: clock?.daySilenceFrom, to: clock?.daySilenceTo }, { label: 'Night Silence', isOn: isNightSilenceOn, from: clock?.nightSilenceFrom, to: clock?.nightSilenceTo }, ].map(({ label, isOn, from, to }) => (
{label} {silenceLabel(isOn, from, to)}
))}
{/* Backlight Automation */} Edit )} >
{backlightOn ? 'Automated' : 'Disabled'}} /> {backlightOn && ( )}
{/* Network & Time */}
{dstActive ? 'Active' : 'Not Active'}} />
{canEdit && (

Read-only — reported directly by the device. Editing timezone/NTP settings from the console is not yet supported.

)}
setShowSync(false)} device={device} /> ) }