From 60da109b96f25eae74921bfbcea449fd96790e48 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Tue, 24 Mar 2026 19:18:45 +0000 Subject: [PATCH 1/4] [#503] Enhance portfolio tab with token holdings, values, and donations - Query on-chain token balances via balanceOf multicall for all storyline tokens, filtered to non-zero holdings - Per-token metrics: story title, token balance, current value (from priceForNextMint), entry price (from first mint in trade_history), last traded date, genre - Total portfolio value in $PLOT equivalent - Sorted by most recently traded, then largest position - Donation history: donations given as reader with totals - Enhanced empty state for addresses with no holdings Fixes #503 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/profile/[address]/page.tsx | 285 +++++++++++++++++++++++++---- 1 file changed, 246 insertions(+), 39 deletions(-) diff --git a/src/app/profile/[address]/page.tsx b/src/app/profile/[address]/page.tsx index 88f5f7ad..4df63f25 100644 --- a/src/app/profile/[address]/page.tsx +++ b/src/app/profile/[address]/page.tsx @@ -542,64 +542,271 @@ function StoryRow({ storyline }: { storyline: Storyline }) { // Portfolio Tab // --------------------------------------------------------------------------- +interface PortfolioHolding { + storyline: Storyline; + balance: bigint; + price: bigint; + value: bigint; + entryPrice: number | null; + lastTraded: string | null; +} + function PortfolioTab({ address }: { address: string }) { - const { data: trades = [], isLoading, error } = useQuery({ - queryKey: ["profile-portfolio", address], + // Fetch on-chain token holdings + const { data: holdings, isLoading: holdingsLoading } = useQuery({ + queryKey: ["profile-holdings", address], + queryFn: async (): Promise => { + if (!supabase) return []; + + // Get all storylines with tokens + const { data: storylines } = await supabase + .from("storylines") + .select("*") + .eq("hidden", false) + .neq("token_address", "") + .eq("contract_address", STORY_FACTORY.toLowerCase()) + .returns(); + if (!storylines || storylines.length === 0) return []; + + // Multicall balanceOf for all tokens + const balanceResults = await browserClient.multicall({ + contracts: storylines.map((sl) => ({ + address: sl.token_address as Address, + abi: erc20Abi, + functionName: "balanceOf" as const, + args: [address as Address], + })), + allowFailure: true, + }); + + const held = storylines + .map((sl, i) => ({ sl, balance: balanceResults[i] })) + .filter((h) => h.balance.status === "success" && (h.balance.result as bigint) > BigInt(0)); + if (held.length === 0) return []; + + // Fetch prices for held tokens + const results = await Promise.all( + held.map(async ({ sl, balance: balResult }): Promise => { + const balance = balResult.result as bigint; + try { + const price = await browserClient.readContract({ + address: MCV2_BOND, + abi: mcv2BondAbi, + functionName: "priceForNextMint", + args: [sl.token_address as Address], + }); + const priceBI = BigInt(price); + const value = (balance * priceBI) / BigInt(10 ** 18); + + // Derive entry price from first mint in trade_history + let entryPrice: number | null = null; + let lastTraded: string | null = null; + if (supabase) { + const { data: firstMint } = await supabase + .from("trade_history") + .select("price_per_token, block_timestamp") + .eq("user_address", address) + .eq("storyline_id", sl.storyline_id) + .eq("event_type", "mint") + .eq("contract_address", STORY_FACTORY.toLowerCase()) + .order("block_timestamp", { ascending: true }) + .limit(1); + if (firstMint && firstMint.length > 0) { + entryPrice = firstMint[0].price_per_token; + } + const { data: lastTrade } = await supabase + .from("trade_history") + .select("block_timestamp") + .eq("user_address", address) + .eq("storyline_id", sl.storyline_id) + .eq("contract_address", STORY_FACTORY.toLowerCase()) + .order("block_timestamp", { ascending: false }) + .limit(1); + if (lastTrade && lastTrade.length > 0) { + lastTraded = lastTrade[0].block_timestamp; + } + } + + return { storyline: sl, balance, price: priceBI, value, entryPrice, lastTraded }; + } catch { + return null; + } + }), + ); + + // Sort by most recently traded, then largest value + return results + .filter((h): h is PortfolioHolding => h !== null) + .sort((a, b) => { + if (a.lastTraded && b.lastTraded) return b.lastTraded.localeCompare(a.lastTraded); + if (a.lastTraded) return -1; + if (b.lastTraded) return 1; + return Number(b.value - a.value); + }); + }, + staleTime: 60000, + }); + + // Donation history (given as reader) + const { data: donationsGiven = [], isLoading: donGivenLoading } = useQuery({ + queryKey: ["profile-donations-given", address], queryFn: async () => { if (!supabase) return []; - const { data, error } = await supabase - .from("trade_history") + const { data } = await supabase + .from("donations") .select("*") - .eq("user_address", address) + .eq("donor_address", address) .eq("contract_address", STORY_FACTORY.toLowerCase()) .order("block_timestamp", { ascending: false }) - .limit(50) - .returns(); - if (error) throw error; + .limit(20) + .returns(); return data ?? []; }, }); + const isLoading = holdingsLoading || donGivenLoading; + if (isLoading) return

Loading...

; - if (error) return

Failed to load portfolio.

; - if (trades.length === 0) { - return

No trading activity yet.

; - } - // Group trades by storyline to show net position - const positions = new Map(); - for (const t of trades) { - const pos = positions.get(t.storyline_id) ?? { storylineId: t.storyline_id, mints: 0, burns: 0, lastTrade: t.block_timestamp }; - if (t.event_type === "mint") pos.mints++; - else if (t.event_type === "burn") pos.burns++; - positions.set(t.storyline_id, pos); + const hasHoldings = holdings && holdings.length > 0; + const hasDonations = donationsGiven.length > 0; + + if (!hasHoldings && !hasDonations) { + return ( +
+

No holdings or donations yet.

+

+ This address hasn't purchased any storyline tokens or made donations. +

+
+ ); } + const totalValue = holdings?.reduce((sum, h) => sum + h.value, BigInt(0)) ?? BigInt(0); + const totalDonated = donationsGiven.reduce((sum, d) => sum + BigInt(d.amount), BigInt(0)); + return ( -
-

Trading Activity

- {Array.from(positions.values()).map((pos) => ( -
-
- - Story #{pos.storylineId} - - - {new Date(pos.lastTrade).toLocaleDateString("en-US", { - month: "short", - day: "numeric", - })} +
+ {/* Portfolio summary */} + {hasHoldings && ( + <> +
+

Portfolio Value

+ + {formatPrice(formatUnits(totalValue, 18))} {RESERVE_LABEL} + + + across {holdings!.length} {holdings!.length === 1 ? "token" : "tokens"}
-
- {pos.mints} mint{pos.mints !== 1 ? "s" : ""} - {pos.burns} burn{pos.burns !== 1 ? "s" : ""} + + {/* Token holdings */} +
+

Token Holdings

+ {holdings!.map((h) => ( +
+
+
+ + {h.storyline.title} + + {h.storyline.genre && ( + + {h.storyline.genre} + + )} +
+ + {formatPrice(formatUnits(h.value, 18))} {RESERVE_LABEL} + +
+
+ + Balance: {formatPrice(formatUnits(h.balance, 18))} tokens + + + Price: {formatPrice(formatUnits(h.price, 18))} {RESERVE_LABEL} + + {h.entryPrice !== null && h.entryPrice > 0 && ( + + Entry: {formatPrice(h.entryPrice)} {RESERVE_LABEL} + + )} + {h.lastTraded && ( + + Last traded:{" "} + + {new Date(h.lastTraded).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + })} + + + )} +
+
+ ))} +
+ + )} + + {/* Donations given */} + {hasDonations && ( +
+

+ Donations Given + {totalDonated > BigInt(0) && ( + + {formatPrice(formatUnits(totalDonated, 18))} {RESERVE_LABEL} total + + )} +

+
+ {donationsGiven.map((d) => ( +
+
+ + Story #{d.storyline_id} + + {d.block_timestamp && ( + + )} +
+
+ + {formatPrice(formatUnits(BigInt(d.amount), 18))} {RESERVE_LABEL} + + {d.tx_hash && ( + + ↗ + + )} +
+
+ ))}
- ))} + )}
); } From 31924f06336f2e8ba076b2213c726aabeb22ff8f Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Tue, 24 Mar 2026 19:20:56 +0000 Subject: [PATCH 2/4] [#503] Scope portfolio multicall to traded storylines only Instead of fetching all storylines and multicalling balanceOf for every token, first query trade_history for storylines the user has actually traded, then scope the balanceOf multicall to only those tokens. This keeps the query bounded as storyline count grows. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/profile/[address]/page.tsx | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/app/profile/[address]/page.tsx b/src/app/profile/[address]/page.tsx index 4df63f25..d212f68c 100644 --- a/src/app/profile/[address]/page.tsx +++ b/src/app/profile/[address]/page.tsx @@ -558,17 +558,28 @@ function PortfolioTab({ address }: { address: string }) { queryFn: async (): Promise => { if (!supabase) return []; - // Get all storylines with tokens + // Get storyline IDs the user has actually traded (bounded) + const { data: tradedRows } = await supabase + .from("trade_history") + .select("storyline_id") + .eq("user_address", address) + .eq("contract_address", STORY_FACTORY.toLowerCase()); + if (!tradedRows || tradedRows.length === 0) return []; + + const tradedIds = [...new Set(tradedRows.map((r) => r.storyline_id))]; + + // Fetch only storylines the user has traded const { data: storylines } = await supabase .from("storylines") .select("*") + .in("storyline_id", tradedIds) .eq("hidden", false) .neq("token_address", "") .eq("contract_address", STORY_FACTORY.toLowerCase()) .returns(); if (!storylines || storylines.length === 0) return []; - // Multicall balanceOf for all tokens + // Multicall balanceOf only for traded tokens (bounded) const balanceResults = await browserClient.multicall({ contracts: storylines.map((sl) => ({ address: sl.token_address as Address, From 78c530b8b476e161ff444f32f7b8e1be9f66817b Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Tue, 24 Mar 2026 19:30:02 +0000 Subject: [PATCH 3/4] [#503] Add aggregate donations received as writer to portfolio tab - Query storylines written by address, then sum donations received across all of them - Show "Donations Received" summary card with total amount and count - Empty state now considers donations received (writer who received donations but has no holdings no longer hits empty state) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/profile/[address]/page.tsx | 52 +++++++++++++++++++++++++++--- 1 file changed, 47 insertions(+), 5 deletions(-) diff --git a/src/app/profile/[address]/page.tsx b/src/app/profile/[address]/page.tsx index d212f68c..cbcdad04 100644 --- a/src/app/profile/[address]/page.tsx +++ b/src/app/profile/[address]/page.tsx @@ -675,14 +675,43 @@ function PortfolioTab({ address }: { address: string }) { }, }); - const isLoading = holdingsLoading || donGivenLoading; + // Aggregate donations received as writer + const { data: donationsReceived, isLoading: donRecvLoading } = useQuery({ + queryKey: ["profile-donations-received-portfolio", address], + queryFn: async () => { + if (!supabase) return { total: BigInt(0), count: 0 }; + // Get storylines written by this address + const { data: writerStorylines } = await supabase + .from("storylines") + .select("storyline_id") + .eq("writer_address", address) + .eq("hidden", false) + .eq("contract_address", STORY_FACTORY.toLowerCase()); + if (!writerStorylines || writerStorylines.length === 0) { + return { total: BigInt(0), count: 0 }; + } + const sids = writerStorylines.map((s) => s.storyline_id); + const { data: donations } = await supabase + .from("donations") + .select("amount") + .in("storyline_id", sids) + .eq("contract_address", STORY_FACTORY.toLowerCase()); + if (!donations || donations.length === 0) return { total: BigInt(0), count: 0 }; + const total = donations.reduce((sum, d) => sum + BigInt(d.amount), BigInt(0)); + return { total, count: donations.length }; + }, + }); + + const isLoading = holdingsLoading || donGivenLoading || donRecvLoading; if (isLoading) return

Loading...

; const hasHoldings = holdings && holdings.length > 0; - const hasDonations = donationsGiven.length > 0; + const hasDonationsGiven = donationsGiven.length > 0; + const hasDonationsReceived = donationsReceived && donationsReceived.count > 0; + const hasAny = hasHoldings || hasDonationsGiven || hasDonationsReceived; - if (!hasHoldings && !hasDonations) { + if (!hasAny) { return (

No holdings or donations yet.

@@ -767,8 +796,21 @@ function PortfolioTab({ address }: { address: string }) { )} - {/* Donations given */} - {hasDonations && ( + {/* Donations received as writer */} + {hasDonationsReceived && ( +
+

Donations Received

+ + {formatPrice(formatUnits(donationsReceived!.total, 18))} {RESERVE_LABEL} + + + from {donationsReceived!.count} {donationsReceived!.count === 1 ? "donation" : "donations"} + +
+ )} + + {/* Donations given as reader */} + {hasDonationsGiven && (

Donations Given From c486601ac0db8ad84cf10422e3a9705262b07bc2 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Tue, 24 Mar 2026 19:31:57 +0000 Subject: [PATCH 4/4] [#503] Scan all storylines for holdings, not just traded ones Revert to scanning all non-hidden storylines with tokens (matching the existing ReaderPortfolio pattern) to catch holdings acquired via direct transfers or other paths outside the indexed trade history. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/profile/[address]/page.tsx | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/src/app/profile/[address]/page.tsx b/src/app/profile/[address]/page.tsx index cbcdad04..64c009ee 100644 --- a/src/app/profile/[address]/page.tsx +++ b/src/app/profile/[address]/page.tsx @@ -558,28 +558,18 @@ function PortfolioTab({ address }: { address: string }) { queryFn: async (): Promise => { if (!supabase) return []; - // Get storyline IDs the user has actually traded (bounded) - const { data: tradedRows } = await supabase - .from("trade_history") - .select("storyline_id") - .eq("user_address", address) - .eq("contract_address", STORY_FACTORY.toLowerCase()); - if (!tradedRows || tradedRows.length === 0) return []; - - const tradedIds = [...new Set(tradedRows.map((r) => r.storyline_id))]; - - // Fetch only storylines the user has traded + // Scan all storylines with tokens (matches ReaderPortfolio pattern) + // to catch holdings acquired via direct transfers, not just indexed trades const { data: storylines } = await supabase .from("storylines") .select("*") - .in("storyline_id", tradedIds) .eq("hidden", false) .neq("token_address", "") .eq("contract_address", STORY_FACTORY.toLowerCase()) .returns(); if (!storylines || storylines.length === 0) return []; - // Multicall balanceOf only for traded tokens (bounded) + // Multicall balanceOf for all storyline tokens const balanceResults = await browserClient.multicall({ contracts: storylines.map((sl) => ({ address: sl.token_address as Address,