diff --git a/.changeset/live-view-on-screen.md b/.changeset/live-view-on-screen.md new file mode 100644 index 0000000..22c429d --- /dev/null +++ b/.changeset/live-view-on-screen.md @@ -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. diff --git a/src/App.svelte b/src/App.svelte index cf357dc..b85af5d 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -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 { const request = ++routeRequest @@ -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(() => { diff --git a/src/App.svelte.test.ts b/src/App.svelte.test.ts index 3215c98..e39c1bc 100644 --- a/src/App.svelte.test.ts +++ b/src/App.svelte.test.ts @@ -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. diff --git a/src/lib/format/plan.test.ts b/src/lib/format/plan.test.ts index 0887d25..b62084c 100644 --- a/src/lib/format/plan.test.ts +++ b/src/lib/format/plan.test.ts @@ -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' @@ -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, diff --git a/src/lib/format/plan.ts b/src/lib/format/plan.ts index e09b549..86f8b77 100644 --- a/src/lib/format/plan.ts +++ b/src/lib/format/plan.ts @@ -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) diff --git a/src/lib/state/flow.test.ts b/src/lib/state/flow.test.ts index 57c2f59..ce51f1f 100644 --- a/src/lib/state/flow.test.ts +++ b/src/lib/state/flow.test.ts @@ -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', () => { diff --git a/src/lib/state/flow.ts b/src/lib/state/flow.ts index 93d3881..eed7772 100644 --- a/src/lib/state/flow.ts +++ b/src/lib/state/flow.ts @@ -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. * @@ -107,7 +134,7 @@ export function flowReadings(fields: ReadonlyMap): 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, }) @@ -121,7 +148,7 @@ export function flowReadings(fields: ReadonlyMap): 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, @@ -138,9 +165,7 @@ export function flowReadings(fields: ReadonlyMap): 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, @@ -156,7 +181,7 @@ export function flowReadings(fields: ReadonlyMap): 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 @@ -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, @@ -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', @@ -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, @@ -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, }) diff --git a/src/lib/ui/portal.test.ts b/src/lib/ui/portal.test.ts new file mode 100644 index 0000000..6fbce4d --- /dev/null +++ b/src/lib/ui/portal.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest' +import { portal } from './portal' + +describe('portal', () => { + it('moves the node onto the app shell so fixed sheets are not in the scroller', () => { + const app = document.createElement('div') + app.className = 'app' + const scroller = document.createElement('main') + const sheet = document.createElement('div') + scroller.append(sheet) + app.append(scroller) + document.body.append(app) + + const action = portal(sheet) + expect(sheet.parentElement).toBe(app) + expect(scroller.contains(sheet)).toBe(false) + + action.destroy() + expect(sheet.isConnected).toBe(false) + app.remove() + }) + + it('falls back to the document when there is no shell', () => { + const sheet = document.createElement('div') + document.body.append(sheet) + const action = portal(sheet) + expect(sheet.parentElement).toBe(document.body) + action.destroy() + }) +}) diff --git a/src/lib/ui/portal.ts b/src/lib/ui/portal.ts new file mode 100644 index 0000000..19e7c19 --- /dev/null +++ b/src/lib/ui/portal.ts @@ -0,0 +1,37 @@ +/* Park a node on the app shell, outside the scrolling view. + * + * Sheets are `position: fixed`. Inside `main` that is a lie on a phone: the + * scroller, and the pull-to-refresh layer's `will-change: transform`, both + * become the containing block, so a bottom sheet lands at the bottom of the + * page and the browser scrolls it into view. Moving the node onto `.app` + * keeps it on the screen someone is looking at. + */ + +export type PortalTarget = string | HTMLElement | undefined + +function resolve(target: PortalTarget): HTMLElement { + if (target instanceof HTMLElement) return target + const selector = target ?? '.overlays, .app' + return document.querySelector(selector) ?? document.body +} + +/** + * Svelte action: move this element to the shell (or `document.body` in tests + * that have no shell) and take it with the component when it unmounts. + */ +export function portal(node: HTMLElement, target?: PortalTarget) { + const place = (next: PortalTarget) => { + const dest = resolve(next) + if (node.parentElement !== dest) dest.append(node) + } + + place(target) + return { + update(next: PortalTarget) { + place(next) + }, + destroy() { + node.remove() + }, + } +} diff --git a/src/views/EvPanel.svelte b/src/views/EvPanel.svelte index 411bade..a2cb74e 100644 --- a/src/views/EvPanel.svelte +++ b/src/views/EvPanel.svelte @@ -22,6 +22,7 @@ type Loadpoint, } from '$lib/format/ev' import { formatPower } from '$lib/format/power' + import { portal } from '$lib/ui/portal' import type { SiteStore } from '$lib/state/site.svelte' interface Props { @@ -146,11 +147,14 @@ + +
-