Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 13 additions & 19 deletions apps/portal/src/components/operators/OperatorCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { Tooltip } from '@/components/ui/tooltip';
import { ApyTooltip } from '@/components/operators/ApyTooltip';
import { OperatorPoolBreakdown } from '@/components/operators/OperatorPoolBreakdown';
import { PositionBreakdown } from '@/components/positions';
import { hasUserPosition, calculateTotalPositionValue } from '@/lib/position-utils';
import type { Operator } from '@/types/operator';
import type { UserPosition } from '@/types/position';

Expand Down Expand Up @@ -36,17 +37,13 @@ interface OperatorCardProps {

export const OperatorCard: React.FC<OperatorCardProps> = React.memo(
({ operator, userPosition, onStake, onWithdraw }) => {
const hasUserPosition =
!!userPosition &&
(userPosition.positionValue > 0 ||
userPosition.storageFeeDeposit > 0 ||
(userPosition.pendingDeposit?.amount || 0) > 0);
const isUserPositionActive = hasUserPosition(userPosition);

return (
<Card
className={`
hover:shadow-lg transition-all duration-200
${hasUserPosition ? 'border-primary/50 bg-primary/5 hover:border-primary' : 'hover:border-primary-200'}
${isUserPositionActive ? 'border-primary/50 bg-primary/5 hover:border-primary' : 'hover:border-primary-200'}
`}
>
<CardContent className="p-6">
Expand Down Expand Up @@ -104,17 +101,19 @@ export const OperatorCard: React.FC<OperatorCardProps> = React.memo(
/>
}
>
<div className="text-2xl font-bold font-mono cursor-help">
{formatNumber(operator.totalPoolValue)} AI3
</div>
<span className="text-lg font-bold text-foreground font-mono cursor-help whitespace-nowrap">
{formatAI3(operator.totalPoolValue)}
</span>
</Tooltip>
) : (
<div className="text-2xl font-bold font-mono text-muted-foreground">--</div>
<span className="text-lg font-bold text-foreground font-mono whitespace-nowrap">
{formatAI3(operator.totalStaked)}
</span>
Comment on lines +109 to +111

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This changes the no-data branch from a muted -- to formatAI3(operator.totalStaked), which renders a staked-only figure under the "Operator Total Value" label on the next line. When totalPoolValue is absent the storage fund is excluded, so the number understates the pool rather than signalling "no data".

It also diverges from the other two views, which still show -- in this branch - OperatorTable.tsx:123 and OperatorSummary.tsx:98. Same operator, three different renderings.

Suggest restoring the placeholder, keeping the new text-lg/span styling and using the muted colour to match the siblings:

Suggested change
<span className="text-lg font-bold text-foreground font-mono whitespace-nowrap">
{formatAI3(operator.totalStaked)}
</span>
<span className="text-lg font-bold text-muted-foreground font-mono whitespace-nowrap">
--
</span>

If the intent was actually to always show something here, that's a reasonable product call - but it should land as its own change that updates all three views together, rather than inside the utils refactor.

)}
<div className="text-xs text-muted-foreground">Operator Total Value</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold font-mono">
<div className="text-lg font-bold text-foreground font-mono">
{typeof operator.nominatorCount === 'number'
? formatNumber(operator.nominatorCount)
: '--'}
Expand All @@ -124,17 +123,12 @@ export const OperatorCard: React.FC<OperatorCardProps> = React.memo(
</div>

{/* Your Position (only shown if user has a position) */}
{hasUserPosition && userPosition && (
{isUserPositionActive && userPosition && (
<div className="mb-4 p-3 bg-muted rounded-lg">
<div className="text-center">
<Tooltip content={<PositionBreakdown position={userPosition} />} side="top">
<span className="text-sm font-medium text-foreground font-mono cursor-help whitespace-nowrap">
{formatAI3(
userPosition.positionValue +
userPosition.storageFeeDeposit +
(userPosition.pendingDeposit?.amount || 0),
2,
)}
{formatAI3(calculateTotalPositionValue(userPosition), 2)}
</span>
</Tooltip>
<div className="text-xs text-muted-foreground">Your Total Position</div>
Expand All @@ -143,7 +137,7 @@ export const OperatorCard: React.FC<OperatorCardProps> = React.memo(
)}

{/* Actions */}
{hasUserPosition ? (
{isUserPositionActive ? (
<div className="flex gap-3">
<Button className="flex-1" onClick={() => onStake(operator.id)}>
Stake
Expand Down
13 changes: 3 additions & 10 deletions apps/portal/src/components/operators/OperatorTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { formatPercentage, getAPYColor, formatNumber } from '@/lib/formatting';
import { hasUserPosition, calculateTotalPositionValue } from '@/lib/position-utils';
import type { Operator, SortField } from '@/types/operator';
import type { UserPosition } from '@/types/position';
import { useOperatorFilters, useStoredPositions } from '@/hooks/use-operators';
Expand Down Expand Up @@ -160,10 +161,7 @@ const OperatorRow: React.FC<OperatorRowProps> = React.memo(
return null;
}

const totalValue =
userPosition.positionValue +
userPosition.storageFeeDeposit +
(userPosition.pendingDeposit?.amount || 0);
const totalValue = calculateTotalPositionValue(userPosition);

if (totalValue <= 0) {
return null;
Expand Down Expand Up @@ -271,12 +269,7 @@ export const OperatorTable: React.FC<OperatorTableProps> = ({
const operatorIdsWithUserPosition = React.useMemo(() => {
const ids = new Set<string>();
for (const position of positions) {
const hasUserPosition =
!!position &&
(position.positionValue > 0 ||
position.storageFeeDeposit > 0 ||
(position.pendingDeposit?.amount || 0) > 0);
if (hasUserPosition) {
if (hasUserPosition(position)) {
ids.add(position.operatorId);
}
}
Expand Down
8 changes: 2 additions & 6 deletions apps/portal/src/components/staking/OperatorSummary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { PositionBreakdown } from '@/components/positions/PositionBreakdown';
import type { Operator } from '@/types/operator';
import { useOperatorPosition } from '@/hooks/use-positions';
import { formatAI3, formatNumber, formatPercentage, getAPYColor } from '@/lib/formatting';
import { calculateTotalPositionValue } from '@/lib/position-utils';

interface OperatorSummaryProps {
operator: Operator;
Expand Down Expand Up @@ -115,12 +116,7 @@ export const OperatorSummary: React.FC<OperatorSummaryProps> = ({ operator }) =>
{userPosition ? (
<Tooltip content={<PositionBreakdown position={userPosition} />} side="top">
<span className="text-sm font-medium text-foreground font-mono cursor-help whitespace-nowrap">
{formatAI3(
userPosition.positionValue +
userPosition.storageFeeDeposit +
(userPosition.pendingDeposit?.amount || 0),
2,
)}
{formatAI3(calculateTotalPositionValue(userPosition), 2)}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you missing an import for this?

</span>
</Tooltip>
) : (
Expand Down
106 changes: 106 additions & 0 deletions apps/portal/src/lib/__tests__/position-utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { describe, it, expect } from 'vitest';
import { hasUserPosition, calculateTotalPositionValue } from '../position-utils';
import type { UserPosition } from '@/types/position';

describe('hasUserPosition', () => {
it('returns false for null, undefined, or empty position', () => {
expect(hasUserPosition(null)).toBe(false);
expect(hasUserPosition(undefined)).toBe(false);

const emptyPosition: UserPosition = {
operatorId: '0',
operatorName: 'Operator 0',
positionValue: 0,
storageFeeDeposit: 0,
pendingDeposit: null,
pendingWithdrawals: [],
status: 'active',
lastUpdated: new Date(),
};
expect(hasUserPosition(emptyPosition)).toBe(false);
});

it('returns true when positionValue > 0', () => {
const pos: UserPosition = {
operatorId: '0',
operatorName: 'Operator 0',
positionValue: 100,
storageFeeDeposit: 0,
pendingDeposit: null,
pendingWithdrawals: [],
status: 'active',
lastUpdated: new Date(),
};
expect(hasUserPosition(pos)).toBe(true);
});

it('returns true when storageFeeDeposit > 0', () => {
const pos: UserPosition = {
operatorId: '0',
operatorName: 'Operator 0',
positionValue: 0,
storageFeeDeposit: 20,
pendingDeposit: null,
pendingWithdrawals: [],
status: 'active',
lastUpdated: new Date(),
};
expect(hasUserPosition(pos)).toBe(true);
});

it('returns true when pendingDeposit.amount > 0', () => {
const pos: UserPosition = {
operatorId: '0',
operatorName: 'Operator 0',
positionValue: 0,
storageFeeDeposit: 0,
pendingDeposit: { amount: 50, effectiveEpoch: 2 },
pendingWithdrawals: [],
status: 'pending',
lastUpdated: new Date(),
};
expect(hasUserPosition(pos)).toBe(true);
});

it('omits pendingWithdrawals from active position presence', () => {
const exitedPos: UserPosition = {
operatorId: '0',
operatorName: 'Operator 0',
positionValue: 0,
storageFeeDeposit: 0,
pendingDeposit: null,
pendingWithdrawals: [
{
grossWithdrawalAmount: 100,
stakeWithdrawalAmount: 80,
storageFeeRefund: 20,
unlockAtBlock: 1000,
},
],
status: 'withdrawing',
lastUpdated: new Date(),
};
expect(hasUserPosition(exitedPos)).toBe(false);
});
});

describe('calculateTotalPositionValue', () => {
it('returns 0 for null or undefined position', () => {
expect(calculateTotalPositionValue(null)).toBe(0);
expect(calculateTotalPositionValue(undefined)).toBe(0);
});

it('sums positionValue, storageFeeDeposit, and pendingDeposit amount accurately', () => {
const pos: UserPosition = {
operatorId: '0',
operatorName: 'Operator 0',
positionValue: 100,
storageFeeDeposit: 25,
pendingDeposit: { amount: 50, effectiveEpoch: 2 },
pendingWithdrawals: [],
status: 'active',
lastUpdated: new Date(),
};
expect(calculateTotalPositionValue(pos)).toBe(175);
});
});
22 changes: 22 additions & 0 deletions apps/portal/src/lib/position-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { UserPosition } from '@/types/position';

/**
* Check if a user has an active or pending stake position with an operator.
* Note: Intentionally omits pending withdrawals as those represent exited positions awaiting unlock.
*/
export const hasUserPosition = (userPosition?: UserPosition | null): boolean =>
Boolean(
userPosition &&
(userPosition.positionValue > 0 ||
userPosition.storageFeeDeposit > 0 ||
(userPosition.pendingDeposit?.amount || 0) > 0),
);

/**
* Calculate total position value including current staked value, live storage fund deposit, and pending deposits.
*/
export const calculateTotalPositionValue = (userPosition?: UserPosition | null): number => {
if (!userPosition) return 0;
const pendingAmount = userPosition.pendingDeposit?.amount || 0;
return userPosition.positionValue + userPosition.storageFeeDeposit + pendingAmount;
};