diff --git a/package-lock.json b/package-lock.json index 36166e1..ef29b4f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/src/App.tsx b/src/App.tsx index 0cf373d..1c536db 100644 --- a/src/App.tsx +++ b/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 = Record; - export default function App() { const theme = useTheme(); @@ -59,37 +58,30 @@ export default function App() { const [selectedChain, setSelectedChain] = useState(CHAINS[0].value); const [interval, setInterval] = useState(ONE_DAY); const [period, setPeriod] = useState('1Y'); - - // --- Data state --- - const [allChainTrades, setAllChainTrades] = useState>({}); - const [cacheLoaded, setCacheLoaded] = useState(false); - const [needsUpdate, setNeedsUpdate] = useState>({}); - const [isFetching, setIsFetching] = useState>({}); - const [fetchProgress, setFetchProgress] = useState>( + const [fetchedChains, setFetchedChains] = useState>( {} ); + const { + allChainTrades, + isFetching, + needsUpdate, + fetchProgress, + showCacheStaleWarning, + cacheLoaded, + } = useTradeData(); + + const { doFullFetch, doIncrementalFetch, doHistoricalFetch, clearCache } = + useTradeActions(); + // --- Top Buyer/Seller account names state --- - const [accountNames, setAccountNames] = useState>({}); + // const [accountNames, setAccountNames] = useState>({}); // --- 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(() => { - // 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 = {}; - 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 ( - - {/* Top bar: status, controls, fetch buttons */} - + + - {/* Status & Clear */} - {/* */} + {/* Top bar: status, controls, fetch buttons */} - - Trades: {tradesCount.toLocaleString()} -
- Latest: {latestDate} -
- - - {/* Manual Update button */} - -
- - {/* Controls */} - - -   Interval: - - -   Show: - {PERIODS.map((p) => ( + + Trades: {tradesCount.toLocaleString()} +
+ Latest: {latestDate} +
- ))} -
- - {/* Fetch Buttons */} - - {!tradesCount && !loading && ( - - )} - {stale && !loading && ( - + - )} - {loading && } + {/* Manual Update button */} + + + + {/* Controls */} + + +   Interval: + + +   Show: + {PERIODS.map((p) => ( + + ))} + + + {/* Fetch Buttons */} + + {!tradesCount && !loading && ( + + )} + {stale && !loading && ( + + )} + {/*display cache state warning */} + {showCacheStaleWarning && ( + + + The cached trade data may be outdated. + + + + )} + {loading && } +
-
- {/* --- Pretty Metrics Row --- */} - - - {period} Trades: {tradeCount.toLocaleString()} - - - - {period} Vol (QORT): {totalQ.toFixed(4)} - - - - - {period} Vol ({selectedChain}): - {' '} - {totalF.toFixed(4)} - - - - {period} High: {highPrice.toFixed(8)} - - - - {period} Low: {lowPrice.toFixed(8)} - - - - Top Buyer: {accountNames[buyerStats.addr]} |{' '} - {buyerStats.addr} -
- Bought: {buyerStats.totalQ.toFixed(4)} QORT @ (avg/Q){' '} - {buyerStats.avgPrice.toFixed(8)} {selectedChain} -
- - Top Seller: {accountNames[sellerStats.addr]} |{' '} - {sellerStats.addr} -
- Sold: {sellerStats.totalQ.toFixed(4)} QORT @ (avg/Q){' '} - {sellerStats.avgPrice.toFixed(8)} {selectedChain} -
-
- {/* Chart */} - - {candles.length ? ( - - c.value === selectedChain)?.label || - selectedChain - } - interval={interval} - /> - - ) : ( - - - - )} - + {/* --- Pretty Metrics Row --- */} + + + {period} Trades: {tradeCount.toLocaleString()} + + + + {period} Vol (QORT): {totalQ.toFixed(4)} + + + + + {period} Vol ({selectedChain}): + {' '} + {totalF.toFixed(4)} + + + + {period} High: {highPrice.toFixed(8)} + + + + {period} Low: {lowPrice.toFixed(8)} + + + + Top Buyer: {accountNames[buyerStats.addr]} |{' '} + {buyerStats.addr} +
+ Bought: {buyerStats.totalQ.toFixed(4)} QORT @ (avg/Q){' '} + {buyerStats.avgPrice.toFixed(8)} {selectedChain} +
+ + Top Seller: {accountNames[sellerStats.addr]} |{' '} + {sellerStats.addr} +
+ Sold: {sellerStats.totalQ.toFixed(4)} QORT @ (avg/Q){' '} + {sellerStats.avgPrice.toFixed(8)} {selectedChain} +
+
+ + {/* Chart */} + + {candles.length ? ( + + c.value === selectedChain)?.label || + selectedChain + } + interval={interval} + /> + + ) : ( + + + + )} + +
+ // ); } diff --git a/src/AppWrapper.tsx b/src/AppWrapper.tsx index 9d5ec98..7e26486 100644 --- a/src/AppWrapper.tsx +++ b/src/AppWrapper.tsx @@ -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, }} > - + + + ); }; diff --git a/src/components/FetchAllTradesModal.tsx b/src/components/FetchAllTradesModal.tsx new file mode 100644 index 0000000..2f395ab --- /dev/null +++ b/src/components/FetchAllTradesModal.tsx @@ -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 = ({ + open, + onClose, + isFetching, + progress, + total, + error, + chain, +}) => { + return ( + + Fetching All {chain} Trades + + {isFetching ? ( + <> + + Obtaining all trades for {chain}.
+ This could take a while, please be patient... +
+ + {progress} trades fetched{total ? ` / ${total}` : ''}. + + + + ) : error ? ( + + Error: {error} + + ) : ( + + Fetch complete. + + )} +
+ + + +
+ ); +}; + +export default FetchAllTradesModal; diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 0000000..9f4dbf4 --- /dev/null +++ b/src/components/Header.tsx @@ -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 ( + + + + Q-Charts + + + + + + ); +} diff --git a/src/context/TradeDataProvider.tsx b/src/context/TradeDataProvider.tsx new file mode 100644 index 0000000..ae7d433 --- /dev/null +++ b/src/context/TradeDataProvider.tsx @@ -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; + accountNames: Record; + cacheLoaded: boolean; + isFetching: Record; + needsUpdate: Record; + fetchProgress: Record; + showCacheStaleWarning: boolean; + namesLoading: boolean; + namesRemaining: number; +} + +interface TradeActions { + doFullFetch: (chain: string) => Promise; + doIncrementalFetch: (chain: string) => Promise; + doHistoricalFetch: (chain: string) => Promise; + clearCache: () => void; + resolveAccountNames: (addresses: string[]) => Promise; +} + +const TradeDataContext = createContext(undefined); +const TradeActionsContext = createContext(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>( + {} + ); + const [accountNames, setAccountNames] = useState>({}); + const [cacheLoaded, setCacheLoaded] = useState(false); + const [isFetching, setIsFetching] = useState>({}); + const [needsUpdate, setNeedsUpdate] = useState>({}); + const [fetchProgress, setFetchProgress] = useState>( + {} + ); + 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(); + 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 ( + + + {children} + + + ); +}; diff --git a/src/pages/Stats.tsx b/src/pages/Stats.tsx new file mode 100644 index 0000000..e6440bc --- /dev/null +++ b/src/pages/Stats.tsx @@ -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
Loading…
; + } + + return ( + + + Merged Trade History + + + + {namesLoading && ( + + Resolving account names… {namesRemaining} remaining + + )} + + {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 ( + + + {sellerName} ({sellerAddr}) + {' '} + →{' '} + + {buyerName} ({buyerAddr}) + {' '} + sold{' '} + + {parseFloat(trade.qortAmount).toFixed(4)} QORT →{' '} + {parseFloat(trade.foreignAmount).toFixed(4)} + {' '} + {trade.chain} at {date} + + ); + })} + + + + setPage(val)} + variant="outlined" + shape="rounded" + /> + + ); +} diff --git a/src/routes/Routes.tsx b/src/routes/Routes.tsx index 5e72523..8f41f05 100644 --- a/src/routes/Routes.tsx +++ b/src/routes/Routes.tsx @@ -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: , }, + { + path: 'stats', + element: , + }, ], }, ], diff --git a/src/styles/Layout.tsx b/src/styles/Layout.tsx index 35cf499..bad6bf9 100644 --- a/src/styles/Layout.tsx +++ b/src/styles/Layout.tsx @@ -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 */} +
- {/* This is where page content will be rendered */} +
- {/* Add Footer here */} ); }; diff --git a/tsconfig.app.json b/tsconfig.app.json index 44268e5..2a9318a 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -25,5 +25,5 @@ "noUncheckedSideEffectImports": true, "types": ["qapp-core/global"] }, - "include": ["src"] + "include": ["src", "BACKUPS/tradeFetcher.ts"] }