Skip to content
Open
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 @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down
34 changes: 32 additions & 2 deletions src/lib/state/plan.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ 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.
*/
#cmdGen = 0

/**
* What the box intends to do, read where the session keeps it.
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -202,13 +229,15 @@ export class PlanStore {
* the toggle snaps back to the truth.
*/
async setMode(mode: SiteMode): Promise<void> {
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In-flight correction hits a conflict

Medium Severity

#cmdGen only stops a stale result from painting. A second setMode is still sent immediately with the same controlRev as the in-flight one, so the box applies the earlier tap and refuses the correction as E_CONFLICT. The house stays on the mis-tap and the UI reports that something else changed the setting.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 771ebcc. Configure here.


switch (result.state) {
case 'applied':
Expand All @@ -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 {
Expand Down
60 changes: 60 additions & 0 deletions src/lib/state/plan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>((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()
})
})
123 changes: 113 additions & 10 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' && plan.command.mode === home.key}
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}
disabled={!plan.canControl || plan.command.kind === 'sending'}
aria-pressed={pressed}
disabled={!plan.canControl}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Queue correction taps until the control revision advances

When a user taps a second mode before the first command's round trip completes, both Session.command() calls are created with the same controlRev. The box accepts the first command, increments that revision, and then rejects the second with E_CONFLICT, so the initially mis-tapped mode remains active even though these newly enabled buttons suggest it can be corrected immediately. Keep the controls disabled during the in-flight request, or queue the later selection until the updated revision arrives.

Useful? React with 👍 / 👎.

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 @@ -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);
Expand Down
Loading
Loading