diff --git a/src/app/story/[storylineId]/page.tsx b/src/app/story/[storylineId]/page.tsx index e508ef50..07e41c25 100644 --- a/src/app/story/[storylineId]/page.tsx +++ b/src/app/story/[storylineId]/page.tsx @@ -172,7 +172,6 @@ export default async function StoryPage({ params }: { params: Params }) { {sl.token_address && priceInfo && ( )} @@ -195,7 +194,6 @@ export default async function StoryPage({ params }: { params: Params }) { {priceInfo && ( )} diff --git a/src/components/PriceChart.tsx b/src/components/PriceChart.tsx index f9f6c71a..a94a3bf2 100644 --- a/src/components/PriceChart.tsx +++ b/src/components/PriceChart.tsx @@ -1,120 +1,134 @@ "use client"; import { useQuery } from "@tanstack/react-query"; -import { type Address, parseUnits, formatUnits } from "viem"; -import { publicClient } from "../../lib/rpc"; -import { mcv2BondAbi } from "../../lib/price"; -import { MCV2_BOND, IS_TESTNET } from "../../lib/contracts/constants"; +import { type Address, formatUnits } from "viem"; +import { supabase } from "../../lib/supabase"; +import { IS_TESTNET } from "../../lib/contracts/constants"; const CHART_W = 320; const CHART_H = 140; const PAD = { top: 10, right: 10, bottom: 24, left: 48 }; const PLOT_W = CHART_W - PAD.left - PAD.right; const PLOT_H = CHART_H - PAD.top - PAD.bottom; -const NUM_POINTS = 20; +const MAX_POINTS = 50; interface PriceChartProps { tokenAddress: Address; - totalSupplyRaw: bigint; currentPriceRaw: bigint; } -/** - * Lightweight bonding curve chart. - * - * Samples getReserveForToken at evenly spaced supply points to plot - * the price curve, then marks the current supply position. - */ -export function PriceChart({ tokenAddress, totalSupplyRaw, currentPriceRaw }: PriceChartProps) { +function formatTime(iso: string): string { + const d = new Date(iso); + const now = new Date(); + const diffMs = now.getTime() - d.getTime(); + const diffDays = diffMs / (1000 * 60 * 60 * 24); + if (diffDays < 1) { + return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); + } + return d.toLocaleDateString([], { month: "short", day: "numeric" }); +} + +function formatPrice(v: number): string { + if (v === 0) return "0"; + if (v < 0.001) return v.toExponential(0); + if (v < 1) return v.toFixed(4); + return v.toFixed(2); +} + +export function PriceChart({ tokenAddress, currentPriceRaw }: PriceChartProps) { const reserveLabel = IS_TESTNET ? "WETH" : "$PLOT"; + const currentPrice = Number(formatUnits(currentPriceRaw, 18)); - // Sample the bonding curve at multiple supply points - const { data: curvePoints } = useQuery({ - queryKey: ["price-curve", tokenAddress], + const { data: tradePoints } = useQuery({ + queryKey: ["price-history", tokenAddress], queryFn: async () => { - // Sample from 1 token to 2x current supply (or a minimum of 100 tokens) - const minMax = parseUnits("100", 18); - const maxSupply = - totalSupplyRaw * BigInt(2) > minMax - ? totalSupplyRaw * BigInt(2) - : minMax; - - const points: { supply: number; price: number }[] = []; - const step = maxSupply / BigInt(NUM_POINTS); - if (step === BigInt(0)) return []; - - // Query cumulative costs for increasing mint amounts from current supply - const promises: Promise[] = []; - for (let i = 1; i <= NUM_POINTS; i++) { - const amount = step * BigInt(i); - promises.push( - publicClient - .readContract({ - address: MCV2_BOND, - abi: mcv2BondAbi, - functionName: "getReserveForToken", - args: [tokenAddress, amount], - }) - .then((r) => (r as readonly [bigint, bigint])[0]) - .catch(() => BigInt(0)), - ); - } - - const cumulativeCosts = await Promise.all(promises); - - // Start with the actual current supply/price point - const currentSupplyNum = Number(formatUnits(totalSupplyRaw, 18)); - const currentPriceNum = Number(formatUnits(currentPriceRaw, 18)); - points.push({ supply: currentSupplyNum, price: currentPriceNum }); - - // Compute marginal price at each future supply step - let prevCost = BigInt(0); - for (let i = 0; i < cumulativeCosts.length; i++) { - const amount = step * BigInt(i + 1); - const marginalCost = cumulativeCosts[i] - prevCost; - const pricePerToken = - Number(formatUnits(marginalCost, 18)) / - Number(formatUnits(step, 18)); - points.push({ - supply: currentSupplyNum + Number(formatUnits(amount, 18)), - price: pricePerToken, - }); - prevCost = cumulativeCosts[i]; + if (!supabase) return []; + const { data } = await supabase + .from("trade_history") + .select("price_per_token, block_timestamp") + .eq("token_address", tokenAddress.toLowerCase()) + .order("block_timestamp", { ascending: true }); + if (!data || data.length === 0) return []; + + // Downsample if too many points + if (data.length <= MAX_POINTS) return data; + const step = (data.length - 1) / (MAX_POINTS - 1); + const sampled = []; + for (let i = 0; i < MAX_POINTS; i++) { + sampled.push(data[Math.round(i * step)]); } - return points; + return sampled; }, - staleTime: 60000, + staleTime: 30000, + refetchInterval: 30000, }); - if (!curvePoints || curvePoints.length === 0) return null; - - // Scale to chart coords - const maxX = Math.max(...curvePoints.map((p) => p.supply)); - const maxY = Math.max(...curvePoints.map((p) => p.price)); - if (maxX === 0 || maxY === 0) return null; - - const scaleX = (v: number) => PAD.left + (v / maxX) * PLOT_W; - const scaleY = (v: number) => PAD.top + PLOT_H - (v / maxY) * PLOT_H; - - // Build SVG polyline - const linePoints = curvePoints - .map((p) => `${scaleX(p.supply)},${scaleY(p.price)}`) + const hasData = tradePoints && tradePoints.length > 0; + + // Empty state + if (!hasData) { + return ( +
+

Price

+
+ + + + + + + +

No trading activity yet

+ {currentPrice > 0 && ( +

+ {formatPrice(currentPrice)} {reserveLabel} +

+ )} +
+
+ ); + } + + // Build points array + const points = tradePoints.map((t) => ({ + time: t.block_timestamp, + price: Number(t.price_per_token), + })); + + // Scale + const prices = points.map((p) => p.price); + const minY = Math.min(...prices); + const maxY = Math.max(...prices); + const yRange = maxY - minY || maxY || 1; + const yPad = yRange * 0.1; + + const scaleX = (i: number) => + PAD.left + (i / (points.length - 1 || 1)) * PLOT_W; + const scaleY = (v: number) => + PAD.top + PLOT_H - ((v - (minY - yPad)) / (yRange + yPad * 2)) * PLOT_H; + + const linePoints = points + .map((p, i) => `${scaleX(i)},${scaleY(p.price)}`) .join(" "); - // Current supply marker — uses actual current supply and price - const currentSupply = Number(formatUnits(totalSupplyRaw, 18)); - const currentPrice = Number(formatUnits(currentPriceRaw, 18)); - const markerX = scaleX(currentSupply); - const markerY = scaleY(currentPrice); + // Last point for pulse marker + const lastIdx = points.length - 1; + const lastX = scaleX(lastIdx); + const lastY = scaleY(points[lastIdx].price); + + // Y-axis ticks + const yTicks = [minY, (minY + maxY) / 2, maxY]; - // Y-axis labels (3 ticks) - const yTicks = [0, maxY / 2, maxY]; - // X-axis labels - const xTicks = [0, maxX / 2, maxX]; + // X-axis time labels (first, mid, last) + const xLabels = [ + { idx: 0, label: formatTime(points[0].time) }, + { idx: Math.floor(lastIdx / 2), label: formatTime(points[Math.floor(lastIdx / 2)].time) }, + { idx: lastIdx, label: formatTime(points[lastIdx].time) }, + ]; return (
-

Price Curve

+

Price

- {v < 0.001 ? v.toExponential(0) : v.toFixed(4)} + {formatPrice(v)} ))} - {/* X-axis labels */} - {xTicks.map((v, i) => ( + {/* X-axis time labels */} + {xLabels.map(({ idx, label }) => ( - {v < 1 ? v.toFixed(1) : Math.round(v).toLocaleString()} + {label} ))} - {/* Curve */} + {/* Price line */} - {/* Current supply marker */} - {currentSupply > 0 && ( - <> - - - - )} + {/* Current price pulse marker */} + + + + +

- Supply vs. price per token ({reserveLabel}) - {currentSupply > 0 && ( - - {" "} - · current: {currentSupply.toLocaleString()} tokens - - )} + Price per token ({reserveLabel}) + + {" "}· latest: {formatPrice(points[lastIdx].price)} {reserveLabel} +

);