feat: section indexes, so the routes can be found
deploy / build (push) Successful in 7m33s
deploy / deploy-web (push) Successful in 5s
deploy / deploy-api (push) Successful in 15s

Every route added lately was reachable only by already knowing an address — a
block hash, a preimage, an SS58 string, a spec_version. Fine for a link somebody
sent you, useless for discovering the pages exist. A kind with nothing after it
is now the index of that kind, and a nav under the chain chips names them.

Not the same page three times. Blocks is the record behind the live ticker,
paged by height. Accounts ranks by tokens earned over the whole indexed record,
which is a different question from the standings' blocks-per-window and here a
different answer: difficulty rose 346-fold inside mainnet's first day, so an
early block cost a three-hundredth of a current one. Runtimes is the only list
with no other home — a block does not name its runtime and neither do the
standings.

No `/:chain/miner`: the standings already are the index of miners, and a second
page of the same rows under another address would be two answers to one
question. That path redirects there, along with anything else that resolves to
the front page without being spelled like it, so the address bar and the section
nav agree about where the reader is.

Two things caught by looking at the rendered pages:

The block index's gaps came from `observed_at` and read *three milliseconds*
between blocks on a chain targeting twelve seconds — exactly the trap CLAUDE.md
records, since a gap fill writes a whole batch within one second. They come from
`authored_at` now, the chain's own clock.

The first page of that index came from the live ticker, which is ordered
oldest-first because that is the order it is pushed in, so page one climbed and
every page after it descended. The index sends a cursor for its first page too.

Accounts shows the address beside any node name: one node legitimately reports
for several payout addresses, and the first cut had two rows reading
`pearl-prover` with nothing to tell them apart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
This commit is contained in:
2026-09-09 18:07:11 +03:00
co-authored by Claude Opus 5
parent 289a80a7eb
commit efbe901646
16 changed files with 1082 additions and 31 deletions
+67 -26
View File
@@ -11,12 +11,16 @@ import { useCallback, useEffect, useMemo } from 'react'
import { Link, useLocation, useNavigate } from 'react-router-dom'
import { AccountPanel } from './components/AccountPanel'
import { AccountsIndex } from './components/AccountsIndex'
import { BlockPanel } from './components/BlockPanel'
import { BlocksIndex } from './components/BlocksIndex'
import { BlockTicker } from './components/BlockTicker'
import { ChainSwitcher } from './components/ChainSwitcher'
import { Leaderboard } from './components/Leaderboard'
import { MinerPanel } from './components/MinerPanel'
import { RuntimePanel } from './components/RuntimePanel'
import { RuntimesIndex } from './components/RuntimesIndex'
import { SectionNav } from './components/SectionNav'
import { StatBar } from './components/StatBar'
import { seconds, windowSpan } from './lib/format'
import { href, parse, WINDOWS } from './lib/routes'
@@ -49,6 +53,25 @@ export default function App() {
}
}, [route.chain, route.window, fallbackChain, navigate])
// Normalise a path that resolves to the standings but is not spelled like
// them: `/quantus`, `/quantus/miner` (the standings *are* the miner index),
// and anything unrecognised, which `parse` degrades to the front page rather
// than a 404. Without this the address bar keeps saying something the page is
// not, and the section nav cannot mark where the reader is.
useEffect(() => {
if (!chain) return
const standings =
route.index === null &&
route.block === null &&
route.miner === null &&
route.account === null &&
route.runtime === null
const canonical = href({ chain, window: route.window })
if (standings && location.pathname !== canonical) {
navigate(canonical, { replace: true })
}
}, [chain, route, location.pathname, navigate])
useWatch(chain, route.window)
/**
@@ -111,6 +134,8 @@ export default function App() {
<ChainSwitcher chains={state.chains} active={chain} window={route.window} />
<SectionNav chain={chain} route={route} window={route.window} />
{info?.status === 'awaiting' && (
<div className="banner banner-warn">
<strong>{info.display_name}</strong> has not started producing blocks yet. This page will
@@ -164,33 +189,49 @@ export default function App() {
for. */}
{route.runtime !== null && chain && <RuntimePanel chain={chain} spec={route.runtime} />}
{!route.account && !route.miner && !route.block && route.runtime === null && (
<div className="two-col">
<section className="panel">
<div className="panel-head">
<h2 className="panel-title">The Standings</h2>
<span className="eyebrow">
last {activeWindow.blocks.toLocaleString('en-US')} blocks ·{' '}
{windowSpan(activeWindow.blocks, interval)}
{pinned.length > 0 && ` · ${pinned.length} marked yours`}
</span>
</div>
<Leaderboard rows={state.leaderboard} chain={chain} ready={state.ready} />
</section>
<section className="panel">
<div className="panel-head">
<h2 className="panel-title">Live Blocks</h2>
<span className="eyebrow">
{state.summary?.block_interval_seconds
? `${seconds(state.summary.block_interval_seconds)} apart`
: 'measuring'}
</span>
</div>
<BlockTicker blocks={state.blocks} chain={chain} />
</section>
</div>
{route.index === 'block' && chain && <BlocksIndex chain={chain} />}
{route.index === 'account' && chain && (
<AccountsIndex
chain={chain}
decimals={info?.token_decimals ?? 12}
symbol={info?.token_symbol ?? ''}
/>
)}
{route.index === 'runtime' && chain && (
<RuntimesIndex chain={chain} current={state.summary?.spec_version ?? null} />
)}
{!route.account &&
!route.miner &&
!route.block &&
route.runtime === null &&
route.index === null && (
<div className="two-col">
<section className="panel">
<div className="panel-head">
<h2 className="panel-title">The Standings</h2>
<span className="eyebrow">
last {activeWindow.blocks.toLocaleString('en-US')} blocks ·{' '}
{windowSpan(activeWindow.blocks, interval)}
{pinned.length > 0 && ` · ${pinned.length} marked yours`}
</span>
</div>
<Leaderboard rows={state.leaderboard} chain={chain} ready={state.ready} />
</section>
<section className="panel">
<div className="panel-head">
<h2 className="panel-title">Live Blocks</h2>
<span className="eyebrow">
{state.summary?.block_interval_seconds
? `${seconds(state.summary.block_interval_seconds)} apart`
: 'measuring'}
</span>
</div>
<BlockTicker blocks={state.blocks} chain={chain} />
</section>
</div>
)}
<footer className="footer">
<span>
+56
View File
@@ -0,0 +1,56 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AttributionSource } from "./AttributionSource";
import type { BigUintDec } from "./BigUintDec";
import type { MinerId } from "./MinerId";
/**
* One row of the accounts index: an account and what it has earned.
*/
export type AccountRow = {
/**
* Position in the ranking, 1-based.
*/
rank: number,
/**
* The SS58 address, which is what a reader recognises and what the page
* for it is addressed by.
*/
address: string,
/**
* The raw account id, `0x` hex.
*/
account: string,
/**
* The reward preimage behind it, when the observer recorded a block it was
* paid for. Absent for an account whose blocks all predate this observer.
*/
miner: MinerId | null,
/**
* A telemetry name for that miner, if one is held.
*/
display: string | null,
/**
* Where `display` came from. Carried for the same reason the leaderboard
* carries it: a name here is an inference from which node reported a block
* first, and a row that renders a guess identically to a settled name is
* making a claim the observer cannot support.
*/
attribution: AttributionSource,
/**
* Agreement behind the held name, 0.0–1.0. Meaningless when `attribution`
* is `Preimage`.
*/
confidence: number,
/**
* How many votes are behind it. A fraction alone cannot tell one vote of
* one from nine of nine, and those are not the same claim.
*/
attribution_votes: number,
/**
* How many blocks it was paid for.
*/
blocks: number,
/**
* Their sum, in the chain's smallest unit.
*/
total: BigUintDec, };
+23
View File
@@ -7,9 +7,11 @@
*/
import type { AccountDetail } from './generated/AccountDetail'
import type { AccountRow } from './generated/AccountRow'
import type { ApiError } from './generated/ApiError'
import type { BlockDetail } from './generated/BlockDetail'
import type { ChainSeries } from './generated/ChainSeries'
import type { RecentBlock } from './generated/RecentBlock'
import type { MinerDetail } from './generated/MinerDetail'
import type { RuntimeDetail } from './generated/RuntimeDetail'
import type { RuntimeSummary } from './generated/RuntimeSummary'
@@ -102,6 +104,27 @@ export function fetchAccount(
)
}
/** Accounts ranked by what they have been paid, most first. */
export function fetchAccounts(chain: string, signal?: AbortSignal): Promise<AccountRow[]> {
return get<AccountRow[]>(`/chains/${encodeURIComponent(chain)}/accounts`, signal)
}
/**
* A page of block history, newest first.
*
* Without `before` this is the live ticker out of the API's memory, which is
* what the front of the list is; with it, the record. Same question, and the
* boundary is whether the caller is at the front.
*/
export function fetchBlockPage(
chain: string,
before?: number,
signal?: AbortSignal,
): Promise<RecentBlock[]> {
const cursor = before === undefined ? '' : `?before=${before}`
return get<RecentBlock[]>(`/chains/${encodeURIComponent(chain)}/blocks${cursor}`, signal)
}
/** Every runtime this chain has been seen running, oldest first. */
export function fetchRuntimes(chain: string, signal?: AbortSignal): Promise<RuntimeSummary[]> {
return get<RuntimeSummary[]>(`/chains/${encodeURIComponent(chain)}/runtimes`, signal)
+154
View File
@@ -0,0 +1,154 @@
/**
* Accounts, by what they have been paid.
*
* Not the standings under another name. The standings rank by blocks won in a
* window and answer "who is winning right now"; this ranks by tokens over the
* whole indexed record and answers "who has been paid the most". On a chain
* whose difficulty rose 346-fold inside its first day those are emphatically
* not the same list — an early block cost a three-hundredth of a current one
* and was paid accordingly.
*/
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import type { AccountRow } from '../api/generated/AccountRow'
import { RequestFailed, fetchAccounts } from '../api/rest'
import { exactTokens, height as fmtHeight, shortAddress, tokens } from '../lib/format'
import { href } from '../lib/routes'
export function AccountsIndex({
chain,
decimals,
symbol,
}: {
chain: string
decimals: number
symbol: string
}) {
const [rows, setRows] = useState<AccountRow[] | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const controller = new AbortController()
setRows(null)
setError(null)
fetchAccounts(chain, controller.signal)
.then(setRows)
.catch((e: unknown) => {
if (controller.signal.aborted) return
setError(e instanceof RequestFailed ? e.message : 'Could not reach the observer.')
})
return () => controller.abort()
}, [chain])
const max = rows && rows.length > 0 ? Number(BigInt(rows[0]!.total.slice(0, 15))) : 0
return (
<section className="panel" style={{ marginBottom: 26 }}>
<div className="panel-head">
<h2 className="panel-title">Accounts</h2>
<span className="eyebrow">by mining rewards, all recorded history</span>
</div>
{error && <p className="empty">{error}</p>}
{!error && rows === null && <p className="empty">Reading the record…</p>}
{rows !== null && rows.length === 0 && (
<p className="empty">
No rewards indexed for this chain yet. The event index fills outward from the tip.
</p>
)}
{rows !== null && rows.length > 0 && (
<div className="scroll-x">
<table className="board">
<caption className="visually-hidden">
Accounts ranked by mining rewards received
</caption>
<thead>
<tr>
<th scope="col">#</th>
<th scope="col" className="left">
Account
</th>
<th scope="col">Blocks</th>
<th scope="col">Earned</th>
<th scope="col">Share</th>
</tr>
</thead>
<tbody>
{rows.map((row) => {
// The same thin-evidence test the leaderboard uses, so a guess
// is marked the same way in both places rather than reading as
// settled here and tentative there.
const named = row.display !== null && row.attribution !== 'preimage'
const tentative = named && (row.attribution_votes < 3 || row.confidence < 0.6)
// Leading digits only: these are u128 and the bar is a shape.
const share = max > 0 ? Number(BigInt(row.total.slice(0, 15))) / max : 0
return (
<tr key={row.account}>
<td className={`rank rank-${row.rank}`}>{row.rank}</td>
<td className="left">
<div className="miner-cell">
<Link
to={href({ chain, account: row.address })}
className={named ? 'miner-name' : 'miner-name anonymous'}
title={row.address}
>
{named ? row.display : shortAddress(row.address)}
</Link>
{/* The address as well as the name, whenever there is a
name. A node name is not unique to an account — one
node legitimately reports for several payout
addresses, and this page had two rows reading
"pearl-prover" with nothing to tell them apart. The
address is what the row actually is. */}
{named && (
<span className="account-address">{shortAddress(row.address)}</span>
)}
{tentative && (
<span
className="chip chip-named chip-guess"
title={`Resting on ${row.attribution_votes} attributed block${
row.attribution_votes === 1 ? '' : 's'
}; a later block may correct it.`}
>
node?
</span>
)}
{row.attribution === 'carried' && (
<span
className="chip chip-named chip-guess"
title="Name carried from another chain, matched on the same reward preimage."
>
elsewhere
</span>
)}
</div>
</td>
<td className="numeral">{fmtHeight(row.blocks)}</td>
<td className="numeral" title={`${exactTokens(row.total, decimals)} ${symbol}`}>
{tokens(row.total, decimals, 2)} {symbol}
</td>
<td>
<div className="share-bar" aria-hidden="true">
<i style={{ width: `${Math.max(share * 100, 1)}%` }} />
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
<p className="panel-note">
Summed from <code>MiningRewards::MinerRewarded</code> events decoded against the runtime
that produced each block. This is what the chain paid, not what an account holds — it says
nothing about transfers out, and an account that has never mined does not appear however
much it holds.
</p>
</section>
)
}
+148
View File
@@ -0,0 +1,148 @@
/**
* Blocks, newest first, paged back through the record.
*
* The ticker beside the standings is the live feed and holds forty rows. This
* is the history behind it: the same rows, from the database rather than from
* memory, and pageable to wherever this observer's record begins.
*
* The first page sends a cursor too, rather than letting it default. Without
* one the API answers with the live ticker, which is ordered oldest-first
* because that is the order it is pushed in — so the first page climbed and
* every page after it descended. `MAX_SAFE_INTEGER` is "before everything",
* which is what the front of a descending list means.
*
* The gap here is from the chain's own clock, not from when the observer saw
* each block. `observed_at` is a measurement only at the tip — a gap fill
* writes a whole batch within the same second — so differencing it down a page
* of history reports milliseconds between blocks on a chain targeting seconds.
* The API does that conversion; this only renders it.
*/
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import type { RecentBlock } from '../api/generated/RecentBlock'
import { RequestFailed, fetchBlockPage } from '../api/rest'
import { height as fmtHeight, seconds, when } from '../lib/format'
import { href } from '../lib/routes'
export function BlocksIndex({ chain }: { chain: string }) {
const [rows, setRows] = useState<RecentBlock[] | null>(null)
const [error, setError] = useState<string | null>(null)
// The cursor, not the rows: paging replaces the page rather than appending,
// because a block history is read by position and an endless scroll would
// make "where am I" unanswerable.
const [before, setBefore] = useState<number | null>(null)
// Above any height this or any chain will reach, and the same sentinel the
// store documents for an absent cursor.
const FRONT = Number.MAX_SAFE_INTEGER
useEffect(() => {
const controller = new AbortController()
setRows(null)
setError(null)
fetchBlockPage(chain, before ?? FRONT, controller.signal)
.then(setRows)
.catch((e: unknown) => {
if (controller.signal.aborted) return
setError(e instanceof RequestFailed ? e.message : 'Could not reach the observer.')
})
return () => controller.abort()
}, [chain, before, FRONT])
const oldest = rows && rows.length > 0 ? rows[rows.length - 1]!.height : null
return (
<section className="panel" style={{ marginBottom: 26 }}>
<div className="panel-head">
<h2 className="panel-title">Blocks</h2>
<span className="eyebrow">
{rows && rows.length > 0
? `#${fmtHeight(rows[0]!.height)} – #${fmtHeight(oldest!)}`
: 'newest first'}
</span>
</div>
{error && <p className="empty">{error}</p>}
{!error && rows === null && <p className="empty">Reading the record…</p>}
{rows !== null && rows.length === 0 && (
<p className="empty">
Nothing recorded below this height. The observer's record begins where it started
watching, not at genesis.
</p>
)}
{rows !== null && rows.length > 0 && (
<div className="scroll-x">
<table className="board">
<caption className="visually-hidden">Blocks, newest first</caption>
<thead>
<tr>
<th scope="col">Block</th>
<th scope="col" className="left">
Miner
</th>
<th scope="col">Gap</th>
<th scope="col" className="event-when">
Seen
</th>
</tr>
</thead>
<tbody>
{rows.map((b) => (
<tr key={b.height}>
<td className="numeral">
<Link to={href({ chain, block: String(b.height) })}>
#{fmtHeight(b.height)}
</Link>
</td>
<td className="left">
<Link
to={href({ chain, miner: b.miner })}
className={
b.attribution === 'preimage' ? 'miner-name anonymous' : 'miner-name'
}
title={b.miner}
>
{b.display}
</Link>
</td>
<td className="numeral">
{b.gap_seconds === null ? '—' : `+${seconds(b.gap_seconds)}`}
</td>
<td className="numeral event-when" style={{ color: 'var(--text-muted)' }}>
{when(b.observed_at)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<div className="pager">
<button
className="segmented panel-button"
disabled={before === null}
onClick={() => setBefore(null)}
>
← Newest
</button>
<button
className="segmented panel-button"
disabled={oldest === null || oldest <= 1}
onClick={() => setBefore(oldest)}
>
Older →
</button>
</div>
<p className="panel-note">
Gaps are the difference between the authors' own timestamps — the chain's clock, not this
observer's. A block's <em>seen</em> time is when this observer first had it, which for
anything it caught up on is later than when the chain made it and is not a measurement of
anything.
</p>
</section>
)
}
+106
View File
@@ -0,0 +1,106 @@
/**
* Every runtime this chain has run.
*
* The one list on the site with no other home: a block does not name its
* runtime, the standings do not, and until this page existed a runtime was
* reachable only by guessing a `spec_version`. The heights come from bisecting
* the upgrade boundaries, so each row is the block that runtime took over at
* rather than the block this observer happened to notice it.
*/
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import type { RuntimeSummary } from '../api/generated/RuntimeSummary'
import { RequestFailed, fetchRuntimes } from '../api/rest'
import { height as fmtHeight, when } from '../lib/format'
import { href } from '../lib/routes'
export function RuntimesIndex({ chain, current }: { chain: string; current: number | null }) {
const [rows, setRows] = useState<RuntimeSummary[] | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const controller = new AbortController()
setRows(null)
setError(null)
fetchRuntimes(chain, controller.signal)
.then(setRows)
.catch((e: unknown) => {
if (controller.signal.aborted) return
setError(e instanceof RequestFailed ? e.message : 'Could not reach the observer.')
})
return () => controller.abort()
}, [chain])
return (
<section className="panel" style={{ marginBottom: 26 }}>
<div className="panel-head">
<h2 className="panel-title">Runtimes</h2>
<span className="eyebrow">newest first</span>
</div>
{error && <p className="empty">{error}</p>}
{!error && rows === null && <p className="empty">Reading the record…</p>}
{rows !== null && rows.length === 0 && (
<p className="empty">
No metadata cached for this chain yet. It is fetched the first time a block is decoded.
</p>
)}
{rows !== null && rows.length > 0 && (
<div className="scroll-x">
<table className="board">
<caption className="visually-hidden">
Runtime versions this chain has run, newest first
</caption>
<thead>
<tr>
<th scope="col">Version</th>
<th scope="col" className="left">
Name
</th>
<th scope="col">In force from</th>
<th scope="col">Metadata</th>
<th scope="col" className="last-seen">
Cached
</th>
</tr>
</thead>
<tbody>
{[...rows].reverse().map((r) => (
<tr key={r.spec_version}>
<td className="numeral">
<Link to={href({ chain, runtime: r.spec_version })}>v{r.spec_version}</Link>
</td>
<td className="left">
<span className="miner-cell">
<code>{r.spec_name}</code>
{r.spec_version === current && <span className="chip chip-you">Current</span>}
</span>
</td>
<td className="numeral">
<Link to={href({ chain, block: String(r.first_seen_height) })}>
#{fmtHeight(r.first_seen_height)}
</Link>
</td>
<td className="numeral">{Math.round(r.bytes / 1024)} KiB</td>
<td className="numeral last-seen" style={{ color: 'var(--text-muted)' }}>
{when(r.fetched_at)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<p className="panel-note">
Found by bisecting the upgrade boundaries — <code>spec_version</code> only increases, so the
blocks where it changes are a sorted sequence and each is found in a few dozen probes rather
than by walking the chain. Metadata is cached per version, so a runtime stays readable after
the node has pruned the state it would need to describe itself again.
</p>
</section>
)
}
+61
View File
@@ -0,0 +1,61 @@
/**
* The site's sections.
*
* Every route this site has was reachable only by already knowing an address —
* a block hash, a preimage, an SS58 string, a spec_version. That is fine for a
* link somebody sent you and useless for finding out the pages exist at all.
* This is the answer to "what else is here".
*
* The standings are the first entry because they are the front page, and there
* is no "Miners" beside them for the same reason: the standings already are the
* index of miners, and a second page listing the same rows under a different
* address would be two answers to one question.
*/
import { Link } from 'react-router-dom'
import type { Window as WindowName } from '../api/generated/Window'
import { href, type Route, SECTIONS } from '../lib/routes'
export function SectionNav({
chain,
route,
window: windowName,
}: {
chain: string | null
route: Route
/** Carried into the standings link so leaving a section and coming back does
* not silently reset the span someone chose. */
window: WindowName
}) {
if (!chain) return null
// The standings are current when nothing else is: no panel open, no index.
const onStandings =
route.index === null &&
route.block === null &&
route.miner === null &&
route.account === null &&
route.runtime === null
return (
<nav className="sections" aria-label="Section">
<Link
to={href({ chain, window: windowName })}
className="section-link"
aria-current={onStandings ? 'page' : undefined}
>
Standings
</Link>
{SECTIONS.map((s) => (
<Link
key={s.id}
to={href({ chain, index: s.id })}
className="section-link"
aria-current={route.index === s.id ? 'page' : undefined}
>
{s.label}
</Link>
))}
</nav>
)
}
+57
View File
@@ -1191,3 +1191,60 @@ tr.mine .share-bar > i {
.runtime-extensions {
counter-reset: none;
}
/* ---- section nav --------------------------------------------------------- */
/* A quieter register than the chain chips above it: choosing a chain changes
what every number on the page is about, and choosing a section only changes
which of them you are looking at. Words rather than chips says that. */
.sections {
display: flex;
flex-wrap: wrap;
gap: 18px;
padding: 0 0 18px;
margin-top: -4px;
}
.section-link {
font-size: 12px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-muted);
text-decoration: none;
padding-bottom: 3px;
border-bottom: 1px solid transparent;
}
.section-link:hover {
color: var(--text-primary);
}
/* Underline rather than a fill: the current section is a position, and a filled
chip here would compete with the active chain chip directly above it. */
.section-link[aria-current='page'] {
color: var(--data-bright);
border-bottom-color: var(--data);
}
/* ---- pager --------------------------------------------------------------- */
.pager {
display: flex;
gap: 10px;
padding: 14px 16px;
border-top: var(--rule);
}
.pager button:disabled {
color: var(--text-muted);
cursor: not-allowed;
}
/* The address beside a name on the accounts index. Muted and monospace: it is
the row's identity but not what a reader scans for, and one node name can
belong to several accounts. */
.account-address {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-muted);
}
+39
View File
@@ -40,6 +40,35 @@ export interface Route {
account: string | null
/** A `spec_version`. */
runtime: number | null
/**
* A kind with no value: the index of everything of that kind.
*
* `/quantus/runtime/` lists the runtimes, `/quantus/account/` the accounts,
* `/quantus/block/` the blocks. It is how the routes are discoverable at all
* — a page addressed by a hash nobody has cannot be found by wandering
* towards it.
*/
index: SectionName | null
}
/** The kinds that have an index. */
export type SectionName = 'block' | 'account' | 'runtime'
/**
* The sections, in the order the nav shows them.
*
* `miner` is deliberately absent: the standings *are* the index of miners, and
* a second page listing the same rows under a different address would be two
* answers to one question. `/:chain/miner/` redirects there.
*/
export const SECTIONS: { id: SectionName; label: string }[] = [
{ id: 'block', label: 'Blocks' },
{ id: 'account', label: 'Accounts' },
{ id: 'runtime', label: 'Runtimes' },
]
function section(name: string): SectionName | null {
return SECTIONS.some((s) => s.id === name) ? (name as SectionName) : null
}
export const EMPTY: Route = {
@@ -49,6 +78,7 @@ export const EMPTY: Route = {
miner: null,
account: null,
runtime: null,
index: null,
}
function asWindow(segment: string | undefined): WindowName | null {
@@ -106,6 +136,14 @@ export function parse(pathname: string): Route {
if (kind === 'account') return { ...EMPTY, chain, account: value }
if (kind === 'runtime') return { ...EMPTY, chain, runtime: spec(value) }
}
// A kind with nothing after it — `/quantus/account` or `/quantus/account/`,
// which `filter(Boolean)` above makes the same path. The index of that kind.
if (kind && !value) {
const named = section(kind)
if (named) return { ...EMPTY, chain, index: named }
// The standings are the miner index; there is no second page of them.
if (kind === 'miner') return { ...EMPTY, chain }
}
// A bare height or hash as the second segment: the shape the site used
// before this module existed. Recognised so links already sent stay good.
if (kind && isBlockRef(kind)) return { ...EMPTY, chain, block: kind.toLowerCase() }
@@ -118,6 +156,7 @@ export function href(route: Partial<Route>): string {
const chain = route.chain ?? ''
if (route.block) return `/${chain}/block/${route.block}`
if (route.miner) return chain ? `/${chain}/miner/${route.miner}` : `/miner/${route.miner}`
if (route.index) return `/${chain}/${route.index}`
if (route.account) return `/${chain}/account/${route.account}`
if (route.runtime !== null && route.runtime !== undefined) {
return chain ? `/${chain}/runtime/${route.runtime}` : `/runtime/${route.runtime}`