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..7cae390 100644 --- a/src/views/Plan.svelte +++ b/src/views/Plan.svelte @@ -89,15 +89,22 @@ 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 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. 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) - $effect(() => { - if (plan.advancedModes.some((m) => m.key === plan.actualMode)) showAdvanced = true - }) + + const selectedAdvanced = $derived( + plan.advancedModes.find((m) => m.key === plan.shownMode) ?? null + ) // ---- Prices ------------------------------------------------------------ @@ -243,17 +250,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 +310,14 @@ {#each plan.advancedModes as info (info.key)} {@render choice(info)} {/each} + {:else} - {/if} @@ -431,6 +479,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 +523,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 +538,10 @@ cursor: default; } + .choice[aria-pressed='true']:disabled { + opacity: 1; + } + .more { align-self: flex-start; color: var(--fg-dim); @@ -465,10 +551,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..e66f6af 100644 --- a/src/views/Plan.svelte.test.ts +++ b/src/views/Plan.svelte.test.ts @@ -890,3 +890,108 @@ 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) + 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 ?? '') + ) 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()) + choice('Self (manual)')!.click() + + await Promise.resolve() + // 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(choice('Self (manual)')!.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) + }) +})