Skip to content

Feature/watchlist addresses#2785

Open
DUosey wants to merge 8 commits into
stx-labs:mainfrom
DUosey:feature/watchlist-addresses
Open

Feature/watchlist addresses#2785
DUosey wants to merge 8 commits into
stx-labs:mainfrom
DUosey:feature/watchlist-addresses

Conversation

@DUosey
Copy link
Copy Markdown

@DUosey DUosey commented Apr 26, 2026

What type of PR is this? (check all applicable)

  • Refactor
  • Feature
  • Bug Fix
  • Optimization
  • Documentation Update

Description

Issue ticket number and link

Checklist:

  • I have performed a self-review of my code.
  • I have tested the change on desktop and mobile.
  • I have added thorough tests where applicable.
  • I've added analytics and error logging where applicable.

Screenshots (if appropriate):

DUosey added 8 commits April 26, 2026 14:29
- 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
Copilot AI review requested due to automatic review settings April 26, 2026 15:35
@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 26, 2026

@DUosey is attempting to deploy a commit to the Stacks Labs Team on Vercel.

A member of the Team first needs to authorize it.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 /watchlist page 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 = 'Ошибка загрузки';
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
const WATCHLIST_CELL_LOAD_ERROR_TITLE = 'Ошибка загрузки';
const WATCHLIST_CELL_LOAD_ERROR_TITLE = 'Loading error';

Copilot uses AI. Check for mistakes.
Comment on lines +70 to +73
const timestamp = getTxUnixSeconds(tx) ?? 0;
const from = tx.sender_address || '';
const to = getToAddress(tx) || '';
const direction: 'in' | 'out' = from === watchPrincipal ? 'out' : 'in';
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +35 to +49
<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>
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +5 to +6
export const WATCHLIST_STORAGE_QUOTA_TOAST_MESSAGE =
'Не удалось сохранить избранное: переполнено хранилище браузера.';
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.

## 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).
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
- 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.

Copilot uses AI. Check for mistakes.
}
}

const MAX_ADDRESSES = 200;
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
const MAX_ADDRESSES = 200;
const MAX_ADDRESSES = 50;

Copilot uses AI. Check for mistakes.
Comment on lines +127 to +132
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';
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +340 to +343
case 'added_desc':
return (a.item.order ?? 0) - (b.item.order ?? 0);
case 'added_asc':
return (b.item.order ?? 0) - (a.item.order ?? 0);
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +82 to +88
try {
saveWatchlistToStorage(store.getState().watchlist.items);
} catch {
dispatch(addWatchlistItem(restored));
toast.error(WATCHLIST_STORAGE_QUOTA_TOAST_MESSAGE);
return { ok: false, code: 'STORAGE_QUOTA' };
}
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +143 to +147
saveWatchlistToStorage(store.getState().watchlist.items);
} catch {
dispatch(reorderWatchlist(snapshot));
toast.error(WATCHLIST_STORAGE_QUOTA_TOAST_MESSAGE);
return { ok: false, code: 'STORAGE_QUOTA' };
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
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.

2 participants