Files
bellsystems-cp/frontend/src/pages/bellcloud/devices/tabs/ClockTab.jsx
T
bonaminandClaude Sonnet 5 cc138e3d82 feat(devices): push General/Clock/Bells/Alerts config live over MQTT
Adopts useDeviceCommand across DeviceDetail and its tabs so config
changes (log levels, clock settings, bell outputs, alert thresholds,
backlight, attributes) are sent to the device immediately via
control commands and only persisted to Firestore once the device
acks success, instead of writing Firestore first and hoping the
device eventually picks it up.

- GeneralTab: log-level sliders now call log.set_serial/sd/mqtt
  directly and revert on failure; a background log.get_config +
  network.info pull reconciles Firestore against the device's actual
  state once per mount
- ClockTab, BellsTab, ControlTab, and the Edit* modals: same
  live-command-then-persist pattern
- EditLoggingModal is removed — its job (log level editing) moved
  inline into GeneralTab's sliders, so a modal round-trip is no
  longer needed
- DeviceDetail wires the shared useDeviceCommand connection through
  to each tab and adds a Health tab entry

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 18:25:30 +03:00

724 lines
30 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 'var(--space-3)' }}>
<span style={{
fontSize: 'var(--font-size-xs)',
fontWeight: 'var(--font-weight-semibold)',
letterSpacing: 'var(--tracking-wide)',
textTransform: 'uppercase',
color: variant === 'primary' ? 'var(--color-primary)' : 'var(--color-text-muted)',
}}>
{label}
</span>
<canvas
ref={canvasRef}
width={160}
height={160}
style={{ display: 'block' }}
aria-label={`${label}: ${digitalTime}`}
/>
<div style={{
fontFamily: 'var(--font-family-mono)',
fontSize: 'var(--font-size-base)',
fontWeight: 'var(--font-weight-medium)',
color: variant === 'primary' ? 'var(--color-text-primary)' : 'var(--color-text-muted)',
letterSpacing: '0.04em',
}}>
{digitalTime}
</div>
</div>
)
}
// ─── SettingRow ───────────────────────────────────────────────────────────────
function SettingRow({ label, value, mono = false, badge }) {
return (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 'var(--space-3)', minHeight: 32 }}>
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)', flexShrink: 0 }}>
{label}
</span>
<span style={{
fontSize: 'var(--font-size-sm)',
color: value === '—' || value === 'Disabled' || value === 'Off' ? 'var(--color-text-muted)' : 'var(--color-text-primary)',
fontFamily: mono ? 'var(--font-family-mono)' : 'inherit',
fontWeight: 'var(--font-weight-medium)',
textAlign: 'right',
}}>
{badge ? badge : value ?? '—'}
</span>
</div>
)
}
function Divider() {
return (
<div style={{ height: 1, backgroundColor: 'var(--color-border)', margin: 'var(--space-1) 0' }} />
)
}
// ─── HeroTile ─────────────────────────────────────────────────────────────────
function HeroTile({ label, value, flash = false }) {
return (
<div style={{
backgroundColor: 'rgba(28, 32, 38, 0.40)',
backdropFilter: 'var(--blur-modal)',
WebkitBackdropFilter: 'var(--blur-modal)',
border: `1px solid ${flash ? 'var(--color-warning)' : 'var(--color-border)'}`,
borderRadius: 'var(--radius-lg)',
padding: 'var(--space-3) var(--space-4)',
display: 'flex',
flexDirection: 'column',
gap: 'var(--space-1)',
transition: 'border-color 0.3s',
animation: flash ? 'tile-flash 0.6s ease 3' : 'none',
}}>
<span style={{
fontSize: 'var(--font-size-xs)',
fontWeight: 'var(--font-weight-semibold)',
letterSpacing: 'var(--tracking-wide)',
textTransform: 'uppercase',
color: flash ? 'var(--color-warning)' : 'var(--color-text-muted)',
transition: 'color 0.3s',
}}>
{label}
</span>
<span style={{
fontSize: 'var(--font-size-xl)',
fontWeight: 'var(--font-weight-bold)',
color: value ? 'var(--color-text-primary)' : 'var(--color-text-muted)',
fontFamily: 'var(--font-family-display)',
letterSpacing: 'var(--tracking-tight)',
lineHeight: 1.1,
fontStyle: value ? 'normal' : 'italic',
}}>
{value ?? 'not set'}
</span>
</div>
)
}
// ─── 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 */}
<style>{`
@keyframes tile-flash {
0%, 100% { border-color: var(--color-border); }
50% { border-color: var(--color-warning); box-shadow: 0 0 0 2px rgba(251,191,36,0.18); }
}
`}</style>
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-6)', width: '100%', maxWidth: 2000, margin: '0 auto' }}>
{/* ── Hero ──────────────────────────────────────────────────────────── */}
<div style={{
position: 'relative',
borderRadius: 'var(--radius-xl)',
backgroundColor: 'rgba(28, 32, 38, 0.30)',
backdropFilter: 'var(--blur-modal)',
WebkitBackdropFilter: 'var(--blur-modal)',
border: '1px solid var(--color-border)',
boxShadow: 'var(--shadow-card), var(--shadow-md)',
overflow: 'hidden',
padding: 'var(--space-6) var(--space-8)',
}}>
<div aria-hidden="true" style={{
position: 'absolute', inset: 0, pointerEvents: 'none',
background: 'radial-gradient(ellipse 50% 80% at 85% 50%, rgba(192,193,255,0.05) 0%, transparent 70%)',
}} />
<div style={{
position: 'relative',
display: 'flex',
alignItems: 'center',
gap: 'var(--space-8)',
flexWrap: 'wrap',
}}>
{/* Left — settings panel */}
<div style={{ flex: 1, minWidth: 260, display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
{/* Title + toggle */}
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-3)' }}>
<span style={{
fontFamily: 'var(--font-family-display)',
fontSize: 'var(--font-size-xl)',
fontWeight: 'var(--font-weight-bold)',
letterSpacing: 'var(--tracking-tight)',
color: 'var(--color-text-primary)',
}}>
Master Control
</span>
<button
type="button"
role="switch"
aria-checked={hasClock}
aria-label="Toggle clock"
onClick={handleMasterToggleClick}
disabled={togglingClock}
style={{
position: 'relative',
width: 36,
height: 20,
borderRadius: 'var(--radius-full)',
backgroundColor: hasClock ? 'var(--color-success)' : 'var(--color-bg-island)',
border: `1px solid ${hasClock ? 'var(--color-success)' : 'var(--color-border-strong)'}`,
flexShrink: 0,
cursor: canEdit && !togglingClock ? 'pointer' : 'default',
transition: 'background-color 0.2s, border-color 0.2s',
padding: 0,
opacity: togglingClock ? 0.6 : 1,
}}
>
<div style={{
position: 'absolute',
top: 2,
left: hasClock ? 17 : 2,
width: 14,
height: 14,
borderRadius: 'var(--radius-full)',
backgroundColor: hasClock ? 'var(--color-text-inverse)' : 'var(--color-text-muted)',
transition: 'left 0.2s',
}} />
</button>
<span style={{
fontSize: 'var(--font-size-sm)',
color: hasClock ? 'var(--color-success)' : 'var(--color-text-muted)',
fontWeight: 'var(--font-weight-medium)',
}}>
{hasClock ? 'Active' : 'Inactive'}
</span>
</div>
{/* 2×2 tile grid */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-3)' }}>
<HeroTile label="Pulse Duration" value={msToSeconds(runPulse)} flash={flashFields && !runPulse} />
<HeroTile label="Pause Duration" value={msToSeconds(pauseTime)} flash={flashFields && !pauseTime} />
<HeroTile label="ODD Output" value={oddOut ? outputLabel(oddOut) : null} flash={flashFields && !oddOut} />
<HeroTile label="EVEN Output" value={evenOut ? outputLabel(evenOut) : null} flash={flashFields && !evenOut} />
</div>
{/* Actions row */}
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-3)' }}>
{canEdit && (
<Button variant="ghost" size="sm" onClick={onEditClockSettings}>Edit Settings</Button>
)}
<button
type="button"
onClick={() => setShowSync(true)}
style={{
background: 'none', border: 'none',
padding: '0 var(--space-1)',
fontSize: 'var(--font-size-sm)',
color: 'var(--color-primary)',
cursor: 'pointer',
opacity: 0.75,
transition: 'opacity 0.15s',
}}
onMouseEnter={e => (e.currentTarget.style.opacity = 1)}
onMouseLeave={e => (e.currentTarget.style.opacity = 0.75)}
>
Sync Clocktower Time
</button>
</div>
</div>
{/* Right — clocks, with equal breathing room on all sides */}
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 'var(--space-12)', padding: '0 var(--space-8)' }}>
<AnalogClock time={now} label="RTC Time" variant="primary" />
<AnalogClock time={now} label="Clocktower" variant="primary" />
</div>
</div>
</div>
{/* ── Cards grid — 3 columns ────────────────────────────────────────── */}
<div className="masonry-grid masonry-grid--3">
{/* Alert Settings */}
<Card
title="Alert Settings"
action={canEdit && (
<Button variant="ghost" size="sm" onClick={onEditAlerts}>Edit</Button>
)}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
{/* Master + type inline */}
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-3)', flexWrap: 'wrap' }}>
<StatusBadge variant={alertVariant}>{alertMasterOn ? 'Enabled' : 'Disabled'}</StatusBadge>
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>·</span>
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-secondary)', fontWeight: 'var(--font-weight-medium)' }}>
{alertTypeLabel}
</span>
{alertType === 'multi' && ringIntervals && (
<>
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>·</span>
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>
Tempo {msToSeconds(ringIntervals)}
</span>
</>
)}
</div>
<Divider />
{/* Bell assignments + silence periods — shared 3-column grid */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 'var(--space-2) var(--space-2)', rowGap: 'var(--space-4)' }}>
{[
{ label: 'Hours', value: bellLabel(hourBell) },
{ label: 'Half-Hours', value: bellLabel(halfBell) },
{ label: 'Quarters', value: bellLabel(quarterBell) },
].map(({ label, value }) => (
<div key={label} style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-1)' }}>
<span style={{
fontSize: 'var(--font-size-xs)',
color: 'var(--color-text-muted)',
letterSpacing: 'var(--tracking-wide)',
textTransform: 'uppercase',
}}>
{label}
</span>
<span style={{
fontSize: 'var(--font-size-base)',
fontWeight: 'var(--font-weight-semibold)',
color: value === 'Disabled' ? 'var(--color-text-muted)' : 'var(--color-text-primary)',
fontFamily: 'var(--font-family-display)',
letterSpacing: 'var(--tracking-tight)',
}}>
{value}
</span>
</div>
))}
{/* 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 }) => (
<div key={label} style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-1)' }}>
<span style={{
fontSize: 'var(--font-size-xs)',
color: 'var(--color-text-muted)',
letterSpacing: 'var(--tracking-wide)',
textTransform: 'uppercase',
}}>
{label}
</span>
<span style={{
fontSize: 'var(--font-size-base)',
fontWeight: 'var(--font-weight-semibold)',
color: isOn ? 'var(--color-text-primary)' : 'var(--color-text-muted)',
fontFamily: isOn ? 'var(--font-family-mono)' : 'inherit',
letterSpacing: isOn ? '0.03em' : 0,
}}>
{silenceLabel(isOn, from, to)}
</span>
</div>
))}
</div>
</div>
</Card>
{/* Backlight Automation */}
<Card
title="Backlight Automation"
action={canEdit && (
<Button variant="ghost" size="sm" onClick={onEditBacklight}>Edit</Button>
)}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-2)' }}>
<SettingRow
label="Automation"
badge={<StatusBadge variant={backlightOn ? 'success' : 'neutral'}>{backlightOn ? 'Automated' : 'Disabled'}</StatusBadge>}
/>
<SettingRow label="Output" value={outputLabel(backlightOutput)} />
{backlightOn && (
<SettingRow
label="Period"
value={backlightPeriod || '—'}
mono={!!backlightPeriod}
/>
)}
</div>
</Card>
{/* Network & Time */}
<Card title="Network & Time">
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-2)' }}>
<SettingRow label="Timezone" value={timezone || '—'} mono={!!timezone} />
<SettingRow label="NTP Server" value={ntpServer || '—'} mono={!!ntpServer} />
<SettingRow
label="DST"
badge={<StatusBadge variant={dstActive ? 'success' : 'neutral'}>{dstActive ? 'Active' : 'Not Active'}</StatusBadge>}
/>
</div>
{canEdit && (
<div style={{ marginTop: 'var(--space-4)', paddingTop: 'var(--space-4)', borderTop: '1px solid var(--color-border)' }}>
<p style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>
Read-only — reported directly by the device. Editing timezone/NTP settings from the console is not yet supported.
</p>
</div>
)}
</Card>
</div>
</div>
<SyncClockModal open={showSync} onClose={() => setShowSync(false)} device={device} />
</>
)
}