diff --git a/src/components/CadencePage.tsx b/src/components/CadencePage.tsx new file mode 100644 index 00000000..dfbcac30 --- /dev/null +++ b/src/components/CadencePage.tsx @@ -0,0 +1,430 @@ +import { useEffect, useState } from 'react'; +import { Link } from './navigation'; +import { networkUpgrades, getUpgradePagePath, NetworkUpgrade } from '../data/upgrades'; +import { parseShortDate, daysBetween } from './schedule/forkDateCalculator'; + +// Strip the trailing "Upgrade" so "Fusaka Upgrade" reads as "Fusaka". +const short = (name: string): string => name.replace(/\s+Upgrade$/i, ''); + +// Live mainnet upgrades with a concrete activation date, oldest → newest. +// This resolves to The Merge → Shapella → Dencun → Pectra → Fusaka: the modern +// (post-Merge) cadence that is actually comparable to the current wait. Sourced +// from the same upgrade data the rest of Forkcast uses. +const timeline = networkUpgrades + .filter( + (u): u is NetworkUpgrade & { activationDate: string } => + u.status === 'Live' && !!u.activationDate && parseShortDate(u.activationDate) !== null, + ) + .map((u) => ({ upgrade: u, date: parseShortDate(u.activationDate)! })) + .sort((a, b) => a.date.getTime() - b.date.getTime()); + +// The gap (in days) leading up to each upgrade after The Merge. +const gaps = timeline.slice(1).map((entry, i) => ({ + name: short(entry.upgrade.name), + days: daysBetween(timeline[i].date, entry.date), +})); + +const last = timeline[timeline.length - 1]; +const pmAvg = gaps.length ? Math.round(gaps.reduce((sum, g) => sum + g.days, 0) / gaps.length) : 0; +const pmMin = gaps.length ? Math.min(...gaps.map((g) => g.days)) : 0; +const pmMax = gaps.length ? Math.max(...gaps.map((g) => g.days)) : 0; + +const nextUpgrade = networkUpgrades.find((u) => u.status === 'Upcoming'); + +// Included EIPs per fork (execution + consensus layer) with total spec size. +// EIP counts come from each hard-fork meta EIP — EIP-7569 (Dencun), EIP-7600 +// (Pectra), EIP-7607 (Fusaka) — and ethereum.org for Shapella, which predates +// the meta-EIP era. `lines` is the summed line count of those EIPs' spec +// markdown in public/eips/.md; regenerate after an EIP-set change with +// `wc -l` over each fork's files. Forkcast's own EIP dataset doesn't cover the +// older forks, so these live here as a small documented table. +const MONTH_DAYS = 365.25 / 12; +const FORK_EIP_STATS: Record = { + Shapella: { eips: 5, lines: 380 }, + Dencun: { eips: 9, lines: 1446 }, + Pectra: { eips: 11, lines: 3219 }, + Fusaka: { eips: 13, lines: 1930 }, +}; + +// Per-fork EIP metrics: shipping rate (EIPs / month) and total spec size (lines). +const throughput = gaps + .filter((g) => FORK_EIP_STATS[g.name] != null) + .map((g) => { + const s = FORK_EIP_STATS[g.name] ?? { eips: 0, lines: 0 }; + const months = g.days / MONTH_DAYS; + return { + name: g.name, + eips: s.eips, + lines: s.lines, + months, + perMonth: s.eips / months, + linesPerMonth: s.lines / months, + }; + }); + +const eipAvg = throughput.length + ? throughput.reduce((sum, t) => sum + t.perMonth, 0) / throughput.length + : 0; +const eipPeak = throughput.length ? Math.max(...throughput.map((t) => t.perMonth)) : 0; +const fastest = throughput.reduce((a, b) => (b.perMonth > a.perMonth ? b : a), throughput[0]); +const slowerRates = throughput.filter((t) => t !== fastest).map((t) => t.perMonth); + +const linesAvg = throughput.length + ? Math.round(throughput.reduce((sum, t) => sum + t.lines, 0) / throughput.length) + : 0; +const linesPeak = throughput.length ? Math.max(...throughput.map((t) => t.lines)) : 0; +const densest = throughput.reduce((a, b) => (b.lines > a.lines ? b : a), throughput[0]); + +const lpmAvg = throughput.length + ? throughput.reduce((sum, t) => sum + t.linesPerMonth, 0) / throughput.length + : 0; +const lpmPeak = throughput.length ? Math.max(...throughput.map((t) => t.linesPerMonth)) : 0; +const lpmFastest = throughput.reduce( + (a, b) => (b.linesPerMonth > a.linesPerMonth ? b : a), + throughput[0], +); +const lpmSlower = throughput.filter((t) => t !== lpmFastest).map((t) => t.linesPerMonth); + +interface BarRowProps { + label: string; + value: number; // drives the bar length (value / chartMax) + display: string; // right-aligned number text + chartMax: number; + highlight?: boolean; +} + +const BarRow = ({ label, value, display, chartMax, highlight = false }: BarRowProps) => ( +
+
+ {label} +
+
+
+
+
+ {display} +
+
+); + +const CadencePage = () => { + const [now, setNow] = useState(() => new Date()); + + useEffect(() => { + // The day count only changes at midnight; a one-minute cadence flips it + // promptly without busy-looping. + const id = setInterval(() => setNow(new Date()), 60_000); + return () => clearInterval(id); + }, []); + + const sinceDays = Math.max(0, daysBetween(last.date, now)); + const chartMax = Math.max(pmMax, pmAvg, sinceDays) * 1.12 || 1; + const avgFraction = pmAvg / chartMax; + // Track lane starts after the w-24 label (+gap-3) and ends before the w-12 + // value (+gap-3), so the average line lands at the same scale as the bars. + const avgLeft = `calc(6.75rem + (100% - 10.5rem) * ${avgFraction})`; + + // Second chart (EIPs/month) is static, but its average line reuses the same + // track-lane geometry as the duration chart above. + const eipChartMax = Math.max(eipPeak, eipAvg) * 1.12 || 1; + const eipAvgLeft = `calc(6.75rem + (100% - 10.5rem) * ${eipAvg / eipChartMax})`; + const linesChartMax = Math.max(linesPeak, linesAvg) * 1.12 || 1; + const linesAvgLeft = `calc(6.75rem + (100% - 10.5rem) * ${linesAvg / linesChartMax})`; + const lpmChartMax = Math.max(lpmPeak, lpmAvg) * 1.12 || 1; + const lpmAvgLeft = `calc(6.75rem + (100% - 10.5rem) * ${lpmAvg / lpmChartMax})`; + + const diff = sinceDays - pmAvg; + const absDiff = Math.abs(diff); + const dayWord = absDiff === 1 ? 'day' : 'days'; + const lastPagePath = getUpgradePagePath(last.upgrade.id); + const lastShort = short(last.upgrade.name); + + return ( +
+
+ {/* Header */} +
+

+ Upgrade Cadence +

+

+ How long it's been since Ethereum's last network upgrade — and how that pace compares to + the rhythm since The Merge. +

+
+ + {/* Hero: days since the last upgrade */} +
+
+ Days since {lastShort} +
+
+ {sinceDays.toLocaleString()} +
+
+ {lastPagePath ? ( + + {last.upgrade.name} + + ) : ( + {last.upgrade.name} + )} + · activated {last.upgrade.activationDate} +
+
+ + {/* Post-Merge cadence chart */} +
+

+ Days between upgrades since The Merge +

+ +
+ {/* Average reference line + label */} +
+
+ avg {pmAvg} +
+ + {/* Bars */} +
+ {gaps.map((g) => ( + + ))} + +
+
+ +

+ {absDiff <= 2 ? ( + <> + Right around the{' '} + {pmAvg}-day{' '} + post-Merge average. + + ) : ( + <> + + {absDiff.toLocaleString()} {dayWord} + {' '} + {diff < 0 ? 'under' : 'beyond'} the{' '} + {pmAvg}-day{' '} + post-Merge average. + + )}{' '} + The last {gaps.length} upgrades came{' '} + + {pmMin}–{pmMax} + {' '} + days apart. +

+
+ + {/* EIPs shipped per month */} + {throughput.length > 0 && ( +
+

+ EIPs shipped per month since last fork +

+ +
+ {/* Average reference line + label */} +
+
+ avg {eipAvg.toFixed(1)} +
+ + {/* Bars */} +
+ {throughput.map((t) => ( + + ))} +
+
+ +

+ {fastest.name} shipped + fastest — {fastest.eips} EIPs in ~ + {Math.round(fastest.months)} months since the last fork ( + {fastest.perMonth.toFixed(1)}/mo) + {slowerRates.length > 0 && ( + <> + , versus{' '} + + {Math.min(...slowerRates).toFixed(1)}–{Math.max(...slowerRates).toFixed(1)} + + /mo for the earlier post-Merge upgrades + + )} + . Counts are included EIPs (EL + CL) from each fork's meta EIP. +

+
+ )} + + {/* Spec lines per month since last fork */} + {throughput.length > 0 && ( +
+

+ Spec lines per month since last fork +

+ +
+ {/* Average reference line + label */} +
+
+ avg {Math.round(lpmAvg).toLocaleString()} +
+ + {/* Bars */} +
+ {throughput.map((t) => ( + + ))} +
+
+ +

+ ~{Math.round(lpmFastest.linesPerMonth).toLocaleString()} lines + per month since the last fork + {lpmSlower.length > 0 && ( + <> + , versus ~ + + {Math.round(Math.min(...lpmSlower))}–{Math.round(Math.max(...lpmSlower))} + {' '} + for the earlier post-Merge upgrades + + )} + . +

+
+ )} + + {/* Lines of EIP spec text */} + {throughput.length > 0 && ( +
+

+ Lines of EIP spec text +

+ +
+ {/* Average reference line + label */} +
+
+ avg {linesAvg.toLocaleString()} +
+ + {/* Bars */} +
+ {throughput.map((t) => ( + + ))} +
+
+ +

+ {densest.name} carried + the most spec text — {densest.lines.toLocaleString()} lines + across {densest.eips} EIPs (~ + {Math.round(densest.lines / densest.eips)} lines/EIP), the + densest upgrade since the Merge. Counts are lines of each EIP's specification markdown. +

+
+ )} + + {/* Next upgrade + source */} + {nextUpgrade && ( +

+ Next up:{' '} + + {short(nextUpgrade.name)} + {' '} + — expected {nextUpgrade.activationDate} +

+ )} + +

+ Activation dates from Forkcast's{' '} + + upgrade data + + . Cadence measured across mainnet upgrades since The Merge. +

+
+
+ ); +}; + +export default CadencePage; diff --git a/src/components/ui/SiteNav.astro b/src/components/ui/SiteNav.astro index be235e1b..21c36bb8 100644 --- a/src/components/ui/SiteNav.astro +++ b/src/components/ui/SiteNav.astro @@ -29,6 +29,7 @@ const navItems = [ { to: '/decisions', label: 'Decisions' }, { to: '/devnets', label: 'Devnets' }, { to: '/schedule', label: 'Schedule' }, + { to: '/cadence', label: 'Cadence' }, ]; const upgradeNavItems = [ diff --git a/src/domain/routes/pageMetadata.ts b/src/domain/routes/pageMetadata.ts index 81428b4a..f17a7990 100644 --- a/src/domain/routes/pageMetadata.ts +++ b/src/domain/routes/pageMetadata.ts @@ -23,6 +23,11 @@ export const staticPageMetadata = { title: 'Network Upgrades - Forkcast', description: 'Catalog of Ethereum network upgrades - in progress, live, and historical.', }, + cadence: { + title: 'Upgrade Cadence - Forkcast', + description: + "How long since Ethereum's last network upgrade, and how the current wait compares to the pace of upgrades since The Merge.", + }, schedule: { title: 'ACD Planning Sandbox - Forkcast', description: 'Internal planning tool for ACD participants. Explore hypothetical upgrade timelines.', diff --git a/src/pages/cadence.astro b/src/pages/cadence.astro new file mode 100644 index 00000000..0cf8ba3d --- /dev/null +++ b/src/pages/cadence.astro @@ -0,0 +1,9 @@ +--- +import Layout from '../layouts/Layout.astro'; +import CadencePage from '../components/CadencePage'; +import { staticPageMetadata } from '../domain/routes/pageMetadata'; +--- + + + +