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/use-the-plan.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 8 additions & 0 deletions src/lib/sim/box.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down
29 changes: 28 additions & 1 deletion src/lib/state/plan.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ const SETTLE_MS = 4_000
export class PlanStore {
#site: SiteStore
#timer: ReturnType<typeof setTimeout> | 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.
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -202,7 +228,8 @@ export class PlanStore {
* the toggle snaps back to the truth.
*/
async setMode(mode: SiteMode): Promise<void> {
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 }
Expand Down
52 changes: 52 additions & 0 deletions src/lib/state/plan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
})
121 changes: 112 additions & 9 deletions src/views/Plan.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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 ------------------------------------------------------------

Expand Down Expand Up @@ -243,17 +250,52 @@
<section class="modes">
<h2 class="label">How your home is run</h2>

<!-- The missing way back. Manual fallbacks live in a drawer so the
everyday choice stays two cards, and once someone is in one there
was nothing that said "the plan" in so many words — Passive
arbitrage does not read as "just optimal". This action names the
return without inventing a third strategy: it is the first primary
mode, the same one the box already puts first. -->
{#if plan.inManual && plan.planHome}
{@const home = plan.planHome}
<div class="use-plan">
<p class="use-plan-copy">The plan is not running the battery.</p>
{#if plan.canControl}
<button
type="button"
class="use-plan-btn"
disabled={plan.command.kind === 'sending'}
onclick={() => choose(home.key)}
>
{plan.command.kind === 'sending' && plan.command.mode === home.key
? 'Sending…'
: 'Use the plan'}
</button>
{/if}
</div>
{/if}

<!-- Pressed buttons rather than radios, the way History's range picker
solves the same exclusive choice: role=radio promises arrow-key moves
between the options, and these buttons never had them. -->
{#snippet choice(info: ModeInfo)}
{@const pressed = plan.shownMode === info.key}
{@const sending = plan.command.kind === 'sending' && plan.command.mode === info.key}
<button
type="button"
class="choice"
aria-pressed={plan.shownMode === info.key}
aria-pressed={pressed}
disabled={!plan.canControl || plan.command.kind === 'sending'}
onclick={() => choose(info.key)}
>
<span class="choice-label">{modeLabel(info)}</span>
<span class="choice-label-row">
<span class="choice-label">{modeLabel(info)}</span>
{#if sending}
<span class="choice-state">Sending…</span>
{:else if pressed}
<span class="choice-state">In use</span>
{/if}
</span>
<span class="choice-help">{modeHelp(info)}</span>
</button>
{/snippet}
Expand All @@ -268,8 +310,14 @@
{#each plan.advancedModes as info (info.key)}
{@render choice(info)}
{/each}
<button type="button" class="more" onclick={() => (showAdvanced = false)}>
Fewer options
</button>
{:else}
<button class="more" onclick={() => (showAdvanced = true)}>
{#if selectedAdvanced}
{@render choice(selectedAdvanced)}
{/if}
<button type="button" class="more" onclick={() => (showAdvanced = true)}>
More ways to run it
</button>
{/if}
Expand Down Expand Up @@ -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;
Expand All @@ -443,19 +523,25 @@
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 {
opacity: 0.5;
cursor: default;
}

.choice[aria-pressed='true']:disabled {
opacity: 1;
}

.more {
align-self: flex-start;
color: var(--fg-dim);
Expand All @@ -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);
Expand Down
Loading