diff --git a/.changeset/history-period-freshness.md b/.changeset/history-period-freshness.md new file mode 100644 index 0000000..e5affd4 --- /dev/null +++ b/.changeset/history-period-freshness.md @@ -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. diff --git a/src/lib/state/energy.svelte.test.ts b/src/lib/state/energy.svelte.test.ts new file mode 100644 index 0000000..d760a83 --- /dev/null +++ b/src/lib/state/energy.svelte.test.ts @@ -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() + }) +}) diff --git a/src/lib/state/energy.svelte.ts b/src/lib/state/energy.svelte.ts index 3398e3e..8cafce0 100644 --- a/src/lib/state/energy.svelte.ts +++ b/src/lib/state/energy.svelte.ts @@ -78,11 +78,11 @@ export class EnergyStore { /** Oldest first, today last. Empty until an answer lands. */ days = $state.raw([]) - /** 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 @@ -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. @@ -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 diff --git a/src/views/Energy.svelte.test.ts b/src/views/Energy.svelte.test.ts index 0d22289..c7ce81e 100644 --- a/src/views/Energy.svelte.test.ts +++ b/src/views/Energy.svelte.test.ts @@ -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' @@ -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