Skip to content

Commit 0727378

Browse files
author
=
committed
Merge main into feature/modern-web-ui
2 parents a9aa83f + e974458 commit 0727378

10 files changed

Lines changed: 301 additions & 29 deletions

File tree

‎apps/predbat/frontend.zip‎

558 Bytes
Binary file not shown.

‎frontend/src/components/PlanTable.css‎

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -724,12 +724,14 @@
724724
position: relative;
725725
}
726726

727-
.plan-rate-control {
727+
.plan-rate-control,
728+
.plan-load-control {
728729
position: relative;
729730
display: inline-block;
730731
}
731732

732-
.plan-rate-button {
733+
.plan-rate-button,
734+
.plan-load-button {
733735
display: inline-flex;
734736
align-items: center;
735737
gap: 0.3rem;
@@ -744,11 +746,14 @@
744746
}
745747

746748
.plan-rate-button:hover,
747-
.plan-rate-button:focus-visible {
749+
.plan-rate-button:focus-visible,
750+
.plan-load-button:hover,
751+
.plan-load-button:focus-visible {
748752
background: color-mix(in srgb, var(--color-text) 6%, transparent);
749753
}
750754

751-
.plan-rate-button.is-manual {
755+
.plan-rate-button.is-manual,
756+
.plan-load-button.is-manual {
752757
border-color: color-mix(in srgb, var(--color-text-muted) 35%, transparent);
753758
}
754759

@@ -946,10 +951,11 @@
946951

947952
.plan-cell-popover {
948953
position: fixed;
949-
inset: auto 0 0;
954+
inset: auto 0 var(--plan-mobile-keyboard-offset, 0px);
950955
z-index: 110;
951956
width: auto;
952957
max-width: none;
958+
max-height: calc(100dvh - var(--plan-mobile-keyboard-offset, 0px));
953959
padding: 0.8rem;
954960
padding-bottom: max(0.8rem, env(safe-area-inset-bottom));
955961
border-radius: 1rem 1rem 0 0;

‎frontend/src/components/PlanTable.tsx‎

Lines changed: 234 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -480,6 +480,179 @@ function RateCell({
480480
)
481481
}
482482

483+
type LoadCellProps = {
484+
row: PlanRow
485+
overrides: PlanOverrides
486+
isPast: boolean
487+
debugEnabled: boolean
488+
open: boolean
489+
onToggle: () => void
490+
onClose: () => void
491+
onOverrideSubmitted: () => void
492+
}
493+
494+
function LoadCell({
495+
row,
496+
overrides,
497+
isPast,
498+
debugEnabled,
499+
open,
500+
onToggle,
501+
onClose,
502+
onOverrideSubmitted
503+
}: LoadCellProps) {
504+
const override = (overrides.manual_load_adjust ?? []).find((item) => item.minutes === row.slot_minute) ?? null
505+
const [value, setValue] = useState('')
506+
const [saving, setSaving] = useState(false)
507+
const [error, setError] = useState<string | null>(null)
508+
const controlRef = useRef<HTMLDivElement | null>(null)
509+
510+
useEffect(() => {
511+
if (!open) {
512+
return
513+
}
514+
515+
function handlePointerDown(event: PointerEvent) {
516+
if (controlRef.current && !controlRef.current.contains(event.target as Node)) {
517+
onClose()
518+
}
519+
}
520+
521+
function handleKeyDown(event: KeyboardEvent) {
522+
if (event.key === 'Escape') {
523+
onClose()
524+
}
525+
}
526+
527+
document.addEventListener('pointerdown', handlePointerDown)
528+
document.addEventListener('keydown', handleKeyDown)
529+
530+
return () => {
531+
document.removeEventListener('pointerdown', handlePointerDown)
532+
document.removeEventListener('keydown', handleKeyDown)
533+
}
534+
}, [open, onClose])
535+
536+
function openEditor() {
537+
if (isPast) {
538+
return
539+
}
540+
541+
if (!open) {
542+
setValue(String(override?.adjustment ?? row.load_forecast))
543+
setError(null)
544+
}
545+
546+
onToggle()
547+
}
548+
549+
async function sendOverride(action: 'Set Load' | 'Clear Load', adjustment: number) {
550+
setSaving(true)
551+
setError(null)
552+
553+
const formData = new FormData()
554+
formData.append('time', formatOverrideTime(row.time))
555+
formData.append('action', action)
556+
formData.append('rate', String(adjustment))
557+
558+
try {
559+
const response = await fetch('./rate_override', {
560+
method: 'POST',
561+
body: formData
562+
})
563+
const result = (await response.json()) as { success?: boolean; message?: string }
564+
565+
if (!response.ok || result.success === false) {
566+
throw new Error(result.message || `HTTP ${response.status}`)
567+
}
568+
569+
onClose()
570+
onOverrideSubmitted()
571+
} catch (error) {
572+
const message = error instanceof Error ? error.message : 'Unknown error'
573+
setError(`Unable to update load forecast: ${message}`)
574+
} finally {
575+
setSaving(false)
576+
}
577+
}
578+
579+
async function setLoad(event: React.FormEvent) {
580+
event.preventDefault()
581+
const adjustment = Number(value)
582+
583+
if (!Number.isFinite(adjustment)) {
584+
setError('Enter a valid load forecast.')
585+
return
586+
}
587+
588+
await sendOverride('Set Load', adjustment)
589+
}
590+
591+
return (
592+
<td
593+
className={['plan-number', 'plan-load', row.load_color ? 'has-plan-cell-colour' : ''].filter(Boolean).join(' ')}
594+
style={cellColour(row.load_color)}
595+
>
596+
<div ref={controlRef} className="plan-load-control">
597+
<button
598+
type="button"
599+
className={['plan-load-button', override ? 'is-manual' : ''].filter(Boolean).join(' ')}
600+
onClick={openEditor}
601+
aria-disabled={isPast}
602+
aria-label={override ? `Load forecast ${row.load_forecast.toFixed(2)} kWh, manually overridden` : `Load forecast ${row.load_forecast.toFixed(2)} kWh`}
603+
>
604+
<span className="plan-value-with-indicator">
605+
{row.load_forecast > 0 && row.load_color && (
606+
<span className="plan-value-indicator" style={{ backgroundColor: row.load_color }} aria-hidden="true" />
607+
)}
608+
<span>{row.load_forecast.toFixed(2)}</span>
609+
</span>
610+
611+
{override && <span className="plan-rate-manual-dot" title="Manual load override" aria-hidden="true" />}
612+
</button>
613+
614+
{debugEnabled && (row.load_forecast10 ?? 0) > 0 && (
615+
<small className="plan-debug-value">({row.load_forecast10?.toFixed(2)})</small>
616+
)}
617+
618+
{open && !isPast && (
619+
<form className="plan-cell-popover" onSubmit={setLoad}>
620+
<div className="plan-cell-popover-title">{formatTime(row.time)} load override</div>
621+
622+
<label>
623+
<span>Load forecast</span>
624+
625+
<div className="plan-rate-input">
626+
<input
627+
type="number"
628+
step="0.01"
629+
inputMode="decimal"
630+
value={value}
631+
onChange={(event) => setValue(event.target.value)}
632+
autoFocus
633+
disabled={saving}
634+
/>
635+
636+
<span>kWh</span>
637+
</div>
638+
</label>
639+
640+
<button type="submit" disabled={saving}>{saving ? 'Saving…' : 'Set override'}</button>
641+
642+
{override && (
643+
<button type="button" className="plan-rate-clear" disabled={saving} onClick={() => void sendOverride('Clear Load', override.adjustment)}>
644+
Use Predbat forecast
645+
</button>
646+
)}
647+
648+
{error && <div className="plan-rate-error" role="alert">{error}</div>}
649+
</form>
650+
)}
651+
</div>
652+
</td>
653+
)
654+
}
655+
483656
type TargetCellProps = {
484657
row: PlanRow
485658
overrides: PlanOverrides
@@ -1136,6 +1309,32 @@ export default function PlanTable({
11361309

11371310
const [openCellEditor, setOpenCellEditor] = useState<string | null>(null)
11381311

1312+
useEffect(() => {
1313+
const visualViewport = window.visualViewport
1314+
1315+
if (!visualViewport) {
1316+
return
1317+
}
1318+
1319+
const viewport = visualViewport as VisualViewport
1320+
1321+
function updateKeyboardOffset() {
1322+
const offset = Math.max(0, window.innerHeight - viewport.height - viewport.offsetTop)
1323+
1324+
document.documentElement.style.setProperty('--plan-mobile-keyboard-offset', `${offset}px`)
1325+
}
1326+
1327+
updateKeyboardOffset()
1328+
viewport.addEventListener('resize', updateKeyboardOffset)
1329+
viewport.addEventListener('scroll', updateKeyboardOffset)
1330+
1331+
return () => {
1332+
viewport.removeEventListener('resize', updateKeyboardOffset)
1333+
viewport.removeEventListener('scroll', updateKeyboardOffset)
1334+
document.documentElement.style.removeProperty('--plan-mobile-keyboard-offset')
1335+
}
1336+
}, [])
1337+
11391338
function toggleCellEditor(key: string) {
11401339
setOpenOverrideTime(null)
11411340
setOverrideError(null)
@@ -1152,11 +1351,19 @@ export default function PlanTable({
11521351
return
11531352
}
11541353

1155-
const viewportWidth = window.visualViewport?.width ?? window.innerWidth
1354+
// Measure from the unshifted position. Otherwise, a later measurement
1355+
// treats the previous correction as the starting point and can move the
1356+
// tooltip back outside the viewport.
1357+
element.style.removeProperty(shiftProperty)
1358+
1359+
const viewport = window.visualViewport
1360+
const viewportLeft = viewport?.offsetLeft ?? 0
1361+
const viewportWidth = viewport?.width ?? window.innerWidth
11561362
const bounds = element.getBoundingClientRect()
11571363
const safeInset = 8
1158-
const maximumLeft = Math.max(safeInset, viewportWidth - bounds.width - safeInset)
1159-
const constrainedLeft = Math.min(maximumLeft, Math.max(safeInset, bounds.left))
1364+
const minimumLeft = viewportLeft + safeInset
1365+
const maximumLeft = Math.max(minimumLeft, viewportLeft + viewportWidth - bounds.width - safeInset)
1366+
const constrainedLeft = Math.min(maximumLeft, Math.max(minimumLeft, bounds.left))
11601367

11611368
element.style.setProperty(shiftProperty, `${constrainedLeft - bounds.left}px`)
11621369
})
@@ -1170,6 +1377,10 @@ export default function PlanTable({
11701377
constrainFloatingElement(tooltip, '--plan-action-tooltip-shift')
11711378
}
11721379

1380+
function constrainActionTooltipForControl(control: HTMLElement) {
1381+
constrainActionTooltip(control.querySelector<HTMLSpanElement>('.plan-action-tooltip'))
1382+
}
1383+
11731384
const [colourStyle, setColourStyle] = useState<'dots' | 'cells'>(() => {
11741385
try {
11751386
return localStorage.getItem('predbat-plan-colour-style') === 'cells' ? 'cells' : 'dots'
@@ -1517,6 +1728,12 @@ export default function PlanTable({
15171728
className={['plan-action-control', menuOpen ? 'is-open' : '']
15181729
.filter(Boolean)
15191730
.join(' ')}
1731+
onPointerEnter={(event) => {
1732+
constrainActionTooltipForControl(event.currentTarget)
1733+
}}
1734+
onFocusCapture={(event) => {
1735+
constrainActionTooltipForControl(event.currentTarget)
1736+
}}
15201737
onPointerDown={(event) => {
15211738
event.stopPropagation()
15221739
}}
@@ -1725,26 +1942,20 @@ export default function PlanTable({
17251942
)}
17261943
</td>
17271944

1728-
<td
1729-
className={['plan-number', 'plan-load', row.load_color ? 'has-plan-cell-colour' : ''].filter(Boolean).join(' ')}
1730-
style={cellColour(row.load_color)}
1731-
>
1732-
<span className="plan-value-with-indicator">
1733-
{row.load_forecast > 0 && row.load_color && (
1734-
<span
1735-
className="plan-value-indicator"
1736-
style={{ backgroundColor: row.load_color }}
1737-
aria-hidden="true"
1738-
/>
1739-
)}
1740-
<span>{row.load_forecast.toFixed(2)}</span>
1741-
</span>
1742-
{debugEnabled && (row.load_forecast10 ?? 0) > 0 && (
1743-
<small className="plan-debug-value">
1744-
({row.load_forecast10?.toFixed(2)})
1745-
</small>
1746-
)}
1747-
</td>
1945+
<LoadCell
1946+
row={row}
1947+
overrides={overrides}
1948+
isPast={isPast}
1949+
debugEnabled={debugEnabled}
1950+
onOverrideSubmitted={onOverrideSubmitted}
1951+
open={openCellEditor === `load:${row.time}`}
1952+
onToggle={() => {
1953+
toggleCellEditor(`load:${row.time}`)
1954+
}}
1955+
onClose={() => {
1956+
setOpenCellEditor(null)
1957+
}}
1958+
/>
17481959

17491960
{debugEnabled && (
17501961
<td className="plan-number plan-debug-column">

‎frontend/src/components/PlanVisual.css‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,10 @@
168168
font-variant-numeric: tabular-nums;
169169
}
170170

171+
.plan-timeline-detail-value-solar strong {
172+
color: var(--color-flow-solar);
173+
}
174+
171175
.plan-timeline-explanation {
172176
margin: 0.7rem 0 0;
173177
padding-top: 0.65rem;

0 commit comments

Comments
 (0)