=> 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 &&
act(`h${d.id}`, () => holdSchedules(d.id, false))}>Resume }
+
+ ) : canControl && data!.schedules_enabled && (
+ {
+ const v = e.target.value
+ if (v) act(`h${d.id}`, () => holdSchedules(d.id, true, v === 'forever' ? undefined : Number(v)))
+ }}>
+ Schedule: auto
+ Pause 1 hour
+ Pause 4 hours
+ Pause 24 hours
+ Pause until resumed
+
+ )}
)
}
@@ -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 && New schedule }
{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() {
+
+
+ setValues({ ...values, schedules_enabled: e.target.checked ? 'true' : 'false' })} />
+ Schedules enabled
+
+
+ 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]) => (
{meta.label}
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
+
+ setDate(shiftDate(data.date, -1))} aria-label="Previous day">
+ 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' }} />
+ setDate(shiftDate(data.date, 1))} aria-label="Next day">
+ setDate(undefined)}>Today
+
+
+ {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[]
+}