From 99614f853eefe27d435d5718344dfa993f629459 Mon Sep 17 00:00:00 2001 From: Face <69168154+face-hh@users.noreply.github.com> Date: Wed, 11 Jun 2025 11:06:05 +0300 Subject: [PATCH] feat: p&l label in portfolio + fixed unnecessary calls to /total --- .../src/lib/components/self/AppSidebar.svelte | 19 +++---- .../lib/components/self/DailyRewards.svelte | 4 +- website/src/lib/stores/portfolio-data.ts | 33 +++++++++++- .../routes/api/portfolio/summary/+server.ts | 51 +++++++++++++++++++ .../src/routes/api/portfolio/total/+server.ts | 39 +++++++++++--- .../src/routes/coin/[coinSymbol]/+page.svelte | 4 +- website/src/routes/gambling/+page.svelte | 10 ++-- website/src/routes/hopium/+page.svelte | 8 +-- website/src/routes/hopium/[id]/+page.svelte | 8 +-- website/src/routes/portfolio/+page.svelte | 49 +++++++++++------- 10 files changed, 172 insertions(+), 53 deletions(-) create mode 100644 website/src/routes/api/portfolio/summary/+server.ts diff --git a/website/src/lib/components/self/AppSidebar.svelte b/website/src/lib/components/self/AppSidebar.svelte index 62c6e55..736cb87 100644 --- a/website/src/lib/components/self/AppSidebar.svelte +++ b/website/src/lib/components/self/AppSidebar.svelte @@ -34,7 +34,7 @@ import { mode, setMode } from 'mode-watcher'; import type { HTMLAttributes } from 'svelte/elements'; 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 { useSidebar } from '$lib/components/ui/sidebar/index.js'; import SignInConfirmDialog from './SignInConfirmDialog.svelte'; import DailyRewards from './DailyRewards.svelte'; @@ -44,6 +44,7 @@ import { formatValue, getPublicUrl } from '$lib/utils'; import { goto } from '$app/navigation'; import { liveTradesStore, isLoadingTrades } from '$lib/stores/websocket'; + import { onMount } from 'svelte'; const data = { navMain: [ @@ -64,11 +65,11 @@ let showPromoCode = $state(false); let showUserManual = $state(false); - $effect(() => { + onMount(() => { if ($USER_DATA) { - fetchPortfolioData(); + fetchPortfolioSummary(); } else { - PORTFOLIO_DATA.set(null); + PORTFOLIO_SUMMARY.set(null); } }); @@ -208,7 +209,7 @@
- {#if !$PORTFOLIO_DATA} + {#if !$PORTFOLIO_SUMMARY}
@@ -318,7 +319,7 @@ Portfolio
- {#if !$PORTFOLIO_DATA} + {#if !$PORTFOLIO_SUMMARY}
@@ -343,19 +344,19 @@ Total Value
- ${formatCurrency($PORTFOLIO_DATA.totalValue)} + ${formatCurrency($PORTFOLIO_SUMMARY.totalValue)}
Cash: ${formatCurrency($PORTFOLIO_DATA.baseCurrencyBalance)}${formatCurrency($PORTFOLIO_SUMMARY.baseCurrencyBalance)}
Coins: - ${formatCurrency($PORTFOLIO_DATA.totalCoinValue)} + ${formatCurrency($PORTFOLIO_SUMMARY.totalCoinValue)}
{/if} diff --git a/website/src/lib/components/self/DailyRewards.svelte b/website/src/lib/components/self/DailyRewards.svelte index 04757f1..17c847e 100644 --- a/website/src/lib/components/self/DailyRewards.svelte +++ b/website/src/lib/components/self/DailyRewards.svelte @@ -2,7 +2,7 @@ import { Button } from '$lib/components/ui/button'; import { Gift, Clock, Loader2, CheckIcon } from 'lucide-svelte'; import { USER_DATA } from '$lib/stores/user-data'; - import { fetchPortfolioData } from '$lib/stores/portfolio-data'; + import { fetchPortfolioSummary } from '$lib/stores/portfolio-data'; import { toast } from 'svelte-sonner'; import { goto } from '$app/navigation'; import { formatTimeRemaining } from '$lib/utils'; @@ -81,7 +81,7 @@ }); if ($USER_DATA) { - await fetchPortfolioData(); + await fetchPortfolioSummary(); } await fetchRewardStatus(); diff --git a/website/src/lib/stores/portfolio-data.ts b/website/src/lib/stores/portfolio-data.ts index 74ebf4a..ac87f49 100644 --- a/website/src/lib/stores/portfolio-data.ts +++ b/website/src/lib/stores/portfolio-data.ts @@ -1,26 +1,55 @@ import { writable } from 'svelte/store'; -export interface PortfolioData { +export interface PortfolioSummary { baseCurrencyBalance: number; totalCoinValue: number; totalValue: number; + currency: string; +} + +export interface PortfolioData extends PortfolioSummary { coinHoldings: Array<{ symbol: string; quantity: number; currentPrice: number; value: number; + percentageChange: number; + change24h: number; + icon?: string; + portfolioPercent: number; }>; - currency: string; } +export const PORTFOLIO_SUMMARY = writable(null); + export const PORTFOLIO_DATA = writable(null); +export async function fetchPortfolioSummary() { + try { + const response = await fetch('/api/portfolio/summary'); + if (response.ok) { + const data = await response.json(); + PORTFOLIO_SUMMARY.set(data); + return data; + } + } catch (error) { + console.error('Failed to fetch portfolio summary:', error); + } + return null; +} + export async function fetchPortfolioData() { try { const response = await fetch('/api/portfolio/total'); if (response.ok) { const data = await response.json(); PORTFOLIO_DATA.set(data); + PORTFOLIO_SUMMARY.set({ + baseCurrencyBalance: data.baseCurrencyBalance, + totalCoinValue: data.totalCoinValue, + totalValue: data.totalValue, + currency: data.currency + }); return data; } } catch (error) { diff --git a/website/src/routes/api/portfolio/summary/+server.ts b/website/src/routes/api/portfolio/summary/+server.ts new file mode 100644 index 0000000..fc75d87 --- /dev/null +++ b/website/src/routes/api/portfolio/summary/+server.ts @@ -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: '$' + }); +} diff --git a/website/src/routes/api/portfolio/total/+server.ts b/website/src/routes/api/portfolio/total/+server.ts index 38170f1..bc77fd9 100644 --- a/website/src/routes/api/portfolio/total/+server.ts +++ b/website/src/routes/api/portfolio/total/+server.ts @@ -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` + 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); diff --git a/website/src/routes/coin/[coinSymbol]/+page.svelte b/website/src/routes/coin/[coinSymbol]/+page.svelte index 3eb3d85..d4a6b02 100644 --- a/website/src/routes/coin/[coinSymbol]/+page.svelte +++ b/website/src/routes/coin/[coinSymbol]/+page.svelte @@ -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()) { diff --git a/website/src/routes/gambling/+page.svelte b/website/src/routes/gambling/+page.svelte index 96183d1..ff8bf1b 100644 --- a/website/src/routes/gambling/+page.svelte +++ b/website/src/routes/gambling/+page.svelte @@ -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; } }); diff --git a/website/src/routes/hopium/+page.svelte b/website/src/routes/hopium/+page.svelte index dba89ed..0a11a61 100644 --- a/website/src/routes/hopium/+page.svelte +++ b/website/src/routes/hopium/+page.svelte @@ -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 }); } diff --git a/website/src/routes/hopium/[id]/+page.svelte b/website/src/routes/hopium/[id]/+page.svelte index e7a6273..7c97a04 100644 --- a/website/src/routes/hopium/[id]/+page.svelte +++ b/website/src/routes/hopium/[id]/+page.svelte @@ -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'); } diff --git a/website/src/routes/portfolio/+page.svelte b/website/src/routes/portfolio/+page.svelte index 56a4d4d..76ef9d1 100644 --- a/website/src/routes/portfolio/+page.svelte +++ b/website/src/routes/portfolio/+page.svelte @@ -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(null); let transactions = $state([]); let loading = $state(true); let error = $state(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()]); } @@ -295,11 +306,11 @@

- {formatValue(portfolioData.baseCurrencyBalance)} + {formatValue(portfolioData?.baseCurrencyBalance || 0)}

{totalPortfolioValue > 0 - ? `${((portfolioData.baseCurrencyBalance / totalPortfolioValue) * 100).toFixed(1)}% of portfolio` + ? `${(((portfolioData?.baseCurrencyBalance || 0) / totalPortfolioValue) * 100).toFixed(1)}% of portfolio` : '100% of portfolio'}

@@ -314,9 +325,9 @@ -

{formatValue(portfolioData.totalCoinValue)}

+

{formatValue(portfolioData?.totalCoinValue || 0)}

- {portfolioData.coinHoldings.length} positions + {portfolioData?.coinHoldings.length || 0} positions