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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
11 changes: 7 additions & 4 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -387,21 +387,23 @@ 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;
}
}
}
}, [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;
Expand Down Expand Up @@ -573,6 +575,7 @@ export function App(): React.ReactElement {
data={data}
loading={dataLoading}
error={dataError}
priceError={priceError}
params={params}
selectedMetric={selectedMetricPath}
selectedAsset={selectedAssetId}
Expand Down
36 changes: 27 additions & 9 deletions src/components/DataView.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -12,6 +13,7 @@ interface DataViewProps {
data: DataPoint[];
loading: boolean;
error: string | null;
priceError?: string | null;
params: MetricParams;
selectedMetric: string | null;
selectedAsset: string | null;
Expand All @@ -31,6 +33,7 @@ export function DataView({
data,
loading,
error,
priceError,
params,
selectedMetric,
selectedAsset,
Expand Down Expand Up @@ -69,29 +72,33 @@ export function DataView({
if (!showPrice || priceData.length === 0) return null;
const map = new Map<number, number>();
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<uPlot.AlignedData | null>(() => {
if (data.length === 0 || chartUnsupported) return null;
const timestamps: number[] = [];
const values: number[] = [];
const values: Array<number | null> = [];
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
Expand Down Expand Up @@ -138,13 +145,24 @@ export function DataView({
</Box>
)}

{showPrice && priceError && (
<Box paddingX={1}>
<Text color="yellow">Price overlay unavailable: {priceError}</Text>
</Box>
)}

{!loading && !error && data.length === 0 && selectedMetric && selectedAsset && (
<Box paddingX={1}>
<Text dimColor>No data</Text>
</Box>
)}

<Box ref={bodyRef} flexDirection="column" flexGrow={1} flexShrink={1} flexBasis={0} overflow="hidden">
{!loading && !error && data.length > 0 && viewMode === 'chart' && chartUnsupported && (
<Box paddingX={1}>
<Text dimColor>Chart unavailable for structured values. Press v for table view.</Text>
</Box>
)}
{!loading && !error && data.length > 0 && viewMode === 'table' && (
<Box flexDirection="column">
<Box paddingX={1} gap={2}>
Expand Down
86 changes: 50 additions & 36 deletions src/hooks/useMetricData.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -8,6 +8,7 @@ interface UseMetricDataResult {
priceData: DataPoint[];
loading: boolean;
error: string | null;
priceError: string | null;
}

export function useMetricData(
Expand All @@ -20,47 +21,60 @@ export function useMetricData(
const [priceData, setPriceData] = useState<DataPoint[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const abortRef = useRef(0);
const [priceError, setPriceError] = useState<string | null>(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<string, string> = {
a: asset,
i: params.interval,
s: String(parseTime(params.since)),
c: params.currency,
};
if (metricPath && queryParams) {
setLoading(true);
Promise.resolve()
.then(() => createClient().callMetric<DataPoint[]>(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<DataPoint[]>(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<DataPoint[]>('/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<DataPoint[]>('/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 };
}
2 changes: 1 addition & 1 deletion src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export interface MetricParams {

export interface DataPoint {
t: number;
v?: number | string;
v?: number | string | null;
o?: Record<string, unknown>;
}

Expand Down
42 changes: 42 additions & 0 deletions test/components/App.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<App />);
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(<App />);
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(<App />);
// Widen the terminal so all 3 panes fit (asset=35 + metric=70 + data needs space)
Expand Down
Loading
Loading