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/OrchestratorCard.tsx b/components/OrchestratorList/OrchestratorCard.tsx
new file mode 100644
index 00000000..602fe315
--- /dev/null
+++ b/components/OrchestratorList/OrchestratorCard.tsx
@@ -0,0 +1,444 @@
+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 { useOrchestratorRowViewModel } from "hooks/useOrchestratorRowViewModel";
+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;
+ };
+ };
+ rowId: string;
+ timeHorizon: ROITimeHorizon;
+ factors: ROIFactors;
+};
+
+export function OrchestratorCard({
+ rowData,
+ rowId,
+ timeHorizon,
+ factors,
+}: OrchestratorCardProps) {
+ const identity = useEnsData(rowData.id);
+ const earnings = rowData.earningsComputed;
+ const { feeCut, rewardCut, rewardCalls, isNewlyActive } =
+ useOrchestratorRowViewModel(earnings);
+
+ return (
+
+
+
+
+
+ {+rowId + 1}
+
+
+
+
+ {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/YieldAssumptionsControls.tsx b/components/OrchestratorList/YieldAssumptionsControls.tsx
new file mode 100644
index 00000000..512f2155
--- /dev/null
+++ b/components/OrchestratorList/YieldAssumptionsControls.tsx
@@ -0,0 +1,437 @@
+import {
+ formatFactors,
+ formatTimeHorizon,
+ ROIFactors,
+ ROIInflationChange,
+ ROITimeHorizon,
+} from "@lib/roi";
+import {
+ Badge,
+ Box,
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+ Flex,
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+ Text,
+ TextField,
+} from "@livepeer/design-system";
+import { Pencil1Icon } from "@radix-ui/react-icons";
+import { ProtocolQueryResult } from "apollo";
+import numbro from "numbro";
+
+import YieldChartIcon from "../../public/img/yield-chart.svg";
+
+type YieldAssumptionsControlsProps = {
+ principle: number;
+ setPrinciple: (value: number) => 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 2e277352..9ef68580 100644
--- a/components/OrchestratorList/index.tsx
+++ b/components/OrchestratorList/index.tsx
@@ -1,69 +1,33 @@
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";
-import dayjs from "@lib/dayjs";
-import {
- calculateROI,
- ROIFactors,
- ROIInflationChange,
- ROITimeHorizon,
-} from "@lib/roi";
+import { formatTimeHorizon } from "@lib/roi";
import { formatAddress, textTruncate } from "@lib/utils";
import {
Badge,
Box,
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuGroup,
- DropdownMenuItem,
- DropdownMenuTrigger,
Flex,
- IconButton,
Link as A,
Popover,
PopoverContent,
PopoverTrigger,
Text,
- TextField,
} from "@livepeer/design-system";
import { ArrowTopRightIcon } from "@modulz/radix-icons";
-import {
- ChevronDownIcon,
- DotsHorizontalIcon,
- 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 { useReadContract } from "wagmi";
-
-import YieldChartIcon from "../../public/img/yield-chart.svg";
-
-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";
+import { useMemo } from "react";
+import { useWindowSize } from "react-use";
-const formatFactors = (factors: ROIFactors) =>
- factors === "lpt+eth"
- ? `LPT + ETH`
- : factors === "lpt"
- ? `LPT Only`
- : `ETH Only`;
+import { OrchestratorActionsMenu } from "./OrchestratorActionsMenu";
+import { OrchestratorCard } from "./OrchestratorCard";
+import { YieldAssumptionsControls } from "./YieldAssumptionsControls";
const OrchestratorList = ({
data,
@@ -78,163 +42,25 @@ 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`,
+ const {
+ filters,
+ setPrinciple,
+ setTimeHorizon,
+ setFactors,
+ setInflationChange,
+ mappedData,
+ formattedPrinciple,
+ maxSupplyTokens,
+ formatPercentChange,
+ } = useOrchestratorViewModel({ data, protocolData });
- [protocolData?.inflation, protocolData?.inflationChange]
- );
+ const { principle, timeHorizon, factors, inflationChange } = filters;
- 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",
- });
+ // Mobile detection
+ const { width } = useWindowSize();
+ const isMobile = width < 768;
- 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,
- },
- });
-
- // Pre-compute formatted values to avoid useMemo in Cell render functions
- const formattedFeeCut = numbro(
- 1 - Number(row.feeShare) / 1000000
- ).format({ mantissa: 0, output: "percent" });
- const formattedRewardCut = numbro(
- Number(row.rewardCut) / 1000000
- ).format({ mantissa: 0, output: "percent" });
- const formattedRewardCalls =
- pools.length > 0
- ? `${numbro(rewardCalls)
- .divide(pools.length)
- .format({ mantissa: 0, output: "percent" })}`
- : "0%";
-
- 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),
- // Pre-formatted values for Cell rendering
- formattedFeeCut,
- formattedRewardCut,
- formattedRewardCalls,
- },
- };
- })
- .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 columns = useMemo(
+ const desktopColumns = useMemo(
() => [
{
Header: (
@@ -357,13 +183,8 @@ const OrchestratorList = ({
accessor: (row) => row.earningsComputed,
id: "earnings",
Cell: ({ row }) => {
- // Use pre-computed values from accessor instead of useMemo in render
- const {
- isNewlyActive,
- formattedFeeCut: feeCut,
- formattedRewardCut: rewardCut,
- formattedRewardCalls: rewardCalls,
- } = row.values.earnings;
+ const { feeCut, rewardCut, rewardCalls, isNewlyActive } =
+ useOrchestratorRowViewModel(row.values.earnings);
return (
@@ -862,113 +683,79 @@ 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
-
-
-
-
+
+
+
),
},
],
[formattedPrinciple, timeHorizon, factors]
);
+ 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 = (
+
+ );
+
return (
-
-
-
-
-
- {"Forecasted Yield Assumptions"}
-
-
-
-
- {
- 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")}
-
-
-
-
-
-
-
- }
+ input={yieldAssumptionsControls}
+ constrainWidth={isMobile}
/>
);
};
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 6e9832fa..0a675905 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,
@@ -26,18 +21,22 @@ import {
useTable,
} from "react-table";
+import { PaginationControls } from "./PaginationControls";
+
function DataTable({
heading = null,
input = null,
data,
columns,
initialState = {},
+ constrainWidth = false,
}: {
heading?: ReactNode;
input?: ReactNode;
data: T[];
columns: Column[];
initialState: object;
+ constrainWidth?: boolean;
}) {
const {
getTableProps,
@@ -53,7 +52,7 @@ function DataTable({
state: { pageIndex },
} = useTable(
{
- columns,
+ columns: columns || [],
data,
initialState,
},
@@ -80,7 +79,11 @@ function DataTable({
<>
{input && (
@@ -98,11 +101,15 @@ function DataTable({
css={{
borderCollapse: "collapse",
tableLayout: "auto",
- minWidth: 980,
width: "100%",
- "@bp4": {
- width: "100%",
- },
+ minWidth: 0,
+ ...(constrainWidth
+ ? {}
+ : {
+ "@bp1": {
+ minWidth: 980,
+ },
+ }),
}}
>
@@ -231,6 +238,13 @@ 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",
+ overflow: "hidden",
+ }),
}}
>
{cell.render("Cell")}
@@ -243,45 +257,14 @@ function DataTable({
-
- {
- if (canPreviousPage) {
- previousPage();
- }
- }}
- />
-
- Page {pageIndex + 1} of{" "}
- {pageCount}
-
- {
- if (canNextPage) {
- nextPage();
- }
- }}
- />
-
+
>
>
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
new file mode 100644
index 00000000..40e9a51b
--- /dev/null
+++ b/hooks/useOrchestratorViewModel.ts
@@ -0,0 +1,194 @@
+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 UseOrchestratorViewModelParams = {
+ data:
+ | NonNullable["transcoders"]
+ | undefined;
+ protocolData:
+ | NonNullable["protocol"]
+ | undefined;
+};
+
+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 formattedPrinciple = useMemo(
+ () =>
+ numbro(Number(principle) || 150).format({ mantissa: 0, average: true }),
+ [principle]
+ );
+
+ 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 {
+ // Filter state
+ filters: {
+ principle,
+ timeHorizon,
+ factors,
+ inflationChange,
+ },
+ // Filter setters
+ setPrinciple,
+ setTimeHorizon,
+ setFactors,
+ setInflationChange,
+ // Computed data
+ mappedData,
+ // Derived values
+ formattedPrinciple,
+ maxSupplyTokens,
+ formatPercentChange,
+ };
+}
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,