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 <[email protected]>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
1 Skipped Deployment
|
|
🔍 Vercel preview: https://anticapture-46kslcqjk-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 <[email protected]>
- 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 <[email protected]>
- 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 <[email protected]>
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 <[email protected]>
- 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 <[email protected]>
…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 <[email protected]>
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 <[email protected]>
API: - proposals-activity accepts an optional `toDate`, enforced on the proposal timestamp in both the page and the analytics queries. The dashboard already sent it for custom ranges, where it was silently discarded, so bounded periods counted every proposal through today. - the feed's delegation enrichment keeps the row that mentions the filtered address. Partial delegations (SCR) write one row per delegatee out of a single DelegateChanged, all sharing tx hash and log index, so collapsing them by key could describe a delegate unrelated to the filtered address. Dashboard: - balance-change and voting-power-change totals read their period boundaries from their own limit-1 lookups instead of the plotted rows, which are capped at 1,000 and hide small events. Active accounts were reporting the change over a truncated suffix of the period. - "Hide dust" moves into the interactions query. Filtering client-side could empty a page, and an empty table drops the infinite-scroll sentinel, leaving qualifying rows on later pages unreachable. - per-address activity fetches carry a generation for the DAO and range, so a superseded response can no longer merge stale proposal counts into the rows. - the drawer's Activity tab only renders for DAOs whose API serves the feed; AAVE showed a permanent error state. An unknown tab in the URL now falls back to the first one instead of an empty body. - clicking an address in the drawer feed carries its entity type, so a delegate opens the delegate profile rather than the token-holder tabs. - the custom range calendar can apply a single day, via an explicit Apply. react-day-picker answers the first click with `from` equal to `to`, so the old inequality check made one specific day unselectable. Co-Authored-By: Claude Opus 5 <[email protected]>
…feat/holders-delegates-v3
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c038ed7139
ℹ️ 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".
…0860) Every in-drawer address column now states which kind of profile it points at, so DrawerAddressButton sets the drawerEntity override the same way the drawer's activity feed does. Clicking a delegator in a delegate drawer no longer opens that holder with delegate tabs, and clicking a delegate from a token holder's delegation history no longer keeps token holder tabs.
The holders and delegates tab param is parsed as an enum instead of a plain string, so a stale or hand-edited ?tab=foo coerces back to the default tab rather than missing every key in tabComponentMap and rendering an empty section body.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 60daa3c9f1
ℹ️ 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".
…eview #3675140866)
Partial delegation writes one delegations row per delegatee out of a single
DelegateChanged, so the primary row alone describes the event badly: an
unfiltered feed renders one arbitrary delegatee, and a feed filtered by the
delegator matches every sibling row, so picking one drops the others.
FeedDelegationMetadata gains an optional 'delegatees' array of
{ delegate, amount }, ordered by delegate address ascending and present only
when the event has more than one row. 'delegate', 'amount' and
'previousDelegate' keep their meaning and still come from the primary row that
indexDelegationsByKey selects, so the deployed client and the dashboard feed
renderer are unaffected.
…ages (review #3676245485) "Hide dust" is on by default and enforced by the query, so an address whose every interaction is under $1 came back empty and the early return took the whole table away, footer switch included, with no way to turn the filter back off. The table now stays mounted and shows an empty state that names the filter responsible. TopInteractions owns the genuinely-no-interactions case instead: its query carries no filters, so it hides the table and shows its blank slate alone rather than stacking two empty states.
…ew #3676245492) The override was a bare entity type, so any path that cleared drawerAddress without clearing it too (the section's tab cleanup, each parent's onClose) left it behind for the next address opened from a table, which then rendered the wrong profile's tabs. It is now recorded as '<entityType>:<address>' and honored only while the recorded address matches the drawer's, compared case-insensitively since the two come from different sources. Re-pointing the drawer drops the override by itself, so no cleanup path has to remember it, and a future one cannot reintroduce the bug. Encoding lives in a single useDrawerEntityOverride hook shared by both writers; DrawerActivityFeed writes through it directly, which retires the onEntityTypeChange prop that could not carry the address.
There was a problem hiding this comment.
💡 Codex Review
https://github.com/blockful/anticapture/blob/40f44142a6027c30d14818203b7b62de3cf587b3//tmp/anticapture-e7a/apps/dashboard/features/holders-and-delegates/components/DrawerActivityFeed.tsx#L105-L107
Re-point drawers through the owner state
When the profile drawer is opened from ActivityFeedSection, its address comes from local drawerState, not the drawerAddress query param. In that context, clicking an address inside this new drawer Activity tab only updates the URL here, so the visible drawer remains on the old address; pass a re-point callback down from the drawer owner (or make all owners read the same state) instead of writing only the query param.
ℹ️ 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".
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 23c90fe6b5
ℹ️ 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".
Drop comments that restated the code they sat on: JSX section labels (Filters, Timeline, Delegators), component headers that paraphrased the component name, and a guard comment narrating its own condition. Tighten the remaining ones to the reason the code is the way it is, and drop the trailing ticket refs, which point at the PR rather than at the code. Comments only, no behavior change.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: cd8d62ad26
ℹ️ 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".
| setMinValue(filterState.minAmount || null); | ||
| setMaxValue(filterState.maxAmount || null); |
There was a problem hiding this comment.
Filter AAVE delegation received by the displayed value
When a user applies the new min/max filter on the AAVE “Delegation received” column, these values are sent as fromValue/toValue, but the AAVE voting-power repository applies that amount filter to combinedPowerSql (delegated voting power plus wallet balance) while this table displays combinedPower - balance as Delegation received. For AAVE addresses with a large self balance but little or no delegated power, the filter can include rows that visibly do not satisfy the column filter, and max filters can exclude delegated rows because their own balance pushes the combined total over the threshold; filter the delegated-power expression or move this filter to the Total column.
Useful? React with 👍 / 👎.
| const userMin = filterVariables.minAmount | ||
| ? BigInt(filterVariables.minAmount) | ||
| : 0n; |
There was a problem hiding this comment.
Guard malformed minAmount before BigInt
Because minAmount comes from the URL, a stale or hand-edited value such as ?minAmount=1.5 or ?minAmount=abc now throws in render before the API/query layer can handle it, taking down the Top Interactions drawer. The other newly added amount filters treat URL values as user-editable and catch parse failures; do the same here or ignore invalid values before calling BigInt.
Useful? React with 👍 / 👎.
…iew) The AAVE delegates table renders `combined - balance` as "Delegation received", but the repository applied `fromValue`/`toValue` to the combined total (delegated power plus the account's own balance). A large self balance alone could satisfy a minimum, and it could push a genuinely delegated account past a maximum. Filter the delegated power expression instead, which also matches what `orderBy=votingPower` already sorts by on the same endpoint, and what every other DAO does. Also guard the Top Interactions `minAmount`/`maxAmount` parse: both come from the URL, so a stale or hand-edited `?minAmount=1.5` threw inside `BigInt()` while rendering and took the drawer down. Invalid values are now ignored. Co-Authored-By: Claude Opus 5 <[email protected]>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6b6c836cb0
ℹ️ 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".
| LEFT JOIN votes_onchain v ON p.id = v.proposal_id AND v.voter_account_id = ${address} | ||
| WHERE (p.timestamp + ${votingPeriodSeconds}) >= ${activityStart} | ||
| ${this.activityEndCondition(activityEnd, "p.timestamp")} | ||
| AND UPPER(p.status) <> 'CANCELED' |
There was a problem hiding this comment.
Bound activity votes to the selected range
For bounded ranges this only removes proposals opened after toDate; the joined votes_onchain rows are not constrained by v.timestamp (and the later analytics getUserVotes query has the same shape). When a custom range ends before an overlapping proposal closes, a delegate vote cast after the selected end date still populates userVote and increments votedProposals, so delegates can be shown as active despite the UI’s “no votes cast in the selected period” semantics. Add vote timestamp bounds for the selected range or change the metric/copy.
Useful? React with 👍 / 👎.
| WHERE NOT EXISTS ( | ||
| SELECT 1 | ||
| FROM votes_onchain v | ||
| WHERE v.voter_account_id = ap.account_id | ||
| AND v.proposal_id IN (SELECT id FROM window_proposals) |
There was a problem hiding this comment.
Bound inactive-summary votes to the selected range
This aggregate has the same bounded-range mismatch: window_proposals is date-limited, but any vote ever cast on those proposal IDs makes the delegate active. If a delegate votes after toDate on a proposal that opened inside the custom range, their current voting power is omitted from inactiveDelegatedVotingPower, so the banner understates inactive delegated VP for that range. Include v.timestamp in the from/to predicate before using the vote to satisfy EXISTS.
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