diff --git a/src/pages/LandingPage.tsx b/src/pages/LandingPage.tsx index 0d6c73f..abbeba4 100644 --- a/src/pages/LandingPage.tsx +++ b/src/pages/LandingPage.tsx @@ -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'; @@ -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'; @@ -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), @@ -1791,4 +1802,4 @@ function LandingPage() { ); } -export default LandingPage; +export default LandingPage; \ No newline at end of file diff --git a/src/utils/__tests__/portfolioValue.utils.test.ts b/src/utils/__tests__/portfolioValue.utils.test.ts index e2589b1..c67a447 100644 --- a/src/utils/__tests__/portfolioValue.utils.test.ts +++ b/src/utils/__tests__/portfolioValue.utils.test.ts @@ -3,6 +3,8 @@ import { calculatePortfolioValue, formatPortfolioValueDisplay, getPortfolioValueHelperText, + calculatePositionTotalValue, + sortHoldingsByTotalValue, } from '../portfolioValue.utils'; describe('calculatePortfolioValue', () => { @@ -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); + }); +}); diff --git a/src/utils/portfolioValue.utils.ts b/src/utils/portfolioValue.utils.ts index 1676d70..ac6a139 100644 --- a/src/utils/portfolioValue.utils.ts +++ b/src/utils/portfolioValue.utils.ts @@ -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); + }); +}