Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/live-view-on-screen.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw-webapp": patch
---

Keep a tap on the house on the screen: the live sheet sits on the app shell instead of at the bottom of the page, wears the same colours as its bubble, and Now always opens on the house. The "What FTW does next" card on Now uses the same command-and-clock shape as the box page.
10 changes: 8 additions & 2 deletions src/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,8 @@
*
* The token makes a slow History import unable to win after a later Box
* tap. The first tick mounts the target hidden; the second restores that
* tab's own scroll position after it becomes the one layout can see.
* tab's own scroll position after it becomes the one layout can see —
* except Now, which always opens on the house.
*/
async function showRoute(route: Route): Promise<void> {
const request = ++routeRequest
Expand All @@ -215,7 +216,12 @@
routeSavedFrom = null
displayedRoute = route
await tick()
if (request === routeRequest && scrollPane) scrollPane.scrollTop = scrollByRoute[route]
if (request === routeRequest && scrollPane) {
// Now is a glance at the house. Restoring a scroll that had walked
// down to the fuse would hide the thing someone opened the tab for —
// the box's own Overview resets to the top for the same reason.
scrollPane.scrollTop = route === 'now' ? 0 : scrollByRoute[route]
}
}

$effect(() => {
Expand Down
15 changes: 15 additions & 0 deletions src/App.svelte.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -457,6 +457,21 @@ describe('signing out, from the phone', () => {
;(await screen.findByRole('button', { name: /^history$/i })).click()
await vi.waitFor(() => expect(main.scrollTop).toBe(355), { timeout: 4_000 })
})

it('opens Now at the house, not where the last glance left off', async () => {
// Overview on the box always lands at the top. Restoring a Now scroll
// that had walked down to the fuse hid the house — the thing someone
// opened the tab for — and made them scroll back up to see it.
await houseOnScreen()
const main = document.querySelector('main')!

main.scrollTop = 420
;(await screen.findByRole('button', { name: /^plan$/i })).click()
await screen.findByText(/How your home is run/i, undefined, { timeout: 4_000 })

;(await screen.findByRole('button', { name: /^now$/i })).click()
await vi.waitFor(() => expect(main.scrollTop).toBe(0), { timeout: 4_000 })
})
})

/* The state this whole screen was built for, and the one it used to get wrong.
Expand Down
54 changes: 53 additions & 1 deletion src/lib/format/plan.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
import { describe, it, expect } from 'vitest'
import { planHeadline, slotAction, reasonText, formatPrice, modeLabel, modeHelp } from './plan'
import {
planHeadline,
planBrief,
slotAction,
reasonText,
formatPrice,
modeLabel,
modeHelp,
} from './plan'
import { formatPrice as boxPrice, unitLabel } from '$vendor/ftw/price-units.js'
import type { Plan, PlanSlot, PlanReason, ModeInfo } from '$lib/protocol/messages'

Expand Down Expand Up @@ -119,6 +127,50 @@ describe('planHeadline', () => {
})
})

describe('planBrief', () => {
// The Overview card on the box: a command, a clock, a reason — never a
// present-tense claim that fights the live reading above it.
it('says the act as a command, until the run ends', () => {
const p = plan([
slot(0, 3000, 'solar_surplus'),
slot(1, 3000, 'solar_surplus'),
slot(2, 0, 'idle'),
])
const b = planBrief(p, T0 + 60_000, { mode: 'planner_passive_arbitrage' })
expect(b.action).toBe('Charge battery at 3.0 kW')
expect(b.action).not.toMatch(/is charging/)
expect(b.time).toMatch(/Now, until/)
expect(b.reason).toBe('Spare solar')
expect(b.state).toEqual({ label: 'Plan active', tone: 'active' })
})

it('names a future act when the battery is resting now', () => {
const p = plan([slot(0, 0, 'reserve_held'), slot(1, -2000, 'expensive_import')])
const b = planBrief(p, T0, { mode: 'planner_arbitrage' })
expect(b.action).toBe('Use battery at 2.0 kW')
expect(b.time).toMatch(/^At /)
expect(b.reason).toBe('Power is expensive')
})

it('admits fallback without claiming a live charge', () => {
const b = planBrief(plan([slot(0, 3000)], { stale: true }), T0, {
mode: 'planner_passive_arbitrage',
})
expect(b.state.tone).toBe('warn')
expect(b.state.label).toBe('Fallback active')
expect(b.action).not.toMatch(/is charging/)
expect(b.constraint).toMatch(/safe live balancing/)
})

it('says when safety has paused dispatch', () => {
const b = planBrief(plan([slot(0, 3000)]), T0, {
mode: 'planner_passive_arbitrage',
dispatchBlockedBy: ['meter.p1'],
})
expect(b.constraint).toMatch(/meter stopped reporting/)
})
})

describe('mode wording comes from the box', () => {
// The failure this replaces: the app renamed FTW's modes — "Passive
// arbitrage" became "Cheapest power" — so the same setting had two names,
Expand Down
141 changes: 141 additions & 0 deletions src/lib/format/plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,147 @@ export function planHeadline(plan: Plan | null, nowMs: number): PlanHeadline {
}
}

export type PlanBriefTone = 'active' | 'warn' | 'idle'

export interface PlanBrief {
/** Short badge, the box page's "Plan active" / "Fallback active". */
state: { label: string; tone: PlanBriefTone }
/** The next act, as a command, never "the battery is charging". */
action: string
/** When that act holds, or null when there is no clock to name. */
time: string | null
reason: string | null
/** Safety, or the honest "nothing is clamping". */
constraint: string
}

/**
* The Overview card on the box page, from the plan this app already has.
*
* The full Plan screen keeps the longer sentence. This card is a glance:
* what, until when, why — the same four lines the LAN page uses, so a
* phone and a laptop in the hall do not tell two stories.
*/
export function planBrief(
plan: Plan | null,
nowMs: number,
opts: { mode?: string | null; dispatchBlockedBy?: readonly string[] } = {}
): PlanBrief {
const mode = opts.mode ?? null
const blocked = opts.dispatchBlockedBy ?? []
const planner = !!mode && mode.startsWith('planner_')

if (!plan) {
return {
state: { label: mode && !planner ? 'Manual' : 'Checking…', tone: 'idle' },
action: planner || !mode ? 'Reading the current plan' : 'Manual control is active',
time: null,
reason: planner || !mode ? null : 'Planning is not controlling the battery',
constraint: constraintLine(plan, blocked, null),
}
}

if (plan.stale) {
return {
state: { label: 'Fallback active', tone: 'warn' },
action: "Your box couldn't plan ahead just now",
time: null,
reason: "It's running on safe defaults",
constraint: constraintLine(plan, blocked, null),
}
}

const current = plan.slots.findIndex(
(s) => nowMs >= s.startMs && nowMs < s.startMs + s.durationMs
)
if (current === -1) {
return {
state: stateFor(mode, false),
action: 'No plan for right now',
time: null,
reason: null,
constraint: constraintLine(plan, blocked, null),
}
}

const now = plan.slots[current]!
const nowAction = slotAction(now)
let runEnd = current
while (
runEnd + 1 < plan.slots.length &&
slotAction(plan.slots[runEnd + 1]!) === nowAction
) {
runEnd++
}
const untilMs = plan.slots[runEnd]!.startMs + plan.slots[runEnd]!.durationMs

let shown = now
let time: string | null = `Now, until ${clock(untilMs)}`
if (nowAction === 'idle') {
const future = plan.slots.find(
(s, i) => i > current && slotAction(s) !== 'idle'
)
if (future) {
shown = future
time = `At ${clock(future.startMs)}`
} else {
time = null
}
}

return {
state: stateFor(mode, true),
action: actionLabel(shown),
time,
reason: reasonText(shown.reason),
constraint: constraintLine(plan, blocked, shown),
}
}

function stateFor(mode: string | null, hasPlan: boolean): PlanBrief['state'] {
if (mode && mode.startsWith('planner_')) {
return { label: hasPlan ? 'Plan active' : 'Checking…', tone: hasPlan ? 'active' : 'idle' }
}
if (mode) return { label: 'Manual', tone: 'idle' }
return { label: hasPlan ? 'Plan ready' : 'Checking…', tone: 'idle' }
}

/** Same verbs the box page uses on Overview, so the two cards read as one. */
function actionLabel(slot: PlanSlot): string {
const action = slotAction(slot)
const power = formatPower(slot.batteryW)
const amount = `${power.text} ${power.unit}`
if (action === 'idle') return 'Keep the battery steady'
if (action === 'charge') return `Charge battery at ${amount}`
return `Use battery at ${amount}`
}

function clock(ms: number): string {
return new Date(ms).toLocaleTimeString(undefined, {
hour: '2-digit',
minute: '2-digit',
hour12: false,
})
}

function constraintLine(
plan: Plan | null,
blocked: readonly string[],
slot: PlanSlot | null
): string {
if (blocked.length > 0) {
return 'Control is paused because a meter stopped reporting.'
}
if (plan?.stale) {
return 'The schedule is old, so FTW is using safe live balancing.'
}
if (slot && slot.reason === 'peak_shaving' && plan?.ceilingW) {
const cap = formatPower(plan.ceilingW)
return `Holding the grid limit at ${cap.text} ${cap.unit}.`
}
return 'No active safety adjustment.'
}

function describeSlot(slot: PlanSlot, when: 'now' | 'later'): string {
const action = slotAction(slot)
const power = formatPower(slot.batteryW)
Expand Down
15 changes: 15 additions & 0 deletions src/lib/state/flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,21 @@ describe('flowReadings', () => {
)!
expect(resting.sub).toBe('idle')
})

it('colours grid and battery by direction, the way the dashboard does', () => {
const discharging = flowReadings(fields([[FID.BATTERY_W, -2_000]])).planets.find(
(p) => p.id === 'battery'
)!
expect(discharging.color).toBe('var(--red-e)')
const charging = flowReadings(fields([[FID.BATTERY_W, 1_800]])).planets.find(
(p) => p.id === 'battery'
)!
expect(charging.color).toBe('var(--green-e)')
const exporting = flowReadings(fields([[FID.GRID_W, -800]])).planets[0]!
expect(exporting.color).toBe('var(--green-e)')
const importing = flowReadings(fields([[FID.GRID_W, 800]])).planets[0]!
expect(importing.color).toBe('var(--red-e)')
})
})

describe('withLoadpointEv', () => {
Expand Down
46 changes: 35 additions & 11 deletions src/lib/state/flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,33 @@ export interface SiteStatus {

const idle = (w: number) => Math.abs(w) <= FLOW_IDLE_W

/** Role colours the hero and the live sheet share, so a tap cannot disagree. */
export type FlowColorRole = 'grid' | 'pv' | 'battery' | 'ev' | 'load'

/**
* The colour a planet (and its live line) wears for this reading.
*
* Direction on grid and battery, identity on solar / battery-at-rest / the
* house. Same tokens the box's dashboard feeds the same component.
*/
export function planetColor(role: FlowColorRole, watts: number | undefined): string {
if (watts === undefined) {
if (role === 'grid' || role === 'pv') return 'var(--fg-muted)'
if (role === 'battery') return 'var(--cyan)'
if (role === 'ev') return 'var(--white-s)'
return 'var(--fg)'
}
if (role === 'grid') {
return idle(watts) ? 'var(--fg-muted)' : watts >= 0 ? 'var(--red-e)' : 'var(--green-e)'
}
if (role === 'pv') return idle(watts) ? 'var(--fg-muted)' : 'var(--amber)'
if (role === 'battery') {
return idle(watts) ? 'var(--cyan)' : watts >= 0 ? 'var(--green-e)' : 'var(--red-e)'
}
if (role === 'ev') return idle(watts) ? 'var(--white-s)' : 'var(--green-e)'
return 'var(--fg)'
}

/**
* Build the component's readings from the field map.
*
Expand All @@ -107,7 +134,7 @@ export function flowReadings(fields: ReadonlyMap<number, number>): FlowReadings
planets.push({
id: 'grid', corner: 'bottom-left', title: 'GRID', role: 'grid',
kw: g, toHub: gridW >= 0,
color: idle(gridW) ? 'var(--fg-muted)' : gridW >= 0 ? 'var(--red-e)' : 'var(--green-e)',
color: planetColor('grid', gridW),
sub: idle(gridW) ? 'balanced' : gridW >= 0 ? 'importing' : 'exporting',
clickable: true,
})
Expand All @@ -121,7 +148,7 @@ export function flowReadings(fields: ReadonlyMap<number, number>): FlowReadings
planets.push({
id: 'pv', corner: 'top-left', title: 'SOLAR', role: 'pv',
kw: p, toHub: true,
color: idle(pvW) ? 'var(--fg-muted)' : 'var(--amber)',
color: planetColor('pv', pvW),
// One-directional: the number already says generating or idle.
sub: '',
clickable: true,
Expand All @@ -138,9 +165,7 @@ export function flowReadings(fields: ReadonlyMap<number, number>): FlowReadings
planets.push({
id: 'battery', corner: 'top-right', title: 'BATTERY', role: 'battery',
kw: b, toHub: batteryW < 0,
// Direction also in the value's colour: charge green (filling),
// discharge red (draining), idle the battery's identity cyan.
color: idle(batteryW) ? 'var(--cyan)' : batteryW >= 0 ? 'var(--green-e)' : 'var(--red-e)',
color: planetColor('battery', batteryW),
sub: idle(batteryW) ? 'idle' : batteryW >= 0 ? 'charging' : 'discharging',
soc: socPermille === undefined ? null : Math.round(socPermille / 10),
clickable: true,
Expand All @@ -156,7 +181,7 @@ export function flowReadings(fields: ReadonlyMap<number, number>): FlowReadings
planets.push({
id: 'ev', corner: 'bottom-right', title: 'EV CHARGER', role: 'ev',
kw: e, toHub: false,
color: active ? 'var(--green-e)' : 'var(--white-s)',
color: planetColor('ev', evW),
sub: active ? 'charging' : 'idle',
// The one bubble that opens something: the charger's sheet. The hero
// makes a clickable planet a button with a name, so the tap target
Expand Down Expand Up @@ -330,7 +355,7 @@ export function flowReadingsFromStatus(status: SiteStatus): FlowReadings {
// Magnitude only: the sign is wire convention. Direction travels as
// toHub and as the sub line, never as a minus on the number.
kw: Math.abs(gridW) / 1000, toHub: gridW >= 0,
color: gIdle ? 'var(--fg-muted)' : gridW >= 0 ? 'var(--red-e)' : 'var(--green-e)',
color: planetColor('grid', gridW),
sub: gIdle ? 'balanced' : gridW >= 0 ? 'importing' : 'exporting',
dailyKwhParts: gridDailyParts,
clickable: true,
Expand All @@ -355,11 +380,10 @@ export function flowReadingsFromStatus(status: SiteStatus): FlowReadings {
const pvW = num(d.pv_w)
if (pvW !== null) {
const pvKw = -pvW / 1000
const pvGen = !idle(pvW)
planets.push({
id: `pv-${name}`, corner: 'top-left', title: 'SOLAR', role: 'pv', name,
kw: pvKw, toHub: true,
color: pvGen ? 'var(--amber)' : 'var(--fg-muted)',
color: planetColor('pv', pvW),
sub: '',
dailyKwh: pvDailyStr,
dailyScope: 'aggregate',
Expand All @@ -379,7 +403,7 @@ export function flowReadingsFromStatus(status: SiteStatus): FlowReadings {
// sign. Magnitude here made two discharging packs look like a
// charge.
kw: batW / 1000, toHub: batW < 0,
color: bIdle ? 'var(--cyan)' : batW >= 0 ? 'var(--green-e)' : 'var(--red-e)',
color: planetColor('battery', batW),
sub: d.observe_only === true ? 'observe only' : bIdle ? 'idle' : batW >= 0 ? 'charging' : 'discharging',
soc: soc === null ? null : Math.round(soc * 100),
dailyKwhParts: batDailyParts,
Expand All @@ -395,7 +419,7 @@ export function flowReadingsFromStatus(status: SiteStatus): FlowReadings {
planets.push({
id: `ev-${name}`, corner: 'bottom-right', title: 'EV CHARGER', role: 'ev', name,
kw: Math.abs(evW) / 1000, toHub: false,
color: active ? 'var(--green-e)' : 'var(--white-s)',
color: planetColor('ev', evW),
sub: active ? 'charging' : 'idle',
clickable: true,
})
Expand Down
Loading