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:
parent
371582cbd6
commit
d33b2fe11d
13 changed files with 368 additions and 12 deletions
|
|
@ -23,6 +23,11 @@ export async function initDb() {
|
|||
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 (
|
||||
id SERIAL PRIMARY KEY,
|
||||
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])
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,19 +9,40 @@ import { pool, getSettings } from '../db.js'
|
|||
import { getDevice, isConnected, setOnlineHandler } from './mqtt.js'
|
||||
import { setPower, setPowerMany, resolveTargetDevices } from './control.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 FIRE_GRACE_MS = 15 * 60 * 1000 // older misses are left to reconcile
|
||||
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) {
|
||||
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`,
|
||||
[s._today, s.id]
|
||||
)
|
||||
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()}`)
|
||||
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('; '))
|
||||
|
|
@ -30,6 +51,8 @@ async function fire(s, trigger) {
|
|||
export async function tick() {
|
||||
if (!isConnected()) return
|
||||
const settings = await getSettings()
|
||||
await expirePauses()
|
||||
if (!schedulesOn(settings)) return
|
||||
const tz = settings.timezone || 'Europe/London'
|
||||
const now = new Date()
|
||||
const today = localParts(now, tz)
|
||||
|
|
@ -78,12 +101,14 @@ async function schedulesFor(deviceId) {
|
|||
export async function reconcile(deviceIds = null) {
|
||||
if (!isConnected()) return { applied: 0 }
|
||||
const settings = await getSettings()
|
||||
if (!schedulesOn(settings)) return { applied: 0 }
|
||||
const now = new Date()
|
||||
let ids = deviceIds
|
||||
if (!ids) {
|
||||
const { rows } = await pool.query('SELECT id FROM light_devices WHERE active = TRUE')
|
||||
ids = rows.map(r => r.id)
|
||||
}
|
||||
ids = await unpaused(ids)
|
||||
let applied = 0
|
||||
for (const id of ids) {
|
||||
const device = getDevice(id)
|
||||
|
|
@ -121,3 +146,61 @@ export function nextRun(s, settings, now = new Date()) {
|
|||
}
|
||||
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 }))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,14 +2,16 @@ import { requireAuth, requireCap } from '../auth.js'
|
|||
import { pool, getSettings } from '../db.js'
|
||||
import { refreshDevices, listDiscovered, isConnected } from '../lib/mqtt.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'
|
||||
|
||||
const TOPIC_RE = /^[A-Za-z0-9_.-]+$/
|
||||
|
||||
async function loadState() {
|
||||
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(
|
||||
`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 tz = settings.timezone || 'Europe/London'
|
||||
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 ──
|
||||
|
|
@ -49,6 +51,25 @@ export async function deviceRoutes(app) {
|
|||
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) => {
|
||||
if (!isConnected()) return reply.status(503).send({ error: 'MQTT broker not connected' })
|
||||
return reconcile()
|
||||
|
|
@ -143,6 +164,23 @@ export async function deviceRoutes(app) {
|
|||
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 ──
|
||||
app.get('/api/events', { preHandler: requireCap('view') }, async (req) => {
|
||||
const limit = Math.min(Number(req.query.limit) || 100, 500)
|
||||
|
|
|
|||
|
|
@ -4,8 +4,9 @@ import { isConnected } from '../lib/mqtt.js'
|
|||
import { validTimezone } from '../lib/time.js'
|
||||
|
||||
// Standard app_settings pattern (see conventions doc). Latitude/longitude drive
|
||||
// the sunrise/sunset triggers; timezone decides what "18:00" and "Mon-Fri" mean.
|
||||
const ALLOWED_KEYS = new Set(['latitude', 'longitude', 'timezone'])
|
||||
// the sunrise/sunset triggers; timezone decides what "18:00" and "Mon-Fri" mean;
|
||||
// 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) {
|
||||
app.addHook('preHandler', requireAuth)
|
||||
|
|
@ -23,6 +24,7 @@ export async function settingsRoutes(app) {
|
|||
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 === '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}"` })
|
||||
await pool.query(
|
||||
`INSERT INTO app_settings (key, value, updated_at) VALUES ($1, $2, NOW())
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import IosInstallHint from './components/IosInstallHint'
|
|||
import Layout from './components/Layout'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import Schedules from './pages/Schedules'
|
||||
import Timeline from './pages/Timeline'
|
||||
import Devices from './pages/Devices'
|
||||
import Log from './pages/Log'
|
||||
import Settings from './pages/Settings'
|
||||
|
|
@ -27,6 +28,7 @@ export default function App() {
|
|||
<Route path="/" element={<Home />} />
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/schedules" element={<Schedules />} />
|
||||
<Route path="/timeline" element={<Timeline />} />
|
||||
<Route path="/devices" element={<Devices />} />
|
||||
<Route path="/log" element={<Log />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type {
|
||||
StateResponse, LightDevice, LightGroup, Schedule, ScheduleInput, DiscoveredDevice, LightEvent, AppSetting, PowerState,
|
||||
StateResponse, LightDevice, LightGroup, Schedule, ScheduleInput, DiscoveredDevice, LightEvent, AppSetting, PowerState, PreviewResponse,
|
||||
} from './types'
|
||||
|
||||
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 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 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' })
|
||||
|
||||
// Devices
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
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 { can } from '../types'
|
||||
|
||||
|
|
@ -9,6 +9,7 @@ const ICON_PROPS = { size: 16, strokeWidth: 1.75 }
|
|||
const NAV = [
|
||||
{ to: '/dashboard', label: 'Control', icon: LayoutGrid, 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: '/log', label: 'Activity', icon: History, cap: 'view' },
|
||||
{ to: '/settings', label: 'Settings', icon: Settings, cap: 'settings' },
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
.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; }
|
||||
.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; } }
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
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 { fetchState, setDevicePower, setGroupPower, reapplySchedules } from '../api'
|
||||
import { fetchState, setDevicePower, setGroupPower, reapplySchedules, holdSchedules } from '../api'
|
||||
import type { StateResponse, LightDevice, PowerState } from '../types'
|
||||
import { can } from '../types'
|
||||
|
||||
|
|
@ -73,6 +73,25 @@ export default function Dashboard() {
|
|||
<div className="tile-meta">
|
||||
{offline ? <><WifiOff size={11} strokeWidth={1.75} /> Offline</> : d.state === null ? 'State unknown' : on ? 'On' : 'Off'}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
|
@ -100,6 +119,7 @@ export default function Dashboard() {
|
|||
{error && <div className="error-banner">{error}</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.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.groups.length > 0 && (
|
||||
|
|
|
|||
|
|
@ -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>}
|
||||
</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') && (
|
||||
<div className="warn-banner">Sunrise/sunset schedules won't run until a site location is set in Settings.</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -65,6 +65,18 @@ export default function Settings() {
|
|||
</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]) => (
|
||||
<div className="field" key={key}>
|
||||
<label>{meta.label}</label>
|
||||
|
|
|
|||
131
frontend/src/pages/Timeline.tsx
Normal file
131
frontend/src/pages/Timeline.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -11,6 +11,8 @@ export interface LightDevice {
|
|||
state: PowerState | null
|
||||
online: boolean | null
|
||||
state_at: string | null
|
||||
schedules_paused: boolean
|
||||
paused_until: string | null
|
||||
}
|
||||
|
||||
export interface LightGroup {
|
||||
|
|
@ -30,6 +32,7 @@ export interface StateResponse {
|
|||
mqtt_connected: boolean
|
||||
sun: SunTimes | null
|
||||
timezone: string
|
||||
schedules_enabled: boolean
|
||||
}
|
||||
|
||||
export interface Schedule {
|
||||
|
|
@ -94,3 +97,31 @@ export interface User {
|
|||
export function can(user: User, cap: string): boolean {
|
||||
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[]
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue