feat: holders and delegates v3 (DEV-562, DEV-476) - #2084
Conversation
Dashboard: - min/max value filters on Delegates (voting power) and Token Holders (balance) - Delegates as default tab; sidebar renamed to "Stakeholders" - larger rows with bottom borders, continuous activity ring, VP as percent of quorum - inactive-delegate flag and 0/0 states (Inactive / No proposals / Never voted) - inactive-VP alert banner on Token Holders; "Voted X/Y (Inactive)" on delegate column - clickable addresses that re-point the drawer; per-address Activity tab - Balance History In / Out / Vesting; dust badge and Hide dust switch on Top Interactions - VP History low-importance filter and All time; time selector MAX plus custom calendar range - delegate drawer tabs renamed (Voting Power, Delegation History); Former Delegators view - proposal final-result filter on the votes tab API: - new endpoints: voting-powers/inactive-summary, accounts/:address/delegators/historical, addresses/labels; address filter on feed/events; proposalStatusIn on proposals-activity Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
1 Skipped Deployment
|
|
🔍 Vercel preview: https://anticapture-5ts4uxhpk-ful.vercel.app |
|
🚅 Deployed to the anticapture-pr-2084 environment in anticapture-infra
|
Address enrichment (ENS, contract flag, arkham labels) had no staleTime, and the global QueryClient defaults to 0, so every EnsAvatar/TypeCell refetched on each remount. Table re-renders (amplified by the inactive banner and per-row activity fetch) remounted rows and fired/canceled these requests repeatedly, flooding the address-enrichment API on every tab open. Give the useGetAddress and useGetAddresses calls a 5m staleTime / 30m gcTime so identical addresses dedupe and stay cached across remounts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- votes: rename user-vote filter labels For/Against to Yes/No - voting power: DS SegmentedControl for current/former view - voting power: summary (Current VP / Total VP Lost) on the selector row - former delegators: short date (Jan 3, 25), dedup VP impact when unchanged - delegation history: net VP change on graph, low-importance toggle on CSV row - activity: DS SegmentedControl for date and relevance - token holders: banner uses DS InlineAlert, table fills height below it - top interactions: hide-dust on CSV row, total as USD, Net Tokens In/Out (90D) - balance history: net balance change value on graph - period label: All time renamed to Max available data - table: footerActions slot; inline alert accepts ReactNode content Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- EnsAvatar: optional subtitle slot rendered under the name; the avatar stays vertically centered against the whole name + subtitle block - token holders: delegate column renders "Voted X/Y" via the subtitle slot so the avatar aligns with both lines - table: row dividers now live on the cells, since border-separate tables do not paint borders set on the <tr> Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Verified against the Figma frame measured pixel by pixel, and against the locally rendered table: - delegate cell: avatar is vertically centered against the whole name + "Voted x/y" block (measured offset 0), and the subtitle starts at the same x as the name, as in the design - row borders: border-separate tables never paint borders declared on the <tr>, and the first cell additionally cleared them on desktop, so the divider was missing entirely and never reached the Address column. The line is now drawn by a cell pseudo element spanning the full cell width, ignoring the horizontal padding. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- address column: long arkham labels are truncated instead of overflowing. The chain was broken by the tooltip trigger button, an inline-block whose shrink-to-fit width was set by the label, so `truncate` never had a width to work with. Clamping the trigger fixes every table using EnsAvatar. - avg vote timing: shows a skeleton while the per-row proposals activity loads, instead of the "-" it uses for delegates with no votes. - calendar popover: uses rounded-base, so the radius follows the DS token (0 for Anticapture, non-zero for whitelabels) instead of a hardcoded md. - token holders change column: right aligned, matching the delegates tab. - balance history and voting power graphs: the heading no longer changes between loading and loaded states. - drawer activity: the feed is scoped to the inspected wallet again. The address filter was fine; "All" omitted `relevance`, which the API reads as MEDIUM, so its value thresholds hid almost everything. The API now takes relevance=ALL to drop the threshold, and the drawer always sends the value explicitly. - drawer activity: infinite scroll works. The observer used the viewport as root while the list scrolls in its own container, leaving the sentinel on the clipped edge and the feed stuck on page one. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ment - holders and delegates page title reads "Stakeholders", matching its nav entry - delegate votes: the rate metric card is labelled "For Rate" - former delegators: VP Impact header aligns left - voting power summary: the loading skeleton aligns left with the value it replaces Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
An ENS record can be a 42 character address plus ".eth", which overflowed the address column and ran into the next one. DrawerAddressButton wraps the avatar in a button, and a button is inline-block, so it was sized by its content and the name below it never had a width to truncate against. Clamping it fixes every drawer table that renders an address. VP Impact is centered in both the header and the cells. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5373f2e6a3
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
A shortened address is already ellipsized in the middle ("0x39441f...bE12"),
so the CSS `truncate` on the EnsAvatar name span appended a second ellipsis
whenever the cell was too narrow, showing six dots instead of three.
The name span now clips shortened addresses without an ellipsis and keeps
`text-ellipsis` for real names. The delegation history and balance history
cells stop passing a redundant `truncate` through `nameClassName`, which
would otherwise override the fix via tailwind-merge.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 274dc96dee
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
The nine `.playwright-mcp/page-*.yml` page snapshots and the root `repro1.png` are debugging output from a local browser session, not fixtures or tests, and every run would add more. The output directory is ignored from now on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The `/:daoId/holders-and-delegates` redirect also matches AAVE, so the dedicated page was unreachable and AAVE users landed on the generic `[daoId]/stakeholders` one. That page renders the shared Delegates table, which reads the combined balance as voting power and requests `/proposals-activity`, an endpoint the AAVE API does not register. AAVE's page moves to a static `app/aave/stakeholders` route instead. Static segments win over `[daoId]`, so the redirect now lands on the specialized page and the AAVE nav entry (already pointing at `stakeholders`) resolves there too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`days` was parsed as an arbitrary string, so a stale or hand-edited `?days=foo` reached the switcher as a selected value while the tables silently fell back to 90 days. It is now parsed as an enum over the presets plus `max`/`custom`, and `custom` is demoted to the default unless `rangeFrom`/`rangeTo` both parse, which is the other way the controls could disagree with the data on screen. `feedRelevance` had the same problem with a worse ending: the API rejects anything outside ALL/HIGH/MEDIUM/LOW with a 400, so a stale value left the drawer's Activity tab on a permanent error state until the query string was cleared by hand. It now falls back to ALL. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both `/:dao/proposals-activity` and `/:dao/voting-powers/inactive-summary` already ignored votes cast after the period's upper bound, but not votes cast before its lower one. A proposal whose voting period overlaps `fromDate` is in scope, so a vote cast on it days earlier still filled `userVote`, counted toward "Voted X/Y", and cleared the delegate out of `inactiveDelegatedVotingPower`, against the "no votes cast in the selected period" semantics the UI states. Both vote queries now require `timestamp >= activityStart`. On the paginated query the bound stays in the LEFT JOIN's ON clause, so the proposal is still listed with no vote attached. With no `fromDate` the service passes the address's first vote, which no vote can precede, so nothing changes for unbounded periods. Also documents why former-delegator sequencing can rely on `(timestamp, log_index)`: no chain indexed today has a block time under two seconds, so two blocks cannot share a timestamp and interleave. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d7d1def9ea
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
Clicking an address inside the drawer only wrote `drawerAddress` to the URL, and not every owner reads it: the DAO overview chart, the activity feed and the proposal page hold the drawer's address in local state. In those drawers the click updated the query string and left the visible profile on the previous address. The drawer now also tracks a re-point of its own, exposed to its subtree through a context, and drops it as soon as the owner supplies a different address, which is what already re-points the URL-driven owners. The URL write stays, so links into a page-level drawer keep working and stay shareable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The last column renders the volume in fiat but was keyed on the transfer count it used to show, and its header read "Total Interactions". Its control never set a sort field either, only a direction, so it reordered rows by whatever `orderBy` happened to be active: transfer count by default, volume once the Volume filter had been used. Same arrow, two meanings, and dollars in an order the reader cannot see. It is now "Total Value (USD)", keyed on volume, and its control sorts by volume, which the endpoint already accepts. Since the fiat figure is the volume times a single price, ordering by volume is ordering by the number on screen. The arrow also stays neutral until the rows really are ordered by this column. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b15001f338
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
The item reads "Stakeholders" but built its href from the legacy `holders-and-delegates` slug. The sidebar and the mobile drawer mark the active item by comparing the pathname to that href, so on `/whitelabel/:daoId/stakeholders` nothing was highlighted, and clicking the item took the redirect hop first. The legacy slug stays in the base-path candidate list, so old whitelabel URLs keep resolving and still redirect onto the page. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: fe23bc1417
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| timestampColumn: string, | ||
| ) { | ||
| if (activityEnd === undefined) return sql.raw(""); | ||
| return sql` AND ${sql.raw(timestampColumn)} <= ${activityEnd}`; |
There was a problem hiding this comment.
Use voting start for activity upper bounds
On DAOs with a non-zero voting delay, this upper bound compares the selected toDate to the proposal creation timestamp, not the time voting opens. A custom period that ends after a proposal is created but before its voting delay expires will still include that proposal with no vote attached, so Voted X/Y and the mirrored inactive-summary window can mark delegates inactive for proposals they could not yet vote on; pass the voting delay separately and require the vote-start timestamp to be within the window.
Useful? React with 👍 / 👎.
| former_delegators AS ( | ||
| SELECT | ||
| ls.delegator AS delegator_address, | ||
| last_event.target_value::text AS amount, |
There was a problem hiding this comment.
Report VP lost from the move-away amount
When a delegator changes balance while still delegated here and later redelegates away, the last event that mentioned this delegate contains the old balance, while the move-away event contains the current amount actually removed. Returning last_event.target_value makes the Former Delegators table and Total VP Lost under- or over-state the VP lost after interim transfers; use the move-away value or reconstruct the target's current delegated amount at end_timestamp.
Useful? React with 👍 / 👎.
What
Holders & Delegates v3. Closes DEV-562 and DEV-476.
Dashboard
withRowBorderson the design-system Table), continuous activity ring, voting power as a percentage of quorum (red at 100%+, yellow at 50%+).InlineAlert; "Voted X/Y (Inactive)" under the delegate name.SwitcherDateRange); "Max available data" replaces "All time".SegmentedControlfor the current/former delegators view and for the Activity feed filters.API
GET /:dao/voting-powers/inactive-summary,GET /:dao/accounts/:address/delegators/historical,GET /:dao/addresses/labels.GET /:dao/feed/eventsgains an optionaladdressfilter (matches the address in any role, case-insensitive) and acceptsrelevance=ALL, which drops the value floor. Omittingrelevancestill defaults to MEDIUM, so existing consumers are unaffected.Testing
totalCount8729 versus 1980 for a single address, and every returned event involves that address.Self-review
Ran a multi-dimension review over the whole diff (API logic, API contract, data fetching, dashboard computation, design system and accessibility, tests, conventions) with an adversarial pass that tried to refute each finding. What survived and got fixed in this PR:
useDelegatesActivityonly recorded addresses whose fetch succeeded. A rejected or empty response left the address selectable again the moment it left the loading set, and since the effect depends on both sets it refetched the same addresses forever while the endpoint kept failing. Fetches now settle into their own set, and one address failing no longer discards the others./voting-powers/{address}param route. Both are now gated on the DAO exposing proposal activity.useGetAddress/useGetAddresseshad no stale time while the global QueryClient defaults to 0, so every avatar refetched on remount. Opening a tab fired hundreds of requests, many cancelled and refired. Now cached for 5 minutes.activityFromDatewas recomputed fromDate.now()on every render, so it changed whenever a render crossed a second boundary and re-keyed both the activity cache and the banner query.DrawerAddressButtonno longer nests the tooltip trigger button inside the row button, which cost a second tab stop and handed the accessible name to the tooltip.Removed from scope
The proposal Final Result filter (and the
proposalStatusInparam that backed it) was pulled out of this PR. The filter matched the storedproposals_onchain.status, but the service overwrites each proposal's status at read time with a derived value, and the indexer only ever persists ACTIVE, CANCELED, EXECUTED, PENDING, QUEUED and VETOED. So DEFEATED, SUCCEEDED, NO_QUORUM, EXPIRED and PENDING_EXECUTION could never match: on ENS, "Failed" returned zero rows and zeroed all four metric cards, "Passed" silently dropped SUCCEEDED, and "Canceled" was unsatisfiable because the query already excludes canceled proposals. Making it correct means either persisting the derived status from the indexer or expressing the derivation in SQL over end block, vote tallies, quorum and timelock, which is its own task rather than a detail of this one.Notes for review
@anticapture/clientSDK regenerates on the preview from the deployed Gateful spec, so the features backed by the new endpoints (inactive banner, Former Delegators, per-address Activity, Vesting labels) only show live data once this branch's API and Gateful are deployed. Local typechecks can pass or fail depending on which Gateful the codegen reached, so code should not compare against generated enum values it cannot guarantee exist.toDatereaches the voting-powers query and the banner but not the per-delegate activity request, so activity is computed overfromDate..now; and the new design-system Switch still hardcodes two colors instead of semantic tokens and ships without a story.EnsAvatartag set rather than Figma's Individual/EAO badges.🤖 Generated with Claude Code