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/history-period-freshness.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw-webapp": patch
---

Keep History totals and bars with their selected period. Clear the previous period while a new one loads, ignore its late replies, and retry failed reads.
48 changes: 48 additions & 0 deletions src/lib/state/energy.svelte.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ApiResponse } from '$lib/protocol/session'
import { EnergyStore } from './energy.svelte'
import { SiteStore } from './site.svelte'

describe('energy period changes', () => {
afterEach(() => vi.restoreAllMocks())

it('does not call a new period empty because the previous period was empty', async () => {
const site = new SiteStore('test')
const api = vi.spyOn(site, 'api').mockResolvedValue({
status: 200, headers: {},
body: new TextEncoder().encode('{"days":[]}'),
})
const energy = new EnergyStore(site)
await energy.load()
expect(energy.loaded).toBe(true)

energy.select('today')
expect(energy.loaded).toBe(false)
expect(energy.days).toEqual([])
expect(api).toHaveBeenCalledTimes(1)
site.destroy()
})

it.each(['success', 'failure'] as const)('ignores a late %s before the next period can be fetched', async outcome => {
const site = new SiteStore('test')
let answer!: (response: ApiResponse) => void
vi.spyOn(site, 'api').mockReturnValue(new Promise(resolve => { answer = resolve }))
const energy = new EnergyStore(site)
const pending = energy.load()

// The new range may be selected offline, before another load can start.
energy.select('today')
answer({
status: outcome === 'success' ? 200 : 503,
headers: {},
body: new TextEncoder().encode(JSON.stringify({ days: [{ day: '2026-07-15', load_wh: 308_000 }] })),
})
await expect(pending).resolves.toBeUndefined()
expect(energy.spec.title).toBe('Today')
expect(energy.days).toEqual([])
expect(energy.loaded).toBe(false)
expect(energy.loading).toBe(false)
expect(energy.error).toBeNull()
site.destroy()
})
})
23 changes: 14 additions & 9 deletions src/lib/state/energy.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,11 +78,11 @@ export class EnergyStore {
/** Oldest first, today last. Empty until an answer lands. */
days = $state.raw<EnergyDay[]>([])

/** True only while waiting on the box. Whatever is drawn stays drawn. */
/** True only while waiting on the box for the selected period. */
loading = $state(false)

/**
* Whether the box has ever answered with a set of days.
* Whether the box has answered for the selected period.
*
* Three states, not two, for the same reason the roster needs three: a
* period this app has not read is not a period with nothing in it. Without
Expand Down Expand Up @@ -125,17 +125,23 @@ export class EnergyStore {
/**
* Choose what the figures cover.
*
* Only sets the range, exactly as the history chart does: the range is the
* question `askWhenLive` asks under, so changing it is already what fetches
* and what heals a tap whose answer never arrives. Fetching here as well
* would spend a second round trip on one tap.
* Clear the previous period before its figures can take the new label.
* `askWhenLive` fetches the selected range and retries failures.
*/
select(range: EnergyRangeKey): void {
if (range === this.range) return
// A selection can change while offline, before another load starts.
// Invalidate that period's pending reply at selection time.
this.#token += 1
this.range = range
this.days = []
this.loaded = false
this.loading = false
this.error = null
}

/**
* Ask the box, and keep whatever is on screen until a better answer comes.
* Refresh the selected period, keeping any figures from that period.
*
* Rejects when the box did not answer, because the caller that heals this
* has no other way to tell an answer from a failure that was swallowed.
Expand Down Expand Up @@ -163,8 +169,7 @@ export class EnergyStore {
// range's news, and not a reason to ask for this one again.
if (token !== this.#token) return

// What happens now. Whatever was drawn stays drawn — last week's totals
// are still last week's — with a line saying they are not current.
// Retain figures only for this period, with a note that the refresh failed.
this.error =
err instanceof BoxApiError
? this.days.length > 0
Expand Down
74 changes: 73 additions & 1 deletion src/views/Energy.svelte.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
*/

import { describe, it, expect, vi, afterEach } from 'vitest'
import { render } from '@testing-library/svelte'
import { fireEvent, render, screen } from '@testing-library/svelte'
import Energy from './Energy.svelte'
import { SiteStore } from '$lib/state/site.svelte'
import { LoopbackCarrier } from '$lib/carrier/loopback'
Expand Down Expand Up @@ -130,6 +130,78 @@ describe('the energy screen', () => {
expect((await chartWhenDrawn()).data).toHaveLength(7)
})

it('never labels the previous period as today while loading or after a failure, then retries', async () => {
vi.useFakeTimers()
vi.setSystemTime(NOON)
const box = new SimBox({ now: () => Date.now() })
const serve = box.api.serve.bind(box.api)
let failDaily = false
vi.spyOn(box.api, 'serve').mockImplementation(req => {
if (req.path === '/api/energy/daily' && failDaily) {
return { status: 503, contentType: 'application/json', body: new TextEncoder().encode('{}') }
}
return serve(req)
})
const site = new SiteStore('test')
site.connect(new LoopbackCarrier(box, { latencyMs: 40 }))
const view = render(Energy, { props: { site } })
await vi.advanceTimersByTimeAsync(1_000)
expect((await chartWhenDrawn()).data).toHaveLength(7)
const week = document.querySelector('.card-value')?.textContent

failDaily = true
await fireEvent.click(screen.getByRole('button', { name: 'Today' }))
expect(screen.getByRole('heading', { name: 'Today' })).toBeTruthy()
expect(document.querySelector('.card-value')?.textContent, 'week total relabelled as Today').toBe('—')
expect(document.querySelector('ftw-bar-chart'), 'week bars remained under Today').toBeNull()
expect(text()).toContain('Reading your box…')
await vi.advanceTimersByTimeAsync(1_000)
expect(text()).toContain('Still trying.')
expect(document.querySelector('.card-value')?.textContent).toBe('—')
expect(text()).not.toContain('Nothing recorded')

failDaily = false
for (let i = 0; i < 32; i++) {
box.tick(1_000)
await vi.advanceTimersByTimeAsync(1_000)
}
const today = document.querySelector('.card-value')?.textContent
expect(today).toContain('kWh')
expect(today).not.toBe(week)
expect(document.querySelector('ftw-bar-chart')).toBeNull()
expect(text()).not.toContain('Still trying.')
expect(screen.getByRole('button', { name: 'Today' }).getAttribute('aria-pressed')).toBe('true')
view.unmount()
site.destroy()
})

it('keeps figures for the same period when its refresh fails', async () => {
vi.useFakeTimers()
vi.setSystemTime(NOON)
const box = new SimBox({ now: () => Date.now() })
const site = new SiteStore('test')
site.connect(new LoopbackCarrier(box, { latencyMs: 0 }))
const view = render(Energy, { props: { site } })
await vi.advanceTimersByTimeAsync(500)
const chart = await chartWhenDrawn()
const before = document.querySelector('.card-value')?.textContent
const serve = box.api.serve.bind(box.api)
vi.spyOn(box.api, 'serve').mockImplementation(req => req.path === '/api/energy/daily'
? { status: 503, contentType: 'application/json', body: new TextEncoder().encode('{}') }
: serve(req))

site.setVisible(false)
await vi.advanceTimersByTimeAsync(20)
site.setVisible(true)
await vi.advanceTimersByTimeAsync(500)
expect(document.querySelector('.card-value')?.textContent).toBe(before)
expect(chart.data).toHaveLength(7)
expect(text()).toContain('Last 7 days')
expect(text()).toContain('Not up to date')
view.unmount()
site.destroy()
})

it('asks again once the hour turns, on a wire that never drops', async () => {
// "Today so far" keeps filling all day, and a figure fetched at nine in
// the morning used to still be the figure at nine in the evening unless
Expand Down