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 9f56060..37a7914 100644 --- a/src/lib/sim/box.ts +++ b/src/lib/sim/box.ts @@ -307,6 +307,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 { @@ -376,6 +383,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..5a2d8f0 100644 --- a/src/lib/state/plan.svelte.ts +++ b/src/lib/state/plan.svelte.ts @@ -28,6 +28,10 @@ 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. + */ /** * What the box intends to do, read where the session keeps it. @@ -102,6 +106,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,7 +228,8 @@ export class PlanStore { * the toggle snaps back to the truth. */ async setMode(mode: SiteMode): Promise { - if (mode === this.actualMode) return + // A second request would carry the in-flight request's control revision. + if (this.command.kind === 'sending' || mode === this.shownMode) return this.#clearTimer() this.command = { kind: 'sending', mode } diff --git a/src/lib/state/plan.test.ts b/src/lib/state/plan.test.ts index 1aa8b4e..2689a10 100644 --- a/src/lib/state/plan.test.ts +++ b/src/lib/state/plan.test.ts @@ -38,3 +38,55 @@ 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('waits for the in-flight request before accepting another mode', async () => { + const box = new SimBox({}) + const site = new SiteStore('test') + const store = new PlanStore(site) + site.connect(new LoopbackCarrier(box, { latencyMs: 60 })) + await vi.waitFor(() => expect(site.session.phase).toBe('streaming'), { timeout: 2_000 }) + const sent = vi.spyOn(site, 'command') + + const first = store.setMode('self_consumption') + expect(store.command.kind).toBe('sending') + await store.setMode('idle') + expect(sent).toHaveBeenCalledTimes(1) + expect(store.shownMode).toBe('self_consumption') + + await first + expect(box.mode).toBe('self_consumption') + await store.setMode('idle') + expect(sent).toHaveBeenCalledTimes(2) + expect(store.command.kind).toBe('applied') + expect(box.mode).toBe('idle') + expect(store.shownMode).toBe('idle') + store.destroy() + site.destroy() + }) +}) diff --git a/src/views/Plan.svelte b/src/views/Plan.svelte index 9eba362..8902cb0 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); @@ -466,10 +552,27 @@ target on a phone held one-handed. */ } + .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..8f6b1ab 100644 --- a/src/views/Plan.svelte.test.ts +++ b/src/views/Plan.svelte.test.ts @@ -890,3 +890,116 @@ 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('Passive arbitrage')!.disabled).toBe(true) + const back = document.querySelector('button.use-plan-btn') as HTMLButtonElement + expect(back.disabled).toBe(true) + back.click() + choice('Passive arbitrage')!.click() + + 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)) + expect(choice('Passive arbitrage')!.disabled).toBe(false) + expect(back.disabled).toBe(false) + }) + + 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) + }) +})