From be2060ebe92e4a72bb4b820ff4591b977b2689d2 Mon Sep 17 00:00:00 2001 From: planadecu Date: Mon, 5 Oct 2026 15:09:16 +0200 Subject: [PATCH] Fix metric loading, chart gaps, and browse selection consistency --- CHANGELOG.md | 11 +++ package.json | 2 +- src/App.tsx | 11 +-- src/components/DataView.tsx | 36 +++++++--- src/hooks/useMetricData.ts | 86 +++++++++++++---------- src/lib/types.ts | 2 +- test/components/App.spec.tsx | 42 +++++++++++ test/components/DataView.spec.tsx | 63 +++++++++++++++-- test/hooks/useMetricData.spec.tsx | 111 ++++++++++++++++++++++++++++++ 9 files changed, 306 insertions(+), 58 deletions(-) create mode 100644 test/hooks/useMetricData.spec.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index d6cc862..9fc5b26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ All notable changes to `glassnode-terminal` are documented here. The project fol [semver](https://semver.org/); see [CONTRIBUTING.md](./CONTRIBUTING.md) for how versions and this file are updated. +## 0.2.3 + +- Keep successful metric data visible when the optional price overlay fails, and show an overlay + warning. Toggling the overlay no longer refetches the primary metric, avoiding extra x402 calls. +- Ignore obsolete metric/overlay responses and reset data, loading and errors when selection is + cleared. +- Show missing chart values and price timestamps as gaps instead of zeros. Structured responses + offer table mode rather than displaying a fabricated chart. +- Selecting an item in the middle pane commits the highlighted metric/asset pair in both browse + modes, including when entering that pane with Tab. + ## 0.2.2 - The repository moves to [glassnode/glassnode-terminal](https://github.com/glassnode/glassnode-terminal) diff --git a/package.json b/package.json index 87ad62c..fe0c47f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "glassnode-terminal", - "version": "0.2.2", + "version": "0.2.3", "description": "Interactive TUI for exploring Glassnode on-chain and market crypto data", "type": "module", "main": "dist/cli.js", diff --git a/src/App.tsx b/src/App.tsx index def00fc..289b0dd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -326,7 +326,7 @@ export function App(): React.ReactElement { }, [selectedMetricPath, validIntervals, validCurrencies]); // Metric data + price (fetched in parallel when both metric and asset are selected) - const { data, priceData, loading: dataLoading, error: dataError } = useMetricData( + const { data, priceData, loading: dataLoading, error: dataError, priceError } = useMetricData( selectedMetricPath, selectedAssetId, params, @@ -387,13 +387,15 @@ export function App(): React.ReactElement { } else if (activePane === Pane.Middle) { if (leftIsAssets) { const item = filteredMetricItems[filteredMetricNav.selectedIndex]; - if (item?.type === 'metric') { + if (item?.type === 'metric' && highlightedAssetSymbol) { + setSelectedAssetId(highlightedAssetSymbol); setSelectedMetricPath(item.path); lastMiddleMetricPath.current = item.path; } } else { const asset = filteredAssets[filteredAssetNav.selectedIndex]; - if (asset) { + if (asset && highlightedMetricPath) { + setSelectedMetricPath(highlightedMetricPath); setSelectedAssetId(asset.symbol); lastMiddleAssetId.current = asset.symbol; } @@ -401,7 +403,7 @@ export function App(): React.ReactElement { } }, [activePane, leftIsAssets, displayedAssets, displayedMetricItems, filteredAssets, filteredMetricItems, assetNav.selectedIndex, metricNav.selectedIndex, filteredAssetNav.selectedIndex, filteredMetricNav.selectedIndex, - selectedMetricPath, selectedAssetId, assetsByMetric]); + selectedMetricPath, selectedAssetId, assetsByMetric, highlightedAssetSymbol, highlightedMetricPath]); useInput((input, key) => { if (searchMode) return; @@ -573,6 +575,7 @@ export function App(): React.ReactElement { data={data} loading={dataLoading} error={dataError} + priceError={priceError} params={params} selectedMetric={selectedMetricPath} selectedAsset={selectedAssetId} diff --git a/src/components/DataView.tsx b/src/components/DataView.tsx index ea3e746..cd196a7 100644 --- a/src/components/DataView.tsx +++ b/src/components/DataView.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; import { Box, Text, measureElement, type DOMElement } from 'ink'; import { InkUPlot } from 'ink-uplot'; +import type uPlot from 'uplot'; import type { DataPoint, MetricParams } from '../lib/types.js'; import { formatDate, formatValue, padRight } from '../lib/format.js'; import { ParamBar } from './ParamBar.js'; @@ -12,6 +13,7 @@ interface DataViewProps { data: DataPoint[]; loading: boolean; error: string | null; + priceError?: string | null; params: MetricParams; selectedMetric: string | null; selectedAsset: string | null; @@ -31,6 +33,7 @@ export function DataView({ data, loading, error, + priceError, params, selectedMetric, selectedAsset, @@ -69,29 +72,33 @@ export function DataView({ if (!showPrice || priceData.length === 0) return null; const map = new Map(); for (const p of priceData) { - if (typeof p.v === 'number') map.set(p.t, p.v); + if (typeof p.v === 'number' && Number.isFinite(p.v)) map.set(p.t, p.v); } return map; }, [showPrice, priceData]); // Prepare chart data: [timestamps[], values[]] with optional price overlay - const chartData = useMemo(() => { - if (data.length === 0) return null; + const chartUnsupported = data.some((point) => + (point.v != null && typeof point.v !== 'number') || + (point.v == null && point.o != null), + ); + const chartData = useMemo(() => { + if (data.length === 0 || chartUnsupported) return null; const timestamps: number[] = []; - const values: number[] = []; + const values: Array = []; for (const point of data) { timestamps.push(point.t); - values.push(typeof point.v === 'number' ? point.v : 0); + values.push(typeof point.v === 'number' && Number.isFinite(point.v) ? point.v : null); } if (priceMap) { - const prices: number[] = timestamps.map((t) => priceMap.get(t) ?? 0); + const prices = timestamps.map((t) => priceMap.get(t) ?? null); // Price before value so uPlot draws it first (behind the main metric line). - return [timestamps, prices, values] as [number[], number[], number[]]; + return [timestamps, prices, values]; } - return [timestamps, values] as [number[], number[]]; - }, [data, priceMap]); + return [timestamps, values]; + }, [data, priceMap, chartUnsupported]); const chartOpts = useMemo(() => { const METRIC_COLOR = '#22d3ee'; // cyan — main metric line + its left axis @@ -138,6 +145,12 @@ export function DataView({ )} + {showPrice && priceError && ( + + Price overlay unavailable: {priceError} + + )} + {!loading && !error && data.length === 0 && selectedMetric && selectedAsset && ( No data @@ -145,6 +158,11 @@ export function DataView({ )} + {!loading && !error && data.length > 0 && viewMode === 'chart' && chartUnsupported && ( + + Chart unavailable for structured values. Press v for table view. + + )} {!loading && !error && data.length > 0 && viewMode === 'table' && ( diff --git a/src/hooks/useMetricData.ts b/src/hooks/useMetricData.ts index 55dac96..5e83c90 100644 --- a/src/hooks/useMetricData.ts +++ b/src/hooks/useMetricData.ts @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef } from 'react'; +import { useState, useEffect, useMemo } from 'react'; import { createClient } from '../lib/api-client.js'; import { parseTime } from '../lib/time-parse.js'; import type { DataPoint, MetricParams } from '../lib/types.js'; @@ -8,6 +8,7 @@ interface UseMetricDataResult { priceData: DataPoint[]; loading: boolean; error: string | null; + priceError: string | null; } export function useMetricData( @@ -20,47 +21,60 @@ export function useMetricData( const [priceData, setPriceData] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - const abortRef = useRef(0); + const [priceError, setPriceError] = useState(null); - useEffect(() => { - if (!metricPath || !asset) { - setData([]); - setPriceData([]); - return; - } + // Both requests use the same time range. Overlay toggles do not change it or refetch the metric. + const queryParams = useMemo(() => metricPath && asset ? { + a: asset, + i: params.interval, + s: String(parseTime(params.since)), + c: params.currency, + } : null, [metricPath, asset, params.interval, params.since, params.currency]); - const requestId = ++abortRef.current; - setLoading(true); + useEffect(() => { + let active = true; + setData([]); setError(null); + setLoading(false); - const queryParams: Record = { - a: asset, - i: params.interval, - s: String(parseTime(params.since)), - c: params.currency, - }; + if (metricPath && queryParams) { + setLoading(true); + Promise.resolve() + .then(() => createClient().callMetric(metricPath, queryParams)) + .then((result) => { + if (!active) return; + setData(Array.isArray(result) ? result : []); + setLoading(false); + }) + .catch((err: Error) => { + if (!active) return; + setError(err.message); + setLoading(false); + }); + } - const client = createClient(); - const metricPromise = client.callMetric(metricPath, queryParams); + // Invalidates late successes/failures on selection changes, clearing, and unmount. + return () => { active = false; }; + }, [metricPath, queryParams]); - const fetchPrice = showPrice && metricPath !== '/market/price_usd_close'; - const pricePromise = fetchPrice - ? client.callMetric('/market/price_usd_close', queryParams) - : Promise.resolve([]); + useEffect(() => { + let active = true; + setPriceData([]); + setPriceError(null); + + if (showPrice && metricPath && metricPath !== '/market/price_usd_close' && queryParams) { + Promise.resolve() + .then(() => createClient().callMetric('/market/price_usd_close', queryParams)) + .then((result) => { + if (active) setPriceData(Array.isArray(result) ? result : []); + }) + .catch((err: Error) => { + if (active) setPriceError(err.message); + }); + } - Promise.all([metricPromise, pricePromise]) - .then(([metricResult, priceResult]) => { - if (abortRef.current !== requestId) return; - setData(Array.isArray(metricResult) ? metricResult : []); - setPriceData(Array.isArray(priceResult) ? priceResult : []); - setLoading(false); - }) - .catch((err: Error) => { - if (abortRef.current !== requestId) return; - setError(err.message); - setLoading(false); - }); - }, [metricPath, asset, params.interval, params.since, params.currency, showPrice]); + return () => { active = false; }; + }, [metricPath, queryParams, showPrice]); - return { data, priceData, loading, error }; + return { data, priceData, loading, error, priceError }; } diff --git a/src/lib/types.ts b/src/lib/types.ts index 5f5325e..5c66a56 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -14,7 +14,7 @@ export interface MetricParams { export interface DataPoint { t: number; - v?: number | string; + v?: number | string | null; o?: Record; } diff --git a/test/components/App.spec.tsx b/test/components/App.spec.tsx index ff1c4d7..84dd3e6 100644 --- a/test/components/App.spec.tsx +++ b/test/components/App.spec.tsx @@ -171,6 +171,48 @@ describe('App integration', () => { mocks.callMetricMock.mockClear(); }); + it('selecting a middle-pane metric commits the highlighted asset after Tab', async () => { + const instance = renderInk(); + await wait(100); + // Commit BTC, then move back and highlight ETH without committing it. + instance.stdin.write(KEYS.enter); + await wait(); + instance.stdin.write('\x1B[D'); + await wait(); + instance.stdin.write(KEYS.down); + await wait(); + instance.stdin.write('\t'); + await wait(); + mocks.callMetricMock.mockClear(); + instance.stdin.write(KEYS.enter); + await vi.waitFor(() => expect(mocks.callMetricMock).toHaveBeenCalled()); + expect(mocks.callMetricMock.mock.calls.every((call: any[]) => call[1].a === 'ETH')).toBe(true); + instance.unmount(); + }); + + it('selecting a middle-pane asset commits the highlighted metric after Tab', async () => { + const instance = renderInk(); + await wait(100); + instance.stdin.write('m'); + await wait(); + // Commit Price USD Close + BTC. + instance.stdin.write(KEYS.enter); + await wait(); + instance.stdin.write(KEYS.enter); + await wait(); + // Highlight OHLC without Enter, then choose the asset via the middle pane. + instance.stdin.write('\x1B[D'); + await wait(); + instance.stdin.write(KEYS.down); + await wait(); + instance.stdin.write('\t'); + await wait(); + mocks.callMetricMock.mockClear(); + instance.stdin.write(KEYS.enter); + await vi.waitFor(() => expect(mocks.callMetricMock).toHaveBeenCalledWith('/market/price_usd_ohlc', expect.objectContaining({ a: 'BTC' }))); + instance.unmount(); + }); + it('per-pane search: /card filters assets only, → to metrics, /price filters metrics only, ↓↓ enter renders, esc, i cycles interval', async () => { const instance = renderInk(); // Widen the terminal so all 3 panes fit (asset=35 + metric=70 + data needs space) diff --git a/test/components/DataView.spec.tsx b/test/components/DataView.spec.tsx index 237ee27..aebd3e8 100644 --- a/test/components/DataView.spec.tsx +++ b/test/components/DataView.spec.tsx @@ -1,18 +1,22 @@ -import { vi, describe, it, expect } from 'vitest'; +import { vi, describe, it, expect, afterEach } from 'vitest'; import React from 'react'; import { Box } from 'ink'; -import { render } from 'ink-testing-library'; +import { render, cleanup } from 'ink-testing-library'; +import type { DataPoint } from '../../src/lib/types.js'; // Capture the size DataView gives the chart instead of rendering a real one. const chartSizes = vi.hoisted(() => [] as Array<{ width?: number; height?: number }>); +const chartValues = vi.hoisted(() => [] as Array>>); vi.mock('ink-uplot', () => ({ - InkUPlot: (props: { width?: number; height?: number }) => { + InkUPlot: (props: { width?: number; height?: number; data: Array> }) => { chartSizes.push({ width: props.width, height: props.height }); + chartValues.push(props.data); return null; }, })); const { DataView } = await import('../../src/components/DataView.js'); +afterEach(() => cleanup()); // Measuring takes a couple of render passes (layout, measure, setState, report), so poll // for the expected state instead of sleeping a fixed time (flaky on slower CI runners). @@ -27,20 +31,26 @@ async function until(cond: () => boolean, timeout = 3000): Promise { function renderDataView( paneWidth: number, paneHeight: number, - { viewMode = 'chart', points = 2, onBodyHeightChange }: { + { viewMode = 'chart', points = 2, onBodyHeightChange, data: suppliedData, priceData = [], showPrice = false, priceError }: { viewMode?: 'chart' | 'table'; points?: number; onBodyHeightChange?: (height: number) => void; + data?: DataPoint[]; + priceData?: DataPoint[]; + showPrice?: boolean; + priceError?: string; } = {}, ) { chartSizes.length = 0; - const data = Array.from({ length: points }, (_, i) => ({ t: 86400 * (i + 1), v: i + 1 })); + chartValues.length = 0; + const data = suppliedData ?? Array.from({ length: points }, (_, i) => ({ t: 86400 * (i + 1), v: i + 1 })); return render( @@ -77,6 +87,45 @@ describe('DataView chart sizing', () => { }); }); +describe('DataView chart values', () => { + it('keeps missing values as gaps and retains real zeros', async () => { + renderDataView(80, 20, { data: [{ t: 1, v: 12 }, { t: 2, v: null }, { t: 3 }, { t: 4, v: 0 }] }); + await until(() => chartValues.length > 0); + expect(chartValues.at(-1)).toEqual([[1, 2, 3, 4], [12, null, null, 0]]); + }); + + it('keeps missing overlay timestamps as gaps', async () => { + renderDataView(80, 20, { + data: [{ t: 1, v: 12 }, { t: 2, v: 13 }, { t: 3, v: 14 }], + showPrice: true, + priceData: [{ t: 1, v: 100 }, { t: 3, v: 0 }], + }); + await until(() => chartValues.length > 0); + expect(chartValues.at(-1)).toEqual([[1, 2, 3], [100, null, 0], [12, 13, 14]]); + }); + + it('offers table mode for object-valued metrics instead of drawing zeros', async () => { + const instance = renderDataView(80, 20, { data: [{ t: 1, o: { open: 123, close: 456 } }] }); + await until(() => instance.lastFrame()?.includes('Chart unavailable') ?? false); + expect(instance.lastFrame()).toContain('Press v for table view'); + expect(chartValues).toHaveLength(0); + }); + + it('preserves structured values in the table', async () => { + const instance = renderDataView(80, 20, { viewMode: 'table', data: [{ t: 1, o: { close: 456 } }] }); + await until(() => instance.lastFrame()?.includes('456') ?? false); + expect(instance.lastFrame()).toContain('456'); + expect(instance.lastFrame()).not.toContain('Chart unavailable'); + }); + + it('shows a price warning alongside the successful metric chart', async () => { + const instance = renderDataView(80, 20, { showPrice: true, priceError: 'price unavailable' }); + await until(() => chartValues.length > 0); + expect(instance.lastFrame()).toContain('Price overlay unavailable: price unavailable'); + expect(chartValues.at(-1)).toEqual([[86400, 172800], [1, 2]]); + }); +}); + describe('DataView table sizing', () => { it('reports the rows available below the param bar and metric line', async () => { const heights: number[] = []; diff --git a/test/hooks/useMetricData.spec.tsx b/test/hooks/useMetricData.spec.tsx new file mode 100644 index 0000000..02572ee --- /dev/null +++ b/test/hooks/useMetricData.spec.tsx @@ -0,0 +1,111 @@ +import React from 'react'; +import { Text } from 'ink'; +import { render, cleanup } from 'ink-testing-library'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { DataPoint, MetricParams } from '../../src/lib/types.js'; + +const { callMetric } = vi.hoisted(() => ({ callMetric: vi.fn() })); +vi.mock('../../src/lib/api-client.js', () => ({ createClient: () => ({ callMetric }) })); +const { useMetricData } = await import('../../src/hooks/useMetricData.js'); + +const params: MetricParams = { interval: '24h', since: '30d', currency: 'usd' }; +let state: ReturnType; +function Probe({ metric = '/addresses/count', asset = 'BTC', showPrice = true }: { + metric?: string | null; asset?: string | null; showPrice?: boolean; +}) { + state = useMetricData(metric, asset, params, showPrice); + return {JSON.stringify(state)}; +} +function deferred() { + let resolve!: (value: DataPoint[]) => void; + let reject!: (error: Error) => void; + const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); + return { promise, resolve, reject }; +} +async function settle() { + await new Promise((resolve) => setTimeout(resolve, 30)); +} +afterEach(() => { cleanup(); callMetric.mockReset(); }); + +describe('useMetricData', () => { + it('shows metric data without waiting for the optional price request', async () => { + const price = deferred(); + callMetric.mockImplementation((path) => path === '/market/price_usd_close' + ? price.promise : Promise.resolve([{ t: 1, v: 123 }])); + render(); + await vi.waitFor(() => expect(state.data).toEqual([{ t: 1, v: 123 }])); + expect(state.loading).toBe(false); + price.reject(new Error('price unavailable')); + await vi.waitFor(() => expect(state.priceError).toBe('price unavailable')); + expect(state.data).toEqual([{ t: 1, v: 123 }]); + expect(state.error).toBeNull(); + }); + + it.each(['success', 'failure'] as const)('ignores a late %s after selection is cleared', async (outcome) => { + const metric = deferred(); + callMetric.mockReturnValue(metric.promise); + const instance = render(); + await vi.waitFor(() => expect(state.loading).toBe(true)); + instance.rerender(); + await vi.waitFor(() => expect(state.loading).toBe(false)); + if (outcome === 'success') metric.resolve([{ t: 1, v: 123 }]); + else metric.reject(new Error('old failure')); + await settle(); + expect(state).toEqual({ data: [], priceData: [], loading: false, error: null, priceError: null }); + }); + + it('clears errors when the asset is deselected', async () => { + callMetric.mockRejectedValue(new Error('metric unavailable')); + const instance = render(); + await vi.waitFor(() => expect(state.error).toBe('metric unavailable')); + instance.rerender(); + await vi.waitFor(() => expect(state.error).toBeNull()); + expect(state.loading).toBe(false); + }); + + it('does not let an old asset response replace the newly selected asset', async () => { + const old = deferred(); + callMetric.mockImplementation((_path, query) => query.a === 'BTC' + ? old.promise : Promise.resolve([{ t: 1, v: 456 }])); + const instance = render(); + await vi.waitFor(() => expect(callMetric).toHaveBeenCalledTimes(1)); + instance.rerender(); + await vi.waitFor(() => expect(state.data).toEqual([{ t: 1, v: 456 }])); + old.resolve([{ t: 1, v: 123 }]); + await settle(); + expect(state.data).toEqual([{ t: 1, v: 456 }]); + }); + + it('toggling the price overlay does not refetch the primary metric', async () => { + callMetric.mockResolvedValue([{ t: 1, v: 123 }]); + const instance = render(); + await vi.waitFor(() => expect(callMetric).toHaveBeenCalledTimes(2)); + instance.rerender(); + await vi.waitFor(() => expect(state.priceData).toEqual([])); + instance.rerender(); + await vi.waitFor(() => expect(callMetric).toHaveBeenCalledTimes(3)); + expect(callMetric.mock.calls.filter(([path]) => path === '/addresses/count')).toHaveLength(1); + }); + + it('ignores a pending overlay failure after hiding the overlay', async () => { + const price = deferred(); + callMetric.mockImplementation((path) => path === '/market/price_usd_close' + ? price.promise : Promise.resolve([{ t: 1, v: 123 }])); + const instance = render(); + await vi.waitFor(() => expect(state.data).toHaveLength(1)); + instance.rerender(); + await settle(); + price.reject(new Error('old overlay failure')); + await settle(); + expect(state.priceError).toBeNull(); + expect(state.data).toHaveLength(1); + }); + + it('fetches the price metric only once when it is the primary metric', async () => { + callMetric.mockResolvedValue([{ t: 1, v: 123 }]); + render(); + await vi.waitFor(() => expect(state.loading).toBe(false)); + await vi.waitFor(() => expect(state.data).toHaveLength(1)); + expect(callMetric).toHaveBeenCalledTimes(1); + }); +});