From d33b2fe11d01c9f75abb20f545a299652e1c7e44 Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Wed, 14 Oct 2026 12:40:45 +0000 Subject: [PATCH] Per-light schedule pause, global schedules switch, Timeline preview page Co-Authored-By: Claude Sonnet 5.5 --- backend/src/db.js | 7 +- backend/src/lib/engine.js | 87 ++++++++++++++++++- backend/src/routes/devices.js | 44 +++++++++- backend/src/routes/settings.js | 6 +- frontend/src/App.tsx | 2 + frontend/src/api.ts | 5 +- frontend/src/components/Layout.tsx | 3 +- frontend/src/index.css | 27 ++++++ frontend/src/pages/Dashboard.tsx | 24 +++++- frontend/src/pages/Schedules.tsx | 1 + frontend/src/pages/Settings.tsx | 12 +++ frontend/src/pages/Timeline.tsx | 131 +++++++++++++++++++++++++++++ frontend/src/types.ts | 31 +++++++ 13 files changed, 368 insertions(+), 12 deletions(-) create mode 100644 frontend/src/pages/Timeline.tsx diff --git a/backend/src/db.js b/backend/src/db.js index 9853a4b..da15b2b 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -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]) } } diff --git a/backend/src/lib/engine.js b/backend/src/lib/engine.js index 4279057..c0e307a 100644 --- a/backend/src/lib/engine.js +++ b/backend/src/lib/engine.js @@ -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 })) +} diff --git a/backend/src/routes/devices.js b/backend/src/routes/devices.js index e9be23f..dc37c81 100644 --- a/backend/src/routes/devices.js +++ b/backend/src/routes/devices.js @@ -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) diff --git a/backend/src/routes/settings.js b/backend/src/routes/settings.js index becfa07..540db25 100644 --- a/backend/src/routes/settings.js +++ b/backend/src/routes/settings.js @@ -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()) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b9102c2..3dd9b47 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 7faf6c3..3014fee 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -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 => 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 => request(`/preview${date ? `?date=${date}` : ''}`) export const reapplySchedules = (): Promise<{ applied: number }> => request('/reconcile', { method: 'POST' }) // Devices diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 03041e3..94bfa21 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -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' }, diff --git a/frontend/src/index.css b/frontend/src/index.css index 4fd03f0..daf000d 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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; } } diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 3fc94e0..a57aa41 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -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() {
{offline ? <> Offline : d.state === null ? 'State unknown' : on ? 'On' : 'Off'}
+ {d.schedules_paused ? ( +
+ + Schedule paused{d.paused_until ? ` until ${fmtTime(d.paused_until, data!.timezone)}` : ''} + {canControl && } +
+ ) : canControl && data!.schedules_enabled && ( + + )} ) } @@ -100,6 +119,7 @@ export default function Dashboard() { {error &&
{error}
} {msg &&
{msg}
} {!data.mqtt_connected &&
The lighting controller can't reach the MQTT broker — switches below may be out of date and commands will fail.
} + {!data.schedules_enabled &&
Schedules are switched off globally — lights only change when switched by hand. Turn them back on in Settings.
} {!data.sun &&
No site location set, so sunrise/sunset schedules won't run. Add latitude/longitude in Settings.
} {data.groups.length > 0 && ( diff --git a/frontend/src/pages/Schedules.tsx b/frontend/src/pages/Schedules.tsx index 2f96060..4e880c9 100644 --- a/frontend/src/pages/Schedules.tsx +++ b/frontend/src/pages/Schedules.tsx @@ -99,6 +99,7 @@ export default function Schedules() { {canEdit && } {error &&
{error}
} + {state && !state.schedules_enabled &&
Schedules are switched off globally (Settings) — none of these will run.
} {state && !state.sun && schedules.some(s => s.trigger_type !== 'fixed') && (
Sunrise/sunset schedules won't run until a site location is set in Settings.
)} diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index b0494db..2e16a6b 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -65,6 +65,18 @@ export default function Settings() { +
+ +
+ 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. +
+
+ {Object.entries(SETTING_LABELS).map(([key, meta]) => (
diff --git a/frontend/src/pages/Timeline.tsx b/frontend/src/pages/Timeline.tsx new file mode 100644 index 0000000..5b66751 --- /dev/null +++ b/frontend/src/pages/Timeline.tsx @@ -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(undefined) + const [data, setData] = useState(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
{error ?
{error}
:

Loading…

}
+ + 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 ( +
+ {d.segments.map((s, i) => ( +
+ ))} +
+ ) + } + + const Overlay = () => ( +
+ {sunMarks.map(m =>
)} + {nowPct !== null &&
} +
+ ) + + return ( +
+
+

Timeline

+
+ + 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' }} /> + + +
+
+ {hhmm(data.sun?.sunrise ?? null, tz)} + {hhmm(data.sun?.sunset ?? null, tz)} +
+
+ + {error &&
{error}
} + {!data.schedules_enabled &&
Schedules are switched off globally (Settings) — this shows what they would do, not what is happening.
} + {!data.sun &&
No site location set, so sunrise/sunset schedules aren't shown. Add latitude/longitude in Settings.
} + + {data.devices.length === 0 ?
No lights set up yet.
: ( +
+
+
+ +
+ {TICKS.map(h => {String(h % 24).padStart(2, '0')})} +
+ On for +
+ {locations.map(loc => ( +
+
{loc}
+ {data.devices.filter(d => (d.location || 'Other') === loc).map(d => ( +
+ + {d.paused && }{d.name} + +
+ {d.scheduled ? duration(d.on_minutes) : '—'} +
+ ))} +
+ ))} +
+
+ )} + +
+ On + Off + Not scheduled + Dashed lines: sunrise / sunset{nowPct !== null ? ' · red: now' : ''} + schedule paused (shown faded) +
+

Planned by the schedules only — manual and wall-switch changes aren't reflected. Hover a bar for the schedule behind it.

+
+ ) +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 5a75a82..9346b92 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -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[] +}