@@ -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+
483656type 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" >
0 commit comments