Per-light schedule pause, global schedules switch, Timeline preview page

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-10-14 12:40:45 +00:00
parent 371582cbd6
commit d33b2fe11d
13 changed files with 368 additions and 12 deletions

View file

@ -23,6 +23,11 @@ export async function initDb() {
UNIQUE (mqtt_topic, channel) UNIQUE (mqtt_topic, channel)
); );
-- Per-light schedule pause: schedules skip a paused light. paused_until NULL
-- means "until someone resumes it".
ALTER TABLE light_devices ADD COLUMN IF NOT EXISTS schedules_paused BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE light_devices ADD COLUMN IF NOT EXISTS paused_until TIMESTAMPTZ;
CREATE TABLE IF NOT EXISTS light_groups ( CREATE TABLE IF NOT EXISTS light_groups (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE, name TEXT NOT NULL UNIQUE,
@ -72,7 +77,7 @@ export async function initDb() {
); );
`) `)
for (const [key, value] of [['latitude', ''], ['longitude', ''], ['timezone', 'Europe/London']]) { for (const [key, value] of [['latitude', ''], ['longitude', ''], ['timezone', 'Europe/London'], ['schedules_enabled', 'true']]) {
await pool.query(`INSERT INTO app_settings (key, value) VALUES ($1, $2) ON CONFLICT (key) DO NOTHING`, [key, value]) await pool.query(`INSERT INTO app_settings (key, value) VALUES ($1, $2) ON CONFLICT (key) DO NOTHING`, [key, value])
} }
} }

View file

@ -9,19 +9,40 @@ import { pool, getSettings } from '../db.js'
import { getDevice, isConnected, setOnlineHandler } from './mqtt.js' import { getDevice, isConnected, setOnlineHandler } from './mqtt.js'
import { setPower, setPowerMany, resolveTargetDevices } from './control.js' import { setPower, setPowerMany, resolveTargetDevices } from './control.js'
import { pruneEvents } from './events.js' import { pruneEvents } from './events.js'
import { localParts, ymd, triggerTime, dayOffset } from './time.js' import { localParts, ymd, triggerTime, dayOffset, zonedToUtc } from './time.js'
const TICK_MS = 20 * 1000 const TICK_MS = 20 * 1000
const FIRE_GRACE_MS = 15 * 60 * 1000 // older misses are left to reconcile const FIRE_GRACE_MS = 15 * 60 * 1000 // older misses are left to reconcile
const LOOKBACK_DAYS = 7 const LOOKBACK_DAYS = 7
export const schedulesOn = settings => settings.schedules_enabled !== 'false'
const NOT_PAUSED = `NOT (schedules_paused AND (paused_until IS NULL OR paused_until > NOW()))`
// Of these devices, the ones whose schedules aren't currently paused.
async function unpaused(ids) {
if (!ids.length) return []
const { rows } = await pool.query(`SELECT id FROM light_devices WHERE id = ANY($1) AND ${NOT_PAUSED}`, [ids])
return rows.map(r => r.id)
}
// Timed pauses that have run out: clear them and let the light catch up with
// whatever its schedules say now (still subject to the manual-override rule).
async function expirePauses() {
const { rows } = await pool.query(
`UPDATE light_devices SET schedules_paused = FALSE, paused_until = NULL
WHERE schedules_paused AND paused_until IS NOT NULL AND paused_until <= NOW() RETURNING id`
)
if (rows.length) reconcile(rows.map(r => r.id)).catch(() => {})
}
async function fire(s, trigger) { async function fire(s, trigger) {
const claimed = await pool.query( const claimed = await pool.query(
`UPDATE light_schedules SET last_fired_on = $1 WHERE id = $2 AND last_fired_on IS DISTINCT FROM $1 RETURNING id`, `UPDATE light_schedules SET last_fired_on = $1 WHERE id = $2 AND last_fired_on IS DISTINCT FROM $1 RETURNING id`,
[s._today, s.id] [s._today, s.id]
) )
if (!claimed.rowCount) return if (!claimed.rowCount) return
const ids = await resolveTargetDevices(s.target_type, s.target_id) const ids = await unpaused(await resolveTargetDevices(s.target_type, s.target_id))
console.log(`[engine] firing "${s.name}" (${s.action}) on ${ids.length} device(s), trigger ${trigger.toISOString()}`) console.log(`[engine] firing "${s.name}" (${s.action}) on ${ids.length} device(s), trigger ${trigger.toISOString()}`)
const failed = await setPowerMany(ids, s.action, 'schedule', s.name) const failed = await setPowerMany(ids, s.action, 'schedule', s.name)
if (failed.length) console.warn(`[engine] "${s.name}": ${failed.length} command(s) failed:`, failed.map(f => f.error).join('; ')) if (failed.length) console.warn(`[engine] "${s.name}": ${failed.length} command(s) failed:`, failed.map(f => f.error).join('; '))
@ -30,6 +51,8 @@ async function fire(s, trigger) {
export async function tick() { export async function tick() {
if (!isConnected()) return if (!isConnected()) return
const settings = await getSettings() const settings = await getSettings()
await expirePauses()
if (!schedulesOn(settings)) return
const tz = settings.timezone || 'Europe/London' const tz = settings.timezone || 'Europe/London'
const now = new Date() const now = new Date()
const today = localParts(now, tz) const today = localParts(now, tz)
@ -78,12 +101,14 @@ async function schedulesFor(deviceId) {
export async function reconcile(deviceIds = null) { export async function reconcile(deviceIds = null) {
if (!isConnected()) return { applied: 0 } if (!isConnected()) return { applied: 0 }
const settings = await getSettings() const settings = await getSettings()
if (!schedulesOn(settings)) return { applied: 0 }
const now = new Date() const now = new Date()
let ids = deviceIds let ids = deviceIds
if (!ids) { if (!ids) {
const { rows } = await pool.query('SELECT id FROM light_devices WHERE active = TRUE') const { rows } = await pool.query('SELECT id FROM light_devices WHERE active = TRUE')
ids = rows.map(r => r.id) ids = rows.map(r => r.id)
} }
ids = await unpaused(ids)
let applied = 0 let applied = 0
for (const id of ids) { for (const id of ids) {
const device = getDevice(id) const device = getDevice(id)
@ -121,3 +146,61 @@ export function nextRun(s, settings, now = new Date()) {
} }
return null return null
} }
// What the schedules alone would do to every light across one site-local day,
// as on/off/unscheduled segments. Planned behaviour only — it ignores manual
// changes and pauses (the caller flags paused lights).
export async function previewDay(day, settings) {
const tz = settings.timezone || 'Europe/London'
const start = zonedToUtc(day.y, day.m, day.d, 0, 0, tz)
const next = dayOffset(day, 1, tz)
const end = zonedToUtc(next.y, next.m, next.d, 0, 0, tz)
const weekday = dayOffset(day, 0, tz).weekday
const { rows: devices } = await pool.query(
`SELECT id, name, location, active,
(schedules_paused AND (paused_until IS NULL OR paused_until > NOW())) AS paused
FROM light_devices WHERE active = TRUE ORDER BY location NULLS LAST, name`
)
const { rows: schedules } = await pool.query('SELECT * FROM light_schedules WHERE enabled = TRUE')
const { rows: members } = await pool.query('SELECT group_id, device_id FROM light_group_members')
const groupsOf = new Map()
for (const m of members) {
if (!groupsOf.has(m.device_id)) groupsOf.set(m.device_id, new Set())
groupsOf.get(m.device_id).add(m.group_id)
}
return devices.map(d => {
const mine = schedules.filter(s =>
(s.target_type === 'device' && s.target_id === d.id) ||
(s.target_type === 'group' && groupsOf.get(d.id)?.has(s.target_id)))
const carried = latestTrigger(mine, settings, new Date(start.getTime() - 1))
const events = []
for (const s of mine) {
if (!s.days.includes(weekday)) continue
const t = triggerTime(s, day, settings)
if (t && t >= start && t < end) events.push({ at: t, action: s.action, schedule: s.name })
}
events.sort((a, b) => a.at - b.at)
const segments = []
let cursor = start
let state = carried?.action ?? null
let by = carried?.name ?? null
for (const ev of events) {
if (ev.at > cursor) segments.push({ from: cursor, to: ev.at, state, schedule: by })
cursor = ev.at; state = ev.action; by = ev.schedule
}
segments.push({ from: cursor, to: end, state, schedule: by })
const onMinutes = Math.round(segments.filter(x => x.state === 'on').reduce((n, x) => n + (x.to - x.from), 0) / 60000)
return {
id: d.id, name: d.name, location: d.location, paused: d.paused,
scheduled: mine.length > 0, on_minutes: onMinutes,
events: events.map(e => ({ at: e.at, action: e.action, schedule: e.schedule })),
segments: segments.filter(x => x.to > x.from),
}
}).map(d => ({ ...d, day_start: start, day_end: end }))
}

View file

@ -2,14 +2,16 @@ import { requireAuth, requireCap } from '../auth.js'
import { pool, getSettings } from '../db.js' import { pool, getSettings } from '../db.js'
import { refreshDevices, listDiscovered, isConnected } from '../lib/mqtt.js' import { refreshDevices, listDiscovered, isConnected } from '../lib/mqtt.js'
import { setPower, setPowerMany, resolveTargetDevices } from '../lib/control.js' import { setPower, setPowerMany, resolveTargetDevices } from '../lib/control.js'
import { reconcile } from '../lib/engine.js' import { reconcile, previewDay, schedulesOn } from '../lib/engine.js'
import { localParts, sunTimes } from '../lib/time.js' import { localParts, sunTimes } from '../lib/time.js'
const TOPIC_RE = /^[A-Za-z0-9_.-]+$/ const TOPIC_RE = /^[A-Za-z0-9_.-]+$/
async function loadState() { async function loadState() {
const { rows: devices } = await pool.query( const { rows: devices } = await pool.query(
`SELECT id, name, location, mqtt_topic, channel, active, state, online, state_at FROM light_devices ORDER BY location NULLS LAST, name` `SELECT id, name, location, mqtt_topic, channel, active, state, online, state_at,
(schedules_paused AND (paused_until IS NULL OR paused_until > NOW())) AS schedules_paused, paused_until
FROM light_devices ORDER BY location NULLS LAST, name`
) )
const { rows: groups } = await pool.query( const { rows: groups } = await pool.query(
`SELECT g.id, g.name, COALESCE(array_agg(m.device_id) FILTER (WHERE m.device_id IS NOT NULL), '{}') AS device_ids `SELECT g.id, g.name, COALESCE(array_agg(m.device_id) FILTER (WHERE m.device_id IS NOT NULL), '{}') AS device_ids
@ -25,7 +27,7 @@ export async function deviceRoutes(app) {
const settings = await getSettings() const settings = await getSettings()
const tz = settings.timezone || 'Europe/London' const tz = settings.timezone || 'Europe/London'
const sun = sunTimes(localParts(new Date(), tz), settings) const sun = sunTimes(localParts(new Date(), tz), settings)
return { ...(await loadState()), mqtt_connected: isConnected(), sun, timezone: tz } return { ...(await loadState()), mqtt_connected: isConnected(), sun, timezone: tz, schedules_enabled: schedulesOn(settings) }
}) })
// ── manual control ── // ── manual control ──
@ -49,6 +51,25 @@ export async function deviceRoutes(app) {
return { ok: failed.length === 0, failed } return { ok: failed.length === 0, failed }
}) })
// Pause (hours = N, or omitted for "until resumed") or resume (paused: false) a
// single light's schedules. Uses the control cap — it's an operational
// override, not schedule editing.
app.post('/api/devices/:id/schedule-hold', { preHandler: requireCap('control') }, async (req, reply) => {
const { paused, hours } = req.body || {}
if (typeof paused !== 'boolean') return reply.status(400).send({ error: 'paused must be true or false' })
const h = hours == null ? null : Number(hours)
if (h !== null && !(h > 0 && h <= 24 * 30)) return reply.status(400).send({ error: 'hours must be between 0 and 720' })
const { rowCount } = await pool.query(
`UPDATE light_devices SET schedules_paused = $1,
paused_until = CASE WHEN $1 AND $2::numeric IS NOT NULL THEN NOW() + ($2::numeric * INTERVAL '1 hour') ELSE NULL END
WHERE id = $3`,
[paused, paused ? h : null, req.params.id]
)
if (!rowCount) return reply.status(404).send({ error: 'Not found' })
if (!paused && isConnected()) await reconcile([Number(req.params.id)])
return { ok: true }
})
app.post('/api/reconcile', { preHandler: requireCap('control') }, async (req, reply) => { app.post('/api/reconcile', { preHandler: requireCap('control') }, async (req, reply) => {
if (!isConnected()) return reply.status(503).send({ error: 'MQTT broker not connected' }) if (!isConnected()) return reply.status(503).send({ error: 'MQTT broker not connected' })
return reconcile() return reconcile()
@ -143,6 +164,23 @@ export async function deviceRoutes(app) {
return { ok: true } return { ok: true }
}) })
// ── timeline preview: what the schedules plan for one day ──
app.get('/api/preview', { preHandler: requireCap('view') }, async (req, reply) => {
const settings = await getSettings()
const tz = settings.timezone || 'Europe/London'
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(req.query.date || '')
const day = m ? { y: +m[1], m: +m[2], d: +m[3] } : localParts(new Date(), tz)
if (m && (day.m < 1 || day.m > 12 || day.d < 1 || day.d > 31)) return reply.status(400).send({ error: 'Invalid date' })
const devices = await previewDay(day, settings)
return {
date: `${day.y}-${String(day.m).padStart(2, '0')}-${String(day.d).padStart(2, '0')}`,
timezone: tz,
schedules_enabled: schedulesOn(settings),
sun: sunTimes(day, settings),
devices,
}
})
// ── activity log ── // ── activity log ──
app.get('/api/events', { preHandler: requireCap('view') }, async (req) => { app.get('/api/events', { preHandler: requireCap('view') }, async (req) => {
const limit = Math.min(Number(req.query.limit) || 100, 500) const limit = Math.min(Number(req.query.limit) || 100, 500)

View file

@ -4,8 +4,9 @@ import { isConnected } from '../lib/mqtt.js'
import { validTimezone } from '../lib/time.js' import { validTimezone } from '../lib/time.js'
// Standard app_settings pattern (see conventions doc). Latitude/longitude drive // Standard app_settings pattern (see conventions doc). Latitude/longitude drive
// the sunrise/sunset triggers; timezone decides what "18:00" and "Mon-Fri" mean. // the sunrise/sunset triggers; timezone decides what "18:00" and "Mon-Fri" mean;
const ALLOWED_KEYS = new Set(['latitude', 'longitude', 'timezone']) // schedules_enabled is the global kill switch for all automation.
const ALLOWED_KEYS = new Set(['latitude', 'longitude', 'timezone', 'schedules_enabled'])
export async function settingsRoutes(app) { export async function settingsRoutes(app) {
app.addHook('preHandler', requireAuth) app.addHook('preHandler', requireAuth)
@ -23,6 +24,7 @@ export async function settingsRoutes(app) {
const v = String(value ?? '').trim() const v = String(value ?? '').trim()
if (key === 'latitude' && v && !(Math.abs(parseFloat(v)) <= 90)) return reply.status(400).send({ error: 'Latitude must be between -90 and 90' }) if (key === 'latitude' && v && !(Math.abs(parseFloat(v)) <= 90)) return reply.status(400).send({ error: 'Latitude must be between -90 and 90' })
if (key === 'longitude' && v && !(Math.abs(parseFloat(v)) <= 180)) return reply.status(400).send({ error: 'Longitude must be between -180 and 180' }) if (key === 'longitude' && v && !(Math.abs(parseFloat(v)) <= 180)) return reply.status(400).send({ error: 'Longitude must be between -180 and 180' })
if (key === 'schedules_enabled' && v !== 'true' && v !== 'false') return reply.status(400).send({ error: 'schedules_enabled must be true or false' })
if (key === 'timezone' && !validTimezone(v)) return reply.status(400).send({ error: `Unknown timezone "${v}"` }) if (key === 'timezone' && !validTimezone(v)) return reply.status(400).send({ error: `Unknown timezone "${v}"` })
await pool.query( await pool.query(
`INSERT INTO app_settings (key, value, updated_at) VALUES ($1, $2, NOW()) `INSERT INTO app_settings (key, value, updated_at) VALUES ($1, $2, NOW())

View file

@ -4,6 +4,7 @@ import IosInstallHint from './components/IosInstallHint'
import Layout from './components/Layout' import Layout from './components/Layout'
import Dashboard from './pages/Dashboard' import Dashboard from './pages/Dashboard'
import Schedules from './pages/Schedules' import Schedules from './pages/Schedules'
import Timeline from './pages/Timeline'
import Devices from './pages/Devices' import Devices from './pages/Devices'
import Log from './pages/Log' import Log from './pages/Log'
import Settings from './pages/Settings' import Settings from './pages/Settings'
@ -27,6 +28,7 @@ export default function App() {
<Route path="/" element={<Home />} /> <Route path="/" element={<Home />} />
<Route path="/dashboard" element={<Dashboard />} /> <Route path="/dashboard" element={<Dashboard />} />
<Route path="/schedules" element={<Schedules />} /> <Route path="/schedules" element={<Schedules />} />
<Route path="/timeline" element={<Timeline />} />
<Route path="/devices" element={<Devices />} /> <Route path="/devices" element={<Devices />} />
<Route path="/log" element={<Log />} /> <Route path="/log" element={<Log />} />
<Route path="/settings" element={<Settings />} /> <Route path="/settings" element={<Settings />} />

View file

@ -1,5 +1,5 @@
import type { import type {
StateResponse, LightDevice, LightGroup, Schedule, ScheduleInput, DiscoveredDevice, LightEvent, AppSetting, PowerState, StateResponse, LightDevice, LightGroup, Schedule, ScheduleInput, DiscoveredDevice, LightEvent, AppSetting, PowerState, PreviewResponse,
} from './types' } from './types'
const BASE = '/lights/api' const BASE = '/lights/api'
@ -27,6 +27,9 @@ const json = (method: string, body: unknown): RequestInit => ({ method, body: JS
export const fetchState = (): Promise<StateResponse> => request('/state') export const fetchState = (): Promise<StateResponse> => request('/state')
export const setDevicePower = (id: number, state: PowerState) => request(`/devices/${id}/power`, json('POST', { state })) export const setDevicePower = (id: number, state: PowerState) => request(`/devices/${id}/power`, json('POST', { state }))
export const setGroupPower = (id: number, state: PowerState) => request(`/groups/${id}/power`, json('POST', { state })) export const setGroupPower = (id: number, state: PowerState) => request(`/groups/${id}/power`, json('POST', { state }))
export const holdSchedules = (id: number, paused: boolean, hours?: number) =>
request(`/devices/${id}/schedule-hold`, json('POST', { paused, hours }))
export const fetchPreview = (date?: string): Promise<PreviewResponse> => request(`/preview${date ? `?date=${date}` : ''}`)
export const reapplySchedules = (): Promise<{ applied: number }> => request('/reconcile', { method: 'POST' }) export const reapplySchedules = (): Promise<{ applied: number }> => request('/reconcile', { method: 'POST' })
// Devices // Devices

View file

@ -1,6 +1,6 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { NavLink, useLocation } from 'react-router-dom' import { NavLink, useLocation } from 'react-router-dom'
import { Lightbulb, LayoutGrid, CalendarClock, Cpu, History, Settings, Menu, LogOut } from 'lucide-react' import { Lightbulb, LayoutGrid, CalendarClock, ChartGantt, Cpu, History, Settings, Menu, LogOut } from 'lucide-react'
import { useAuth } from './AuthGate' import { useAuth } from './AuthGate'
import { can } from '../types' import { can } from '../types'
@ -9,6 +9,7 @@ const ICON_PROPS = { size: 16, strokeWidth: 1.75 }
const NAV = [ const NAV = [
{ to: '/dashboard', label: 'Control', icon: LayoutGrid, cap: 'view' }, { to: '/dashboard', label: 'Control', icon: LayoutGrid, cap: 'view' },
{ to: '/schedules', label: 'Schedules', icon: CalendarClock, cap: 'view' }, { to: '/schedules', label: 'Schedules', icon: CalendarClock, cap: 'view' },
{ to: '/timeline', label: 'Timeline', icon: ChartGantt, cap: 'view' },
{ to: '/devices', label: 'Devices', icon: Cpu, cap: 'manage_devices' }, { to: '/devices', label: 'Devices', icon: Cpu, cap: 'manage_devices' },
{ to: '/log', label: 'Activity', icon: History, cap: 'view' }, { to: '/log', label: 'Activity', icon: History, cap: 'view' },
{ to: '/settings', label: 'Settings', icon: Settings, cap: 'settings' }, { to: '/settings', label: 'Settings', icon: Settings, cap: 'settings' },

View file

@ -368,3 +368,30 @@ table.data tr.clickable { cursor: pointer; }
.check-list { display: flex; flex-direction: column; gap: 6px; max-height: 240px; overflow-y: auto; padding: 4px 0; } .check-list { display: flex; flex-direction: column; gap: 6px; max-height: 240px; overflow-y: auto; padding: 4px 0; }
.sun-strip { display: flex; gap: 16px; align-items: center; font-size: 13px; color: var(--text-mid); flex-wrap: wrap; } .sun-strip { display: flex; gap: 16px; align-items: center; font-size: 13px; color: var(--text-mid); flex-wrap: wrap; }
.sun-strip span { display: inline-flex; align-items: center; gap: 5px; } .sun-strip span { display: inline-flex; align-items: center; gap: 5px; }
.tile-select {
font-size: 11.5px; padding: 3px 6px; border: 1px solid var(--card-border); border-radius: 8px;
background: var(--card-bg); color: var(--text-mid); font-family: inherit; width: 100%;
}
/* Timeline */
.tl { background: var(--card-bg); border: 1px solid var(--card-border); border-radius: var(--radius); box-shadow: var(--shadow-sm); overflow-x: auto; }
.tl-inner { min-width: 640px; }
.tl-row { display: grid; grid-template-columns: 150px 1fr 64px; align-items: center; gap: 10px; padding: 5px 12px; border-bottom: 1px solid var(--card-border); }
.tl-row:last-child { border-bottom: none; }
.tl-head { color: var(--text-mid); font-size: 11px; position: sticky; top: 0; background: var(--card-bg); }
.tl-loc { background: var(--body-bg); font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; color: var(--text-mid); padding: 4px 12px; }
.tl-name { font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: flex; align-items: center; gap: 5px; }
.tl-total { font-size: 11.5px; color: var(--text-mid); text-align: right; }
.tl-track { position: relative; height: 22px; border-radius: 6px; overflow: hidden; background: var(--body-bg); }
.tl-seg { position: absolute; top: 0; bottom: 0; }
.tl-seg.on { background: var(--gold); }
.tl-seg.off { background: var(--card-border); }
.tl-seg.none { background: repeating-linear-gradient(45deg, var(--body-bg), var(--body-bg) 4px, var(--card-border) 4px, var(--card-border) 5px); }
.tl-row.paused .tl-track { opacity: .45; }
.tl-ticks { position: relative; height: 16px; }
.tl-ticks span { position: absolute; transform: translateX(-50%); font-size: 10.5px; }
.tl-marker { position: absolute; top: 0; bottom: 0; width: 0; border-left: 1px dashed var(--text-mid); pointer-events: none; z-index: 2; }
.tl-marker.now { border-left: 2px solid var(--danger); }
.tl-legend { display: flex; gap: 14px; flex-wrap: wrap; font-size: 12px; color: var(--text-mid); margin-top: 10px; align-items: center; }
.tl-legend i { display: inline-block; width: 14px; height: 10px; border-radius: 3px; margin-right: 5px; vertical-align: -1px; }
@media (max-width: 768px) { .tl-row { grid-template-columns: 96px 1fr 52px; gap: 6px; } }

View file

@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { Sunrise, Sunset, WifiOff, RefreshCw, Lightbulb, LightbulbOff } from 'lucide-react' import { Sunrise, Sunset, WifiOff, RefreshCw, Lightbulb, LightbulbOff, PauseCircle } from 'lucide-react'
import { useAuth } from '../components/AuthGate' import { useAuth } from '../components/AuthGate'
import { fetchState, setDevicePower, setGroupPower, reapplySchedules } from '../api' import { fetchState, setDevicePower, setGroupPower, reapplySchedules, holdSchedules } from '../api'
import type { StateResponse, LightDevice, PowerState } from '../types' import type { StateResponse, LightDevice, PowerState } from '../types'
import { can } from '../types' import { can } from '../types'
@ -73,6 +73,25 @@ export default function Dashboard() {
<div className="tile-meta"> <div className="tile-meta">
{offline ? <><WifiOff size={11} strokeWidth={1.75} /> Offline</> : d.state === null ? 'State unknown' : on ? 'On' : 'Off'} {offline ? <><WifiOff size={11} strokeWidth={1.75} /> Offline</> : d.state === null ? 'State unknown' : on ? 'On' : 'Off'}
</div> </div>
{d.schedules_paused ? (
<div className="tile-meta" style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
<PauseCircle size={13} strokeWidth={1.75} />
<span>Schedule paused{d.paused_until ? ` until ${fmtTime(d.paused_until, data!.timezone)}` : ''}</span>
{canControl && <button className="btn btn-sm" onClick={() => act(`h${d.id}`, () => holdSchedules(d.id, false))}>Resume</button>}
</div>
) : canControl && data!.schedules_enabled && (
<select className="tile-select" value="" aria-label={`Pause schedule for ${d.name}`}
onChange={e => {
const v = e.target.value
if (v) act(`h${d.id}`, () => holdSchedules(d.id, true, v === 'forever' ? undefined : Number(v)))
}}>
<option value="">Schedule: auto</option>
<option value="1">Pause 1 hour</option>
<option value="4">Pause 4 hours</option>
<option value="24">Pause 24 hours</option>
<option value="forever">Pause until resumed</option>
</select>
)}
</div> </div>
) )
} }
@ -100,6 +119,7 @@ export default function Dashboard() {
{error && <div className="error-banner">{error}</div>} {error && <div className="error-banner">{error}</div>}
{msg && <div className="ok-banner">{msg}</div>} {msg && <div className="ok-banner">{msg}</div>}
{!data.mqtt_connected && <div className="warn-banner">The lighting controller can't reach the MQTT broker — switches below may be out of date and commands will fail.</div>} {!data.mqtt_connected && <div className="warn-banner">The lighting controller can't reach the MQTT broker — switches below may be out of date and commands will fail.</div>}
{!data.schedules_enabled && <div className="warn-banner">Schedules are switched off globally — lights only change when switched by hand. Turn them back on in Settings.</div>}
{!data.sun && <div className="warn-banner">No site location set, so sunrise/sunset schedules won't run. Add latitude/longitude in Settings.</div>} {!data.sun && <div className="warn-banner">No site location set, so sunrise/sunset schedules won't run. Add latitude/longitude in Settings.</div>}
{data.groups.length > 0 && ( {data.groups.length > 0 && (

View file

@ -99,6 +99,7 @@ export default function Schedules() {
{canEdit && <button className="btn btn-primary" onClick={openNew}><Plus size={14} strokeWidth={1.75} /> New schedule</button>} {canEdit && <button className="btn btn-primary" onClick={openNew}><Plus size={14} strokeWidth={1.75} /> New schedule</button>}
</div> </div>
{error && <div className="error-banner">{error}</div>} {error && <div className="error-banner">{error}</div>}
{state && !state.schedules_enabled && <div className="warn-banner">Schedules are switched off globally (Settings) — none of these will run.</div>}
{state && !state.sun && schedules.some(s => s.trigger_type !== 'fixed') && ( {state && !state.sun && schedules.some(s => s.trigger_type !== 'fixed') && (
<div className="warn-banner">Sunrise/sunset schedules won't run until a site location is set in Settings.</div> <div className="warn-banner">Sunrise/sunset schedules won't run until a site location is set in Settings.</div>
)} )}

View file

@ -65,6 +65,18 @@ export default function Settings() {
</div> </div>
</div> </div>
<div className="card" style={{ marginBottom: 16 }}>
<label className="field-check">
<input type="checkbox" checked={values.schedules_enabled !== 'false'}
onChange={e => setValues({ ...values, schedules_enabled: e.target.checked ? 'true' : 'false' })} />
<span><strong>Schedules enabled</strong></span>
</label>
<div className="field-hint">
Master switch for all automation. When off, nothing runs on a schedule and lights only change when switched by hand.
Turning it back on doesn't undo anything — use "Re-apply schedules" on the control screen to bring lights back in line.
</div>
</div>
{Object.entries(SETTING_LABELS).map(([key, meta]) => ( {Object.entries(SETTING_LABELS).map(([key, meta]) => (
<div className="field" key={key}> <div className="field" key={key}>
<label>{meta.label}</label> <label>{meta.label}</label>

View file

@ -0,0 +1,131 @@
import { useEffect, useState } from 'react'
import { ChevronLeft, ChevronRight, Sunrise, Sunset, PauseCircle } from 'lucide-react'
import { fetchPreview } from '../api'
import type { PreviewResponse, PreviewDevice, PreviewSegment } from '../types'
const TICKS = [0, 3, 6, 9, 12, 15, 18, 21, 24]
function hhmm(iso: string | null, tz: string) {
if (!iso) return '—'
return new Date(iso).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', timeZone: tz })
}
function duration(mins: number) {
const h = Math.floor(mins / 60), m = mins % 60
return h ? (m ? `${h}h ${m}m` : `${h}h`) : `${m}m`
}
function shiftDate(date: string, days: number) {
const d = new Date(`${date}T12:00:00Z`)
d.setUTCDate(d.getUTCDate() + days)
return d.toISOString().slice(0, 10)
}
export default function Timeline() {
const [date, setDate] = useState<string | undefined>(undefined)
const [data, setData] = useState<PreviewResponse | null>(null)
const [error, setError] = useState('')
const [now, setNow] = useState(() => Date.now())
useEffect(() => {
setError('')
fetchPreview(date).then(setData).catch(e => setError(e instanceof Error ? e.message : 'Failed to load'))
}, [date])
useEffect(() => {
const t = window.setInterval(() => setNow(Date.now()), 30000)
return () => window.clearInterval(t)
}, [])
if (!data) return <div className="page">{error ? <div className="error-banner">{error}</div> : <p className="muted">Loading…</p>}</div>
const tz = data.timezone
const first = data.devices[0]
// Position within the actual day, so a 23- or 25-hour DST day still fills the track.
const dayStart = first ? new Date(first.day_start).getTime() : 0
const dayEnd = first ? new Date(first.day_end).getTime() : 1
const pct = (iso: string | number) => Math.min(100, Math.max(0, ((new Date(iso).getTime() - dayStart) / (dayEnd - dayStart)) * 100))
const nowPct = now >= dayStart && now < dayEnd ? pct(now) : null
const sunMarks = [data.sun?.sunrise, data.sun?.sunset].filter((x): x is string => !!x)
const locations = [...new Set(data.devices.map(d => d.location || 'Other'))]
const segTitle = (s: PreviewSegment) =>
s.state ? `${s.state === 'on' ? 'On' : 'Off'} ${hhmm(s.from, tz)}–${hhmm(s.to, tz)} · ${s.schedule}` : `Not scheduled ${hhmm(s.from, tz)}–${hhmm(s.to, tz)}`
function Track({ d }: { d: PreviewDevice }) {
return (
<div className="tl-track">
{d.segments.map((s, i) => (
<div key={i} className={`tl-seg ${s.state ?? 'none'}`} title={segTitle(s)}
style={{ left: `${pct(s.from)}%`, width: `${pct(s.to) - pct(s.from)}%` }} />
))}
</div>
)
}
const Overlay = () => (
<div style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }}>
{sunMarks.map(m => <div key={m} className="tl-marker" style={{ left: `${pct(m)}%` }} />)}
{nowPct !== null && <div className="tl-marker now" style={{ left: `${nowPct}%` }} />}
</div>
)
return (
<div className="page">
<div className="page-header">
<h1>Timeline</h1>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<button className="btn btn-sm" onClick={() => setDate(shiftDate(data.date, -1))} aria-label="Previous day"><ChevronLeft size={14} strokeWidth={1.75} /></button>
<input type="date" value={data.date} onChange={e => e.target.value && setDate(e.target.value)}
style={{ padding: '4px 8px', border: '1px solid var(--card-border)', borderRadius: 8, background: 'var(--card-bg)', color: 'var(--text-dark)', fontFamily: 'inherit' }} />
<button className="btn btn-sm" onClick={() => setDate(shiftDate(data.date, 1))} aria-label="Next day"><ChevronRight size={14} strokeWidth={1.75} /></button>
<button className="btn btn-sm" onClick={() => setDate(undefined)}>Today</button>
</div>
<div className="sun-strip">
<span><Sunrise size={15} strokeWidth={1.75} /> {hhmm(data.sun?.sunrise ?? null, tz)}</span>
<span><Sunset size={15} strokeWidth={1.75} /> {hhmm(data.sun?.sunset ?? null, tz)}</span>
</div>
</div>
{error && <div className="error-banner">{error}</div>}
{!data.schedules_enabled && <div className="warn-banner">Schedules are switched off globally (Settings) — this shows what they would do, not what is happening.</div>}
{!data.sun && <div className="warn-banner">No site location set, so sunrise/sunset schedules aren't shown. Add latitude/longitude in Settings.</div>}
{data.devices.length === 0 ? <div className="empty-state">No lights set up yet.</div> : (
<div className="tl">
<div className="tl-inner">
<div className="tl-row tl-head">
<span />
<div className="tl-ticks">
{TICKS.map(h => <span key={h} style={{ left: `${(h / 24) * 100}%` }}>{String(h % 24).padStart(2, '0')}</span>)}
</div>
<span className="tl-total">On for</span>
</div>
{locations.map(loc => (
<div key={loc}>
<div className="tl-loc">{loc}</div>
{data.devices.filter(d => (d.location || 'Other') === loc).map(d => (
<div key={d.id} className={`tl-row${d.paused ? ' paused' : ''}`}>
<span className="tl-name" title={d.name}>
{d.paused && <PauseCircle size={13} strokeWidth={1.75} />}{d.name}
</span>
<div style={{ position: 'relative' }}><Track d={d} /><Overlay /></div>
<span className="tl-total">{d.scheduled ? duration(d.on_minutes) : '—'}</span>
</div>
))}
</div>
))}
</div>
</div>
)}
<div className="tl-legend">
<span><i style={{ background: 'var(--gold)' }} />On</span>
<span><i style={{ background: 'var(--card-border)' }} />Off</span>
<span><i style={{ background: 'repeating-linear-gradient(45deg, var(--body-bg), var(--body-bg) 2px, var(--card-border) 2px, var(--card-border) 3px)' }} />Not scheduled</span>
<span>Dashed lines: sunrise / sunset{nowPct !== null ? ' · red: now' : ''}</span>
<span><PauseCircle size={12} strokeWidth={1.75} style={{ verticalAlign: -2 }} /> schedule paused (shown faded)</span>
</div>
<p className="field-hint">Planned by the schedules only — manual and wall-switch changes aren't reflected. Hover a bar for the schedule behind it.</p>
</div>
)
}

View file

@ -11,6 +11,8 @@ export interface LightDevice {
state: PowerState | null state: PowerState | null
online: boolean | null online: boolean | null
state_at: string | null state_at: string | null
schedules_paused: boolean
paused_until: string | null
} }
export interface LightGroup { export interface LightGroup {
@ -30,6 +32,7 @@ export interface StateResponse {
mqtt_connected: boolean mqtt_connected: boolean
sun: SunTimes | null sun: SunTimes | null
timezone: string timezone: string
schedules_enabled: boolean
} }
export interface Schedule { export interface Schedule {
@ -94,3 +97,31 @@ export interface User {
export function can(user: User, cap: string): boolean { export function can(user: User, cap: string): boolean {
return user.is_admin || user.caps.includes(cap) return user.is_admin || user.caps.includes(cap)
} }
export interface PreviewSegment {
from: string
to: string
state: PowerState | null // null = nothing schedules this light
schedule: string | null
}
export interface PreviewDevice {
id: number
name: string
location: string | null
paused: boolean
scheduled: boolean
on_minutes: number
events: { at: string; action: PowerState; schedule: string }[]
segments: PreviewSegment[]
day_start: string
day_end: string
}
export interface PreviewResponse {
date: string
timezone: string
schedules_enabled: boolean
sun: SunTimes | null
devices: PreviewDevice[]
}