From 37f838f4c38169639bd208f9c8af83386655e564 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 31 Aug 2026 04:36:47 +0000 Subject: [PATCH 1/2] fix: keep a way back to the plan after Self (manual) Choosing a fallback hid the next step: the planner cards stayed on the page but nothing said they were the way out, and the tap itself only dimmed. A house already on Self (manual) now gets an In use mark, Sending while the command is in flight, and a Use the plan button that restores the first primary mode. Signed-off-by: Cursor Agent Co-authored-by: Fredrik Ahlgren Signed-off-by: Cursor Agent --- .changeset/use-the-plan.md | 5 ++ src/lib/sim/box.ts | 8 +++ src/lib/state/plan.svelte.ts | 34 +++++++++- src/lib/state/plan.test.ts | 60 ++++++++++++++++ src/views/Plan.svelte | 124 +++++++++++++++++++++++++++++++--- src/views/Plan.svelte.test.ts | 101 +++++++++++++++++++++++++++ 6 files changed, 322 insertions(+), 10 deletions(-) create mode 100644 .changeset/use-the-plan.md diff --git a/.changeset/use-the-plan.md b/.changeset/use-the-plan.md new file mode 100644 index 0000000..507af9f --- /dev/null +++ b/.changeset/use-the-plan.md @@ -0,0 +1,5 @@ +--- +"ftw-webapp": patch +--- + +Keep a way back to the plan after choosing a manual mode, and show In use / Sending on the selected choice so a tap is visible. diff --git a/src/lib/sim/box.ts b/src/lib/sim/box.ts index ba4a003..9c3b3b1 100644 --- a/src/lib/sim/box.ts +++ b/src/lib/sim/box.ts @@ -303,6 +303,13 @@ export interface SimBoxOptions { scopes?: string[] /** False models a box from before hello could carry a subscription. */ inlineSubscribe?: boolean + /** + * Starting dispatch mode. Defaults to FTW's own default (passive + * arbitrage). Tests that open the Plan screen already on a manual + * fallback pass `self_consumption` so they do not have to click through + * the disclosure first. + */ + mode?: SiteMode } export class SimBox { @@ -357,6 +364,7 @@ export class SimBox { this.#role = opts.role ?? ROLE_OWNER this.#scopes = opts.scopes ?? null this.#inlineSubscribe = opts.inlineSubscribe ?? true + if (opts.mode && MODE_KEYS.includes(opts.mode)) this.#mode = opts.mode this.#api = new SimApi({ house: this.house, now: this.#now, diff --git a/src/lib/state/plan.svelte.ts b/src/lib/state/plan.svelte.ts index 9c056d5..9f7650e 100644 --- a/src/lib/state/plan.svelte.ts +++ b/src/lib/state/plan.svelte.ts @@ -28,6 +28,11 @@ const SETTLE_MS = 4_000 export class PlanStore { #site: SiteStore #timer: ReturnType | null = null + /** + * Which `setMode` call is current. A tap while another is in flight must + * not let the earlier result paint over the later one. + */ + #cmdGen = 0 /** * What the box intends to do, read where the session keeps it. @@ -102,6 +107,28 @@ export class PlanStore { return this.actualMode } + /** + * True when the shown mode is a manual fallback, not a forecast plan. + * + * Uses `shownMode` so a tap on "Use the plan" hides the manual banner at + * once, rather than waiting for the box to confirm. + */ + get inManual(): boolean { + const mode = this.shownMode + return mode !== null && this.advancedModes.some((m) => m.key === mode) + } + + /** + * The recommended plan to return to: the first primary mode, which is + * FTW's default (`planner_passive_arbitrage` today). + * + * The app does not invent a third strategy named "optimal". It offers the + * same first primary the box already put at the front of the catalogue. + */ + get planHome(): ModeInfo | null { + return this.primaryModes[0] ?? null + } + /** * Whether to draw the mode buttons at all. * @@ -202,13 +229,15 @@ export class PlanStore { * the toggle snaps back to the truth. */ async setMode(mode: SiteMode): Promise { - if (mode === this.actualMode) return + if (mode === this.shownMode) return + const mine = ++this.#cmdGen this.#clearTimer() this.command = { kind: 'sending', mode } try { const result: CmdResult = await this.#site.command(OP_SET_MODE, { mode }) + if (mine !== this.#cmdGen) return switch (result.state) { case 'applied': @@ -229,13 +258,14 @@ export class PlanStore { this.command = { kind: 'failed', help: commandHelp(result) } } } catch (err) { + if (mine !== this.#cmdGen) return this.command = { kind: 'failed', help: err instanceof CommandError ? err.help : "That didn't go through. Try again.", } } - this.#settleLater() + if (mine === this.#cmdGen) this.#settleLater() } destroy(): void { diff --git a/src/lib/state/plan.test.ts b/src/lib/state/plan.test.ts index 1aa8b4e..7c60786 100644 --- a/src/lib/state/plan.test.ts +++ b/src/lib/state/plan.test.ts @@ -38,3 +38,63 @@ describe('a replan this phone never asked for', () => { site.destroy() }) }) + +describe('the mode the Plan store offers a way back from', () => { + async function connected(mode?: string) { + const box = new SimBox(mode ? { mode } : {}) + const site = new SiteStore('test') + const store = new PlanStore(site) + site.connect(new LoopbackCarrier(box, { latencyMs: 0 })) + await vi.waitFor(() => expect(site.session.phase).toBe('streaming'), { timeout: 2_000 }) + return { box, site, store } + } + + it('names the first primary mode as the plan to return to', async () => { + const { store } = await connected() + expect(store.planHome?.key).toBe('planner_passive_arbitrage') + expect(store.inManual).toBe(false) + store.destroy() + }) + + it('treats Self (manual) as a manual fallback, not a plan', async () => { + const { store, site } = await connected() + await store.setMode('self_consumption') + expect(store.inManual).toBe(true) + expect(store.shownMode).toBe('self_consumption') + expect(site.session.modes.find((m) => m.key === store.shownMode)?.tier).toBe('advanced') + store.destroy() + }) + + it('does not let an earlier mode change paint over a later one', async () => { + const box = new SimBox({}) + const site = new SiteStore('test') + const store = new PlanStore(site) + site.connect(new LoopbackCarrier(box, { latencyMs: 0 })) + await vi.waitFor(() => expect(site.session.phase).toBe('streaming'), { timeout: 2_000 }) + + const real = site.command.bind(site) + let release!: () => void + const held = new Promise((resolve) => { + release = resolve + }) + let calls = 0 + vi.spyOn(site, 'command').mockImplementation(async (op, args) => { + const n = ++calls + if (n === 1) await held + return real(op, args) + }) + + const first = store.setMode('self_consumption') + await vi.waitFor(() => expect(store.command.kind).toBe('sending')) + expect(store.shownMode).toBe('self_consumption') + + const second = store.setMode('idle') + await vi.waitFor(() => expect(store.shownMode).toBe('idle')) + release() + await Promise.all([first, second]) + + expect(store.shownMode).toBe('idle') + expect(box.mode).toBe('idle') + store.destroy() + }) +}) diff --git a/src/views/Plan.svelte b/src/views/Plan.svelte index e21a601..0587f1c 100644 --- a/src/views/Plan.svelte +++ b/src/views/Plan.svelte @@ -92,13 +92,25 @@ } // FTW's own split: forecast-driven strategies are the choice most people - // want, the manual fallbacks are a drawer. Open it if the box is already in - // one of them, so the current setting is never hidden from its owner. + // want, the manual fallbacks are a drawer. Open it when the box *enters* + // one of them, so the current setting is never hidden — but only then, + // so "Fewer options" is not undone by the next 1 Hz snapshot. let showAdvanced = $state(false) + let openedFor: SiteMode | null = null $effect(() => { - if (plan.advancedModes.some((m) => m.key === plan.actualMode)) showAdvanced = true + const mode = plan.actualMode + const manual = mode !== null && plan.advancedModes.some((m) => m.key === mode) + if (manual && openedFor !== mode) { + showAdvanced = true + openedFor = mode + } + if (!manual) openedFor = null }) + const selectedAdvanced = $derived( + plan.advancedModes.find((m) => m.key === plan.shownMode) ?? null + ) + // ---- Prices ------------------------------------------------------------ let prices = $state.raw(null) @@ -243,17 +255,52 @@

How your home is run

+ + {#if plan.inManual && plan.planHome} + {@const home = plan.planHome} +
+

The plan is not running the battery.

+ {#if plan.canControl} + + {/if} +
+ {/if} + {#snippet choice(info: ModeInfo)} + {@const pressed = plan.shownMode === info.key} + {@const sending = plan.command.kind === 'sending' && plan.command.mode === info.key} {/snippet} @@ -268,8 +315,14 @@ {#each plan.advancedModes as info (info.key)} {@render choice(info)} {/each} + {:else} - {/if} @@ -431,6 +484,38 @@ gap: var(--space-2); } + .use-plan { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--space-2); + margin-bottom: var(--space-3); + padding: var(--pad-card); + background: var(--surface-raised); + border: 1px solid var(--line); + border-radius: var(--radius-md); + } + + .use-plan-copy { + font-size: 13px; + color: var(--fg-dim); + line-height: 1.4; + } + + .use-plan-btn { + min-height: 44px; + padding: 0 var(--space-4); + background: var(--accent); + color: var(--on-accent); + border-radius: var(--radius-sm); + font-weight: 500; + } + + .use-plan-btn:disabled { + opacity: 0.7; + cursor: default; + } + .choice { display: flex; flex-direction: column; @@ -443,12 +528,14 @@ border-radius: var(--radius-md); transition: border-color var(--motion-base) var(--ease), - background var(--motion-base) var(--ease); + background var(--motion-base) var(--ease), + box-shadow var(--motion-base) var(--ease); } .choice[aria-pressed='true'] { border-color: var(--accent); background: var(--surface-elevated); + box-shadow: inset 3px 0 0 var(--accent); } .choice:disabled { @@ -456,6 +543,10 @@ cursor: default; } + .choice[aria-pressed='true']:disabled { + opacity: 1; + } + .more { align-self: flex-start; color: var(--fg-dim); @@ -465,10 +556,27 @@ min-height: 36px; } + .choice-label-row { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: var(--space-2); + width: 100%; + } + .choice-label { font-weight: 500; } + .choice-state { + font-family: var(--mono); + font-size: 10px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--accent); + flex-shrink: 0; + } + .choice-help { font-size: 13px; color: var(--fg-dim); diff --git a/src/views/Plan.svelte.test.ts b/src/views/Plan.svelte.test.ts index b196d47..fbf3601 100644 --- a/src/views/Plan.svelte.test.ts +++ b/src/views/Plan.svelte.test.ts @@ -890,3 +890,104 @@ describe('the notice under the price chart', () => { expect(document.body.textContent).toMatch(/some hours are missing their price/i) }) }) + +/* Switching how the house is run. + * + * The catalogue split (primary plan vs manual drawer) hid the way back: + * once Self (manual) was on, nothing said "the plan" in words a person + * would tap, the selected card did not say it was selected, and every + * card greying out on send read as the tap having done nothing. + */ +describe('switching how the home is run', () => { + afterEach(() => { + document.body.replaceChildren() + vi.restoreAllMocks() + }) + + function choice(label: string): HTMLButtonElement | undefined { + return [...document.querySelectorAll('button.choice')].find((b) => + b.textContent?.includes(label) + ) as HTMLButtonElement | undefined + } + + async function mount(opts: { mode?: string; latencyMs?: number } = {}) { + vi.spyOn(Date, 'now').mockReturnValue(MORNING) + vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('no origin')) + const box = new SimBox({ + now: () => MORNING, + ...(opts.mode ? { mode: opts.mode } : {}), + }) + const site = new SiteStore('test') + site.connect(new LoopbackCarrier(box, { latencyMs: opts.latencyMs ?? 0 })) + render(Plan, { props: { site } }) + await vi.waitFor(() => expect(document.querySelector('button.choice')).not.toBeNull(), { + timeout: 2_000, + }) + return { box, site } + } + + it('keeps the plan choices when Self (manual) is on, and offers a way back', async () => { + const { box } = await mount({ mode: 'self_consumption' }) + + await vi.waitFor(() => expect(choice('Self (manual)')).toBeTruthy()) + const self = choice('Self (manual)')! + expect(self.getAttribute('aria-pressed')).toBe('true') + expect(self.textContent).toMatch(/in use/i) + + expect(choice('Passive arbitrage'), 'the way back to the plan was missing').toBeTruthy() + expect(document.body.textContent).toMatch(/the plan is not running the battery/i) + + const back = [...document.querySelectorAll('button')].find((b) => + /use the plan/i.test(b.textContent ?? '') + ) as HTMLButtonElement | undefined + expect(back, 'Use the plan was not offered').toBeTruthy() + back!.click() + + await vi.waitFor(() => expect(box.mode).toBe('planner_passive_arbitrage')) + await vi.waitFor(() => + expect(choice('Passive arbitrage')!.getAttribute('aria-pressed')).toBe('true') + ) + expect(choice('Passive arbitrage')!.textContent).toMatch(/in use/i) + expect(document.body.textContent).not.toMatch(/the plan is not running the battery/i) + }) + + it('marks a tap at once, before the box has confirmed', async () => { + const { box } = await mount({ latencyMs: 80 }) + + const more = document.querySelector('button.more') as HTMLButtonElement | null + expect(more, 'the manual drawer was not offered').toBeTruthy() + more!.click() + await vi.waitFor(() => expect(choice('Self (manual)')).toBeTruthy()) + const self = choice('Self (manual)')! + self.click() + + await Promise.resolve() + expect(self!.getAttribute('aria-pressed')).toBe('true') + expect(self!.textContent).toMatch(/sending/i) + expect(box.mode, 'the box confirmed before the UI had anything to show').not.toBe( + 'self_consumption' + ) + + await vi.waitFor(() => expect(box.mode).toBe('self_consumption')) + await vi.waitFor(() => expect(self!.textContent).toMatch(/in use/i)) + }) + + it('does not offer Use the plan to a viewer', async () => { + vi.spyOn(Date, 'now').mockReturnValue(MORNING) + vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('no origin')) + const site = new SiteStore('test') + site.connect( + new LoopbackCarrier( + new SimBox({ now: () => MORNING, role: ROLE_VIEWER, mode: 'self_consumption' }), + { latencyMs: 0 } + ) + ) + render(Plan, { props: { site } }) + await vi.waitFor(() => expect(choice('Self (manual)')).toBeTruthy(), { timeout: 2_000 }) + + expect(document.body.textContent).toMatch(/the plan is not running the battery/i) + expect(document.body.textContent).not.toMatch(/use the plan/i) + expect(choice('Self (manual)')!.disabled).toBe(true) + expect(choice('Passive arbitrage')!.disabled).toBe(true) + }) +}) From 771ebccb724a822cea41a5387f1630f3765fe91f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 31 Aug 2026 04:50:57 +0000 Subject: [PATCH 2/2] fix: keep Use the plan on screen after a manual tap Choosing Self (manual) used to leave the extras open, so the way back scrolled off under Idle, Peak and Charge. The selected fallback already renders when the drawer is closed; fold the extras on the tap. Signed-off-by: Cursor Agent Co-authored-by: Fredrik Ahlgren Signed-off-by: Cursor Agent --- src/views/Plan.svelte | 21 ++++++++------------- src/views/Plan.svelte.test.ts | 14 +++++++++----- 2 files changed, 17 insertions(+), 18 deletions(-) diff --git a/src/views/Plan.svelte b/src/views/Plan.svelte index 0587f1c..7cae390 100644 --- a/src/views/Plan.svelte +++ b/src/views/Plan.svelte @@ -89,23 +89,18 @@ function choose(mode: SiteMode) { void plan.setMode(mode) + // The selected fallback already renders when the drawer is closed. + // Folding the extras keeps "Use the plan" on screen instead of + // scrolling it off under Idle / Peak / Charge. + if (plan.advancedModes.some((m) => m.key === mode)) showAdvanced = false } // FTW's own split: forecast-driven strategies are the choice most people - // want, the manual fallbacks are a drawer. Open it when the box *enters* - // one of them, so the current setting is never hidden — but only then, - // so "Fewer options" is not undone by the next 1 Hz snapshot. + // want, the manual fallbacks are a drawer. The current fallback stays on + // the page even when the drawer is closed — see selectedAdvanced — so a + // house already on Self (manual) never needs the extras opened to see + // what is running, or to get back to the plan. let showAdvanced = $state(false) - let openedFor: SiteMode | null = null - $effect(() => { - const mode = plan.actualMode - const manual = mode !== null && plan.advancedModes.some((m) => m.key === mode) - if (manual && openedFor !== mode) { - showAdvanced = true - openedFor = mode - } - if (!manual) openedFor = null - }) const selectedAdvanced = $derived( plan.advancedModes.find((m) => m.key === plan.shownMode) ?? null diff --git a/src/views/Plan.svelte.test.ts b/src/views/Plan.svelte.test.ts index fbf3601..e66f6af 100644 --- a/src/views/Plan.svelte.test.ts +++ b/src/views/Plan.svelte.test.ts @@ -936,6 +936,7 @@ describe('switching how the home is run', () => { expect(choice('Passive arbitrage'), 'the way back to the plan was missing').toBeTruthy() expect(document.body.textContent).toMatch(/the plan is not running the battery/i) + expect(choice('Peak'), 'the extras were open, hiding the way back').toBeUndefined() const back = [...document.querySelectorAll('button')].find((b) => /use the plan/i.test(b.textContent ?? '') @@ -958,18 +959,21 @@ describe('switching how the home is run', () => { expect(more, 'the manual drawer was not offered').toBeTruthy() more!.click() await vi.waitFor(() => expect(choice('Self (manual)')).toBeTruthy()) - const self = choice('Self (manual)')! - self.click() + choice('Self (manual)')!.click() await Promise.resolve() - expect(self!.getAttribute('aria-pressed')).toBe('true') - expect(self!.textContent).toMatch(/sending/i) + // Choosing a fallback folds the extras, so the button is the one the + // closed drawer keeps on the page — not the node that was just clicked. + const self = choice('Self (manual)')! + expect(self.getAttribute('aria-pressed')).toBe('true') + expect(self.textContent).toMatch(/sending/i) + expect(choice('Peak'), 'the extras stayed open after the tap').toBeUndefined() expect(box.mode, 'the box confirmed before the UI had anything to show').not.toBe( 'self_consumption' ) await vi.waitFor(() => expect(box.mode).toBe('self_consumption')) - await vi.waitFor(() => expect(self!.textContent).toMatch(/in use/i)) + await vi.waitFor(() => expect(choice('Self (manual)')!.textContent).toMatch(/in use/i)) }) it('does not offer Use the plan to a viewer', async () => {