Skip to content

Commit 21359c1

Browse files
author
=
committed
Show split plan actions and document frontend workflow
1 parent 2f47a0f commit 21359c1

10 files changed

Lines changed: 283 additions & 5 deletions

File tree

‎apps/predbat/frontend.zip‎

188 Bytes
Binary file not shown.

‎frontend/README.md‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# Predbat Modern UI
2+
3+
The Modern UI is a React and TypeScript application built with Vite. It is
4+
served by Predbat either from the development server or from the bundled
5+
`apps/predbat/frontend.zip` archive.
6+
7+
## Start developing
8+
9+
```bash
10+
cd frontend
11+
npm install
12+
npm run dev
13+
```
14+
15+
Open the address printed by Vite. Run Predbat locally as well when testing
16+
live data; the frontend reads its APIs relative to the current Predbat URL, so
17+
the same build works through Home Assistant ingress.
18+
19+
## Check a change
20+
21+
```bash
22+
npm test
23+
npm run lint
24+
npm run build
25+
```
26+
27+
`npm run bundle` runs the production build and packages `dist/` as
28+
`../apps/predbat/frontend.zip`. Run it before testing a change in Home
29+
Assistant or opening a pull request that changes frontend behaviour.
30+
31+
## Useful project locations
32+
33+
| Location | Purpose |
34+
| --- | --- |
35+
| `src/pages/` | Full-page views and route-level UI |
36+
| `src/components/` | Reusable UI components and their CSS |
37+
| `src/types/` | TypeScript contracts for Predbat API data |
38+
| `src/utils/` | Formatting and data transformation helpers |
39+
| `tests/` | Node-based component and utility tests |
40+
| `tests/fixtures/` | Anonymised API examples for repeatable tests |
41+
| `docs/` | Frontend API and design conventions |
42+
43+
## Working conventions
44+
45+
- Keep API paths relative, for example `./api/plan_data`, so Home Assistant
46+
ingress continues to work.
47+
- Add or update a fixture when an API field has important display behaviour.
48+
- Prefer a focused regression test for a reported UI problem.
49+
- Do not edit the generated `dist/` directory directly.
50+
- Read [the API contract](docs/api-contract.md) and
51+
[UI conventions](docs/ui-conventions.md) before changing a page.

‎frontend/docs/api-contract.md‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# Modern UI API contract
2+
3+
The frontend calls Predbat using relative URLs. Do not turn these into root
4+
URLs: Home Assistant ingress mounts Predbat below a changing path.
5+
6+
This document records the fields whose meaning is easy to misread. It is not a
7+
replacement for the backend implementation; update it with a fixture and a
8+
test when a new field affects the UI.
9+
10+
## `GET ./api/plan_data`
11+
12+
Returns the active plan, previous-day plan, baseline plan, and manual
13+
overrides. The current plan is in `plan.rows`.
14+
15+
### Important row fields
16+
17+
| Field | Meaning |
18+
| --- | --- |
19+
| `time` | ISO timestamp for the start of the half-hour slot. |
20+
| `slot_minute` | Minute index used by manual overrides. |
21+
| `state` | The final action for the slot. This alone is not sufficient for split actions. |
22+
| `state_text` | First legacy display action, supplied for a split charge/export slot. |
23+
| `state2_text` | Second legacy display action. When both display fields describe actions, render both. |
24+
| `state_target` | Planned battery target for the action. |
25+
| `reasons` | Structured explanation data rendered in action tooltips. |
26+
| `import_rate`, `export_rate` | Base tariff rates in the configured minor currency unit. |
27+
| `*_rate_adjusted` | Rate after Predbat losses or an event adjustment. |
28+
| `*_rate_adjust_type` | Includes `future` for a predicted rate and `saving` for an energy event. |
29+
| `rate_event_type` | Event type used for the plan badge, such as `octopus_power_down` or `octopus_happy_hour`. |
30+
31+
### Split charge and export actions
32+
33+
Predbat can charge and export in one half-hour slot. In that case `state` is
34+
normally the final export state, while `state_text` and `state2_text` preserve
35+
both display actions. The Modern Plan table must show both actions rather than
36+
using `state` alone.
37+
38+
Use [the split-action fixture](../tests/fixtures/plan-data-split-actions.json)
39+
when changing this behaviour.
40+
41+
## Other frequently used endpoints
42+
43+
| Endpoint | Used for |
44+
| --- | --- |
45+
| `GET ./api/status` | Predbat status, calculation state and update state. |
46+
| `GET ./api/power_flow` | Live power-flow and overview data. |
47+
| `GET ./api/chart_data?chart=<name>` | Chart series. |
48+
| `GET/POST ./api/config` | User-facing configuration page. |
49+
| `GET/POST ./api/apps_yaml` | Raw `apps.yaml` editor. |
50+
| `GET ./api/apps_schema` | Editor completion, validation and hover descriptions. |
51+
| `GET ./api/entities?all=1` | Home Assistant entity completion. |

‎frontend/docs/ui-conventions.md‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# Modern UI conventions
2+
3+
## Meaningful colours
4+
5+
- **Blue**: battery charging and charge actions.
6+
- **Purple**: export power and export actions.
7+
- **Yellow**: solar generation and solar forecast.
8+
- **Green**: normal live demand, completed/healthy state, and enabled controls.
9+
- **Muted grey**: inactive, unavailable, or zero-power values.
10+
11+
Use the existing CSS custom properties instead of introducing page-specific
12+
colour values.
13+
14+
## Plan table
15+
16+
- The table remains horizontally scrollable on narrow screens; do not clip
17+
cells or hide data to force it to fit.
18+
- Use compact column labels on mobile.
19+
- Actions remain legible pills. A split slot displays one pill for each planned
20+
action.
21+
- Tooltips explain an action or event without duplicating visible labels.
22+
- Keep manual-override controls available on future slots only.
23+
24+
## Responsive layout
25+
26+
- Pages must not create horizontal scrolling on the document itself. Any wide
27+
content belongs in a deliberately scrollable container.
28+
- Cards in a group should share a width and align their primary values.
29+
- Avoid fixed widths where a grid or flex layout can adapt to Home Assistant
30+
panels, desktop windows, and mobile screens.
31+
32+
## Accessibility and interaction
33+
34+
- Every icon-only control needs an accessible name or visible tooltip.
35+
- Keep visible keyboard focus states.
36+
- Do not rely on colour alone to convey a planned action or warning.
37+
- Respect the existing light, dark and automatic theme settings.

‎frontend/src/components/PlanTable.css‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -230,7 +230,8 @@
230230
/*
231231
* Detailed action badges.
232232
*/
233-
.plan-action {
233+
.plan-action,
234+
.plan-action-badge {
234235
display: inline-flex;
235236
align-items: center;
236237
gap: 0.35rem;
@@ -242,6 +243,19 @@
242243
cursor: help;
243244
}
244245

246+
/* A split Predbat slot remains one editable control, with one badge per action. */
247+
.plan-action-multiple {
248+
gap: 0.25rem;
249+
padding: 0.1rem 0;
250+
background: transparent;
251+
}
252+
253+
.plan-action-badges {
254+
display: inline-flex;
255+
align-items: center;
256+
gap: 0.2rem;
257+
}
258+
245259
/*
246260
* Active charge.
247261
*/

‎frontend/src/components/PlanTable.tsx‎

Lines changed: 57 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -750,6 +750,38 @@ function getActionType(state: string): ActionType {
750750
}
751751
}
752752

753+
/*
754+
* A charge and export plan can share one half-hour slot. Predbat supplies
755+
* the two display states separately for the legacy table; its `state` field
756+
* contains only the final action, so use both labels when they are present.
757+
*/
758+
function getActionTypeFromDisplayState(state?: string | null): ActionType | null {
759+
if (!state) {
760+
return null
761+
}
762+
763+
if (state.includes('FrzChrg')) return 'freeze-charge'
764+
if (state.includes('HoldChrg')) return 'hold-charge'
765+
if (state.includes('Chrg')) return 'charge'
766+
if (state.includes('FrzExp')) return 'freeze-export'
767+
if (state.includes('HoldExp')) return 'hold-export'
768+
if (state.includes('Exp')) return 'export'
769+
770+
return null
771+
}
772+
773+
function getPlannedActions(row: PlanRow): ActionType[] {
774+
const splitActions = [row.state_text, row.state2_text]
775+
.map(getActionTypeFromDisplayState)
776+
.filter((action): action is ActionType => action !== null)
777+
778+
if (splitActions.length > 1) {
779+
return [...new Set(splitActions)]
780+
}
781+
782+
return [getActionType(row.state)]
783+
}
784+
753785
/*
754786
* Friendly action label.
755787
*/
@@ -1326,7 +1358,11 @@ export default function PlanTable({
13261358

13271359
const newDay = !previousRow || getDateKey(previousRow.time) !== getDateKey(row.time)
13281360

1329-
const action = getActionType(row.state)
1361+
const actions = getPlannedActions(row)
1362+
1363+
const action = actions[0]
1364+
1365+
const hasMultipleActions = actions.length > 1
13301366

13311367
const reason = getReasonText(plan, row)
13321368

@@ -1386,7 +1422,7 @@ export default function PlanTable({
13861422
className={[
13871423
'plan-action',
13881424
'plan-tooltip-trigger',
1389-
getActionClass(action),
1425+
hasMultipleActions ? 'plan-action-multiple' : getActionClass(action),
13901426
manualOverride ? 'is-manual' : ''
13911427
]
13921428
.filter(Boolean)
@@ -1409,9 +1445,26 @@ export default function PlanTable({
14091445
setOpenOverrideTime(menuOpen ? null : row.time)
14101446
}}
14111447
>
1412-
{getActionIcon(action)}
1448+
{hasMultipleActions ? (
1449+
<span className="plan-action-badges">
1450+
{actions.map((plannedAction) => (
1451+
<span
1452+
key={plannedAction}
1453+
className={['plan-action-badge', getActionClass(plannedAction)].join(' ')}
1454+
>
1455+
{getActionIcon(plannedAction)}
1456+
1457+
<span>{getActionLabel(plannedAction)}</span>
1458+
</span>
1459+
))}
1460+
</span>
1461+
) : (
1462+
<>
1463+
{getActionIcon(action)}
14131464

1414-
<span>{getActionLabel(action)}</span>
1465+
<span>{getActionLabel(action)}</span>
1466+
</>
1467+
)}
14151468

14161469
{rateEvent && (
14171470
<span className={`plan-saving-session-badge is-${rateEvent.colour}`}>

‎frontend/src/types/plan.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,13 @@ export type PlanRow = {
1818
rate_color_import?: string
1919

2020
state: string
21+
/**
22+
* Legacy Predbat exposes two state labels when a slot is split between
23+
* charging and exporting. The modern table uses these to display both
24+
* planned actions instead of only the final state.
25+
*/
26+
state_text?: string
27+
state2_text?: string | null
2128
state_target: string
2229
state_override: string
2330

‎frontend/tests/fixtures/README.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# Frontend fixtures
2+
3+
Fixtures are small, anonymised API responses that capture UI edge cases. They
4+
make reported behaviour reproducible without access to a contributor's Home
5+
Assistant installation.
6+
7+
When adding one:
8+
9+
1. Remove real entity IDs, account IDs, addresses and dates that identify a
10+
user.
11+
2. Keep only the fields needed to reproduce the display behaviour.
12+
3. Add a regression test that loads the fixture.
13+
4. Name it after the behaviour, not the reporter or hardware.
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
{
2+
"unchanged": false,
3+
"plan": {
4+
"rows": [
5+
{
6+
"time": "2099-01-01T14:00:00Z",
7+
"slot_minute": 840,
8+
"import_rate": 0,
9+
"export_rate": 12,
10+
"import_rate_adjusted": 0,
11+
"export_rate_adjusted": 12,
12+
"state": "Exp",
13+
"state_text": "Chrg&nearr;",
14+
"state2_text": "Exp&searr;",
15+
"state_target": "87",
16+
"state_override": "",
17+
"reasons": [],
18+
"pv_forecast": 0.5,
19+
"load_forecast": 0.3,
20+
"soc_percent": 87,
21+
"soc_change": 2,
22+
"cost_change": -0.02,
23+
"total_cost": 0.42,
24+
"description": ""
25+
}
26+
],
27+
"reason_templates": {},
28+
"currency_symbols": ["£", "p"],
29+
"soc": 8.7,
30+
"soc_max": 10,
31+
"mode": "Control charge & discharge",
32+
"num_cars": 0,
33+
"car_charging_from_battery": false,
34+
"car_energy_reported_load": false
35+
}
36+
}

‎frontend/tests/plan-debug.test.mjs‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ const require = createRequire(import.meta.url)
1313
const componentPath = fileURLToPath(new URL('../src/components/PlanTable.tsx', import.meta.url))
1414
const currencyUtilityPath = fileURLToPath(new URL('../src/utils/currency.ts', import.meta.url))
1515
const planUtilityPath = fileURLToPath(new URL('../src/utils/plan.ts', import.meta.url))
16+
const splitActionsFixturePath = fileURLToPath(new URL('./fixtures/plan-data-split-actions.json', import.meta.url))
1617

1718
function loadTypeScriptModule(path) {
1819
const source = fs.readFileSync(path, 'utf8')
@@ -155,6 +156,21 @@ test('debug mode exposes the diagnostic plan values supplied by Predbat', () =>
155156
assert.match(html, />\(55\)</)
156157
})
157158

159+
test('a split charge and export slot displays both planned actions', () => {
160+
const fixture = JSON.parse(fs.readFileSync(splitActionsFixturePath, 'utf8'))
161+
162+
const html = renderPlanTable(false, {
163+
...plan,
164+
...fixture.plan
165+
})
166+
167+
assert.match(html, /plan-action-multiple/)
168+
assert.match(html, /plan-action-badge plan-action-charge/)
169+
assert.match(html, /plan-action-badge plan-action-export/)
170+
assert.match(html, />Charge<\/span>/)
171+
assert.match(html, />Export<\/span>/)
172+
})
173+
158174
test('future rates show a prediction indicator', () => {
159175
const html = renderPlanTable(false, {
160176
...plan,

0 commit comments

Comments
 (0)