feat: p&l label in portfolio + fixed unnecessary calls to /total

This commit is contained in:
Face 2025-06-11 11:06:05 +03:00
parent 8cba222fe2
commit 99614f853e
10 changed files with 172 additions and 53 deletions

View file

@ -0,0 +1,51 @@
import { auth } from '$lib/auth';
import { error, json } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { user, userPortfolio, coin } from '$lib/server/db/schema';
import { eq } from 'drizzle-orm';
export async function GET({ request }) {
const session = await auth.api.getSession({ headers: request.headers });
if (!session?.user) {
throw error(401, 'Not authenticated');
}
const userId = Number(session.user.id);
const [userData, holdings] = await Promise.all([
db.select({ baseCurrencyBalance: user.baseCurrencyBalance })
.from(user)
.where(eq(user.id, userId))
.limit(1),
db.select({
quantity: userPortfolio.quantity,
currentPrice: coin.currentPrice
})
.from(userPortfolio)
.innerJoin(coin, eq(userPortfolio.coinId, coin.id))
.where(eq(userPortfolio.userId, userId))
]);
if (!userData[0]) {
throw error(404, 'User not found');
}
let totalCoinValue = 0;
for (const holding of holdings) {
const quantity = Number(holding.quantity);
const price = Number(holding.currentPrice);
totalCoinValue += quantity * price;
}
const baseCurrencyBalance = Number(userData[0].baseCurrencyBalance);
return json({
baseCurrencyBalance,
totalCoinValue,
totalValue: baseCurrencyBalance + totalCoinValue,
currency: '$'
});
}

View file

@ -1,8 +1,8 @@
import { auth } from '$lib/auth';
import { error, json } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { user, userPortfolio, coin } from '$lib/server/db/schema';
import { eq } from 'drizzle-orm';
import { user, userPortfolio, coin, transaction } from '$lib/server/db/schema';
import { eq, and, sql } from 'drizzle-orm';
export async function GET({ request }) {
const session = await auth.api.getSession({ headers: request.headers });
@ -24,7 +24,8 @@ export async function GET({ request }) {
currentPrice: coin.currentPrice,
symbol: coin.symbol,
icon: coin.icon,
change24h: coin.change24h
change24h: coin.change24h,
coinId: coin.id
})
.from(userPortfolio)
.innerJoin(coin, eq(userPortfolio.coinId, coin.id))
@ -37,21 +38,47 @@ export async function GET({ request }) {
let totalCoinValue = 0;
const coinHoldings = holdings.map(holding => {
const coinHoldings = await Promise.all(holdings.map(async (holding) => {
const quantity = Number(holding.quantity);
const price = Number(holding.currentPrice);
const value = quantity * price;
totalCoinValue += value;
// Calculate average purchase price from buy transactions
const avgPriceResult = await db.select({
avgPrice: sql<number>`
CASE
WHEN SUM(${transaction.quantity}) > 0
THEN SUM(${transaction.totalBaseCurrencyAmount}) / SUM(${transaction.quantity})
ELSE 0
END
`
})
.from(transaction)
.where(
and(
eq(transaction.userId, userId),
eq(transaction.coinId, holding.coinId),
eq(transaction.type, 'BUY')
)
);
const avgPurchasePrice = Number(avgPriceResult[0]?.avgPrice || 0);
const percentageChange = avgPurchasePrice > 0
? ((price - avgPurchasePrice) / avgPurchasePrice) * 100
: 0;
return {
symbol: holding.symbol,
icon: holding.icon,
quantity,
currentPrice: price,
value,
change24h: Number(holding.change24h)
change24h: Number(holding.change24h),
avgPurchasePrice,
percentageChange
};
});
}));
const baseCurrencyBalance = Number(userData[0].baseCurrencyBalance);

View file

@ -23,7 +23,7 @@
import { toast } from 'svelte-sonner';
import CoinIcon from '$lib/components/self/CoinIcon.svelte';
import { USER_DATA } from '$lib/stores/user-data';
import { fetchPortfolioData } from '$lib/stores/portfolio-data';
import { fetchPortfolioSummary } from '$lib/stores/portfolio-data';
import { getPublicUrl, getTimeframeInSeconds, timeToLocal } from '$lib/utils.js';
import { websocketController, type PriceUpdate, isConnectedStore } from '$lib/stores/websocket';
import SEO from '$lib/components/self/SEO.svelte';
@ -124,7 +124,7 @@
}
}
async function handleTradeSuccess() {
await Promise.all([loadCoinData(), loadUserHolding(), fetchPortfolioData()]);
await Promise.all([loadCoinData(), loadUserHolding(), fetchPortfolioSummary()]);
}
function handlePriceUpdate(priceUpdate: PriceUpdate) {
if (coin && priceUpdate.coinSymbol === coinSymbol.toUpperCase()) {

View file

@ -2,7 +2,7 @@
import Coinflip from '$lib/components/self/games/Coinflip.svelte';
import Slots from '$lib/components/self/games/Slots.svelte';
import { USER_DATA } from '$lib/stores/user-data';
import { PORTFOLIO_DATA, fetchPortfolioData } from '$lib/stores/portfolio-data';
import { PORTFOLIO_SUMMARY, fetchPortfolioSummary } from '$lib/stores/portfolio-data';
import { onMount } from 'svelte';
import { toast } from 'svelte-sonner';
import SignInConfirmDialog from '$lib/components/self/SignInConfirmDialog.svelte';
@ -17,8 +17,8 @@
function handleBalanceUpdate(newBalance: number) {
balance = newBalance;
if ($PORTFOLIO_DATA) {
PORTFOLIO_DATA.update((data) =>
if ($PORTFOLIO_SUMMARY) {
PORTFOLIO_SUMMARY.update((data) =>
data
? {
...data,
@ -31,8 +31,8 @@
}
$effect(() => {
if ($USER_DATA && $PORTFOLIO_DATA) {
balance = $PORTFOLIO_DATA.baseCurrencyBalance;
if ($USER_DATA && $PORTFOLIO_SUMMARY) {
balance = $PORTFOLIO_SUMMARY.baseCurrencyBalance;
}
});
</script>

View file

@ -23,7 +23,7 @@
XIcon
} from 'lucide-svelte';
import { USER_DATA } from '$lib/stores/user-data';
import { PORTFOLIO_DATA, fetchPortfolioData } from '$lib/stores/portfolio-data';
import { PORTFOLIO_SUMMARY, fetchPortfolioSummary } from '$lib/stores/portfolio-data';
import { toast } from 'svelte-sonner';
import { onMount } from 'svelte';
import { formatDateWithYear, formatTimeUntil, formatValue, getPublicUrl } from '$lib/utils';
@ -39,12 +39,12 @@
let newQuestion = $state('');
let creatingQuestion = $state(false);
let userBalance = $derived($PORTFOLIO_DATA ? $PORTFOLIO_DATA.baseCurrencyBalance : 0);
let userBalance = $derived($PORTFOLIO_SUMMARY ? $PORTFOLIO_SUMMARY.baseCurrencyBalance : 0);
onMount(() => {
fetchQuestions();
if ($USER_DATA) {
fetchPortfolioData();
fetchPortfolioSummary();
}
});
@ -91,7 +91,7 @@
showCreateDialog = false;
newQuestion = '';
fetchQuestions();
fetchPortfolioData();
fetchPortfolioSummary();
} else {
toast.error(result.error || 'Failed to create question', { duration: 20000 });
}

View file

@ -20,7 +20,7 @@
XIcon
} from 'lucide-svelte';
import { USER_DATA } from '$lib/stores/user-data';
import { PORTFOLIO_DATA, fetchPortfolioData } from '$lib/stores/portfolio-data';
import { PORTFOLIO_SUMMARY, fetchPortfolioSummary } from '$lib/stores/portfolio-data';
import { toast } from 'svelte-sonner';
import { onMount } from 'svelte';
import { formatDateWithYear, getPublicUrl, formatTimeUntil } from '$lib/utils';
@ -37,7 +37,7 @@
let placingBet = $state(false);
let customBetAmount = $state('');
let userBalance = $derived($PORTFOLIO_DATA ? $PORTFOLIO_DATA.baseCurrencyBalance : 0);
let userBalance = $derived($PORTFOLIO_SUMMARY ? $PORTFOLIO_SUMMARY.baseCurrencyBalance : 0);
let questionId = $derived(parseInt(page.params.id));
// Chart related
@ -48,7 +48,7 @@
onMount(() => {
fetchQuestion();
if ($USER_DATA) {
fetchPortfolioData();
fetchPortfolioSummary();
}
});
@ -97,7 +97,7 @@
);
customBetAmount = '';
fetchQuestion();
fetchPortfolioData();
fetchPortfolioSummary();
} else {
toast.error(result.error || 'Failed to place bet');
}

View file

@ -10,29 +10,28 @@
import { TrendingUp, DollarSign, Wallet, Receipt, Send } from 'lucide-svelte';
import { goto } from '$app/navigation';
import { USER_DATA } from '$lib/stores/user-data';
import { PORTFOLIO_DATA, fetchPortfolioData } from '$lib/stores/portfolio-data';
import SendMoneyModal from '$lib/components/self/SendMoneyModal.svelte';
// TODO: add type definitions
let portfolioData = $state<any>(null);
let transactions = $state<any[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
let sendMoneyModalOpen = $state(false);
onMount(async () => {
await Promise.all([fetchPortfolioData(), fetchRecentTransactions()]);
await Promise.all([loadPortfolioData(), fetchRecentTransactions()]);
loading = false;
});
async function fetchPortfolioData() {
async function loadPortfolioData() {
try {
const response = await fetch('/api/portfolio/total');
if (response.ok) {
portfolioData = await response.json();
error = null;
} else {
const data = await fetchPortfolioData();
if (!data) {
error = 'Failed to load portfolio data';
toast.error('Failed to load portfolio data');
} else {
error = null;
}
} catch (e) {
console.error('Failed to fetch portfolio data:', e);
@ -59,10 +58,11 @@
async function retryFetch() {
loading = true;
error = null;
await Promise.all([fetchPortfolioData(), fetchRecentTransactions()]);
await Promise.all([loadPortfolioData(), fetchRecentTransactions()]);
loading = false;
}
let portfolioData = $derived($PORTFOLIO_DATA);
let totalPortfolioValue = $derived(portfolioData ? portfolioData.totalValue : 0);
let hasHoldings = $derived(portfolioData && portfolioData.coinHoldings.length > 0);
let hasTransactions = $derived(transactions.length > 0);
@ -71,7 +71,7 @@
{
key: 'coin',
label: 'Coin',
class: 'w-[30%] min-w-[120px] md:w-[12%]',
class: 'w-[25%] min-w-[120px] md:w-[12%]',
render: (value: any, row: any) => ({
component: 'coin',
icon: row.icon,
@ -83,21 +83,32 @@
{
key: 'quantity',
label: 'Quantity',
class: 'w-[20%] min-w-[80px] md:w-[12%] font-mono',
class: 'w-[15%] min-w-[80px] md:w-[10%] font-mono',
sortable: true,
render: (value: any) => formatQuantity(value)
},
{
key: 'currentPrice',
label: 'Price',
class: 'w-[15%] min-w-[70px] md:w-[12%] font-mono',
class: 'w-[12%] min-w-[70px] md:w-[10%] font-mono',
sortable: true,
render: (value: any) => `$${formatPrice(value)}`
},
{
key: 'percentageChange',
label: 'P&L %',
class: 'w-[15%] min-w-[80px] md:w-[12%]',
sortable: true,
render: (value: any) => ({
component: 'badge',
variant: value >= 0 ? 'success' : 'destructive',
text: `${value >= 0 ? '+' : ''}${value.toFixed(2)}%`
})
},
{
key: 'change24h',
label: '24h Change',
class: 'w-[20%] min-w-[80px] md:w-[12%]',
class: 'w-[15%] min-w-[80px] md:w-[12%]',
sortable: true,
render: (value: any) => ({
component: 'badge',
@ -108,7 +119,7 @@
{
key: 'value',
label: 'Value',
class: 'w-[15%] min-w-[70px] md:w-[12%] font-mono font-medium',
class: 'w-[12%] min-w-[70px] md:w-[10%] font-mono font-medium',
sortable: true,
defaultSort: true,
render: (value: any) => formatValue(value)
@ -222,7 +233,7 @@
]);
async function handleTransferSuccess() {
await Promise.all([fetchPortfolioData(), fetchRecentTransactions()]);
await Promise.all([loadPortfolioData(), fetchRecentTransactions()]);
}
</script>
@ -295,11 +306,11 @@
</Card.Header>
<Card.Content>
<p class="text-3xl font-bold">
{formatValue(portfolioData.baseCurrencyBalance)}
{formatValue(portfolioData?.baseCurrencyBalance || 0)}
</p>
<p class="text-muted-foreground text-xs">
{totalPortfolioValue > 0
? `${((portfolioData.baseCurrencyBalance / totalPortfolioValue) * 100).toFixed(1)}% of portfolio`
? `${(((portfolioData?.baseCurrencyBalance || 0) / totalPortfolioValue) * 100).toFixed(1)}% of portfolio`
: '100% of portfolio'}
</p>
</Card.Content>
@ -314,9 +325,9 @@
</Card.Title>
</Card.Header>
<Card.Content>
<p class="text-3xl font-bold">{formatValue(portfolioData.totalCoinValue)}</p>
<p class="text-3xl font-bold">{formatValue(portfolioData?.totalCoinValue || 0)}</p>
<p class="text-muted-foreground text-xs">
{portfolioData.coinHoldings.length} positions
{portfolioData?.coinHoldings.length || 0} positions
</p>
</Card.Content>
</Card.Root>