Skip to content

feat(collective-rewards): rebuild the page as a cycle-driven financial dashboard - #2181

Open
sleyter93 wants to merge 4 commits into
feature/redesignfrom
feat/collective-rewards-financial-dashboard
Open

feat(collective-rewards): rebuild the page as a cycle-driven financial dashboard#2181
sleyter93 wants to merge 4 commits into
feature/redesignfrom
feat/collective-rewards-financial-dashboard

Conversation

@sleyter93

@sleyter93 sleyter93 commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

What

Rebuilds /collective-rewards around one idea: every figure on the page describes a cycle, and the cycle is selectable. Picking a row in the history table — or a point on the chart — reloads the backing figure, the distribution panel and the header tiles together.

That selection is the reason for the refactor, not a nicety. The old components each fetched "the current cycle" on their own and had no way to describe any other one.

Sections

Component What it shows
DashboardMetrics ABI, total backing with its USD equivalent, all-time distribution with this cycle's delta, participants, cycle progress
CycleBackingChart Total stRIF backing indexed by cycle instead of by month, with a range toggle and an enlarged dot on the selected cycle
CycleDistribution The selected cycle's payout split by token and by role, with per-Backer and per-Builder averages
CycleHistoryTable One row per cycle, selectable by mouse or keyboard
PositionSimulator + CTAs What a given stRIF position would earn, and the two calls to action

Each presentational half has a Storybook story, following the Content/Loader split the repo already uses.

Data

useGetCycleHistory folds four sources into one row per cycle. Only cycles and daily allocations gate loading — the NotifyReward events and the per-cycle Backer counts feed two columns that degrade to a dash on their own, so a slow gauge fetch no longer holds up a table that can already render most of itself.

The Backer/Builder split reuses events the page was already downloading; no new fetch. It is weighted by fiat value rather than token count, so a cycle paying 1 rBTC to Backers and 1,000 RIF to Builders reads 99.9/0.1 instead of 50/50.

/api/cycles/backers is new. AllocationHistory records allocation changes, not balances, so counting Backers per cycle needs a running total: a Backer who allocated in cycle 30 and never touched it again is still backing in cycle 46.

Design alignment

The page was compared against the design prototype and the gaps closed, in rough order of visual weight:

  • Surfaces — 28px corner radius across the five cards, held in one constant so they cannot drift apart, and 16px gutters in place of 8px.
  • History table — status is a bordered pill instead of loose text, the panel header sits above a rule, and a selected row is a filled row with a short accent bar rather than an outline. The outline had to be assembled per cell, because borders on a <tr> do not render reliably; that forced border-separate, a transparent border on every cell to stop the rows shifting, and corners split across two cells. A background needs none of it, so that scaffolding is gone.
  • Chart — no gridlines, no visible value axis, no shaded band. The value axis is hidden rather than deleted: the scale still has to be declared, and dropping the element would hand the domain to recharts' defaults and silently change what the filled area means. The chart card now stretches to the height of the distribution panel beside it.
  • Split bar — RIF blue for Backers, primary orange for Builders.
  • Distribution panel — reward tokens carry their own logos instead of coloured dots.

Those last two are linked. Blue and orange were avoided at first because the coloured dots already used them for USDRIF and rBTC, so the same two hues a few lines apart would each have meant two things. Replacing the dots with logos freed the colours.

Reviewer notes

  • The new endpoint has now run against a populated indexer. A local Postgres with a mainnet state-sync snapshot was available for this round: 40 cycles, 493 daily allocations, 4,248 allocation-history rows, 19 Builders. /api/cycles/backers returns plausible per-cycle counts that climb over time (186 → 305), and the whole page renders against it. The earlier caveat on this PR no longer applies.
  • Cycle numbers are calendar-derived, not row indices. The 40 indexed cycles are numbered 4 → 44: cycles 1–3 predate the snapshot and cycle 5 is missing outright (a 28-day gap between 12 Dec 2024 and 9 Jan 2025). The table will show C4 next to C6. That is an indexer gap, not a rendering bug, but it is worth confirming with the state-sync team.
  • Backing figures use a fixed single decimal. millify keeps up to three and drops trailing zeros, so real data rendered 27.59M directly above 27.422M and the column read as noise. The fixtures had hidden this by being round numbers.
  • The default cycle skips an incomplete one. If the newest cycle's NotifyReward events never landed, its split is a dash — and that is the row a visitor sees before touching anything. Cycle 44 in the current snapshot is exactly this. Selection now falls through to the newest cycle that can answer in full. A running cycle is exempt, since it legitimately has no split for its first hours and is the one the page is built around.
  • The chart's tooltip is unverified. Removing the value axis rests on the tooltip carrying the exact figure, and it now writes the number out in full rather than abbreviating it. It could not be exercised in the headless preview used here — and it does not activate there on the pre-change code either, so this is not a regression, but please hover the chart in a real browser before approving.
  • USD figures for past cycles use spot prices. We keep no price history, so a settled cycle is re-valued whenever the market moves. The distribution panel says so instead of implying the numbers are fixed.
  • No tests were added for this round's changes. formatBackingCompact and the default-cycle fallback are both covered only by eye. Happy to add them if you would rather they were pinned.
  • A subtree is now orphanedCollectiveRewardsChart, CallToActionSection and everything below it, TotalRewardsDistributed, CycleMetrics, and the collective-rewards copies of TotalBacking / EstimatedRewards / ActiveBuilders / CountMetric. Roughly 20 files and 13 stories. Left in place deliberately so the new dashboard can be validated first; happy to remove in a follow-up. AnnualBackersIncentives is not in that list — my-rewards still uses it.

Excluded by product decision

  • The ABI boost mechanic from the design (the banner, +1.2%, and the 5%/10% split). No such mechanic exists on-chain.
  • The BUILDERS and ABI columns in the history table. No historical data for either; ABI would additionally need historical prices.

Still open against the design

  • The page header has no cycle badge and no subtitle.
  • No hover or reveal treatment on the cards. Agreed approach when we get to it: define the utilities globally, apply them here only.
  • The prototype's chart is zero-suppressed, which is why its line has visible movement where ours is nearly flat. Ours keeps the baseline at zero, so the shape is honest but reads as static — with the value axis now gone, a truncated baseline would be unlabelled and unreadable. Worth a design call.

Also

  • ComparativeProgressBar gained a segmented mode and an aria-label, and no longer computes a NaN width when every value is zero. It had no production call sites before this.
  • The 26-cycle year in the ABI formula is now a shared constant, so the simulator's projection cannot drift from the published ABI.

Verification

tsc and eslint clean. 125 tests pass, 27 of them new (18 on buildCycleHistory, 9 on the query).

The full page now renders against the local indexer snapshot with live CoinMarketCap prices, and row selection was confirmed end to end: clicking a row moves the chart header, the distribution panel and all five tiles to that cycle together. Chart and distribution cards were measured at equal height. Every component also checked in Storybook.

The simulator reproduces the design's figures exactly — 1,000,000 stRIF at 5% gives 50,000 RIF a year, $4,005, 1,923 per cycle — which is a useful check that the ABI formula and the cycle divisor line up.

Not covered: the running-cycle state. Every cycle in the snapshot is settled (the newest closed 9 Jul), so the "Day X of Y" progress bar, the live-cycle Builder count and the running status pill were exercised only in Storybook, never against real data.

…l dashboard

Replaces the single "rewards at work" section and its dual-axis chart with a
dashboard organised around one idea: every figure on the page describes a cycle,
and the cycle is selectable.

Selecting a row in the history table — or a point on the chart — reloads the
backing figure, the distribution panel and the header tiles together. That is
the reason for the refactor: the old components each fetched "the current cycle"
independently and had no way to describe any other one.

Sections:
- DashboardMetrics: the ABI figure alongside total backing, all-time
  distribution with this cycle's delta, participants, and cycle progress.
- CycleBackingChart: total stRIF backing indexed by cycle rather than by month,
  with a range toggle and a band marking the selected cycle.
- CycleDistribution: the selected cycle's payout split by token and by role,
  with per-Backer and per-Builder averages.
- CycleHistoryTable: one row per cycle, selectable by mouse or keyboard.
- PositionSimulator and the two calls to action.

useGetCycleHistory folds four sources into one row per cycle. Only cycles and
daily allocations gate loading; the NotifyReward events and the per-cycle Backer
counts feed two columns that degrade to a dash on their own, so a slow gauge
fetch no longer holds up a table that can already render most of itself.

The Backer/Builder split is weighted by fiat value, not token count — a cycle
paying 1 rBTC to Backers and 1,000 RIF to Builders is 99.9/0.1, not 50/50.

/api/cycles/backers is new. AllocationHistory records allocation changes rather
than balances, so counting Backers per cycle needs a running total: a Backer who
allocated in cycle 30 and never touched it again is still backing in cycle 46.
Its tests cover the query's shape, not its results — that still needs one run
against a populated indexer.

USD figures for past cycles are valued at spot prices, since no price history is
kept. The distribution panel says so rather than implying the numbers are fixed.

Also:
- ComparativeProgressBar gained a segmented mode and an aria-label, and no
  longer computes a NaN width when every value is zero.
- The 26-cycle year in the ABI formula is now a shared constant, so the
  simulator's projection cannot drift from the published ABI.

Deliberately excluded, per product decision: the ABI boost mechanic (no such
mechanic exists on-chain), and the Builders and ABI columns in the history table
(no historical data for either).
@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown

Dependency Review

✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.

Scanned Files

None

The history table promised that picking a row loads it "above", but only the
chart and the distribution panel listened. The metric tiles read cycles[0] and
never moved, so selecting cycle 43 left the header still describing cycle 46.

Selection now lives in CycleDashboardProvider, above every section. The tiles
relabel themselves as they follow it — "paid this cycle" becomes "paid in cycle
43", the day counter becomes the cycle's date window, and the Builder count
drops, because today's roster cannot describe a settled cycle. That also means
the per-cycle fold runs once for the page instead of once in each of the three
components that were calling the hook.

Accessibility:
- The table was a half-built ARIA grid: role="grid" with every row tabbable and
  no arrow keys, costing ten tab stops to pass. It now uses a roving tabindex
  with up/down navigation, and focus draws a visible outline rather than only
  tinting the background.
- The chart range and the simulator presets were rows of aria-pressed buttons,
  which announces independent toggles for what is a single choice. Both now use
  a shared SegmentedControl built as a radio group.
- Sections carry headings for the document outline even where the design shows
  none, so the page is navigable by landmark.

States:
- The distribution column held its width open as an empty 360px gap while the
  chart loaded beside it. It now shows a placeholder.
- An empty history collapsed the chart, the panel and the table into one line of
  text. Each keeps its own empty state.

The spot-price caveat applied to every USD figure on the page but appeared only
inside the distribution panel. It now sits once under the section it describes,
with the all-time figure carrying it as a tooltip.

Builders in the split bar were grey, which reads as disabled and implied they
were the lesser half. Two tints of one purple were tried and measured 2.45:1
against each other on the dark card; white against the brand purple measures
3.23:1, with both segments clearing 4.9:1 against the card behind them.
@sleyter93

Copy link
Copy Markdown
Collaborator Author

UX review pass

Reviewed the implementation against the design for coherence and pushed the fixes that didn't need a product call. Two findings were left open deliberately — see the end.

The page now actually follows the selected cycle

The table promised that picking a row loads it "above", but only the chart and the panel listened; the metric tiles read cycles[0] and never moved. Selecting cycle 43 left the header still describing cycle 46.

Selection moved into CycleDashboardProvider, above every section, and the tiles relabel themselves as they follow it:

Running cycle Cycle 43 selected
Hero Paid this cycle Paid in cycle 43
Cycle tile Cycle 46 · Day 11 of 14 Cycle 43 · 11 Jun → 25 Jun
Participants 308 — 292 Backers · 16 Builders 271 — 271 Backers

The Builder count drops on a settled cycle because today's roster cannot describe a past one. As a side effect the per-cycle fold runs once for the page instead of once in each of the three components that were calling the hook.

Accessibility

  • The table was a half-built ARIA grid — role="grid", every row tabbable, no arrow keys — costing ten tab stops to pass. Now a roving tabindex with up/down navigation, verified as one stop with the arrows moving selection and focus together. Focus draws a visible outline instead of only tinting the background.
  • The chart range and the simulator presets were rows of aria-pressed buttons, which announces independent toggles for what is a single choice. Both now use a shared SegmentedControl built as a radio group.
  • Sections carry headings for the document outline even where the design shows none.

States

  • The distribution column held its width open as an empty 360px gap while the chart loaded. It now shows a placeholder.
  • An empty history collapsed the chart, panel and table into a single line of text. Each keeps its own empty state.
  • The table's spinner sized itself to 20% of its container, which is enormous at full width.

Copy and colour

The spot-price caveat applied to every USD figure on the page but appeared only inside the distribution panel. It now sits once under the section it describes, with the all-time figure carrying it as a tooltip.

Builders in the split bar were grey, which reads as disabled and implied they were the lesser half. I tried two tints of one purple and measured 2.45:1 between them on the dark card; white against the brand purple measures 3.23:1, with both segments clearing 4.9:1 against the card. Every bar is also labelled with its percentages, so the split is never carried by colour alone.

Left open — these change the design, so they need a call

  1. Four of the six figures in the top row are duplicates. With the design's numbers, $13,148 appears three times (paid this cycle, the all-time delta, and the cycle tile) and $428,587 twice. TOTAL BACKING 26.8M appears both as a tile and as the chart's header, 200px apart. Six slots carrying two new facts.

  2. The progress bar sits directly under a dollar figure, so it reads as progress toward a monetary target. It measures days elapsed in the cycle. Two dimensions in one tile.

Both come from the design rather than the implementation, which is why I flagged them instead of changing them.

Compared the page against the design prototype and closed the gaps that
carried the most visual weight.

Surfaces: 28px corner radius across the five cards, held in one constant
so they cannot drift apart, and 16px gutters in place of 8px.

History table: status is a bordered pill instead of loose text, the panel
header sits above a rule, and a selected row is a filled row with a short
accent bar rather than an outline. The outline had to be assembled per
cell — borders on a `<tr>` do not render reliably — which forced
`border-separate`, a transparent border on every cell to stop the rows
shifting, and corners split across two cells. A background needs none of
that.

Chart: no gridlines, no visible value axis and no shaded band. The value
axis is hidden rather than deleted, because the scale still has to be
declared — dropping the element would hand the domain to recharts'
defaults and silently change what the filled area means. Its tooltip now
writes the figure out in full, since with the axis gone that is the only
place the exact number appears. The chart card stretches to the height of
the distribution panel beside it, so the row no longer ends ragged.

Split bar: RIF blue for Backers, primary orange for Builders. This
pairing was avoided before because the distribution panel used coloured
dots for USDRIF and rBTC, so the same two hues a few lines above would
each have meant two things. Those dots are now the tokens' own logos,
which frees the colours.

Backing figures use a fixed single decimal. `millify` keeps up to three
and drops trailing zeros, so real data rendered `27.59M` directly above
`27.422M` and the column read as noise; the fixtures had hidden it by
being round.
The newest cycle is not always complete. If its NotifyReward events never
landed, its Backer/Builder split is a dash — and since that cycle is what
the page loads first, the split is missing from the row a visitor sees
before they touch anything. Against the indexed mainnet data this is not
hypothetical: cycle 44 is settled and has no split.

Fall through to the newest cycle that can answer, leaving explicit
selection untouched.

A running cycle is exempt. It legitimately has no split for the first
hours of its life, and it is the cycle the page is built around, so
skipping it would hide the live one behind stale figures.
@sleyter93
sleyter93 changed the base branch from main to feature/redesign August 10, 2026 03:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant