diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md
index 5e2e9760b..d945c8054 100644
--- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md
+++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md
@@ -7713,3 +7713,19 @@ remain separate errors. No polling frequency, request or access policy changes.
and selection changes; `browser-tests/history-status.cjs` checks actual chart
states in desktop Chromium and phone WebKit with synthetic transport failures.
These checks do not establish native collection or released availability.
+
+### Shared History single observations
+
+A single stored observation renders as a visible centred marker with one real
+timestamp label, including measured zero. It must not imply a trend or invent
+a duration. Pointer and keyboard inspection resolve to that actual observation;
+ordinary multi-observation interpolation and empty-result states are unchanged.
+The geometry's inverse timestamp mapping is shared by pointer inspection.
+
+`HistoryChart.sparse.test.tsx` and `historyChartModel.branchcov2.test.ts` cover
+marker geometry, one timestamp label, pointer bounds, keyboard inspection and
+single/zero/multiple/empty refreshes. `browser-tests/history-sparse.cjs` checks
+actual series pixels and tooltips in desktop Chromium and phone WebKit, using
+the production chart and API transport with synthetic stored samples. This
+does not establish native collection, whole-report resolution or availability
+in a release. Existing request, source, units and access boundaries are unchanged.
diff --git a/frontend-modern/browser-tests/history-sparse.cjs b/frontend-modern/browser-tests/history-sparse.cjs
new file mode 100644
index 000000000..2cde2cc36
--- /dev/null
+++ b/frontend-modern/browser-tests/history-sparse.cjs
@@ -0,0 +1,154 @@
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+const { chromium, webkit } = require('playwright');
+
+(async () => {
+ const root = '/workspace/frontend-modern';
+ process.chdir(root);
+ const artifacts = path.join(root, 'node_modules/history-sparse-proof');
+ fs.mkdirSync(artifacts, { recursive: true });
+ const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js'));
+ const server = await createServer({
+ root, configFile: path.join(root, 'vite.config.ts'),
+ cacheDir: fs.mkdtempSync(path.join(artifacts, 'vite-')),
+ server: { host: '127.0.0.1', port: 5226, strictPort: true },
+ });
+ const parent = process.argv.includes('--parent');
+ const timestamp = 1790942400000;
+ const point = (value, offset = 0) => ({ timestamp: timestamp + offset, value, min: value, max: value });
+ const observations = [];
+ let browser;
+ try {
+ await server.listen();
+ for (const [name, engine, width, height] of [['chromium', chromium, 1365, 900], ['webkit', webkit, 390, 844]]) {
+ browser = await engine.launch(name === 'chromium'
+ ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
+ : { headless: true });
+ const page = await browser.newPage({
+ viewport: { width, height }, hasTouch: name === 'webkit', isMobile: name === 'webkit',
+ });
+ const errors = [];
+ page.on('pageerror', error => errors.push(error.message));
+ let samples = [point(42)], calls = 0;
+ await page.route('**/*', route => {
+ const url = new URL(route.request().url());
+ if (url.origin !== 'http://127.0.0.1:5226') return route.abort();
+ if (!url.pathname.startsWith('/api/')) return route.continue();
+ if (url.pathname.includes('/metrics-store/history')) {
+ calls++;
+ return route.fulfill({ json: { points: samples, source: 'store' } });
+ }
+ return route.fulfill({ json: url.pathname === '/api/license/runtime-capabilities'
+ ? { capabilities: [], limits: [], max_history_days: 7, hosted_mode: false, runtime: { build: 'community' }, blocked_capabilities: [] }
+ : { data: [], enabled: false } });
+ });
+ await page.clock.install();
+ await page.goto('http://127.0.0.1:5226/browser-tests/history-sparse.html');
+ if (name === 'webkit') await page.evaluate(() => document.documentElement.classList.add('dark'));
+ const chart = page.getByRole('img', { name: 'Usage chart', exact: true });
+ await chart.waitFor();
+ const description = page.locator('#' + await chart.getAttribute('aria-describedby'));
+ const tooltip = page.locator('[data-history-chart-tooltip]');
+ const live = page.locator('[aria-live="polite"]');
+ async function state(phase, expected) {
+ await page.waitForFunction(({ id, expected }) => document.getElementById(id).textContent.includes(expected),
+ { id: await chart.getAttribute('aria-describedby'), expected });
+ await page.clock.runFor(100);
+ // Count actual blue series pixels, not mocked drawing calls or tooltip DOM.
+ const pixels = await chart.evaluate(canvas => {
+ const ctx = canvas.getContext('2d');
+ const data = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
+ let count = 0;
+ for (let i = 0; i < data.length; i += 4) {
+ if (data[i] >= 40 && data[i] <= 80 && data[i + 1] >= 100 && data[i + 1] <= 150 && data[i + 2] >= 220 && data[i + 2] <= 255 && data[i + 3] >= 80) count++;
+ }
+ return count;
+ });
+ const dimensions = await page.evaluate(() => ({ scroll: document.documentElement.scrollWidth, width: innerWidth }));
+ assert.ok(dimensions.scroll <= dimensions.width + 1, JSON.stringify(dimensions));
+ const screenshot = `${parent ? 'parent-' : ''}${name}-${phase}.png`;
+ await page.screenshot({ path: path.join(artifacts, screenshot), fullPage: true });
+ observations.push({ name, version: browser.version(), phase, description: await description.textContent(), pixels, calls, dimensions, screenshot });
+ return pixels;
+ }
+ const pixels = await state('single', '1 data point');
+ const box = await chart.boundingBox();
+ assert.ok(box);
+ if (parent) {
+ assert.equal(pixels, 0, 'predecessor singleton has no visible series pixels');
+ await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
+ await page.clock.runFor(100);
+ assert.equal(await tooltip.count(), 0, 'predecessor pointer rejects singleton');
+ observations.at(-1).pointerTooltipCount = await tooltip.count();
+ } else {
+ assert.ok(pixels >= 30, 'singleton has a visible marker without hover');
+ for (const fraction of [0.25, 0.5, 0.75]) {
+ await page.mouse.move(box.x + box.width * fraction, box.y + box.height / 2);
+ await page.clock.runFor(100);
+ assert.equal(await tooltip.count(), 1);
+ assert.ok((await tooltip.textContent()).includes('42.0%'));
+ assert.ok((await tooltip.textContent()).includes(await page.evaluate(ts => new Date(ts).toLocaleString(), timestamp)));
+ assert.equal(await live.textContent(), '', 'pointer does not announce mouse movements');
+ }
+ await state('pointer', '1 data point');
+ await page.mouse.move(0, 0);
+ await page.getByRole('button', { name: 'Before chart' }).focus();
+ await page.keyboard.press('Tab');
+ assert.equal(await chart.evaluate(el => el === document.activeElement), true);
+ for (const key of ['Home', 'ArrowLeft', 'ArrowRight', 'End']) {
+ await page.keyboard.press(key);
+ await page.clock.runFor(100);
+ assert.ok((await live.textContent()).includes('42.0%'));
+ }
+ await page.keyboard.press('Escape');
+ assert.equal(await tooltip.count(), 0);
+ await page.keyboard.press('Tab');
+ assert.equal(await page.getByRole('button', { name: 'After chart' }).evaluate(el => el === document.activeElement), true);
+
+ samples = [point(0)];
+ await page.clock.runFor(10000);
+ assert.ok(await state('zero', '0.0%') >= 30, 'measured zero keeps its marker');
+ await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
+ await page.clock.runFor(100);
+ assert.ok((await tooltip.textContent()).includes('0.0%'));
+
+ samples = [point(10), point(20, 60000), point(30, 120000)];
+ await page.mouse.move(0, 0);
+ await page.clock.runFor(10000);
+ assert.ok(await state('multiple', '3 data points') > 100, 'normal series still draws');
+ await chart.focus();
+ await page.keyboard.press('Home');
+ await page.clock.runFor(100);
+ assert.ok((await live.textContent()).includes('10.0%'));
+ await page.keyboard.press('ArrowRight');
+ await page.clock.runFor(100);
+ assert.ok((await live.textContent()).includes('20.0%'));
+ await page.keyboard.press('Tab');
+
+ samples = [];
+ await page.clock.runFor(10000);
+ assert.equal(await state('empty', 'No 1-hour'), 0);
+ assert.equal(await tooltip.count(), 0);
+ assert.equal(await live.textContent(), '');
+ assert.equal(await page.getByText('No history samples in this time range.', { exact: true }).count(), 1);
+
+ samples = [point(70, 180000)];
+ await page.clock.runFor(10000);
+ assert.ok(await state('recovered-single', '1 data point') >= 30);
+ await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
+ await page.clock.runFor(100);
+ assert.ok((await tooltip.textContent()).includes('70.0%'));
+ }
+ assert.deepEqual(errors, []);
+ await browser.close(); browser = null;
+ }
+ fs.writeFileSync(path.join(artifacts, parent ? 'parent.json' : 'result.json'), JSON.stringify({
+ playwright: require('playwright/package.json').version, parent, observations,
+ }, null, 2));
+ console.log(JSON.stringify({ result: 'passed', parent, states: observations.length }));
+ } finally {
+ if (browser) await browser.close();
+ await server.close();
+ }
+})().catch(error => { console.error(error); process.exitCode = 1; });
diff --git a/frontend-modern/browser-tests/history-sparse.html b/frontend-modern/browser-tests/history-sparse.html
new file mode 100644
index 000000000..19d1b6947
--- /dev/null
+++ b/frontend-modern/browser-tests/history-sparse.html
@@ -0,0 +1 @@
+
Sparse History verification
diff --git a/frontend-modern/browser-tests/history-sparse.tsx b/frontend-modern/browser-tests/history-sparse.tsx
new file mode 100644
index 000000000..b876116d6
--- /dev/null
+++ b/frontend-modern/browser-tests/history-sparse.tsx
@@ -0,0 +1,28 @@
+import { render } from 'solid-js/web';
+import { HistoryChart } from '../src/components/shared/HistoryChart';
+import '../src/index.css';
+
+render(
+ () => (
+
+ Storage History: sparse observations
+ Synthetic stored samples; production chart, transport and styles.
+
+
+
+
+ ),
+ document.getElementById('root')!,
+);
diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json
index 3daf270df..d836db5f3 100644
--- a/frontend-modern/browser-verification.json
+++ b/frontend-modern/browser-verification.json
@@ -1,16 +1,18 @@
{
"version": 1,
- "base_sha": "df18123e7da3b682eed811c0e01c94a44dc2c29c",
- "verified_at": "2026-10-02T16:36:02.327983Z",
+ "base_sha": "ad7f7a1063d45e26f77faf35bacbca6877f41cac",
+ "verified_at": "2026-10-02T18:37:50.309899Z",
"result": "passed",
"changed_paths": [
- "frontend-modern/src/features/storageBackups/diskDetailPresentation.ts"
+ "frontend-modern/src/components/shared/historyChartModel.ts",
+ "frontend-modern/src/components/shared/useHistoryChartState.ts"
],
"content_sha256": {
- "frontend-modern/src/features/storageBackups/diskDetailPresentation.ts": "035614af7ddee52c5ee56099b4f447b0137afd9dccf07be9981d44a486eb19a4"
+ "frontend-modern/src/components/shared/historyChartModel.ts": "c96437081022b073dca0c730a8617954987f712ee84e058c6078ae3cad05f162",
+ "frontend-modern/src/components/shared/useHistoryChartState.ts": "38af5f416fb95874e708b08d66fdbcf27b85660840e0096cb1dd24d9bc744991"
},
"routes": [
- "/browser-tests/disk-temperature.html"
+ "/browser-tests/history-sparse.html"
],
"viewports": [
{
@@ -23,28 +25,37 @@
}
],
"states": [
- "Temperature without extended SMART; critical temperature; Fahrenheit; absent temperature; full SMART snapshot",
- "Chromium desktop light and WebKit phone dark: 10 final states, no page errors or horizontal overflow; synthetic inputs, not native collection"
+ "Single stored 42% before inspection, pointer inspection, measured zero, multiple observations, successful empty response, restored singleton",
+ "Desktop Chromium light and phone WebKit dark-class presentation; 12 observations, no page errors or horizontal overflow"
],
"interactions": [
- "Same-identity snapshot replacement and reactive temperature-unit changes",
- "Parent Chromium and WebKit reproduce hidden 42 C; representative desktop and all five phone final screenshots inspected"
+ "Pointer at three plot positions resolves to the real singleton timestamp; no mouse live announcements",
+ "Tab, Home/End, Left/Right, Escape and focus exit; matching API refreshes across sparse, populated and empty results",
+ "Both actual predecessor engines reproduce zero series pixels and missing pointer tooltip. Parent phone and final phone singleton/tooltip/zero/empty plus final desktop multi screenshots inspected."
],
- "command": "pulse-worker-browser frontend-modern/browser-tests/disk-temperature.cjs",
+ "command": "pulse-worker-browser frontend-modern/browser-tests/history-sparse.cjs",
"browser_versions": {
"playwright": "1.56.1",
"chromium": "141.0.7390.37",
"webkit": "26.0"
},
"artifacts": [
- "/var/lib/pulse-maintainer/worker-outputs/web-product-ebfg3k6d/browser/result.json",
- "/var/lib/pulse-maintainer/worker-outputs/web-product-ebfg3k6d/browser/parent.json",
- "/var/lib/pulse-maintainer/worker-outputs/web-product-ebfg3k6d/browser/parent-chromium-temperature-hidden.png",
- "/var/lib/pulse-maintainer/worker-outputs/web-product-ebfg3k6d/browser/chromium-temperature-only.png",
- "/var/lib/pulse-maintainer/worker-outputs/web-product-ebfg3k6d/browser/webkit-temperature-only.png",
- "/var/lib/pulse-maintainer/worker-outputs/web-product-ebfg3k6d/browser/webkit-hot.png",
- "/var/lib/pulse-maintainer/worker-outputs/web-product-ebfg3k6d/browser/webkit-fahrenheit.png",
- "/var/lib/pulse-maintainer/worker-outputs/web-product-ebfg3k6d/browser/webkit-missing.png",
- "/var/lib/pulse-maintainer/worker-outputs/web-product-ebfg3k6d/browser/webkit-extended.png"
- ]
+ "/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/result.json",
+ "/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/parent.json",
+ "/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/parent-chromium-single.png",
+ "/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/parent-webkit-single.png",
+ "/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/chromium-single.png",
+ "/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/chromium-pointer.png",
+ "/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/chromium-zero.png",
+ "/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/chromium-multiple.png",
+ "/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/chromium-empty.png",
+ "/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/chromium-recovered-single.png",
+ "/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/webkit-single.png",
+ "/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/webkit-pointer.png",
+ "/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/webkit-zero.png",
+ "/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/webkit-multiple.png",
+ "/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/webkit-empty.png",
+ "/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/webkit-recovered-single.png"
+ ],
+ "limitations": "Synthetic samples, not native PBS/TrueNAS collection, assistive-device testing or whole-application theme-transition acceptance."
}
diff --git a/frontend-modern/src/components/shared/__tests__/HistoryChart.sparse.test.tsx b/frontend-modern/src/components/shared/__tests__/HistoryChart.sparse.test.tsx
new file mode 100644
index 000000000..ce0261a8c
--- /dev/null
+++ b/frontend-modern/src/components/shared/__tests__/HistoryChart.sparse.test.tsx
@@ -0,0 +1,137 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { createSignal } from 'solid-js';
+import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library';
+import type { AggregatedMetricPoint } from '@/api/charts';
+import { HistoryChart } from '../HistoryChart';
+import { formatHistoryChartTimeLabel } from '../historyChartModel';
+
+vi.mock('@/stores/license', () => ({
+ isRangeLocked: () => false,
+ loadRuntimeCapabilities: vi.fn(),
+ maxHistoryDays: () => 7,
+}));
+vi.mock('@/api/charts', () => ({ ChartsAPI: { getMetricsHistory: vi.fn() } }));
+
+const ctx = {
+ clearRect: vi.fn(),
+ setTransform: vi.fn(),
+ beginPath: vi.fn(),
+ moveTo: vi.fn(),
+ lineTo: vi.fn(),
+ stroke: vi.fn(),
+ fillText: vi.fn(),
+ closePath: vi.fn(),
+ fill: vi.fn(),
+ arc: vi.fn(),
+ save: vi.fn(),
+ restore: vi.fn(),
+ setLineDash: vi.fn(),
+ measureText: vi.fn(() => ({ width: 20 })),
+};
+const point = (value: number, timestamp = 1000): AggregatedMetricPoint => ({
+ timestamp,
+ value,
+ min: value,
+ max: value,
+});
+
+function mount(initial: AggregatedMetricPoint[]) {
+ const [data, setData] = createSignal(initial);
+ const view = render(() => (
+
+ ));
+ const canvas = screen.getByRole('img', { name: 'History chart' });
+ vi.spyOn(canvas, 'getBoundingClientRect').mockReturnValue({
+ x: 0,
+ y: 0,
+ left: 0,
+ top: 0,
+ right: 300,
+ bottom: 200,
+ width: 300,
+ height: 200,
+ toJSON: () => ({}),
+ });
+ return { ...view, canvas, setData };
+}
+
+describe('Sparse History observations', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(
+ ctx as unknown as CanvasRenderingContext2D,
+ );
+ vi.stubGlobal(
+ 'ResizeObserver',
+ class {
+ observe() {}
+ disconnect() {}
+ },
+ );
+ });
+ afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
+ });
+
+ it.each([0, 42])(
+ 'draws a %s singleton marker and exactly one timestamp before hover',
+ (value) => {
+ mount([point(value)]);
+ const lastClear = ctx.clearRect.mock.invocationCallOrder.at(-1)!;
+ const arcs = ctx.arc.mock.calls.filter(
+ (_, i) => ctx.arc.mock.invocationCallOrder[i] > lastClear,
+ );
+ expect(arcs).toHaveLength(1);
+ expect(arcs[0][0]).toBe(164);
+ expect(arcs[0][1]).toBeCloseTo(180 - (value / 100) * 160);
+ expect(arcs[0][2]).toBe(4);
+ const times = ctx.fillText.mock.calls.filter(
+ (args, i) =>
+ ctx.fillText.mock.invocationCallOrder[i] > lastClear &&
+ args[0] === formatHistoryChartTimeLabel(1000, '1h'),
+ );
+ expect(times).toHaveLength(1);
+ expect(times[0][1]).toBe(164);
+ },
+ );
+
+ it('inspects the actual singleton from any pointer position and clears outside the plot', () => {
+ const { canvas, container } = mount([point(42)]);
+ for (const clientX of [100, 175, 250]) {
+ fireEvent.mouseMove(canvas, { clientX });
+ const tooltip = container.querySelector('[data-history-chart-tooltip]')!;
+ expect(tooltip).toHaveTextContent('42.0%');
+ expect(tooltip).toHaveTextContent(new Date(1000).toLocaleString());
+ expect(container.querySelector('[aria-live="polite"]')).toHaveTextContent('');
+ }
+ fireEvent.mouseMove(canvas, { clientX: 5 });
+ expect(container.querySelector('[data-history-chart-tooltip]')).toBeNull();
+ });
+
+ it('keeps zero, multiple and empty refreshes truthful during inspection', () => {
+ const { canvas, container, setData } = mount([point(42)]);
+ fireEvent.focus(canvas);
+ for (const key of ['Home', 'End', 'ArrowLeft', 'ArrowRight']) {
+ fireEvent.keyDown(canvas, { key });
+ expect(container.querySelector('[aria-live="polite"]')).toHaveTextContent('42.0%');
+ }
+ setData([point(0)]);
+ expect(container.querySelector('[data-history-chart-tooltip]')).toHaveTextContent('0.0%');
+ setData([point(10), point(20, 2000), point(30, 3000)]);
+ fireEvent.keyDown(canvas, { key: 'ArrowRight' });
+ expect(container.querySelector('[data-history-chart-tooltip]')).toHaveTextContent('20.0%');
+ setData([]);
+ expect(container.querySelector('[data-history-chart-tooltip]')).toBeNull();
+ expect(container.querySelector('[aria-live="polite"]')).toHaveTextContent('');
+ expect(screen.getByText('No history samples in this time range.')).toBeInTheDocument();
+ });
+});
diff --git a/frontend-modern/src/components/shared/__tests__/HistoryChart.test.tsx b/frontend-modern/src/components/shared/__tests__/HistoryChart.test.tsx
index 7bfe9b99c..d2eb0a84b 100644
--- a/frontend-modern/src/components/shared/__tests__/HistoryChart.test.tsx
+++ b/frontend-modern/src/components/shared/__tests__/HistoryChart.test.tsx
@@ -231,6 +231,46 @@ describe('HistoryChart', () => {
expect(screen.queryByText('Min')).not.toBeInTheDocument();
});
+ it('exposes a stored singleton to pointer inspection without treating a later empty response as zero', async () => {
+ const request = vi.mocked(ChartsAPI.getMetricsHistory);
+ request.mockResolvedValueOnce({
+ points: [{ timestamp: 1000, value: 42, min: 42, max: 42 }],
+ source: 'store',
+ } as never);
+ const [target, setTarget] = createSignal('pool-a');
+ const { container } = render(() => (
+
+ ));
+ const chart = screen.getByRole('img', { name: 'History chart' });
+ const description = document.getElementById(chart.getAttribute('aria-describedby')!)!;
+ const rectSpy = vi.spyOn(chart, 'getBoundingClientRect').mockReturnValue({
+ x: 0,
+ y: 0,
+ left: 0,
+ top: 0,
+ right: 300,
+ bottom: 200,
+ width: 300,
+ height: 200,
+ toJSON: () => ({}),
+ });
+ await waitFor(() => expect(description).toHaveTextContent('1 data point'));
+ fireEvent.mouseMove(chart, { clientX: 175 });
+ expect(container.querySelector('[data-history-chart-tooltip]')).toHaveTextContent('42.0%');
+ request.mockResolvedValueOnce({ points: [], source: 'store' } as never);
+ setTarget('pool-b');
+ await waitFor(() => expect(description).toHaveTextContent('No 1-hour history'));
+ expect(description).not.toHaveTextContent('0.0%');
+ expect(container.querySelector('[data-history-chart-tooltip]')).toBeNull();
+ rectSpy.mockRestore();
+ });
+
it('synchronizes the hovered timestamp across charts in the same group', () => {
const rectSpy = vi.spyOn(HTMLCanvasElement.prototype, 'getBoundingClientRect').mockReturnValue({
x: 0,
diff --git a/frontend-modern/src/components/shared/__tests__/historyChartModel.branchcov2.test.ts b/frontend-modern/src/components/shared/__tests__/historyChartModel.branchcov2.test.ts
index 76f4c1c49..fb517d9ae 100644
--- a/frontend-modern/src/components/shared/__tests__/historyChartModel.branchcov2.test.ts
+++ b/frontend-modern/src/components/shared/__tests__/historyChartModel.branchcov2.test.ts
@@ -406,6 +406,40 @@ describe('createHistoryChartGeometry', () => {
expect(geo.getX(100)).toBe(40);
});
+ it('centres a single timestamp and maps every plot position back to that observation', () => {
+ const geo = createHistoryChartGeometry({
+ width: 300,
+ height: 120,
+ startTime: 1000,
+ endTime: 1000,
+ minValue: 0,
+ maxValue: 100,
+ leftInset: 60,
+ rightInset: 20,
+ });
+ expect(geo.getX(1000)).toBe(170);
+ for (const x of [60, 100, 170, 280]) {
+ expect(geo.getTimestamp(x)).toBe(1000);
+ }
+ expect(geo.getY(0)).toBe(100);
+ });
+
+ it('inverts timestamp geometry without changing multi-observation interpolation', () => {
+ const geo = createHistoryChartGeometry({
+ width: 300,
+ height: 120,
+ startTime: 1000,
+ endTime: 3000,
+ minValue: 0,
+ maxValue: 100,
+ leftInset: 60,
+ rightInset: 20,
+ });
+ for (const timestamp of [1000, 1500, 2000, 3000]) {
+ expect(geo.getTimestamp(geo.getX(timestamp))).toBeCloseTo(timestamp);
+ }
+ });
+
it('left-pads the first timestamp to the chart origin', () => {
const geo = createHistoryChartGeometry({
width: 200,
diff --git a/frontend-modern/src/components/shared/historyChartModel.ts b/frontend-modern/src/components/shared/historyChartModel.ts
index 1e9cab000..bd7436171 100644
--- a/frontend-modern/src/components/shared/historyChartModel.ts
+++ b/frontend-modern/src/components/shared/historyChartModel.ts
@@ -241,14 +241,21 @@ export function createHistoryChartGeometry({
rightInset?: number;
}) {
const timeSpan = Math.max(1, endTime - startTime);
+ const plotWidth = width - leftInset - rightInset;
+ const singleTimestamp = startTime === endTime;
const getX = (timestamp: number) =>
- leftInset + ((timestamp - startTime) / timeSpan) * (width - leftInset - rightInset);
+ singleTimestamp
+ ? leftInset + plotWidth / 2
+ : leftInset + ((timestamp - startTime) / timeSpan) * plotWidth;
+ const getTimestamp = (x: number) =>
+ singleTimestamp ? startTime : startTime + ((x - leftInset) / plotWidth) * timeSpan;
const getY = (value: number) =>
height - 20 - ((value - minValue) / (maxValue - minValue)) * (height - 40);
return {
timeSpan,
getX,
+ getTimestamp,
getY,
};
}
diff --git a/frontend-modern/src/components/shared/useHistoryChartState.ts b/frontend-modern/src/components/shared/useHistoryChartState.ts
index a63b01d35..93d36881d 100644
--- a/frontend-modern/src/components/shared/useHistoryChartState.ts
+++ b/frontend-modern/src/components/shared/useHistoryChartState.ts
@@ -197,15 +197,22 @@ export function useHistoryChartState(
const yAxisTicks = getHistoryChartYAxisLabels(scale, props.unit);
const labelCount = 4;
const timeAxisTicks =
- points.length > 0
- ? Array.from({ length: labelCount }, (_, index) => {
- const timestamp =
- points[0].timestamp +
- ((points[points.length - 1].timestamp - points[0].timestamp) * index) /
- (labelCount - 1);
- return { timestamp, label: formatHistoryChartTimeLabel(timestamp, range()) };
- })
- : [];
+ points.length === 1
+ ? [
+ {
+ timestamp: points[0].timestamp,
+ label: formatHistoryChartTimeLabel(points[0].timestamp, range()),
+ },
+ ]
+ : points.length > 0
+ ? Array.from({ length: labelCount }, (_, index) => {
+ const timestamp =
+ points[0].timestamp +
+ ((points[points.length - 1].timestamp - points[0].timestamp) * index) /
+ (labelCount - 1);
+ return { timestamp, label: formatHistoryChartTimeLabel(timestamp, range()) };
+ })
+ : [];
ctx.font = '10px sans-serif';
chartLeftInset = getHistoryChartLeftInset(
@@ -248,26 +255,39 @@ export function useHistoryChartState(
rightInset: chartRightInset,
});
- ctx.beginPath();
- points.forEach((point, index) => {
- if (index === 0) ctx.moveTo(geometry.getX(point.timestamp), height - 20);
- ctx.lineTo(geometry.getX(point.timestamp), geometry.getY(point.value));
- });
- if (points.length > 0) {
+ if (points.length === 1) {
+ // A stored observation is visible before inspection, including measured
+ // zero. A line or filled area would invent a duration for this sample.
+ ctx.beginPath();
+ ctx.arc(
+ geometry.getX(points[0].timestamp),
+ geometry.getY(points[0].value),
+ 4,
+ 0,
+ Math.PI * 2,
+ );
+ ctx.fillStyle = mainColor;
+ ctx.fill();
+ } else {
+ ctx.beginPath();
+ points.forEach((point, index) => {
+ if (index === 0) ctx.moveTo(geometry.getX(point.timestamp), height - 20);
+ ctx.lineTo(geometry.getX(point.timestamp), geometry.getY(point.value));
+ });
ctx.lineTo(geometry.getX(points[points.length - 1].timestamp), height - 20);
- }
- ctx.closePath();
- ctx.fillStyle = `${mainColor}66`;
- ctx.fill();
+ ctx.closePath();
+ ctx.fillStyle = `${mainColor}66`;
+ ctx.fill();
- ctx.beginPath();
- ctx.strokeStyle = mainColor;
- ctx.lineWidth = 2;
- points.forEach((point, index) => {
- if (index === 0) ctx.moveTo(geometry.getX(point.timestamp), geometry.getY(point.value));
- else ctx.lineTo(geometry.getX(point.timestamp), geometry.getY(point.value));
- });
- ctx.stroke();
+ ctx.beginPath();
+ ctx.strokeStyle = mainColor;
+ ctx.lineWidth = 2;
+ points.forEach((point, index) => {
+ if (index === 0) ctx.moveTo(geometry.getX(point.timestamp), geometry.getY(point.value));
+ else ctx.lineTo(geometry.getX(point.timestamp), geometry.getY(point.value));
+ });
+ ctx.stroke();
+ }
ctx.fillStyle = axisTextColor;
ctx.font = '10px sans-serif';
@@ -404,9 +424,17 @@ export function useHistoryChartState(
return;
}
- const ratio = (x - chartLeftInset) / (width - chartLeftInset - chartRightInset);
- const timeSpan = Math.max(1, points[points.length - 1].timestamp - points[0].timestamp);
- setHoveredTimestamp(points[0].timestamp + ratio * timeSpan);
+ const geometry = createHistoryChartGeometry({
+ width,
+ height: chartHeight(),
+ startTime: points[0].timestamp,
+ endTime: points[points.length - 1].timestamp,
+ minValue: 0,
+ maxValue: 1,
+ leftInset: chartLeftInset,
+ rightInset: chartRightInset,
+ });
+ setHoveredTimestamp(geometry.getTimestamp(x));
};
const handleMouseLeave = () => {