From 6c7027c1a11130c1ab1253afb7cd823141316cec Mon Sep 17 00:00:00 2001 From: christian harrington Date: Thu, 12 Feb 2026 17:03:03 +0000 Subject: [PATCH 01/21] readme --- README.md | 330 ++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 309 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 9d0b4bc..71048ac 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,318 @@ -# React + TypeScript + Vite +# QuantAMM Web App -This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. +Frontend for QuantAMM product discovery, documentation/fact sheets, and simulation workflows. -Currently, two official plugins are available: +The app is built with React + TypeScript + Vite and uses both: +- GraphQL (Apollo) for Balancer data +- REST (RTK Query) for app/backend operations such as simulations, docs, prices, and audit logging -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh +## Prerequisites -## Expanding the ESLint configuration +- Node.js `20.x` (matches CI) +- npm `10+` (or compatible with your Node install) -If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: +## Quick Start -- Configure the top-level `parserOptions` property like this: +1. Install dependencies -```js -export default { - // other rules... - parserOptions: { - ecmaVersion: 'latest', - sourceType: 'module', - project: ['./tsconfig.json', './tsconfig.node.json'], - tsconfigRootDir: __dirname, - }, -}; +```bash +npm ci ``` -- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked` -- Optionally add `plugin:@typescript-eslint/stylistic-type-checked` -- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list +2. Create local env file + +```bash +cp env.local.template .env.local +``` + +3. Fill in required values in `.env.local` (see variables below) +4. Run the app + +```bash +npm run dev +``` + +Default local URL: `http://localhost:5173` + +## Environment Variables + +Create `.env.local` from `env.local.template`. + +| Variable | Required | Purpose | +| --- | --- | --- | +| `VITE_BASE_URL` | Yes | Base URL for REST endpoints used by RTK Query services | +| `VITE_GRAPH_TARGET` | Yes | GraphQL endpoint used by Apollo + GraphQL codegen | +| `VITE_USE_STUBS_DATA` | Optional | Enables/disables stub product list flow in hooks | +| `AG_GRID_LICENCE_KEY` | Required for enterprise features | AG Grid/AG Charts enterprise license key | + +Notes: +- Never commit `.env.local`. +- `npm run codegen` reads `.env.local` via `dotenv_config_path=.env.local`. + +## Scripts + +- `npm run dev`: start Vite dev server +- `npm run start`: same as `dev` +- `npm run build`: type-check (`tsc`) + production Vite build +- `npm run preview`: preview production build locally +- `npm run lint`: ESLint with strict warning policy (`--max-warnings 0`) +- `npm run format`: Prettier write on `src/` +- `npm run test`: run Vitest once +- `npm run test:watch`: run Vitest in watch mode +- `npm run test:coverage`: run tests with V8 coverage output +- `npm run codegen`: regenerate GraphQL types/hooks from `src/queries/*.graphql` + +## Project Structure + +Top-level frontend source is in `src/`, and the repo follows a mostly feature-first structure. + +### High-level layout + +| Path | Responsibility | +| --- | --- | +| `src/index.tsx` | Runtime entry point; wires Apollo provider, Redux provider, and router | +| `src/App.tsx` | Main app shell; layout/menu, global bootstrapping, route outlet host | +| `src/routes.tsx` | Route graph (`createBrowserRouter`) and page-level composition | +| `src/routeComponents.ts` | Lazy imports for route-level code splitting | +| `src/app/` | Store, typed hooks, app-level Redux plumbing | +| `src/features/` | Domain features (UI, slice logic, feature-local utils/tests) | +| `src/services/` | Shared RTK Query services and service helpers | +| `src/queries/` | Apollo client + GraphQL operation documents | +| `src/__generated__/` | GraphQL codegen output (generated types/hooks) | +| `src/hooks/` | Cross-feature hooks for data fetch/transform orchestration | +| `src/utils/` | Generic utilities not owned by a single feature | +| `src/test/setup.ts` | Global test setup for Vitest | +| `public/prerun_sims/` | Static MessagePack files used for pre-run simulation data | + +### Product/domain structure under `src/features` + +| Path | What it owns | +| --- | --- | +| `src/features/simulationRunner/` | Simulation wizard orchestration, run controls, import/export, run-status flow | +| `src/features/simulationRunConfiguration/` | Pool/token selection, update-rule parameters, pre-run configuration state | +| `src/features/simulationResults/` | Result summaries, visualisations, breakdown tables, compare/save flows | +| `src/features/productExplorer/` | Product listing, filtering, sorting, pagination, explorer state | +| `src/features/productDetail/` | Product detail page, sidebar/content panels, product modal actions | +| `src/features/coinData/` | Coin data page, current price polling, coin-level state | +| `src/features/documentation/` | Documentation pages, fact sheets, landing/TOS/ineligible flows | +| `src/features/shared/` | Shared graphs, tables, and explanatory content components | +| `src/features/themes/` | Theme slice and AG Grid theme integration | + +### Typical module pattern inside a feature + +Common pattern in this repo: +- `FeaturePage.tsx` style containers for major screens +- `featureSlice.ts` for feature state and reducers +- `*Service.ts` for feature-specific RTK Query APIs +- `*.ts` utilities for transforms/view-model logic +- `*.test.ts` / `*.test.tsx` colocated near logic being tested + +This keeps UI, state, and logic near their domain ownership while still allowing shared hooks/services across features. + +## App Architecture Overview + +### Runtime composition + +Runtime boot sequence: +1. `src/index.tsx` mounts React root +2. Wraps app with `ApolloProvider` +3. Wraps app with Redux `Provider` +4. Mounts `RouterProvider` with route config from `src/routes.tsx` + +`src/App.tsx` then provides: +- App-level layout (`antd` `Layout`, header/menu/content) +- App bootstrap side effects (for example, price history load and simulation initialization when entering simulation routes) +- Route outlet rendering via `` + +### Routing architecture + +- Routes are centralized in `src/routes.tsx`. +- Route components are lazy-loaded through `src/routeComponents.ts` to keep initial bundle size smaller. +- Route groups include: + - landing/company/research/contact/docs pages + - product explorer and product detail routes + - simulation runner and simulation result comparison routes + - fact-sheet and simulator-example routes +- `src/routeErrorBoundary.tsx` handles router-level errors. + +### State architecture and ownership + +Redux Toolkit store is in `src/app/store.ts`. + +Core state slices: +- `simConfig`: simulation pool/time/rule configuration state +- `simRunner`: simulation wizard step/run status and active run breakdowns +- `simResults`: result-focused state (comparison, chart/breakdown selections) +- `docs`: documentation-related state +- `productExplorer`: explorer filters/search/sort/tab/page-level state +- `currentPrices`: current token pricing state +- `theme`: UI theme state + +In the same store, RTK Query API slices are registered for: +- simulation execution +- coin price retrieval +- documentation retrieval +- product and filter retrieval +- financial analysis +- audit logging + +### Data access and integration boundaries + +There are three primary data paths: + +1. GraphQL via Apollo +- Client: `src/queries/apolloClient.ts` +- Operations: `src/queries/*.graphql` +- Generated types/hooks: `src/__generated__/graphql-types.ts` + +2. REST via RTK Query +- Feature-local example: `src/features/simulationRunner/simulationRunnerService.ts` +- Shared service examples: `src/services/productRetrievalService.ts`, `src/services/financialAnalysisService.ts`, `src/services/auditLogService.ts` +- Most requests include CSRF token propagation from cookie helpers + +3. Local static runtime assets +- Precomputed simulation data loaded from `public/prerun_sims/*.msgpack` + +### Simulation workflow architecture + +The simulation product flow is split across: +- `simulationRunConfiguration` for building pool config +- `simulationRunner` for run orchestration and step transitions +- `simulationResults` for post-run analysis and visual output + +Runner step model (managed by `simRunner.simulationRunnerCurrentStepIndex`): +- `0`: options +- `1`: pool constituent selection +- `2`: time range +- `3`: hooks +- `4`: final review +- `5`: run progress/state view +- `6`: results summary +- `7`: save-to-compare view + +Execution path: +1. UI triggers `createRunSimulationsThunk` (`simulationRunButtonLogic.ts`) +2. Thunk initializes ranges and pools in runner state +3. Calls `runSimulation` RTK Query mutation per pool/time range +4. Converts API response into snapshots + analysis payload +5. Dispatches success/failure reducers (`addSimRunResults`, `completeRun`, `failRun`) +6. Completes run and advances to results step + +### UI and styling architecture + +- UI components primarily use Ant Design primitives +- Data-heavy tables/charts use AG Grid and AG Charts enterprise modules +- Styling uses a mix of CSS modules (`*.module.css`) and Sass modules (`*.module.scss`) +- Feature folders generally keep style modules next to owning components + +## Testing + +Current test stack: +- Vitest +- Testing Library (`@testing-library/*`) +- Coverage via `@vitest/coverage-v8` + +Configuration: `vite.config.ts` + +Important current convention: +- Vitest environment is `node` by default. +- Most current tests focus on pure logic (reducers, utilities, view-model logic). +- If you add DOM-heavy component tests, prefer file-level jsdom override: + +```ts +// @vitest-environment jsdom +``` + +Run specific test file: + +```bash +npx vitest run src/features/simulationRunner/simulationRunButton.test.ts +``` + +## Linting, Formatting, and Type Safety + +- TypeScript is strict (`strict: true` and additional unused/fallthrough checks). +- ESLint uses type-aware rules (`@typescript-eslint/*-type-checked`). +- Prettier config is in `.prettierrc`. + +Recommended pre-PR local check: + +```bash +npm run lint +npm run test +npm run build +``` + +## GraphQL Codegen Workflow + +1. Update queries in `src/queries/*.graphql` +2. Ensure `.env.local` has correct `VITE_GRAPH_TARGET` +3. Run: + +```bash +npm run codegen +``` + +Generated output: +- `src/__generated__/graphql-types.ts` + +## CI and Branch Quality Gates + +GitHub Actions workflow: `.github/workflows/tests.yml` + +Current CI jobs on PRs to `main` and pushes to `main`: +- Lint +- Test +- Build + +To enforce merge blocking on CI: +1. Go to GitHub repo settings +2. `Branches` -> branch protection rule for `main` +3. Enable `Require status checks to pass before merging` +4. Select required checks from this workflow (e.g. `Lint`, `Test`, `Build`) + +## Common Developer Tasks + +### Add a new route/page + +1. Create feature/page component under `src/features/...` +2. Add lazy export in `src/routeComponents.ts` +3. Register route in `src/routes.tsx` + +### Add a new REST endpoint + +1. Add/extend an RTK Query service in `src/services` or feature-local service +2. Register reducer + middleware in `src/app/store.ts` (if new API slice) +3. Use generated hook in feature component/hook + +### Add a new Redux slice + +1. Create slice in relevant `src/features/...` +2. Add reducer to `src/app/store.ts` +3. Add selectors + tests near the slice + +## Troubleshooting + +### Build fails with missing module path + +If files were moved during refactors, check relative imports first (especially CSS module imports). + +### Tests pass locally but fail in CI + +- Re-run `npm ci` to align dependency tree with lockfile +- Run `npm run lint && npm run test && npm run build` locally +- Confirm no generated or local-only files are accidentally required + +### GraphQL codegen issues + +- Validate `VITE_GRAPH_TARGET` in `.env.local` +- Ensure query files are valid under `src/queries/*.graphql` + +--- + +If you are new to this codebase, start with: +1. `src/routes.tsx` +2. `src/app/store.ts` +3. `src/features/simulationRunner/` and `src/features/simulationRunConfiguration/` + +Those files give the fastest understanding of routing, global state, and the main workflow engine in this app. From ce77e8a139b4c4261a2a1ae72f20f0dd9487fbc1 Mon Sep 17 00:00:00 2001 From: christian harrington Date: Wed, 18 Feb 2026 17:09:51 +0000 Subject: [PATCH 02/21] change menu to use memo more often and not have inline options --- src/Menu.tsx | 85 +++++++++++++++++++++++----------------------------- 1 file changed, 38 insertions(+), 47 deletions(-) diff --git a/src/Menu.tsx b/src/Menu.tsx index 681311c..b4bb0d4 100644 --- a/src/Menu.tsx +++ b/src/Menu.tsx @@ -73,8 +73,31 @@ export const MenuComponent: FC = ({ initialise }) => { [initialise, navigate, dispatch] ); - function getMobileItems(): MenuItem[] { - return [ + const liveProductChildren = useMemo( + () => + liveProducts.factsheets + .filter((x) => x.status == 'LIVE') + .map((product) => ({ + key: + ROUTES.PRODUCT_EXPLORER + + '/' + + product.poolChain + + '/' + + product.poolId, + label: `View ${product.iconTitle}`, + icon: ( + {product.iconTitle} + ), + })), + [liveProducts.factsheets] + ); + + const mobileItems = useMemo( + () => [ { key: 'home', label: '', @@ -90,25 +113,8 @@ export const MenuComponent: FC = ({ initialise }) => { key: 'view-products', label: 'View Products', type: 'submenu', - style: { marginLeft: 'auto' }, // Align to the right - children: liveProducts.factsheets - .filter((x) => x.status == 'LIVE') - .map((product) => ({ - key: - ROUTES.PRODUCT_EXPLORER + - '/' + - product.poolChain + - '/' + - product.poolId, - label: `View ${product.iconTitle}`, - icon: ( - {product.iconTitle} - ), - })), + style: { marginLeft: 'auto' }, + children: liveProductChildren, }, { key: 'Education', @@ -126,11 +132,12 @@ export const MenuComponent: FC = ({ initialise }) => { }, ], }, - ]; - } + ], + [liveProductChildren] + ); - function getItems(): MenuItem[] { - return [ + const desktopItems = useMemo( + () => [ { key: 'home', label: '', @@ -146,25 +153,8 @@ export const MenuComponent: FC = ({ initialise }) => { key: 'view-products', label: 'View Products', type: 'submenu', - style: { marginLeft: 'auto' }, // Align to the right - children: liveProducts.factsheets - .filter((x) => x.status == 'LIVE') - .map((product) => ({ - key: - ROUTES.PRODUCT_EXPLORER + - '/' + - product.poolChain + - '/' + - product.poolId, - label: `View ${product.iconTitle}`, - icon: ( - {product.iconTitle} - ), - })), + style: { marginLeft: 'auto' }, + children: liveProductChildren, }, { key: 'About', @@ -219,8 +209,9 @@ export const MenuComponent: FC = ({ initialise }) => { key: 'tos', label: 'Terms of Service', }, - ]; - } + ], + [liveProductChildren] + ); return (
= ({ initialise }) => { onClick={handleClick} selectedKeys={[current]} mode="horizontal" - items={isMobile ? getMobileItems() : getItems()} + items={isMobile ? mobileItems : desktopItems} overflowedIndicator={} style={{ width: '100%', From 379b86dc16c57b8321ad28904fb68251fee68771 Mon Sep 17 00:00:00 2001 From: christian harrington Date: Wed, 18 Feb 2026 17:10:51 +0000 Subject: [PATCH 03/21] change factsheet to use memo more and avoid sub compponent remounting --- .../arbitrumMacro/arbitrumMacroSimView.tsx | 26 +-- .../factSheets/baseMacro/baseMacroSimView.tsx | 26 +-- .../factSheets/desktop/factsheetDesktop.tsx | 46 ++++-- .../factSheets/mobile/factsheetMobile.tsx | 73 ++++---- .../factSheets/safeHaven/safeHavenSimView.tsx | 26 +-- .../customTruflationFactsheetDesktop.tsx | 126 +++++++------- .../customTruflationFactsheetMobile.tsx | 156 ++++++++++-------- 7 files changed, 272 insertions(+), 207 deletions(-) diff --git a/src/features/documentation/factSheets/arbitrumMacro/arbitrumMacroSimView.tsx b/src/features/documentation/factSheets/arbitrumMacro/arbitrumMacroSimView.tsx index c7b5411..a40d42f 100644 --- a/src/features/documentation/factSheets/arbitrumMacro/arbitrumMacroSimView.tsx +++ b/src/features/documentation/factSheets/arbitrumMacro/arbitrumMacroSimView.tsx @@ -1,5 +1,5 @@ import { Col, Row, Tabs, Spin } from 'antd'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useMemo } from 'react'; import { SimulationResultsSummaryStep } from '../../../simulationResults/simulationResultsSummaryStep'; import { getBreakdown, Pool } from '../../../../services/breakdownService'; import { SimulationRunBreakdown } from '../../../simulationResults/simulationResultSummaryModels'; @@ -11,6 +11,21 @@ export default function ArbitrumMacroSimulatorExample() { const [key, setKey] = useState('2'); const [breakdowns, setBreakdowns] = useState([]); const [loading, setLoading] = useState(false); + const seriesName = useMemo( + () => ({ + 'Power Channel': '#c7b283', + 'Balancer Weighted': '#528aae', + HODL: '#52ad80', + }), + [] + ); + const seriesStrokeColor = useMemo( + () => ({ + 'Power Channel': 'ARBITRUM MACRO BTF', + 'Balancer Weighted': 'Traditional DEX', + }), + [] + ); // Function to load breakdowns based on the selected tab const loadBreakdowns = async (poolNames: Pool[]) => { @@ -56,15 +71,6 @@ export default function ArbitrumMacroSimulatorExample() { }); // Trigger loading of breakdowns }, [key]); // Dependency array ensures that effect runs when `key` changes - const seriesName = { - 'Power Channel': '#c7b283', - 'Balancer Weighted': '#528aae', - HODL: '#52ad80', - }; - const seriesStrokeColor = { - 'Power Channel': 'ARBITRUM MACRO BTF', - 'Balancer Weighted': 'Traditional DEX', - }; return (
diff --git a/src/features/documentation/factSheets/baseMacro/baseMacroSimView.tsx b/src/features/documentation/factSheets/baseMacro/baseMacroSimView.tsx index 5dd05f8..5a71e75 100644 --- a/src/features/documentation/factSheets/baseMacro/baseMacroSimView.tsx +++ b/src/features/documentation/factSheets/baseMacro/baseMacroSimView.tsx @@ -1,5 +1,5 @@ import { Col, Row, Tabs, Spin } from 'antd'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useMemo } from 'react'; import { SimulationResultsSummaryStep } from '../../../simulationResults/simulationResultsSummaryStep'; import { getBreakdown, Pool } from '../../../../services/breakdownService'; import { SimulationRunBreakdown } from '../../../simulationResults/simulationResultSummaryModels'; @@ -11,6 +11,21 @@ export default function BaseMacroSimulatorExample() { const [key, setKey] = useState('2'); const [breakdowns, setBreakdowns] = useState([]); const [loading, setLoading] = useState(false); + const seriesName = useMemo( + () => ({ + 'Power Channel': '#c7b283', + 'Balancer Weighted': '#528aae', + HODL: '#52ad80', + }), + [] + ); + const seriesStrokeColor = useMemo( + () => ({ + 'Power Channel': 'BASE MACRO BTF', + 'Balancer Weighted': 'Traditional DEX', + }), + [] + ); // Function to load breakdowns based on the selected tab const loadBreakdowns = async (poolNames: Pool[]) => { @@ -56,15 +71,6 @@ export default function BaseMacroSimulatorExample() { }); // Trigger loading of breakdowns }, [key]); // Dependency array ensures that effect runs when `key` changes - const seriesName = { - 'Power Channel': '#c7b283', - 'Balancer Weighted': '#528aae', - HODL: '#52ad80', - }; - const seriesStrokeColor = { - 'Power Channel': 'BASE MACRO BTF', - 'Balancer Weighted': 'Traditional DEX', - }; return (
diff --git a/src/features/documentation/factSheets/desktop/factsheetDesktop.tsx b/src/features/documentation/factSheets/desktop/factsheetDesktop.tsx index 595210e..65d1073 100644 --- a/src/features/documentation/factSheets/desktop/factsheetDesktop.tsx +++ b/src/features/documentation/factSheets/desktop/factsheetDesktop.tsx @@ -159,6 +159,26 @@ export function FactSheetDesktop(props: FactsheetDesktopProps) { return props.model.xAxisIntervals.get(period); }, [period, props.model.xAxisIntervals]); + const testMarketValueBreakdowns = useMemo( + () => + [breakdowns[btf], breakdowns[cfmm], breakdowns[hodl]].filter( + ( + breakdown + ): breakdown is SimulationRunBreakdown => breakdown !== undefined + ), + [breakdowns, btf, cfmm, hodl] + ); + + const trainMarketValueBreakdowns = useMemo( + () => + [breakdowns[btfTrain], breakdowns[cfmmTrain], breakdowns[hodlTrain]].filter( + ( + breakdown + ): breakdown is SimulationRunBreakdown => breakdown !== undefined + ), + [breakdowns, btfTrain, cfmmTrain, hodlTrain] + ); + return (
@@ -297,15 +317,11 @@ export function FactSheetDesktop(props: FactsheetDesktopProps) { } className={styles.cardMarginSmall} > - + )} @@ -558,7 +574,8 @@ export function FactSheetDesktop(props: FactsheetDesktopProps) { > - + + )} diff --git a/src/features/documentation/factSheets/mobile/factsheetMobile.tsx b/src/features/documentation/factSheets/mobile/factsheetMobile.tsx index ee57fda..e66ac5c 100644 --- a/src/features/documentation/factSheets/mobile/factsheetMobile.tsx +++ b/src/features/documentation/factSheets/mobile/factsheetMobile.tsx @@ -123,6 +123,21 @@ export function FactSheetMobile(props: FactsheetDesktopProps) { [period, props.model.poolPrefix] ); + const btfTrain = useMemo( + () => props.model.poolPrefix + `BTF${props.model.trainPeriod}`, + [props.model.poolPrefix, props.model.trainPeriod] + ); + + const cfmmTrain = useMemo( + () => props.model.poolPrefix + `CFMM${props.model.trainPeriod}`, + [props.model.poolPrefix, props.model.trainPeriod] + ); + + const hodlTrain = useMemo( + () => props.model.poolPrefix + `Hodl${props.model.trainPeriod}`, + [props.model.poolPrefix, props.model.trainPeriod] + ); + const trainXAxisMonthInterval = useMemo(() => { return props.model.xAxisIntervals.get(props.model.trainPeriod); }, [props.model.trainPeriod, props.model.xAxisIntervals]); @@ -185,6 +200,26 @@ export function FactSheetMobile(props: FactsheetDesktopProps) { return props.model.xAxisIntervals.get(period); }, [period, props.model.xAxisIntervals]); + const testMarketValueBreakdowns = useMemo( + () => + [breakdowns[btf], breakdowns[cfmm], breakdowns[hodl]].filter( + ( + breakdown + ): breakdown is SimulationRunBreakdown => breakdown !== undefined + ), + [breakdowns, btf, cfmm, hodl] + ); + + const trainMarketValueBreakdowns = useMemo( + () => + [breakdowns[btfTrain], breakdowns[cfmmTrain], breakdowns[hodlTrain]].filter( + ( + breakdown + ): breakdown is SimulationRunBreakdown => breakdown !== undefined + ), + [breakdowns, btfTrain, cfmmTrain, hodlTrain] + ); + return (
@@ -317,15 +352,11 @@ export function FactSheetMobile(props: FactsheetDesktopProps) { className={styles.cardMarginSmall} > {periodSelector} - + )} @@ -633,14 +664,11 @@ export function FactSheetMobile(props: FactsheetDesktopProps) { > - + + )} diff --git a/src/features/documentation/factSheets/safeHaven/safeHavenSimView.tsx b/src/features/documentation/factSheets/safeHaven/safeHavenSimView.tsx index 18439ff..b1cbf10 100644 --- a/src/features/documentation/factSheets/safeHaven/safeHavenSimView.tsx +++ b/src/features/documentation/factSheets/safeHaven/safeHavenSimView.tsx @@ -1,5 +1,5 @@ import { Col, Row, Tabs, Spin } from 'antd'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useMemo } from 'react'; import { SimulationResultsSummaryStep } from '../../../simulationResults/simulationResultsSummaryStep'; import { getBreakdown, Pool } from '../../../../services/breakdownService'; import { SimulationRunBreakdown } from '../../../simulationResults/simulationResultSummaryModels'; @@ -11,6 +11,21 @@ export default function SafeHavenSimulatorExample() { const [key, setKey] = useState('2'); const [breakdowns, setBreakdowns] = useState([]); const [loading, setLoading] = useState(false); + const seriesName = useMemo( + () => ({ + 'Power Channel': '#c7b283', + 'Balancer Weighted': '#528aae', + HODL: '#52ad80', + }), + [] + ); + const seriesStrokeColor = useMemo( + () => ({ + 'Power Channel': 'SAFE HAVEN BTF', + 'Balancer Weighted': 'Traditional DEX', + }), + [] + ); // Function to load breakdowns based on the selected tab const loadBreakdowns = async (poolNames: Pool[]) => { @@ -56,15 +71,6 @@ export default function SafeHavenSimulatorExample() { }); // Trigger loading of breakdowns }, [key]); // Dependency array ensures that effect runs when `key` changes - const seriesName = { - 'Power Channel': '#c7b283', - 'Balancer Weighted': '#528aae', - HODL: '#52ad80', - }; - const seriesStrokeColor = { - 'Power Channel': 'SAFE HAVEN BTF', - 'Balancer Weighted': 'Traditional DEX', - }; return (
diff --git a/src/features/documentation/factSheets/truflationBitcoin/customTruflationFactsheetDesktop.tsx b/src/features/documentation/factSheets/truflationBitcoin/customTruflationFactsheetDesktop.tsx index 51f4407..cb415bf 100644 --- a/src/features/documentation/factSheets/truflationBitcoin/customTruflationFactsheetDesktop.tsx +++ b/src/features/documentation/factSheets/truflationBitcoin/customTruflationFactsheetDesktop.tsx @@ -191,6 +191,26 @@ export function TruflationFactSheetDesktop(props: FactsheetDesktopProps) { const xAxisMonthInterval = useMemo(() => { return props.model.xAxisIntervals.get(testPeriod); }, [testPeriod, props.model.xAxisIntervals]); + + const testComparisonBreakdowns = useMemo( + () => + [breakdowns[btf], breakdowns[hodl]].filter( + ( + breakdown + ): breakdown is SimulationRunBreakdown => breakdown !== undefined + ), + [breakdowns, btf, hodl] + ); + + const trainingComparisonBreakdowns = useMemo( + () => + [breakdowns[btfTrain], breakdowns[hodlTrain]].filter( + ( + breakdown + ): breakdown is SimulationRunBreakdown => breakdown !== undefined + ), + [breakdowns, btfTrain, hodlTrain] + ); console.log(breakdowns[btf]); return (
@@ -282,9 +302,7 @@ export function TruflationFactSheetDesktop(props: FactsheetDesktopProps) { @@ -322,11 +340,11 @@ export function TruflationFactSheetDesktop(props: FactsheetDesktopProps) { } className={styles.cardMarginSmall} > - + )} @@ -393,59 +411,53 @@ export function TruflationFactSheetDesktop(props: FactsheetDesktopProps) { - + {trainingView === 'weightChange' && ( + <> +
Constituent weights over time
+ + + )} + + )}
diff --git a/src/features/documentation/factSheets/truflationBitcoin/customTruflationFactsheetMobile.tsx b/src/features/documentation/factSheets/truflationBitcoin/customTruflationFactsheetMobile.tsx index 725c327..ded8908 100644 --- a/src/features/documentation/factSheets/truflationBitcoin/customTruflationFactsheetMobile.tsx +++ b/src/features/documentation/factSheets/truflationBitcoin/customTruflationFactsheetMobile.tsx @@ -176,6 +176,26 @@ export function TruflationFactSheetMobile(props: FactsheetDesktopProps) { [poolKeyFor, metricsPeriod] ); + const testComparisonBreakdowns = useMemo( + () => + [breakdowns[btfTest], breakdowns[hodlTest]].filter( + ( + breakdown + ): breakdown is SimulationRunBreakdown => breakdown !== undefined + ), + [breakdowns, btfTest, hodlTest] + ); + + const trainingComparisonBreakdowns = useMemo( + () => + [breakdowns[btfTrain], breakdowns[hodlTrain]].filter( + ( + breakdown + ): breakdown is SimulationRunBreakdown => breakdown !== undefined + ), + [breakdowns, btfTrain, hodlTrain] + ); + const renderPeriodSelector = ( includeTrainPeriod: boolean, value: string, @@ -337,9 +357,7 @@ export function TruflationFactSheetMobile(props: FactsheetDesktopProps) { @@ -388,13 +406,11 @@ export function TruflationFactSheetMobile(props: FactsheetDesktopProps) { className={styles.cardMarginSmall} > {renderPeriodSelector(false, testPeriod, setTestPeriod)} - + )} @@ -634,73 +650,67 @@ export function TruflationFactSheetMobile(props: FactsheetDesktopProps) { - + {trainingView === 'weightChange' && ( + <> +
Constituent weights over time
+ + + )} + + )}
From a5cf391b2025f2472309237487021fddf2220264 Mon Sep 17 00:00:00 2001 From: christian harrington Date: Wed, 18 Feb 2026 17:11:38 +0000 Subject: [PATCH 04/21] add more conditional rerender instead of hidden, use memo more often as well --- .../landing/desktop/strategySummary.tsx | 221 ++++++++++-------- .../landing/mobile/strategySummaryMobile.tsx | 39 ++-- 2 files changed, 145 insertions(+), 115 deletions(-) diff --git a/src/features/documentation/landing/desktop/strategySummary.tsx b/src/features/documentation/landing/desktop/strategySummary.tsx index a653587..17ee141 100644 --- a/src/features/documentation/landing/desktop/strategySummary.tsx +++ b/src/features/documentation/landing/desktop/strategySummary.tsx @@ -1,6 +1,6 @@ import { Button, Col, Row, Tooltip, Typography } from 'antd'; import { ProductItemBackground } from '../../../productExplorer/productItem/productItemBackground'; -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { SimulationRunBreakdown } from '../../../simulationResults/simulationResultSummaryModels'; import { getBreakdown, Pool } from '../../../../services/breakdownService'; import { WeightChangeOverTimeGraph } from '../../../shared'; @@ -10,78 +10,6 @@ import styles from './landingDesktop.module.css'; const { Title } = Typography; export function StrategySummary() { - const [breakdowns, setBreakdowns] = useState([]); - const [loading, setLoading] = useState(true); - const [strategy, setStrategy] = useState('Momentum'); - - const [autoCycle, setAutoCycle] = useState(true); - - useEffect(() => { - let interval: NodeJS.Timeout | null = null; - - if (autoCycle) { - const allStrageies = [ - 'Momentum', - 'AntiMomentum', - 'Channel Following', - 'Power Channel', - ]; - - interval = setInterval(() => { - setStrategy((prevStrategy) => { - const currentIndex = allStrageies.indexOf(prevStrategy); - const nextIndex = (currentIndex + 1) % allStrageies.length; - return allStrageies[nextIndex]; - }); - }, 5000); // Change strategy every 5 seconds - } - - return () => { - if (interval) { - clearInterval(interval); - } - }; - }, [autoCycle]); - - // Effect to load breakdowns whenever the tab key changes - useEffect(() => { - // Function to load breakdowns based on the selected tab - const loadBreakdowns = async ( - poolNames: Pool[] - ): Promise => { - setLoading(true); // Start loading - const fetchedBreakdowns = await Promise.all( - poolNames.map((poolName) => getBreakdown(poolName)) - ); - setBreakdowns(fetchedBreakdowns); - return fetchedBreakdowns; - }; - - const loadData = async (): Promise => { - // Load breakdowns for the selected tab - return await loadBreakdowns([ - 'solExampleWeighted', - 'solExampleMomentum', - 'solExampleAntimomentum', - 'solExamplePowerChannel', - 'solExampleChannelFollowing', - 'solExampleHodl', - ] as Pool[]); // Awaiting the asynchronous function here - }; - - if (loading) { - loadData() - .then((fetchedBreakdowns) => { - setBreakdowns(fetchedBreakdowns); - }) - .catch((error) => { - console.error('Failed to load breakdowns:', error); - }) - .finally(() => { - setLoading(false); - }); - } - }, [setBreakdowns, setLoading, breakdowns, loading]); const strategies = [ { title: 'Momentum', @@ -117,14 +45,122 @@ export function StrategySummary() { }, ]; - const traditionalDexBreakdown = breakdowns.find( - (x) => x.simulationRun.updateRule.updateRuleName === 'Balancer Weighted' + const [breakdowns, setBreakdowns] = useState([]); + const [loading, setLoading] = useState(true); + const [strategy, setStrategy] = useState('Momentum'); + + const [autoCycle, setAutoCycle] = useState(true); + const overrideSeriesStrokeColor = useMemo( + () => ({ + Momentum: '#c7b283', + AntiMomentum: '#c7b283', + 'Channel Following': '#c7b283', + 'Power Channel': '#c7b283', + 'Balancer Weighted': '#528aae', + }), + [] + ); + + const overrideSeriesName = useMemo( + () => ({ + Momentum: 'QuantAMM', + AntiMomentum: 'QuantAMM', + 'Channel Following': 'QuantAMM', + 'Power Channel': 'QuantAMM', + 'Balancer Weighted': 'Traditional DEX', + }), + [] ); - const selectedStrategyBreakdown = breakdowns.find( - (x) => x.simulationRun.updateRule.updateRuleName === strategy + + useEffect(() => { + let interval: NodeJS.Timeout | null = null; + const autoCycleStrategies = [ + 'Momentum', + 'AntiMomentum', + 'Channel Following', + 'Power Channel', + ]; + + if (autoCycle) { + interval = setInterval(() => { + setStrategy((prevStrategy) => { + const currentIndex = autoCycleStrategies.indexOf(prevStrategy); + const nextIndex = (currentIndex + 1) % autoCycleStrategies.length; + return autoCycleStrategies[nextIndex]; + }); + }, 5000); // Change strategy every 5 seconds + } + + return () => { + if (interval) { + clearInterval(interval); + } + }; + }, [autoCycle]); + + useEffect(() => { + let isMounted = true; + + const loadData = async () => { + setLoading(true); + + try { + const fetchedBreakdowns = await Promise.all( + ( + [ + 'solExampleWeighted', + 'solExampleMomentum', + 'solExampleAntimomentum', + 'solExamplePowerChannel', + 'solExampleChannelFollowing', + 'solExampleHodl', + ] as Pool[] + ).map((poolName) => getBreakdown(poolName)) + ); + if (isMounted) { + setBreakdowns(fetchedBreakdowns); + } + } catch (error) { + console.error('Failed to load breakdowns:', error); + } finally { + if (isMounted) { + setLoading(false); + } + } + }; + + void loadData(); + + return () => { + isMounted = false; + }; + }, []); + + const traditionalDexBreakdown = useMemo( + () => + breakdowns.find( + (x) => x.simulationRun.updateRule.updateRuleName === 'Balancer Weighted' + ), + [breakdowns] + ); + + const selectedStrategyBreakdown = useMemo( + () => + breakdowns.find((x) => x.simulationRun.updateRule.updateRuleName === strategy), + [breakdowns, strategy] + ); + + const marketValueBreakdowns = useMemo( + () => + breakdowns.filter( + (x) => + x.simulationRun.updateRule.updateRuleName === strategy || + x.simulationRun.updateRule.updateRuleName === 'Balancer Weighted' + ), + [breakdowns, strategy] ); - const StrategySelectorPanel = () => ( + const strategySelectorPanel = (

ADAPTIVE STRATEGIES

@@ -176,7 +212,7 @@ export function StrategySummary() {

); - const HoldingsChartsSection = () => ( + const holdingsChartsSection = (

Traditional DEX Pool Holdings

@@ -241,36 +277,19 @@ export function StrategySummary() {
- + {strategySelectorPanel} - + {holdingsChartsSection}
- x.simulationRun.updateRule.updateRuleName === strategy || - x.simulationRun.updateRule.updateRuleName === - 'Balancer Weighted' - )} + breakdowns={marketValueBreakdowns} forceViewResults={true} overrideXAxisInterval={24} - overrideSeriesStrokeColor={{ - Momentum: '#c7b283', - AntiMomentum: '#c7b283', - 'Channel Following': '#c7b283', - 'Power Channel': '#c7b283', - 'Balancer Weighted': '#528aae', - }} - overrideSeriesName={{ - Momentum: 'QuantAMM', - AntiMomentum: 'QuantAMM', - 'Channel Following': 'QuantAMM', - 'Power Channel': 'QuantAMM', - 'Balancer Weighted': 'Traditional DEX', - }} + overrideSeriesStrokeColor={overrideSeriesStrokeColor} + overrideSeriesName={overrideSeriesName} />
diff --git a/src/features/documentation/landing/mobile/strategySummaryMobile.tsx b/src/features/documentation/landing/mobile/strategySummaryMobile.tsx index efacf6e..8df2cf2 100644 --- a/src/features/documentation/landing/mobile/strategySummaryMobile.tsx +++ b/src/features/documentation/landing/mobile/strategySummaryMobile.tsx @@ -12,23 +12,34 @@ export function StrategySummaryMobile() { const [loading, setLoading] = useState(true); useEffect(() => { - const loadBreakdowns = async ( - poolNames: Pool[] - ): Promise => { + let isMounted = true; + + const loadBreakdowns = async () => { setLoading(true); - const fetchedBreakdowns = await Promise.all( - poolNames.map((poolName) => getBreakdown(poolName)) - ); - setBreakdowns(fetchedBreakdowns); - return fetchedBreakdowns; + try { + const fetchedBreakdowns = await Promise.all( + (['balancerWeighted', 'quantAMMAntiMomentum'] as Pool[]).map( + (poolName) => getBreakdown(poolName) + ) + ); + if (isMounted) { + setBreakdowns(fetchedBreakdowns); + } + } catch (error) { + console.error(error); + } finally { + if (isMounted) { + setLoading(false); + } + } }; - if (loading) { - loadBreakdowns(['balancerWeighted', 'quantAMMAntiMomentum'] as Pool[]) - .catch(console.error) - .finally(() => setLoading(false)); - } - }, [loading]); + void loadBreakdowns(); + + return () => { + isMounted = false; + }; + }, []); return (
From c76514be7e710a317032f0ebe39ef65988c5cd42 Mon Sep 17 00:00:00 2001 From: christian harrington Date: Wed, 18 Feb 2026 17:12:17 +0000 Subject: [PATCH 05/21] larger refactor to make product detail events lazy load the ag grid table to make initial mount / render more performant --- .../events/productDetailEvents.module.scss | 4 + .../events/productDetailEvents.tsx | 125 ++++++++++-------- .../events/productDetailEventsGrid.tsx | 57 ++++---- .../events/productDetailEventsHeader.tsx | 10 +- 4 files changed, 112 insertions(+), 84 deletions(-) diff --git a/src/features/productDetail/productDetailContent/events/productDetailEvents.module.scss b/src/features/productDetail/productDetailContent/events/productDetailEvents.module.scss index eaba09c..ee759ec 100644 --- a/src/features/productDetail/productDetailContent/events/productDetailEvents.module.scss +++ b/src/features/productDetail/productDetailContent/events/productDetailEvents.module.scss @@ -2,6 +2,10 @@ margin-top: 20px; } +.eventsCollapse { + width: 100%; +} + .headerCol { display: flex; flex-direction: row; diff --git a/src/features/productDetail/productDetailContent/events/productDetailEvents.tsx b/src/features/productDetail/productDetailContent/events/productDetailEvents.tsx index eff8672..08c7720 100644 --- a/src/features/productDetail/productDetailContent/events/productDetailEvents.tsx +++ b/src/features/productDetail/productDetailContent/events/productDetailEvents.tsx @@ -1,5 +1,5 @@ -import { FC, memo, useMemo, useRef } from 'react'; -import { Col, Empty, Row, Spin } from 'antd'; +import { FC, memo, useCallback, useRef, useState } from 'react'; +import { Col, Collapse, Empty, Row, Spin } from 'antd'; import { AgGridReact } from 'ag-grid-react'; import { GqlChain, @@ -27,6 +27,8 @@ export interface ProductDetailEventsProps { chain: GqlChain; isMobile?: boolean; } +const EVENTS_PANEL_KEY = 'events-panel'; +const { Panel } = Collapse; export const ProductDetailEvents: FC = memo( function ProductDetailEventsImpl({ @@ -34,6 +36,8 @@ export const ProductDetailEvents: FC = memo( chain, isMobile, }: ProductDetailEventsProps) { + const [isPanelOpen, setIsPanelOpen] = useState(false); + const [hasRequestedData, setHasRequestedData] = useState(false); const darkThemeAg = useAppSelector(selectAgGridTheme); const chartTheme = useAppSelector(selectAgChartTheme); @@ -46,72 +50,83 @@ export const ProductDetailEvents: FC = memo( skip: undefined, poolId: productId, chain, + enabled: hasRequestedData, }); const explorerBase = useExplorerBase(chain); const thresholds = useBadgeThresholds(productAddress, livePools); const gridRef = useRef>(null); - const rowData = useMemo( - () => - (poolEvents ?? []).map( - ({ blockNumber, id, sender, timestamp, tx, type, valueUSD }) => ({ - id, - blockNumber, - type, - sender, - tx, - timestamp, - valueUSD, - }) - ), - [poolEvents] - ); + const rowData = poolEvents; const showSpinner = loading && !error && rowData.length === 0; const heatmap = useHeatmapData(product, poolEvents); + const handleCollapseChange = useCallback((activeKey: string | string[]) => { + const isOpen = Array.isArray(activeKey) + ? activeKey.includes(EVENTS_PANEL_KEY) + : activeKey === EVENTS_PANEL_KEY; + setIsPanelOpen(isOpen); + if (isOpen) { + setHasRequestedData(true); + } + }, []); return ( - - gridRef.current?.api?.exportDataAsCsv()} - /> - - - - {showSpinner ? ( - - ) : error && rowData.length === 0 ? ( -
Failed to load events.
- ) : isMobile ? ( -
- {!heatmap.heatmapData.length ? ( - - ) : ( - - )} -
- ) : ( -
-
- -
-
- )} + + + + + + gridRef.current?.api?.exportDataAsCsv()} + /> + + + {!hasRequestedData && isPanelOpen ? ( + + ) : showSpinner ? ( + + ) : error && rowData.length === 0 ? ( +
Failed to load events.
+ ) : isMobile ? ( +
+ {!heatmap.heatmapData.length ? ( + + ) : ( + + )} +
+ ) : ( +
+
+ +
+
+ )} + +
+
+
); diff --git a/src/features/productDetail/productDetailContent/events/productDetailEventsGrid.tsx b/src/features/productDetail/productDetailContent/events/productDetailEventsGrid.tsx index 9a6f6ca..3132ee4 100644 --- a/src/features/productDetail/productDetailContent/events/productDetailEventsGrid.tsx +++ b/src/features/productDetail/productDetailContent/events/productDetailEventsGrid.tsx @@ -183,6 +183,7 @@ export const ProductDetailEventsGrid = forwardRef< () => ({ columnDefs, rowHeight: 26, + getRowId: (params) => String(params.data?.id ?? ''), defaultColDef: { filter: 'agTextColumnFilter', sortable: true, @@ -197,6 +198,35 @@ export const ProductDetailEventsGrid = forwardRef< }), [columnDefs] ); + const sideBar = useMemo( + () => ({ + toolPanels: [ + { + id: 'columns', + labelDefault: 'Columns', + labelKey: 'columns', + iconKey: 'columns', + toolPanel: 'agColumnsToolPanel', + minWidth: 100, + maxWidth: 300, + width: 200, + }, + { + id: 'filters', + labelDefault: 'Filters', + labelKey: 'filters', + iconKey: 'filter', + toolPanel: 'agFiltersToolPanel', + minWidth: 100, + maxWidth: 300, + width: 200, + }, + ], + position: 'right' as const, + defaultToolPanel: 'none', + }), + [] + ); return ( ); }); diff --git a/src/features/productDetail/productDetailContent/events/productDetailEventsHeader.tsx b/src/features/productDetail/productDetailContent/events/productDetailEventsHeader.tsx index 9393ceb..e0c03b2 100644 --- a/src/features/productDetail/productDetailContent/events/productDetailEventsHeader.tsx +++ b/src/features/productDetail/productDetailContent/events/productDetailEventsHeader.tsx @@ -7,22 +7,26 @@ const { Title } = Typography; interface EventsHeaderProps { isMobile: boolean; onCsv: () => void; + showTitle?: boolean; } export const ProductDetailEventsHeader: FC = memo( - function EventsHeader({ isMobile, onCsv }) { + function EventsHeader({ isMobile, onCsv, showTitle = true }) { return ( <Row> <Col span={20}> - <h4 hidden={isMobile}>Events</h4> + <h4 hidden={isMobile || !showTitle}>Events</h4> </Col> {!isMobile && ( <Col span={4} className={styles.actionsCol}> <Button type="primary" size="small" - onClick={onCsv} + onClick={(e) => { + e.stopPropagation(); + onCsv(); + }} className={styles.csvButton} > Download CSV From d6ec14c7bdd06d4735d746a494a7f3ef717cc305 Mon Sep 17 00:00:00 2001 From: christian harrington <christian@bulkbit.systems> Date: Wed, 18 Feb 2026 17:13:06 +0000 Subject: [PATCH 06/21] avoid () => definition given potential remounting, refactor constants --- .../summary/productDetailSummaryDesktop.tsx | 76 +++++++++---------- 1 file changed, 36 insertions(+), 40 deletions(-) diff --git a/src/features/productDetail/productDetailContent/summary/productDetailSummaryDesktop.tsx b/src/features/productDetail/productDetailContent/summary/productDetailSummaryDesktop.tsx index 7c704db..82454a9 100644 --- a/src/features/productDetail/productDetailContent/summary/productDetailSummaryDesktop.tsx +++ b/src/features/productDetail/productDetailContent/summary/productDetailSummaryDesktop.tsx @@ -40,6 +40,9 @@ import { AnalysisSimplifiedBreakdownTable } from '../../../simulationResults/bre const { Text } = Typography; const { Panel } = Collapse; +const WEIGHT_CHART_Y_AXIS_OVERRIDE = { label: { enabled: false } }; +const RETURN_DISTRIBUTION_Y_AXIS_OVERRIDE = { title: { enabled: false } }; +const SHOW_STRATEGY_WORKFLOW_SECTION = false; interface ProductDetailSummaryDesktopProps { product: Product; @@ -308,7 +311,7 @@ export const ProductDetailSummaryDesktop: FC< ]; }, [simulationRunBreakdown]); - const MetricsToggle = () => ( + const metricsToggle = ( <div onClick={(e) => { e.stopPropagation(); @@ -343,7 +346,7 @@ export const ProductDetailSummaryDesktop: FC< </div> ); - const PoolWeightsCard = () => ( + const poolWeightsCard = ( <Card className={styles['product-detail-summary__cardDesktop']} title="Pool weights" @@ -362,13 +365,13 @@ export const ProductDetailSummaryDesktop: FC< <ProductTokenWeightChangeOverTimeGraph product={product} isBenchmark={weightsView === 'benchmark'} - yAxisOverride={{ label: { enabled: false } }} + yAxisOverride={WEIGHT_CHART_Y_AXIS_OVERRIDE} /> </div> </Card> ); - const ReturnDistributionCard = () => ( + const returnDistributionCard = ( <Card className={styles['product-detail-summary__cardDesktop']} title="Return distribution" @@ -386,7 +389,7 @@ export const ProductDetailSummaryDesktop: FC< <div className={styles['product-detail-summary__chart']}> {ts.length > 0 ? ( <ReturnDistributionGraph - yAxisOverride={{ title: { enabled: false } }} + yAxisOverride={RETURN_DISTRIBUTION_Y_AXIS_OVERRIDE} marketValues={ returnsView === 'benchmark' ? marketValuesBenchmark @@ -402,40 +405,33 @@ export const ProductDetailSummaryDesktop: FC< return ( <div className={styles['product-detail-summary__desktop']}> - <PoolWeightsCard /> - - { - /* hidden as the subgraph has still not been pushed to prod*/ - <div hidden> - <Collapse - defaultActiveKey={[]} - className={styles['product-detail-summary__collapse']} - bordered={false} + {poolWeightsCard} + + {SHOW_STRATEGY_WORKFLOW_SECTION && ( + <Collapse + defaultActiveKey={[]} + className={styles['product-detail-summary__collapse']} + bordered={false} + > + <Panel + key="strategy-workflow" + header={ + <div className={styles['product-detail-summary__collapseHeader']}> + <span className={styles['product-detail-summary__stepNumber']}> + 2 + </span> + <Text strong>Strategy workflow</Text> + <Text + type="secondary" + className={styles['product-detail-summary__collapseHint']} + ></Text> + </div> + } > - <Panel - key="strategy-workflow" - header={ - <div - className={styles['product-detail-summary__collapseHeader']} - > - <span - className={styles['product-detail-summary__stepNumber']} - > - 2 - </span> - <Text strong>Strategy workflow</Text> - <Text - type="secondary" - className={styles['product-detail-summary__collapseHint']} - ></Text> - </div> - } - > - <StrategyWorkflowCard product={product} factsheet={factsheet} /> - </Panel> - </Collapse> - </div> - } + <StrategyWorkflowCard product={product} factsheet={factsheet} /> + </Panel> + </Collapse> + )} {/* Collapsible metrics section with icon toggle */} <Collapse @@ -458,7 +454,7 @@ export const ProductDetailSummaryDesktop: FC< </Tooltip> </div> } - extra={<MetricsToggle />} + extra={metricsToggle} > {metricsView === 'table' ? ( isLoading ? ( @@ -623,7 +619,7 @@ export const ProductDetailSummaryDesktop: FC< </Panel> </Collapse> - <ReturnDistributionCard /> + {returnDistributionCard} </div> ); }; From e033691c712cace3a442a342aa042d6fe7dcc335 Mon Sep 17 00:00:00 2001 From: christian harrington <christian@bulkbit.systems> Date: Wed, 18 Feb 2026 17:13:14 +0000 Subject: [PATCH 07/21] same for mobile --- .../summary/productDetailSummaryMobile.tsx | 63 +++++++++++-------- 1 file changed, 36 insertions(+), 27 deletions(-) diff --git a/src/features/productDetail/productDetailContent/summary/productDetailSummaryMobile.tsx b/src/features/productDetail/productDetailContent/summary/productDetailSummaryMobile.tsx index f716565..cdfa83c 100644 --- a/src/features/productDetail/productDetailContent/summary/productDetailSummaryMobile.tsx +++ b/src/features/productDetail/productDetailContent/summary/productDetailSummaryMobile.tsx @@ -12,6 +12,10 @@ import { ComparableProductSelector } from '../comparableProduct/comparableProduc import { getThresholdColor, getThresholdPostscript } from './utils'; const { Text } = Typography; +const WEIGHT_CHART_Y_AXIS_OVERRIDE = { label: { enabled: false } }; +const LEGEND_DISABLED_OVERRIDE = { enabled: false }; +const RETURN_DISTRIBUTION_Y_AXIS_OVERRIDE = { title: { enabled: false } }; +const SHOW_COMPARE_PRODUCT_PANEL = false; interface ProductDetailSummaryMobileProps { product: Product; @@ -181,6 +185,18 @@ export const ProductDetailSummaryMobile = ({ 'Annualized Jensen’s Alpha (%)', [selectedBenchmarkReturnAnalysis] ); + const productMarketValues = useMemo( + () => product.timeSeries?.map((x) => x.sharePrice) ?? [], + [product.timeSeries] + ); + const benchmarkMarketValues = useMemo( + () => product.timeSeries?.map((x) => x.hodlSharePrice) ?? [], + [product.timeSeries] + ); + const comparingProductMarketValues = useMemo( + () => comparingProduct?.timeSeries?.map((x) => x.sharePrice) ?? [], + [comparingProduct?.timeSeries] + ); // Colors + grade text for the three entities per card const repr = ( @@ -192,8 +208,8 @@ export const ProductDetailSummaryMobile = ({ grade: getThresholdPostscript(thresholds, metricName, v), // "(VERY GOOD)" etc. }); - const CompareProductPanel = () => ( - <div style={{ width: '95%' }} hidden={true}> + const compareProductPanel = SHOW_COMPARE_PRODUCT_PANEL ? ( + <div style={{ width: '95%' }}> <Collapse items={[ { @@ -212,9 +228,9 @@ export const ProductDetailSummaryMobile = ({ activeKey={isCompareProductOpen ? ['1'] : []} /> </div> - ); + ) : null; - const PoolWeightCard = () => ( + const poolWeightCard = ( <div style={{ width: '95%', marginTop: 12 }}> <Card title="Pool token weight over time [%]"> <div> @@ -222,8 +238,8 @@ export const ProductDetailSummaryMobile = ({ <div style={{ height: '100%', width: '100%' }}> <ProductTokenWeightChangeOverTimeGraph product={product} - yAxisOverride={{ label: { enabled: false } }} - legendOverride={{ enabled: false }} + yAxisOverride={WEIGHT_CHART_Y_AXIS_OVERRIDE} + legendOverride={LEGEND_DISABLED_OVERRIDE} /> </div> <Text strong>{product.name}</Text> @@ -231,8 +247,8 @@ export const ProductDetailSummaryMobile = ({ <ProductTokenWeightChangeOverTimeGraph product={product} isBenchmark={true} - yAxisOverride={{ label: { enabled: false } }} - legendOverride={{ enabled: false }} + yAxisOverride={WEIGHT_CHART_Y_AXIS_OVERRIDE} + legendOverride={LEGEND_DISABLED_OVERRIDE} /> </div> {comparingProduct && ( @@ -241,8 +257,8 @@ export const ProductDetailSummaryMobile = ({ <div style={{ height: '100%', width: '100%' }}> <ProductTokenWeightChangeOverTimeGraph product={comparingProduct} - yAxisOverride={{ label: { enabled: false } }} - legendOverride={{ enabled: false }} + yAxisOverride={WEIGHT_CHART_Y_AXIS_OVERRIDE} + legendOverride={LEGEND_DISABLED_OVERRIDE} /> </div> </div> @@ -252,7 +268,7 @@ export const ProductDetailSummaryMobile = ({ </div> ); - const ReturnDistributionCard = () => ( + const returnDistributionCard = ( <div style={{ width: '95%' }}> <Card title="Return distribution"> <div> @@ -260,10 +276,8 @@ export const ProductDetailSummaryMobile = ({ <div style={{ height: '100%', width: '100%' }}> {(product.timeSeries?.length ?? 0) > 0 && ( <ReturnDistributionGraph - marketValues={ - product.timeSeries?.map((x) => x.sharePrice) ?? [] - } - yAxisOverride={{ title: { enabled: false } }} + marketValues={productMarketValues} + yAxisOverride={RETURN_DISTRIBUTION_Y_AXIS_OVERRIDE} /> )} </div> @@ -275,10 +289,8 @@ export const ProductDetailSummaryMobile = ({ <div style={{ height: '100%', width: '100%' }}> {(product.timeSeries?.length ?? 0) > 0 && ( <ReturnDistributionGraph - marketValues={ - product.timeSeries?.map((x) => x.hodlSharePrice) ?? [] - } - yAxisOverride={{ title: { enabled: false } }} + marketValues={benchmarkMarketValues} + yAxisOverride={RETURN_DISTRIBUTION_Y_AXIS_OVERRIDE} /> )} </div> @@ -289,11 +301,8 @@ export const ProductDetailSummaryMobile = ({ <div style={{ height: '100%', width: '100%' }}> {(comparingProduct?.timeSeries?.length ?? 0) > 0 && ( <ReturnDistributionGraph - marketValues={ - comparingProduct?.timeSeries?.map((x) => x.sharePrice) ?? - [] - } - yAxisOverride={{ title: { enabled: false } }} + marketValues={comparingProductMarketValues} + yAxisOverride={RETURN_DISTRIBUTION_Y_AXIS_OVERRIDE} /> )} </div> @@ -307,8 +316,8 @@ export const ProductDetailSummaryMobile = ({ return ( <div> {/* Hidden because currently live analytics is turned off, comparing factsheet with live is not great */} - <CompareProductPanel /> - <PoolWeightCard /> + {compareProductPanel} + {poolWeightCard} <Card style={{ borderRadius: 16, @@ -467,7 +476,7 @@ export const ProductDetailSummaryMobile = ({ </div> </Card> <Divider /> - <ReturnDistributionCard /> + {returnDistributionCard} </div> ); }; From 1776e28fa5860fa7853974157e986faf254bb805 Mon Sep 17 00:00:00 2001 From: christian harrington <christian@bulkbit.systems> Date: Wed, 18 Feb 2026 17:14:04 +0000 Subject: [PATCH 08/21] bugfix selector array generator for memo use, implement use createselector --- .../productExplorer/productExplorerSlice.ts | 112 +++++++++++------- 1 file changed, 72 insertions(+), 40 deletions(-) diff --git a/src/features/productExplorer/productExplorerSlice.ts b/src/features/productExplorer/productExplorerSlice.ts index 63fead9..27266cd 100644 --- a/src/features/productExplorer/productExplorerSlice.ts +++ b/src/features/productExplorer/productExplorerSlice.ts @@ -1,4 +1,4 @@ -import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { createSelector, createSlice, PayloadAction } from '@reduxjs/toolkit'; import { RootState } from '../../app/store'; import { FilterList, @@ -188,70 +188,102 @@ export const selectLoadingSimulationRunBreakdown = ( export const selectLoadingJsonBreakdown = (state: RootState) => state.productExplorer.loadingJsonProductSimulations; -export const selectReturnAnalysisByProductId = ( - state: RootState, - id: string -) => { - const targetProduct = state.productExplorer.productMap[id]; - - if (targetProduct) { - return ( - targetProduct.simulationRunBreakdown?.simulationRunResultAnalysis?.return_analysis - // filter out absolute return metric - .filter((element) => element.metricName !== 'Absolute Return (%)') - ); - } +const getProductById = (state: RootState, id: string) => + state.productExplorer.productMap[id]; - return null; -}; +const filterReturnAnalysis = (targetProduct?: Product) => + targetProduct?.simulationRunBreakdown?.simulationRunResultAnalysis?.return_analysis + // filter out absolute return metric + .filter((element) => element.metricName !== 'Absolute Return (%)') ?? null; -export const selectBenchmarkAnalysisByProductId = ( - state: RootState, - id: string, +const filterBenchmarkAnalysis = ( + targetProduct: Product | undefined, benchmarkName: string | null ) => { - const targetProduct = state.productExplorer.productMap[id]; - if (targetProduct && benchmarkName) { return targetProduct.simulationRunBreakdown?.simulationRunResultAnalysis?.benchmark_analysis.filter( (element) => element.benchmarkName === benchmarkName ); - } else if (targetProduct && !benchmarkName) { - //the return analysis metrics of the benchmark itself + } + if (targetProduct && !benchmarkName) { + // the return analysis metrics of the benchmark itself return targetProduct.simulationRunBreakdown?.simulationRunResultAnalysis?.benchmark_analysis.filter( (element) => element.benchmarkName === undefined || element.benchmarkName === '' ); } - return null; }; +const mapTimeseriesAnalysis = (targetProduct?: Product) => + targetProduct?.simulationRunBreakdown?.simulationRunResultAnalysis?.return_timeseries_analysis.map( + (element) => ({ + ...element, + metricKey: getMetricKey(element.metricName), + }) + ) ?? null; + +const returnAnalysisSelectorCache = new Map< + string, + (state: RootState) => ReturnType<typeof filterReturnAnalysis> +>(); + +export const selectReturnAnalysisByProductId = (state: RootState, id: string) => { + let selector = returnAnalysisSelectorCache.get(id); + if (!selector) { + selector = createSelector( + [(rootState: RootState) => getProductById(rootState, id)], + (targetProduct) => filterReturnAnalysis(targetProduct) + ); + returnAnalysisSelectorCache.set(id, selector); + } + return selector(state); +}; + +const benchmarkAnalysisSelectorCache = new Map< + string, + (state: RootState) => ReturnType<typeof filterBenchmarkAnalysis> +>(); + +export const selectBenchmarkAnalysisByProductId = ( + state: RootState, + id: string, + benchmarkName: string | null +) => { + const cacheKey = `${id}::${benchmarkName ?? '__default__'}`; + let selector = benchmarkAnalysisSelectorCache.get(cacheKey); + if (!selector) { + selector = createSelector( + [(rootState: RootState) => getProductById(rootState, id)], + (targetProduct) => filterBenchmarkAnalysis(targetProduct, benchmarkName) + ); + benchmarkAnalysisSelectorCache.set(cacheKey, selector); + } + return selector(state); +}; + const getMetricKey = (metricName: string) => { return metricName.toLowerCase().replace(/ /g, '_'); }; +const timeseriesAnalysisSelectorCache = new Map< + string, + (state: RootState) => ReturnType<typeof mapTimeseriesAnalysis> +>(); + export const selectTimeseriesAnalysisByProductId = ( state: RootState, id: string ) => { - const targetProduct = state.productExplorer.productMap[id]; - - if (targetProduct) { - const result = - targetProduct.simulationRunBreakdown?.simulationRunResultAnalysis?.return_timeseries_analysis.map( - (element) => { - return { - ...element, - metricKey: getMetricKey(element.metricName), - }; - } - ); - - return result; + let selector = timeseriesAnalysisSelectorCache.get(id); + if (!selector) { + selector = createSelector( + [(rootState: RootState) => getProductById(rootState, id)], + (targetProduct) => mapTimeseriesAnalysis(targetProduct) + ); + timeseriesAnalysisSelectorCache.set(id, selector); } - - return null; + return selector(state); }; export const selectPageSize = (state: RootState) => From a1644252f6f1e207edd012822e0ca552699b70e8 Mon Sep 17 00:00:00 2001 From: christian harrington <christian@bulkbit.systems> Date: Wed, 18 Feb 2026 17:15:21 +0000 Subject: [PATCH 09/21] redo graphs to memo options to avoid remounting, change graphs to be passed as memo given the input params do not change often and the whole thing can also be memo'd --- .../shared/graphs/returnDistributionGraph.tsx | 130 +++--- .../shared/graphs/weightChangeOverTime.tsx | 120 +++-- .../simulationResultAmountChart.tsx | 442 +++++++++--------- .../simulationResultDrawdownGraph.tsx | 147 +++--- .../simulationResultMarketValueChart.tsx | 151 +++--- .../simulationResultReturnChart.tsx | 200 ++++---- .../simulationResultVaRGraph.tsx | 113 ++--- .../simulationResultWeightChart.tsx | 224 +++++---- 8 files changed, 811 insertions(+), 716 deletions(-) diff --git a/src/features/shared/graphs/returnDistributionGraph.tsx b/src/features/shared/graphs/returnDistributionGraph.tsx index 27bd95c..0f8a125 100644 --- a/src/features/shared/graphs/returnDistributionGraph.tsx +++ b/src/features/shared/graphs/returnDistributionGraph.tsx @@ -1,5 +1,7 @@ -import { FC, useMemo } from 'react'; +import { FC, memo, useMemo } from 'react'; import { + AgCartesianAxisOptions, + AgChartOptions, AgHistogramSeriesOptions, AgCategoryAxisOptions, AgNumberAxisOptions, @@ -29,7 +31,7 @@ const distributionBarSeries: AgHistogramSeriesOptions[] = [ }, ]; -export const ReturnDistributionGraph: FC<ReturnDistributionGraphProps> = ({ +const ReturnDistributionGraphComponent: FC<ReturnDistributionGraphProps> = ({ marketValues = [], xAxisOverride, yAxisOverride, @@ -42,74 +44,82 @@ export const ReturnDistributionGraph: FC<ReturnDistributionGraphProps> = ({ ); }, [marketValues]); - return ( - <AgCharts - options={{ - height: 230, - padding: { - left: 20, - right: 20, - top: 20, - bottom: 20, + const axes = useMemo<AgCartesianAxisOptions[]>( + () => [ + { + type: 'number', + position: 'left', + title: { + text: 'Daily Return Count', }, - data, - series: distributionBarSeries, - axes: [ - { - type: 'number', - position: 'left', - title: { - text: 'Daily Return Count', - }, - ...yAxisOverride, - }, - { - type: 'number', - position: 'bottom', - title: { - text: 'Daily Return (%)', - }, - ...xAxisOverride, - }, - ], - legend: { - enabled: false, + ...yAxisOverride, + }, + { + type: 'number', + position: 'bottom', + title: { + text: 'Daily Return (%)', }, - overlays: { - noData: { - text: 'No data to display', - }, + ...xAxisOverride, + }, + ], + [xAxisOverride, yAxisOverride] + ); + + const chartOptions = useMemo<AgChartOptions>( + () => ({ + height: 230, + padding: { + left: 20, + right: 20, + top: 20, + bottom: 20, + }, + data, + series: distributionBarSeries, + axes, + legend: { + enabled: false, + }, + overlays: { + noData: { + text: 'No data to display', }, - theme: { - baseTheme: chartTheme, - overrides: { - common: { - background: { - fill: 'transparent', - }, + }, + theme: { + baseTheme: chartTheme, + overrides: { + common: { + background: { + fill: 'transparent', }, - line: { - series: { - stroke: '#DAAB43', - cursor: 'crosshair', - marker: { - fill: '#DAAB43', - enabled: false, - }, + }, + line: { + series: { + stroke: '#DAAB43', + cursor: 'crosshair', + marker: { + fill: '#DAAB43', + enabled: false, }, }, - histogram: { - axes: { - number: { - gridLine: { - enabled: false, - }, + }, + histogram: { + axes: { + number: { + gridLine: { + enabled: false, }, }, }, }, }, - }} - /> + }, + }), + [axes, chartTheme, data] ); + + return <AgCharts options={chartOptions} />; }; + +export const ReturnDistributionGraph = memo(ReturnDistributionGraphComponent); diff --git a/src/features/shared/graphs/weightChangeOverTime.tsx b/src/features/shared/graphs/weightChangeOverTime.tsx index 95c8de7..42e2572 100644 --- a/src/features/shared/graphs/weightChangeOverTime.tsx +++ b/src/features/shared/graphs/weightChangeOverTime.tsx @@ -1,13 +1,16 @@ -import { FC, useMemo } from 'react'; +import { memo, useMemo } from 'react'; import { AgCharts } from 'ag-charts-react'; import { AgAxisLabelFormatterParams, AgAreaSeriesOptions, + AgCartesianAxisOptions, + AgCartesianSeriesOptions, AgChartLegendOptions, + AgChartOptions, + AgChartThemeName, AgNumberAxisOptions, AgTimeAxisOptions, time, - AgChartThemeName, } from 'ag-charts-community'; import 'ag-charts-enterprise'; import { useAppSelector } from '../../../app/hooks'; @@ -28,14 +31,14 @@ interface WeightChangeOverTimeGraphProps { const normalisedTokenName = (token: string) => token.replace(/\./g, '-'); -export const WeightChangeOverTimeGraph: FC<WeightChangeOverTimeGraphProps> = ({ +function WeightChangeOverTimeGraphComponent({ simulationRunBreakdown, yAxisOverride, legendOverride, tickIntervalInMonths = 6, overrideChartTheme, overrideXAxisInterval, -}) => { +}: WeightChangeOverTimeGraphProps) { const chartTheme = useAppSelector(selectAgChartTheme); const normalisedAreaData = useMemo(() => { @@ -46,7 +49,7 @@ export const WeightChangeOverTimeGraph: FC<WeightChangeOverTimeGraphProps> = ({ let data = getChartTimeSteps(simulationRunBreakdown); if (data.length > 200) { - //stacked chart is more cpu intensive, ~500 points is a good balance + // stacked chart is cpu intensive; reduce points to keep UI responsive const proportion = Math.ceil(data.length / 200); data = data.filter((_, index) => index % proportion === 0); } @@ -92,8 +95,8 @@ export const WeightChangeOverTimeGraph: FC<WeightChangeOverTimeGraphProps> = ({ return series; }, [simulationRunBreakdown]); - const timeAxisOption: AgTimeAxisOptions = useMemo(() => { - return { + const timeAxisOption = useMemo<AgTimeAxisOptions>( + () => ({ type: 'time', interval: { step: time.month.every(overrideXAxisInterval ?? tickIntervalInMonths), @@ -101,53 +104,68 @@ export const WeightChangeOverTimeGraph: FC<WeightChangeOverTimeGraphProps> = ({ label: { format: '%Y-%m', }, - }; - }, [tickIntervalInMonths, overrideXAxisInterval]); - - return ( - <AgCharts - options={{ - height: 230, - padding: { - right: 40, - top: 20, - bottom: 20, - left: 0, - }, - data: normalisedAreaData, - axes: [ - { ...timeAxisOption }, - { - type: 'number', - position: 'left', - label: { - formatter: (params: AgAxisLabelFormatterParams) => { - return params.value.toFixed(2) + '%'; - }, - }, - ...yAxisOverride, - }, - ], - series: normalisedAreaSeries, - legend: { - ...legendOverride, + }), + [tickIntervalInMonths, overrideXAxisInterval] + ); + + const axes = useMemo<AgCartesianAxisOptions[]>( + () => [ + { ...timeAxisOption }, + { + type: 'number', + position: 'left', + label: { + formatter: (params: AgAxisLabelFormatterParams) => + params.value.toFixed(2) + '%', }, - overlays: { - noData: { - text: 'No data', - }, + ...yAxisOverride, + }, + ], + [timeAxisOption, yAxisOverride] + ); + + const chartOptions = useMemo<AgChartOptions>( + () => ({ + height: 230, + padding: { + right: 40, + top: 20, + bottom: 20, + left: 0, + }, + data: normalisedAreaData, + axes, + series: normalisedAreaSeries as AgCartesianSeriesOptions[], + legend: { + ...legendOverride, + }, + overlays: { + noData: { + text: 'No data', }, - theme: { - baseTheme: overrideChartTheme ? overrideChartTheme : chartTheme, - overrides: { - common: { - background: { - fill: 'transparent', - }, + }, + theme: { + baseTheme: overrideChartTheme ? overrideChartTheme : chartTheme, + overrides: { + common: { + background: { + fill: 'transparent', }, }, }, - }} - /> + }, + }), + [ + axes, + chartTheme, + legendOverride, + normalisedAreaData, + normalisedAreaSeries, + overrideChartTheme, + ] ); -}; + + return <AgCharts options={chartOptions} />; +} + +export const WeightChangeOverTimeGraph = memo(WeightChangeOverTimeGraphComponent); diff --git a/src/features/simulationResults/visualisations/simulationResultAmountChart.tsx b/src/features/simulationResults/visualisations/simulationResultAmountChart.tsx index eeee5b6..4cd8df8 100644 --- a/src/features/simulationResults/visualisations/simulationResultAmountChart.tsx +++ b/src/features/simulationResults/visualisations/simulationResultAmountChart.tsx @@ -1,7 +1,7 @@ import { AgCharts } from 'ag-charts-react'; import * as agCharts from 'ag-charts-community'; -import { AgTimeAxisOptions } from 'ag-charts-community'; -import { useMemo, useState } from 'react'; +import { AgChartOptions, AgTimeAxisOptions } from 'ag-charts-community'; +import { memo, useMemo, useState } from 'react'; import styles from '../simulationResultSummary.module.css'; import { useAppSelector } from '../../../app/hooks'; @@ -30,125 +30,240 @@ export interface FlatAmountTimeStep { type VolumeType = 'tradingVolume' | 'reserveQuantity'; -export function SimulationResultAmountChart(props: BreakdownProps) { - const simulationBreakdownResults = props.breakdowns; - const simulationTimeRangeSelected = useAppSelector( - selectSimulationResultTimeRangeSelection - ); - const chartTheme = useAppSelector(selectAgChartTheme); +const normalisedTokenName = (token: string) => token.replace(/\./g, '-'); - const [volumeType, setVolumeType] = useState<VolumeType>('tradingVolume'); +function getAmountDeltaData(breakdown: SimulationRunBreakdown): FlatAmountTimeStep[] { + const result: FlatAmountTimeStep[] = []; + const data = getChartTimeSteps(breakdown); - // function amountFormatter(amount: number) { - // var sansDec = amount.toFixed(0); - // var formatted = sansDec.replace(/\B(?=(\d{3})+(?!\d))/g, ","); - // return `${formatted}`; - // } + for (const [index, current] of data.entries()) { + const prev = data[index - 1]; - const normalisedTokenName = (token: string) => { - return token.replace(/\./g, '-'); - }; + const timeStep: FlatAmountTimeStep = { + date: current.date, + unix: current.unix, + }; - function getAmountDeltaData( - breakdown: SimulationRunBreakdown - ): FlatAmountTimeStep[] { - const result: FlatAmountTimeStep[] = []; - const data = getChartTimeSteps(breakdown); + current.coinsHeld.forEach((coinHeld) => { + const lowerCode = coinHeld.coin.coinCode.toLowerCase(); + if (coinHeld.amount !== undefined && coinHeld.amount !== null) { + timeStep[normalisedTokenName(lowerCode)] = Math.abs( + coinHeld.amount - + (prev?.coinsHeld.find( + (z) => z.coin.coinCode === coinHeld.coin.coinCode + )?.amount ?? 0) + ); + } + }); - for (const [index, current] of data.entries()) { - const prev = data[index - 1]; + result.push(timeStep); + } - const timeStep: FlatAmountTimeStep = { - date: current.date, - unix: current.unix, - }; + return result; +} - current.coinsHeld.forEach((coinHeld) => { - const lowerCode = coinHeld.coin.coinCode.toLowerCase(); - if (coinHeld.amount !== undefined && coinHeld.amount !== null) { - timeStep[normalisedTokenName(lowerCode)] = Math.abs( - coinHeld.amount - - (prev?.coinsHeld.find( - (z) => z.coin.coinCode === coinHeld.coin.coinCode - )?.amount ?? 0) - ); - } - }); +function getAmountData(breakdown: SimulationRunBreakdown): FlatAmountTimeStep[] { + const result: FlatAmountTimeStep[] = []; + let data = getChartTimeSteps(breakdown); - result.push(timeStep); - } + if (data.length > 500) { + // stacked chart is cpu intensive; reduce points to keep UI responsive + const proportion = Math.ceil(data.length / 500); + data = data.filter((_, index) => index % proportion === 0); + } + for (const current of data) { + const timeStep: FlatAmountTimeStep = { + date: current.date, + unix: current.unix, + }; + + current.coinsHeld.forEach((coinHeld) => { + const lowerCode = coinHeld.coin.coinCode.toLowerCase(); + timeStep[normalisedTokenName(lowerCode)] = coinHeld.amount; + }); - return result; + result.push(timeStep); } + return result; +} - function getAmountData(breakdown: SimulationRunBreakdown) { - const result: FlatAmountTimeStep[] = []; - let data = getChartTimeSteps(breakdown); +function getAmountSeries( + breakdown: SimulationRunBreakdown +): agCharts.AgCartesianSeriesOptions[] { + return breakdown.simulationRun.poolConstituents.map((x) => ({ + type: 'line', + xKey: 'unix', + yKey: normalisedTokenName(x.coin.coinCode.toLowerCase()), + yName: x.coin.coinCode.toLowerCase(), + marker: { enabled: false }, + })); +} - if (data.length > 500) { - //stacked chart is more cpu intensive, ~500 points is a good balance - const proportion = Math.ceil(data.length / 500); - data = data.filter((_, index) => index % proportion === 0); - } - for (const current of data) { - const timeStep: FlatAmountTimeStep = { - date: current.date, - unix: current.unix, - }; +function getTimeAxisOption(dataLength: number): AgTimeAxisOptions { + return { + type: 'time', + interval: { + step: + dataLength > 350 + ? agCharts.time.month.every(6) + : dataLength > 150 + ? agCharts.time.month.every(3) + : agCharts.time.month.every(1), + }, + label: { + format: '%m/%y', + }, + }; +} - current.coinsHeld.forEach((coinHeld) => { - const lowerCode = coinHeld.coin.coinCode.toLowerCase(); - timeStep[normalisedTokenName(lowerCode)] = coinHeld.amount; - }); +const AmountBreakdownRow = memo(function AmountBreakdownRow({ + breakdown, + volumeType, +}: { + breakdown: SimulationRunBreakdown; + volumeType: VolumeType; +}) { + const chartTheme = useAppSelector(selectAgChartTheme); - result.push(timeStep); - } - return result; - } + const amountDeltaData = useMemo( + () => getAmountDeltaData(breakdown), + [breakdown] + ); + const amountData = useMemo(() => getAmountData(breakdown), [breakdown]); + const amountSeries = useMemo(() => getAmountSeries(breakdown), [breakdown]); - function getAmountSeries( - breakdown: SimulationRunBreakdown - ): agCharts.AgCartesianSeriesOptions[] { - const series: agCharts.AgCartesianSeriesOptions[] = []; - breakdown.simulationRun.poolConstituents.forEach((x) => { - series.push({ - type: 'line', - xKey: 'unix', - yKey: normalisedTokenName(x.coin.coinCode.toLowerCase()), - yName: x.coin.coinCode.toLowerCase(), - marker: { enabled: false }, - }); - }); - return series; - } + const tradingVolumeOptions = useMemo<AgChartOptions>( + () => ({ + height: 350, + data: amountDeltaData, + navigator: { + enabled: true, + height: 5, + spacing: 6, + }, + axes: [ + getTimeAxisOption(amountDeltaData.length), + { + type: 'log', + position: 'left', + base: 2, + label: { + format: '~s', + }, + }, + ], + series: amountSeries, + legend: { + position: 'bottom', + }, + overlays: { + noData: { + text: 'No data', + }, + }, + theme: { + baseTheme: chartTheme, + overrides: { + common: { + background: { + fill: 'transparent', + }, + }, + }, + }, + }), + [amountDeltaData, amountSeries, chartTheme] + ); - function getTimeAxisOption(dataLength: number): AgTimeAxisOptions { - return { - type: 'time', - interval: { - step: - dataLength > 350 - ? agCharts.time.month.every(6) - : dataLength > 150 - ? agCharts.time.month.every(3) - : agCharts.time.month.every(1), + const reserveQuantityOptions = useMemo<AgChartOptions>( + () => ({ + height: 350, + data: amountData, + navigator: { + enabled: true, + height: 5, + spacing: 6, }, - label: { - format: '%m/%y', + axes: [ + getTimeAxisOption(amountData.length), + { + type: 'log', + position: 'left', + base: 2, + label: { + format: '~s', + }, + }, + ], + series: amountSeries, + legend: { + position: 'bottom', }, - }; - } + overlays: { + noData: { + text: 'No data', + }, + }, + theme: { + baseTheme: chartTheme, + overrides: { + common: { + background: { + fill: 'transparent', + }, + }, + }, + }, + }), + [amountData, amountSeries, chartTheme] + ); + + return ( + <Row> + <Col span={4}> + <div className={styles.weightChartDescription}> + <h4>{breakdown.simulationRun.updateRule.updateRuleName}</h4> + <p>For time period: {breakdown.timeRange.name}</p> + <p>start date: {breakdown.timeRange.startDate}</p> + <p>end date: {breakdown.timeRange.endDate}</p> + </div> + </Col> + <Col + span={20} + style={{ + display: volumeType === 'reserveQuantity' ? 'none' : 'block', + }} + > + <AgCharts options={tradingVolumeOptions} /> + </Col> + <Col + span={20} + style={{ + display: volumeType !== 'reserveQuantity' ? 'none' : 'block', + }} + > + <AgCharts options={reserveQuantityOptions} /> + </Col> + </Row> + ); +}); + +function SimulationResultAmountChartComponent(props: BreakdownProps) { + const simulationTimeRangeSelected = useAppSelector( + selectSimulationResultTimeRangeSelection + ); + const [volumeType, setVolumeType] = useState<VolumeType>('tradingVolume'); const visibleBreakdowns = useMemo( () => - simulationBreakdownResults + props.breakdowns .filter((x) => x.simulationRun.updateRule.updateRuleName !== 'HODL') .filter((x) => x.simulationRunStatus === 'Complete') .filter((x) => x.timeRange.name === simulationTimeRangeSelected), - [simulationBreakdownResults, simulationTimeRangeSelected] + [props.breakdowns, simulationTimeRangeSelected] ); - const ChartDataTypeSelector = () => ( + const chartDataTypeSelector = ( <Row> <Col span={24}> <Divider className={styles.simResultDividers}>Amount Changes</Divider> @@ -169,131 +284,22 @@ export function SimulationResultAmountChart(props: BreakdownProps) { </Row> ); - const BreakdownAmountRows = () => ( - <Row className={styles.resultChartRow}> - <Col span={24}> - {visibleBreakdowns.map((x, index) => { - const amountDeltaData = getAmountDeltaData(x); - const amountData = getAmountData(x); - const amountSeries = getAmountSeries(x); - - return ( - <Row key={index}> - <Col span={4}> - <div className={styles.weightChartDescription}> - <h4>{x.simulationRun.updateRule.updateRuleName}</h4> - <p>For time period: {x.timeRange.name}</p> - <p>start date: {x.timeRange.startDate}</p> - <p>end date: {x.timeRange.endDate}</p> - </div> - </Col> - <Col - span={20} - style={{ - display: volumeType === 'reserveQuantity' ? 'none' : 'block', - }} - > - <AgCharts - options={{ - height: 350, - data: amountDeltaData, - navigator: { - enabled: true, - height: 5, - spacing: 6, - }, - axes: [ - getTimeAxisOption(amountDeltaData.length), - { - type: 'log', - position: 'left', - base: 2, - label: { - format: '~s', - }, - }, - ], - series: amountSeries, - legend: { - position: 'bottom', - }, - overlays: { - noData: { - text: 'No data', - }, - }, - theme: { - baseTheme: chartTheme, - overrides: { - common: { - background: { - fill: 'transparent', - }, - }, - }, - }, - }} - /> - </Col> - <Col - span={20} - style={{ - display: volumeType !== 'reserveQuantity' ? 'none' : 'block', - }} - > - <AgCharts - options={{ - height: 350, - data: amountData, - navigator: { - enabled: true, - height: 5, - spacing: 6, - }, - axes: [ - getTimeAxisOption(amountData.length), - { - type: 'log', - position: 'left', - base: 2, - label: { - format: '~s', - }, - }, - ], - series: amountSeries, - legend: { - position: 'bottom', - }, - overlays: { - noData: { - text: 'No data', - }, - }, - theme: { - baseTheme: chartTheme, - overrides: { - common: { - background: { - fill: 'transparent', - }, - }, - }, - }, - }} - /> - </Col> - </Row> - ); - })} - </Col> - </Row> - ); - return ( <div> - <ChartDataTypeSelector /> - <BreakdownAmountRows /> + {chartDataTypeSelector} + <Row className={styles.resultChartRow}> + <Col span={24}> + {visibleBreakdowns.map((breakdown) => ( + <AmountBreakdownRow + key={`${breakdown.simulationRun.id}-${breakdown.timeRange.name}-${breakdown.simulationRun.updateRule.updateRuleKey}`} + breakdown={breakdown} + volumeType={volumeType} + /> + ))} + </Col> + </Row> </div> ); } + +export const SimulationResultAmountChart = memo(SimulationResultAmountChartComponent); diff --git a/src/features/simulationResults/visualisations/simulationResultDrawdownGraph.tsx b/src/features/simulationResults/visualisations/simulationResultDrawdownGraph.tsx index c184280..010390f 100644 --- a/src/features/simulationResults/visualisations/simulationResultDrawdownGraph.tsx +++ b/src/features/simulationResults/visualisations/simulationResultDrawdownGraph.tsx @@ -8,32 +8,11 @@ import { DownOutlined } from '@ant-design/icons'; import { selectSimulationResultTimeRangeSelection } from '../../simulationRunner/simulationRunnerSlice'; import { selectAgChartTheme } from '../../themes/themeSlice'; import { BreakdownProps } from '../simulationResultsSummaryStep'; -import { useMemo, useState } from 'react'; +import { memo, useCallback, useMemo, useState } from 'react'; import { SimulationResultTimestepDto } from '../../simulationRunner/simulationRunnerDtos'; -export function SimulationResultDrawdownChart(props: BreakdownProps) { - const simulationTimeRangeSelected = useAppSelector( - selectSimulationResultTimeRangeSelection - ); - const chartTheme = useAppSelector(selectAgChartTheme); - - const [drawdownType, setDrawdownType] = useState( - 'Avg Daily Drawdown per week' - ); - - const onClick = ({ key }: { key: string }) => { - setDrawdownType(key); - }; - - const visibleBreakdowns = useMemo( - () => - props.breakdowns.filter( - (x) => x.timeRange.name === simulationTimeRangeSelected - ), - [props.breakdowns, simulationTimeRangeSelected] - ); - - const items = [ +function SimulationResultDrawdownChartComponent(props: BreakdownProps) { + const drawdownItems = [ { label: 'Avg Daily Drawdown per week', key: 'Avg Daily Drawdown per week', @@ -58,6 +37,27 @@ export function SimulationResultDrawdownChart(props: BreakdownProps) { { label: 'Monthly CDaR', key: 'Monthly CDaR' }, ]; + const simulationTimeRangeSelected = useAppSelector( + selectSimulationResultTimeRangeSelection + ); + const chartTheme = useAppSelector(selectAgChartTheme); + + const [drawdownType, setDrawdownType] = useState( + 'Avg Daily Drawdown per week' + ); + + const onClick = useCallback(({ key }: { key: string }) => { + setDrawdownType(key); + }, []); + + const visibleBreakdowns = useMemo( + () => + props.breakdowns.filter( + (x) => x.timeRange.name === simulationTimeRangeSelected + ), + [props.breakdowns, simulationTimeRangeSelected] + ); + const series = useMemo((): agCharts.AgCartesianSeriesOptions[] => { const seriesArray: agCharts.AgCartesianSeriesOptions[] = []; visibleBreakdowns.forEach((x) => { @@ -113,6 +113,52 @@ export function SimulationResultDrawdownChart(props: BreakdownProps) { }; }, [visibleBreakdowns]); + const chartOptions = useMemo( + () => ({ + height: 400, + navigator: { + enabled: true, + height: 5, + spacing: 6, + }, + axes: [ + timeAxisOption, + { + type: 'number' as const, + position: 'left' as const, + }, + ], + series, + legend: { + position: 'bottom' as const, + }, + overlays: { + noData: { + text: 'No data', + }, + }, + theme: { + baseTheme: chartTheme, + overrides: { + common: { + background: { + fill: 'transparent', + }, + }, + line: { + series: { + cursor: 'crosshair', + marker: { + enabled: false, + }, + }, + }, + }, + }, + }), + [chartTheme, series, timeAxisOption] + ); + return ( <div> <div hidden={props.hideTitle}> @@ -125,7 +171,7 @@ export function SimulationResultDrawdownChart(props: BreakdownProps) { <Col span={8}> <Dropdown menu={{ - items, + items: drawdownItems, onClick, }} > @@ -144,7 +190,7 @@ export function SimulationResultDrawdownChart(props: BreakdownProps) { <Col span={24}> <Dropdown menu={{ - items, + items: drawdownItems, onClick, }} > @@ -160,52 +206,13 @@ export function SimulationResultDrawdownChart(props: BreakdownProps) { </div> <Row className={styles.resultChartRow}> <Col span={24}> - <AgCharts - options={{ - height: 400, - navigator: { - enabled: true, - height: 5, - spacing: 6, - }, - axes: [ - timeAxisOption, - { - type: 'number', - position: 'left', - }, - ], - series, - legend: { - position: 'bottom', - }, - overlays: { - noData: { - text: 'No data', - }, - }, - theme: { - baseTheme: chartTheme, - overrides: { - common: { - background: { - fill: 'transparent', - }, - }, - line: { - series: { - cursor: 'crosshair', - marker: { - enabled: false, - }, - }, - }, - }, - }, - }} - /> + <AgCharts options={chartOptions} /> </Col> </Row> </div> ); } + +export const SimulationResultDrawdownChart = memo( + SimulationResultDrawdownChartComponent +); diff --git a/src/features/simulationResults/visualisations/simulationResultMarketValueChart.tsx b/src/features/simulationResults/visualisations/simulationResultMarketValueChart.tsx index 958d1fa..56c440d 100644 --- a/src/features/simulationResults/visualisations/simulationResultMarketValueChart.tsx +++ b/src/features/simulationResults/visualisations/simulationResultMarketValueChart.tsx @@ -1,7 +1,7 @@ import { AgCharts } from 'ag-charts-react'; import * as agCharts from 'ag-charts-community'; import { AgTimeAxisOptions } from 'ag-charts-community'; -import { useMemo } from 'react'; +import { memo, useMemo } from 'react'; import styles from '../simulationResultSummary.module.css'; import { useAppSelector } from '../../../app/hooks'; @@ -24,7 +24,7 @@ export interface SeriesConfig { stroke: string | undefined; } -export function SimulationResultMarketValueChart(props: BreakdownProps) { +function SimulationResultMarketValueChartComponent(props: BreakdownProps) { const simulationBreakdownResults = props.breakdowns; const simulationTimeRangeSelected = useAppSelector( selectSimulationResultTimeRangeSelection @@ -99,27 +99,96 @@ export function SimulationResultMarketValueChart(props: BreakdownProps) { visibleBreakdowns, ]); - function getTimeAxisOption(dataLength: number): AgTimeAxisOptions { - return { + const visibleBreakdownStepsLength = + visibleBreakdowns[0]?.timeSteps.length ?? 1; + + const timeAxisOption = useMemo<AgTimeAxisOptions>( + () => ({ type: 'time', interval: { step: props.overrideXAxisInterval !== undefined ? agCharts.time.month.every(props.overrideXAxisInterval) - : dataLength > 350 + : visibleBreakdownStepsLength > 350 ? agCharts.time.month.every(6) - : dataLength > 150 + : visibleBreakdownStepsLength > 150 ? agCharts.time.month.every(3) : agCharts.time.month.every(1), }, label: { format: '%Y-%m', }, - }; - } + }), + [props.overrideXAxisInterval, visibleBreakdownStepsLength] + ); - const visibleBreakdownStepsLength = - visibleBreakdowns[0]?.timeSteps.length ?? 1; + const chartOptions = useMemo( + () => ({ + height: props.overrideHeight ?? 500, + navigator: { + enabled: props.overrideNagivagtion ?? true, + height: 5, + spacing: 6, + }, + padding: { + right: 40, + }, + axes: [ + timeAxisOption, + { + type: 'number' as const, + position: 'left' as const, + label: { + format: '$~s', + }, + max: props.overrideYAxisMax ?? undefined, + min: props.overrideYAxisMin ?? undefined, + interval: props.overrideYAxisInterval + ? { + values: props.overrideYAxisInterval, + } + : undefined, + }, + ], + series, + legend: { + position: 'top' as const, + }, + overlays: { + noData: { + text: 'No data', + }, + }, + theme: { + baseTheme: chartTheme, + overrides: { + common: { + background: { + fill: 'transparent', + }, + }, + line: { + series: { + cursor: 'crosshair', + marker: { + enabled: false, + }, + }, + }, + }, + }, + }), + [ + chartTheme, + props.overrideHeight, + props.overrideNagivagtion, + props.overrideYAxisInterval, + props.overrideYAxisMax, + props.overrideYAxisMin, + series, + timeAxisOption, + ] + ); return ( <div> @@ -132,63 +201,7 @@ export function SimulationResultMarketValueChart(props: BreakdownProps) { <Col span={24}> <Row className={styles.marketValueChart}> <Col span={24}> - <AgCharts - options={{ - height: props.overrideHeight ?? 500, - navigator: { - enabled: props.overrideNagivagtion ?? true, - height: 5, - spacing: 6, - }, - padding: { - right: 40, - }, - axes: [ - getTimeAxisOption(visibleBreakdownStepsLength), - { - type: 'number', - position: 'left', - label: { - format: '$~s', - }, - max: props.overrideYAxisMax ?? undefined, - min: props.overrideYAxisMin ?? undefined, - interval: props.overrideYAxisInterval - ? { - values: props.overrideYAxisInterval, - } - : undefined, - }, - ], - series, - legend: { - position: 'top', - }, - overlays: { - noData: { - text: 'No data', - }, - }, - theme: { - baseTheme: chartTheme, - overrides: { - common: { - background: { - fill: 'transparent', - }, - }, - line: { - series: { - cursor: 'crosshair', - marker: { - enabled: false, - }, - }, - }, - }, - }, - }} - /> + <AgCharts options={chartOptions} /> </Col> </Row> </Col> @@ -196,3 +209,7 @@ export function SimulationResultMarketValueChart(props: BreakdownProps) { </div> ); } + +export const SimulationResultMarketValueChart = memo( + SimulationResultMarketValueChartComponent +); diff --git a/src/features/simulationResults/visualisations/simulationResultReturnChart.tsx b/src/features/simulationResults/visualisations/simulationResultReturnChart.tsx index 2376c24..6a993eb 100644 --- a/src/features/simulationResults/visualisations/simulationResultReturnChart.tsx +++ b/src/features/simulationResults/visualisations/simulationResultReturnChart.tsx @@ -1,38 +1,119 @@ import { useAppSelector } from '../../../app/hooks'; import { Row, Col, Divider } from 'antd'; -import { useMemo } from 'react'; +import { memo, useMemo } from 'react'; import { ReturnDistributionGraph } from '../../shared/graphs'; import { selectSimulationResultTimeRangeSelection } from '../../simulationRunner/simulationRunnerSlice'; import { BreakdownProps } from '../simulationResultsSummaryStep'; import styles from '../simulationResultSummary.module.css'; -import { SimulationRunMetric } from '../simulationResultSummaryModels'; +import { + SimulationRunBreakdown, + SimulationRunMetric, +} from '../simulationResultSummaryModels'; export interface Marker { enabled: boolean; } -export function SimulationResultReturnChart(props: BreakdownProps) { +function getMetricName( + metric: SimulationRunMetric[], + metricName: string | string[], + percentage = false +) { + const metricNames = Array.isArray(metricName) ? metricName : [metricName]; + const selected = metric.find((x) => metricNames.includes(x.metricName)); + if (selected?.metricValue == null) { + return '-'; + } + + return percentage + ? (selected.metricValue * 100).toFixed(4) + : selected.metricValue.toFixed(4); +} + +const ReturnBreakdownRow = memo(function ReturnBreakdownRow({ + result, +}: { + result: SimulationRunBreakdown; +}) { + const marketValues = useMemo( + () => result.timeSteps.map((x) => x.totalPoolMarketValue), + [result.timeSteps] + ); + + return ( + <Row> + <Col span={4}> + <div className={styles.weightChartDescription}> + <h4>{result.simulationRun.updateRule.updateRuleName}</h4> + <p>For time period: {result.timeRange.name}</p> + <p>start date: {result.timeRange.startDate}</p> + <p>end date: {result.timeRange.endDate}</p> + </div> + </Col> + <Col span={16}> + <ReturnDistributionGraph marketValues={marketValues} /> + </Col> + <Col span={4}> + <Row> + <Col span={24}> + <p style={{ margin: 0 }}> + mean:{' '} + {getMetricName( + result.simulationRunResultAnalysis?.return_analysis ?? [], + 'mean', + true + )} + </p> + </Col> + <Col span={24}> + <p style={{ margin: 0 }}> + std:{' '} + {getMetricName( + result.simulationRunResultAnalysis?.return_analysis ?? [], + 'std', + true + )} + </p> + </Col> + <Col span={24}> + <p style={{ margin: 0 }}> + skewness:{' '} + {getMetricName( + result.simulationRunResultAnalysis?.return_analysis ?? [], + 'skewness' + )} + </p> + </Col> + <Col span={24}> + <p style={{ margin: 0 }}> + Kurtosis:{' '} + {getMetricName( + result.simulationRunResultAnalysis?.return_analysis ?? [], + 'kurtosis' + )} + </p> + </Col> + <Col span={24}> + <p style={{ margin: 0 }}> + Jaque Bera:{' '} + {getMetricName( + result.simulationRunResultAnalysis?.return_analysis ?? [], + ['jarqueBera', 'jarque_bera', 'jaqueBera'] + )} + </p> + </Col> + </Row> + </Col> + </Row> + ); +}); + +function SimulationResultReturnChartComponent(props: BreakdownProps) { const simulationBreakdownResults = props.breakdowns; const simulationTimeRangeSelected = useAppSelector( selectSimulationResultTimeRangeSelection ); - function getMetricName( - metric: SimulationRunMetric[], - metricName: string | string[], - percentage = false - ) { - const metricNames = Array.isArray(metricName) ? metricName : [metricName]; - const selected = metric.find((x) => metricNames.includes(x.metricName)); - if (selected?.metricValue == null) { - return '-'; - } - - return percentage - ? (selected.metricValue * 100).toFixed(4) - : selected.metricValue.toFixed(4); - } - const visibleBreakdowns = useMemo( () => simulationBreakdownResults @@ -50,83 +131,16 @@ export function SimulationResultReturnChart(props: BreakdownProps) { </Divider> <Row className={styles.resultChartRow}> <Col span={24}> - {visibleBreakdowns.map((result, index) => ( - <Row key={index}> - <Col span={4}> - <div className={styles.weightChartDescription}> - <h4>{result.simulationRun.updateRule.updateRuleName}</h4> - <p>For time period: {result.timeRange.name}</p> - <p>start date: {result.timeRange.startDate}</p> - <p>end date: {result.timeRange.endDate}</p> - </div> - </Col> - <Col span={16}> - <ReturnDistributionGraph - marketValues={result.timeSteps.map( - (x) => x.totalPoolMarketValue - )} - /> - </Col> - <Col span={4}> - <Row> - <Col span={24}> - <p style={{ margin: 0 }}> - mean:{' '} - {getMetricName( - result.simulationRunResultAnalysis?.return_analysis ?? - [], - 'mean', - true - )} - </p> - </Col> - <Col span={24}> - <p style={{ margin: 0 }}> - std:{' '} - {getMetricName( - result.simulationRunResultAnalysis?.return_analysis ?? - [], - 'std', - true - )} - </p> - </Col> - <Col span={24}> - <p style={{ margin: 0 }}> - skewness:{' '} - {getMetricName( - result.simulationRunResultAnalysis?.return_analysis ?? - [], - 'skewness' - )} - </p> - </Col> - <Col span={24}> - <p style={{ margin: 0 }}> - Kurtosis:{' '} - {getMetricName( - result.simulationRunResultAnalysis?.return_analysis ?? - [], - 'kurtosis' - )} - </p> - </Col> - <Col span={24}> - <p style={{ margin: 0 }}> - Jaque Bera:{' '} - {getMetricName( - result.simulationRunResultAnalysis?.return_analysis ?? - [], - ['jarqueBera', 'jarque_bera', 'jaqueBera'] - )} - </p> - </Col> - </Row> - </Col> - </Row> + {visibleBreakdowns.map((result) => ( + <ReturnBreakdownRow + key={`${result.simulationRun.id}-${result.timeRange.name}-${result.simulationRun.updateRule.updateRuleKey}`} + result={result} + /> ))} </Col> </Row> </div> ); } + +export const SimulationResultReturnChart = memo(SimulationResultReturnChartComponent); diff --git a/src/features/simulationResults/visualisations/simulationResultVaRGraph.tsx b/src/features/simulationResults/visualisations/simulationResultVaRGraph.tsx index eda9162..bed3863 100644 --- a/src/features/simulationResults/visualisations/simulationResultVaRGraph.tsx +++ b/src/features/simulationResults/visualisations/simulationResultVaRGraph.tsx @@ -9,7 +9,7 @@ import { selectSimulationResultTimeRangeSelection } from '../../simulationRunner import { VaRTimestep } from '../simulationResultSummaryModels'; import { selectAgChartTheme } from '../../themes/themeSlice'; import { BreakdownProps } from '../simulationResultsSummaryStep'; -import { useMemo, useState } from 'react'; +import { memo, useCallback, useMemo, useState } from 'react'; import { DownOutlined } from '@ant-design/icons'; import { SimulationResultTimestepDto } from '../../simulationRunner/simulationRunnerDtos'; @@ -26,7 +26,12 @@ export interface VaRSeriesConfig { marker: Marker; } -export function SimulationResultVaRChart(props: BreakdownProps) { +function SimulationResultVaRChartComponent(props: BreakdownProps) { + const varItems = [ + { label: 'Weekly CDaR', key: 'Weekly CDaR' }, + { label: 'Monthly CDaR', key: 'Monthly CDaR' }, + ]; + const simulationTimeRangeSelected = useAppSelector( selectSimulationResultTimeRangeSelection ); @@ -34,14 +39,9 @@ export function SimulationResultVaRChart(props: BreakdownProps) { const [varType, setVarType] = useState('Weekly CDaR'); - const onClick = ({ key }: { key: string }) => { + const onClick = useCallback(({ key }: { key: string }) => { setVarType(key); - }; - - const items = [ - { label: 'Weekly CDaR', key: 'Weekly CDaR' }, - { label: 'Monthly CDaR', key: 'Monthly CDaR' }, - ]; + }, []); const visibleBreakdowns = useMemo( () => @@ -106,6 +106,52 @@ export function SimulationResultVaRChart(props: BreakdownProps) { }; }, [visibleBreakdowns]); + const chartOptions = useMemo( + () => ({ + height: 400, + navigator: { + enabled: true, + height: 5, + spacing: 6, + }, + axes: [ + timeAxisOption, + { + type: 'number' as const, + position: 'left' as const, + }, + ], + series, + legend: { + position: 'bottom' as const, + }, + overlays: { + noData: { + text: 'No data', + }, + }, + theme: { + baseTheme: chartTheme, + overrides: { + common: { + background: { + fill: 'transparent', + }, + }, + line: { + series: { + cursor: 'crosshair', + marker: { + enabled: false, + }, + }, + }, + }, + }, + }), + [chartTheme, series, timeAxisOption] + ); + return ( <div> <Row> @@ -115,7 +161,7 @@ export function SimulationResultVaRChart(props: BreakdownProps) { <Col span={8}> <Dropdown menu={{ - items, + items: varItems, onClick, }} > @@ -130,52 +176,11 @@ export function SimulationResultVaRChart(props: BreakdownProps) { </Row> <Row className={styles.resultChartRow}> <Col span={24}> - <AgCharts - options={{ - height: 400, - navigator: { - enabled: true, - height: 5, - spacing: 6, - }, - axes: [ - timeAxisOption, - { - type: 'number', - position: 'left', - }, - ], - series, - legend: { - position: 'bottom', - }, - overlays: { - noData: { - text: 'No data', - }, - }, - theme: { - baseTheme: chartTheme, - overrides: { - common: { - background: { - fill: 'transparent', - }, - }, - line: { - series: { - cursor: 'crosshair', - marker: { - enabled: false, - }, - }, - }, - }, - }, - }} - /> + <AgCharts options={chartOptions} /> </Col> </Row> </div> ); } + +export const SimulationResultVaRChart = memo(SimulationResultVaRChartComponent); diff --git a/src/features/simulationResults/visualisations/simulationResultWeightChart.tsx b/src/features/simulationResults/visualisations/simulationResultWeightChart.tsx index 1eb4a64..9788de7 100644 --- a/src/features/simulationResults/visualisations/simulationResultWeightChart.tsx +++ b/src/features/simulationResults/visualisations/simulationResultWeightChart.tsx @@ -1,7 +1,7 @@ import { Row, Col, Divider } from 'antd'; import { AgCharts } from 'ag-charts-react'; -import { AgDonutSeriesOptions } from 'ag-charts-community'; -import { useMemo } from 'react'; +import { AgChartOptions, AgDonutSeriesOptions } from 'ag-charts-community'; +import { memo, useMemo } from 'react'; import { useAppSelector } from '../../../app/hooks'; import { selectSimulationResultTimeRangeSelection } from '../../simulationRunner/simulationRunnerSlice'; import { WeightChangeOverTimeGraph } from '../../shared/graphs/weightChangeOverTime'; @@ -25,66 +25,125 @@ export interface FlatWeightTimeStep { [key: string]: any; } -export function SimulationResultWeightChart({ breakdowns }: BreakdownProps) { - const simulationTimeRangeSelected = useAppSelector( - selectSimulationResultTimeRangeSelection - ); - const chartTheme = useAppSelector(selectAgChartTheme); +function getPieWeightData(breakdown: SimulationRunBreakdown): FlatWeightData[] { + const data: FlatWeightData[] = []; + const final = breakdown.timeSteps[breakdown.timeSteps.length - 1]; + breakdown.simulationRun.poolConstituents.forEach((x) => { + if (x.weight !== undefined) { + data.push({ + coinCode: x.coin.coinCode, + initialWeight: x.weight, + finalWeight: + final.coinsHeld.find((y) => y.coin.coinCode === x.coin.coinCode) + ?.weight ?? x.weight, + }); + } + }); + + return data; +} + +function getPieSimulationResultSeries( + breakdown: SimulationRunBreakdown +): AgDonutSeriesOptions[] { + const seriesArray: AgDonutSeriesOptions[] = []; - function getPieWeightData( - breakdown: SimulationRunBreakdown - ): FlatWeightData[] { - const data: FlatWeightData[] = []; - const final = breakdown.timeSteps[breakdown.timeSteps.length - 1]; - breakdown.simulationRun.poolConstituents.forEach((x) => { - if (x.weight !== undefined) { - data.push({ - coinCode: x.coin.coinCode, - initialWeight: x.weight, - finalWeight: - final.coinsHeld.find((y) => y.coin.coinCode === x.coin.coinCode) - ?.weight ?? x.weight, - }); - } + if (breakdown.timeSteps.length !== 0) { + seriesArray.push({ + type: 'donut', + sectorLabelKey: 'coinCode', + angleKey: 'finalWeight', + outerRadiusOffset: -5, + innerRadiusOffset: -15, + outerRadiusRatio: 1, + innerRadiusRatio: 0.6, + shadow: { + enabled: true, + }, }); - return data; + seriesArray.push({ + type: 'donut', + angleKey: 'initialWeight', + outerRadiusOffset: -15, + innerRadiusOffset: -25, + outerRadiusRatio: 0.6, + innerRadiusRatio: 0.1, + shadow: { + enabled: true, + }, + }); } - function getPieSimulationResultSeries( - breakdown: SimulationRunBreakdown - ): AgDonutSeriesOptions[] { - const seriesArray: AgDonutSeriesOptions[] = []; - - if (breakdown.timeSteps.length !== 0) { - seriesArray.push({ - type: 'donut', - sectorLabelKey: 'coinCode', - angleKey: 'finalWeight', - outerRadiusOffset: -5, - innerRadiusOffset: -15, - outerRadiusRatio: 1, - innerRadiusRatio: 0.6, - shadow: { - enabled: true, - }, - }); + return seriesArray; +} + +const WeightBreakdownRow = memo(function WeightBreakdownRow({ + result, +}: { + result: SimulationRunBreakdown; +}) { + const chartTheme = useAppSelector(selectAgChartTheme); + + const pieWeightData = useMemo(() => getPieWeightData(result), [result]); + const pieSeries = useMemo(() => getPieSimulationResultSeries(result), [result]); - seriesArray.push({ - type: 'donut', - angleKey: 'initialWeight', - outerRadiusOffset: -15, - innerRadiusOffset: -25, - outerRadiusRatio: 0.6, - innerRadiusRatio: 0.1, - shadow: { - enabled: true, + const chartOptions = useMemo<AgChartOptions>( + () => ({ + height: 220, + width: 300, + data: pieWeightData, + series: pieSeries, + overlays: { + noData: { + text: 'No data', }, - }); - } + }, + theme: { + baseTheme: chartTheme, + overrides: { + common: { + background: { + fill: 'transparent', + }, + }, + line: { + series: { + cursor: 'crosshair', + marker: { + enabled: false, + }, + }, + }, + }, + }, + }), + [chartTheme, pieSeries, pieWeightData] + ); - return seriesArray; - } + return ( + <Row> + <Col span={4}> + <div className={styles.weightChartDescription}> + <h4>{result.simulationRun.updateRule.updateRuleName}</h4> + <p>start date: {result.timeRange.startDate}</p> + <p>end date: {result.timeRange.endDate}</p> + </div> + </Col> + <Col span={6}> + <AgCharts options={chartOptions} /> + </Col> + <Col span={14}> + <WeightChangeOverTimeGraph simulationRunBreakdown={result} /> + </Col> + </Row> + ); +}); + +function SimulationResultWeightChartComponent({ breakdowns }: BreakdownProps) { + const simulationTimeRangeSelected = useAppSelector( + selectSimulationResultTimeRangeSelection + ); const visibleBreakdowns = useMemo( () => @@ -117,57 +176,16 @@ export function SimulationResultWeightChart({ breakdowns }: BreakdownProps) { </Row> <Row className={styles.resultChartRow}> <Col span={24}> - {visibleBreakdowns.map( - (result: SimulationRunBreakdown, index: number) => ( - <Row key={index}> - <Col span={4}> - <div className={styles.weightChartDescription}> - <h4>{result.simulationRun.updateRule.updateRuleName}</h4> - <p>start date: {result.timeRange.startDate}</p> - <p>end date: {result.timeRange.endDate}</p> - </div> - </Col> - <Col span={6}> - <AgCharts - options={{ - height: 220, - width: 300, - data: getPieWeightData(result), - series: getPieSimulationResultSeries(result), - overlays: { - noData: { - text: 'No data', - }, - }, - theme: { - baseTheme: chartTheme, - overrides: { - common: { - background: { - fill: 'transparent', - }, - }, - line: { - series: { - cursor: 'crosshair', - marker: { - enabled: false, - }, - }, - }, - }, - }, - }} - /> - </Col> - <Col span={14}> - <WeightChangeOverTimeGraph simulationRunBreakdown={result} /> - </Col> - </Row> - ) - )} + {visibleBreakdowns.map((result: SimulationRunBreakdown) => ( + <WeightBreakdownRow + key={`${result.simulationRun.id}-${result.timeRange.name}-${result.simulationRun.updateRule.updateRuleKey}`} + result={result} + /> + ))} </Col> </Row> </div> ); } + +export const SimulationResultWeightChart = memo(SimulationResultWeightChartComponent); From 901b20cddc66575dada0b0b08a7c5d479fafdae6 Mon Sep 17 00:00:00 2001 From: christian harrington <christian@bulkbit.systems> Date: Wed, 18 Feb 2026 17:16:07 +0000 Subject: [PATCH 10/21] additional component and subcomponent definition changes to avoid profiler spotted remounts --- .../simulationResultsSummaryStep.tsx | 58 +-- .../simulationRunnerImportResultsModal.tsx | 198 +++++++++- .../simulationRunner/simulationRunner.tsx | 362 ++++++++++++++++-- 3 files changed, 567 insertions(+), 51 deletions(-) diff --git a/src/features/simulationResults/simulationResultsSummaryStep.tsx b/src/features/simulationResults/simulationResultsSummaryStep.tsx index 527de6f..37426d5 100644 --- a/src/features/simulationResults/simulationResultsSummaryStep.tsx +++ b/src/features/simulationResults/simulationResultsSummaryStep.tsx @@ -10,7 +10,7 @@ import { } from '../simulationRunner/simulationRunnerSlice'; import { Col, Menu, Row, Tabs } from 'antd'; -import { useEffect } from 'react'; +import { memo, useCallback, useEffect } from 'react'; import { LineChartOutlined, @@ -100,7 +100,10 @@ function getBreakdownMenuItems(): ItemType[] { ]; } -export function SimulationResultsSummaryStep(props: BreakdownProps) { +function SimulationResultsSummaryStepComponent(props: BreakdownProps) { + const graphMenuItems = getGraphMenuItems(); + const breakdownMenuItems = getBreakdownMenuItems(); + const runStatusIndex = useAppSelector(selectSimulationRunStatusStepIndex); const resultChartSelection = useAppSelector( selectSimulationResultChartSelection @@ -111,7 +114,6 @@ export function SimulationResultsSummaryStep(props: BreakdownProps) { const timeRangeSelected = useAppSelector( selectSimulationResultTimeRangeSelection ); - const chartSelection = useAppSelector(selectSimulationResultChartSelection); const dispatch = useAppDispatch(); @@ -127,7 +129,7 @@ export function SimulationResultsSummaryStep(props: BreakdownProps) { } }, [dispatch, props.breakdowns, timeRangeSelected]); - function getChart(): JSX.Element { + const getChart = (): JSX.Element => { if (resultChartSelection === 'MarketValueOverTime') { return ( <SimulationResultMarketValueChart @@ -167,9 +169,9 @@ export function SimulationResultsSummaryStep(props: BreakdownProps) { } return <div>No charts selected</div>; - } + }; - function getBreakdown(): JSX.Element { + const getBreakdown = (): JSX.Element => { if (resultBreakdownSelection === 'MvSummary') { return ( <SimulationRunMvSummaryBreakdown @@ -181,9 +183,23 @@ export function SimulationResultsSummaryStep(props: BreakdownProps) { return <SimulationRunPerformanceAnalysisBreakdown {...props} />; } return <div>No Breakdown</div>; - } + }; + + const handleChartSelection = useCallback( + (menuItem: { key: string }) => { + dispatch(changeChartSelected(menuItem.key)); + }, + [dispatch] + ); + + const handleBreakdownSelection = useCallback( + (menuItem: { key: string }) => { + dispatch(changeBreakdownSelected(menuItem.key)); + }, + [dispatch] + ); - const VisualisationTab = () => ( + const visualisationTab = ( <Row> <Col span={4}> <Row justify="center"> @@ -193,12 +209,9 @@ export function SimulationResultsSummaryStep(props: BreakdownProps) { width: 200, fontSize: 10, }} - defaultSelectedKeys={[chartSelection]} - items={getGraphMenuItems()} - activeKey={chartSelection} - onClick={(x) => { - dispatch(changeChartSelected(x.key)); - }} + items={graphMenuItems} + activeKey={resultChartSelection} + onClick={handleChartSelection} /> </Col> </Row> @@ -213,7 +226,7 @@ export function SimulationResultsSummaryStep(props: BreakdownProps) { </Row> ); - const BreakdownTab = () => ( + const breakdownTab = ( <Row> <Col span={4}> <Row justify="center"> @@ -223,11 +236,8 @@ export function SimulationResultsSummaryStep(props: BreakdownProps) { width: 200, fontSize: 10, }} - defaultSelectedKeys={[resultBreakdownSelection]} - items={getBreakdownMenuItems()} - onClick={(x) => { - dispatch(changeBreakdownSelected(x.key)); - }} + items={breakdownMenuItems} + onClick={handleBreakdownSelection} activeKey={resultBreakdownSelection} /> </Col> @@ -242,13 +252,17 @@ export function SimulationResultsSummaryStep(props: BreakdownProps) { <Col span={24}> <Tabs> <TabPane tab="Result Visualisation" key={'vis'}> - <VisualisationTab /> + {visualisationTab} </TabPane> <TabPane tab="Result Breakdown" key={'breakdown'}> - <BreakdownTab /> + {breakdownTab} </TabPane> </Tabs> </Col> </Row> ); } + +export const SimulationResultsSummaryStep = memo( + SimulationResultsSummaryStepComponent +); diff --git a/src/features/simulationRunner/sections/simulationRunnerImportResultsModal.tsx b/src/features/simulationRunner/sections/simulationRunnerImportResultsModal.tsx index 75813d3..c4f139f 100644 --- a/src/features/simulationRunner/sections/simulationRunnerImportResultsModal.tsx +++ b/src/features/simulationRunner/sections/simulationRunnerImportResultsModal.tsx @@ -1,29 +1,153 @@ -import { Button, Col, Modal } from 'antd'; -import { ChangeEvent, RefObject } from 'react'; +import { + Button, + Col, + InputNumber, + Modal, + Select, + Spin, + Typography, +} from 'antd'; +import { + ChangeEvent, + RefObject, + memo, + useEffect, + useMemo, + useState, +} from 'react'; +import { + GetPoolsSummaryQueryVariables, + GqlChain, + GqlPoolType, +} from '../../../__generated__/graphql-types'; +import { useFetchPoolsSummaryByParams } from '../../../hooks/useFetchPoolsSummaryByParams'; +import { poolTypes } from '../../productDetail/productDetailContent/comparableProduct/comparableProductFormHelper'; import styles from '../../simulationResults/simulationResultSummary.module.css'; import runnerStyles from '../simulationRunnerCommon.module.css'; +const { Text } = Typography; +const DEFAULT_MIN_TVL = 10000; +const MIN_ALLOWED_TVL = 1000; + +export interface LivePoolSelection { + id: string; + chain: GqlChain; + name: string; +} + +interface LivePoolSelectOption extends LivePoolSelection { + value: string; + label: string; +} + interface ImportResultsModalProps { isOpen: boolean; + runStatusIndex: number; + importingLivePool: boolean; onClose: () => void; paramsFileInputRef: RefObject<HTMLInputElement>; resultsFileInputRef: RefObject<HTMLInputElement>; onParamsImportClick: () => void; onResultsImportClick: () => void; + onLivePoolImport: (pool: LivePoolSelection) => Promise<void> | void; onParamsFileChange: (event: ChangeEvent<HTMLInputElement>) => void; onResultsFileChange: (event: ChangeEvent<HTMLInputElement>) => void; } -export function SimulationRunnerImportResultsModal({ +function SimulationRunnerImportResultsModalComponent({ isOpen, + runStatusIndex, + importingLivePool, onClose, paramsFileInputRef, resultsFileInputRef, onParamsImportClick, onResultsImportClick, + onLivePoolImport, onParamsFileChange, onResultsFileChange, }: ImportResultsModalProps) { + const [selectedPoolType, setSelectedPoolType] = useState< + GqlPoolType | undefined + >(undefined); + const [selectedPoolValue, setSelectedPoolValue] = useState< + string | undefined + >(undefined); + const [minTvl, setMinTvl] = useState<number>(DEFAULT_MIN_TVL); + + const poolParams = useMemo<GetPoolsSummaryQueryVariables>( + () => ({ + first: 100, + where: { + minTvl, + tagNotIn: ['BLACK_LISTED'], + ...(selectedPoolType ? { poolTypeIn: [selectedPoolType] } : {}), + }, + }), + [minTvl, selectedPoolType] + ); + + const { data, loading: loadingPools } = useFetchPoolsSummaryByParams( + poolParams, + { + skip: !isOpen, + } + ); + + const poolOptions = useMemo<LivePoolSelectOption[]>( + () => + data?.poolGetPools.map((pool) => ({ + value: `${pool.chain}:${pool.id}`, + label: `${pool.name} (${pool.chain})`, + id: pool.id, + chain: pool.chain, + name: pool.name, + })) ?? [], + [data?.poolGetPools] + ); + + const selectedLivePool = useMemo( + () => poolOptions.find((option) => option.value === selectedPoolValue), + [poolOptions, selectedPoolValue] + ); + + useEffect(() => { + if (selectedPoolValue && !selectedLivePool) { + setSelectedPoolValue(undefined); + } + }, [selectedPoolValue, selectedLivePool]); + + useEffect(() => { + if (!isOpen) { + setSelectedPoolValue(undefined); + } + }, [isOpen]); + + const canImportLivePool = runStatusIndex === 2 && !!selectedLivePool; + + const handleMinTvlChange = (value: number | null) => { + const nextMinTvl = Math.max(value ?? MIN_ALLOWED_TVL, MIN_ALLOWED_TVL); + setMinTvl(nextMinTvl); + setSelectedPoolValue(undefined); + }; + + const handlePoolTypeChange = (value: GqlPoolType | undefined) => { + setSelectedPoolType(value); + setSelectedPoolValue(undefined); + }; + + const handleImportLivePoolClick = () => { + if (!selectedLivePool) { + return; + } + + void onLivePoolImport({ + id: selectedLivePool.id, + chain: selectedLivePool.chain, + name: selectedLivePool.name, + }); + }; + return ( <Modal title="Import File or Select Balancer Pool" @@ -52,7 +176,75 @@ export function SimulationRunnerImportResultsModal({ onChange={onResultsFileChange} /> <Button onClick={onResultsImportClick}>Import Results from Run</Button> + + <div className={styles.orDivider}>OR</div> + + <h4>Import Live Pool:</h4> + <Text>Pool Type</Text> + <Select<GqlPoolType> + allowClear + showSearch + optionFilterProp="label" + options={poolTypes} + placeholder="Any pool type" + value={selectedPoolType} + onChange={handlePoolTypeChange} + /> + + <Text>Min TVL</Text> + <InputNumber + min={MIN_ALLOWED_TVL} + value={minTvl} + style={{ width: '100%' }} + onChange={handleMinTvlChange} + formatter={(value) => + `${value}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',') + } + /> + + <Text>Pool Name</Text> + {loadingPools ? ( + <Spin tip="Loading live pools..." /> + ) : ( + <Select + allowClear + showSearch + optionFilterProp="label" + listHeight={300} + placeholder="Select a live pool" + value={selectedPoolValue} + options={poolOptions} + filterOption={(input, option) => + String(option?.label ?? '') + .toLowerCase() + .includes(input.toLowerCase()) + } + onChange={(value: string | undefined) => { + setSelectedPoolValue(value); + }} + /> + )} + {!loadingPools && poolOptions.length === 0 && ( + <Text type="secondary">No pools match the current filters.</Text> + )} + {runStatusIndex !== 2 && ( + <Text type="secondary"> + Import Live Pool is enabled when simulation status is at Results. + </Text> + )} + <Button + type="primary" + disabled={!canImportLivePool} + loading={importingLivePool} + onClick={handleImportLivePoolClick} + > + Import Live Pool + </Button> </Col> </Modal> ); } + +export const SimulationRunnerImportResultsModal = memo( + SimulationRunnerImportResultsModalComponent +); diff --git a/src/features/simulationRunner/simulationRunner.tsx b/src/features/simulationRunner/simulationRunner.tsx index 582fca0..89bbbd6 100644 --- a/src/features/simulationRunner/simulationRunner.tsx +++ b/src/features/simulationRunner/simulationRunner.tsx @@ -5,27 +5,212 @@ import { selectSimulationRunnerCurrentStepIndex, changeSimulationRunnerCurrentStepIndex, changeSimulationRunnerCurrentRunTypeIndex, + importSimRunResults, resetSimulationRunner, } from './simulationRunnerSlice'; import { useAppDispatch, useAppSelector } from '../../app/hooks'; +import { message } from 'antd'; +import { useGetPoolByIdLazyQuery } from '../../__generated__/graphql-types'; +import { Benchmark, Product, TimeSeriesData } from '../../models'; +import { useRunFinancialAnalysisMutation } from '../../services'; +import { loadProducts } from '../productExplorer/productExplorerSlice'; +import { SimulationResultAnalysisDto } from '../simulationRunner/simulationRunnerDtos'; import { resetSims } from '../simulationRunConfiguration/simulationRunConfigurationSlice'; +import { LiquidityPoolCoin } from '../simulationRunConfiguration/simulationRunConfigModels'; import { SimulationRunnerTimePeriodStep } from './simulationRunnerTimePeriodStep'; import { SimulationRunnerHookTimePeriodStep } from './simulationRunnerHookTimePeriodStep'; import { handleDownloadResults, handleDownloadParams } from './index'; +import { getBreakdown } from '../../services/breakdownService'; +import { generateProductDataFromPoolData } from '../../hooks/useGenerateProductDataFromPool'; import { SimulationRunnerFinalReviewStep } from './simulationRunnerFinalReviewStep'; import { SimulationResultsSummaryStep } from '../simulationResults/simulationResultsSummaryStep'; import { SimulationResultSaveToCompareTab } from '../simulationResults/simulationResultSaveToCompareTab'; import { SimulationRunnerHistoricInProgress } from './simulationRunnerHistoricInProgress'; -import { useRef, useState } from 'react'; +import { + SimulationRunBreakdown, + SimulationRunLiquidityPoolSnapshot, +} from '../simulationResults/simulationResultSummaryModels'; +import { ChangeEvent, useCallback, useRef, useState } from 'react'; import { SimulatorOptions } from './simulationOptions'; import { PoolConstituentSelectionStep } from './sections/poolConstituentSelectionStep'; import { SimulationRunnerHeader } from './sections/simulationRunnerHeader'; -import { SimulationRunnerImportResultsModal } from './sections/simulationRunnerImportResultsModal'; +import { + LivePoolSelection, + SimulationRunnerImportResultsModal, +} from './sections/simulationRunnerImportResultsModal'; +import { CURRENT_LIVE_FACTSHEETS } from '../documentation/factSheets/liveFactsheets'; +import { Chain } from '../simulationRunConfiguration/simulationRunConfigModels'; + +const formatSimulationRangeDate = (timestamp: number): string => { + if (!timestamp) { + return ''; + } + + return new Date(timestamp * 1000) + .toISOString() + .slice(0, 19) + .replace('T', ' '); +}; + +const buildPortfolioReturnsForAnalysis = (timeSeries: TimeSeriesData[]) => + timeSeries + .map((step, i) => { + if (i === 0) { + return [step.timestamp * 1000, 0, 0] as [number, number, number]; + } + + const prevStep = timeSeries[i - 1]; + if (!prevStep) { + return null; + } + + const portfolioReturn = + (step.sharePrice - prevStep.sharePrice) / prevStep.sharePrice; + const hodlReturn = + (step.hodlSharePrice - prevStep.hodlSharePrice) / + prevStep.hodlSharePrice; + + return [step.timestamp * 1000, portfolioReturn, hodlReturn] as [ + number, + number, + number, + ]; + }) + .filter( + (portfolioReturn): portfolioReturn is [number, number, number] => + portfolioReturn !== null + ); + +const buildSyntheticSnapshotsFromProduct = ( + product: Product +): SimulationRunLiquidityPoolSnapshot[] => { + const timeSeries = product.timeSeries ?? []; + + return timeSeries.map((step) => { + const tokenInputs = product.poolConstituents.map((constituent, index) => { + const tokenAmount = step.amounts[index] ?? 0; + const tokenPrice = + step.tokenPrices[constituent.address] ?? + step.tokenPrices[constituent.address.toLowerCase()] ?? + step.tokenPriceArray[index] ?? + 0; + + return { + constituent, + tokenAmount, + tokenPrice, + marketValue: tokenAmount * tokenPrice, + }; + }); + + const totalByTokenValue = tokenInputs.reduce( + (sum, tokenInput) => sum + tokenInput.marketValue, + 0 + ); + const weightDenominator = + totalByTokenValue > 0 + ? totalByTokenValue + : step.totalLiquidity > 0 + ? step.totalLiquidity + : 1; + + const coinsHeld: LiquidityPoolCoin[] = tokenInputs.map((tokenInput) => ({ + coin: { + coinName: tokenInput.constituent.coin, + coinCode: tokenInput.constituent.coin, + dailyPriceHistory: [], + dailyPriceHistoryMap: new Map(), + dailyReturns: new Map(), + coinComparisons: new Map(), + deploymentByChain: new Map< + Chain, + { + address: string; + oracles: Map<string, string>; + approvalStatus: boolean; + } + >(), + }, + amount: tokenInput.tokenAmount, + marketValue: tokenInput.marketValue, + currentPrice: tokenInput.tokenPrice, + currentPriceUnix: step.timestamp, + weight: (tokenInput.marketValue / weightDenominator) * 100, + factorValue: null, + })); + + return { + unix: step.timestamp, + date: new Date(step.timestamp * 1000).toISOString(), + coinsHeld, + feeForSnapshot: step.fees24h ?? 0, + hodlEquiv: undefined, + totalFeesReceivedToDate: 0, + totalPoolMarketValue: + step.sharePrice > 0 ? step.sharePrice : totalByTokenValue, + }; + }); +}; + +const buildLiveProductBreakdown = ({ + product, + analysis, +}: { + product: Product; + analysis: SimulationResultAnalysisDto; +}): SimulationRunBreakdown => { + const syntheticTimeSteps = buildSyntheticSnapshotsFromProduct(product); + const firstTimeStep = syntheticTimeSteps[0]; + const lastTimeStep = syntheticTimeSteps[syntheticTimeSteps.length - 1]; + + return { + simulationRunResultAnalysis: analysis, + simulationRun: { + id: product.id, + enableAutomaticArbBots: false, + poolNumeraireCoinCode: '', + poolConstituents: firstTimeStep?.coinsHeld ?? [], + updateRule: { + updateRuleName: product.name, + updateRuleKey: product.id, + updateRuleParameters: [], + updateRuleResultProfileSummary: '', + heatmapKeys: [], + updateRuleRunUrl: '', + updateRuleTrainUrl: '', + updateRuleSimKey: '', + applicablePoolTypes: [], + chainDeploymentDetails: new Map(), + }, + runStatus: 'completed', + name: product.name, + feeHooks: [], + swapImports: [], + poolType: { + name: 'LIVE', + mandatoryProperties: [], + shortDescription: 'live product pool', + requiresPoolNumeraire: false, + }, + }, + flatSimulationRunResult: undefined, + simulationRunStatus: 'Complete', + simulationComplete: true, + timeSteps: syntheticTimeSteps, + timeRange: { + name: 'live', + startDate: formatSimulationRangeDate(firstTimeStep?.unix ?? 0), + endDate: formatSimulationRangeDate(lastTimeStep?.unix ?? 0), + }, + }; +}; export default function SimulationRunner() { const dispatch = useAppDispatch(); + const [getPoolById] = useGetPoolByIdLazyQuery(); + const [runFinancialAnalysis] = useRunFinancialAnalysisMutation(); const results = useAppSelector(selectSimulationRunBreakdowns); const runStatusIndex = useAppSelector(selectSimulationRunStatusStepIndex); @@ -38,33 +223,162 @@ export default function SimulationRunner() { ); const [isModalOpen, setIsModalOpen] = useState(false); const [forceViewResults, setForceViewResults] = useState(false); + const [importingLivePool, setImportingLivePool] = useState(false); - const showModal = () => { + const showModal = useCallback(() => { setIsModalOpen(true); - }; + }, []); - const closeModal = () => { + const closeModal = useCallback(() => { setIsModalOpen(false); - }; + }, []); - const onChange = (value: number) => { - if (value === 5 && runStatusIndex !== 2) { - return; - } + const onChange = useCallback( + (value: number) => { + if (value === 5 && runStatusIndex !== 2) { + return; + } - dispatch(changeSimulationRunnerCurrentStepIndex(value)); - }; + dispatch(changeSimulationRunnerCurrentStepIndex(value)); + }, + [dispatch, runStatusIndex] + ); const paramsFileInputRef = useRef<HTMLInputElement>(null); const resultsFileInputRef = useRef<HTMLInputElement>(null); - const handleParamsImportClick = () => { + const handleParamsImportClick = useCallback(() => { paramsFileInputRef.current?.click(); - }; + }, []); - const handleResultsImportClick = () => { + const handleResultsImportClick = useCallback(() => { resultsFileInputRef.current?.click(); - }; + }, []); + + const handleResetClick = useCallback(() => { + dispatch(resetSimulationRunner()); + dispatch(resetSims()); + dispatch(changeSimulationRunnerCurrentStepIndex(0)); + dispatch(changeSimulationRunnerCurrentRunTypeIndex(0)); + }, [dispatch]); + + const handleParamsFileChange = useCallback( + (event: ChangeEvent<HTMLInputElement>) => handleDownloadParams(event, dispatch), + [dispatch] + ); + + const handleResultsFileChange = useCallback( + (event: ChangeEvent<HTMLInputElement>) => + handleDownloadResults(event, dispatch, setForceViewResults), + [dispatch] + ); + + const handleLivePoolImport = useCallback( + async (pool: LivePoolSelection) => { + if (runStatusIndex !== 2 || importingLivePool) { + return; + } + + setImportingLivePool(true); + + try { + const poolQueryResult = await getPoolById({ + variables: { + id: pool.id, + chain: pool.chain, + }, + fetchPolicy: 'network-only', + }); + + if (!poolQueryResult.data?.poolGetPool?.id) { + void message.error('Unable to load live pool data.'); + return; + } + + const product = await generateProductDataFromPoolData( + poolQueryResult.data + ); + + dispatch(loadProducts({ [product.id]: product })); + + const productAddress = product.address?.toLowerCase() ?? ''; + const productId = product.id?.toLowerCase() ?? ''; + + const matchedFactsheet = CURRENT_LIVE_FACTSHEETS.factsheets.find( + (factsheet) => { + const factsheetPoolId = factsheet.poolId.toLowerCase(); + return ( + factsheetPoolId === productAddress || factsheetPoolId === productId + ); + } + ); + + let importedBreakdown: SimulationRunBreakdown | undefined; + + if (matchedFactsheet?.targetPoolJson) { + importedBreakdown = await getBreakdown(matchedFactsheet.targetPoolJson); + } else { + const timeSeries = product.timeSeries ?? []; + if (timeSeries.length < 2) { + void message.error( + 'Not enough historical data to import this live pool.' + ); + return; + } + + const startTimestamp = timeSeries[0]?.timestamp; + const endTimestamp = timeSeries[timeSeries.length - 1]?.timestamp; + + const analysisResult = await runFinancialAnalysis({ + request: { + startDateString: startTimestamp + ? new Date(startTimestamp * 1000).toLocaleString() + : '', + endDateString: endTimestamp + ? new Date(endTimestamp * 1000).toLocaleString() + : '', + tokens: [...new Set(product.poolConstituents.map((pc) => pc.coin))], + returns: buildPortfolioReturnsForAnalysis(timeSeries), + benchmarks: [Benchmark.HODL], + }, + }); + + if (!('data' in analysisResult) || !analysisResult.data?.analysis) { + void message.error( + 'Unable to calculate analysis for the selected live pool.' + ); + return; + } + + importedBreakdown = buildLiveProductBreakdown({ + product, + analysis: analysisResult.data.analysis, + }); + } + + if (!importedBreakdown) { + return; + } + + dispatch( + importSimRunResults({ + simulationRunner: importedBreakdown, + }) + ); + + setForceViewResults(true); + dispatch(changeSimulationRunnerCurrentStepIndex(6)); + setIsModalOpen(false); + void message.success(`Imported live pool: ${pool.name}`); + } catch (error) { + console.error('Error importing live pool:', error); + void message.error('Failed to import live pool results.'); + } finally { + setImportingLivePool(false); + } + }, + [dispatch, getPoolById, importingLivePool, runFinancialAnalysis, runStatusIndex] + ); function getRunnerStep(): JSX.Element { switch (currentStepIndex) { @@ -112,25 +426,21 @@ export default function SimulationRunner() { runStatusIndex={runStatusIndex} onChange={onChange} onImportClick={showModal} - onResetClick={() => { - dispatch(resetSimulationRunner()); - dispatch(resetSims()); - dispatch(changeSimulationRunnerCurrentStepIndex(0)); - dispatch(changeSimulationRunnerCurrentRunTypeIndex(0)); - }} + onResetClick={handleResetClick} /> <SimulationRunnerImportResultsModal isOpen={isModalOpen} + runStatusIndex={runStatusIndex} + importingLivePool={importingLivePool} onClose={closeModal} paramsFileInputRef={paramsFileInputRef} resultsFileInputRef={resultsFileInputRef} onParamsImportClick={handleParamsImportClick} onResultsImportClick={handleResultsImportClick} - onParamsFileChange={(event) => handleDownloadParams(event, dispatch)} - onResultsFileChange={(event) => - handleDownloadResults(event, dispatch, setForceViewResults) - } + onLivePoolImport={handleLivePoolImport} + onParamsFileChange={handleParamsFileChange} + onResultsFileChange={handleResultsFileChange} /> {getRunnerStep()} From 6970d28e388f5190aca582bbaf8498ecb6972e27 Mon Sep 17 00:00:00 2001 From: christian harrington <christian@bulkbit.systems> Date: Wed, 18 Feb 2026 17:16:40 +0000 Subject: [PATCH 11/21] hook changes needed for memo changes and lazy loading --- src/hooks/useFetchPoolEventsData.tsx | 23 +++++--- src/hooks/useFetchPoolsSummaryByParams.tsx | 6 ++- src/hooks/useGenerateProductDataFromPool.tsx | 57 +++++++++++--------- 3 files changed, 53 insertions(+), 33 deletions(-) diff --git a/src/hooks/useFetchPoolEventsData.tsx b/src/hooks/useFetchPoolEventsData.tsx index 451faaf..0664788 100644 --- a/src/hooks/useFetchPoolEventsData.tsx +++ b/src/hooks/useFetchPoolEventsData.tsx @@ -1,4 +1,5 @@ import { ApolloError } from '@apollo/client'; +import { useMemo } from 'react'; import { GqlChain, GqlPoolEvent, @@ -11,11 +12,13 @@ export const useFetchPoolEventsData = ({ skip, poolId, chain, + enabled = true, }: { first: number | undefined; skip: number | undefined; poolId: string; chain: GqlChain; + enabled?: boolean; }): { poolEvents: GqlPoolEvent[]; loading: boolean; @@ -27,6 +30,7 @@ export const useFetchPoolEventsData = ({ )?.launchUnixTimestamp ?? 0; const { data, loading, error } = useGetPoolEventsQuery({ + skip: !enabled || !poolId, variables: { first, skip, @@ -36,15 +40,20 @@ export const useFetchPoolEventsData = ({ }, }, }); + const poolEvents = useMemo( + () => + (data?.poolEvents ?? []) + .map((event) => ({ + ...event, + logIndex: 0, + userAddress: '', + })) + .filter((event) => event.timestamp >= launchUnixTimestamp), + [data?.poolEvents, launchUnixTimestamp] + ); return { - poolEvents: (data?.poolEvents ?? []) - .map((event) => ({ - ...event, - logIndex: 0, - userAddress: '', - })) - .filter((event) => event.timestamp >= launchUnixTimestamp), + poolEvents, loading, error, }; diff --git a/src/hooks/useFetchPoolsSummaryByParams.tsx b/src/hooks/useFetchPoolsSummaryByParams.tsx index c38a313..972a1a7 100644 --- a/src/hooks/useFetchPoolsSummaryByParams.tsx +++ b/src/hooks/useFetchPoolsSummaryByParams.tsx @@ -12,11 +12,15 @@ const DEFAULT_MIN_TVL = 10000; const DEFAULT_SKIPPED_POOLS = 0; export const useFetchPoolsSummaryByParams = ( - params: GetPoolsSummaryQueryVariables + params: GetPoolsSummaryQueryVariables, + options?: { + skip?: boolean; + } ) => { const { first, orderBy, orderDirection, skip, where } = params; const { data, loading, error } = useGetPoolsSummaryQuery({ + skip: options?.skip ?? false, variables: { first: first ?? DEFAULT_POOLS_LIMIT, orderBy: orderBy ?? DEFAULT_ORDER_BY, diff --git a/src/hooks/useGenerateProductDataFromPool.tsx b/src/hooks/useGenerateProductDataFromPool.tsx index 7d42407..a86532a 100644 --- a/src/hooks/useGenerateProductDataFromPool.tsx +++ b/src/hooks/useGenerateProductDataFromPool.tsx @@ -13,6 +13,36 @@ import { getHistoricalTokenPrices, } from './fetchSnapshotDataUtils'; +export const generateProductDataFromPoolData = async ( + poolData: GetPoolByIdQuery +): Promise<Product> => { + if (!poolData.poolGetPool?.id) { + throw new Error('Missing pool data'); + } + + const pool = { + id: poolData.poolGetPool.id, + chain: poolData.poolGetPool.chain, + }; + + const poolSnapshotsMap = await getPoolSnapshotsMap([pool]); + + const tokens = poolData.poolGetPool.poolTokens.map( + (token) => `${pool.chain}:${getTokenAddress(token)}` + ); + + const pricesResponses = await getHistoricalTokenPrices(tokens); + const tokenPricesMap = getTokenPriceMap(pricesResponses); + + const timeSeriesData: ProductTimeSeriesData = getTimeSeriesDataForProduct( + poolData, + poolSnapshotsMap, + tokenPricesMap + ); + + return getProductFromPool(poolData, timeSeriesData); +}; + export const useGenerateProductDataFromPool = ( poolData?: GetPoolByIdQuery, isLoadingPools?: boolean, @@ -37,31 +67,8 @@ export const useGenerateProductDataFromPool = ( setLoading(true); setError(undefined); try { - const pool = { - id: poolData.poolGetPool.id, - chain: poolData.poolGetPool.chain, - }; - - const poolSnapshotsMap = await getPoolSnapshotsMap([pool]); - - const tokens = poolData.poolGetPool.poolTokens.map( - (token) => `${pool.chain}:${getTokenAddress(token)}` - ); - - const pricesResponses = await getHistoricalTokenPrices(tokens); - - const tokenPricesMap = getTokenPriceMap(pricesResponses); - - const timeSeriesData: ProductTimeSeriesData = - getTimeSeriesDataForProduct( - poolData, - poolSnapshotsMap, - tokenPricesMap - ); - - const generatedProduct: Product = getProductFromPool( - poolData, - timeSeriesData + const generatedProduct = await generateProductDataFromPoolData( + poolData ); if (isMounted) { From b5149c634b9db465d4a57011a6dbc194ddf7704e Mon Sep 17 00:00:00 2001 From: christian harrington <christian@bulkbit.systems> Date: Fri, 20 Feb 2026 16:26:53 +0000 Subject: [PATCH 12/21] avoid price load remount --- src/App.tsx | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 09f5208..8cef029 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -46,17 +46,14 @@ function App() { return; } - if (page === ROUTES.SIMULATION_RUNNER) { + if (page !== ROUTES.SIMULATION_RUNNER) { + dispatch(loadPriceHistoryAsync()); dispatch(initialiseSimsToRun()); } }, [dispatch] ); - useEffect(() => { - dispatch(loadPriceHistoryAsync()); - }, [dispatch]); - useEffect(() => { const segment = location.pathname.split('/')[1] ?? ''; const page = isRoute(segment) ? segment : ROUTES.HOME; From 965f1d1219edcebff9ce73daddae6d915e5869a0 Mon Sep 17 00:00:00 2001 From: christian harrington <christian@bulkbit.systems> Date: Fri, 20 Feb 2026 16:27:26 +0000 Subject: [PATCH 13/21] missed memo --- .../productItem/productItemGrid.tsx | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/features/productExplorer/productItem/productItemGrid.tsx b/src/features/productExplorer/productItem/productItemGrid.tsx index 73b1341..98ad0e0 100644 --- a/src/features/productExplorer/productItem/productItemGrid.tsx +++ b/src/features/productExplorer/productItem/productItemGrid.tsx @@ -38,6 +38,11 @@ export const ProductItemGrid: FC<ProductItemGridProps> = ({ wide }) => { () => Object.keys(products).length > 0, [products] ); + const sortedProducts = useMemo( + () => sort(Object.values(products)), + [products, sort] + ); + const showLoadingProducts = loading && !areProductsLoaded; useEffect(() => { if (parent.current) { @@ -95,15 +100,14 @@ export const ProductItemGrid: FC<ProductItemGridProps> = ({ wide }) => { }} > {wide && <ProductItemGridHeader />} - {(loading || !areProductsLoaded) && + {showLoadingProducts && loadingProducts.map((loadingProduct) => ( <Col xs={wide ? 24 : undefined} key={loadingProduct}> {wide ? <ProductItemWideLoading /> : <ProductItemLoading />} </Col> ))} - {!loading && - areProductsLoaded && - sort(Object.values(products)).map((product) => ( + {areProductsLoaded && + sortedProducts.map((product) => ( <Col xs={wide ? 24 : undefined} key={product.id}> {wide ? ( <ProductItemWide product={product} /> @@ -113,7 +117,7 @@ export const ProductItemGrid: FC<ProductItemGridProps> = ({ wide }) => { </Col> ))} </Row> - {!loading && areProductsLoaded && ( + {areProductsLoaded && ( <Row style={{ marginTop: 16 }} justify="center"> <ProductExplorerPagination /> </Row> From 00563706590aec117bcb199c71757f52306b5b1e Mon Sep 17 00:00:00 2001 From: christian harrington <christian@bulkbit.systems> Date: Fri, 20 Feb 2026 16:27:52 +0000 Subject: [PATCH 14/21] ellipsis better hiding and inlining some components --- .../productItem/card/productItem.tsx | 12 +- .../productItem/wide/productItemWide.tsx | 182 +++++++++--------- 2 files changed, 101 insertions(+), 93 deletions(-) diff --git a/src/features/productExplorer/productItem/card/productItem.tsx b/src/features/productExplorer/productItem/card/productItem.tsx index 114384f..b48c1fc 100644 --- a/src/features/productExplorer/productItem/card/productItem.tsx +++ b/src/features/productExplorer/productItem/card/productItem.tsx @@ -134,7 +134,17 @@ export const ProductItem: FC<ProductItemProps> = ({ product }) => { <Card className={styles['product-item__card']} hoverable> <ProductItemBackground> <div className={styles['product-item__card__top']}> - <Text ellipsis={{ tooltip: product.name }}>{product.name}</Text> + <Text + title={product.name} + style={{ + display: 'block', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }} + > + {product.name} + </Text> </div> <Row className={styles['product-item__card-under-body']}> <Col span={8} style={{ textAlign: 'center' }}> diff --git a/src/features/productExplorer/productItem/wide/productItemWide.tsx b/src/features/productExplorer/productItem/wide/productItemWide.tsx index e52a762..593779b 100644 --- a/src/features/productExplorer/productItem/wide/productItemWide.tsx +++ b/src/features/productExplorer/productItem/wide/productItemWide.tsx @@ -56,100 +56,12 @@ export const ProductItemWide: FC<ProductItemProps> = ({ product }) => { return !!product.timeSeries && product.timeSeries.length > 0; }, [product.timeSeries]); - const LoadingGraph = () => ( + const renderLoadingGraph = () => ( <div className={styles['product-item__card__loading']}> <Spin /> </div> ); - const OverviewScoresColumn = () => ( - <Col - span={3} - className={ - product.overview.length > 0 - ? styles['product-item__card-column'] - : undefined - } - > - {product.overview.length > 0 ? ( - <List - dataSource={Object.entries(product.overview)} - renderItem={(item) => ( - <List.Item style={{ padding: 0 }}> - <Text - className={styles['product-item__card-scores__text']} - style={{ - color: getScoreColor(Number(item[1].value)), - }} - > - {String(item[1].metric)} - </Text> - <Text - className={styles['product-item__card-scores__text']} - style={{ - color: getScoreColor(Number(item[1].value)), - marginLeft: 4, - }} - > - {String(item[1].value)} / {MAX_SCORE} - </Text> - </List.Item> - )} - /> - ) : ( - <div className={styles['product-item-graph']}> - <LoadingGraph /> - </div> - )} - </Col> - ); - - const ChartsAndTokensColumns = () => ( - <> - <Col span={2}> - {product.overview.length > 0 ? ( - <div className={styles['product-item-graph']}> - <ProductItemOverviewGraph - data={product.overview} - isDarkTheme={isDarkTheme} - wide={true} - showScoreOverall={true} - /> - </div> - ) : ( - <LoadingGraph /> - )} - </Col> - <Col span={2}> - {shouldShow ? ( - <div className={styles['product-item-graph']}> - <ProductItemPerformanceLineGraph product={product} wide={true} /> - </div> - ) : ( - <LoadingGraph /> - )} - </Col> - <Col - span={3} - className={shouldShow ? styles['product-item__card-column'] : undefined} - style={ - shouldShow - ? { - padding: 0, - overflow: 'hidden', - } - : undefined - } - > - {shouldShow ? ( - <ProductItemTokenList product={product} /> - ) : ( - <LoadingGraph /> - )} - </Col> - </> - ); - return ( <div className={ @@ -174,8 +86,14 @@ export const ProductItemWide: FC<ProductItemProps> = ({ product }) => { > <div className={styles['product-item__card__title']}> <Text - ellipsis={{ tooltip: product.name }} + title={product.name} className={styles['product-item__card-top__text']} + style={{ + display: 'block', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }} > {product.name} </Text> @@ -213,8 +131,88 @@ export const ProductItemWide: FC<ProductItemProps> = ({ product }) => { )} </Col> - <OverviewScoresColumn /> - <ChartsAndTokensColumns /> + <Col + span={3} + className={ + product.overview.length > 0 + ? styles['product-item__card-column'] + : undefined + } + > + {product.overview.length > 0 ? ( + <List + dataSource={Object.entries(product.overview)} + renderItem={(item) => ( + <List.Item style={{ padding: 0 }}> + <Text + className={styles['product-item__card-scores__text']} + style={{ + color: getScoreColor(Number(item[1].value)), + }} + > + {String(item[1].metric)} + </Text> + <Text + className={styles['product-item__card-scores__text']} + style={{ + color: getScoreColor(Number(item[1].value)), + marginLeft: 4, + }} + > + {String(item[1].value)} / {MAX_SCORE} + </Text> + </List.Item> + )} + /> + ) : ( + <div className={styles['product-item-graph']}> + {renderLoadingGraph()} + </div> + )} + </Col> + <Col span={2}> + {product.overview.length > 0 ? ( + <div className={styles['product-item-graph']}> + <ProductItemOverviewGraph + data={product.overview} + isDarkTheme={isDarkTheme} + wide={true} + showScoreOverall={true} + /> + </div> + ) : ( + renderLoadingGraph() + )} + </Col> + <Col span={2}> + {shouldShow ? ( + <div className={styles['product-item-graph']}> + <ProductItemPerformanceLineGraph product={product} wide={true} /> + </div> + ) : ( + renderLoadingGraph() + )} + </Col> + <Col + span={3} + className={ + shouldShow ? styles['product-item__card-column'] : undefined + } + style={ + shouldShow + ? { + padding: 0, + overflow: 'hidden', + } + : undefined + } + > + {shouldShow ? ( + <ProductItemTokenList product={product} /> + ) : ( + renderLoadingGraph() + )} + </Col> <Col span={3} className={styles['product-item__card-column-right']}> <div className={styles['product-item__card__action']}> From 6ace35e3575be19d7b3a98841ab066cd00616543 Mon Sep 17 00:00:00 2001 From: christian harrington <christian@bulkbit.systems> Date: Fri, 20 Feb 2026 16:28:06 +0000 Subject: [PATCH 15/21] more load flag granularity during list loading --- src/hooks/useFetchProductListData.test.ts | 31 +++++++++ src/hooks/useFetchProductListData.tsx | 82 ++++++++++++++++++----- 2 files changed, 98 insertions(+), 15 deletions(-) diff --git a/src/hooks/useFetchProductListData.test.ts b/src/hooks/useFetchProductListData.test.ts index 2f84bc1..62e488b 100644 --- a/src/hooks/useFetchProductListData.test.ts +++ b/src/hooks/useFetchProductListData.test.ts @@ -33,6 +33,8 @@ describe('useFetchProductListData view-model logic', () => { loading: false, stubDataLoading: true, baseProductsLoading: false, + fullProductsLoading: false, + fullProductsError: undefined, isStubData: true, }) ).toBe(true); @@ -42,6 +44,8 @@ describe('useFetchProductListData view-model logic', () => { loading: false, stubDataLoading: false, baseProductsLoading: true, + fullProductsLoading: false, + fullProductsError: undefined, isStubData: false, }) ).toBe(true); @@ -51,6 +55,33 @@ describe('useFetchProductListData view-model logic', () => { loading: false, stubDataLoading: false, baseProductsLoading: false, + fullProductsLoading: false, + fullProductsError: undefined, + isStubData: false, + }) + ).toBe(false); + + expect( + getProductMapLoadingState({ + loading: false, + stubDataLoading: false, + baseProductsLoading: false, + fullProductsLoading: true, + fullProductsError: undefined, + isStubData: false, + }) + ).toBe(true); + + expect( + getProductMapLoadingState({ + loading: false, + stubDataLoading: false, + baseProductsLoading: false, + fullProductsLoading: true, + fullProductsError: { + status: 500, + data: {}, + }, isStubData: false, }) ).toBe(false); diff --git a/src/hooks/useFetchProductListData.tsx b/src/hooks/useFetchProductListData.tsx index 52d8ea9..6589dc6 100644 --- a/src/hooks/useFetchProductListData.tsx +++ b/src/hooks/useFetchProductListData.tsx @@ -50,17 +50,30 @@ export const getProductMapLoadingState = ({ loading, stubDataLoading, baseProductsLoading, + fullProductsLoading, + fullProductsError, isStubData, }: { loading: boolean; stubDataLoading: boolean; baseProductsLoading: boolean; + fullProductsLoading: boolean; + fullProductsError: + | FetchBaseQueryError + | SerializedError + | ApolloError + | undefined; isStubData: boolean; }) => { if (isStubData) { - return loading || stubDataLoading; + return loading || stubDataLoading || baseProductsLoading; } - return loading || baseProductsLoading; + + if (fullProductsError) { + return loading || baseProductsLoading; + } + + return loading || baseProductsLoading || fullProductsLoading; }; const IS_STUB_DATA = isStubDataEnabled(import.meta.env.VITE_USE_STUBS_DATA); @@ -157,28 +170,59 @@ export const useFetchProductListData = ( }, [baseProductsError]); useEffect(() => { - if (fullProductsError) { - setError(fullProductsError); - setLoading(false); + if (IS_STUB_DATA) { + if (!baseProductsLoading && !baseProductsError && baseProductsData) { + setProductMap(baseProductsData); + setLoading(false); + } + return; } - }, [fullProductsError]); - useEffect(() => { - if (!baseProductsLoading && !baseProductsError && baseProductsData) { + const hasBaseProducts = Object.keys(baseProductsData).length > 0; + const hasFullProducts = Object.keys(fullProductsData).length > 0; + + // Keep phased loading: render base rows first while full product enrichment is pending. + if (hasBaseProducts && !hasFullProducts && !fullProductsError) { setProductMap(baseProductsData); - setLoading(false); + setError(null); + return; } - }, [baseProductsData, baseProductsLoading, baseProductsError]); - useEffect(() => { if (!fullProductsLoading && !fullProductsError && fullProductsData) { - setProductMap(fullProductsData); + const isTrulyEmptyResult = + !baseProductsLoading && !hasBaseProducts; + + if (hasFullProducts || isTrulyEmptyResult) { + setProductMap(fullProductsData); + setError(null); + setLoading(false); + return; + } + } + + if (fullProductsError) { + if ( + !baseProductsLoading && + !baseProductsError && + Object.keys(baseProductsData).length > 0 + ) { + setProductMap(baseProductsData); + setError(null); + } else { + setError(fullProductsError); + } setLoading(false); } - }, [fullProductsData, fullProductsLoading, fullProductsError]); + }, [ + baseProductsData, + baseProductsError, + baseProductsLoading, + fullProductsData, + fullProductsError, + fullProductsLoading, + ]); useEffect(() => { - setProductMap({}); setLoading(true); setError(null); }, [activeFilters, textSearch, page, pageSize]); @@ -189,9 +233,17 @@ export const useFetchProductListData = ( loading, stubDataLoading, baseProductsLoading, + fullProductsLoading, + fullProductsError, isStubData: IS_STUB_DATA, }), - [stubDataLoading, baseProductsLoading, loading] + [ + baseProductsLoading, + fullProductsError, + fullProductsLoading, + loading, + stubDataLoading, + ] ); return { From 2f230f93afd52d1bff651e0fc4652765e3ed7936 Mon Sep 17 00:00:00 2001 From: christian harrington <christian@bulkbit.systems> Date: Mon, 23 Feb 2026 13:28:51 +0000 Subject: [PATCH 16/21] toggle product explorer profiler fixes --- .../productExplorer/productExplorer.tsx | 9 +- .../productItem/card/productItem.tsx | 8 +- .../card/productItemPerformanceAreaGraph.tsx | 64 ++++++----- .../productItem/productItemGrid.tsx | 7 +- .../wide/productItemPerformanceLineGraph.tsx | 76 +++++++------ .../graphs/productItemOverviewGraph.tsx | 101 +++++++++++------- src/hooks/useHasEnteredViewport.ts | 45 ++++++++ 7 files changed, 204 insertions(+), 106 deletions(-) create mode 100644 src/hooks/useHasEnteredViewport.ts diff --git a/src/features/productExplorer/productExplorer.tsx b/src/features/productExplorer/productExplorer.tsx index 492461e..a7dd4a4 100644 --- a/src/features/productExplorer/productExplorer.tsx +++ b/src/features/productExplorer/productExplorer.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { startTransition, useCallback, useEffect, useState } from 'react'; import { Layout } from 'antd'; import { useAppDispatch, useAppSelector } from '../../app/hooks'; import { useRetrieveFiltersQuery } from '../../services/productRetrievalService'; @@ -20,6 +20,11 @@ import TermsOfServiceGateModal from '../documentation/landing/termsOfServiceModa export default function ProductExplorer() { const dispatch = useAppDispatch(); const [horizontalView, setHorizontalView] = useState(true); + const handleHorizontalViewChange = useCallback((checked: boolean) => { + startTransition(() => { + setHorizontalView(checked); + }); + }, []); const handleGateClose = () => dispatch(setAcceptedTermsAndConditions(true)); @@ -74,7 +79,7 @@ export default function ProductExplorer() { <> <ProductExplorerFilters horizontalView={horizontalView} - setHorizontalView={setHorizontalView} + setHorizontalView={handleHorizontalViewChange} isDark={isDark} /> <ProductItemGrid wide={horizontalView} /> diff --git a/src/features/productExplorer/productItem/card/productItem.tsx b/src/features/productExplorer/productItem/card/productItem.tsx index b48c1fc..208ce50 100644 --- a/src/features/productExplorer/productItem/card/productItem.tsx +++ b/src/features/productExplorer/productItem/card/productItem.tsx @@ -15,6 +15,7 @@ import { ProductItemPerformanceAreaGraph } from './productItemPerformanceAreaGra import { ProductItemBottom } from './productItemBottom'; import { ProductItemBackground } from '../productItemBackground'; import { getTvl } from '../productItemHelpers'; +import { ProductItemWide } from '../wide/productItemWide'; import styles from './productItem.module.scss'; const DEFAULT_ACTIVE_KEY = '1'; @@ -23,9 +24,14 @@ const { Text } = Typography; interface ProductItemProps { product: Product; + wide?: boolean; } -export const ProductItem: FC<ProductItemProps> = ({ product }) => { +export const ProductItem: FC<ProductItemProps> = ({ product, wide }) => { + if (wide) { + return <ProductItemWide product={product} />; + } + const dispatch = useAppDispatch(); const overrideTab = useAppSelector(selectOverrideTab); const isDarkTheme = useAppSelector(selectTheme); diff --git a/src/features/productExplorer/productItem/card/productItemPerformanceAreaGraph.tsx b/src/features/productExplorer/productItem/card/productItemPerformanceAreaGraph.tsx index ce62ec8..8776db5 100644 --- a/src/features/productExplorer/productItem/card/productItemPerformanceAreaGraph.tsx +++ b/src/features/productExplorer/productItem/card/productItemPerformanceAreaGraph.tsx @@ -14,6 +14,7 @@ import { currencyFormatter, percentageFormatter, } from '../../../../utils/formatters'; +import { useHasEnteredViewport } from '../../../../hooks/useHasEnteredViewport'; type PerformanceGraphData = MonthlyPerformance & { trendUp: boolean; @@ -57,9 +58,13 @@ const SCALE_FACTOR = 1.3; export const ProductItemPerformanceAreaGraph: FC< ProductItemPerformanceGraphProps > = ({ data, wide }) => { - const mappedData = mapPerformanceData(data); + const { containerRef, hasEnteredViewport } = + useHasEnteredViewport<HTMLDivElement>(); + const mappedData = useMemo(() => mapPerformanceData(data), [data]); const chartTheme = useAppSelector(selectAgChartTheme); + const chartWidth = wide ? 100 : 288; + const chartHeight = wide ? 100 : 240; const performanceList = useMemo( () => mappedData.map((item) => item.absReturn), @@ -155,33 +160,42 @@ export const ProductItemPerformanceAreaGraph: FC< ]; }, [mappedData, interval, min, max, wide]); - return ( - <AgCharts - options={{ - width: wide ? 100 : 288, - height: wide ? 100 : 240, - data: mappedData, - series, - axes, - legend: { - enabled: false, - }, - overlays: { - noData: { - text: 'No data', - }, + const chartOptions = useMemo( + () => ({ + width: chartWidth, + height: chartHeight, + data: mappedData, + series, + axes, + legend: { + enabled: false, + }, + overlays: { + noData: { + text: 'No data', }, - theme: { - baseTheme: chartTheme, - overrides: { - common: { - background: { - fill: 'transparent', - }, + }, + theme: { + baseTheme: chartTheme, + overrides: { + common: { + background: { + fill: 'transparent', }, }, }, - }} - /> + }, + }), + [axes, chartHeight, chartTheme, chartWidth, mappedData, series] + ); + + return ( + <div ref={containerRef}> + {hasEnteredViewport ? ( + <AgCharts options={chartOptions} /> + ) : ( + <div style={{ width: chartWidth, height: chartHeight }} /> + )} + </div> ); }; diff --git a/src/features/productExplorer/productItem/productItemGrid.tsx b/src/features/productExplorer/productItem/productItemGrid.tsx index 98ad0e0..0c39279 100644 --- a/src/features/productExplorer/productItem/productItemGrid.tsx +++ b/src/features/productExplorer/productItem/productItemGrid.tsx @@ -12,7 +12,6 @@ import { ProductExplorerSort } from '../productExplorerSort/productExplorerSort' import { ProductExplorerTabOverride } from '../productExplorerTabOverride/productExplorerTabOverride'; import { ProductExplorerPagination } from '../ProductExplorerPagination'; import { useSort } from './useSort'; -import { ProductItemWide } from './wide/productItemWide'; import { ProductItemGridHeader } from './productItemGridHeader'; import { ProductItemLoading } from './card/productItemLoading'; import { ProductItemWideLoading } from './wide/productItemWideLoading'; @@ -109,11 +108,7 @@ export const ProductItemGrid: FC<ProductItemGridProps> = ({ wide }) => { {areProductsLoaded && sortedProducts.map((product) => ( <Col xs={wide ? 24 : undefined} key={product.id}> - {wide ? ( - <ProductItemWide product={product} /> - ) : ( - <ProductItem product={product} /> - )} + <ProductItem product={product} wide={wide} /> </Col> ))} </Row> diff --git a/src/features/productExplorer/productItem/wide/productItemPerformanceLineGraph.tsx b/src/features/productExplorer/productItem/wide/productItemPerformanceLineGraph.tsx index de07b7a..7a18e93 100644 --- a/src/features/productExplorer/productItem/wide/productItemPerformanceLineGraph.tsx +++ b/src/features/productExplorer/productItem/wide/productItemPerformanceLineGraph.tsx @@ -12,6 +12,7 @@ import { CURRENT_PERFORMANCE_PERIOD, Product } from '../../../../models'; import { selectAgChartTheme } from '../../../themes/themeSlice'; import { filterByTimeRange } from '../../../productDetail/productDetailContent/helpers'; import { getCurrentPerformanceComponent } from '../shared/CurrentPerformance'; +import { useHasEnteredViewport } from '../../../../hooks/useHasEnteredViewport'; import styles from './productItemPerformanceLineGraph.module.scss'; @@ -45,9 +46,13 @@ const mapPerformanceData = (product: Product) => { export const ProductItemPerformanceLineGraph: FC< ProductItemPerformanceGraphProps > = ({ product, wide }) => { - const mappedData = mapPerformanceData(product); + const { containerRef, hasEnteredViewport } = + useHasEnteredViewport<HTMLDivElement>(); + const mappedData = useMemo(() => mapPerformanceData(product), [product]); const chartTheme = useAppSelector(selectAgChartTheme); + const chartWidth = wide ? 100 : 288; + const chartHeight = wide ? 100 : 240; const series: AgCartesianSeriesOptions[] = useMemo(() => { return [ @@ -105,43 +110,50 @@ export const ProductItemPerformanceLineGraph: FC< ]; }, []); + const chartOptions = useMemo( + () => ({ + width: chartWidth, + height: chartHeight, + data: mappedData, + series, + axes, + legend: { + enabled: false, + }, + overlays: { + noData: { + text: 'No data', + }, + }, + animation: { + enabled: false, + }, + theme: { + baseTheme: chartTheme, + overrides: { + common: { + background: { + fill: 'transparent', + }, + }, + }, + }, + }), + [axes, chartHeight, chartTheme, chartWidth, mappedData, series] + ); + return ( - <div className={styles['product-item__graph-overlay']}> + <div className={styles['product-item__graph-overlay']} ref={containerRef}> <div className={styles['product-item__graph-overlay__content']}> <Text strong style={{ fontSize: 10 }}> {getCurrentPerformanceComponent(product)} </Text> </div> - <AgCharts - options={{ - width: wide ? 100 : 288, - height: wide ? 100 : 240, - data: mappedData, - series, - axes, - legend: { - enabled: false, - }, - overlays: { - noData: { - text: 'No data', - }, - }, - animation: { - enabled: false, - }, - theme: { - baseTheme: chartTheme, - overrides: { - common: { - background: { - fill: 'transparent', - }, - }, - }, - }, - }} - /> + {hasEnteredViewport ? ( + <AgCharts options={chartOptions} /> + ) : ( + <div style={{ width: chartWidth, height: chartHeight }} /> + )} </div> ); }; diff --git a/src/features/shared/graphs/productItemOverviewGraph.tsx b/src/features/shared/graphs/productItemOverviewGraph.tsx index 626737d..411d60d 100644 --- a/src/features/shared/graphs/productItemOverviewGraph.tsx +++ b/src/features/shared/graphs/productItemOverviewGraph.tsx @@ -10,6 +10,7 @@ import { import 'ag-charts-enterprise'; import { Typography } from 'antd'; import { useAppSelector } from '../../../app/hooks'; +import { useHasEnteredViewport } from '../../../hooks/useHasEnteredViewport'; import { selectAgChartTheme } from '../../themes/themeSlice'; import { productExplorerTranslation } from '../../productExplorer/translations'; import { @@ -51,8 +52,12 @@ export const ProductItemOverviewGraph: FC<ProductItemOverviewGraphProps> = ({ widthOverride, }) => { const chartTheme = useAppSelector(selectAgChartTheme); + const { containerRef, hasEnteredViewport } = + useHasEnteredViewport<HTMLDivElement>(); const totalScore = getTotalScore(data.map((item) => item.value ?? 0)); + const chartWidth = widthOverride ?? (wide ? 100 : 288); + const chartHeight = heightOverride ?? (wide ? 100 : 240); const radarColor = useMemo(() => { return getTotalScoreColor(totalScore); @@ -118,8 +123,58 @@ export const ProductItemOverviewGraph: FC<ProductItemOverviewGraphProps> = ({ ]; }, [intervalStepOverride, fontSizeOverride, orientationOverride, wide]); + const chartOptions = useMemo( + () => ({ + width: chartWidth, + height: chartHeight, + data, + series, + axes, + legend: { + enabled: false, + }, + overlays: { + noData: { + text: 'No data', + }, + }, + animation: { + enabled: !wide, + }, + theme: { + baseTheme: chartTheme, + overrides: { + common: { + background: { + fill: 'transparent', + }, + }, + 'radar-area': { + axes: { + 'angle-category': { + label: { + color: isDarkTheme ? '#FFFFEF' : '#0B1827', + }, + }, + }, + }, + }, + }, + }), + [ + axes, + chartHeight, + chartTheme, + chartWidth, + data, + isDarkTheme, + series, + wide, + ] + ); + return ( - <div className={styles['product-item__graph-overlay']}> + <div className={styles['product-item__graph-overlay']} ref={containerRef}> {showScoreOverall && ( <div className={styles['product-item__graph-overlay__content']}> <Text strong style={{ fontSize: wide ? 9 : '' }}> @@ -130,45 +185,11 @@ export const ProductItemOverviewGraph: FC<ProductItemOverviewGraphProps> = ({ </Text> </div> )} - <AgCharts - options={{ - width: widthOverride ?? (wide ? 100 : 288), - height: heightOverride ?? (wide ? 100 : 240), - data, - series, - axes, - legend: { - enabled: false, - }, - overlays: { - noData: { - text: 'No data', - }, - }, - animation: { - enabled: !wide, - }, - theme: { - baseTheme: chartTheme, - overrides: { - common: { - background: { - fill: 'transparent', - }, - }, - 'radar-area': { - axes: { - 'angle-category': { - label: { - color: isDarkTheme ? '#FFFFEF' : '#0B1827', - }, - }, - }, - }, - }, - }, - }} - /> + {hasEnteredViewport ? ( + <AgCharts options={chartOptions} /> + ) : ( + <div style={{ width: chartWidth, height: chartHeight }} /> + )} </div> ); }; diff --git a/src/hooks/useHasEnteredViewport.ts b/src/hooks/useHasEnteredViewport.ts new file mode 100644 index 0000000..6ad63fb --- /dev/null +++ b/src/hooks/useHasEnteredViewport.ts @@ -0,0 +1,45 @@ +import { useEffect, useRef, useState } from 'react'; + +interface UseHasEnteredViewportOptions { + rootMargin?: string; +} + +export const useHasEnteredViewport = <T extends HTMLElement>({ + rootMargin = '200px', +}: UseHasEnteredViewportOptions = {}) => { + const containerRef = useRef<T | null>(null); + const [hasEnteredViewport, setHasEnteredViewport] = useState(false); + + useEffect(() => { + if (hasEnteredViewport) { + return; + } + + const node = containerRef.current; + + if (!node) { + return; + } + + if (typeof window === 'undefined' || !('IntersectionObserver' in window)) { + setHasEnteredViewport(true); + return; + } + + const observer = new IntersectionObserver( + (entries) => { + if (entries.some((entry) => entry.isIntersecting)) { + setHasEnteredViewport(true); + observer.disconnect(); + } + }, + { rootMargin } + ); + + observer.observe(node); + + return () => observer.disconnect(); + }, [hasEnteredViewport, rootMargin]); + + return { containerRef, hasEnteredViewport }; +}; From b92a435eaaccfb7f56689b8c02d77ceb0a4adeea Mon Sep 17 00:00:00 2001 From: christian harrington <christian@bulkbit.systems> Date: Mon, 23 Feb 2026 14:20:31 +0000 Subject: [PATCH 17/21] bring product detail collapsable section stylings inline and make some performance tweaks to them --- .../events/productDetailEvents.module.scss | 12 +- .../events/productDetailEvents.tsx | 115 +++++++++++------- .../events/productDetailEventsGrid.tsx | 25 ++-- .../productDetailContent.tsx | 17 --- .../summary/productDetailSummary.module.scss | 8 +- .../summary/productDetailSummaryDesktop.tsx | 27 ++-- .../summary/productDetailSummaryMobile.tsx | 14 +++ 7 files changed, 118 insertions(+), 100 deletions(-) diff --git a/src/features/productDetail/productDetailContent/events/productDetailEvents.module.scss b/src/features/productDetail/productDetailContent/events/productDetailEvents.module.scss index ee759ec..256c9f7 100644 --- a/src/features/productDetail/productDetailContent/events/productDetailEvents.module.scss +++ b/src/features/productDetail/productDetailContent/events/productDetailEvents.module.scss @@ -1,24 +1,16 @@ .eventsRow { - margin-top: 20px; + margin-top: 0; } .eventsCollapse { width: 100%; } -.headerCol { - display: flex; - flex-direction: row; - justify-content: flex-start; - padding-left: 8px; - border-bottom: 1px solid var(--primary-lighter); -} - .contentCol { display: flex; justify-content: center; align-items: center; - margin-top: 20px; + margin-top: 0; padding-left: 12px; padding-right: 12px; width: 100%; diff --git a/src/features/productDetail/productDetailContent/events/productDetailEvents.tsx b/src/features/productDetail/productDetailContent/events/productDetailEvents.tsx index 08c7720..d218135 100644 --- a/src/features/productDetail/productDetailContent/events/productDetailEvents.tsx +++ b/src/features/productDetail/productDetailContent/events/productDetailEvents.tsx @@ -1,5 +1,5 @@ -import { FC, memo, useCallback, useRef, useState } from 'react'; -import { Col, Collapse, Empty, Row, Spin } from 'antd'; +import { FC, memo, MouseEvent, useCallback, useRef, useState } from 'react'; +import { Button, Col, Collapse, Empty, Row, Spin } from 'antd'; import { AgGridReact } from 'ag-grid-react'; import { GqlChain, @@ -14,7 +14,6 @@ import { import { selectProductById } from '../../../productExplorer/productExplorerSlice'; import { CURRENT_LIVE_FACTSHEETS } from '../../../documentation/factSheets/liveFactsheets'; -import { ProductDetailEventsHeader } from './productDetailEventsHeader'; import { ProductDetailEventsGrid } from './productDetailEventsGrid'; import { ProductDetailEventsHeatmap } from './productDetailEventsHeatmap'; import { useExplorerBase } from '../productDetailUseExplorerBase'; @@ -58,10 +57,14 @@ export const ProductDetailEvents: FC<ProductDetailEventsProps> = memo( const gridRef = useRef<AgGridReact<GqlPoolEvent>>(null); const rowData = poolEvents; + const isDesktop = !isMobile; const showSpinner = loading && !error && rowData.length === 0; - const heatmap = useHeatmapData(product, poolEvents); + const heatmap = useHeatmapData( + isMobile ? product : undefined, + isMobile ? poolEvents : undefined + ); const handleCollapseChange = useCallback((activeKey: string | string[]) => { const isOpen = Array.isArray(activeKey) ? activeKey.includes(EVENTS_PANEL_KEY) @@ -71,6 +74,44 @@ export const ProductDetailEvents: FC<ProductDetailEventsProps> = memo( setHasRequestedData(true); } }, []); + const handleCsvExport = useCallback((event?: MouseEvent) => { + event?.stopPropagation(); + gridRef.current?.api?.exportDataAsCsv(); + }, []); + + const content = + !hasRequestedData && isPanelOpen ? ( + <Spin /> + ) : showSpinner ? ( + <Spin /> + ) : error && rowData.length === 0 ? ( + <div>Failed to load events.</div> + ) : !isDesktop ? ( + <div className={styles.contentFullWidth}> + {!heatmap.heatmapData.length ? ( + <Empty description="No swap data" /> + ) : ( + <ProductDetailEventsHeatmap + chartTheme={chartTheme} + data={heatmap.heatmapData} + xDomain={heatmap.xDomain} + yDomain={heatmap.yDomain} + addrNameMap={heatmap.addrNameMap} + /> + )} + </div> + ) : ( + <div className={styles.contentFullWidth}> + <div className={`${darkThemeAg} ${styles.gridWrapper}`}> + <ProductDetailEventsGrid + ref={gridRef} + rowData={rowData} + explorerBase={explorerBase} + thresholds={thresholds} + /> + </div> + </div> + ); return ( <Row id="events" className={styles.eventsRow}> @@ -78,51 +119,35 @@ export const ProductDetailEvents: FC<ProductDetailEventsProps> = memo( <Collapse defaultActiveKey={[]} className={styles.eventsCollapse} - bordered={false} onChange={handleCollapseChange} > - <Panel key={EVENTS_PANEL_KEY} header="Events"> + <Panel + key={EVENTS_PANEL_KEY} + header="Events" + extra={ + isDesktop ? ( + <div + onClick={(e) => { + e.stopPropagation(); + }} + onMouseDown={(e) => { + e.stopPropagation(); + }} + > + <Button + type="primary" + size="small" + onClick={handleCsvExport} + > + Download CSV + </Button> + </div> + ) : null + } + > <Row> - <Col span={24} className={styles.headerCol}> - <ProductDetailEventsHeader - isMobile={!!isMobile} - showTitle={false} - onCsv={() => gridRef.current?.api?.exportDataAsCsv()} - /> - </Col> <Col span={24} className={styles.contentCol}> - {!hasRequestedData && isPanelOpen ? ( - <Spin /> - ) : showSpinner ? ( - <Spin /> - ) : error && rowData.length === 0 ? ( - <div>Failed to load events.</div> - ) : isMobile ? ( - <div className={styles.contentFullWidth}> - {!heatmap.heatmapData.length ? ( - <Empty description="No swap data" /> - ) : ( - <ProductDetailEventsHeatmap - chartTheme={chartTheme} - data={heatmap.heatmapData} - xDomain={heatmap.xDomain} - yDomain={heatmap.yDomain} - addrNameMap={heatmap.addrNameMap} - /> - )} - </div> - ) : ( - <div className={styles.contentFullWidth}> - <div className={`${darkThemeAg} ${styles.gridWrapper}`}> - <ProductDetailEventsGrid - ref={gridRef} - rowData={rowData} - explorerBase={explorerBase} - thresholds={thresholds} - /> - </div> - </div> - )} + {content} </Col> </Row> </Panel> diff --git a/src/features/productDetail/productDetailContent/events/productDetailEventsGrid.tsx b/src/features/productDetail/productDetailContent/events/productDetailEventsGrid.tsx index 3132ee4..255798f 100644 --- a/src/features/productDetail/productDetailContent/events/productDetailEventsGrid.tsx +++ b/src/features/productDetail/productDetailContent/events/productDetailEventsGrid.tsx @@ -39,6 +39,17 @@ export const ProductDetailEventsGrid = forwardRef< AgGridReact<GqlPoolEvent>, EventsGridProps >(function EventsGrid({ rowData, explorerBase, thresholds }, ref) { + const usdFormatter = useMemo( + () => + new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }), + [] + ); + const columnDefs = useMemo( () => [ { @@ -107,21 +118,14 @@ export const ProductDetailEventsGrid = forwardRef< enableRowGroup: true, type: 'number', valueFormatter: (p: ValueFormatterParams) => - p.value - ? new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', - minimumFractionDigits: 2, - maximumFractionDigits: 2, - }).format(Number(p.value)) - : '', + p.value ? usdFormatter.format(Number(p.value)) : '', cellStyle: { textAlign: 'right' as const }, }, { colId: 'sender', field: 'sender', headerName: 'Sender', - width: 160, + width: 145, enableRowGroup: true, cellRenderer: (p: ICellRendererParams) => { const url = `${explorerBase}/address/${p.value}`; @@ -143,7 +147,7 @@ export const ProductDetailEventsGrid = forwardRef< colId: 'tx', field: 'tx', headerName: 'Tx', - width: 160, + width: 145, enableRowGroup: true, cellRenderer: (p: ICellRendererParams) => { const url = `${explorerBase}/tx/${p.value}`; @@ -176,6 +180,7 @@ export const ProductDetailEventsGrid = forwardRef< thresholds.goldThreshold, thresholds.silverThreshold, thresholds.srcPrefix, + usdFormatter, ] ); diff --git a/src/features/productDetail/productDetailContent/productDetailContent.tsx b/src/features/productDetail/productDetailContent/productDetailContent.tsx index be286c8..7cd054a 100644 --- a/src/features/productDetail/productDetailContent/productDetailContent.tsx +++ b/src/features/productDetail/productDetailContent/productDetailContent.tsx @@ -5,13 +5,10 @@ import { selectProductById } from '../../productExplorer/productExplorerSlice'; import { ProductDetailPoolGraph } from './productDetailPoolGraph'; import { ProductDetailStats } from './productDetailStats'; import { ProductDetailNav } from './productDetailNav'; -import { ProductDetailEvents } from './events/productDetailEvents'; import sharedStyles from '../../../shared.module.scss'; import { ProductDetailInfo } from '../productDetailSidebar/productDetailInfo'; -import { GqlChain } from '../../../__generated__/graphql-types'; import { ProductDetailSidebarSocials } from '../productDetailSidebar/productDetailSidebarSocials'; -import { ProductDetailSidebarStrategySummary } from '../productDetailSidebar/productDetailSidebarStrategySummary'; import { ProductDetailTable } from './components/productDetailTable'; import styles from './productDetailContent.module.scss'; @@ -49,25 +46,11 @@ export const ProductDetailContent: FC<ProductDetailContentProps> = ({ ) : ( <></> )} - <Row> - <Col span={24} className={styles.strategyCol}> - <ProductDetailSidebarStrategySummary product={product} /> - </Col> - </Row> <Row> <Col span={24}> <ProductDetailStats productId={product.id} /> </Col> </Row> - <Row> - <Col span={24}> - <ProductDetailEvents - productId={product.id} - chain={product.chain as GqlChain} - isMobile={isMobile} - /> - </Col> - </Row> {isMobile ? ( <Row id="details" className={styles.detailsRow}> <Col span={24} className={styles.detailsCol}> diff --git a/src/features/productDetail/productDetailContent/summary/productDetailSummary.module.scss b/src/features/productDetail/productDetailContent/summary/productDetailSummary.module.scss index e51bf05..71dcfef 100644 --- a/src/features/productDetail/productDetailContent/summary/productDetailSummary.module.scss +++ b/src/features/productDetail/productDetailContent/summary/productDetailSummary.module.scss @@ -1,9 +1,7 @@ .product-detail-summary__title { - display: flex; - flex-direction: row; - justify-content: start; - padding-left: 8px; - border-bottom: 1px solid var(--primary-lighter); + display: inline-flex; + align-items: center; + gap: 8px; } .product-detail-summary__container { diff --git a/src/features/productDetail/productDetailContent/summary/productDetailSummaryDesktop.tsx b/src/features/productDetail/productDetailContent/summary/productDetailSummaryDesktop.tsx index 82454a9..bb0c30a 100644 --- a/src/features/productDetail/productDetailContent/summary/productDetailSummaryDesktop.tsx +++ b/src/features/productDetail/productDetailContent/summary/productDetailSummaryDesktop.tsx @@ -21,6 +21,7 @@ import { TableOutlined, BoxPlotOutlined, } from '@ant-design/icons'; +import { GqlChain } from '../../../../__generated__/graphql-types'; import { FinancialMetricThresholds, Product } from '../../../../models'; import { @@ -29,12 +30,13 @@ import { } from '../../../shared/graphs'; import { SimulationRunMetric } from '../../../simulationResults/simulationResultSummaryModels'; import { ProductDetailDropdown } from '../components/productDetailDropdown'; +import { ProductDetailEvents } from '../events/productDetailEvents'; +import { ProductDetailSidebarStrategySummary } from '../../productDetailSidebar/productDetailSidebarStrategySummary'; import { CURRENT_LIVE_FACTSHEETS } from '../../../documentation/factSheets/liveFactsheets'; import styles from './productDetailSummary.module.scss'; import { getMax, getMin } from './utils'; -import Title from 'antd/es/typography/Title'; import { StrategyWorkflowCard } from './strategyWorkflowCard'; import { AnalysisSimplifiedBreakdownTable } from '../../../simulationResults/breakdowns/simulationRunPerformanceSimpleTable'; @@ -321,7 +323,7 @@ export const ProductDetailSummaryDesktop: FC< }} > <Segmented - size="large" + size="small" value={metricsView} onChange={(v) => setMetricsView(v as 'gauge' | 'table')} options={[ @@ -406,6 +408,7 @@ export const ProductDetailSummaryDesktop: FC< return ( <div className={styles['product-detail-summary__desktop']}> {poolWeightsCard} + <ProductDetailSidebarStrategySummary product={product} /> {SHOW_STRATEGY_WORKFLOW_SECTION && ( <Collapse @@ -436,23 +439,16 @@ export const ProductDetailSummaryDesktop: FC< {/* Collapsible metrics section with icon toggle */} <Collapse defaultActiveKey={['metrics']} - className={styles['product-detail-summary__collapse']} - bordered={false} > <Panel key="metrics" header={ - <div className={styles['product-detail-summary__title']}> + <span className={styles['product-detail-summary__title']}> + Simulated HODL Performance Metric Analysis <Tooltip title="This pool is new and does not have enough data for live financial metrics. This is a simulated performance metric analysis based on the test period (see factsheet). Once the pool has been running for a while it will become live metrics"> - <Title level={4} style={{ margin: 0 }}> - Simulated HODL Performance Metric Analysis {' '} - <WarningOutlined - type="warning" - style={{ color: 'orange' }} - />{' '} - + -
+ } extra={metricsToggle} > @@ -619,6 +615,11 @@ export const ProductDetailSummaryDesktop: FC< + + {returnDistributionCard}
); diff --git a/src/features/productDetail/productDetailContent/summary/productDetailSummaryMobile.tsx b/src/features/productDetail/productDetailContent/summary/productDetailSummaryMobile.tsx index cdfa83c..dce97af 100644 --- a/src/features/productDetail/productDetailContent/summary/productDetailSummaryMobile.tsx +++ b/src/features/productDetail/productDetailContent/summary/productDetailSummaryMobile.tsx @@ -2,6 +2,7 @@ import { useMemo, useState } from 'react'; import { Card, Collapse, Divider, Tooltip, Typography } from 'antd'; import { InfoCircleOutlined } from '@ant-design/icons'; +import { GqlChain } from '../../../../__generated__/graphql-types'; import { FinancialMetricThresholds, Product } from '../../../../models'; import { SimulationRunMetric } from '../../../simulationResults/simulationResultSummaryModels'; import { @@ -9,6 +10,8 @@ import { ReturnDistributionGraph, } from '../../../shared/graphs'; import { ComparableProductSelector } from '../comparableProduct/comparableProductSelector'; +import { ProductDetailEvents } from '../events/productDetailEvents'; +import { ProductDetailSidebarStrategySummary } from '../../productDetailSidebar/productDetailSidebarStrategySummary'; import { getThresholdColor, getThresholdPostscript } from './utils'; const { Text } = Typography; @@ -318,6 +321,9 @@ export const ProductDetailSummaryMobile = ({ {/* Hidden because currently live analytics is turned off, comparing factsheet with live is not great */} {compareProductPanel} {poolWeightCard} +
+ +
+
+ +
+ {returnDistributionCard}
); From 3aaab614fb9b2ad0b66c547adf20d9857940b6b2 Mon Sep 17 00:00:00 2001 From: christian harrington Date: Mon, 23 Feb 2026 14:47:01 +0000 Subject: [PATCH 18/21] revamp stale fixed menu given new collapsable sections --- .../events/productDetailEvents.tsx | 43 +- .../productDetailContent.tsx | 5 +- .../productDetailContent/productDetailNav.tsx | 67 ++- .../summary/productDetailSummaryDesktop.tsx | 437 ++++++++++-------- .../summary/productDetailSummaryMobile.tsx | 138 +++--- .../productDetailSidebarStrategySummary.tsx | 46 +- 6 files changed, 444 insertions(+), 292 deletions(-) diff --git a/src/features/productDetail/productDetailContent/events/productDetailEvents.tsx b/src/features/productDetail/productDetailContent/events/productDetailEvents.tsx index d218135..f14a49c 100644 --- a/src/features/productDetail/productDetailContent/events/productDetailEvents.tsx +++ b/src/features/productDetail/productDetailContent/events/productDetailEvents.tsx @@ -1,4 +1,12 @@ -import { FC, memo, MouseEvent, useCallback, useRef, useState } from 'react'; +import { + FC, + memo, + MouseEvent, + useCallback, + useEffect, + useRef, + useState, +} from 'react'; import { Button, Col, Collapse, Empty, Row, Spin } from 'antd'; import { AgGridReact } from 'ag-grid-react'; import { @@ -79,6 +87,37 @@ export const ProductDetailEvents: FC = memo( gridRef.current?.api?.exportDataAsCsv(); }, []); + useEffect(() => { + const maybeOpenForTarget = (target?: string) => { + if (target === '#events') { + setIsPanelOpen(true); + setHasRequestedData(true); + } + }; + + maybeOpenForTarget(window.location.hash); + + const onHashChange = () => maybeOpenForTarget(window.location.hash); + const onNavSelect = (event: Event) => { + const detail = (event as CustomEvent<{ href?: string }>).detail; + maybeOpenForTarget(detail?.href); + }; + + window.addEventListener('hashchange', onHashChange); + window.addEventListener( + 'product-detail-nav-select', + onNavSelect as EventListener + ); + + return () => { + window.removeEventListener('hashchange', onHashChange); + window.removeEventListener( + 'product-detail-nav-select', + onNavSelect as EventListener + ); + }; + }, []); + const content = !hasRequestedData && isPanelOpen ? ( @@ -117,7 +156,7 @@ export const ProductDetailEvents: FC = memo( diff --git a/src/features/productDetail/productDetailContent/productDetailContent.tsx b/src/features/productDetail/productDetailContent/productDetailContent.tsx index 7cd054a..10d4082 100644 --- a/src/features/productDetail/productDetailContent/productDetailContent.tsx +++ b/src/features/productDetail/productDetailContent/productDetailContent.tsx @@ -30,7 +30,10 @@ export const ProductDetailContent: FC = ({ {product?.id && product.id !== '' && ( <> - +
{isMobile ? ( diff --git a/src/features/productDetail/productDetailContent/productDetailNav.tsx b/src/features/productDetail/productDetailContent/productDetailNav.tsx index 6995e45..e5d18c3 100644 --- a/src/features/productDetail/productDetailContent/productDetailNav.tsx +++ b/src/features/productDetail/productDetailContent/productDetailNav.tsx @@ -1,4 +1,5 @@ -import { memo, useEffect, useState } from 'react'; +import { memo, useCallback, useEffect, useMemo, useState } from 'react'; +import type { MouseEvent as ReactMouseEvent } from 'react'; import { Affix, Anchor, Button } from 'antd'; import { getBalancerPoolUrl } from '../../../utils'; import { ProductModal } from '../modal/productModal'; @@ -39,6 +40,46 @@ function ProductDetailNavInternal({ productId, chain }: ProductDetailNavProps) { const baseBalancerUrl = getBalancerPoolUrl(chain, productId); const addLiquidityBalancerPoolUrl = `${baseBalancerUrl}/add-liquidity`; const removeLiquidityBalancerPoolUrl = `${baseBalancerUrl}/remove-liquidity`; + const anchorItems = useMemo( + () => [ + { + key: 'graph', + href: '#graph', + title: 'Graph', + }, + { + key: 'summary', + href: '#summary', + title: 'Summary', + }, + { + key: 'metrics', + href: '#metrics', + title: 'Metrics', + }, + { + key: 'events', + href: '#events', + title: 'Events', + }, + { + key: 'distribution', + href: '#distribution', + title: 'Distribution', + }, + ], + [] + ); + const handleAnchorClick = useCallback( + (_event: ReactMouseEvent, link: any) => { + window.dispatchEvent( + new CustomEvent('product-detail-nav-select', { + detail: { href: link?.href }, + }) + ); + }, + [] + ); return ( <> @@ -50,28 +91,8 @@ function ProductDetailNavInternal({ productId, chain }: ProductDetailNavProps) { direction="horizontal" affix className={styles['product-detail-nav__anchor']} - items={[ - { - key: 'graph', - href: '#graph', - title: 'Graph', - }, - { - key: 'summary', - href: '#summary', - title: 'Summary', - }, - { - key: 'details', - href: '#details', - title: 'Details', - }, - { - key: 'events', - href: '#events', - title: 'Events', - }, - ]} + items={anchorItems} + onClick={handleAnchorClick} />
diff --git a/src/features/productDetail/productDetailContent/summary/productDetailSummaryDesktop.tsx b/src/features/productDetail/productDetailContent/summary/productDetailSummaryDesktop.tsx index bb0c30a..c129d72 100644 --- a/src/features/productDetail/productDetailContent/summary/productDetailSummaryDesktop.tsx +++ b/src/features/productDetail/productDetailContent/summary/productDetailSummaryDesktop.tsx @@ -1,4 +1,4 @@ -import { FC, useEffect, useMemo, useState } from 'react'; +import { FC, useCallback, useEffect, useMemo, useState } from 'react'; import { Card, Collapse, @@ -42,6 +42,7 @@ import { AnalysisSimplifiedBreakdownTable } from '../../../simulationResults/bre const { Text } = Typography; const { Panel } = Collapse; +const METRICS_PANEL_KEY = 'metrics'; const WEIGHT_CHART_Y_AXIS_OVERRIDE = { label: { enabled: false } }; const RETURN_DISTRIBUTION_Y_AXIS_OVERRIDE = { title: { enabled: false } }; const SHOW_STRATEGY_WORKFLOW_SECTION = false; @@ -152,6 +153,7 @@ export const ProductDetailSummaryDesktop: FC< ); const [metricsView, setMetricsView] = useState<'gauge' | 'table'>('gauge'); + const [isMetricsPanelOpen, setIsMetricsPanelOpen] = useState(true); const [selectedReturnMetricName, setSelectedReturnMetricName] = useState< string | undefined >(initialSelectedReturnAnalysis?.metricName); @@ -348,6 +350,46 @@ export const ProductDetailSummaryDesktop: FC<
); + const handleMetricsCollapseChange = useCallback( + (activeKey: string | string[]) => { + const isOpen = Array.isArray(activeKey) + ? activeKey.includes(METRICS_PANEL_KEY) + : activeKey === METRICS_PANEL_KEY; + setIsMetricsPanelOpen(isOpen); + }, + [] + ); + + useEffect(() => { + const maybeOpenForTarget = (target?: string) => { + if (target === '#metrics') { + setIsMetricsPanelOpen(true); + } + }; + + maybeOpenForTarget(window.location.hash); + + const onHashChange = () => maybeOpenForTarget(window.location.hash); + const onNavSelect = (event: Event) => { + const detail = (event as CustomEvent<{ href?: string }>).detail; + maybeOpenForTarget(detail?.href); + }; + + window.addEventListener('hashchange', onHashChange); + window.addEventListener( + 'product-detail-nav-select', + onNavSelect as EventListener + ); + + return () => { + window.removeEventListener('hashchange', onHashChange); + window.removeEventListener( + 'product-detail-nav-select', + onNavSelect as EventListener + ); + }; + }, []); + const poolWeightsCard = ( setReturnsView(v as 'product' | 'benchmark')} - options={[ - { label: 'Product', value: 'product' }, - { label: 'Benchmark', value: 'benchmark' }, - ]} - /> - } - > -
- {ts.length > 0 ? ( - + setReturnsView(v as 'product' | 'benchmark')} + options={[ + { label: 'Product', value: 'product' }, + { label: 'Benchmark', value: 'benchmark' }, + ]} /> - ) : ( - No Data - )} -
-
+ } + > +
+ {ts.length > 0 ? ( + + ) : ( + No Data + )} +
+ +
); return ( @@ -437,183 +481,184 @@ export const ProductDetailSummaryDesktop: FC< )} {/* Collapsible metrics section with icon toggle */} - - - Simulated HODL Performance Metric Analysis - - - - - } - extra={metricsToggle} +
+ - {metricsView === 'table' ? ( - isLoading ? ( - - ) : simulationRunBreakdown ? ( - + + Simulated HODL Performance Metric Analysis + + + + + } + extra={metricsToggle} + > + {metricsView === 'table' ? ( + isLoading ? ( + + ) : simulationRunBreakdown ? ( + + ) : ( + No Data + ) ) : ( - No Data - ) - ) : ( - <> - - Metric:{' '} - -
- } - > - {isLoading ? ( - - ) : ( - - - - - - - - + + Metric:{' '} + - - - - -
- - Delta (Product − Benchmark) - +
+ } + > + {isLoading ? ( + + ) : ( + + + + + + + + + + + +
- {deltaTag} + + Delta (Product − Benchmark) + +
+ {deltaTag} +
+ + + + Product: {format2(productReturnValue)} + + + Benchmark:{' '} + {format2(benchmarkReturnValue)} + +
- +
+ )} +
+ + {/* Benchmark-relative metric (product only) */} + + Benchmark metric: + +
+ } + > + {isLoading ? ( + + ) : ( + + + - - - Product: {format2(productReturnValue)} - + + + + +
- Benchmark:{' '} - {format2(benchmarkReturnValue)} + {selectedBenchmarkRelThreshold?.tooltipDescription} - -
- -
- )} - - - {/* Benchmark-relative metric (product only) */} - - Benchmark metric: - -
- } - > - {isLoading ? ( - - ) : ( - - - - - - - -
- - {selectedBenchmarkRelThreshold?.tooltipDescription} - -
- -
- )} - - - )} - - +
+ + + )} + + + )} + + + +
{product.name} @@ -324,88 +324,90 @@ export const ProductDetailSummaryMobile = ({
- } - > -
- {/* Product */} - - - {/* HODL */} - - - {/* Comparing product (optional) */} - {comparingProduct && ( +
+ } + > +
+ {/* Product */} x.metricName == selectedReturnAnalysis?.metricName - )?.metricValue ?? 0 + label={product.name} + value={selectedReturnAnalysis?.metricValue ?? 0} + gradeColor={ + repr( + returnAnalysisThresholds, + currentReturnAnalysisLabel, + selectedBenchmarkReturnAnalysis?.metricValue ?? 0 + ).color + } + gradeText={ + repr( + returnAnalysisThresholds, + currentReturnAnalysisLabel, + selectedBenchmarkReturnAnalysis?.metricValue ?? 0 + ).grade } + /> + + {/* HODL */} + x.metricName == selectedReturnAnalysis?.metricName - )?.metricValue ?? 0 + selectedReturnAnalysis?.metricValue ?? 0 ).color } gradeText={ repr( returnAnalysisThresholds, currentReturnAnalysisLabel, - comparingProductReturnAnalysis?.find( - (x) => x.metricName == selectedReturnAnalysis?.metricName - )?.metricValue ?? 0 + selectedReturnAnalysis?.metricValue ?? 0 ).grade } /> - )} -
-
+ + {/* Comparing product (optional) */} + {comparingProduct && ( + x.metricName == selectedReturnAnalysis?.metricName + )?.metricValue ?? 0 + } + gradeColor={ + repr( + returnAnalysisThresholds, + currentReturnAnalysisLabel, + comparingProductReturnAnalysis?.find( + (x) => x.metricName == selectedReturnAnalysis?.metricName + )?.metricValue ?? 0 + ).color + } + gradeText={ + repr( + returnAnalysisThresholds, + currentReturnAnalysisLabel, + comparingProductReturnAnalysis?.find( + (x) => x.metricName == selectedReturnAnalysis?.metricName + )?.metricValue ?? 0 + ).grade + } + /> + )} +
+ +
= ({ product }) => { + const [isPanelOpen, setIsPanelOpen] = useState(false); const livePools = CURRENT_LIVE_FACTSHEETS; const liveProduct = livePools.factsheets.find( (p) => p.poolId.toLowerCase() == product.address.toLowerCase() @@ -19,12 +21,52 @@ export const ProductDetailSidebarStrategySummary: FC< const strategyName = liveProduct?.fixedSettings.find( (x) => x[0] == 'Strategy' )?.[1]; + + const handleCollapseChange = useCallback((activeKey: string | string[]) => { + const isOpen = Array.isArray(activeKey) + ? activeKey.includes(STRATEGY_PANEL_KEY) + : activeKey === STRATEGY_PANEL_KEY; + setIsPanelOpen(isOpen); + }, []); + + useEffect(() => { + const maybeOpenForTarget = (target?: string) => { + if (target === '#summary') { + setIsPanelOpen(true); + } + }; + + maybeOpenForTarget(window.location.hash); + + const onHashChange = () => maybeOpenForTarget(window.location.hash); + const onNavSelect = (event: Event) => { + const detail = (event as CustomEvent<{ href?: string }>).detail; + maybeOpenForTarget(detail?.href); + }; + + window.addEventListener('hashchange', onHashChange); + window.addEventListener( + 'product-detail-nav-select', + onNavSelect as EventListener + ); + + return () => { + window.removeEventListener('hashchange', onHashChange); + window.removeEventListener( + 'product-detail-nav-select', + onNavSelect as EventListener + ); + }; + }, []); + return ( <> From 223f0ddec8ecfcacb3cab165bbf98c50998df1da Mon Sep 17 00:00:00 2001 From: christian harrington Date: Mon, 23 Feb 2026 14:58:11 +0000 Subject: [PATCH 19/21] active key scrolling bug fix --- .../productDetailContent.tsx | 5 ++- .../productDetailContent/productDetailNav.tsx | 37 +++++++++++++------ 2 files changed, 29 insertions(+), 13 deletions(-) diff --git a/src/features/productDetail/productDetailContent/productDetailContent.tsx b/src/features/productDetail/productDetailContent/productDetailContent.tsx index 10d4082..dbadd6a 100644 --- a/src/features/productDetail/productDetailContent/productDetailContent.tsx +++ b/src/features/productDetail/productDetailContent/productDetailContent.tsx @@ -34,7 +34,10 @@ export const ProductDetailContent: FC = ({ productId={product.id} chain={product.chain} /> -
+
{isMobile ? ( diff --git a/src/features/productDetail/productDetailContent/productDetailNav.tsx b/src/features/productDetail/productDetailContent/productDetailNav.tsx index e5d18c3..04c5f6e 100644 --- a/src/features/productDetail/productDetailContent/productDetailNav.tsx +++ b/src/features/productDetail/productDetailContent/productDetailNav.tsx @@ -8,6 +8,8 @@ import styles from './productDetailNav.module.scss'; import { useAppSelector } from '../../../app/hooks'; import { selectAcceptedTermsAndConditions } from '../../productExplorer/productExplorerSlice'; +const PRODUCT_DETAIL_SCROLL_CONTAINER_ID = 'product-detail-scroll-container'; + interface ProductDetailNavProps { productId: string; chain: string; @@ -17,8 +19,9 @@ function ProductDetailNavInternal({ productId, chain }: ProductDetailNavProps) { const acceptedTermsAndConditions = useAppSelector( selectAcceptedTermsAndConditions ); - const [targetOffset, setTargetOffset] = useState( - undefined + const [targetOffset, setTargetOffset] = useState(120); + const [anchorContainer, setAnchorContainer] = useState( + null ); const [productModalUrl, setProductModalUrl] = useState( undefined @@ -26,8 +29,12 @@ function ProductDetailNavInternal({ productId, chain }: ProductDetailNavProps) { const [isWithdraw, setIsWithdraw] = useState(false); useEffect(() => { - setTargetOffset(window.innerHeight / 2); - }, []); + // Track sections relative to the product detail scroll container. + setAnchorContainer( + document.getElementById(PRODUCT_DETAIL_SCROLL_CONTAINER_ID) + ); + setTargetOffset(120); + }, [productId]); const showProductModal = (url: string) => { setProductModalUrl(url); @@ -40,6 +47,9 @@ function ProductDetailNavInternal({ productId, chain }: ProductDetailNavProps) { const baseBalancerUrl = getBalancerPoolUrl(chain, productId); const addLiquidityBalancerPoolUrl = `${baseBalancerUrl}/add-liquidity`; const removeLiquidityBalancerPoolUrl = `${baseBalancerUrl}/remove-liquidity`; + const getAnchorContainer = useCallback(() => { + return anchorContainer ?? document.documentElement; + }, [anchorContainer]); const anchorItems = useMemo( () => [ { @@ -86,14 +96,17 @@ function ProductDetailNavInternal({ productId, chain }: ProductDetailNavProps) {
- + {anchorContainer && ( + + )}
From 96b25e2e3c3475c71d009c0cde3e4809c567951c Mon Sep 17 00:00:00 2001 From: christian harrington Date: Fri, 27 Feb 2026 15:33:24 +0000 Subject: [PATCH 20/21] add vite prefix to the env --- README.md | 3 ++- env.local.template | 5 ++++- src/App.tsx | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 71048ac..a2f43b2 100644 --- a/README.md +++ b/README.md @@ -43,11 +43,12 @@ Create `.env.local` from `env.local.template`. | `VITE_BASE_URL` | Yes | Base URL for REST endpoints used by RTK Query services | | `VITE_GRAPH_TARGET` | Yes | GraphQL endpoint used by Apollo + GraphQL codegen | | `VITE_USE_STUBS_DATA` | Optional | Enables/disables stub product list flow in hooks | -| `AG_GRID_LICENCE_KEY` | Required for enterprise features | AG Grid/AG Charts enterprise license key | +| `VITE_AG_GRID_LICENSE_KEY` | Required for enterprise features | AG Grid/AG Charts enterprise license key | Notes: - Never commit `.env.local`. - `npm run codegen` reads `.env.local` via `dotenv_config_path=.env.local`. +- `VITE_AG_GRID_LICENSE_KEY` must be set at build time for AG Grid/AG Charts enterprise features. ## Scripts diff --git a/env.local.template b/env.local.template index ab51022..cbbada9 100644 --- a/env.local.template +++ b/env.local.template @@ -12,7 +12,10 @@ VITE_USE_STUBS_DATA=false VITE_GRAPH_TARGET=https://api-v3.balancer.fi/ # if backend is running locally - VITE_BASE_URL=http://localhost:5001/api +VITE_BASE_URL=http://localhost:5001/api # if backend is running on production +# VITE_BASE_URL=https://your-api-host/api +# AG Grid / AG Charts enterprise license key +VITE_AG_GRID_LICENSE_KEY= diff --git a/src/App.tsx b/src/App.tsx index 8cef029..c55f5df 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -20,7 +20,7 @@ const { Content, Header } = Layout; const isRoute = (value: string): value is ROUTES => (Object.values(ROUTES) as string[]).includes(value); -const AG_GRID_LICENSE_KEY = import.meta.env.AG_GRID_LICENCE_KEY ?? ''; +const AG_GRID_LICENSE_KEY = import.meta.env.VITE_AG_GRID_LICENSE_KEY ?? ''; const initialiseSimsToRun = (): AppThunk => (dispatch, getState) => { if (getState().simRunner.simulationsToRun.length === 0) { From b2552f0110486d153467ae5c6314fa3e02a1120e Mon Sep 17 00:00:00 2001 From: christian harrington Date: Sat, 28 Feb 2026 16:37:00 +0000 Subject: [PATCH 21/21] fix linting --- .../productItem/card/productItem.tsx | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/features/productExplorer/productItem/card/productItem.tsx b/src/features/productExplorer/productItem/card/productItem.tsx index 208ce50..2343025 100644 --- a/src/features/productExplorer/productItem/card/productItem.tsx +++ b/src/features/productExplorer/productItem/card/productItem.tsx @@ -27,11 +27,7 @@ interface ProductItemProps { wide?: boolean; } -export const ProductItem: FC = ({ product, wide }) => { - if (wide) { - return ; - } - +const ProductItemCard: FC> = ({ product }) => { const dispatch = useAppDispatch(); const overrideTab = useAppSelector(selectOverrideTab); const isDarkTheme = useAppSelector(selectTheme); @@ -192,3 +188,11 @@ export const ProductItem: FC = ({ product, wide }) => {
); }; + +export const ProductItem: FC = ({ product, wide }) => { + if (wide) { + return ; + } + + return ; +};