Feature/watchlist addresses#2785
Conversation
Made-with: Cursor
Made-with: Cursor
Made-with: Cursor
Made-with: Cursor
…r cleanup Made-with: Cursor
- Add HTML5 drag-and-drop for watchlist rows (feature flag) - Extend persistence, visibility refetch, and watchlist queries - Add integration, a11y, and unit tests; adjust test render utils Made-with: Cursor
|
@DUosey is attempting to deploy a commit to the Stacks Labs Team on Vercel. A member of the Team first needs to authorize it. |
There was a problem hiding this comment.
Pull request overview
Adds a client-side Watchlist feature for saving Stacks principals, showing aggregated balances/transactions, and surfacing new-activity UI (badge + optional toasts). It also centralizes token pricing into a fixed, explorer-wide constant configuration (no external price feeds).
Changes:
- Introduces Watchlist state management (Redux slice), persistence (localStorage hydration), validation, and utilities (ordering, portfolio summary, tx unification, deduped new-tx counting).
- Adds the
/watchlistpage UI (portfolio + distribution + combined tx feed + filters + drag-and-drop reorder) and integrates watchlist controls into the address page and nav. - Adds watchlist-specific React Query fetching (batch balances via API route + per-address tx queries) and updates global/fixed pricing utilities.
Reviewed changes
Copilot reviewed 65 out of 66 changed files in this pull request and generated 13 comments.
Show a summary per file
| File | Description |
|---|---|
| src/lib/crypto-prices.config.ts | Adds fixed STX/BTC USD constants + source marker. |
| src/features/watchlist/watchlistNewTxCountUtils.ts | Deduped “new tx” counting + dev logging helper. |
| src/features/watchlist/watchlist-usd.ts | USD formatting helper for micro-STX using fixed STX/USD. |
| src/features/watchlist/watchlist-slice.ts | Redux slice for watchlist items + hydration + notification prefs. |
| src/features/watchlist/watchlist-dnd-flag.ts | Feature flag for desktop HTML5 drag-and-drop reorder. |
| src/features/watchlist/validation.ts | Validates watchlist principal (address or contract id). |
| src/features/watchlist/utils/reorderUtils.ts | Array move + order assignment fallback logic. |
| src/features/watchlist/useWatchlistNewTxCount.ts | Hook computing + throttling nav badge count from tx queries. |
| src/features/watchlist/useWatchlist.ts | Main watchlist hook: CRUD, persistence, ordering, viewed markers. |
| src/features/watchlist/unifiedTxMap.ts | Unwraps v2 rows + maps txs into a unified watchlist tx model. |
| src/features/watchlist/types.ts | Watchlist types/constants/errors and unified tx types. |
| src/features/watchlist/storage.ts | localStorage load/save + quota handling + notification preference storage. |
| src/features/watchlist/portfolio-utils.ts | Micro-STX summation + portfolio summary builder. |
| src/features/watchlist/hooks/useWatchlistDragAndDrop.ts | HTML5 drag-and-drop state + handlers for row reordering. |
| src/features/watchlist/components/WatchlistDraggableRow.tsx | Table row wrapper enabling conditional DnD event wiring. |
| src/features/watchlist/components/WatchlistDragHandle.tsx | Drag handle UI with tooltip + disabled state. |
| src/features/watchlist/tests/watchlistNewTxCountUtils.spec.ts | Unit tests for deduped new-tx counting. |
| src/features/watchlist/tests/watchlist-slice.spec.ts | Reducer tests for watchlist slice actions. |
| src/features/watchlist/tests/validation.spec.ts | Unit tests for principal validation. |
| src/features/watchlist/tests/useWatchlistQueries.keys.spec.ts | Tests ensuring query keys isolate network/principals and timing constants. |
| src/features/watchlist/tests/useWatchlistNewTxCount.spec.tsx | Integration-ish tests via nav link badge behavior + throttling. |
| src/features/watchlist/tests/useWatchlistHtml5RowReorder.spec.ts | Hook tests for HTML5 DnD reorder handler behavior. |
| src/features/watchlist/tests/useWatchlist.spec.tsx | Hook tests for add/remove/toggle/persist/reorder behaviors. |
| src/features/watchlist/tests/unifiedTxMap.spec.ts | Unit tests for tx unwrapping/time/type mapping. |
| src/features/watchlist/tests/storage.spec.ts | Unit tests for localStorage parsing/saving and quota behavior. |
| src/features/watchlist/tests/reorderUtils.spec.ts | Unit tests for array move + order assignment. |
| src/features/watchlist/tests/portfolio-utils.spec.ts | Unit tests for portfolio computations and distribution math. |
| src/features/watchlist/tests/crossNetwork.spec.ts | Tests for cross-network validation and query key isolation. |
| src/features/watchlist/tests/WatchlistVisibilityRefetch.spec.tsx | Tests for visibility-based invalidation strategy. |
| src/features/watchlist/tests/WatchlistTxNotifier.spec.tsx | Tests for toast notifier behavior + notifications disabled case. |
| src/features/watchlist/tests/WatchlistPersistence.spec.tsx | Tests for hydration from localStorage. |
| src/features/watchlist/tests/RemoveFromWatchlistDialog.spec.tsx | Tests for remove confirm/cancel dialog flows. |
| src/features/watchlist/WatchlistVisibilityRefetch.tsx | Invalidates watchlist balance/tx queries when tab becomes visible. |
| src/features/watchlist/WatchlistTxNotifier.tsx | Toasts on newest-tx changes beyond baseline when enabled. |
| src/features/watchlist/WatchlistPersistence.tsx | Client-side hydration into Redux + order normalization. |
| src/features/watchlist/RemoveFromWatchlistDialog.tsx | Shared confirmation dialog before removal. |
| src/common/utils/utils.ts | Updates getUsdValue to fall back to fixed STX price when missing/invalid. |
| src/common/utils/test-utils/render-utils.tsx | Adds watchlist reducer to test store and allows injecting QueryClient. |
| src/common/state/store.ts | Adds watchlist reducer and state typing to root store. |
| src/common/queries/watchlistBalancesBatch.ts | Implements batch-ish balances fetch strategy + v2 fallback + client API route call. |
| src/common/queries/useWatchlistQueries.ts | Adds watchlist balances batch query + per-address tx queries w/ keys and timings. |
| src/common/queries/useCurrentPrices.ts | Replaces price hook with fixed STX price constant. |
| src/common/hooks/useEffectiveStxUsdPrice.ts | Fixed STX/USD hook for display. |
| src/common/hooks/useCryptoPrices.ts | Exposes fixed STX/BTC USD rates + source. |
| src/common/hooks/tests/useCryptoPrices.spec.ts | Tests for useCryptoPrices. |
| src/common/context/GlobalContextProvider.tsx | Sets default token prices from fixed config. |
| src/app/watchlist/page.tsx | Adds watchlist route with metadata + client page component. |
| src/app/watchlist/tests/watchlist-page.spec.tsx | Tests route metadata + client component rendering. |
| src/app/watchlist/tests/WatchlistPageClient.integration.spec.tsx | Integration tests for main watchlist page flows and pagination reset behavior. |
| src/app/watchlist/tests/WatchlistPageClient.a11y.spec.tsx | Basic a11y/loading chrome tests for watchlist page. |
| src/app/watchlist/WatchlistPageClient.tsx | Main watchlist UI: portfolio, distribution, list/table, filters, combined tx feed, DnD reorder. |
| src/app/getTokenPriceInfo.ts | Replaces external price fetching with fixed config for SSR token prices. |
| src/app/api/watchlist/balances/route.ts | Adds server API route to aggregate balances (proxy + v2 fallback). |
| src/app/address/[principal]/redesign/WatchlistStarButton.tsx | Adds watchlist star button on address page header. |
| src/app/address/[principal]/redesign/AddressHeader.tsx | Mounts watchlist star button in address header layout. |
| src/app/address/[principal]/WatchlistAddressLifecycle.tsx | Marks viewed + syncs BNS name into watchlist entry when on address page. |
| src/app/address/[principal]/PageClient.tsx | Mounts watchlist address lifecycle component on address page. |
| src/app/_components/Providers.tsx | Adds WatchlistPersistence to app providers for hydration. |
| src/app/_components/PageWrapper.tsx | Mounts visibility refetch + tx notifier globally. |
| src/app/_components/NewNavBar/consts.ts | Adds Watchlist to primary pages list/types. |
| src/app/_components/NewNavBar/WatchlistNavLink.tsx | Adds nav link to /watchlist with new-tx badge. |
| src/app/_components/NewNavBar/PagesSlidingMenu.tsx | Maps /watchlist path to Watchlist label. |
| src/app/_components/NewNavBar/NavBar.tsx | Renders Watchlist nav link in desktop nav bar. |
| next-env.d.ts | Updates Next route types reference path. |
| jest.setup.js | Adds matchMedia mock for Chakra hooks in jsdom. |
| docs/workplans/WATCHLIST-001-Watchlist-feature.md | Adds implementation workplan / verification notes for watchlist feature. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| ] as const; | ||
|
|
||
| /** `title` / tooltip hint when balance or tx count failed to load */ | ||
| const WATCHLIST_CELL_LOAD_ERROR_TITLE = 'Ошибка загрузки'; |
There was a problem hiding this comment.
The watchlist page mixes English and Russian user-facing strings in the same UI (e.g. GROUP_LABEL/sort labels are English while WATCHLIST_CELL_LOAD_ERROR_TITLE is Russian). This makes localization inconsistent and hard to maintain; consider routing all strings through the same i18n mechanism or at least using a single language consistently for this page.
| const WATCHLIST_CELL_LOAD_ERROR_TITLE = 'Ошибка загрузки'; | |
| const WATCHLIST_CELL_LOAD_ERROR_TITLE = 'Loading error'; |
| const timestamp = getTxUnixSeconds(tx) ?? 0; | ||
| const from = tx.sender_address || ''; | ||
| const to = getToAddress(tx) || ''; | ||
| const direction: 'in' | 'out' = from === watchPrincipal ? 'out' : 'in'; |
There was a problem hiding this comment.
transactionToUnified() coerces missing timestamps to 0. Downstream code treats falsy timestamps as "Pending" for display, but other logic (like day-based grouping/sorting) can accidentally interpret 0 as a real time. Consider keeping timestamp as null/undefined (and updating UnifiedTransaction accordingly) or adding an explicit pending flag so UI logic doesn’t have to special-case 0.
| <DialogTitle>Remove from watchlist?</DialogTitle> | ||
| </DialogHeader> | ||
| <DialogBody> | ||
| <Text textStyle="text-regular-sm" color="textPrimary"> | ||
| Удалить{' '} | ||
| <Text as="span" fontWeight="semibold"> | ||
| {addressLabel} | ||
| </Text>{' '} | ||
| из избранного? | ||
| </Text> | ||
| </DialogBody> | ||
| <DialogFooter gap={3}> | ||
| <Button variant="redesignTertiary" onClick={() => onOpenChange(false)}> | ||
| Cancel | ||
| </Button> |
There was a problem hiding this comment.
The dialog’s title/buttons are English ("Remove from watchlist?", "Cancel", "Remove") but the body copy is Russian. This mixed-language UX is inconsistent and will be difficult to localize/maintain; consider making all strings consistent or pulling them from the same translation source.
| export const WATCHLIST_STORAGE_QUOTA_TOAST_MESSAGE = | ||
| 'Не удалось сохранить избранное: переполнено хранилище браузера.'; |
There was a problem hiding this comment.
The storage quota toast message is hard-coded in Russian, while other newly introduced watchlist UI strings are English. If the app isn’t fully localized, this will feel inconsistent; consider using a consistent language here or sourcing the message from the same i18n/strings layer as the rest of the UI.
|
|
||
| ## Notes | ||
|
|
||
| - True multi-address “single HTTP batch” for balances is not provided by the public API; the implementation uses one React Query request per principal (`useQueries`) with aligned refetch interval (~60s). |
There was a problem hiding this comment.
This workplan note says balances use one React Query request per principal via useQueries with ~60s refetch. The implementation in this PR uses a single useQuery (useWatchlistBalancesBatch) that POSTs to /api/watchlist/balances (with a client fallback), and uses a 30s refetch interval. Please update the note to reflect the current approach/timing so the doc stays accurate.
| - True multi-address “single HTTP batch” for balances is not provided by the public API; the implementation uses one React Query request per principal (`useQueries`) with aligned refetch interval (~60s). | |
| - Balances are loaded via a single React Query `useQuery` (`useWatchlistBalancesBatch`) that POSTs to `/api/watchlist/balances`, with a client-side fallback when needed, and uses a 30s refetch interval. |
| } | ||
| } | ||
|
|
||
| const MAX_ADDRESSES = 200; |
There was a problem hiding this comment.
MAX_ADDRESSES is set to 200, but the watchlist max in the client state is 50. Allowing 200 here increases worst-case server work/time (sequential v2 fallbacks + network I/O) and makes the route easier to abuse. Consider lowering this limit to match the product constraint (50) or adding server-side timeouts/concurrency controls.
| const MAX_ADDRESSES = 200; | |
| const MAX_ADDRESSES = 50; |
| function txGroupLabel(ts: number): 'today' | 'yesterday' | 'earlier' { | ||
| const t = dayjs.unix(ts); | ||
| const now = dayjs(); | ||
| if (t.isSame(now, 'day')) return 'today'; | ||
| if (t.isSame(now.subtract(1, 'day'), 'day')) return 'yesterday'; | ||
| return 'earlier'; |
There was a problem hiding this comment.
txGroupLabel() treats ts=0 as a real unix timestamp (1970-01-01), which will group transactions with unknown/pending timestamps into "Earlier". Since transactionToUnified() uses 0 when no timestamp is available, consider treating 0 (or any falsy timestamp) as a separate "pending" bucket or excluding from day-based grouping to avoid misleading UI.
| case 'added_desc': | ||
| return (a.item.order ?? 0) - (b.item.order ?? 0); | ||
| case 'added_asc': | ||
| return (b.item.order ?? 0) - (a.item.order ?? 0); |
There was a problem hiding this comment.
The "Date added" sort options are implemented by sorting on item.order (and added_desc is also the only mode where drag-reorder is enabled). After a user drags rows, order no longer represents "date added", so the label/meaning becomes inaccurate. Consider renaming these sort keys/labels to reflect "custom/list order" vs actual addedAt sorting, or switch the comparator to addedAt when the intent is strictly date-based sorting.
| try { | ||
| saveWatchlistToStorage(store.getState().watchlist.items); | ||
| } catch { | ||
| dispatch(addWatchlistItem(restored)); | ||
| toast.error(WATCHLIST_STORAGE_QUOTA_TOAST_MESSAGE); | ||
| return { ok: false, code: 'STORAGE_QUOTA' }; | ||
| } |
There was a problem hiding this comment.
Same issue as in add(): this catch assumes any persistence failure is quota-related and shows the quota-specific toast. Since saveWatchlistToStorage() can throw non-quota errors too, consider distinguishing quota vs unknown errors so the toast message stays accurate.
| saveWatchlistToStorage(store.getState().watchlist.items); | ||
| } catch { | ||
| dispatch(reorderWatchlist(snapshot)); | ||
| toast.error(WATCHLIST_STORAGE_QUOTA_TOAST_MESSAGE); | ||
| return { ok: false, code: 'STORAGE_QUOTA' }; |
There was a problem hiding this comment.
Same persistence error handling concern: this catch always shows the quota-specific toast, even though saveWatchlistToStorage() can throw other errors. Consider narrowing the toast to actual quota cases and using a generic message otherwise.
What type of PR is this? (check all applicable)
Description
Issue ticket number and link
Checklist:
Screenshots (if appropriate):