// 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 (
)
}
// ─── 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 ──────────────────────────────────────────────────────────── */}
{/* Left — settings panel */}
{/* Title + toggle */}
Master Control
{hasClock ? 'Active' : 'Inactive'}
{/* 2×2 tile grid */}
{/* Actions row */}
{canEdit && (
)}
{/* Right — clocks, with equal breathing room on all sides */}
{/* ── 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} />
>
)
}