From 6a57efc481bb8519b105f939beb549d60d645ddc Mon Sep 17 00:00:00 2001
From: Cho Young-Hwi
Date: Sat, 14 Mar 2026 20:22:11 +0000
Subject: [PATCH 1/5] [#29] Add trading stats to writer and reader dashboards
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Fixes #29
- P5-7a: WriterTradingStats component — shows total donations received,
per-story token supply (minting volume), tokens minted breakdown
- P5-7b: ReaderPortfolio component — queries balanceOf for all storyline
tokens, shows portfolio value (balance * price per token from
getReserveForToken), best-performing pick
- Replaces Phase 5 placeholder in reader dashboard
- All addresses from lib/contracts/constants.ts
Co-Authored-By: Claude Opus 4.6 (1M context)
---
src/app/dashboard/reader/page.tsx | 12 +-
src/app/dashboard/writer/page.tsx | 13 +++
src/components/ReaderPortfolio.tsx | 153 ++++++++++++++++++++++++++
src/components/WriterTradingStats.tsx | 111 +++++++++++++++++++
4 files changed, 281 insertions(+), 8 deletions(-)
create mode 100644 src/components/ReaderPortfolio.tsx
create mode 100644 src/components/WriterTradingStats.tsx
diff --git a/src/app/dashboard/reader/page.tsx b/src/app/dashboard/reader/page.tsx
index 61a0da82..56a17c9e 100644
--- a/src/app/dashboard/reader/page.tsx
+++ b/src/app/dashboard/reader/page.tsx
@@ -5,7 +5,8 @@ import { useAccount } from "wagmi";
import { useQuery } from "@tanstack/react-query";
import { supabase, type Donation } from "../../../../lib/supabase";
import { ConnectWallet } from "../../../components/ConnectWallet";
-import { formatUnits } from "viem";
+import { ReaderPortfolio } from "../../../components/ReaderPortfolio";
+import { formatUnits, type Address } from "viem";
const PAGE_SIZE = 50;
@@ -76,13 +77,8 @@ export default function ReaderDashboard() {
Reader Dashboard
- {/* --- Portfolio section (Phase 5) --- */}
-
- Portfolio
-
- Token holdings and portfolio value available after Phase 5 (P5-7b).
-
-
+ {/* --- Portfolio section --- */}
+
{/* --- Donation History --- */}
diff --git a/src/app/dashboard/writer/page.tsx b/src/app/dashboard/writer/page.tsx
index 9c334d10..f492e7f7 100644
--- a/src/app/dashboard/writer/page.tsx
+++ b/src/app/dashboard/writer/page.tsx
@@ -6,6 +6,7 @@ import { supabase, type Storyline } from "../../../../lib/supabase";
import { ConnectWallet } from "../../../components/ConnectWallet";
import { DeadlineCountdown } from "../../../components/DeadlineCountdown";
import { ClaimRoyalties } from "../../../components/ClaimRoyalties";
+import { WriterTradingStats } from "../../../components/WriterTradingStats";
import Link from "next/link";
import { type Address } from "viem";
@@ -62,6 +63,18 @@ export default function WriterDashboard() {
)}
+ {isConnected && address && storylines.length > 0 && (
+ s.token_address)
+ .map((s) => ({
+ storylineId: s.storyline_id,
+ tokenAddress: s.token_address as Address,
+ }))}
+ />
+ )}
+
{storylines.map((s) => (
diff --git a/src/components/ReaderPortfolio.tsx b/src/components/ReaderPortfolio.tsx
new file mode 100644
index 00000000..c1f99b6a
--- /dev/null
+++ b/src/components/ReaderPortfolio.tsx
@@ -0,0 +1,153 @@
+"use client";
+
+import { useQuery } from "@tanstack/react-query";
+import { formatUnits, type Address } from "viem";
+import { publicClient } from "../../lib/rpc";
+import { erc20Abi, mcv2BondAbi } from "../../lib/price";
+import { MCV2_BOND, IS_TESTNET } from "../../lib/contracts/constants";
+import { supabase, type Storyline } from "../../lib/supabase";
+
+interface ReaderPortfolioProps {
+ readerAddress: Address;
+}
+
+interface Holding {
+ storylineId: number;
+ title: string;
+ balance: bigint;
+ pricePerToken: bigint;
+ value: bigint;
+}
+
+export function ReaderPortfolio({ readerAddress }: ReaderPortfolioProps) {
+ const reserveLabel = IS_TESTNET ? "WETH" : "$PLOT";
+
+ const { data: holdings, isLoading } = useQuery({
+ queryKey: ["reader-portfolio", readerAddress],
+ queryFn: async () => {
+ if (!supabase) return [];
+
+ // Fetch all storylines with token addresses
+ const { data: storylines } = await supabase
+ .from("storylines")
+ .select("*")
+ .eq("hidden", false)
+ .returns
();
+
+ if (!storylines) return [];
+
+ const withTokens = storylines.filter((s) => s.token_address);
+ const results: Holding[] = [];
+
+ // Check balance for each token
+ for (const s of withTokens) {
+ try {
+ const balance = await publicClient.readContract({
+ address: s.token_address as Address,
+ abi: erc20Abi,
+ functionName: "balanceOf",
+ args: [readerAddress],
+ });
+
+ if (balance > BigInt(0)) {
+ // Get current price for value calculation
+ let pricePerToken = BigInt(0);
+ try {
+ const oneToken = BigInt(10 ** 18);
+ pricePerToken = await publicClient.readContract({
+ address: MCV2_BOND,
+ abi: mcv2BondAbi,
+ functionName: "getReserveForToken",
+ args: [s.token_address as Address, oneToken],
+ });
+ } catch {
+ // Price unavailable
+ }
+
+ const value =
+ pricePerToken > BigInt(0)
+ ? (balance * pricePerToken) / BigInt(10 ** 18)
+ : BigInt(0);
+
+ results.push({
+ storylineId: s.storyline_id,
+ title: s.title,
+ balance,
+ pricePerToken,
+ value,
+ });
+ }
+ } catch {
+ // Skip tokens that fail
+ }
+ }
+
+ return results;
+ },
+ });
+
+ const allHoldings = holdings ?? [];
+ const totalValue = allHoldings.reduce((sum, h) => sum + h.value, BigInt(0));
+ const bestPick =
+ allHoldings.length > 0
+ ? allHoldings.reduce((best, h) => (h.value > best.value ? h : best))
+ : null;
+
+ return (
+
+ Portfolio
+
+ {isLoading && (
+ Loading holdings...
+ )}
+
+ {!isLoading && allHoldings.length === 0 && (
+
+ No token holdings found.
+
+ )}
+
+ {allHoldings.length > 0 && (
+ <>
+
+
+
+ Portfolio Value
+
+
+ {formatUnits(totalValue, 18)} {reserveLabel}
+
+
+ {bestPick && (
+
+
+ Best Pick
+
+ {bestPick.title}
+
+ )}
+
+
+
+ {allHoldings.map((h) => (
+
+ {h.title}
+
+ {formatUnits(h.balance, 18)} tokens
+ {h.value > BigInt(0) && (
+
+ ({formatUnits(h.value, 18)} {reserveLabel})
+
+ )}
+
+
+ ))}
+
+ >
+ )}
+
+ );
+}
diff --git a/src/components/WriterTradingStats.tsx b/src/components/WriterTradingStats.tsx
new file mode 100644
index 00000000..dbcb34bb
--- /dev/null
+++ b/src/components/WriterTradingStats.tsx
@@ -0,0 +1,111 @@
+"use client";
+
+import { useQuery } from "@tanstack/react-query";
+import { formatUnits, type Address } from "viem";
+import { publicClient } from "../../lib/rpc";
+import { erc20Abi } from "../../lib/price";
+import { IS_TESTNET } from "../../lib/contracts/constants";
+import { supabase, type Donation } from "../../lib/supabase";
+
+interface WriterTradingStatsProps {
+ writerAddress: Address;
+ storylineTokens: { storylineId: number; tokenAddress: Address }[];
+}
+
+interface StoryStats {
+ storylineId: number;
+ totalSupply: bigint;
+}
+
+export function WriterTradingStats({
+ writerAddress,
+ storylineTokens,
+}: WriterTradingStatsProps) {
+ const reserveLabel = IS_TESTNET ? "WETH" : "$PLOT";
+
+ // Fetch total donations received
+ const { data: totalDonations } = useQuery({
+ queryKey: ["writer-total-donations", writerAddress],
+ queryFn: async () => {
+ if (!supabase) return BigInt(0);
+ // Query donations for storylines owned by this writer
+ const { data } = await supabase
+ .from("donations")
+ .select("amount")
+ .returns[]>();
+ if (!data) return BigInt(0);
+ return data.reduce((sum, d) => sum + BigInt(d.amount), BigInt(0));
+ },
+ });
+
+ // Fetch per-story trading volume (totalSupply) and holder count
+ const { data: storyStats } = useQuery({
+ queryKey: ["writer-story-stats", storylineTokens.map((t) => t.tokenAddress)],
+ queryFn: async () => {
+ const results: StoryStats[] = [];
+ for (const t of storylineTokens) {
+ try {
+ const supply = await publicClient.readContract({
+ address: t.tokenAddress,
+ abi: erc20Abi,
+ functionName: "totalSupply",
+ });
+ results.push({ storylineId: t.storylineId, totalSupply: supply });
+ } catch {
+ results.push({ storylineId: t.storylineId, totalSupply: BigInt(0) });
+ }
+ }
+ return results;
+ },
+ enabled: storylineTokens.length > 0,
+ });
+
+ if (storylineTokens.length === 0) return null;
+
+ const totalVolume = (storyStats ?? []).reduce(
+ (sum, s) => sum + s.totalSupply,
+ BigInt(0),
+ );
+
+ return (
+
+ Trading Stats
+
+
+
+ Total Donations
+
+
+ {formatUnits(totalDonations ?? BigInt(0), 18)} {reserveLabel}
+
+
+
+
+ Total Tokens Minted
+
+
+ {formatUnits(totalVolume, 18)}
+
+
+
+
+ {storyStats && storyStats.length > 0 && (
+
+ {storyStats
+ .filter((s) => s.totalSupply > BigInt(0))
+ .map((s) => (
+
+ Story #{s.storylineId}
+
+ {formatUnits(s.totalSupply, 18)} tokens minted
+
+
+ ))}
+
+ )}
+
+ );
+}
From 9f0c0fa8b2876ca101739fe32e4bf77120a0d1a4 Mon Sep 17 00:00:00 2001
From: Cho Young-Hwi
Date: Sat, 14 Mar 2026 20:24:39 +0000
Subject: [PATCH 2/5] [#29] Fix donations scope and add holder count
- Scope donations query to writer's storyline IDs only
- Add unique holder count per storyline (unique donors as proxy)
- Display total holders in summary and per-story breakdown
Co-Authored-By: Claude Opus 4.6 (1M context)
---
src/components/WriterTradingStats.tsx | 62 +++++++++++++++++++++++----
1 file changed, 54 insertions(+), 8 deletions(-)
diff --git a/src/components/WriterTradingStats.tsx b/src/components/WriterTradingStats.tsx
index dbcb34bb..9b374b3d 100644
--- a/src/components/WriterTradingStats.tsx
+++ b/src/components/WriterTradingStats.tsx
@@ -15,6 +15,7 @@ interface WriterTradingStatsProps {
interface StoryStats {
storylineId: number;
totalSupply: bigint;
+ holderCount: number;
}
export function WriterTradingStats({
@@ -23,25 +24,49 @@ export function WriterTradingStats({
}: WriterTradingStatsProps) {
const reserveLabel = IS_TESTNET ? "WETH" : "$PLOT";
- // Fetch total donations received
+ const storyIds = storylineTokens.map((t) => t.storylineId);
+
+ // Fetch total donations received for this writer's storylines only
const { data: totalDonations } = useQuery({
- queryKey: ["writer-total-donations", writerAddress],
+ queryKey: ["writer-total-donations", writerAddress, storyIds],
queryFn: async () => {
- if (!supabase) return BigInt(0);
- // Query donations for storylines owned by this writer
+ if (!supabase || storyIds.length === 0) return BigInt(0);
const { data } = await supabase
.from("donations")
.select("amount")
+ .in("storyline_id", storyIds)
.returns[]>();
if (!data) return BigInt(0);
return data.reduce((sum, d) => sum + BigInt(d.amount), BigInt(0));
},
+ enabled: storyIds.length > 0,
});
// Fetch per-story trading volume (totalSupply) and holder count
const { data: storyStats } = useQuery({
queryKey: ["writer-story-stats", storylineTokens.map((t) => t.tokenAddress)],
queryFn: async () => {
+ // Get unique donor counts per storyline as holder proxy
+ const donorCounts = new Map();
+ if (supabase && storyIds.length > 0) {
+ const { data: donors } = await supabase
+ .from("donations")
+ .select("storyline_id, donor_address")
+ .in("storyline_id", storyIds)
+ .returns[]>();
+ if (donors) {
+ const perStory = new Map>();
+ for (const d of donors) {
+ const set = perStory.get(d.storyline_id) ?? new Set();
+ set.add(d.donor_address);
+ perStory.set(d.storyline_id, set);
+ }
+ for (const [id, set] of perStory) {
+ donorCounts.set(id, set.size);
+ }
+ }
+ }
+
const results: StoryStats[] = [];
for (const t of storylineTokens) {
try {
@@ -50,9 +75,17 @@ export function WriterTradingStats({
abi: erc20Abi,
functionName: "totalSupply",
});
- results.push({ storylineId: t.storylineId, totalSupply: supply });
+ results.push({
+ storylineId: t.storylineId,
+ totalSupply: supply,
+ holderCount: donorCounts.get(t.storylineId) ?? 0,
+ });
} catch {
- results.push({ storylineId: t.storylineId, totalSupply: BigInt(0) });
+ results.push({
+ storylineId: t.storylineId,
+ totalSupply: BigInt(0),
+ holderCount: donorCounts.get(t.storylineId) ?? 0,
+ });
}
}
return results;
@@ -70,7 +103,7 @@ export function WriterTradingStats({
return (
Trading Stats
-
+
Total Donations
@@ -87,6 +120,14 @@ export function WriterTradingStats({
{formatUnits(totalVolume, 18)}
+
+
+ Unique Holders
+
+
+ {(storyStats ?? []).reduce((sum, s) => sum + s.holderCount, 0)}
+
+
{storyStats && storyStats.length > 0 && (
@@ -100,7 +141,12 @@ export function WriterTradingStats({
>
Story #{s.storylineId}
- {formatUnits(s.totalSupply, 18)} tokens minted
+ {formatUnits(s.totalSupply, 18)} minted
+ {s.holderCount > 0 && (
+
+ · {s.holderCount} holders
+
+ )}
))}
From 57416f8060463d6ca937bdecd25d13e5d042f998 Mon Sep 17 00:00:00 2001
From: Cho Young-Hwi
Date: Sat, 14 Mar 2026 20:26:02 +0000
Subject: [PATCH 3/5] [#29] Add royalties to total earned metric
Total Earned now sums royalties (from MCV2_Bond.getRoyaltyInfo) +
donations, with a breakdown showing both components.
Co-Authored-By: Claude Opus 4.6 (1M context)
---
src/components/WriterTradingStats.tsx | 37 ++++++++++++++++++++++++---
1 file changed, 33 insertions(+), 4 deletions(-)
diff --git a/src/components/WriterTradingStats.tsx b/src/components/WriterTradingStats.tsx
index 9b374b3d..1846d5c8 100644
--- a/src/components/WriterTradingStats.tsx
+++ b/src/components/WriterTradingStats.tsx
@@ -3,8 +3,8 @@
import { useQuery } from "@tanstack/react-query";
import { formatUnits, type Address } from "viem";
import { publicClient } from "../../lib/rpc";
-import { erc20Abi } from "../../lib/price";
-import { IS_TESTNET } from "../../lib/contracts/constants";
+import { erc20Abi, mcv2BondAbi } from "../../lib/price";
+import { MCV2_BOND, IS_TESTNET } from "../../lib/contracts/constants";
import { supabase, type Donation } from "../../lib/supabase";
interface WriterTradingStatsProps {
@@ -93,8 +93,34 @@ export function WriterTradingStats({
enabled: storylineTokens.length > 0,
});
+ // Fetch unclaimed royalties across all storylines
+ const { data: totalRoyalties } = useQuery({
+ queryKey: ["writer-total-royalties", storylineTokens.map((t) => t.tokenAddress)],
+ queryFn: async () => {
+ let total = BigInt(0);
+ for (const t of storylineTokens) {
+ try {
+ const result = await publicClient.readContract({
+ address: MCV2_BOND,
+ abi: mcv2BondAbi,
+ functionName: "getRoyaltyInfo",
+ args: [t.tokenAddress],
+ });
+ total += result[0];
+ } catch {
+ // Skip on error
+ }
+ }
+ return total;
+ },
+ enabled: storylineTokens.length > 0,
+ });
+
if (storylineTokens.length === 0) return null;
+ const totalEarned =
+ (totalDonations ?? BigInt(0)) + (totalRoyalties ?? BigInt(0));
+
const totalVolume = (storyStats ?? []).reduce(
(sum, s) => sum + s.totalSupply,
BigInt(0),
@@ -106,10 +132,13 @@ export function WriterTradingStats({
- Total Donations
+ Total Earned
- {formatUnits(totalDonations ?? BigInt(0), 18)} {reserveLabel}
+ {formatUnits(totalEarned, 18)} {reserveLabel}
+
+
+ {formatUnits(totalRoyalties ?? BigInt(0), 18)} royalties + {formatUnits(totalDonations ?? BigInt(0), 18)} donations
From e813c3e28d4f24de514dbf1e82ed213e33bba836 Mon Sep 17 00:00:00 2001
From: Cho Young-Hwi
Date: Sat, 14 Mar 2026 20:48:55 +0000
Subject: [PATCH 4/5] [#29] Split into Donations Received and Unclaimed
Royalties
Replaces misleading "Total Earned" (which understated after claims)
with two separate metrics: Donations Received (lifetime from DB) and
Unclaimed Royalties (current on-chain balance from getRoyaltyInfo).
Co-Authored-By: Claude Opus 4.6 (1M context)
---
src/components/WriterTradingStats.tsx | 18 ++++++++++--------
1 file changed, 10 insertions(+), 8 deletions(-)
diff --git a/src/components/WriterTradingStats.tsx b/src/components/WriterTradingStats.tsx
index 1846d5c8..b90fa311 100644
--- a/src/components/WriterTradingStats.tsx
+++ b/src/components/WriterTradingStats.tsx
@@ -118,9 +118,6 @@ export function WriterTradingStats({
if (storylineTokens.length === 0) return null;
- const totalEarned =
- (totalDonations ?? BigInt(0)) + (totalRoyalties ?? BigInt(0));
-
const totalVolume = (storyStats ?? []).reduce(
(sum, s) => sum + s.totalSupply,
BigInt(0),
@@ -129,16 +126,21 @@ export function WriterTradingStats({
return (
Trading Stats
-
+
- Total Earned
+ Donations Received
- {formatUnits(totalEarned, 18)} {reserveLabel}
+ {formatUnits(totalDonations ?? BigInt(0), 18)} {reserveLabel}
+
+
+
+
+ Unclaimed Royalties
-
- {formatUnits(totalRoyalties ?? BigInt(0), 18)} royalties + {formatUnits(totalDonations ?? BigInt(0), 18)} donations
+
+ {formatUnits(totalRoyalties ?? BigInt(0), 18)} {reserveLabel}
From 1327464a88bea36664afca8b2786d3cdc923b174 Mon Sep 17 00:00:00 2001
From: Cho Young-Hwi
Date: Sat, 14 Mar 2026 20:50:06 +0000
Subject: [PATCH 5/5] [#29] Explicitly defer lifetime total earned metric
Adds notice that lifetime total earned requires a royalty-claim
indexer. Shows available metrics (donations, unclaimed royalties)
without a misleading combined total.
Co-Authored-By: Claude Opus 4.6 (1M context)
---
src/components/WriterTradingStats.tsx | 3 +++
1 file changed, 3 insertions(+)
diff --git a/src/components/WriterTradingStats.tsx b/src/components/WriterTradingStats.tsx
index b90fa311..7a834902 100644
--- a/src/components/WriterTradingStats.tsx
+++ b/src/components/WriterTradingStats.tsx
@@ -126,6 +126,9 @@ export function WriterTradingStats({
return (
Trading Stats
+
+ Lifetime total earned requires a royalty-claim indexer — showing available metrics.
+