From f0e9d88678b26f0079ddb0df575f2ef210a4124c Mon Sep 17 00:00:00 2001 From: roaring30s Date: Wed, 7 Jan 2026 21:34:14 -0500 Subject: [PATCH 01/13] fix: add flex wrap to forecasted yeild assumptions row --- components/OrchestratorList/index.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/components/OrchestratorList/index.tsx b/components/OrchestratorList/index.tsx index 1370086e..008db708 100644 --- a/components/OrchestratorList/index.tsx +++ b/components/OrchestratorList/index.tsx @@ -996,9 +996,15 @@ const OrchestratorList = ({ }} > {"Forecasted Yield Assumptions"} - + - + { From c0101db2d55ba9786833387c3eddec2d03439e5b Mon Sep 17 00:00:00 2001 From: roaring30s Date: Wed, 7 Jan 2026 21:36:02 -0500 Subject: [PATCH 02/13] fix: enable auto instead of scroll for table overflows --- components/OrchestratorList/index.tsx | 2 -- components/Table/index.tsx | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/components/OrchestratorList/index.tsx b/components/OrchestratorList/index.tsx index 008db708..4d46f718 100644 --- a/components/OrchestratorList/index.tsx +++ b/components/OrchestratorList/index.tsx @@ -1001,8 +1001,6 @@ const OrchestratorList = ({ diff --git a/components/Table/index.tsx b/components/Table/index.tsx index 6e9832fa..06f748c6 100644 --- a/components/Table/index.tsx +++ b/components/Table/index.tsx @@ -80,7 +80,7 @@ function DataTable({ <> {input && ( From 432c451a37eb72dda675568b9c00ad34bbd21646 Mon Sep 17 00:00:00 2001 From: roaring30s Date: Wed, 7 Jan 2026 22:03:52 -0500 Subject: [PATCH 03/13] fix: add margin to align wrapped items --- components/OrchestratorList/index.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/components/OrchestratorList/index.tsx b/components/OrchestratorList/index.tsx index 4d46f718..52021b58 100644 --- a/components/OrchestratorList/index.tsx +++ b/components/OrchestratorList/index.tsx @@ -996,11 +996,17 @@ const OrchestratorList = ({ }} > {"Forecasted Yield Assumptions"} - + *": { + marginLeft: "$1", + marginTop: "$1", + }, }} > From 7b702e5ce84a9bfb57f0350f021a0bdd2feba99b Mon Sep 17 00:00:00 2001 From: roaring30s Date: Wed, 7 Jan 2026 23:30:11 -0500 Subject: [PATCH 04/13] feat: Add mobile-responsive card view for orchestrator list --- components/OrchestratorList/index.tsx | 1287 ++++++++++++++++++------- 1 file changed, 936 insertions(+), 351 deletions(-) diff --git a/components/OrchestratorList/index.tsx b/components/OrchestratorList/index.tsx index 52021b58..a95133af 100644 --- a/components/OrchestratorList/index.tsx +++ b/components/OrchestratorList/index.tsx @@ -42,6 +42,14 @@ import Link from "next/link"; import numbro from "numbro"; import { useCallback, useMemo, useState } from "react"; import { useReadContract } from "wagmi"; +import { useWindowSize } from "react-use"; +import { + useTable, + usePagination, + UsePaginationInstanceProps, + TableInstance, +} from "react-table"; +import { ArrowLeftIcon, ArrowRightIcon } from "@radix-ui/react-icons"; import YieldChartIcon from "../../public/img/yield-chart.svg"; @@ -966,153 +974,236 @@ const OrchestratorList = ({ [formattedPrinciple, timeHorizon, factors] ); - return ( - - - - - - [0]; + index: number; + pageIndex: number; + pageSize: number; + timeHorizon: ROITimeHorizon; + factors: ROIFactors; + }) => { + const identity = useEnsData(rowData.id); + const earnings = rowData.earningsComputed; + const isNewlyActive = earnings.isNewlyActive; + const feeCut = numbro(1 - Number(earnings.feeShare) / 1000000).format({ + mantissa: 0, + output: "percent", + }); + const rewardCut = numbro(Number(earnings.rewardCut) / 1000000).format({ + mantissa: 0, + output: "percent", + }); + const rewardCalls = `${numbro(earnings.rewardCalls) + .divide(earnings.rewardCallLength) + .format({ mantissa: 0, output: "percent" })}`; + + return ( + + + + + + {index + 1 + pageIndex * pageSize} + + + + + {identity?.name ? ( + + + {textTruncate(identity.name, 20, "…")} + + + {rowData.id.substring(0, 6)} + + + ) : ( + + {rowData.id.replace(rowData.id.slice(7, 37), "…")} + + )} + + + + + + { + e.stopPropagation(); }} + asChild > - {"Forecasted Yield Assumptions"} - - - *": { - marginLeft: "$1", - marginTop: "$1", - }, - }} - > - - { - e.stopPropagation(); + - + + + { + e.stopPropagation(); + }} + onPointerEnterCapture={undefined} + onPointerLeaveCapture={undefined} + placeholder={undefined} + > + + - - - + Actions + - - {"Time horizon:"} - - - {formatTimeHorizon(timeHorizon)} - - - - + Delegate + + + - - setTimeHorizon("half-year")} - > - {"6 months"} - - setTimeHorizon("one-year")} - > - {"1 year"} - - setTimeHorizon("two-years")} - > - {"2 years"} - - setTimeHorizon("three-years")} - > - {"3 years"} - - setTimeHorizon("four-years")} - > - {"4 years"} - - - - - - - { - e.stopPropagation(); + + Account Details + + + + Orchestrating + + + Delegating + + + History + + + + + + + + + + Forecasted Yield + + {isNewlyActive ? ( + + NEW ✨ + + ) : ( + + - - + {numbro( + earnings.roi.delegatorPercent.fees + + earnings.roi.delegatorPercent.rewards + ).format({ mantissa: 1, output: "percent" })} + + - - {"Delegation:"} - - - {numbro(principle).format({ mantissa: 1, average: true })} - {" LPT"} - - - - { - setPrinciple( - Number(e.target.value) > maxSupplyTokens - ? maxSupplyTokens - : Number(e.target.value) - ); - }} - min="1" - max={`${Number( - protocolData?.totalSupply || 1e7 - ).toFixed(0)}`} - /> - - LPT - - - - - - - - - { - e.stopPropagation(); - }} - asChild - > - - - - - - - {"Factors:"} - - - {formatFactors(factors)} - - - - - - setFactors("lpt+eth")} - > - {formatFactors("lpt+eth")} - - setFactors("lpt")} - > - {formatFactors("lpt")} - - setFactors("eth")} - > - {formatFactors("eth")} - - - - - - - - { - e.stopPropagation(); - }} - asChild - > - - - - - + - {"Inflation change:"} + {`Yield (${formatTimeHorizon(timeHorizon)})`} - - {formatPercentChange(inflationChange)} - - - - + + Rewards ( + {numbro(earnings.roi.delegatorPercent.rewards).format({ + mantissa: 1, + output: "percent", + })} + ): + + + {numbro(earnings.roi.delegator.rewards).format({ + mantissa: 1, + })}{" "} + LPT + + + )} + {factors !== "lpt" && ( + + + Fees ( + {numbro(earnings.roi.delegatorPercent.fees).format({ + mantissa: 1, + output: "percent", + })} + ): + + + {numbro(earnings.roi.delegator.fees).format({ + mantissa: 3, + })}{" "} + ETH + + + )} + + + + )} + + + + + Delegated Stake + + + {numbro(rowData.totalStake).format({ + mantissa: 0, + thousandSeparated: true, + })}{" "} + LPT + + + + + + Trailing 90D Fees + + + {numbro(rowData.ninetyDayVolumeETH).format({ + mantissa: 2, + average: true, + })}{" "} + ETH + + + + + + Orchestrator Details + + + + + Reward Cut + + + {rewardCut} + + + + + + Fee Cut + + + {feeCut} + + + + + + Reward Call Ratio (90d) + + + {rewardCalls} + + + + + + ); + }; + + // Mobile detection + const { width } = useWindowSize(); + const isMobile = width < 768; + + // Extract filter controls (yield assumptions section) + const yieldAssumptionsControls = ( + + + + + + + {"Forecasted Yield Assumptions"} + + + *": { + marginLeft: "$1", + marginTop: "$1", + }, + }} + > + + { + e.stopPropagation(); + }} + asChild + > + + + + + + + {"Time horizon:"} + + + {formatTimeHorizon(timeHorizon)} + + + + + + setTimeHorizon("half-year")} + > + {"6 months"} + + setTimeHorizon("one-year")} + > + {"1 year"} + + setTimeHorizon("two-years")} + > + {"2 years"} + + setTimeHorizon("three-years")} + > + {"3 years"} + + setTimeHorizon("four-years")} + > + {"4 years"} + + + + + + + { + e.stopPropagation(); + }} + asChild + > + + + + + - - setInflationChange("none")} - > - {formatPercentChange("none")} - - setInflationChange("positive")} - > - {formatPercentChange("positive")} - - setInflationChange("negative")} - > - {formatPercentChange("negative")} - - - - - - + {"Delegation:"} + + + {numbro(principle).format({ mantissa: 1, average: true })} + {" LPT"} + + + + + + + { + setPrinciple( + Number(e.target.value) > maxSupplyTokens + ? maxSupplyTokens + : Number(e.target.value) + ); + }} + min="1" + max={`${Number( + protocolData?.totalSupply || 1e7 + ).toFixed(0)}`} + /> + + LPT + + + + + + + + + { + e.stopPropagation(); + }} + asChild + > + + + + + + + {"Factors:"} + + + {formatFactors(factors)} + + + + + + setFactors("lpt+eth")} + > + {formatFactors("lpt+eth")} + + setFactors("lpt")} + > + {formatFactors("lpt")} + + setFactors("eth")} + > + {formatFactors("eth")} + + + + - } + + + { + e.stopPropagation(); + }} + asChild + > + + + + + + + {"Inflation change:"} + + + {formatPercentChange(inflationChange)} + + + + + + setInflationChange("none")} + > + {formatPercentChange("none")} + + setInflationChange("positive")} + > + {formatPercentChange("positive")} + + setInflationChange("negative")} + > + {formatPercentChange("negative")} + + + + + + + + ); + + // Use react-table hooks for pagination (works for both mobile and desktop) + const tableInstance = useTable( + { + columns: columns, + data: mappedData as object[], + initialState: { + pageSize, + hiddenColumns: ["identity"], + sortBy: [ + { + id: "earnings", + desc: true, + }, + ], + } as any, + }, + usePagination + ) as TableInstance & + UsePaginationInstanceProps & { state: { pageIndex: number } }; + + const { + page, + canPreviousPage, + canNextPage, + pageCount, + nextPage, + previousPage, + state: { pageIndex }, + } = tableInstance; + + // Pagination controls component (reusable) + const PaginationControls = () => ( + + { + if (canPreviousPage) { + previousPage(); + } + }} + /> + + Page {pageIndex + 1} of{" "} + {pageCount} + + { + if (canNextPage) { + nextPage(); + } + }} + /> + + ); + + // Mobile card view + if (isMobile) { + return ( + + {yieldAssumptionsControls} + + + {page.map((row, index) => { + const rowData = row.original as NonNullable[number]; + return ( + + ); + })} + + + + + ); + } + + // Desktop: use existing Table component + return ( +
); }; From 50624c7be10ea4522dfc125bfdb029ef5037f70d Mon Sep 17 00:00:00 2001 From: roaring30s Date: Fri, 9 Jan 2026 23:15:11 -0500 Subject: [PATCH 05/13] refactor(Table): add optional card rendering support for mobile views --- components/OrchestratorList/index.tsx | 152 +++++--------------------- components/Table/index.tsx | 89 ++++++++++++++- 2 files changed, 113 insertions(+), 128 deletions(-) diff --git a/components/OrchestratorList/index.tsx b/components/OrchestratorList/index.tsx index a95133af..8e8c0a13 100644 --- a/components/OrchestratorList/index.tsx +++ b/components/OrchestratorList/index.tsx @@ -41,15 +41,9 @@ import { useBondingManagerAddress } from "hooks/useContracts"; import Link from "next/link"; import numbro from "numbro"; import { useCallback, useMemo, useState } from "react"; -import { useReadContract } from "wagmi"; +import { Row } from "react-table"; import { useWindowSize } from "react-use"; -import { - useTable, - usePagination, - UsePaginationInstanceProps, - TableInstance, -} from "react-table"; -import { ArrowLeftIcon, ArrowRightIcon } from "@radix-ui/react-icons"; +import { useReadContract } from "wagmi"; import YieldChartIcon from "../../public/img/yield-chart.svg"; @@ -1246,10 +1240,12 @@ const OrchestratorList = ({ Rewards ( - {numbro(earnings.roi.delegatorPercent.rewards).format({ - mantissa: 1, - output: "percent", - })} + {numbro(earnings.roi.delegatorPercent.rewards).format( + { + mantissa: 1, + output: "percent", + } + )} ): ); - // Use react-table hooks for pagination (works for both mobile and desktop) - const tableInstance = useTable( - { - columns: columns, - data: mappedData as object[], - initialState: { - pageSize, - hiddenColumns: ["identity"], - sortBy: [ - { - id: "earnings", - desc: true, - }, - ], - } as any, - }, - usePagination - ) as TableInstance & - UsePaginationInstanceProps & { state: { pageIndex: number } }; - - const { - page, - canPreviousPage, - canNextPage, - pageCount, - nextPage, - previousPage, - state: { pageIndex }, - } = tableInstance; - - // Pagination controls component (reusable) - const PaginationControls = () => ( - - { - if (canPreviousPage) { - previousPage(); - } - }} - /> - - Page {pageIndex + 1} of{" "} - {pageCount} - - { - if (canNextPage) { - nextPage(); - } - }} - /> - - ); - - // Mobile card view - if (isMobile) { + // Render card function for mobile view + const renderCard = (row: Row, index: number, pageIndex: number) => { + const rowData = row.original as NonNullable[number]; return ( - - {yieldAssumptionsControls} - - - {page.map((row, index) => { - const rowData = row.original as NonNullable[number]; - return ( - - ); - })} - - - - + ); - } + }; - // Desktop: use existing Table component return (
); }; diff --git a/components/Table/index.tsx b/components/Table/index.tsx index 06f748c6..3dee489e 100644 --- a/components/Table/index.tsx +++ b/components/Table/index.tsx @@ -18,6 +18,7 @@ import { ReactNode } from "react"; import { Column, HeaderGroup, + Row, TableInstance, usePagination, UsePaginationInstanceProps, @@ -32,12 +33,14 @@ function DataTable({ data, columns, initialState = {}, + renderCard, }: { heading?: ReactNode; input?: ReactNode; data: T[]; columns: Column[]; initialState: object; + renderCard?: (row: Row, index: number, pageIndex: number) => ReactNode; }) { const { getTableProps, @@ -53,7 +56,7 @@ function DataTable({ state: { pageIndex }, } = useTable( { - columns, + columns: columns || [], data, initialState, }, @@ -63,6 +66,90 @@ function DataTable({ UsePaginationInstanceProps & UseSortByInstanceProps & { state: { pageIndex: number } }; + // Card view (if renderCard is provided) + if (renderCard) { + return ( + <> + {heading && ( + + {heading} + + )} + + {input && ( + + {input} + + )} + + {page.map((row, index) => ( + + {renderCard(row, index, pageIndex)} + + ))} + + + { + if (canPreviousPage) { + previousPage(); + } + }} + /> + + Page {pageIndex + 1} of{" "} + {pageCount} + + { + if (canNextPage) { + nextPage(); + } + }} + /> + + + + ); + } + + // Table view (default) return ( <> {heading && ( From a72f6973748970dec3ab7f827cd9ac17cd631353 Mon Sep 17 00:00:00 2001 From: roaring30s Date: Sat, 10 Jan 2026 00:01:38 -0500 Subject: [PATCH 06/13] fix: adjust popover ui --- components/OrchestratorList/index.tsx | 11 ++++++++--- components/Table/index.tsx | 8 +++++++- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/components/OrchestratorList/index.tsx b/components/OrchestratorList/index.tsx index 8e8c0a13..f23ff527 100644 --- a/components/OrchestratorList/index.tsx +++ b/components/OrchestratorList/index.tsx @@ -933,7 +933,6 @@ const OrchestratorList = ({ css={{ flexDirection: "column", padding: "$1", - borderBottom: "1px solid $neutral6", }} > { e.stopPropagation(); }} @@ -1138,7 +1144,6 @@ const OrchestratorList = ({ css={{ flexDirection: "column", padding: "$1", - borderBottom: "1px solid $neutral6", }} > ({ backgroundColor: "$panel", borderRadius: "$4", padding: "$4", + maxWidth: "100%", + overflowX: "hidden", }} > {input && ( @@ -97,10 +99,14 @@ function DataTable({ display: "grid", gridTemplateColumns: "1fr", gap: "$3", + minWidth: 0, }} > {page.map((row, index) => ( - + {renderCard(row, index, pageIndex)} ))} From 65aeef485a5725818b91b4cba8ad3c97cf0714c9 Mon Sep 17 00:00:00 2001 From: roaring30s Date: Sat, 10 Jan 2026 00:13:09 -0500 Subject: [PATCH 07/13] refactor(PaginationControls): add PaginationControls to table --- components/Table/PaginationControls.tsx | 62 ++++++++++++++ components/Table/index.tsx | 103 +++++------------------- 2 files changed, 81 insertions(+), 84 deletions(-) create mode 100644 components/Table/PaginationControls.tsx diff --git a/components/Table/PaginationControls.tsx b/components/Table/PaginationControls.tsx new file mode 100644 index 00000000..dd626159 --- /dev/null +++ b/components/Table/PaginationControls.tsx @@ -0,0 +1,62 @@ +import { Box, Flex } from "@livepeer/design-system"; +import { ArrowLeftIcon, ArrowRightIcon } from "@radix-ui/react-icons"; + +type PaginationControlsProps = { + canPreviousPage: boolean; + canNextPage: boolean; + pageIndex: number; + pageCount: number; + previousPage: () => void; + nextPage: () => void; +}; + +export function PaginationControls({ + canPreviousPage, + canNextPage, + pageIndex, + pageCount, + previousPage, + nextPage, +}: PaginationControlsProps) { + return ( + + { + if (canPreviousPage) { + previousPage(); + } + }} + /> + + Page {pageIndex + 1} of{" "} + {pageCount} + + { + if (canNextPage) { + nextPage(); + } + }} + /> + + ); +} diff --git a/components/Table/index.tsx b/components/Table/index.tsx index c3488756..9fa7ca29 100644 --- a/components/Table/index.tsx +++ b/components/Table/index.tsx @@ -8,12 +8,7 @@ import { Thead, Tr, } from "@livepeer/design-system"; -import { - ArrowLeftIcon, - ArrowRightIcon, - ChevronDownIcon, - ChevronUpIcon, -} from "@radix-ui/react-icons"; +import { ChevronDownIcon, ChevronUpIcon } from "@radix-ui/react-icons"; import { ReactNode } from "react"; import { Column, @@ -27,6 +22,8 @@ import { useTable, } from "react-table"; +import { PaginationControls } from "./PaginationControls"; + function DataTable({ heading = null, input = null, @@ -111,45 +108,14 @@ function DataTable({ ))} - - { - if (canPreviousPage) { - previousPage(); - } - }} - /> - - Page {pageIndex + 1} of{" "} - {pageCount} - - { - if (canNextPage) { - nextPage(); - } - }} - /> - + ); @@ -336,45 +302,14 @@ function DataTable({
- - { - if (canPreviousPage) { - previousPage(); - } - }} - /> - - Page {pageIndex + 1} of{" "} - {pageCount} - - { - if (canNextPage) { - nextPage(); - } - }} - /> - + From a4a3ae8587fb2cac903ca216cd595be2b30c8c6b Mon Sep 17 00:00:00 2001 From: roaring30s Date: Sat, 10 Jan 2026 00:26:21 -0500 Subject: [PATCH 08/13] refactor: add OrchestratorActionsMenu --- .../OrchestratorActionsMenu.tsx | 121 +++++++++++ components/OrchestratorList/index.tsx | 201 +----------------- 2 files changed, 127 insertions(+), 195 deletions(-) create mode 100644 components/OrchestratorList/OrchestratorActionsMenu.tsx diff --git a/components/OrchestratorList/OrchestratorActionsMenu.tsx b/components/OrchestratorList/OrchestratorActionsMenu.tsx new file mode 100644 index 00000000..958fed3b --- /dev/null +++ b/components/OrchestratorList/OrchestratorActionsMenu.tsx @@ -0,0 +1,121 @@ +import PopoverLink from "@components/PopoverLink"; +import { + Box, + Flex, + IconButton, + Popover, + PopoverContent, + PopoverTrigger, + Text, +} from "@livepeer/design-system"; +import { DotsHorizontalIcon } from "@radix-ui/react-icons"; + +type OrchestratorActionsMenuProps = { + accountId: string; + isMobile?: boolean; +}; + +export function OrchestratorActionsMenu({ + accountId, + isMobile = false, +}: OrchestratorActionsMenuProps) { + return ( + + { + e.stopPropagation(); + }} + asChild + > + + + + + { + e.stopPropagation(); + }} + onPointerEnterCapture={undefined} + onPointerLeaveCapture={undefined} + placeholder={undefined} + > + + + Actions + + + + Delegate + + + + + Account Details + + + + Orchestrating + + + Delegating + + + History + + + + + ); +} diff --git a/components/OrchestratorList/index.tsx b/components/OrchestratorList/index.tsx index f23ff527..5d8f5138 100644 --- a/components/OrchestratorList/index.tsx +++ b/components/OrchestratorList/index.tsx @@ -1,6 +1,5 @@ import { ExplorerTooltip } from "@components/ExplorerTooltip"; import IdentityAvatar from "@components/IdentityAvatar"; -import PopoverLink from "@components/PopoverLink"; import Table from "@components/Table"; import { bondingManager } from "@lib/api/abis/main/BondingManager"; import { AVERAGE_L1_BLOCK_TIME } from "@lib/chains"; @@ -21,7 +20,6 @@ import { DropdownMenuItem, DropdownMenuTrigger, Flex, - IconButton, Link as A, Popover, PopoverContent, @@ -30,11 +28,7 @@ import { TextField, } from "@livepeer/design-system"; import { ArrowTopRightIcon } from "@modulz/radix-icons"; -import { - ChevronDownIcon, - DotsHorizontalIcon, - Pencil1Icon, -} from "@radix-ui/react-icons"; +import { ChevronDownIcon, Pencil1Icon } from "@radix-ui/react-icons"; import { OrchestratorsQueryResult, ProtocolQueryResult } from "apollo"; import { useEnsData } from "hooks"; import { useBondingManagerAddress } from "hooks/useContracts"; @@ -46,6 +40,7 @@ import { useWindowSize } from "react-use"; import { useReadContract } from "wagmi"; import YieldChartIcon from "../../public/img/yield-chart.svg"; +import { OrchestratorActionsMenu } from "./OrchestratorActionsMenu"; const formatTimeHorizon = (timeHorizon: ROITimeHorizon) => timeHorizon === "one-year" @@ -868,99 +863,9 @@ const OrchestratorList = ({ Header: <>, id: "actions", Cell: ({ row }) => ( - - { - e.stopPropagation(); - }} - asChild - > - - - - - - - { - e.stopPropagation(); - }} - onPointerEnterCapture={undefined} - onPointerLeaveCapture={undefined} - placeholder={undefined} - > - - - Actions - - - - Delegate - - - - - Account Details - - - - Orchestrating - - - Delegating - - - History - - - - + + + ), }, ], @@ -1077,101 +982,7 @@ const OrchestratorList = ({
- - { - e.stopPropagation(); - }} - asChild - > - - - - - { - e.stopPropagation(); - }} - onPointerEnterCapture={undefined} - onPointerLeaveCapture={undefined} - placeholder={undefined} - > - - - Actions - - - - Delegate - - - - - Account Details - - - - Orchestrating - - - Delegating - - - History - - - - +
Date: Sat, 10 Jan 2026 00:50:29 -0500 Subject: [PATCH 09/13] refactor: move roi functions to utils --- .../OrchestratorList/OrchestratorCard.tsx | 399 ++++++++++++++++++ components/OrchestratorList/index.tsx | 392 +---------------- hooks/useOrchestratorViewModel.ts | 46 ++ lib/roi.ts | 30 ++ 4 files changed, 478 insertions(+), 389 deletions(-) create mode 100644 components/OrchestratorList/OrchestratorCard.tsx create mode 100644 hooks/useOrchestratorViewModel.ts diff --git a/components/OrchestratorList/OrchestratorCard.tsx b/components/OrchestratorList/OrchestratorCard.tsx new file mode 100644 index 00000000..baffd594 --- /dev/null +++ b/components/OrchestratorList/OrchestratorCard.tsx @@ -0,0 +1,399 @@ +import IdentityAvatar from "@components/IdentityAvatar"; +import { formatTimeHorizon, ROIFactors, ROITimeHorizon } from "@lib/roi"; +import { textTruncate } from "@lib/utils"; +import { + Badge, + Box, + Flex, + Link as A, + Popover, + PopoverContent, + PopoverTrigger, + Text, +} from "@livepeer/design-system"; +import { ChevronDownIcon } from "@radix-ui/react-icons"; +import { useEnsData } from "hooks"; +import { useOrchestratorViewModel } from "hooks/useOrchestratorViewModel"; +import Link from "next/link"; +import numbro from "numbro"; + +import { OrchestratorActionsMenu } from "./OrchestratorActionsMenu"; + +type OrchestratorCardProps = { + rowData: { + id: string; + earningsComputed: { + feeShare: number | string; + rewardCut: number | string; + rewardCalls: number; + rewardCallLength: number; + isNewlyActive: boolean; + roi: { + delegatorPercent: { + fees: number; + rewards: number; + }; + delegator: { + fees: number; + rewards: number; + }; + }; + totalStake: number; + ninetyDayVolumeETH: number; + }; + }; + index: number; + pageIndex: number; + pageSize: number; + timeHorizon: ROITimeHorizon; + factors: ROIFactors; +}; + +export function OrchestratorCard({ + rowData, + index, + pageIndex, + pageSize, + timeHorizon, + factors, +}: OrchestratorCardProps) { + const identity = useEnsData(rowData.id); + const earnings = rowData.earningsComputed; + const { feeCut, rewardCut, rewardCalls, isNewlyActive } = + useOrchestratorViewModel(earnings); + + return ( + + + + + + {index + 1 + pageIndex * pageSize} + + + + + {identity?.name ? ( + + + {textTruncate(identity.name, 20, "…")} + + + {rowData.id.substring(0, 6)} + + + ) : ( + + {rowData.id.replace(rowData.id.slice(7, 37), "…")} + + )} + + + + + + + + + + + Forecasted Yield + + {isNewlyActive ? ( + + NEW ✨ + + ) : ( + + + + {numbro( + earnings.roi.delegatorPercent.fees + + earnings.roi.delegatorPercent.rewards + ).format({ mantissa: 1, output: "percent" })} + + + + + + + + + {`Yield (${formatTimeHorizon(timeHorizon)})`} + + {factors !== "eth" && ( + + + Rewards ( + {numbro(earnings.roi.delegatorPercent.rewards).format({ + mantissa: 1, + output: "percent", + })} + ): + + + {numbro(earnings.roi.delegator.rewards).format({ + mantissa: 1, + })}{" "} + LPT + + + )} + {factors !== "lpt" && ( + + + Fees ( + {numbro(earnings.roi.delegatorPercent.fees).format({ + mantissa: 1, + output: "percent", + })} + ): + + + {numbro(earnings.roi.delegator.fees).format({ + mantissa: 3, + })}{" "} + ETH + + + )} + + + + )} + + + + + Delegated Stake + + + {numbro(earnings.totalStake).format({ + mantissa: 0, + thousandSeparated: true, + })}{" "} + LPT + + + + + + Trailing 90D Fees + + + {numbro(earnings.ninetyDayVolumeETH).format({ + mantissa: 2, + average: true, + })}{" "} + ETH + + + + + + Orchestrator Details + + + + + Reward Cut + + + {rewardCut} + + + + + + Fee Cut + + + {feeCut} + + + + + + Reward Call Ratio (90d) + + + {rewardCalls} + + + + + + ); +} diff --git a/components/OrchestratorList/index.tsx b/components/OrchestratorList/index.tsx index 5d8f5138..3d271d47 100644 --- a/components/OrchestratorList/index.tsx +++ b/components/OrchestratorList/index.tsx @@ -6,6 +6,8 @@ import { AVERAGE_L1_BLOCK_TIME } from "@lib/chains"; import dayjs from "@lib/dayjs"; import { calculateROI, + formatFactors, + formatTimeHorizon, ROIFactors, ROIInflationChange, ROITimeHorizon, @@ -41,26 +43,7 @@ import { useReadContract } from "wagmi"; import YieldChartIcon from "../../public/img/yield-chart.svg"; import { OrchestratorActionsMenu } from "./OrchestratorActionsMenu"; - -const formatTimeHorizon = (timeHorizon: ROITimeHorizon) => - timeHorizon === "one-year" - ? `1Y` - : timeHorizon === "half-year" - ? `6M` - : timeHorizon === "three-years" - ? `3Y` - : timeHorizon === "two-years" - ? `2Y` - : timeHorizon === "four-years" - ? `4Y` - : "N/A"; - -const formatFactors = (factors: ROIFactors) => - factors === "lpt+eth" - ? `LPT + ETH` - : factors === "lpt" - ? `LPT Only` - : `ETH Only`; +import { OrchestratorCard } from "./OrchestratorCard"; const OrchestratorList = ({ data, @@ -872,375 +855,6 @@ const OrchestratorList = ({ [formattedPrinciple, timeHorizon, factors] ); - // Mobile card component - const OrchestratorCard = ({ - rowData, - index, - pageIndex, - pageSize, - timeHorizon, - factors, - }: { - rowData: NonNullable[0]; - index: number; - pageIndex: number; - pageSize: number; - timeHorizon: ROITimeHorizon; - factors: ROIFactors; - }) => { - const identity = useEnsData(rowData.id); - const earnings = rowData.earningsComputed; - const isNewlyActive = earnings.isNewlyActive; - const feeCut = numbro(1 - Number(earnings.feeShare) / 1000000).format({ - mantissa: 0, - output: "percent", - }); - const rewardCut = numbro(Number(earnings.rewardCut) / 1000000).format({ - mantissa: 0, - output: "percent", - }); - const rewardCalls = `${numbro(earnings.rewardCalls) - .divide(earnings.rewardCallLength) - .format({ mantissa: 0, output: "percent" })}`; - - return ( - - - - - - {index + 1 + pageIndex * pageSize} - - - - - {identity?.name ? ( - - - {textTruncate(identity.name, 20, "…")} - - - {rowData.id.substring(0, 6)} - - - ) : ( - - {rowData.id.replace(rowData.id.slice(7, 37), "…")} - - )} - - - - - - - - - - - Forecasted Yield - - {isNewlyActive ? ( - - NEW ✨ - - ) : ( - - - - {numbro( - earnings.roi.delegatorPercent.fees + - earnings.roi.delegatorPercent.rewards - ).format({ mantissa: 1, output: "percent" })} - - - - - - - - - {`Yield (${formatTimeHorizon(timeHorizon)})`} - - {factors !== "eth" && ( - - - Rewards ( - {numbro(earnings.roi.delegatorPercent.rewards).format( - { - mantissa: 1, - output: "percent", - } - )} - ): - - - {numbro(earnings.roi.delegator.rewards).format({ - mantissa: 1, - })}{" "} - LPT - - - )} - {factors !== "lpt" && ( - - - Fees ( - {numbro(earnings.roi.delegatorPercent.fees).format({ - mantissa: 1, - output: "percent", - })} - ): - - - {numbro(earnings.roi.delegator.fees).format({ - mantissa: 3, - })}{" "} - ETH - - - )} - - - - )} - - - - - Delegated Stake - - - {numbro(rowData.totalStake).format({ - mantissa: 0, - thousandSeparated: true, - })}{" "} - LPT - - - - - - Trailing 90D Fees - - - {numbro(rowData.ninetyDayVolumeETH).format({ - mantissa: 2, - average: true, - })}{" "} - ETH - - - - - - Orchestrator Details - - - - - Reward Cut - - - {rewardCut} - - - - - - Fee Cut - - - {feeCut} - - - - - - Reward Call Ratio (90d) - - - {rewardCalls} - - - - - - ); - }; - // Mobile detection const { width } = useWindowSize(); const isMobile = width < 768; diff --git a/hooks/useOrchestratorViewModel.ts b/hooks/useOrchestratorViewModel.ts new file mode 100644 index 00000000..538f9448 --- /dev/null +++ b/hooks/useOrchestratorViewModel.ts @@ -0,0 +1,46 @@ +import { useMemo } from "react"; +import numbro from "numbro"; + +type EarningsData = { + feeShare: number | string; + rewardCut: number | string; + rewardCalls: number; + rewardCallLength: number; + isNewlyActive: boolean; +}; + +export function useOrchestratorViewModel(earnings: EarningsData) { + const feeCut = useMemo( + () => + numbro(1 - Number(earnings.feeShare) / 1000000).format({ + mantissa: 0, + output: "percent", + }), + [earnings.feeShare] + ); + + const rewardCut = useMemo( + () => + numbro(Number(earnings.rewardCut) / 1000000).format({ + mantissa: 0, + output: "percent", + }), + [earnings.rewardCut] + ); + + const rewardCalls = useMemo( + () => + `${numbro(earnings.rewardCalls) + .divide(earnings.rewardCallLength) + .format({ mantissa: 0, output: "percent" })}`, + [earnings.rewardCalls, earnings.rewardCallLength] + ); + + return { + feeCut, + rewardCut, + rewardCalls, + isNewlyActive: earnings.isNewlyActive, + }; +} + diff --git a/lib/roi.ts b/lib/roi.ts index 332da0b6..053602d3 100644 --- a/lib/roi.ts +++ b/lib/roi.ts @@ -52,6 +52,36 @@ export const getMonthsForTimeHorizon = (timeHorizon: ROITimeHorizon) => ? 48 : 12; +export function formatTimeHorizon(timeHorizon: ROITimeHorizon): string { + switch (timeHorizon) { + case "half-year": + return "6 months"; + case "one-year": + return "1 year"; + case "two-years": + return "2 years"; + case "three-years": + return "3 years"; + case "four-years": + return "4 years"; + default: + return "1 year"; + } +} + +export function formatFactors(factors: ROIFactors): string { + switch (factors) { + case "lpt+eth": + return "LPT + ETH"; + case "lpt": + return "LPT Only"; + case "eth": + return "ETH Only"; + default: + return "LPT + ETH"; + } +} + export function calculateROI({ inputs: { principle, From 525d503b3e8519dee5eef2f6ae71677e78471c58 Mon Sep 17 00:00:00 2001 From: roaring30s Date: Sat, 10 Jan 2026 01:44:38 -0500 Subject: [PATCH 10/13] refactor(orchestrator-list): extract components and centralize view model logic --- .../OrchestratorList/OrchestratorCard.tsx | 4 +- .../YieldAssumptionsControls.tsx | 437 +++++++++++++ components/OrchestratorList/index.tsx | 598 +----------------- hooks/useOrchestratorRowViewModel.ts | 45 ++ hooks/useOrchestratorViewModel.ts | 212 ++++++- 5 files changed, 698 insertions(+), 598 deletions(-) create mode 100644 components/OrchestratorList/YieldAssumptionsControls.tsx create mode 100644 hooks/useOrchestratorRowViewModel.ts diff --git a/components/OrchestratorList/OrchestratorCard.tsx b/components/OrchestratorList/OrchestratorCard.tsx index baffd594..fb12d9c5 100644 --- a/components/OrchestratorList/OrchestratorCard.tsx +++ b/components/OrchestratorList/OrchestratorCard.tsx @@ -13,7 +13,7 @@ import { } from "@livepeer/design-system"; import { ChevronDownIcon } from "@radix-ui/react-icons"; import { useEnsData } from "hooks"; -import { useOrchestratorViewModel } from "hooks/useOrchestratorViewModel"; +import { useOrchestratorRowViewModel } from "hooks/useOrchestratorRowViewModel"; import Link from "next/link"; import numbro from "numbro"; @@ -60,7 +60,7 @@ export function OrchestratorCard({ const identity = useEnsData(rowData.id); const earnings = rowData.earningsComputed; const { feeCut, rewardCut, rewardCalls, isNewlyActive } = - useOrchestratorViewModel(earnings); + useOrchestratorRowViewModel(earnings); return ( void; + timeHorizon: ROITimeHorizon; + setTimeHorizon: (value: ROITimeHorizon) => void; + factors: ROIFactors; + setFactors: (value: ROIFactors) => void; + inflationChange: ROIInflationChange; + setInflationChange: (value: ROIInflationChange) => void; + protocolData: + | NonNullable["protocol"] + | undefined; + maxSupplyTokens: number; + formatPercentChange: (change: ROIInflationChange) => string; +}; + +export function YieldAssumptionsControls({ + principle, + setPrinciple, + timeHorizon, + setTimeHorizon, + factors, + setFactors, + inflationChange, + setInflationChange, + protocolData, + maxSupplyTokens, + formatPercentChange, +}: YieldAssumptionsControlsProps) { + return ( + + + + + + + {"Forecasted Yield Assumptions"} + + + *": { + marginLeft: "$1", + marginTop: "$1", + }, + }} + > + + { + e.stopPropagation(); + }} + asChild + > + + + + + + + {"Time horizon:"} + + + {formatTimeHorizon(timeHorizon)} + + + + + + setTimeHorizon("half-year")} + > + {"6 months"} + + setTimeHorizon("one-year")} + > + {"1 year"} + + setTimeHorizon("two-years")} + > + {"2 years"} + + setTimeHorizon("three-years")} + > + {"3 years"} + + setTimeHorizon("four-years")} + > + {"4 years"} + + + + + + + { + e.stopPropagation(); + }} + asChild + > + + + + + + {"Delegation:"} + + + {numbro(principle).format({ mantissa: 1, average: true })} + {" LPT"} + + + + + + + { + setPrinciple( + Number(e.target.value) > maxSupplyTokens + ? maxSupplyTokens + : Number(e.target.value) + ); + }} + min="1" + max={`${Number(protocolData?.totalSupply || 1e7).toFixed( + 0 + )}`} + /> + + LPT + + + + + + + + + { + e.stopPropagation(); + }} + asChild + > + + + + + + + {"Factors:"} + + + {formatFactors(factors)} + + + + + + setFactors("lpt+eth")} + > + {formatFactors("lpt+eth")} + + setFactors("lpt")} + > + {formatFactors("lpt")} + + setFactors("eth")} + > + {formatFactors("eth")} + + + + + + + + { + e.stopPropagation(); + }} + asChild + > + + + + + + + {"Inflation change:"} + + + {formatPercentChange(inflationChange)} + + + + + + setInflationChange("none")} + > + {formatPercentChange("none")} + + setInflationChange("positive")} + > + {formatPercentChange("positive")} + + setInflationChange("negative")} + > + {formatPercentChange("negative")} + + + + + + + + ); +} diff --git a/components/OrchestratorList/index.tsx b/components/OrchestratorList/index.tsx index 3d271d47..8a892e1e 100644 --- a/components/OrchestratorList/index.tsx +++ b/components/OrchestratorList/index.tsx @@ -1,49 +1,34 @@ import { ExplorerTooltip } from "@components/ExplorerTooltip"; import IdentityAvatar from "@components/IdentityAvatar"; import Table from "@components/Table"; -import { bondingManager } from "@lib/api/abis/main/BondingManager"; import { AVERAGE_L1_BLOCK_TIME } from "@lib/chains"; -import dayjs from "@lib/dayjs"; -import { - calculateROI, - formatFactors, - formatTimeHorizon, - ROIFactors, - ROIInflationChange, - ROITimeHorizon, -} from "@lib/roi"; +import { formatTimeHorizon } from "@lib/roi"; import { textTruncate } from "@lib/utils"; import { Badge, Box, - DropdownMenu, - DropdownMenuContent, - DropdownMenuGroup, - DropdownMenuItem, - DropdownMenuTrigger, Flex, Link as A, Popover, PopoverContent, PopoverTrigger, Text, - TextField, } from "@livepeer/design-system"; import { ArrowTopRightIcon } from "@modulz/radix-icons"; -import { ChevronDownIcon, Pencil1Icon } from "@radix-ui/react-icons"; +import { ChevronDownIcon } from "@radix-ui/react-icons"; import { OrchestratorsQueryResult, ProtocolQueryResult } from "apollo"; import { useEnsData } from "hooks"; -import { useBondingManagerAddress } from "hooks/useContracts"; +import { useOrchestratorRowViewModel } from "hooks/useOrchestratorRowViewModel"; +import { useOrchestratorViewModel } from "hooks/useOrchestratorViewModel"; import Link from "next/link"; import numbro from "numbro"; -import { useCallback, useMemo, useState } from "react"; +import { useMemo } from "react"; import { Row } from "react-table"; import { useWindowSize } from "react-use"; -import { useReadContract } from "wagmi"; -import YieldChartIcon from "../../public/img/yield-chart.svg"; import { OrchestratorActionsMenu } from "./OrchestratorActionsMenu"; import { OrchestratorCard } from "./OrchestratorCard"; +import { YieldAssumptionsControls } from "./YieldAssumptionsControls"; const OrchestratorList = ({ data, @@ -58,143 +43,19 @@ const OrchestratorList = ({ | NonNullable["transcoders"] | undefined; }) => { - const formatPercentChange = useCallback( - (change: ROIInflationChange) => - change === "none" - ? `Fixed at ${numbro( - Number(protocolData?.inflation) / 1000000000 - ).format({ - mantissa: 3, - output: "percent", - })}` - : `${numbro(Number(protocolData?.inflationChange) / 1000000000).format({ - mantissa: 5, - output: "percent", - forceSign: true, - })} per round`, - - [protocolData?.inflation, protocolData?.inflationChange] - ); - - const [principle, setPrinciple] = useState(150); - const [inflationChange, setInflationChange] = - useState("none"); - const [factors, setFactors] = useState("lpt+eth"); - const [timeHorizon, setTimeHorizon] = useState("one-year"); - const maxSupplyTokens = useMemo( - () => Math.floor(Number(protocolData?.totalSupply || 1e7)), - [protocolData] - ); - const formattedPrinciple = useMemo( - () => - numbro(Number(principle) || 150).format({ mantissa: 0, average: true }), - [principle] - ); - const { data: bondingManagerAddress } = useBondingManagerAddress(); - const { data: treasuryRewardCutRate = BigInt(0.0) } = useReadContract({ - query: { enabled: Boolean(bondingManagerAddress) }, - address: bondingManagerAddress, - abi: bondingManager, - functionName: "treasuryRewardCutRate", - }); - - const mappedData = useMemo(() => { - return data - ?.map((row) => { - const pools = row.pools ?? []; - const rewardCalls = - pools.length > 0 ? pools.filter((r) => r?.rewardTokens).length : 0; - const rewardCallRatio = rewardCalls / pools.length; - - const activation = dayjs.unix(row.activationTimestamp); - - const isNewlyActive = dayjs().diff(activation, "days") < 45; - - const feeShareDaysSinceChange = dayjs().diff( - dayjs.unix(row.feeShareUpdateTimestamp), - "days" - ); - const rewardCutDaysSinceChange = dayjs().diff( - dayjs.unix(row.rewardCutUpdateTimestamp), - "days" - ); - - const roi = calculateROI({ - inputs: { - principle: Number(principle), - timeHorizon, - inflationChange, - factors, - }, - orchestratorParams: { - totalStake: Number(row.totalStake), - }, - feeParams: { - ninetyDayVolumeETH: Number(row.ninetyDayVolumeETH), - feeShare: Number(row.feeShare) / 1000000, - lptPriceEth: Number(protocolData?.lptPriceEth), - }, - rewardParams: { - inflation: Number(protocolData?.inflation) / 1000000000, - inflationChangePerRound: - Number(protocolData?.inflationChange) / 1000000000, - totalSupply: Number(protocolData?.totalSupply), - totalActiveStake: Number(protocolData?.totalActiveStake), - roundLength: Number(protocolData?.roundLength), - - rewardCallRatio, - rewardCut: Number(row.rewardCut) / 1000000, - treasuryRewardCut: - Number(treasuryRewardCutRate / BigInt(1e18)) / 1e9, - }, - }); + const { + filters, + setPrinciple, + setTimeHorizon, + setFactors, + setInflationChange, + mappedData, + formattedPrinciple, + maxSupplyTokens, + formatPercentChange, + } = useOrchestratorViewModel({ data, protocolData }); - return { - ...row, - daysSinceChangeParams: - (feeShareDaysSinceChange < rewardCutDaysSinceChange - ? feeShareDaysSinceChange - : rewardCutDaysSinceChange) ?? 0, - daysSinceChangeParamsFormatted: - (feeShareDaysSinceChange < rewardCutDaysSinceChange - ? dayjs.unix(row.feeShareUpdateTimestamp).fromNow() - : dayjs.unix(row.rewardCutUpdateTimestamp).fromNow()) ?? "", - earningsComputed: { - roi, - activation, - isNewlyActive, - rewardCalls, - rewardCallLength: pools.length, - rewardCallRatio, - feeShare: row.feeShare, - rewardCut: row.rewardCut, - ninetyDayVolumeETH: Number(row.ninetyDayVolumeETH), - totalActiveStake: Number(protocolData?.totalActiveStake), - totalStake: Number(row.totalStake), - }, - }; - }) - .sort((a, b) => - a.earningsComputed.isNewlyActive - ? 1 - : b.earningsComputed.isNewlyActive - ? -1 - : a.earningsComputed.roi.delegatorPercent.fees + - a.earningsComputed.roi.delegatorPercent.rewards > - b.earningsComputed.roi.delegatorPercent.fees + - b.earningsComputed.roi.delegatorPercent.rewards - ? -1 - : 1 - ); - }, [ - data, - inflationChange, - protocolData, - principle, - timeHorizon, - factors, - treasuryRewardCutRate, - ]); + const { principle, timeHorizon, factors, inflationChange } = filters; const columns = useMemo( () => [ @@ -319,35 +180,8 @@ const OrchestratorList = ({ accessor: (row) => row.earningsComputed, id: "earnings", Cell: ({ row }) => { - const isNewlyActive = useMemo( - () => row.values.earnings.isNewlyActive, - [row.values?.earnings?.isNewlyActive] - ); - const feeCut = useMemo( - () => - numbro(1 - Number(row.values.earnings.feeShare) / 1000000).format( - { mantissa: 0, output: "percent" } - ), - [row.values.earnings.feeShare] - ); - const rewardCut = useMemo( - () => - numbro(Number(row.values.earnings.rewardCut) / 1000000).format({ - mantissa: 0, - output: "percent", - }), - [row.values.earnings.rewardCut] - ); - const rewardCalls = useMemo( - () => - `${numbro(row.values.earnings.rewardCalls) - .divide(row.values.earnings.rewardCallLength) - .format({ mantissa: 0, output: "percent" })}`, - [ - row.values.earnings.rewardCalls, - row.values.earnings.rewardCallLength, - ] - ); + const { feeCut, rewardCut, rewardCalls, isNewlyActive } = + useOrchestratorRowViewModel(row.values.earnings); return ( @@ -859,385 +693,21 @@ const OrchestratorList = ({ const { width } = useWindowSize(); const isMobile = width < 768; - // Extract filter controls (yield assumptions section) + // Yield assumptions controls const yieldAssumptionsControls = ( - - - - - - - {"Forecasted Yield Assumptions"} - - - *": { - marginLeft: "$1", - marginTop: "$1", - }, - }} - > - - { - e.stopPropagation(); - }} - asChild - > - - - - - - - {"Time horizon:"} - - - {formatTimeHorizon(timeHorizon)} - - - - - - setTimeHorizon("half-year")} - > - {"6 months"} - - setTimeHorizon("one-year")} - > - {"1 year"} - - setTimeHorizon("two-years")} - > - {"2 years"} - - setTimeHorizon("three-years")} - > - {"3 years"} - - setTimeHorizon("four-years")} - > - {"4 years"} - - - - - - - { - e.stopPropagation(); - }} - asChild - > - - - - - - {"Delegation:"} - - - {numbro(principle).format({ mantissa: 1, average: true })} - {" LPT"} - - - - - - - { - setPrinciple( - Number(e.target.value) > maxSupplyTokens - ? maxSupplyTokens - : Number(e.target.value) - ); - }} - min="1" - max={`${Number(protocolData?.totalSupply || 1e7).toFixed( - 0 - )}`} - /> - - LPT - - - - - - - - - { - e.stopPropagation(); - }} - asChild - > - - - - - - - {"Factors:"} - - - {formatFactors(factors)} - - - - - - setFactors("lpt+eth")} - > - {formatFactors("lpt+eth")} - - setFactors("lpt")} - > - {formatFactors("lpt")} - - setFactors("eth")} - > - {formatFactors("eth")} - - - - - - - - { - e.stopPropagation(); - }} - asChild - > - - - - - - - {"Inflation change:"} - - - {formatPercentChange(inflationChange)} - - - - - - setInflationChange("none")} - > - {formatPercentChange("none")} - - setInflationChange("positive")} - > - {formatPercentChange("positive")} - - setInflationChange("negative")} - > - {formatPercentChange("negative")} - - - - - - - + ); // Render card function for mobile view diff --git a/hooks/useOrchestratorRowViewModel.ts b/hooks/useOrchestratorRowViewModel.ts new file mode 100644 index 00000000..a1099dd4 --- /dev/null +++ b/hooks/useOrchestratorRowViewModel.ts @@ -0,0 +1,45 @@ +import numbro from "numbro"; +import { useMemo } from "react"; + +type EarningsData = { + feeShare: number | string; + rewardCut: number | string; + rewardCalls: number; + rewardCallLength: number; + isNewlyActive: boolean; +}; + +export function useOrchestratorRowViewModel(earnings: EarningsData) { + const feeCut = useMemo( + () => + numbro(1 - Number(earnings.feeShare) / 1000000).format({ + mantissa: 0, + output: "percent", + }), + [earnings.feeShare] + ); + + const rewardCut = useMemo( + () => + numbro(Number(earnings.rewardCut) / 1000000).format({ + mantissa: 0, + output: "percent", + }), + [earnings.rewardCut] + ); + + const rewardCalls = useMemo( + () => + `${numbro(earnings.rewardCalls) + .divide(earnings.rewardCallLength) + .format({ mantissa: 0, output: "percent" })}`, + [earnings.rewardCalls, earnings.rewardCallLength] + ); + + return { + feeCut, + rewardCut, + rewardCalls, + isNewlyActive: earnings.isNewlyActive, + }; +} diff --git a/hooks/useOrchestratorViewModel.ts b/hooks/useOrchestratorViewModel.ts index 538f9448..40e9a51b 100644 --- a/hooks/useOrchestratorViewModel.ts +++ b/hooks/useOrchestratorViewModel.ts @@ -1,46 +1,194 @@ -import { useMemo } from "react"; +import { bondingManager } from "@lib/api/abis/main/BondingManager"; +import dayjs from "@lib/dayjs"; +import { + calculateROI, + ROIFactors, + ROIInflationChange, + ROITimeHorizon, +} from "@lib/roi"; +import { OrchestratorsQueryResult, ProtocolQueryResult } from "apollo"; +import { useBondingManagerAddress } from "hooks/useContracts"; import numbro from "numbro"; +import { useCallback, useMemo, useState } from "react"; +import { useReadContract } from "wagmi"; -type EarningsData = { - feeShare: number | string; - rewardCut: number | string; - rewardCalls: number; - rewardCallLength: number; - isNewlyActive: boolean; +type UseOrchestratorViewModelParams = { + data: + | NonNullable["transcoders"] + | undefined; + protocolData: + | NonNullable["protocol"] + | undefined; }; -export function useOrchestratorViewModel(earnings: EarningsData) { - const feeCut = useMemo( - () => - numbro(1 - Number(earnings.feeShare) / 1000000).format({ - mantissa: 0, - output: "percent", - }), - [earnings.feeShare] +export function useOrchestratorViewModel({ + data, + protocolData, +}: UseOrchestratorViewModelParams) { + // Filter state + const [principle, setPrinciple] = useState(150); + const [inflationChange, setInflationChange] = + useState("none"); + const [factors, setFactors] = useState("lpt+eth"); + const [timeHorizon, setTimeHorizon] = useState("one-year"); + + // Derived values + const maxSupplyTokens = useMemo( + () => Math.floor(Number(protocolData?.totalSupply || 1e7)), + [protocolData] ); - const rewardCut = useMemo( + const formattedPrinciple = useMemo( () => - numbro(Number(earnings.rewardCut) / 1000000).format({ - mantissa: 0, - output: "percent", - }), - [earnings.rewardCut] + numbro(Number(principle) || 150).format({ mantissa: 0, average: true }), + [principle] ); - const rewardCalls = useMemo( - () => - `${numbro(earnings.rewardCalls) - .divide(earnings.rewardCallLength) - .format({ mantissa: 0, output: "percent" })}`, - [earnings.rewardCalls, earnings.rewardCallLength] + const formatPercentChange = useCallback( + (change: ROIInflationChange) => + change === "none" + ? `Fixed at ${numbro( + Number(protocolData?.inflation) / 1000000000 + ).format({ + mantissa: 3, + output: "percent", + })}` + : `${numbro(Number(protocolData?.inflationChange) / 1000000000).format({ + mantissa: 5, + output: "percent", + forceSign: true, + })} per round`, + [protocolData?.inflation, protocolData?.inflationChange] ); + // Contract data + const { data: bondingManagerAddress } = useBondingManagerAddress(); + const { data: treasuryRewardCutRate = BigInt(0.0) } = useReadContract({ + query: { enabled: Boolean(bondingManagerAddress) }, + address: bondingManagerAddress, + abi: bondingManager, + functionName: "treasuryRewardCutRate", + }); + + // Computed data + const mappedData = + useMemo(() => { + if (!data) return []; + return data + .map((row) => { + const pools = row.pools ?? []; + const rewardCalls = + pools.length > 0 ? pools.filter((r) => r?.rewardTokens).length : 0; + const rewardCallRatio = rewardCalls / pools.length; + + const activation = dayjs.unix(row.activationTimestamp); + + const isNewlyActive = dayjs().diff(activation, "days") < 45; + + const feeShareDaysSinceChange = dayjs().diff( + dayjs.unix(row.feeShareUpdateTimestamp), + "days" + ); + const rewardCutDaysSinceChange = dayjs().diff( + dayjs.unix(row.rewardCutUpdateTimestamp), + "days" + ); + + const roi = calculateROI({ + inputs: { + principle: Number(principle), + timeHorizon, + inflationChange, + factors, + }, + orchestratorParams: { + totalStake: Number(row.totalStake), + }, + feeParams: { + ninetyDayVolumeETH: Number(row.ninetyDayVolumeETH), + feeShare: Number(row.feeShare) / 1000000, + lptPriceEth: Number(protocolData?.lptPriceEth), + }, + rewardParams: { + inflation: Number(protocolData?.inflation) / 1000000000, + inflationChangePerRound: + Number(protocolData?.inflationChange) / 1000000000, + totalSupply: Number(protocolData?.totalSupply), + totalActiveStake: Number(protocolData?.totalActiveStake), + roundLength: Number(protocolData?.roundLength), + + rewardCallRatio, + rewardCut: Number(row.rewardCut) / 1000000, + treasuryRewardCut: + Number(treasuryRewardCutRate / BigInt(1e18)) / 1e9, + }, + }); + + return { + ...row, + daysSinceChangeParams: + (feeShareDaysSinceChange < rewardCutDaysSinceChange + ? feeShareDaysSinceChange + : rewardCutDaysSinceChange) ?? 0, + daysSinceChangeParamsFormatted: + (feeShareDaysSinceChange < rewardCutDaysSinceChange + ? dayjs.unix(row.feeShareUpdateTimestamp).fromNow() + : dayjs.unix(row.rewardCutUpdateTimestamp).fromNow()) ?? "", + earningsComputed: { + roi, + activation, + isNewlyActive, + rewardCalls, + rewardCallLength: pools.length, + rewardCallRatio, + feeShare: row.feeShare, + rewardCut: row.rewardCut, + ninetyDayVolumeETH: Number(row.ninetyDayVolumeETH), + totalActiveStake: Number(protocolData?.totalActiveStake), + totalStake: Number(row.totalStake), + }, + }; + }) + .sort((a, b) => + a.earningsComputed.isNewlyActive + ? 1 + : b.earningsComputed.isNewlyActive + ? -1 + : a.earningsComputed.roi.delegatorPercent.fees + + a.earningsComputed.roi.delegatorPercent.rewards > + b.earningsComputed.roi.delegatorPercent.fees + + b.earningsComputed.roi.delegatorPercent.rewards + ? -1 + : 1 + ); + }, [ + data, + inflationChange, + protocolData, + principle, + timeHorizon, + factors, + treasuryRewardCutRate, + ]) ?? []; + return { - feeCut, - rewardCut, - rewardCalls, - isNewlyActive: earnings.isNewlyActive, + // Filter state + filters: { + principle, + timeHorizon, + factors, + inflationChange, + }, + // Filter setters + setPrinciple, + setTimeHorizon, + setFactors, + setInflationChange, + // Computed data + mappedData, + // Derived values + formattedPrinciple, + maxSupplyTokens, + formatPercentChange, }; } - From bdd859e5ca2545ff13cb6b5bd328fbfe43421c84 Mon Sep 17 00:00:00 2001 From: roaring30s Date: Sat, 10 Jan 2026 14:15:01 -0500 Subject: [PATCH 11/13] refactor(orchestrator-list): unify mobile and desktop rendering through column arrays --- .../OrchestratorList/OrchestratorCard.tsx | 10 +-- components/OrchestratorList/index.tsx | 69 ++++++++++----- components/Table/index.tsx | 85 +++++-------------- 3 files changed, 68 insertions(+), 96 deletions(-) diff --git a/components/OrchestratorList/OrchestratorCard.tsx b/components/OrchestratorList/OrchestratorCard.tsx index fb12d9c5..33a66376 100644 --- a/components/OrchestratorList/OrchestratorCard.tsx +++ b/components/OrchestratorList/OrchestratorCard.tsx @@ -42,18 +42,14 @@ type OrchestratorCardProps = { ninetyDayVolumeETH: number; }; }; - index: number; - pageIndex: number; - pageSize: number; + rowId: string; timeHorizon: ROITimeHorizon; factors: ROIFactors; }; export function OrchestratorCard({ rowData, - index, - pageIndex, - pageSize, + rowId, timeHorizon, factors, }: OrchestratorCardProps) { @@ -99,7 +95,7 @@ export function OrchestratorCard({ alignItems: "center", }} > - {index + 1 + pageIndex * pageSize} + {+rowId + 1} [ { Header: ( @@ -689,9 +692,45 @@ const OrchestratorList = ({ [formattedPrinciple, timeHorizon, factors] ); - // Mobile detection - const { width } = useWindowSize(); - const isMobile = width < 768; + const mobileColumns = useMemo( + () => [ + { + Header: <>, + id: "earnings", + // Use same accessor as desktop for proper sorting + accessor: (row) => row.earningsComputed, + Cell: ({ row }) => { + const rowData = row.original as NonNullable< + typeof mappedData + >[number]; + return ( + + ); + }, + // Use same sortType as desktop earnings column + sortType: (rowA, rowB) => { + return rowA.values.earnings.isNewlyActive + ? -1 + : rowB.values.earnings.isNewlyActive + ? 1 + : rowA.values.earnings.roi.delegatorPercent.fees + + rowA.values.earnings.roi.delegatorPercent.rewards > + rowB.values.earnings.roi.delegatorPercent.fees + + rowB.values.earnings.roi.delegatorPercent.rewards + ? 1 + : -1; + }, + }, + ], + [timeHorizon, factors] + ); + + const columns = isMobile ? mobileColumns : desktopColumns; // Yield assumptions controls const yieldAssumptionsControls = ( @@ -710,29 +749,13 @@ const OrchestratorList = ({ /> ); - // Render card function for mobile view - const renderCard = (row: Row, index: number, pageIndex: number) => { - const rowData = row.original as NonNullable[number]; - return ( - - ); - }; - return ( ); }; diff --git a/components/Table/index.tsx b/components/Table/index.tsx index 9fa7ca29..c320db52 100644 --- a/components/Table/index.tsx +++ b/components/Table/index.tsx @@ -13,7 +13,6 @@ import { ReactNode } from "react"; import { Column, HeaderGroup, - Row, TableInstance, usePagination, UsePaginationInstanceProps, @@ -30,14 +29,14 @@ function DataTable({ data, columns, initialState = {}, - renderCard, + constrainWidth = false, }: { heading?: ReactNode; input?: ReactNode; data: T[]; columns: Column[]; initialState: object; - renderCard?: (row: Row, index: number, pageIndex: number) => ReactNode; + constrainWidth?: boolean; }) { const { getTableProps, @@ -63,65 +62,6 @@ function DataTable({ UsePaginationInstanceProps & UseSortByInstanceProps & { state: { pageIndex: number } }; - // Card view (if renderCard is provided) - if (renderCard) { - return ( - <> - {heading && ( - - {heading} - - )} - - {input && ( - - {input} - - )} - - {page.map((row, index) => ( - - {renderCard(row, index, pageIndex)} - - ))} - - - - - ); - } - - // Table view (default) return ( <> {heading && ( @@ -140,6 +80,10 @@ function DataTable({ {input && ( @@ -157,11 +101,14 @@ function DataTable({ css={{ borderCollapse: "collapse", tableLayout: "auto", - minWidth: 980, width: "100%", - "@bp4": { - width: "100%", - }, + ...(constrainWidth + ? {} + : { + "@bp1": { + minWidth: 980, + }, + }), }} > @@ -290,6 +237,12 @@ function DataTable({ paddingLeft: i === 0 ? "$5" : "$1", paddingRight: i === 0 ? "$5" : "$1", width: i === 0 ? "40px" : "auto", + ...(constrainWidth && { + maxWidth: "100%", + minWidth: 0, + boxSizing: "border-box", + wordWrap: "break-word", + }), }} > {cell.render("Cell")} From 172ab2a1c55b3f744c60bfae93c2f6ec9dc2e301 Mon Sep 17 00:00:00 2001 From: roaring30s Date: Sun, 11 Jan 2026 15:14:44 -0500 Subject: [PATCH 12/13] fix(orchestrator-list): fix card overflow and responsive layout issues --- .../OrchestratorList/OrchestratorCard.tsx | 71 ++++++++++++++++--- components/Table/index.tsx | 2 + 2 files changed, 62 insertions(+), 11 deletions(-) diff --git a/components/OrchestratorList/OrchestratorCard.tsx b/components/OrchestratorList/OrchestratorCard.tsx index 33a66376..602fe315 100644 --- a/components/OrchestratorList/OrchestratorCard.tsx +++ b/components/OrchestratorList/OrchestratorCard.tsx @@ -65,8 +65,10 @@ export function OrchestratorCard({ borderRadius: "$4", padding: "$4", backgroundColor: "$neutral2", + width: "100%", maxWidth: "100%", - overflowX: "hidden", + boxSizing: "border-box", + overflow: "hidden", }} > - - + + {+rowId + 1} @@ -106,30 +116,67 @@ export function OrchestratorCard({ textDecoration: "none", "&:hover": { textDecoration: "none" }, flex: 1, + minWidth: 0, }} > - + {identity?.name ? ( - + {textTruncate(identity.name, 20, "…")} - + {rowData.id.substring(0, 6)} ) : ( - + {rowData.id.replace(rowData.id.slice(7, 37), "…")} )} @@ -137,7 +184,9 @@ export function OrchestratorCard({ - + + + ({ borderCollapse: "collapse", tableLayout: "auto", width: "100%", + minWidth: 0, ...(constrainWidth ? {} : { @@ -242,6 +243,7 @@ function DataTable({ minWidth: 0, boxSizing: "border-box", wordWrap: "break-word", + overflow: "hidden", }), }} > From bf31189491fdbecd78d84622f93b7c6f4e7ec5f3 Mon Sep 17 00:00:00 2001 From: roaring30s Date: Wed, 14 Jan 2026 14:08:09 -0500 Subject: [PATCH 13/13] fix: resolve linter errors --- components/OrchestratorList/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/OrchestratorList/index.tsx b/components/OrchestratorList/index.tsx index ccfc6d78..9ef68580 100644 --- a/components/OrchestratorList/index.tsx +++ b/components/OrchestratorList/index.tsx @@ -3,7 +3,7 @@ import IdentityAvatar from "@components/IdentityAvatar"; import Table from "@components/Table"; import { AVERAGE_L1_BLOCK_TIME } from "@lib/chains"; import { formatTimeHorizon } from "@lib/roi"; -import { textTruncate } from "@lib/utils"; +import { formatAddress, textTruncate } from "@lib/utils"; import { Badge, Box,