From 3895a0d349d3bf8dfb085ae94f8c5c904be84ebe Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Wed, 22 Apr 2026 15:58:09 +0900 Subject: [PATCH 1/4] [#936] Redesign airdrop hero + chart as combined section - Combined CampaignHero and chart into one cohesive section - Live countdown (d:h:m:s) updating every second - Expanded explanation (5% supply, milestone-based, burn) - Lock-up proof link (placeholder when LOCKER_ID not set) - 6-month timeline chart with 3 lines: actual FDV, linear projection, pool value - Milestone markers (Bronze/Silver/Gold/Diamond) with emojis - Heartbeat-animated dot on current FDV position - Current FDV + zone + pool value displayed below chart - Correct pool value calculation per tier - New /api/airdrop/daily-prices endpoint for historical FDV data - Removed standalone MilestoneTrack from sidebar Fixes #936 Co-Authored-By: Claude Opus 4.6 (1M context) --- package.json | 2 +- src/app/airdrop/page.tsx | 2 - src/app/api/airdrop/daily-prices/route.ts | 34 ++ src/components/airdrop/CampaignHero.tsx | 523 ++++++++++++++++++++-- 4 files changed, 512 insertions(+), 49 deletions(-) create mode 100644 src/app/api/airdrop/daily-prices/route.ts diff --git a/package.json b/package.json index aa7cb2cf..e04e3e9a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "plotlink", - "version": "0.1.36", + "version": "0.1.37", "private": true, "workspaces": [ "packages/*" diff --git a/src/app/airdrop/page.tsx b/src/app/airdrop/page.tsx index 660d68ad..54df2487 100644 --- a/src/app/airdrop/page.tsx +++ b/src/app/airdrop/page.tsx @@ -4,7 +4,6 @@ import { UserPoints } from "../../components/airdrop/UserPoints"; import { ClaimPanel } from "../../components/airdrop/ClaimPanel"; import { Leaderboard } from "../../components/airdrop/Leaderboard"; import { WeeklySnapshots } from "../../components/airdrop/WeeklySnapshots"; -import { MilestoneTrack } from "../../components/airdrop/MilestoneTrack"; import { AIRDROP_CONFIG } from "../../../lib/airdrop/config"; export const metadata: Metadata = { @@ -29,7 +28,6 @@ export default function AirdropPage() { {/* Right column: global sections */}
-
diff --git a/src/app/api/airdrop/daily-prices/route.ts b/src/app/api/airdrop/daily-prices/route.ts new file mode 100644 index 00000000..3e6644ed --- /dev/null +++ b/src/app/api/airdrop/daily-prices/route.ts @@ -0,0 +1,34 @@ +/** + * Daily FDV history for the campaign timeline chart (#936) + * GET /api/airdrop/daily-prices — no auth required + * + * Returns array of { date, fdv } ordered by date ascending. + */ + +import { NextResponse } from "next/server"; +import { createServerClient } from "../../../../../lib/supabase"; + +export async function GET() { + const supabase = createServerClient(); + if (!supabase) { + return NextResponse.json({ error: "Supabase not configured" }, { status: 500 }); + } + + const { data, error } = await supabase + .from("pl_daily_prices") + .select("recorded_at, mcap_usd") + .order("recorded_at", { ascending: true }); + + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }); + } + + const points = (data ?? []).map((row) => ({ + date: row.recorded_at, + fdv: Number(row.mcap_usd), + })); + + return NextResponse.json(points, { + headers: { "Cache-Control": "public, s-maxage=300, stale-while-revalidate=60" }, + }); +} diff --git a/src/components/airdrop/CampaignHero.tsx b/src/components/airdrop/CampaignHero.tsx index 7f9fa321..eb904357 100644 --- a/src/components/airdrop/CampaignHero.tsx +++ b/src/components/airdrop/CampaignHero.tsx @@ -1,8 +1,11 @@ "use client"; +import { useEffect, useMemo, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { formatUsdValue } from "../../../lib/usd-price"; +/* ─── Types ─── */ + interface StatusData { campaignStart: string; campaignEnd: string; @@ -22,6 +25,32 @@ interface StatusData { lockerId: string | null; } +interface DailyPrice { + date: string; + fdv: number; +} + +/* ─── Constants ─── */ + +const MAX_SUPPLY = 1_000_000; + +const TIERS = [ + { key: "bronze", emoji: "\uD83E\uDD49", label: "Bronze", fdv: 1_000_000, pct: 10 }, + { key: "silver", emoji: "\uD83E\uDD48", label: "Silver", fdv: 10_000_000, pct: 30 }, + { key: "gold", emoji: "\uD83E\uDD47", label: "Gold", fdv: 50_000_000, pct: 50 }, + { key: "diamond", emoji: "\uD83D\uDC8E", label: "Diamond", fdv: 100_000_000, pct: 100 }, +] as const; + +/* ─── SVG layout ─── */ + +const SVG_W = 700; +const SVG_H = 340; +const PAD = { top: 30, right: 70, bottom: 40, left: 70 }; +const CW = SVG_W - PAD.left - PAD.right; +const CH = SVG_H - PAD.top - PAD.bottom; + +/* ─── Helpers ─── */ + function useAirdropStatus() { return useQuery({ queryKey: ["airdrop-status"], @@ -35,31 +64,404 @@ function useAirdropStatus() { }); } -function CountdownDisplay({ days }: { days: number }) { - const weeks = Math.floor(days / 7); - const remainingDays = days % 7; +function useDailyPrices() { + return useQuery({ + queryKey: ["airdrop-daily-prices"], + queryFn: async () => { + const res = await fetch("/api/airdrop/daily-prices"); + if (!res.ok) throw new Error("Failed to fetch daily prices"); + return res.json(); + }, + staleTime: 300_000, + }); +} + +/** Pool value at current FDV: highest reached tier pct * pool * price */ +function poolValueAtFdv(fdv: number, poolAmount: number): number { + const price = fdv / MAX_SUPPLY; + if (fdv >= 100_000_000) return poolAmount * 1.0 * price; + if (fdv >= 50_000_000) return poolAmount * 0.5 * price; + if (fdv >= 10_000_000) return poolAmount * 0.3 * price; + if (fdv >= 1_000_000) return poolAmount * 0.1 * price; + return 0; +} + +function currentZoneLabel(fdv: number): string { + if (fdv >= 100_000_000) return "Diamond"; + if (fdv >= 50_000_000) return "Gold"; + if (fdv >= 10_000_000) return "Silver"; + if (fdv >= 1_000_000) return "Bronze"; + return "Pre-Bronze"; +} + +function formatCompact(val: number): string { + if (val >= 1_000_000) return `$${(val / 1_000_000).toFixed(1)}M`; + if (val >= 1_000) return `$${(val / 1_000).toFixed(0)}K`; + return `$${val.toFixed(0)}`; +} + +/* ─── Countdown hook ─── */ + +function useCountdown(endDateStr: string) { + const [remaining, setRemaining] = useState({ d: 0, h: 0, m: 0, s: 0 }); + + useEffect(() => { + const endMs = new Date(endDateStr + "T00:00:00Z").getTime(); + function update() { + const diff = Math.max(0, endMs - Date.now()); + const totalSec = Math.floor(diff / 1000); + setRemaining({ + d: Math.floor(totalSec / 86400), + h: Math.floor((totalSec % 86400) / 3600), + m: Math.floor((totalSec % 3600) / 60), + s: totalSec % 60, + }); + } + update(); + const id = setInterval(update, 1000); + return () => clearInterval(id); + }, [endDateStr]); + + return remaining; +} + +/* ─── Pure chart helpers (outside component to avoid unstable refs) ─── */ + +const DIAMOND_FDV = 100_000_000; +const FDV_LOG_MIN = Math.log10(100); +const FDV_LOG_MAX = Math.log10(200_000_000); + +function timeToX(ms: number, startMs: number, totalMs: number): number { + return PAD.left + ((ms - startMs) / totalMs) * CW; +} + +function poolToY(usd: number, yLeftMax: number): number { + return PAD.top + CH * (1 - usd / yLeftMax); +} + +function fdvToY(fdv: number): number { + if (fdv <= 0) return PAD.top + CH; + const t = (Math.log10(Math.max(fdv, 100)) - FDV_LOG_MIN) / (FDV_LOG_MAX - FDV_LOG_MIN); + return PAD.top + CH * (1 - t); +} + +/* ─── Chart sub-component ─── */ + +function TimelineChart({ + campaignStart, + campaignEnd, + currentFdv, + poolAmount, +}: { + campaignStart: string; + campaignEnd: string; + currentFdv: number; + poolAmount: number; +}) { + const { data: dailyPrices } = useDailyPrices(); + const [nowMs, setNowMs] = useState(() => Date.now()); + + // Refresh current time once per minute (chart doesn't need per-second updates) + useEffect(() => { + const id = setInterval(() => setNowMs(Date.now()), 60_000); + return () => clearInterval(id); + }, []); + + const startMs = new Date(campaignStart + "T00:00:00Z").getTime(); + const endMs = new Date(campaignEnd + "T00:00:00Z").getTime(); + const totalMs = endMs - startMs; + + const nowX = timeToX(Math.min(nowMs, endMs), startMs, totalMs); + + const diamondPoolUsd = poolAmount * (DIAMOND_FDV / MAX_SUPPLY); + const yLeftMax = diamondPoolUsd * 1.1; + + // Month labels for x-axis + const months = useMemo(() => { + const result: { label: string; ms: number }[] = []; + const d = new Date(campaignStart + "T00:00:00Z"); + for (let i = 0; i < 7; i++) { + const ms = d.getTime(); + if (ms <= endMs) { + result.push({ label: `M${i + 1}`, ms }); + } + d.setUTCMonth(d.getUTCMonth() + 1); + } + return result; + }, [campaignStart, endMs]); + + // Pool value step line from daily price data + const poolStepPath = useMemo(() => { + if (!dailyPrices?.length) return ""; + const parts: string[] = []; + let lastPoolVal = 0; + 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); + const pv = poolValueAtFdv(dp.fdv, poolAmount); + if (pv !== lastPoolVal && parts.length > 0) { + parts.push(`L ${x.toFixed(1)} ${poolToY(lastPoolVal, yLeftMax).toFixed(1)}`); + } + parts.push(`${parts.length === 0 ? "M" : "L"} ${x.toFixed(1)} ${poolToY(pv, yLeftMax).toFixed(1)}`); + lastPoolVal = pv; + } + if (parts.length > 0) { + parts.push(`L ${nowX.toFixed(1)} ${poolToY(lastPoolVal, yLeftMax).toFixed(1)}`); + } + return parts.join(" "); + }, [dailyPrices, startMs, endMs, totalMs, poolAmount, nowX, yLeftMax]); + + // Pool value area fill + const poolAreaPath = useMemo(() => { + if (!poolStepPath) return ""; + const baseline = poolToY(0, yLeftMax); + const firstX = dailyPrices?.length + ? timeToX(new Date(dailyPrices[0].date + "T00:00:00Z").getTime(), 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]); + + // Actual FDV line + const actualFdvPath = useMemo(() => { + if (!dailyPrices?.length) return ""; + const parts: string[] = []; + 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); + const y = fdvToY(dp.fdv); + parts.push(`${parts.length === 0 ? "M" : "L"} ${x.toFixed(1)} ${y.toFixed(1)}`); + } + if (parts.length > 0 && currentFdv > 0) { + parts.push(`L ${nowX.toFixed(1)} ${fdvToY(currentFdv).toFixed(1)}`); + } + return parts.join(" "); + }, [dailyPrices, startMs, endMs, totalMs, currentFdv, nowX]); + + // Linear projection: current FDV → Diamond at campaign end + const projectionPath = useMemo(() => { + const fromX = PAD.left; + const toX = PAD.left + CW; + const fromY = fdvToY(currentFdv > 0 ? currentFdv : 100); + const toY = fdvToY(DIAMOND_FDV); + return `M ${fromX} ${fromY} L ${toX} ${toY}`; + }, [currentFdv]); + + const dotY = fdvToY(currentFdv > 0 ? currentFdv : 100); + + const milestoneLines = TIERS.map((t) => ({ + ...t, + y: fdvToY(t.fdv), + })); + + const yLeftTicks = [0, diamondPoolUsd * 0.25, diamondPoolUsd * 0.5, diamondPoolUsd]; + const yRightTicks = [1_000, 100_000, 1_000_000, 10_000_000, 100_000_000]; return ( -
- {weeks > 0 && ( -
-
{weeks}
-
weeks
-
- )} - {weeks > 0 && ( -
:
- )} -
-
{weeks > 0 ? remainingDays : days}
-
days
+
+ + + + + + + + + {/* Grid lines (horizontal at each milestone FDV) */} + {milestoneLines.map((m) => ( + + + {/* Right-side label */} + + {m.emoji} {formatCompact(m.fdv)} + + + ))} + + {/* Y-left axis ticks (pool value) */} + {yLeftTicks.map((val) => ( + + {formatCompact(val)} + + ))} + + {/* Y-right axis ticks (FDV) */} + {yRightTicks.map((val) => ( + + {formatCompact(val)} + + ))} + + {/* X-axis month labels */} + {months.map((m) => ( + + + + {m.label} + + + ))} + + {/* Axis labels */} + + Pool Value (USD) + + + FDV (USD) + + + {/* 1. Pool value area fill */} + {poolAreaPath && ( + + )} + + {/* 2. Pool value step line */} + {poolStepPath && ( + + )} + + {/* 3. Linear FDV projection (dashed) */} + + + {/* 4. Actual FDV line (solid) */} + {actualFdvPath && ( + + )} + + {/* Heartbeat dot on current FDV position */} + {currentFdv > 0 && ( + + {/* Pulse ring */} + + + + + {/* Solid dot */} + + + + + + )} + + {/* Chart border */} + + + + {/* Legend */} +
+ + Actual FDV + + + Linear projection + + + Pool value +
); } +/* ─── Main component ─── */ + export function CampaignHero() { const { data, isLoading } = useAirdropStatus(); + const countdown = useCountdown(data?.campaignEnd ?? "2027-01-01"); if (isLoading || !data) { return ( @@ -69,33 +471,57 @@ export function CampaignHero() { ); } + const pad2 = (n: number) => String(n).padStart(2, "0"); + return ( -
- {/* Title + Tagline */} -
+
+ {/* Title + Explanation */} +

PLOT Big or Nothing Airdrop

-

- {data.poolAmount.toLocaleString()} PLOT locked. Earn or burn. +

+ {data.poolAmount.toLocaleString()} PLOT (5% of max supply) locked in a time-locked contract. + If PLOT FDV reaches milestone targets within 6 months, the pool is distributed to point holders. + If not, it's burned forever.

+ + {/* Lock-up proof */} + {data.lockerId ? ( + + 🔒 View lock-up proof on Mint Club + + ) : ( + + 🔒 Lock-up proof: pending + + )}
- {/* Countdown */} + {/* Live Countdown */} {data.timeRemainingDays > 0 && ( -
- -
-
-
-
-
- {data.timeElapsedPercent}% elapsed +
+ {[ + { val: countdown.d, label: "days" }, + { val: countdown.h, label: "hrs" }, + { val: countdown.m, label: "min" }, + { val: countdown.s, label: "sec" }, + ].map((unit, i) => ( +
+ {i > 0 && :} +
+
+ {i === 0 ? unit.val : pad2(unit.val)} +
+
{unit.label}
+
-
+ ))}
)} @@ -117,19 +543,24 @@ export function CampaignHero() {
- {/* Lockup proof */} - {data.lockerId && ( -
- - View lockup proof on-chain - + {/* 6-Month Timeline Chart */} + + + {/* Current position summary */} +
+
+ Current FDV: {data.currentFdv > 0 ? formatUsdValue(data.currentFdv) : "\u2014"} + · Zone: {currentZoneLabel(data.currentFdv)}
- )} +
+ Pool value: {data.currentFdv > 0 ? formatUsdValue(poolValueAtFdv(data.currentFdv, data.poolAmount)) : "$0"} +
+
); } From 23ffd46cc8993e653777dbb94e8139f4bf53b2b3 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Wed, 22 Apr 2026 16:01:39 +0900 Subject: [PATCH 2/4] [#936] Fix projection line origin and remove overlapping right-axis ticks - Linear projection now starts from current time (nowX) instead of chart start - Removed yRightTicks labels that overlapped with milestone emoji labels Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/airdrop/CampaignHero.tsx | 23 ++++------------------- 1 file changed, 4 insertions(+), 19 deletions(-) diff --git a/src/components/airdrop/CampaignHero.tsx b/src/components/airdrop/CampaignHero.tsx index eb904357..f517330c 100644 --- a/src/components/airdrop/CampaignHero.tsx +++ b/src/components/airdrop/CampaignHero.tsx @@ -240,13 +240,13 @@ function TimelineChart({ }, [dailyPrices, startMs, endMs, totalMs, currentFdv, nowX]); // Linear projection: current FDV → Diamond at campaign end + // Linear projection: from current position (nowX) → Diamond at campaign end const projectionPath = useMemo(() => { - const fromX = PAD.left; const toX = PAD.left + CW; const fromY = fdvToY(currentFdv > 0 ? currentFdv : 100); const toY = fdvToY(DIAMOND_FDV); - return `M ${fromX} ${fromY} L ${toX} ${toY}`; - }, [currentFdv]); + return `M ${nowX} ${fromY} L ${toX} ${toY}`; + }, [currentFdv, nowX]); const dotY = fdvToY(currentFdv > 0 ? currentFdv : 100); @@ -256,7 +256,7 @@ function TimelineChart({ })); const yLeftTicks = [0, diamondPoolUsd * 0.25, diamondPoolUsd * 0.5, diamondPoolUsd]; - const yRightTicks = [1_000, 100_000, 1_000_000, 10_000_000, 100_000_000]; + // Right-axis ticks omitted — milestone emoji labels already show FDV values return (
@@ -314,21 +314,6 @@ function TimelineChart({ ))} - {/* Y-right axis ticks (FDV) */} - {yRightTicks.map((val) => ( - - {formatCompact(val)} - - ))} - {/* X-axis month labels */} {months.map((m) => ( From 9582ad0b183e0318581886bb938ff6c042a09aa4 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Wed, 22 Apr 2026 16:05:36 +0900 Subject: [PATCH 3/4] [#936] Use config-driven milestones, clamp area fill to campaign start - Derive tier thresholds from API milestones (respects test/prod config) - poolValueAtFdv/currentZoneLabel now accept tiers parameter - Clamp pool area fill start to campaignStart (fixes pre-campaign data) - Remove hardcoded DIAMOND_FDV constant Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/airdrop/CampaignHero.tsx | 72 +++++++++++++++---------- 1 file changed, 45 insertions(+), 27 deletions(-) diff --git a/src/components/airdrop/CampaignHero.tsx b/src/components/airdrop/CampaignHero.tsx index f517330c..a1a93aac 100644 --- a/src/components/airdrop/CampaignHero.tsx +++ b/src/components/airdrop/CampaignHero.tsx @@ -34,12 +34,22 @@ interface DailyPrice { const MAX_SUPPLY = 1_000_000; -const TIERS = [ - { key: "bronze", emoji: "\uD83E\uDD49", label: "Bronze", fdv: 1_000_000, pct: 10 }, - { key: "silver", emoji: "\uD83E\uDD48", label: "Silver", fdv: 10_000_000, pct: 30 }, - { key: "gold", emoji: "\uD83E\uDD47", label: "Gold", fdv: 50_000_000, pct: 50 }, - { key: "diamond", emoji: "\uD83D\uDC8E", label: "Diamond", fdv: 100_000_000, pct: 100 }, -] as const; +const TIER_META = [ + { key: "bronze" as const, emoji: "\uD83E\uDD49", label: "Bronze" }, + { key: "silver" as const, emoji: "\uD83E\uDD48", label: "Silver" }, + { key: "gold" as const, emoji: "\uD83E\uDD47", label: "Gold" }, + { key: "diamond" as const, emoji: "\uD83D\uDC8E", label: "Diamond" }, +]; + +type Tier = { key: string; emoji: string; label: string; fdv: number; pct: number }; + +/** Build tier array from API milestones so test/prod config is respected */ +function buildTiers(milestones: StatusData["milestones"]): Tier[] { + return TIER_META.map((m) => { + const ms = milestones[m.key]; + return { ...m, fdv: ms.mcap, pct: ms.pct }; + }); +} /* ─── SVG layout ─── */ @@ -77,21 +87,20 @@ function useDailyPrices() { } /** Pool value at current FDV: highest reached tier pct * pool * price */ -function poolValueAtFdv(fdv: number, poolAmount: number): number { +function poolValueAtFdv(fdv: number, poolAmount: number, tiers: Tier[]): number { const price = fdv / MAX_SUPPLY; - if (fdv >= 100_000_000) return poolAmount * 1.0 * price; - if (fdv >= 50_000_000) return poolAmount * 0.5 * price; - if (fdv >= 10_000_000) return poolAmount * 0.3 * price; - if (fdv >= 1_000_000) return poolAmount * 0.1 * price; + // Walk tiers in reverse to find highest reached + for (let i = tiers.length - 1; i >= 0; i--) { + if (fdv >= tiers[i].fdv) return poolAmount * (tiers[i].pct / 100) * price; + } return 0; } -function currentZoneLabel(fdv: number): string { - if (fdv >= 100_000_000) return "Diamond"; - if (fdv >= 50_000_000) return "Gold"; - if (fdv >= 10_000_000) return "Silver"; - if (fdv >= 1_000_000) return "Bronze"; - return "Pre-Bronze"; +function currentZoneLabel(fdv: number, tiers: Tier[]): string { + for (let i = tiers.length - 1; i >= 0; i--) { + if (fdv >= tiers[i].fdv) return tiers[i].label; + } + return "Pre-" + tiers[0].label; } function formatCompact(val: number): string { @@ -127,7 +136,6 @@ function useCountdown(endDateStr: string) { /* ─── Pure chart helpers (outside component to avoid unstable refs) ─── */ -const DIAMOND_FDV = 100_000_000; const FDV_LOG_MIN = Math.log10(100); const FDV_LOG_MAX = Math.log10(200_000_000); @@ -152,11 +160,13 @@ function TimelineChart({ campaignEnd, currentFdv, poolAmount, + tiers, }: { campaignStart: string; campaignEnd: string; currentFdv: number; poolAmount: number; + tiers: Tier[]; }) { const { data: dailyPrices } = useDailyPrices(); const [nowMs, setNowMs] = useState(() => Date.now()); @@ -173,7 +183,8 @@ function TimelineChart({ const nowX = timeToX(Math.min(nowMs, endMs), startMs, totalMs); - const diamondPoolUsd = poolAmount * (DIAMOND_FDV / MAX_SUPPLY); + const diamondFdv = tiers[tiers.length - 1].fdv; + const diamondPoolUsd = poolAmount * (diamondFdv / MAX_SUPPLY); const yLeftMax = diamondPoolUsd * 1.1; // Month labels for x-axis @@ -199,7 +210,7 @@ function TimelineChart({ const dpMs = new Date(dp.date + "T00:00:00Z").getTime(); if (dpMs < startMs || dpMs > endMs) continue; const x = timeToX(dpMs, startMs, totalMs); - const pv = poolValueAtFdv(dp.fdv, poolAmount); + const pv = poolValueAtFdv(dp.fdv, poolAmount, tiers); if (pv !== lastPoolVal && parts.length > 0) { parts.push(`L ${x.toFixed(1)} ${poolToY(lastPoolVal, yLeftMax).toFixed(1)}`); } @@ -210,14 +221,15 @@ function TimelineChart({ parts.push(`L ${nowX.toFixed(1)} ${poolToY(lastPoolVal, yLeftMax).toFixed(1)}`); } return parts.join(" "); - }, [dailyPrices, startMs, endMs, totalMs, poolAmount, nowX, yLeftMax]); + }, [dailyPrices, startMs, endMs, totalMs, poolAmount, nowX, yLeftMax, tiers]); // Pool value area fill const poolAreaPath = useMemo(() => { if (!poolStepPath) return ""; const baseline = poolToY(0, yLeftMax); + // Clamp area fill start to campaign start (daily prices may predate campaign) const firstX = dailyPrices?.length - ? timeToX(new Date(dailyPrices[0].date + "T00:00:00Z").getTime(), startMs, totalMs) + ? 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]); @@ -244,13 +256,13 @@ function TimelineChart({ const projectionPath = useMemo(() => { const toX = PAD.left + CW; const fromY = fdvToY(currentFdv > 0 ? currentFdv : 100); - const toY = fdvToY(DIAMOND_FDV); + const toY = fdvToY(diamondFdv); return `M ${nowX} ${fromY} L ${toX} ${toY}`; - }, [currentFdv, nowX]); + }, [currentFdv, nowX, diamondFdv]); const dotY = fdvToY(currentFdv > 0 ? currentFdv : 100); - const milestoneLines = TIERS.map((t) => ({ + const milestoneLines = tiers.map((t) => ({ ...t, y: fdvToY(t.fdv), })); @@ -448,6 +460,11 @@ export function CampaignHero() { const { data, isLoading } = useAirdropStatus(); const countdown = useCountdown(data?.campaignEnd ?? "2027-01-01"); + const tiers = useMemo( + () => (data ? buildTiers(data.milestones) : []), + [data], + ); + if (isLoading || !data) { return (
@@ -534,16 +551,17 @@ export function CampaignHero() { campaignEnd={data.campaignEnd} currentFdv={data.currentFdv} poolAmount={data.poolAmount} + tiers={tiers} /> {/* Current position summary */}
Current FDV: {data.currentFdv > 0 ? formatUsdValue(data.currentFdv) : "\u2014"} - · Zone: {currentZoneLabel(data.currentFdv)} + · Zone: {currentZoneLabel(data.currentFdv, tiers)}
- Pool value: {data.currentFdv > 0 ? formatUsdValue(poolValueAtFdv(data.currentFdv, data.poolAmount)) : "$0"} + Pool value: {data.currentFdv > 0 ? formatUsdValue(poolValueAtFdv(data.currentFdv, data.poolAmount, tiers)) : "$0"}
From 315c04f0329546ede5fc488608d011bf955f047c Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Wed, 22 Apr 2026 16:07:57 +0900 Subject: [PATCH 4/4] [#936] Derive FDV log-scale ceiling from config diamond tier - fdvToY now accepts logMax param derived from diamond FDV (2x headroom) - Removes last hardcoded prod value (FDV_LOG_MAX=200M) - Test mode milestones now render correctly across the chart Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/airdrop/CampaignHero.tsx | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/src/components/airdrop/CampaignHero.tsx b/src/components/airdrop/CampaignHero.tsx index a1a93aac..3b5efbcb 100644 --- a/src/components/airdrop/CampaignHero.tsx +++ b/src/components/airdrop/CampaignHero.tsx @@ -137,7 +137,6 @@ function useCountdown(endDateStr: string) { /* ─── Pure chart helpers (outside component to avoid unstable refs) ─── */ const FDV_LOG_MIN = Math.log10(100); -const FDV_LOG_MAX = Math.log10(200_000_000); function timeToX(ms: number, startMs: number, totalMs: number): number { return PAD.left + ((ms - startMs) / totalMs) * CW; @@ -147,9 +146,9 @@ function poolToY(usd: number, yLeftMax: number): number { return PAD.top + CH * (1 - usd / yLeftMax); } -function fdvToY(fdv: number): number { +function fdvToY(fdv: number, logMax: number): number { if (fdv <= 0) return PAD.top + CH; - const t = (Math.log10(Math.max(fdv, 100)) - FDV_LOG_MIN) / (FDV_LOG_MAX - FDV_LOG_MIN); + const t = (Math.log10(Math.max(fdv, 100)) - FDV_LOG_MIN) / (logMax - FDV_LOG_MIN); return PAD.top + CH * (1 - t); } @@ -184,6 +183,7 @@ function TimelineChart({ const nowX = timeToX(Math.min(nowMs, endMs), startMs, totalMs); const diamondFdv = tiers[tiers.length - 1].fdv; + const fdvLogMax = Math.log10(diamondFdv * 2); // 2x headroom above diamond const diamondPoolUsd = poolAmount * (diamondFdv / MAX_SUPPLY); const yLeftMax = diamondPoolUsd * 1.1; @@ -242,29 +242,28 @@ function TimelineChart({ const dpMs = new Date(dp.date + "T00:00:00Z").getTime(); if (dpMs < startMs || dpMs > endMs) continue; const x = timeToX(dpMs, startMs, totalMs); - const y = fdvToY(dp.fdv); + const y = fdvToY(dp.fdv, fdvLogMax); parts.push(`${parts.length === 0 ? "M" : "L"} ${x.toFixed(1)} ${y.toFixed(1)}`); } if (parts.length > 0 && currentFdv > 0) { - parts.push(`L ${nowX.toFixed(1)} ${fdvToY(currentFdv).toFixed(1)}`); + parts.push(`L ${nowX.toFixed(1)} ${fdvToY(currentFdv, fdvLogMax).toFixed(1)}`); } return parts.join(" "); - }, [dailyPrices, startMs, endMs, totalMs, currentFdv, nowX]); + }, [dailyPrices, startMs, endMs, totalMs, currentFdv, nowX, fdvLogMax]); - // Linear projection: current FDV → Diamond at campaign end // Linear projection: from current position (nowX) → Diamond at campaign end const projectionPath = useMemo(() => { const toX = PAD.left + CW; - const fromY = fdvToY(currentFdv > 0 ? currentFdv : 100); - const toY = fdvToY(diamondFdv); + const fromY = fdvToY(currentFdv > 0 ? currentFdv : 100, fdvLogMax); + const toY = fdvToY(diamondFdv, fdvLogMax); return `M ${nowX} ${fromY} L ${toX} ${toY}`; - }, [currentFdv, nowX, diamondFdv]); + }, [currentFdv, nowX, diamondFdv, fdvLogMax]); - const dotY = fdvToY(currentFdv > 0 ? currentFdv : 100); + const dotY = fdvToY(currentFdv > 0 ? currentFdv : 100, fdvLogMax); const milestoneLines = tiers.map((t) => ({ ...t, - y: fdvToY(t.fdv), + y: fdvToY(t.fdv, fdvLogMax), })); const yLeftTicks = [0, diamondPoolUsd * 0.25, diamondPoolUsd * 0.5, diamondPoolUsd];