version 0.2
This commit is contained in:
12
package-lock.json
generated
12
package-lock.json
generated
@@ -2750,6 +2750,18 @@
|
||||
"integrity": "sha512-d4ZVpCW31eWwCMe1YT3ur7mUDnTXbgwyzaL320DrcRT45rfjYxkt5QWLrmOJ+/UEAI2+fQgKe/fCjR8l4TpRgw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/date-fns": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
|
||||
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/kossnocorp"
|
||||
}
|
||||
},
|
||||
"node_modules/dayjs": {
|
||||
"version": "1.11.13",
|
||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz",
|
||||
|
788
src/App.tsx
788
src/App.tsx
@@ -1,8 +1,9 @@
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
import { Trade, fetchTrades, aggregateCandles } from './utils/qortTrades';
|
||||
import { Trade, aggregateCandles } from './utils/qortTrades';
|
||||
import { Candle } from './utils/qortTrades';
|
||||
import { QortMultiChart } from './components/QortMultiChart';
|
||||
import { QortalAccountName } from './utils/qortTrades';
|
||||
// import { QortalAccountName } from './utils/qortTrades';
|
||||
import { useTradeData, useTradeActions } from './context/TradeDataProvider';
|
||||
|
||||
import {
|
||||
Button,
|
||||
@@ -50,8 +51,6 @@ const ONE_DAY = 24 * ONE_HOUR;
|
||||
const LS_KEY = 'QORT_CANDLE_TRADES';
|
||||
const LS_VERSION = 1;
|
||||
|
||||
type ChainMap<T> = Record<string, T>;
|
||||
|
||||
export default function App() {
|
||||
const theme = useTheme();
|
||||
|
||||
@@ -59,37 +58,30 @@ export default function App() {
|
||||
const [selectedChain, setSelectedChain] = useState<string>(CHAINS[0].value);
|
||||
const [interval, setInterval] = useState<number>(ONE_DAY);
|
||||
const [period, setPeriod] = useState<string>('1Y');
|
||||
|
||||
// --- Data state ---
|
||||
const [allChainTrades, setAllChainTrades] = useState<ChainMap<Trade[]>>({});
|
||||
const [cacheLoaded, setCacheLoaded] = useState<boolean>(false);
|
||||
const [needsUpdate, setNeedsUpdate] = useState<ChainMap<boolean>>({});
|
||||
const [isFetching, setIsFetching] = useState<ChainMap<boolean>>({});
|
||||
const [fetchProgress, setFetchProgress] = useState<Record<string, number>>(
|
||||
const [fetchedChains, setFetchedChains] = useState<Record<string, boolean>>(
|
||||
{}
|
||||
);
|
||||
|
||||
const {
|
||||
allChainTrades,
|
||||
isFetching,
|
||||
needsUpdate,
|
||||
fetchProgress,
|
||||
showCacheStaleWarning,
|
||||
cacheLoaded,
|
||||
} = useTradeData();
|
||||
|
||||
const { doFullFetch, doIncrementalFetch, doHistoricalFetch, clearCache } =
|
||||
useTradeActions();
|
||||
|
||||
// --- Top Buyer/Seller account names state ---
|
||||
const [accountNames, setAccountNames] = useState<Record<string, string>>({});
|
||||
// const [accountNames, setAccountNames] = useState<Record<string, string>>({});
|
||||
|
||||
// --- Helpers ---
|
||||
const getLatest = (trades: Trade[]) =>
|
||||
trades.length ? Math.max(...trades.map((t) => t.tradeTimestamp)) : 0;
|
||||
|
||||
// --- 1) Load cache ---
|
||||
useEffect(() => {
|
||||
const raw = localStorage.getItem(LS_KEY);
|
||||
if (raw) {
|
||||
try {
|
||||
const { version, allChainTrades: saved } = JSON.parse(raw);
|
||||
if (version === LS_VERSION) setAllChainTrades(saved);
|
||||
else localStorage.removeItem(LS_KEY);
|
||||
} catch {
|
||||
localStorage.removeItem(LS_KEY);
|
||||
}
|
||||
}
|
||||
setCacheLoaded(true);
|
||||
}, []);
|
||||
|
||||
// --- 2) Save cache ---
|
||||
useEffect(() => {
|
||||
@@ -98,149 +90,82 @@ export default function App() {
|
||||
localStorage.setItem(LS_KEY, JSON.stringify(payload));
|
||||
}, [allChainTrades, cacheLoaded]);
|
||||
|
||||
// --- 3) Decide fetch strategy ---
|
||||
useEffect(() => {
|
||||
if (!cacheLoaded) return;
|
||||
const trades = allChainTrades[selectedChain] || [];
|
||||
if (!trades.length) doFullFetch(selectedChain);
|
||||
else {
|
||||
const age = Date.now() - getLatest(trades);
|
||||
setNeedsUpdate((m) => ({ ...m, [selectedChain]: age > ONE_DAY }));
|
||||
if (
|
||||
cacheLoaded &&
|
||||
!isFetching[selectedChain] &&
|
||||
trades.length === 0 &&
|
||||
!fetchedChains[selectedChain]
|
||||
) {
|
||||
console.log(`Auto-fetching ${selectedChain} trades...`);
|
||||
doFullFetch(selectedChain);
|
||||
setFetchedChains((prev) => ({ ...prev, [selectedChain]: true }));
|
||||
}
|
||||
}, [cacheLoaded, selectedChain, allChainTrades]);
|
||||
}, [
|
||||
cacheLoaded,
|
||||
selectedChain,
|
||||
allChainTrades,
|
||||
isFetching,
|
||||
fetchedChains,
|
||||
doFullFetch,
|
||||
]);
|
||||
|
||||
// // --- 4) Prepare candles ---
|
||||
// const candles = useMemo<Candle[]>(() => {
|
||||
// if (!cacheLoaded) return [];
|
||||
// const trades = allChainTrades[selectedChain] || [];
|
||||
// if (!trades.length) return [];
|
||||
|
||||
// // apply period filter
|
||||
// const now = Date.now();
|
||||
// const p = PERIODS.find((p) => p.label === period);
|
||||
// let cutoff = 0;
|
||||
// if (p) {
|
||||
// if (p.days != null) cutoff = now - p.days * ONE_DAY;
|
||||
// else if (p.months != null) {
|
||||
// const d = new Date(now);
|
||||
// d.setMonth(d.getMonth() - p.months);
|
||||
// cutoff = d.getTime();
|
||||
// }
|
||||
// }
|
||||
// const filtered = cutoff
|
||||
// ? trades.filter((t) => t.tradeTimestamp >= cutoff)
|
||||
// : trades;
|
||||
|
||||
// // percentile filter
|
||||
// const cleaned = fastPercentileFilter(filtered, 0.00005, 0.995);
|
||||
// return aggregateCandles(cleaned, interval);
|
||||
// }, [allChainTrades, selectedChain, period, interval, cacheLoaded]);
|
||||
|
||||
// --- Full fetch ---
|
||||
async function doFullFetch(chain: string) {
|
||||
setIsFetching((m) => ({ ...m, [chain]: true }));
|
||||
let all: Trade[] = [];
|
||||
let offset = 0;
|
||||
const BATCH = 100;
|
||||
try {
|
||||
while (true) {
|
||||
const batch = await fetchTrades({
|
||||
foreignBlockchain: chain,
|
||||
minimumTimestamp: 0,
|
||||
limit: BATCH,
|
||||
offset,
|
||||
reverse: true,
|
||||
});
|
||||
all = all.concat(batch);
|
||||
setFetchProgress((m) => ({ ...m, [chain]: all.length }));
|
||||
setAllChainTrades((m) => ({ ...m, [chain]: all }));
|
||||
if (batch.length < BATCH) break;
|
||||
offset += BATCH;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Full fetch error', e);
|
||||
} finally {
|
||||
setIsFetching((m) => ({ ...m, [chain]: false }));
|
||||
}
|
||||
}
|
||||
|
||||
// --- Incremental fetch ---
|
||||
async function doIncrementalFetch(chain: string) {
|
||||
setIsFetching((m) => ({ ...m, [chain]: true }));
|
||||
try {
|
||||
const existing = allChainTrades[chain] || [];
|
||||
const latest = getLatest(existing);
|
||||
let newTrades: Trade[] = [];
|
||||
let offset = 0;
|
||||
const BATCH = 100;
|
||||
while (true) {
|
||||
const batch = await fetchTrades({
|
||||
foreignBlockchain: chain,
|
||||
minimumTimestamp: latest + 1,
|
||||
limit: BATCH,
|
||||
offset,
|
||||
reverse: true,
|
||||
});
|
||||
newTrades = newTrades.concat(batch);
|
||||
setFetchProgress((m) => ({ ...m, [chain]: newTrades.length }));
|
||||
if (batch.length < BATCH) break;
|
||||
offset += BATCH;
|
||||
}
|
||||
if (newTrades.length)
|
||||
setAllChainTrades((m) => ({
|
||||
...m,
|
||||
[chain]: [...newTrades, ...(m[chain] || [])],
|
||||
}));
|
||||
setNeedsUpdate((m) => ({ ...m, [chain]: false }));
|
||||
} catch (e) {
|
||||
console.error('Incremental fetch error', e);
|
||||
} finally {
|
||||
setIsFetching((m) => ({ ...m, [chain]: false }));
|
||||
}
|
||||
}
|
||||
|
||||
async function doHistoricalFetch(chain: string) {
|
||||
const existing = allChainTrades[chain] || [];
|
||||
if (!existing.length) return doFullFetch(chain);
|
||||
|
||||
const earliest = Math.min(...existing.map((t) => t.tradeTimestamp));
|
||||
let allOld: Trade[] = [];
|
||||
let offset = 0;
|
||||
const BATCH = 100;
|
||||
while (true) {
|
||||
const batch = await fetchTrades({
|
||||
foreignBlockchain: chain,
|
||||
minimumTimestamp: 0,
|
||||
maximumTimestamp: earliest - 1,
|
||||
limit: BATCH,
|
||||
offset,
|
||||
reverse: false, // ascending older trades
|
||||
});
|
||||
if (!batch.length) break;
|
||||
allOld = allOld.concat(batch);
|
||||
offset += BATCH;
|
||||
}
|
||||
if (allOld.length) {
|
||||
setAllChainTrades((prev) => ({
|
||||
...prev,
|
||||
[chain]: [...prev[chain], ...allOld],
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// --- percentile filter ---
|
||||
function fastPercentileFilter(trades: Trade[], lower = 0.002, upper = 0.998) {
|
||||
if (trades.length < 200) return trades;
|
||||
const prices = trades
|
||||
.map((t) => parseFloat(t.foreignAmount) / parseFloat(t.qortAmount))
|
||||
.filter((x) => isFinite(x) && x > 0)
|
||||
.sort((a, b) => a - b);
|
||||
const lo = prices[Math.floor(prices.length * lower)];
|
||||
const hi = prices[Math.ceil(prices.length * upper) - 1];
|
||||
return trades.filter((t) => {
|
||||
const p = parseFloat(t.foreignAmount) / parseFloat(t.qortAmount);
|
||||
return p >= lo && p <= hi;
|
||||
function filterByWeightedAverage(trades: Trade[], tolerance = 1.0): Trade[] {
|
||||
const validTrades = trades.filter((t) => {
|
||||
const fq = parseFloat(t.qortAmount);
|
||||
const ff = parseFloat(t.foreignAmount);
|
||||
return isFinite(fq) && isFinite(ff) && fq > 0 && ff > 0;
|
||||
});
|
||||
|
||||
if (!validTrades.length) return [];
|
||||
|
||||
// Step 1: Calculate weighted average
|
||||
let totalWeight = 0;
|
||||
let weightedSum = 0;
|
||||
|
||||
for (const t of validTrades) {
|
||||
const fq = parseFloat(t.qortAmount);
|
||||
const ff = parseFloat(t.foreignAmount);
|
||||
const price = ff / fq;
|
||||
|
||||
totalWeight += fq;
|
||||
weightedSum += fq * price;
|
||||
}
|
||||
|
||||
const weightedAvg = weightedSum / totalWeight;
|
||||
|
||||
// Step 2: Reject outliers
|
||||
const minPrice = weightedAvg / (1 + tolerance);
|
||||
const maxPrice = weightedAvg * (1 + tolerance);
|
||||
|
||||
return validTrades.filter((t) => {
|
||||
const fq = parseFloat(t.qortAmount);
|
||||
const ff = parseFloat(t.foreignAmount);
|
||||
const price = ff / fq;
|
||||
return price >= minPrice && price <= maxPrice;
|
||||
});
|
||||
}
|
||||
|
||||
function filterTradesByPeriod(trades: Trade[], periodLabel: string): Trade[] {
|
||||
const now = Date.now();
|
||||
const p = PERIODS.find((p) => p.label === periodLabel);
|
||||
let cutoff = 0;
|
||||
|
||||
if (p) {
|
||||
if (p.months != null) {
|
||||
const d = new Date(now);
|
||||
d.setMonth(d.getMonth() - p.months);
|
||||
cutoff = d.getTime();
|
||||
}
|
||||
}
|
||||
|
||||
return cutoff
|
||||
? trades.filter(
|
||||
(t) =>
|
||||
typeof t.tradeTimestamp === 'number' && t.tradeTimestamp >= cutoff
|
||||
)
|
||||
: trades;
|
||||
}
|
||||
|
||||
const {
|
||||
@@ -262,33 +187,40 @@ export default function App() {
|
||||
cutoff = d.getTime();
|
||||
}
|
||||
}
|
||||
const filtered = cutoff
|
||||
const timeFiltered = cutoff
|
||||
? trades.filter((t) => t.tradeTimestamp >= cutoff)
|
||||
: trades;
|
||||
|
||||
// clean and aggregate for chart
|
||||
const cleaned = fastPercentileFilter(filtered, 0.00005, 0.995);
|
||||
// const cleaned = fastPercentileFilter(filtered, 0.01, 0.99);
|
||||
const cleaned = filterByWeightedAverage(timeFiltered, 6.3);
|
||||
const agg = aggregateCandles(cleaned, interval);
|
||||
|
||||
return { candles: agg, filteredTrades: cleaned };
|
||||
}, [allChainTrades, selectedChain, period, interval, cacheLoaded]);
|
||||
|
||||
const rawTrades = useMemo(() => {
|
||||
return filterTradesByPeriod(allChainTrades[selectedChain] || [], period);
|
||||
}, [allChainTrades, selectedChain, period]);
|
||||
|
||||
// compute metrics
|
||||
const tradeCount = filteredTrades.length;
|
||||
const tradeCount = rawTrades.length;
|
||||
const totalQ = useMemo(
|
||||
() => filteredTrades.reduce((s, t) => s + parseFloat(t.qortAmount), 0),
|
||||
[filteredTrades]
|
||||
() => rawTrades.reduce((s, t) => s + parseFloat(t.qortAmount), 0),
|
||||
[rawTrades]
|
||||
);
|
||||
const totalF = useMemo(
|
||||
() => filteredTrades.reduce((s, t) => s + parseFloat(t.foreignAmount), 0),
|
||||
[filteredTrades]
|
||||
() => rawTrades.reduce((s, t) => s + parseFloat(t.foreignAmount), 0),
|
||||
[rawTrades]
|
||||
);
|
||||
const prices = useMemo(
|
||||
() =>
|
||||
filteredTrades
|
||||
rawTrades
|
||||
.map((t) => parseFloat(t.foreignAmount) / parseFloat(t.qortAmount))
|
||||
.filter((v) => isFinite(v)),
|
||||
[filteredTrades]
|
||||
[rawTrades]
|
||||
);
|
||||
|
||||
const highPrice = prices.length ? Math.max(...prices) : 0;
|
||||
const lowPrice = prices.length ? Math.min(...prices) : 0;
|
||||
// biggest buyer/seller
|
||||
@@ -336,19 +268,8 @@ export default function App() {
|
||||
};
|
||||
}, [filteredTrades]);
|
||||
|
||||
function isQortalAccountNameArray(arr: unknown): arr is QortalAccountName[] {
|
||||
return (
|
||||
Array.isArray(arr) &&
|
||||
// every element is an object with string `name` and `owner`
|
||||
arr.every(
|
||||
(el) =>
|
||||
typeof el === 'object' &&
|
||||
el !== null &&
|
||||
typeof el.name === 'string' &&
|
||||
typeof el.owner === 'string'
|
||||
)
|
||||
);
|
||||
}
|
||||
const { resolveAccountNames } = useTradeActions();
|
||||
const { accountNames } = useTradeData();
|
||||
|
||||
useEffect(() => {
|
||||
const addrs = [buyerStats.addr, sellerStats.addr].filter(
|
||||
@@ -356,38 +277,8 @@ export default function App() {
|
||||
);
|
||||
if (!addrs.length) return;
|
||||
|
||||
Promise.all(
|
||||
addrs.map(async (addr) => {
|
||||
try {
|
||||
const resp = await qortalRequest({
|
||||
action: 'GET_ACCOUNT_NAMES',
|
||||
address: addr, // or `account: addr` if that’s what your node expects
|
||||
limit: 1,
|
||||
offset: 0,
|
||||
reverse: false,
|
||||
});
|
||||
if (!isQortalAccountNameArray(resp)) {
|
||||
console.warn('Unexpected GET_ACCOUNT_NAMES response:', resp);
|
||||
return { addr, name: 'No Name' };
|
||||
}
|
||||
const list = Array.isArray(resp) ? resp : [];
|
||||
// find the entry matching our address, or fallback to the first
|
||||
const entry = list.find((x) => x.owner === addr) || list[0] || {};
|
||||
const name = entry.name?.trim() || 'No Name';
|
||||
return { addr, name };
|
||||
} catch (err) {
|
||||
console.error('Name lookup failed for', addr, err);
|
||||
return { addr, name: 'No Name' };
|
||||
}
|
||||
})
|
||||
).then((pairs) => {
|
||||
const map: Record<string, string> = {};
|
||||
pairs.forEach(({ addr, name }) => {
|
||||
map[addr] = name;
|
||||
});
|
||||
setAccountNames(map);
|
||||
});
|
||||
}, [buyerStats.addr, sellerStats.addr]);
|
||||
resolveAccountNames(addrs);
|
||||
}, [buyerStats.addr, sellerStats.addr, resolveAccountNames]);
|
||||
|
||||
if (!cacheLoaded) {
|
||||
const prog = fetchProgress[selectedChain] || 0;
|
||||
@@ -430,242 +321,263 @@ export default function App() {
|
||||
const stale = needsUpdate[selectedChain];
|
||||
const loading = isFetching[selectedChain];
|
||||
|
||||
// --- clear cache ---
|
||||
const clearCache = () => {
|
||||
localStorage.removeItem(LS_KEY);
|
||||
setAllChainTrades({});
|
||||
setNeedsUpdate({});
|
||||
};
|
||||
|
||||
return (
|
||||
<Container
|
||||
maxWidth={false}
|
||||
disableGutters
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100vh', // take full viewport height
|
||||
// overflow: 'hidden',
|
||||
// background: theme.palette.background.default,
|
||||
}}
|
||||
>
|
||||
{/* Top bar: status, controls, fetch buttons */}
|
||||
<Box
|
||||
// <TradeContext.Provider value={{ allChainTrades, accountNames }}>
|
||||
<Container maxWidth={false} disableGutters>
|
||||
<Container
|
||||
maxWidth={false}
|
||||
disableGutters
|
||||
sx={{
|
||||
flex: '0 0 auto',
|
||||
p: 2,
|
||||
position: 'relative',
|
||||
background: theme.palette.background.default,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100vh', // take full viewport height
|
||||
// overflow: 'hidden',
|
||||
// background: theme.palette.background.default,
|
||||
}}
|
||||
>
|
||||
{/* Status & Clear */}
|
||||
{/* <Box position="absolute" top={16} right={16} textAlign="right"> */}
|
||||
{/* Top bar: status, controls, fetch buttons */}
|
||||
<Box
|
||||
mx={1}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
alignContent="flex-end"
|
||||
flexDirection="row"
|
||||
sx={{
|
||||
flex: '0 0 auto',
|
||||
p: 2,
|
||||
position: 'relative',
|
||||
background: theme.palette.background.default,
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption">
|
||||
Trades: {tradesCount.toLocaleString()}
|
||||
<br />
|
||||
Latest: {latestDate}
|
||||
</Typography>
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
color="warning"
|
||||
onClick={clearCache}
|
||||
sx={{ mt: 1 }}
|
||||
{/* Status & Clear */}
|
||||
{/* <Box position="absolute" top={16} right={16} textAlign="right"> */}
|
||||
<Box
|
||||
mx={1}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
alignContent="flex-end"
|
||||
flexDirection="row"
|
||||
>
|
||||
Clear Cache
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
color="secondary"
|
||||
onClick={() => doHistoricalFetch(selectedChain)}
|
||||
disabled={isFetching[selectedChain]}
|
||||
sx={{ ml: 2 }}
|
||||
>
|
||||
Fetch Older Trades
|
||||
</Button>
|
||||
{/* Manual Update button */}
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
color="info"
|
||||
onClick={() => doIncrementalFetch(selectedChain)}
|
||||
disabled={isFetching[selectedChain]}
|
||||
sx={{ ml: 2 }}
|
||||
>
|
||||
Fetch Newer Trades
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* Controls */}
|
||||
<Box mb={2} display="flex" alignItems="center" flexWrap="wrap">
|
||||
<label>
|
||||
Pair:
|
||||
<select
|
||||
value={selectedChain}
|
||||
onChange={(e) => setSelectedChain(e.target.value)}
|
||||
>
|
||||
{CHAINS.map((c) => (
|
||||
<option key={c.value} value={c.value}>
|
||||
{c.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
Interval:
|
||||
<Button size="small" onClick={() => setInterval(ONE_HOUR)}>
|
||||
1H
|
||||
</Button>
|
||||
<Button size="small" onClick={() => setInterval(24 * ONE_HOUR)}>
|
||||
1D
|
||||
</Button>
|
||||
Show:
|
||||
{PERIODS.map((p) => (
|
||||
<Typography variant="caption">
|
||||
Trades: {tradesCount.toLocaleString()}
|
||||
<br />
|
||||
Latest: {latestDate}
|
||||
</Typography>
|
||||
<Button
|
||||
key={p.label}
|
||||
size="small"
|
||||
variant={period === p.label ? 'contained' : 'outlined'}
|
||||
onClick={() => setPeriod(p.label)}
|
||||
sx={{ mx: 0.1 }}
|
||||
>
|
||||
{p.label}
|
||||
</Button>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* Fetch Buttons */}
|
||||
<Box mx={2}>
|
||||
{!tradesCount && !loading && (
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => doFullFetch(selectedChain)}
|
||||
>
|
||||
Fetch ALL {selectedChain} Trades
|
||||
</Button>
|
||||
)}
|
||||
{stale && !loading && (
|
||||
<Button
|
||||
variant="contained"
|
||||
color="warning"
|
||||
onClick={() => doIncrementalFetch(selectedChain)}
|
||||
onClick={clearCache}
|
||||
sx={{ mt: 1 }}
|
||||
>
|
||||
Clear Cache
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
color="secondary"
|
||||
onClick={() => doHistoricalFetch(selectedChain)}
|
||||
disabled={isFetching[selectedChain]}
|
||||
sx={{ ml: 2 }}
|
||||
>
|
||||
Fetch new trades (notice!)
|
||||
Fetch Older Trades
|
||||
</Button>
|
||||
)}
|
||||
{loading && <CircularProgress size={24} sx={{ ml: 2 }} />}
|
||||
{/* Manual Update button */}
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
color="info"
|
||||
onClick={() => doIncrementalFetch(selectedChain)}
|
||||
disabled={isFetching[selectedChain]}
|
||||
sx={{ ml: 2 }}
|
||||
>
|
||||
Fetch Newer Trades
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* Controls */}
|
||||
<Box mb={2} display="flex" alignItems="center" flexWrap="wrap">
|
||||
<label>
|
||||
Pair:
|
||||
<select
|
||||
value={selectedChain}
|
||||
onChange={(e) => setSelectedChain(e.target.value)}
|
||||
>
|
||||
{CHAINS.map((c) => (
|
||||
<option key={c.value} value={c.value}>
|
||||
{c.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
Interval:
|
||||
<Button size="small" onClick={() => setInterval(ONE_HOUR)}>
|
||||
1H
|
||||
</Button>
|
||||
<Button size="small" onClick={() => setInterval(24 * ONE_HOUR)}>
|
||||
1D
|
||||
</Button>
|
||||
Show:
|
||||
{PERIODS.map((p) => (
|
||||
<Button
|
||||
key={p.label}
|
||||
size="small"
|
||||
variant={period === p.label ? 'contained' : 'outlined'}
|
||||
onClick={() => setPeriod(p.label)}
|
||||
sx={{ mx: 0.1 }}
|
||||
>
|
||||
{p.label}
|
||||
</Button>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* Fetch Buttons */}
|
||||
<Box mx={2}>
|
||||
{!tradesCount && !loading && (
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => doFullFetch(selectedChain)}
|
||||
>
|
||||
Fetch ALL {selectedChain} Trades
|
||||
</Button>
|
||||
)}
|
||||
{stale && !loading && (
|
||||
<Button
|
||||
variant="contained"
|
||||
color="warning"
|
||||
onClick={() => doIncrementalFetch(selectedChain)}
|
||||
sx={{ ml: 2 }}
|
||||
>
|
||||
Fetch new trades (notice!)
|
||||
</Button>
|
||||
)}
|
||||
{/*display cache state warning */}
|
||||
{showCacheStaleWarning && (
|
||||
<Box
|
||||
sx={{
|
||||
mt: 2,
|
||||
p: 2,
|
||||
border: '1px dashed orange',
|
||||
borderRadius: 2,
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" color="warning.main">
|
||||
The cached trade data may be outdated.
|
||||
</Typography>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={() => doIncrementalFetch(selectedChain)}
|
||||
sx={{ mt: 1 }}
|
||||
>
|
||||
Fetch Newer Trades
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
{loading && <CircularProgress size={24} sx={{ ml: 2 }} />}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
{/* --- Pretty Metrics Row --- */}
|
||||
<Paper
|
||||
elevation={1}
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: {
|
||||
xs: '1fr', // single column on mobile
|
||||
sm: 'repeat(3, 1fr)', // three columns on tablet+
|
||||
md: 'repeat(6, auto)', // six auto‐sized columns on desktop
|
||||
},
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
px: 2,
|
||||
py: 1,
|
||||
mb: 2,
|
||||
background: theme.palette.background.paper,
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" noWrap>
|
||||
<strong>{period} Trades:</strong> {tradeCount.toLocaleString()}
|
||||
</Typography>
|
||||
<Divider orientation="vertical" flexItem />
|
||||
<Typography variant="body2" noWrap>
|
||||
<strong>{period} Vol (QORT):</strong> {totalQ.toFixed(4)}
|
||||
</Typography>
|
||||
<Divider orientation="vertical" flexItem />
|
||||
<Typography variant="body2" noWrap>
|
||||
<strong>
|
||||
{period} Vol ({selectedChain}):
|
||||
</strong>{' '}
|
||||
{totalF.toFixed(4)}
|
||||
</Typography>
|
||||
<Divider orientation="vertical" flexItem />
|
||||
<Typography variant="body2" noWrap>
|
||||
<strong> {period} High:</strong> {highPrice.toFixed(8)}
|
||||
</Typography>
|
||||
<Divider orientation="vertical" flexItem />
|
||||
<Typography variant="body2" noWrap>
|
||||
<strong> {period} Low:</strong> {lowPrice.toFixed(8)}
|
||||
</Typography>
|
||||
<Divider orientation="vertical" flexItem />
|
||||
<Typography>
|
||||
<strong>Top Buyer:</strong> {accountNames[buyerStats.addr]} |{' '}
|
||||
{buyerStats.addr}
|
||||
<br />
|
||||
<em>Bought:</em> {buyerStats.totalQ.toFixed(4)} QORT @ (avg/Q){' '}
|
||||
{buyerStats.avgPrice.toFixed(8)} {selectedChain}
|
||||
</Typography>
|
||||
<Typography>
|
||||
<strong>Top Seller:</strong> {accountNames[sellerStats.addr]} |{' '}
|
||||
{sellerStats.addr}
|
||||
<br />
|
||||
<em>Sold:</em> {sellerStats.totalQ.toFixed(4)} QORT @ (avg/Q){' '}
|
||||
{sellerStats.avgPrice.toFixed(8)} {selectedChain}
|
||||
</Typography>
|
||||
</Paper>
|
||||
{/* Chart */}
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1, // fill all leftover space
|
||||
display: 'flex',
|
||||
overflow: 'hidden', // clip any chart overflow
|
||||
p: 2,
|
||||
background: theme.palette.background.paper,
|
||||
}}
|
||||
>
|
||||
{candles.length ? (
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
background: 'transparent', // or theme.palette.background.default
|
||||
p: 2,
|
||||
}}
|
||||
>
|
||||
<QortMultiChart
|
||||
candles={candles}
|
||||
showSMA
|
||||
themeMode={theme.palette.mode as 'light' | 'dark'}
|
||||
background={theme.palette.background.paper}
|
||||
textColor={theme.palette.text.primary}
|
||||
pairLabel={
|
||||
CHAINS.find((c) => c.value === selectedChain)?.label ||
|
||||
selectedChain
|
||||
}
|
||||
interval={interval}
|
||||
/>
|
||||
</Box>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
{/* --- Pretty Metrics Row --- */}
|
||||
<Paper
|
||||
elevation={1}
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: {
|
||||
xs: '1fr', // single column on mobile
|
||||
sm: 'repeat(3, 1fr)', // three columns on tablet+
|
||||
md: 'repeat(6, auto)', // six auto‐sized columns on desktop
|
||||
},
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
px: 2,
|
||||
py: 1,
|
||||
mb: 2,
|
||||
background: theme.palette.background.paper,
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" noWrap>
|
||||
<strong>{period} Trades:</strong> {tradeCount.toLocaleString()}
|
||||
</Typography>
|
||||
<Divider orientation="vertical" flexItem />
|
||||
<Typography variant="body2" noWrap>
|
||||
<strong>{period} Vol (QORT):</strong> {totalQ.toFixed(4)}
|
||||
</Typography>
|
||||
<Divider orientation="vertical" flexItem />
|
||||
<Typography variant="body2" noWrap>
|
||||
<strong>
|
||||
{period} Vol ({selectedChain}):
|
||||
</strong>{' '}
|
||||
{totalF.toFixed(4)}
|
||||
</Typography>
|
||||
<Divider orientation="vertical" flexItem />
|
||||
<Typography variant="body2" noWrap>
|
||||
<strong> {period} High:</strong> {highPrice.toFixed(8)}
|
||||
</Typography>
|
||||
<Divider orientation="vertical" flexItem />
|
||||
<Typography variant="body2" noWrap>
|
||||
<strong> {period} Low:</strong> {lowPrice.toFixed(8)}
|
||||
</Typography>
|
||||
<Divider orientation="vertical" flexItem />
|
||||
<Typography>
|
||||
<strong>Top Buyer:</strong> {accountNames[buyerStats.addr]} |{' '}
|
||||
{buyerStats.addr}
|
||||
<br />
|
||||
<em>Bought:</em> {buyerStats.totalQ.toFixed(4)} QORT @ (avg/Q){' '}
|
||||
{buyerStats.avgPrice.toFixed(8)} {selectedChain}
|
||||
</Typography>
|
||||
<Typography>
|
||||
<strong>Top Seller:</strong> {accountNames[sellerStats.addr]} |{' '}
|
||||
{sellerStats.addr}
|
||||
<br />
|
||||
<em>Sold:</em> {sellerStats.totalQ.toFixed(4)} QORT @ (avg/Q){' '}
|
||||
{sellerStats.avgPrice.toFixed(8)} {selectedChain}
|
||||
</Typography>
|
||||
</Paper>
|
||||
|
||||
{/* Chart */}
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1, // fill all leftover space
|
||||
display: 'flex',
|
||||
overflow: 'hidden', // clip any chart overflow
|
||||
p: 2,
|
||||
background: theme.palette.background.paper,
|
||||
}}
|
||||
>
|
||||
{candles.length ? (
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
background: 'transparent', // or theme.palette.background.default
|
||||
p: 2,
|
||||
}}
|
||||
>
|
||||
<QortMultiChart
|
||||
candles={candles}
|
||||
showSMA
|
||||
themeMode={theme.palette.mode as 'light' | 'dark'}
|
||||
background={theme.palette.background.paper}
|
||||
textColor={theme.palette.text.primary}
|
||||
pairLabel={
|
||||
CHAINS.find((c) => c.value === selectedChain)?.label ||
|
||||
selectedChain
|
||||
}
|
||||
interval={interval}
|
||||
/>
|
||||
</Box>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Container>
|
||||
</Container>
|
||||
// </TradeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
@@ -1,6 +1,7 @@
|
||||
import { Routes } from './routes/Routes.tsx';
|
||||
import { GlobalProvider } from 'qapp-core';
|
||||
import { publicSalt } from './qapp-config.ts';
|
||||
import { TradeDataProvider } from './context/TradeDataProvider';
|
||||
|
||||
export const AppWrapper = () => {
|
||||
return (
|
||||
@@ -17,7 +18,9 @@ export const AppWrapper = () => {
|
||||
publicSalt: publicSalt,
|
||||
}}
|
||||
>
|
||||
<Routes />
|
||||
<TradeDataProvider>
|
||||
<Routes />
|
||||
</TradeDataProvider>
|
||||
</GlobalProvider>
|
||||
);
|
||||
};
|
||||
|
67
src/components/FetchAllTradesModal.tsx
Normal file
67
src/components/FetchAllTradesModal.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import React from 'react';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import Button from '@mui/material/Button';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
|
||||
interface FetchAllTradesModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
isFetching: boolean;
|
||||
progress: number;
|
||||
total?: number | null;
|
||||
error?: string | null;
|
||||
chain: string;
|
||||
}
|
||||
|
||||
const FetchAllTradesModal: React.FC<FetchAllTradesModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
isFetching,
|
||||
progress,
|
||||
total,
|
||||
error,
|
||||
chain,
|
||||
}) => {
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="xs" fullWidth>
|
||||
<DialogTitle>Fetching All {chain} Trades</DialogTitle>
|
||||
<DialogContent>
|
||||
{isFetching ? (
|
||||
<>
|
||||
<Typography gutterBottom>
|
||||
Obtaining all trades for <b>{chain}</b>.<br />
|
||||
This could take a while, please be patient...
|
||||
</Typography>
|
||||
<Typography gutterBottom>
|
||||
<b>{progress}</b> trades fetched{total ? ` / ${total}` : ''}.
|
||||
</Typography>
|
||||
<CircularProgress />
|
||||
</>
|
||||
) : error ? (
|
||||
<Typography color="error" gutterBottom>
|
||||
Error: {error}
|
||||
</Typography>
|
||||
) : (
|
||||
<Typography color="success.main" gutterBottom>
|
||||
Fetch complete.
|
||||
</Typography>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
onClick={onClose}
|
||||
color={isFetching ? 'inherit' : 'primary'}
|
||||
variant="contained"
|
||||
>
|
||||
{isFetching ? 'Hide' : 'Close'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default FetchAllTradesModal;
|
20
src/components/Header.tsx
Normal file
20
src/components/Header.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import { AppBar, Toolbar, Button, Typography } from '@mui/material';
|
||||
|
||||
export default function Header() {
|
||||
return (
|
||||
<AppBar position="static">
|
||||
<Toolbar>
|
||||
<Typography variant="h6" sx={{ flexGrow: 1 }}>
|
||||
Q-Charts
|
||||
</Typography>
|
||||
<Button color="inherit" component={RouterLink} to="/">
|
||||
Home
|
||||
</Button>
|
||||
<Button color="inherit" component={RouterLink} to="/stats">
|
||||
History
|
||||
</Button>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
);
|
||||
}
|
330
src/context/TradeDataProvider.tsx
Normal file
330
src/context/TradeDataProvider.tsx
Normal file
@@ -0,0 +1,330 @@
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
useCallback,
|
||||
} from 'react';
|
||||
import {
|
||||
fetchTrades,
|
||||
// aggregateCandles,
|
||||
Trade,
|
||||
QortalAccountName,
|
||||
} from '../utils/qortTrades';
|
||||
|
||||
const LS_KEY = 'QORT_CANDLE_TRADES';
|
||||
const LS_VERSION = 1;
|
||||
const ONE_DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
interface TradeData {
|
||||
allChainTrades: Record<string, Trade[]>;
|
||||
accountNames: Record<string, string>;
|
||||
cacheLoaded: boolean;
|
||||
isFetching: Record<string, boolean>;
|
||||
needsUpdate: Record<string, boolean>;
|
||||
fetchProgress: Record<string, number>;
|
||||
showCacheStaleWarning: boolean;
|
||||
namesLoading: boolean;
|
||||
namesRemaining: number;
|
||||
}
|
||||
|
||||
interface TradeActions {
|
||||
doFullFetch: (chain: string) => Promise<void>;
|
||||
doIncrementalFetch: (chain: string) => Promise<void>;
|
||||
doHistoricalFetch: (chain: string) => Promise<void>;
|
||||
clearCache: () => void;
|
||||
resolveAccountNames: (addresses: string[]) => Promise<void>;
|
||||
}
|
||||
|
||||
const TradeDataContext = createContext<TradeData | undefined>(undefined);
|
||||
const TradeActionsContext = createContext<TradeActions | undefined>(undefined);
|
||||
|
||||
export const useTradeData = (): TradeData => {
|
||||
const context = useContext(TradeDataContext);
|
||||
if (!context)
|
||||
throw new Error('useTradeData must be used within TradeDataProvider');
|
||||
return context;
|
||||
};
|
||||
|
||||
export const useTradeActions = (): TradeActions => {
|
||||
const context = useContext(TradeActionsContext);
|
||||
if (!context)
|
||||
throw new Error('useTradeActions must be used within TradeDataProvider');
|
||||
return context;
|
||||
};
|
||||
|
||||
export const TradeDataProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
children,
|
||||
}) => {
|
||||
const [allChainTrades, setAllChainTrades] = useState<Record<string, Trade[]>>(
|
||||
{}
|
||||
);
|
||||
const [accountNames, setAccountNames] = useState<Record<string, string>>({});
|
||||
const [cacheLoaded, setCacheLoaded] = useState(false);
|
||||
const [isFetching, setIsFetching] = useState<Record<string, boolean>>({});
|
||||
const [needsUpdate, setNeedsUpdate] = useState<Record<string, boolean>>({});
|
||||
const [fetchProgress, setFetchProgress] = useState<Record<string, number>>(
|
||||
{}
|
||||
);
|
||||
const [showCacheStaleWarning, setShowCacheStaleWarning] = useState(false);
|
||||
|
||||
const BATCH_SIZE = 25;
|
||||
|
||||
const [namesLoading, setNamesLoading] = useState(false);
|
||||
const [namesRemaining, setNamesRemaining] = useState(0);
|
||||
|
||||
const resolveAccountNames = useCallback(
|
||||
async (addresses: string[]) => {
|
||||
const uniqueAddresses = addresses.filter(
|
||||
(addr) => addr && !accountNames[addr]
|
||||
);
|
||||
if (!uniqueAddresses.length) return;
|
||||
|
||||
await Promise.all(
|
||||
uniqueAddresses.map(async (addr) => {
|
||||
try {
|
||||
const resp = await qortalRequest({
|
||||
action: 'GET_ACCOUNT_NAMES',
|
||||
address: addr,
|
||||
limit: 1,
|
||||
offset: 0,
|
||||
reverse: false,
|
||||
});
|
||||
if (Array.isArray(resp) && resp.every(isQortalAccountName)) {
|
||||
const entry = resp.find((x) => x.owner === addr) || resp[0] || {};
|
||||
const name = entry.name?.trim() || 'No Name';
|
||||
setAccountNames((prev) => ({ ...prev, [addr]: name }));
|
||||
} else {
|
||||
setAccountNames((prev) => ({ ...prev, [addr]: 'No Name' }));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch account name for', addr, err);
|
||||
setAccountNames((prev) => ({ ...prev, [addr]: 'No Name' }));
|
||||
}
|
||||
})
|
||||
);
|
||||
},
|
||||
[accountNames]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const raw = localStorage.getItem(LS_KEY);
|
||||
if (raw) {
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (
|
||||
parsed.version === LS_VERSION &&
|
||||
typeof parsed.allChainTrades === 'object'
|
||||
) {
|
||||
setAllChainTrades(parsed.allChainTrades);
|
||||
if (parsed.accountNames && typeof parsed.accountNames === 'object') {
|
||||
setAccountNames(parsed.accountNames);
|
||||
}
|
||||
let hasRecent = false;
|
||||
for (const chain in parsed.allChainTrades) {
|
||||
const trades: Trade[] = parsed.allChainTrades[chain];
|
||||
if (!Array.isArray(trades) || !trades.length) continue;
|
||||
const timestamps = trades
|
||||
.map((t) => t.tradeTimestamp)
|
||||
.filter(Boolean);
|
||||
const latest = Math.max(...timestamps);
|
||||
const age = Date.now() - latest;
|
||||
if (age < 7 * ONE_DAY) {
|
||||
hasRecent = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
setShowCacheStaleWarning(!hasRecent);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to load trade cache:', err);
|
||||
}
|
||||
}
|
||||
setCacheLoaded(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (cacheLoaded) {
|
||||
const payload = {
|
||||
version: LS_VERSION,
|
||||
allChainTrades,
|
||||
accountNames,
|
||||
};
|
||||
|
||||
localStorage.setItem(LS_KEY, JSON.stringify(payload));
|
||||
}
|
||||
}, [allChainTrades, accountNames, cacheLoaded]);
|
||||
|
||||
const doFullFetch = async (chain: string) => {
|
||||
setIsFetching((prev) => ({ ...prev, [chain]: true }));
|
||||
let all: Trade[] = [];
|
||||
let offset = 0;
|
||||
const BATCH = 100;
|
||||
while (true) {
|
||||
const batch = await fetchTrades({
|
||||
foreignBlockchain: chain,
|
||||
minimumTimestamp: 0,
|
||||
limit: BATCH,
|
||||
offset,
|
||||
reverse: true,
|
||||
});
|
||||
all = all.concat(batch);
|
||||
setFetchProgress((prev) => ({ ...prev, [chain]: all.length }));
|
||||
if (batch.length < BATCH) break;
|
||||
offset += BATCH;
|
||||
}
|
||||
setAllChainTrades((prev) => ({ ...prev, [chain]: all }));
|
||||
setIsFetching((prev) => ({ ...prev, [chain]: false }));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!cacheLoaded) return;
|
||||
|
||||
const allAddresses = new Set<string>();
|
||||
Object.values(allChainTrades).forEach((trades) => {
|
||||
trades.forEach((t) => {
|
||||
if (t.buyerReceivingAddress) allAddresses.add(t.buyerReceivingAddress);
|
||||
if (t.sellerAddress) allAddresses.add(t.sellerAddress);
|
||||
});
|
||||
});
|
||||
|
||||
const unresolved = Array.from(allAddresses).filter(
|
||||
(addr) => addr && !accountNames[addr]
|
||||
);
|
||||
|
||||
if (!unresolved.length) return;
|
||||
|
||||
setNamesRemaining(unresolved.length);
|
||||
setNamesLoading(true);
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const resolveBatches = async () => {
|
||||
for (let i = 0; i < unresolved.length; i += BATCH_SIZE) {
|
||||
if (cancelled) return;
|
||||
const batch = unresolved.slice(i, i + BATCH_SIZE);
|
||||
await resolveAccountNames(batch);
|
||||
setNamesRemaining((r) => Math.max(r - batch.length, 0));
|
||||
}
|
||||
if (!cancelled) setNamesLoading(false);
|
||||
};
|
||||
|
||||
resolveBatches();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [allChainTrades, cacheLoaded, accountNames, resolveAccountNames]);
|
||||
|
||||
const doIncrementalFetch = async (chain: string) => {
|
||||
setIsFetching((prev) => ({ ...prev, [chain]: true }));
|
||||
const existing = allChainTrades[chain] || [];
|
||||
const latest = existing.length
|
||||
? Math.max(...existing.map((t) => t.tradeTimestamp))
|
||||
: 0;
|
||||
let newTrades: Trade[] = [];
|
||||
let offset = 0;
|
||||
const BATCH = 100;
|
||||
while (true) {
|
||||
const batch = await fetchTrades({
|
||||
foreignBlockchain: chain,
|
||||
minimumTimestamp: latest + 1,
|
||||
limit: BATCH,
|
||||
offset,
|
||||
reverse: true,
|
||||
});
|
||||
newTrades = newTrades.concat(batch);
|
||||
setFetchProgress((prev) => ({ ...prev, [chain]: newTrades.length }));
|
||||
if (batch.length < BATCH) break;
|
||||
offset += BATCH;
|
||||
}
|
||||
if (newTrades.length) {
|
||||
setAllChainTrades((prev) => ({
|
||||
...prev,
|
||||
[chain]: [...newTrades, ...(prev[chain] || [])],
|
||||
}));
|
||||
}
|
||||
setNeedsUpdate((prev) => ({ ...prev, [chain]: false }));
|
||||
setIsFetching((prev) => ({ ...prev, [chain]: false }));
|
||||
setShowCacheStaleWarning(false);
|
||||
};
|
||||
|
||||
const doHistoricalFetch = async (chain: string) => {
|
||||
const existing = allChainTrades[chain] || [];
|
||||
if (!existing.length) return doFullFetch(chain);
|
||||
|
||||
setIsFetching((prev) => ({ ...prev, [chain]: true }));
|
||||
const earliest = Math.min(...existing.map((t) => t.tradeTimestamp));
|
||||
let allOld: Trade[] = [];
|
||||
let offset = 0;
|
||||
const BATCH = 100;
|
||||
while (true) {
|
||||
const batch = await fetchTrades({
|
||||
foreignBlockchain: chain,
|
||||
maximumTimestamp: earliest - 1,
|
||||
limit: BATCH,
|
||||
offset,
|
||||
reverse: false,
|
||||
});
|
||||
if (!batch.length) break;
|
||||
allOld = allOld.concat(batch);
|
||||
offset += BATCH;
|
||||
}
|
||||
if (allOld.length) {
|
||||
setAllChainTrades((prev) => ({
|
||||
...prev,
|
||||
[chain]: [...prev[chain], ...allOld],
|
||||
}));
|
||||
}
|
||||
setIsFetching((prev) => ({ ...prev, [chain]: false }));
|
||||
};
|
||||
|
||||
const clearCache = () => {
|
||||
localStorage.removeItem(LS_KEY);
|
||||
setAllChainTrades({});
|
||||
setAccountNames({});
|
||||
setIsFetching({});
|
||||
setNeedsUpdate({});
|
||||
setFetchProgress({});
|
||||
setShowCacheStaleWarning(false);
|
||||
setNamesLoading(false);
|
||||
setNamesRemaining(0);
|
||||
};
|
||||
|
||||
function isQortalAccountName(obj: unknown): obj is QortalAccountName {
|
||||
return (
|
||||
typeof obj === 'object' &&
|
||||
obj !== null &&
|
||||
typeof (obj as QortalAccountName).name === 'string' &&
|
||||
typeof (obj as QortalAccountName).owner === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TradeDataContext.Provider
|
||||
value={{
|
||||
allChainTrades,
|
||||
accountNames,
|
||||
cacheLoaded,
|
||||
isFetching,
|
||||
needsUpdate,
|
||||
fetchProgress,
|
||||
showCacheStaleWarning,
|
||||
namesLoading,
|
||||
namesRemaining,
|
||||
}}
|
||||
>
|
||||
<TradeActionsContext.Provider
|
||||
value={{
|
||||
doFullFetch,
|
||||
doIncrementalFetch,
|
||||
doHistoricalFetch,
|
||||
clearCache,
|
||||
resolveAccountNames,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</TradeActionsContext.Provider>
|
||||
</TradeDataContext.Provider>
|
||||
);
|
||||
};
|
80
src/pages/Stats.tsx
Normal file
80
src/pages/Stats.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { useTradeData } from '../context/TradeDataProvider';
|
||||
import { Box, Typography, Divider, Pagination, useTheme } from '@mui/material';
|
||||
import { format } from 'date-fns';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
const PER_PAGE = 100;
|
||||
|
||||
export default function Stats() {
|
||||
const theme = useTheme();
|
||||
const { allChainTrades, accountNames, namesLoading, namesRemaining } =
|
||||
useTradeData();
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const mergedRawTrades = useMemo(() => {
|
||||
return Object.entries(allChainTrades)
|
||||
.flatMap(([chain, trades]) => trades.map((t) => ({ ...t, chain })))
|
||||
.sort((a, b) => b.tradeTimestamp - a.tradeTimestamp);
|
||||
}, [allChainTrades]);
|
||||
|
||||
const totalPages = Math.ceil(mergedRawTrades.length / PER_PAGE);
|
||||
const tradesOnPage = mergedRawTrades.slice(
|
||||
(page - 1) * PER_PAGE,
|
||||
page * PER_PAGE
|
||||
);
|
||||
|
||||
if (mergedRawTrades.length === 0) {
|
||||
return <div>Loading…</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box p={2}>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
Merged Trade History
|
||||
</Typography>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
|
||||
{namesLoading && (
|
||||
<Typography variant="body2" color="warning.main" sx={{ mb: 2 }}>
|
||||
Resolving account names… {namesRemaining} remaining
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{tradesOnPage.map((trade, idx) => {
|
||||
const date = format(trade.tradeTimestamp, 'yyyy-MM-dd HH:mm');
|
||||
const buyerAddr = trade.buyerReceivingAddress ?? '';
|
||||
const sellerAddr = trade.sellerAddress ?? '';
|
||||
const buyerName = buyerAddr ? accountNames[buyerAddr] || '—' : '—';
|
||||
const sellerName = sellerAddr ? accountNames[sellerAddr] || '—' : '—';
|
||||
|
||||
return (
|
||||
<Typography key={idx} variant="body2" sx={{ mb: 0.5 }}>
|
||||
<span style={{ color: theme.palette.error.main }}>
|
||||
<strong>{sellerName}</strong> ({sellerAddr})
|
||||
</span>{' '}
|
||||
→{' '}
|
||||
<span style={{ color: theme.palette.success.main }}>
|
||||
<strong>{buyerName}</strong> ({buyerAddr})
|
||||
</span>{' '}
|
||||
sold{' '}
|
||||
<strong>
|
||||
{parseFloat(trade.qortAmount).toFixed(4)} QORT →{' '}
|
||||
{parseFloat(trade.foreignAmount).toFixed(4)}
|
||||
</strong>{' '}
|
||||
<strong>{trade.chain}</strong> at {date}
|
||||
</Typography>
|
||||
);
|
||||
})}
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
|
||||
<Pagination
|
||||
count={totalPages}
|
||||
page={page}
|
||||
onChange={(_, val) => setPage(val)}
|
||||
variant="outlined"
|
||||
shape="rounded"
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
@@ -1,6 +1,7 @@
|
||||
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
|
||||
import Layout from '../styles/Layout';
|
||||
import App from '../App';
|
||||
import Stats from '../pages/Stats';
|
||||
|
||||
// Use a custom type if you need it
|
||||
interface CustomWindow extends Window {
|
||||
@@ -20,6 +21,10 @@ export function Routes() {
|
||||
index: true,
|
||||
element: <App />,
|
||||
},
|
||||
{
|
||||
path: 'stats',
|
||||
element: <Stats />,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
@@ -1,15 +1,16 @@
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { useIframe } from '../hooks/useIframeListener';
|
||||
import Header from '../components/Header';
|
||||
|
||||
const Layout = () => {
|
||||
useIframe();
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Add Header here */}
|
||||
<Header />
|
||||
<main>
|
||||
<Outlet /> {/* This is where page content will be rendered */}
|
||||
<Outlet />
|
||||
</main>
|
||||
{/* Add Footer here */}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@@ -25,5 +25,5 @@
|
||||
"noUncheckedSideEffectImports": true,
|
||||
"types": ["qapp-core/global"]
|
||||
},
|
||||
"include": ["src"]
|
||||
"include": ["src", "BACKUPS/tradeFetcher.ts"]
|
||||
}
|
||||
|
Reference in New Issue
Block a user