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
|
|
@ -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