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
57 changes: 34 additions & 23 deletions src/pages/LandingPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ import showToast from '@/utils/toast.util';
import { getSignatureErrorMessage } from '@/utils/errorHandling.utils';
import { formatCompactNumber, formatNumber } from '@/utils/numberFormat.utils';
import { formatOwnershipPercent } from '@/utils/ownership.utils';
import {
calculatePortfolioValue,
formatPortfolioValueDisplay,
getPortfolioValueHelperText,
sortHoldingsByTotalValue,
} from '@/utils/portfolioValue.utils';
import PrecisionModeToggle, {
type PrecisionMode,
} from '@/components/common/PrecisionModeToggle';
Expand All @@ -61,11 +67,6 @@ import {
formatDisplayKeyPrice,
resolveCreatorKeyPriceStroops,
} from '@/utils/keyPriceDisplay.utils';
import {
calculatePortfolioValue,
formatPortfolioValueDisplay,
getPortfolioValueHelperText,
} from '@/utils/portfolioValue.utils';
import { usePrefersReducedMotion } from '@/hooks/usePrefersReducedMotion';
import { useNavigationTiming } from '@/hooks/useNavigationTiming';
import { CREATOR_LIST_SORT_LAYOUT_TRANSITION } from '@/utils/creatorListSortTransition';
Expand Down Expand Up @@ -756,23 +757,33 @@ function LandingPage() {

const heldKeyPositions = useMemo(
() =>
holdingsCreators.map((creator, index) => {
const cached = cachedHoldings.find(h => h.creatorId === creator.id);
const baseQuantity =
index === 0
? featuredHoldings
: (DEMO_HELD_KEY_QUANTITIES[index] ?? 0);
return {
creatorId: creator.id,
quantity: cached?.quantity ?? baseQuantity,
priceStroops: creator.priceStroops,
price: creator.price,
isPriceLoading: isPriceRefreshing,
isPriceStale: creatorsAreStale,
pending: cached?.pending ?? false,
};
}),
[holdingsCreators, creatorsAreStale, featuredHoldings, isPriceRefreshing, cachedHoldings]
sortHoldingsByTotalValue(
holdingsCreators.map((creator, index) => {
const cached = cachedHoldings.find(
h => h.creatorId === creator.id
);
const baseQuantity =
index === 0
? featuredHoldings
: (DEMO_HELD_KEY_QUANTITIES[index] ?? 0);
return {
creatorId: creator.id,
quantity: cached?.quantity ?? baseQuantity,
priceStroops: creator.priceStroops,
price: creator.price,
isPriceLoading: isPriceRefreshing,
isPriceStale: creatorsAreStale,
pending: cached?.pending ?? false,
};
})
),
[
holdingsCreators,
creatorsAreStale,
featuredHoldings,
isPriceRefreshing,
cachedHoldings,
]
);
const portfolioValue = useMemo(
() => calculatePortfolioValue(heldKeyPositions),
Expand Down Expand Up @@ -1791,4 +1802,4 @@ function LandingPage() {
);
}

export default LandingPage;
export default LandingPage;
140 changes: 140 additions & 0 deletions src/utils/__tests__/portfolioValue.utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import {
calculatePortfolioValue,
formatPortfolioValueDisplay,
getPortfolioValueHelperText,
calculatePositionTotalValue,
sortHoldingsByTotalValue,
} from '../portfolioValue.utils';

describe('calculatePortfolioValue', () => {
Expand Down Expand Up @@ -96,3 +98,141 @@ describe('calculatePortfolioValue', () => {
);
});
});

describe('calculatePositionTotalValue', () => {
it('calculates total value for a position with valid price and quantity', () => {
const result = calculatePositionTotalValue({
creatorId: 'alex',
quantity: 5,
priceStroops: 1_000_000,
});

expect(result).toBe(5_000_000);
});

it('returns null when price is missing', () => {
const result = calculatePositionTotalValue({
creatorId: 'alex',
quantity: 5,
priceStroops: null,
price: null,
});

expect(result).toBeNull();
});

it('returns null when quantity is zero', () => {
const result = calculatePositionTotalValue({
creatorId: 'alex',
quantity: 0,
priceStroops: 1_000_000,
});

expect(result).toBeNull();
});

it('returns null when quantity is null', () => {
const result = calculatePositionTotalValue({
creatorId: 'alex',
quantity: null,
priceStroops: 1_000_000,
});

expect(result).toBeNull();
});
});

describe('sortHoldingsByTotalValue', () => {
it('sorts holdings in descending order by total value', () => {
const positions = [
{ creatorId: 'alex', quantity: 5, priceStroops: 100_000 }, // 500,000
{ creatorId: 'sarah', quantity: 12, priceStroops: 100_000 }, // 1,200,000
{ creatorId: 'marcus', quantity: 3, priceStroops: 100_000 }, // 300,000
];

const sorted = sortHoldingsByTotalValue(positions);

expect(sorted[0].creatorId).toBe('sarah'); // 1,200,000
expect(sorted[1].creatorId).toBe('alex'); // 500,000
expect(sorted[2].creatorId).toBe('marcus'); // 300,000
});

it('updates order when data changes', () => {
const positions = [
{ creatorId: 'alex', quantity: 5, priceStroops: 100_000 }, // 500,000
{ creatorId: 'sarah', quantity: 12, priceStroops: 100_000 }, // 1,200,000
{ creatorId: 'marcus', quantity: 3, priceStroops: 100_000 }, // 300,000
];

const sorted = sortHoldingsByTotalValue(positions);
expect(sorted[0].creatorId).toBe('sarah'); // 1,200,000

// Update marcus to have higher value
const updatedPositions = [
{ creatorId: 'alex', quantity: 5, priceStroops: 100_000 }, // 500,000
{ creatorId: 'sarah', quantity: 12, priceStroops: 100_000 }, // 1,200,000
{ creatorId: 'marcus', quantity: 15, priceStroops: 100_000 }, // 1,500,000
];

const resorted = sortHoldingsByTotalValue(updatedPositions);
expect(resorted[0].creatorId).toBe('marcus'); // 1,500,000
expect(resorted[1].creatorId).toBe('sarah'); // 1,200,000
expect(resorted[2].creatorId).toBe('alex'); // 500,000
});

it('maintains stable secondary sort by creator ID for equal values', () => {
const positions = [
{ creatorId: 'alex', quantity: 10, priceStroops: 100_000 }, // 1,000,000
{ creatorId: 'sarah', quantity: 10, priceStroops: 100_000 }, // 1,000,000
{ creatorId: 'marcus', quantity: 10, priceStroops: 100_000 }, // 1,000,000
];

const sorted = sortHoldingsByTotalValue(positions);

// All have same value, should be sorted alphabetically by creator ID
expect(sorted[0].creatorId).toBe('alex');
expect(sorted[1].creatorId).toBe('marcus');
expect(sorted[2].creatorId).toBe('sarah');
});

it('handles positions with missing prices by treating them as zero value', () => {
const positions = [
{ creatorId: 'alex', quantity: 5, priceStroops: 100_000 }, // 500,000
{ creatorId: 'sarah', quantity: 10, priceStroops: null, price: null }, // null -> 0
{ creatorId: 'marcus', quantity: 3, priceStroops: 100_000 }, // 300,000
];

const sorted = sortHoldingsByTotalValue(positions);

expect(sorted[0].creatorId).toBe('alex'); // 500,000
expect(sorted[1].creatorId).toBe('marcus'); // 300,000
expect(sorted[2].creatorId).toBe('sarah'); // 0 (missing price)
});

it('handles positions with zero quantity by treating them as zero value', () => {
const positions = [
{ creatorId: 'alex', quantity: 5, priceStroops: 100_000 }, // 500,000
{ creatorId: 'sarah', quantity: 0, priceStroops: 100_000 }, // 0
{ creatorId: 'marcus', quantity: 3, priceStroops: 100_000 }, // 300,000
];

const sorted = sortHoldingsByTotalValue(positions);

expect(sorted[0].creatorId).toBe('alex'); // 500,000
expect(sorted[1].creatorId).toBe('marcus'); // 300,000
expect(sorted[2].creatorId).toBe('sarah'); // 0 (zero quantity)
});

it('does not mutate the original array', () => {
const positions = [
{ creatorId: 'alex', quantity: 5, priceStroops: 100_000 },
{ creatorId: 'sarah', quantity: 12, priceStroops: 100_000 },
{ creatorId: 'marcus', quantity: 3, priceStroops: 100_000 },
];

const originalOrder = positions.map(p => p.creatorId);
sortHoldingsByTotalValue(positions);

expect(positions.map(p => p.creatorId)).toEqual(originalOrder);
});
});
37 changes: 37 additions & 0 deletions src/utils/portfolioValue.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,3 +129,40 @@ export function getPortfolioValueHelperText(result: PortfolioValueResult) {

return `Across ${result.heldPositionCount} held creator ${result.heldPositionCount === 1 ? 'position' : 'positions'}.`;
}

/**
* Calculates the total value (in stroops) for a single held key position.
*/
export function calculatePositionTotalValue(
position: HeldKeyPosition
): number | null {
const priceStroops = resolveCreatorKeyPriceStroops(position);
const quantity = normalizeHeldQuantity(position.quantity);

if (priceStroops == null || quantity === 0) {
return null;
}

return priceStroops * quantity;
}

/**
* Sorts held key positions by total value in descending order.
* Positions with equal total values maintain a stable secondary sort by creator ID.
*/
export function sortHoldingsByTotalValue(
positions: HeldKeyPosition[]
): HeldKeyPosition[] {
return [...positions].sort((a, b) => {
const aValue = calculatePositionTotalValue(a) ?? 0;
const bValue = calculatePositionTotalValue(b) ?? 0;

// Primary sort: descending by total value
if (bValue !== aValue) {
return bValue - aValue;
}

// Secondary sort: stable by creator ID for equal values
return a.creatorId.localeCompare(b.creatorId);
});
}
Loading