15 KiB

Pagination Loading Overlay — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Add overlay + spinner to all broker paginated tables when switching pages

Architecture: Use TanStack Query's isFetching (any fetch) vs isLoading (initial fetch) to show overlay when data exists and a new page is loading. Keep keepPreviousData so old data stays visible under the overlay. Add CSS spinner animation.

Tech Stack: React, TanStack Query v5, CSS custom properties


Task 1: CSS — spinner animation and overlay styles

Files:

  • Modify: apps/frontend/src/styles.css

  • Step 1: Add spinner keyframes and loading-spinner class

Add to apps/frontend/src/styles.css at the end:

@keyframes loading-spin {
  to { transform: rotate(360deg); }
}

.loading-spinner {
  width: 20px;
  height: 20px;
  border: 2px solid var(--color-bg);
  border-top-color: var(--color-primary);
  border-radius: 50%;
  animation: loading-spin 0.8s linear infinite;
}

.table-container {
  position: relative;
}

.table-loading-overlay {
  position: absolute;
  inset: 0;
  background: rgba(255, 255, 255, 0.65);
  display: flex;
  align-items: center;
  justify-content: center;
  flex-direction: column;
  gap: 12px;
  transition: opacity 0.2s ease;
  z-index: 1;
}
  • Step 2: Commit
git add apps/frontend/src/styles.css
git commit -m "style: add loading-spinner and overlay CSS classes"

Task 2: PositionGroupTable — overlay on pagination + spinner in buttons

Files:

  • Modify: apps/frontend/src/pages/broker/BrokerPositionsSection.tsx

  • Step 1: Add isFetching to the query destructuring

Line 103 changes from:

const { data: page, isLoading } = useBrokerPositions(accountId, query);

to:

const { data: page, isLoading, isFetching } = useBrokerPositions(accountId, query);
  • Step 2: Replace the loading rendering section

Current (lines 179-213):

{isLoading && (
  <div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
    <table style={tableStyle}>
      <thead>
        <tr>
          <th align="left" style={thStyle}>Тикер</th>
          <th align="left" style={thStyle}>Название</th>
          <th align="right" style={thStyle}>Количество</th>
          <th align="right" style={thStyle}>Цена</th>
          <th align="right" style={thStyle}>Стоимость</th>
        </tr>
      </thead>
      <TableSkeleton rows={4} columns={[
        { width: '30%' }, { width: '50%' }, { width: '20%' }, { width: '25%' }, { width: '25%' },
      ]} />
    </table>
  </div>
)}

{!isLoading && positions.length > 0 && (
  <div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
    <table aria-label={`Брокерские позиции: ${group.title}`} style={tableStyle}>
      ...
    </table>
  </div>
)}

Replace with new rendering logic:

{isLoading && (
  <div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
    <table style={tableStyle}>
      <thead>
        <tr>
          <th align="left" style={thStyle}>Тикер</th>
          <th align="left" style={thStyle}>Название</th>
          <th align="right" style={thStyle}>Количество</th>
          <th align="right" style={thStyle}>Цена</th>
          <th align="right" style={thStyle}>Стоимость</th>
        </tr>
      </thead>
      <TableSkeleton rows={4} columns={[
        { width: '30%' }, { width: '50%' }, { width: '20%' }, { width: '25%' }, { width: '25%' },
      ]} />
    </table>
  </div>
)}

{!isLoading && positions.length > 0 && (
  <div className="table-container">
    <div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
      <table aria-label={`Брокерские позиции: ${group.title}`} style={tableStyle}>
        <thead>
          <tr>
            <th align="left" style={thStyle}>Тикер</th>
            <th align="left" style={thStyle}>Название</th>
            <th align="right" style={thStyle}>Количество</th>
            <th align="right" style={thStyle}>Цена</th>
            <th align="right" style={thStyle}>Стоимость</th>
          </tr>
        </thead>
        <tbody>
          {positions.map((position) => (
            <tr
              key={
                position.positionUid ||
                position.instrumentUid ||
                position.ticker ||
                position.figi
              }
            >
              <td style={tdStyle}><PositionTicker position={position} /></td>
              <td style={tdStyle}>
                <span style={{ color: 'var(--color-text-secondary)' }}>
                  {position.name || '-'}
                </span>
              </td>
              <td align="right" style={tdStyle}>{formatQuantity(position.quantity)}</td>
              <td align="right" style={tdStyle}>{formatMoney(position.currentPrice)}</td>
              <td align="right" style={tdStyle}>{formatMoney(position.currentValue)}</td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
    {isFetching && (
      <div className="table-loading-overlay">
        <div className="loading-spinner" />
        <span style={{ fontSize: 13, color: 'var(--color-text-secondary)' }}>
          Загрузка страницы {pageNumber}
        </span>
      </div>
    )}
  </div>
)}
  • Step 3: Update pagination buttons to show spinner during fetch

Replace the button content in lines 167-174 (the "→" button):

<button
  type="button"
  onClick={handleNext}
  disabled={!canGoForward || isFetching}
  style={canGoForward && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
>
  {isFetching ? <span className="loading-spinner" style={{ width: 14, height: 14, display: 'block' }} /> : '→'}
</button>

Also update the "←" button (lines 148-155):

<button
  type="button"
  onClick={handlePrevious}
  disabled={!canGoBack || isFetching}
  style={canGoBack && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
>
  {isFetching ? <span className="loading-spinner" style={{ width: 14, height: 14, display: 'block' }} /> : '←'}
</button>
  • Step 4: Commit
git add apps/frontend/src/pages/broker/BrokerPositionsSection.tsx
git commit -m "feat: add loading overlay and spinner to PositionGroupTable"

Task 3: BrokerOperationsTable — new isFetching prop + overlay

Files:

  • Modify: apps/frontend/src/pages/broker/BrokerOperationsTable.tsx

  • Step 1: Add isFetching to props interface

Change the component props destructuring (line 96-113):

export function BrokerOperationsTable({
  isLoading,
  isFetching,
  page,
  pageNumber,
  canGoBack,
  canGoForward,
  onPrevious,
  onNext,
}: {
  isLoading: boolean;
  isFetching: boolean;
  page: BrokerOperationsPage | undefined;
  pageNumber: number;
  canGoBack: boolean;
  canGoForward: boolean;
  onPrevious: () => void;
  onNext: () => void;
}) {
  • Step 2: Replace the loading/empty/data rendering

Current (lines 158-229):

{isLoading ? (
  <div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
    <table style={tableStyle}>
      <thead>
        <tr>
          <th align="left" style={thStyle}>Дата</th>
          <th align="left" style={thStyle}>Тип</th>
          <th align="left" style={thStyle}>Инструмент</th>
          <th align="right" style={thStyle}>Сумма</th>
        </tr>
      </thead>
      <TableSkeleton
        rows={5}
        columns={[{ width: '35%' }, { width: '30%' }, { width: '40%' }, { width: '25%' }]}
      />
    </table>
  </div>
) : operations.length === 0 ? (
  <p style={{ color: 'var(--color-text-secondary)' }}>Операций за выбранный период нет</p>
) : (
  <div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
    <table style={tableStyle}>
      <thead>
        <tr>
          <th align="left" style={thStyle}>Дата</th>
          <th align="left" style={thStyle}>Тип</th>
          <th align="left" style={thStyle}>Инструмент</th>
          <th align="right" style={thStyle}>Сумма</th>
        </tr>
      </thead>
      <tbody>
        {operations.map((operation) => {
          const impact = getBrokerOperationImpact(operation);
          return (
            <tr key={operation.cursor || operation.id}>
              <td style={tdStyle}>{formatDate(operation.date)}</td>
              <td style={tdStyle}><span>{getBrokerOperationTypeLabel(operation)}</span></td>
              <td style={tdStyle}><OperationInstrument operation={operation} /></td>
              <td align="right" style={{ ...tdStyle, color: moneyColor(impact), fontWeight: 700 }}>
                {formatMoney(operation.payment)}
              </td>
            </tr>
          );
        })}
      </tbody>
    </table>
  </div>
)}

Replace with:

{isLoading ? (
  <div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
    <table style={tableStyle}>
      <thead>
        <tr>
          <th align="left" style={thStyle}>Дата</th>
          <th align="left" style={thStyle}>Тип</th>
          <th align="left" style={thStyle}>Инструмент</th>
          <th align="right" style={thStyle}>Сумма</th>
        </tr>
      </thead>
      <TableSkeleton
        rows={5}
        columns={[{ width: '35%' }, { width: '30%' }, { width: '40%' }, { width: '25%' }]}
      />
    </table>
  </div>
) : operations.length === 0 && !isFetching ? (
  <p style={{ color: 'var(--color-text-secondary)' }}>Операций за выбранный период нет</p>
) : (
  <div className="table-container">
    <div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
      <table style={tableStyle}>
        <thead>
          <tr>
            <th align="left" style={thStyle}>Дата</th>
            <th align="left" style={thStyle}>Тип</th>
            <th align="left" style={thStyle}>Инструмент</th>
            <th align="right" style={thStyle}>Сумма</th>
          </tr>
        </thead>
        <tbody>
          {operations.map((operation) => {
            const impact = getBrokerOperationImpact(operation);
            return (
              <tr key={operation.cursor || operation.id}>
                <td style={tdStyle}>{formatDate(operation.date)}</td>
                <td style={tdStyle}><span>{getBrokerOperationTypeLabel(operation)}</span></td>
                <td style={tdStyle}><OperationInstrument operation={operation} /></td>
                <td align="right" style={{ ...tdStyle, color: moneyColor(impact), fontWeight: 700 }}>
                  {formatMoney(operation.payment)}
                </td>
              </tr>
            );
          })}
        </tbody>
      </table>
    </div>
    {isFetching && (
      <div className="table-loading-overlay">
        <div className="loading-spinner" />
        <span style={{ fontSize: 13, color: 'var(--color-text-secondary)' }}>
          Загрузка страницы {pageNumber}
        </span>
      </div>
    )}
  </div>
)}

Note: The empty state check changed from operations.length === 0 to operations.length === 0 && !isFetching — this ensures the overlay shows on top of old data, not the empty message.

  • Step 3: Update pagination buttons

Replace line 132-135 (← button):

<button
  type="button"
  onClick={onPrevious}
  disabled={!canGoBack || isFetching}
  style={canGoBack && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
>
  {isFetching ? <span className="loading-spinner" style={{ width: 14, height: 14, display: 'block' }} /> : '←'}
</button>

Replace lines 147-154 (→ button):

<button
  type="button"
  onClick={onNext}
  disabled={!canGoForward || isFetching}
  style={canGoForward && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
>
  {isFetching ? <span className="loading-spinner" style={{ width: 14, height: 14, display: 'block' }} /> : '→'}
</button>
  • Step 4: Commit
git add apps/frontend/src/pages/broker/BrokerOperationsTable.tsx
git commit -m "feat: add loading overlay and spinner to BrokerOperationsTable"

Task 4: BrokerAccountDetailPage — pass isFetching to operations table

Files:

  • Modify: apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx

  • Step 1: Add isFetching to the BrokerOperationsTable props

Change the <BrokerOperationsTable> call (line 123-131):

<BrokerOperationsTable
  isLoading={operations.isLoading}
  isFetching={operations.isFetching}
  page={operations.data}
  pageNumber={operationCursorStack.length + 1}
  canGoBack={operationCursorStack.length > 0}
  canGoForward={Boolean(operations.data?.hasNext && operations.data.nextCursor)}
  onPrevious={handlePreviousOperationsPage}
  onNext={handleNextOperationsPage}
/>
  • Step 2: Commit
git add apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx
git commit -m "feat: pass isFetching to BrokerOperationsTable"

Task 5: Update tests

Files:

  • Modify: apps/frontend/src/pages/broker/BrokerPages.test.tsx

  • Step 1: Add isFetching: false to all existing position mocks

In mockUseBrokerPositions (line 47-61), add isFetching: false:

return {
  data: {
    accountId: 'acc-1',
    items: filtered,
    nextCursor: null,
    hasNext: false,
    asOf: '2026-06-17T00:00:00.000Z',
  },
  isLoading: false,
  isFetching: false,
  error: null,
} as any;
  • Step 2: Add isFetching: false to all operations mocks

Add isFetching: false alongside each isLoading: false in the operations mocks (lines 58, 85, 112, 147, 190, 201, 266, 324, 363, 433).

For example, line 112 area becomes:

data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-17T00:00:00.000Z' },
isLoading: false,
isFetching: false,
  • Step 3: Verify tests pass
npx vitest run apps/frontend/src/pages/broker/BrokerPages.test.tsx -w apps/frontend

Expected: All tests PASS.

  • Step 4: Commit
git add apps/frontend/src/pages/broker/BrokerPages.test.tsx
git commit -m "test: add isFetching to mock return values"

Task 6: Lint and final verification

  • Step 1: Run lint
npm run lint

Expected: No errors (or only pre-existing ones).

  • Step 2: Run full frontend test suite
npm run test:frontend

Expected: All tests pass.

  • Step 3: Run typecheck
npx tsc -b apps/frontend

Expected: No type errors.