diff --git a/astro.config.mjs b/astro.config.mjs index 28d4dd26..b07fd629 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -32,6 +32,9 @@ function callIssueRedirects() { // Static builds emit these as `` pages. See the // "Intentional Route Changes" section of docs/astro-migration-phase-1.md. const legacyRedirects = { + // The tier maker is now per-fork at `/rank/{fork}`; keep the original short URL + // (shared publicly and baked into exported ranking images) pointing at Hegota. + '/rank': '/rank/hegota', '/feedback': 'https://ethereum-magicians.org/t/community-feedback-on-non-headlining-features-in-glamsterdam/26410', '/planner': '/schedule', diff --git a/src/components/GlamsterdamUpgradePage.tsx b/src/components/GlamsterdamUpgradePage.tsx index 3093985a..dbea8161 100644 --- a/src/components/GlamsterdamUpgradePage.tsx +++ b/src/components/GlamsterdamUpgradePage.tsx @@ -6,6 +6,7 @@ import ClientPriorityTab from './glamsterdam/ClientPriorityTab'; import TestComplexityTab from './glamsterdam/TestComplexityTab'; import { getUpgradeById } from '../data/upgrades'; import { getUpgradeStatusColor } from '../utils/colors'; +import { TierMakerLink } from './network-upgrade'; const upgrade = getUpgradeById('glamsterdam')!; @@ -79,8 +80,8 @@ const GlamsterdamUpgradePage: React.FC = ({ activeT

{upgrade.description}

- {upgrade.metaEipLink && ( -
+
+ {upgrade.metaEipLink && ( = ({ activeT -
- )} + )} + +
diff --git a/src/components/HegotaUpgradePage.tsx b/src/components/HegotaUpgradePage.tsx index 642b59b8..69f9802c 100644 --- a/src/components/HegotaUpgradePage.tsx +++ b/src/components/HegotaUpgradePage.tsx @@ -3,6 +3,7 @@ import { getUpgradeById } from '../data/upgrades'; import { getUpgradeStatusColor } from '../utils/colors'; import TestComplexityTab from './glamsterdam/TestComplexityTab'; import OverviewTab from './hegota/OverviewTab'; +import { TierMakerLink } from './network-upgrade'; const upgrade = getUpgradeById('hegota')!; @@ -62,8 +63,8 @@ const HegotaUpgradePage: React.FC = ({ activeTab }) => {

{upgrade.description}

- {upgrade.metaEipLink && ( -
+
+ {upgrade.metaEipLink && ( = ({ activeTab }) => { -
- )} + )} + +
diff --git a/src/components/RankPage.tsx b/src/components/RankPage.tsx index 570b66f5..f3f6fc1b 100644 --- a/src/components/RankPage.tsx +++ b/src/components/RankPage.tsx @@ -5,11 +5,14 @@ import { getLaymanTitle, getProposalPrefix, getEipLayer, - wasHeadlinerCandidate, + isHeadliner, + isForkInclusionCandidate, } from "../utils/eip"; import { useAnalytics } from "../hooks/useAnalytics"; import { eipsData } from "../data/eips"; import { getPendingProposalsForFork, PendingProposal } from "../data/pending-proposals"; +import { getUpgradeById } from "../data/upgrades"; +import { getTierMakerConfig } from "../utils/tierMaker"; const ChampionDisplay: React.FC<{ champions?: Champion[] }> = ({ champions }) => { if (!champions || champions.length === 0 || !champions.some(c => c.name)) return null; @@ -25,6 +28,8 @@ interface TierItem { id: string; eip?: EIP; pendingProposal?: PendingProposal; + /** True for the fork's selected headliner EIPs, toggled by the headliner checkbox. */ + headliner: boolean; tier: string | null; } @@ -139,7 +144,20 @@ const truncateText = (text: string, maxLength: number): string => { return text.slice(0, maxLength).trim() + '...'; }; -const RankPage: React.FC = () => { +interface RankPageProps { + /** Lowercase upgrade id whose proposals are ranked, e.g. 'hegota'. */ + forkName: string; +} + +const RankPage: React.FC = ({ forkName }) => { + const fork = forkName.toLowerCase(); + const upgrade = getUpgradeById(fork); + // "Hegotá Upgrade" -> "Hegotá"; falls back to the id for unknown forks. + const displayName = (upgrade?.name ?? fork).replace(/\s+Upgrade$/, ""); + const tierConfig = getTierMakerConfig(fork); + const storageKey = `${fork}-rankings`; + const rankPath = `/rank/${fork}`; + const navigate = useNavigate(); const { trackLinkClick, trackEvent } = useAnalytics(); const [items, setItems] = useState([]); @@ -153,35 +171,38 @@ const RankPage: React.FC = () => { ); const [collectionOrder, setCollectionOrder] = useState([]); const [isInstructionsExpanded, setIsInstructionsExpanded] = useState(false); + const [includeHeadliners, setIncludeHeadliners] = useState(true); const [hoveredItem, setHoveredItem] = useState(null); const [tooltipPosition, setTooltipPosition] = useState<{ x: number; y: number } | null>(null); const isTouchDevice = typeof window !== "undefined" && ("ontouchstart" in window || navigator.maxTouchPoints > 0); - // Initialize with Hegota headliner EIPs and pending proposals + // Initialize with this fork's proposed EIPs and pending proposals useEffect(() => { - // Get EIPs that were headliner candidates for Hegota - const hegotaHeadlinerEips = eipsData - .filter((eip) => wasHeadlinerCandidate(eip, "hegota")) + // EIPs in the fork's inclusion funnel (proposed and beyond, not declined) + const proposalEips = eipsData + .filter((eip) => isForkInclusionCandidate(eip, fork)) .map((eip) => ({ id: `eip-${eip.id}`, eip, + headliner: isHeadliner(eip, fork), tier: null, })); - // Get pending proposals for Hegota - const hegotaPendingProposals = getPendingProposalsForFork("hegota") + // Get pending proposals for this fork (no EIP number yet) + const pendingItems = getPendingProposalsForFork(fork) .map((proposal) => ({ id: `pending-${proposal.id}`, pendingProposal: proposal, + headliner: false, tier: null, })); - const allItems = [...hegotaHeadlinerEips, ...hegotaPendingProposals]; + const allItems = [...proposalEips, ...pendingItems]; // Try to load saved rankings from localStorage - const savedRankings = localStorage.getItem("hegota-rankings"); + const savedRankings = localStorage.getItem(storageKey); if (savedRankings) { try { const parsed = JSON.parse(savedRankings); @@ -198,14 +219,14 @@ const RankPage: React.FC = () => { } else { setItems(allItems); } - }, []); + }, [fork, storageKey]); // Save rankings to localStorage whenever they change useEffect(() => { if (items.length > 0) { - localStorage.setItem("hegota-rankings", JSON.stringify(items)); + localStorage.setItem(storageKey, JSON.stringify(items)); } - }, [items]); + }, [items, storageKey]); // Initialize expanded collections based on layers useEffect(() => { @@ -213,13 +234,12 @@ const RankPage: React.FC = () => { const unassigned = items.filter((item) => item.tier === null); const layers = new Set(); unassigned.forEach((item) => { - const layer = getItemLayer(item); - if (layer) layers.add(layer); + layers.add(getItemLayer(item) || 'Other'); }); if (layers.size > 0) { setExpandedCollections(layers); - // Keep consistent order: EL first, then CL - setCollectionOrder(['EL', 'CL'].filter(l => layers.has(l))); + // Keep a consistent order: EL, then CL, then any layer-less proposals. + setCollectionOrder(['EL', 'CL', 'Other'].filter(l => layers.has(l))); } } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -287,12 +307,18 @@ const RankPage: React.FC = () => { ); }; + // The rankable set, with headliner EIPs filtered out when the checkbox is off. + // Hidden items keep their tier assignment in `items` and reappear when re-enabled. + const visibleItems = includeHeadliners + ? items + : items.filter((item) => !item.headliner); + const getItemsInTier = (tierId: string) => { - return items.filter((item) => item.tier === tierId); + return visibleItems.filter((item) => item.tier === tierId); }; const getUnassignedItems = () => { - return items.filter((item) => item.tier === null); + return visibleItems.filter((item) => item.tier === null); }; const getUnassignedItemsByLayer = () => { @@ -333,7 +359,7 @@ const RankPage: React.FC = () => { }; const getTotalItemsCountByLayer = (layer: string): number => { - return items.filter((item) => getItemLayer(item) === layer).length; + return visibleItems.filter((item) => (getItemLayer(item) || 'Other') === layer).length; }; const toggleCollection = (collection: string) => { @@ -354,7 +380,7 @@ const RankPage: React.FC = () => { }; const generateTierImage = () => { - const rankedItems = items.filter((item) => item.tier !== null); + const rankedItems = visibleItems.filter((item) => item.tier !== null); if (rankedItems.length === 0) { alert("Please rank at least one proposal before generating an image."); return; @@ -488,7 +514,7 @@ const RankPage: React.FC = () => { ctx.textBaseline = "middle"; // Title in the center with date - const titleText = "Hegota Headliner Rankings"; + const titleText = `${displayName} Proposal Rankings`; const titleFont = `${13 * scale}px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif`; const dateFont = `${13 * scale}px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif`; @@ -511,10 +537,10 @@ const RankPage: React.FC = () => { ctx.fillStyle = "#f1f5f9"; ctx.fillText(` • ${dateStamp}`, titleStartX + titleWidth, footerY1); - // Line 2: 'Make your own at forkcast.org/rank' + // Line 2: 'Make your own at forkcast.org/rank/{fork}' const prefix = "Make your own at "; const logo = "forkcast"; - const suffix = ".org/rank"; + const suffix = `.org${rankPath}`; ctx.font = `${ 13 * scale }px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif`; @@ -540,7 +566,7 @@ const RankPage: React.FC = () => { const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; - a.download = "hegota-headliner-rankings.png"; + a.download = `${fork}-proposal-rankings.png`; document.body.appendChild(a); a.click(); document.body.removeChild(a); @@ -665,7 +691,7 @@ const RankPage: React.FC = () => { const handleReset = () => { setItems((prev) => prev.map((item) => ({ ...item, tier: null }))); - localStorage.removeItem("hegota-rankings"); + localStorage.removeItem(storageKey); }; const handleExternalLinkClick = (linkType: string, url: string) => { @@ -679,13 +705,13 @@ const RankPage: React.FC = () => {

- Hegota Headliner Tier Maker + {displayName} Tier Maker

@@ -723,44 +749,46 @@ const RankPage: React.FC = () => {

Users, node operators, app developers, core developers, and any other stakeholders - are invited to voice their support for their preferred headliner proposals for the Hegota upgrade. + are invited to voice their support for their preferred proposals for the {displayName} upgrade.

- Drag and drop (desktop) or tap-to-assign (mobile) the headliner proposals + Drag and drop (desktop) or tap-to-assign (mobile) the proposals into tiers. S-tier represents your highest priority proposals, while D-tier represents your lowest priority.

Download the image to share your rankings and start a conversation.{" "} - Learn more about Hegota + Learn more about {displayName} .

-
-
- - - + {tierConfig?.deadline && ( +
+
+ + + +
+

+ The deadline for headliner proposal submissions was {tierConfig.deadline}. +

-

- The deadline for headliner proposal submissions was February 4th, 2025. -

-
+ )}
)}
@@ -769,7 +797,7 @@ const RankPage: React.FC = () => {

Your Rankings

- forkcast.org/rank + forkcast.org{rankPath}
{/* Scrollable tier rows container */} @@ -907,17 +935,30 @@ const RankPage: React.FC = () => { {/* Unassigned Items */}
-
-

- Headliner Proposals - - ({getUnassignedItems().length} unranked) - -

- {items.filter((item) => item.tier !== null).length > 0 && ( -
- Ready to generate image -
+
+
+

+ Proposals + + ({getUnassignedItems().length} unranked) + +

+ {visibleItems.filter((item) => item.tier !== null).length > 0 && ( +
+ Ready to generate image +
+ )} +
+ {items.some((item) => item.headliner) && ( + )}
@@ -1127,7 +1168,7 @@ const RankPage: React.FC = () => { {hoveredItem.eip && ( fork.forkName.toLowerCase() === "hegota")?.champions} + champions={hoveredItem.eip.forkRelationships.find(fr => fr.forkName.toLowerCase() === fork)?.champions} /> )} {hoveredItem.pendingProposal && hoveredItem.pendingProposal.champions.length > 0 && ( diff --git a/src/components/network-upgrade/TierMakerLink.tsx b/src/components/network-upgrade/TierMakerLink.tsx new file mode 100644 index 00000000..91b91649 --- /dev/null +++ b/src/components/network-upgrade/TierMakerLink.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import { forkHasTierMaker } from '../../utils/tierMaker'; + +interface TierMakerLinkProps { + /** Lowercase upgrade id, e.g. 'hegota'. */ + forkId: string; +} + +/** + * Inline header link to a fork's proposal tier maker (`/rank/{fork}`). Renders + * nothing for forks without a tier maker, so it is safe to drop into any upgrade + * page header. Styled to match the adjacent "View Meta EIP Discussion" link. + */ +export const TierMakerLink: React.FC = ({ forkId }) => { + const id = forkId.toLowerCase(); + if (!forkHasTierMaker(id)) return null; + + return ( + + Rank the proposals + + + + + ); +}; diff --git a/src/components/network-upgrade/VoiceYourSupportCTA.tsx b/src/components/network-upgrade/VoiceYourSupportCTA.tsx index 9d2edcca..8cddab53 100644 --- a/src/components/network-upgrade/VoiceYourSupportCTA.tsx +++ b/src/components/network-upgrade/VoiceYourSupportCTA.tsx @@ -25,7 +25,7 @@ export const VoiceYourSupportCTA: React.FC = ({ forkNa
Create Your Ranking diff --git a/src/components/network-upgrade/index.ts b/src/components/network-upgrade/index.ts index 3c6d3b31..762ec37c 100644 --- a/src/components/network-upgrade/index.ts +++ b/src/components/network-upgrade/index.ts @@ -10,4 +10,5 @@ export * from './PectraTimeline'; export * from './HeadlinerOptions'; export * from './NetworkUpgradeTimeline'; export * from './OverviewSection'; -export * from './TableOfContents'; \ No newline at end of file +export * from './TableOfContents'; +export * from './TierMakerLink'; \ No newline at end of file diff --git a/src/domain/routes/pageMetadata.ts b/src/domain/routes/pageMetadata.ts index 81428b4a..fe13d0fb 100644 --- a/src/domain/routes/pageMetadata.ts +++ b/src/domain/routes/pageMetadata.ts @@ -37,8 +37,8 @@ export const staticPageMetadata = { description: 'Key decisions from Ethereum AllCoreDevs meetings.', }, rank: { - title: 'Headliner Rankings - Forkcast', - description: 'Rank and compare headliner proposals for upcoming Ethereum network upgrades.', + title: 'Proposal Rankings - Forkcast', + description: 'Rank and compare proposals for upcoming Ethereum network upgrades.', }, eipsIndex: { title: 'EIP Directory - Forkcast', diff --git a/src/pages/rank.astro b/src/pages/rank.astro deleted file mode 100644 index e51419cc..00000000 --- a/src/pages/rank.astro +++ /dev/null @@ -1,9 +0,0 @@ ---- -import Layout from '../layouts/Layout.astro'; -import RankPage from '../components/RankPage'; -import { staticPageMetadata } from '../domain/routes/pageMetadata'; ---- - - - - diff --git a/src/pages/rank/[fork].astro b/src/pages/rank/[fork].astro new file mode 100644 index 00000000..8fc08d33 --- /dev/null +++ b/src/pages/rank/[fork].astro @@ -0,0 +1,16 @@ +--- +import Layout from '../../layouts/Layout.astro'; +import RankPage from '../../components/RankPage'; +import { staticPageMetadata } from '../../domain/routes/pageMetadata'; +import { getTierMakerForkIds } from '../../utils/tierMaker'; + +export function getStaticPaths() { + return getTierMakerForkIds().map((fork) => ({ params: { fork } })); +} + +const { fork } = Astro.params; +--- + + + + diff --git a/src/utils/eip.test.ts b/src/utils/eip.test.ts index 89d5881a..735460ec 100644 --- a/src/utils/eip.test.ts +++ b/src/utils/eip.test.ts @@ -6,6 +6,7 @@ import { getStageAbbreviation, getSummaryDescription, getUpgradeAnchorExpansionState, + isForkInclusionCandidate, isPendingEip, } from './eip'; import type { EIP } from '../types'; @@ -70,6 +71,36 @@ describe('inclusion stage labels', () => { }); }); +describe('isForkInclusionCandidate', () => { + const withStatus = (forkName: string, ...statuses: string[]): EIP => + makeEip({ + forkRelationships: [ + { + forkName, + wasHeadlinerCandidate: false, + isHeadliner: false, + statusHistory: statuses.map((status) => ({ status, call: null, date: null })), + }, + ] as EIP['forkRelationships'], + }); + + it('includes EIPs in the inclusion funnel (proposed and beyond)', () => { + expect(isForkInclusionCandidate(withStatus('Hegota', 'Proposed'), 'hegota')).toBe(true); + expect(isForkInclusionCandidate(withStatus('Hegota', 'Considered'), 'hegota')).toBe(true); + expect(isForkInclusionCandidate(withStatus('Hegota', 'Scheduled'), 'hegota')).toBe(true); + expect(isForkInclusionCandidate(withStatus('Hegota', 'Included'), 'hegota')).toBe(true); + }); + + it('excludes declined, withdrawn, and other forks', () => { + expect(isForkInclusionCandidate(withStatus('Hegota', 'Declined'), 'hegota')).toBe(false); + expect(isForkInclusionCandidate(withStatus('Hegota', 'Withdrawn'), 'hegota')).toBe(false); + // Latest status wins: proposed then declined is not a candidate. + expect(isForkInclusionCandidate(withStatus('Hegota', 'Proposed', 'Declined'), 'hegota')).toBe(false); + // No relationship for the requested fork. + expect(isForkInclusionCandidate(withStatus('Glamsterdam', 'Proposed'), 'hegota')).toBe(false); + }); +}); + describe('getEipIdFromHash', () => { it('parses EIP anchor hashes', () => { expect(getEipIdFromHash('#eip-8011')).toBe(8011); diff --git a/src/utils/eip.ts b/src/utils/eip.ts index cb0332ee..72c40b8a 100644 --- a/src/utils/eip.ts +++ b/src/utils/eip.ts @@ -56,6 +56,21 @@ export const getInclusionStage = (eip: EIP, forkName?: string): InclusionStage = return INCLUSION_STAGE_BY_STATUS[status] ?? 'Unknown'; }; +/** + * Whether an EIP is a live inclusion candidate for a fork: it entered the + * inclusion funnel (Proposed/Considered/Scheduled/Included) and was not later + * declined or withdrawn. This is the set the tier maker lets people rank. + */ +export const isForkInclusionCandidate = (eip: EIP, forkName?: string): boolean => { + const stage = getInclusionStage(eip, forkName); + return ( + stage === 'Proposed for Inclusion' || + stage === 'Considered for Inclusion' || + stage === 'Scheduled for Inclusion' || + stage === 'Included' + ); +}; + /** * Get the compact display label for an inclusion stage. */ diff --git a/src/utils/index.ts b/src/utils/index.ts index b66a14fb..56fbbd0d 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,4 +1,5 @@ export * from './eip'; +export * from './tierMaker'; export * from './colors'; export * from './markdown'; export * from './timeline'; diff --git a/src/utils/tierMaker.ts b/src/utils/tierMaker.ts new file mode 100644 index 00000000..af291128 --- /dev/null +++ b/src/utils/tierMaker.ts @@ -0,0 +1,23 @@ +// Forks with a public proposal tier maker at `/rank/{fork}`. Adding one here +// gives it a tier-maker page and an upgrade-page header link automatically; add +// a fork once it is actively collecting proposals for inclusion. +// `deadline`: optional headliner submission deadline shown in the info panel. +interface TierMakerConfig { + deadline?: string; +} + +const TIER_MAKER_FORKS: Record = { + hegota: { deadline: 'February 4th, 2025' }, + glamsterdam: {}, +}; + +/** Whether a fork (by upgrade id, case-insensitive) has a tier maker. */ +export const forkHasTierMaker = (forkName: string): boolean => + forkName.toLowerCase() in TIER_MAKER_FORKS; + +/** Lowercase upgrade ids of every fork that has a tier maker. */ +export const getTierMakerForkIds = (): string[] => Object.keys(TIER_MAKER_FORKS); + +/** Tier-maker config for a fork, or undefined when it has none. */ +export const getTierMakerConfig = (forkName: string): TierMakerConfig | undefined => + TIER_MAKER_FORKS[forkName.toLowerCase()];