From 1de6aefc376bdec6e2f26dddff4c8c86f86b90cb Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Wed, 22 Apr 2026 18:10:46 +0900 Subject: [PATCH 1/2] [#938] Fix chart: empty state fallback, mobile overflow - Show current FDV point and $0 pool line when no daily price history - Heartbeat dot always visible at current FDV position - Pool value line renders flat $0 pre-Bronze or stepped if in a tier - Remove minWidth:320 to let SVG scale freely on mobile - Hide axis labels and Y-left ticks on mobile (< 640px) Fixes #938 Co-Authored-By: Claude Opus 4.6 (1M context) --- package.json | 2 +- src/components/airdrop/CampaignHero.tsx | 76 ++++++++++++++++--------- 2 files changed, 51 insertions(+), 27 deletions(-) diff --git a/package.json b/package.json index e04e3e9a..09ded99c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "plotlink", - "version": "0.1.37", + "version": "0.1.38", "private": true, "workspaces": [ "packages/*" diff --git a/src/components/airdrop/CampaignHero.tsx b/src/components/airdrop/CampaignHero.tsx index 3b5efbcb..e6157045 100644 --- a/src/components/airdrop/CampaignHero.tsx +++ b/src/components/airdrop/CampaignHero.tsx @@ -201,12 +201,24 @@ function TimelineChart({ return result; }, [campaignStart, endMs]); - // Pool value step line from daily price data + // Effective data points: use daily prices if available, else synthesize from current FDV + const hasHistory = !!(dailyPrices?.length && dailyPrices.some((dp) => { + const dpMs = new Date(dp.date + "T00:00:00Z").getTime(); + return dpMs >= startMs && dpMs <= endMs; + })); + + // Pool value step line from daily price data (or $0 flat line if no data) const poolStepPath = useMemo(() => { - if (!dailyPrices?.length) return ""; + const baseline = poolToY(0, yLeftMax); + if (!hasHistory) { + // No data: flat $0 line from campaign start to now + const currentPv = poolValueAtFdv(currentFdv, poolAmount, tiers); + const pvY = currentPv > 0 ? poolToY(currentPv, yLeftMax) : baseline; + return `M ${PAD.left.toFixed(1)} ${baseline.toFixed(1)} L ${nowX.toFixed(1)} ${pvY.toFixed(1)}`; + } const parts: string[] = []; let lastPoolVal = 0; - for (const dp of dailyPrices) { + for (const dp of dailyPrices!) { const dpMs = new Date(dp.date + "T00:00:00Z").getTime(); if (dpMs < startMs || dpMs > endMs) continue; const x = timeToX(dpMs, startMs, totalMs); @@ -221,24 +233,33 @@ function TimelineChart({ parts.push(`L ${nowX.toFixed(1)} ${poolToY(lastPoolVal, yLeftMax).toFixed(1)}`); } return parts.join(" "); - }, [dailyPrices, startMs, endMs, totalMs, poolAmount, nowX, yLeftMax, tiers]); + }, [hasHistory, dailyPrices, startMs, endMs, totalMs, poolAmount, nowX, yLeftMax, tiers, currentFdv]); // Pool value area fill const poolAreaPath = useMemo(() => { if (!poolStepPath) return ""; const baseline = poolToY(0, yLeftMax); + if (!hasHistory) { + // Area from campaign start baseline → pool step → back to baseline + return `M ${PAD.left.toFixed(1)} ${baseline.toFixed(1)} ${poolStepPath.replace(/^M/, "L")} L ${nowX.toFixed(1)} ${baseline.toFixed(1)} Z`; + } // Clamp area fill start to campaign start (daily prices may predate campaign) const firstX = dailyPrices?.length ? timeToX(Math.max(new Date(dailyPrices[0].date + "T00:00:00Z").getTime(), startMs), startMs, totalMs) : PAD.left; return `M ${firstX.toFixed(1)} ${baseline.toFixed(1)} ${poolStepPath.replace(/^M/, "L")} L ${nowX.toFixed(1)} ${baseline.toFixed(1)} Z`; - }, [poolStepPath, dailyPrices, startMs, totalMs, nowX, yLeftMax]); + }, [poolStepPath, hasHistory, dailyPrices, startMs, totalMs, nowX, yLeftMax]); - // Actual FDV line + // Actual FDV line (or single point if no history) const actualFdvPath = useMemo(() => { - if (!dailyPrices?.length) return ""; + if (!hasHistory) { + // No history: just draw a point at current position (will be rendered as dot) + if (currentFdv <= 0) return ""; + const y = fdvToY(currentFdv, fdvLogMax); + return `M ${nowX.toFixed(1)} ${y.toFixed(1)} L ${nowX.toFixed(1)} ${y.toFixed(1)}`; + } const parts: string[] = []; - for (const dp of dailyPrices) { + for (const dp of dailyPrices!) { const dpMs = new Date(dp.date + "T00:00:00Z").getTime(); if (dpMs < startMs || dpMs > endMs) continue; const x = timeToX(dpMs, startMs, totalMs); @@ -249,7 +270,7 @@ function TimelineChart({ parts.push(`L ${nowX.toFixed(1)} ${fdvToY(currentFdv, fdvLogMax).toFixed(1)}`); } return parts.join(" "); - }, [dailyPrices, startMs, endMs, totalMs, currentFdv, nowX, fdvLogMax]); + }, [hasHistory, dailyPrices, startMs, endMs, totalMs, currentFdv, nowX, fdvLogMax]); // Linear projection: from current position (nowX) → Diamond at campaign end const projectionPath = useMemo(() => { @@ -270,11 +291,10 @@ function TimelineChart({ // Right-axis ticks omitted — milestone emoji labels already show FDV values return ( -
+
@@ -310,20 +330,22 @@ function TimelineChart({ ))} - {/* Y-left axis ticks (pool value) */} - {yLeftTicks.map((val) => ( - - {formatCompact(val)} - - ))} + {/* Y-left axis ticks (pool value) — hidden on mobile */} + + {yLeftTicks.map((val) => ( + + {formatCompact(val)} + + ))} + {/* X-axis month labels */} {months.map((m) => ( @@ -349,7 +371,7 @@ function TimelineChart({ ))} - {/* Axis labels */} + {/* Axis labels — hidden on mobile via CSS media query */} Pool Value (USD) @@ -369,6 +392,7 @@ function TimelineChart({ fontSize={9} fontFamily="Inter, system-ui, sans-serif" transform={`rotate(90, ${SVG_W - 8}, ${PAD.top + CH / 2})`} + className="hidden sm:block" > FDV (USD) From 2ab45199c542e1706b2c46bdc36bb336b67181fc Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Wed, 22 Apr 2026 18:13:00 +0900 Subject: [PATCH 2/2] [#938] Clamp nowX to campaignStart to prevent pre-campaign rendering Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/airdrop/CampaignHero.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/airdrop/CampaignHero.tsx b/src/components/airdrop/CampaignHero.tsx index e6157045..5395d1a4 100644 --- a/src/components/airdrop/CampaignHero.tsx +++ b/src/components/airdrop/CampaignHero.tsx @@ -180,7 +180,7 @@ function TimelineChart({ const endMs = new Date(campaignEnd + "T00:00:00Z").getTime(); const totalMs = endMs - startMs; - const nowX = timeToX(Math.min(nowMs, endMs), startMs, totalMs); + const nowX = timeToX(Math.max(startMs, Math.min(nowMs, endMs)), startMs, totalMs); const diamondFdv = tiers[tiers.length - 1].fdv; const fdvLogMax = Math.log10(diamondFdv * 2); // 2x headroom above diamond