Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 1 addition & 6 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -307,12 +307,7 @@ function App() {
return (
<div className={`App min-h-screen ${contentTopPad}`}>
<RealtimeStatusBanner />
<BackendCapabilitiesBanner
notices={notices}
loadError={loadError}
loading={readinessLoading}
belowRealtimeBar={false}
/>
<BackendCapabilitiesBanner belowRealtimeBar={false} />

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail
rg -n -C 6 'useReadinessReport|useReadinessQuery|readinessKeys|fetchReadinessReport|setInterval' frontend/src

Repository: ritik4ever/stellar-portfolio-rebalancer

Length of output: 32422


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== App readiness/layout declarations and banner invocation =="
sed -n '45,68p' frontend/src/App.tsx
echo
sed -n '280,325p' frontend/src/App.tsx
echo
echo "== read-only hook/body evidence =="
sed -n '1,90p' frontend/src/hooks/queries/useReadinessQuery.ts
echo
sed -n '165,208p' frontend/src/hooks/useReadinessReport.ts
echo
echo "== Behavioral model from source text =="
python3 - <<'PY'
from pathlib import Path
app = Path('frontend/src/App.tsx').read_text()
banner = Path('frontend/src/components/BackendCapabilitiesBanner.tsx').read_text()
report = Path('frontend/src/hooks/useReadinessReport.ts').read_text()
query = Path('frontend/src/hooks/queries/useReadinessQuery.ts').read_text()
checks = {
    'App uses both readiness hooks': 'useReadinessReport' in app and 'useReadinessQuery' in banner and app.count('BackendCapabilitiesBanner') >= 1,
    'useReadinessQuery uses React Query interval/backoff': 'useQuery' in query and 'refetchInterval: () => document.visibilityState' in query,
    'useReadinessReport uses manual polling interval/backoff': 'window.setInterval' in report and 'visibilitychange' in report,
    'App derives showBackendBanner from useReadinessReport': 'showBackendBanner' in app and 'notices.some' in app,
    'layout top-padding class from showBackendBanner': 'pt-4' in app and 'contentTopPad' in app,
    'errorTop uses report from useReadinessReport': 'errorTop' in app and 'report' in app and 'useReadinessReport' in app,
}
for name, ok in checks.items():
    print(f'{name}: {ok}')
PY

Repository: ritik4ever/stellar-portfolio-rebalancer

Length of output: 8725


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== App relevant sections =="
sed -n '62,80p' frontend/src/App.tsx
sed -n '200,320p' frontend/src/App.tsx

echo
echo "== useReadinessReport refresh and any App/other use/useQueryClient references =="
rg -n -C 4 'function useReadinessReport|refresh|useQueryClient|invalidateQueries|reset|useReadinessReport\(|useReadinessQuery\(' frontend/src/App.tsx frontend/src/hooks/utils frontend/src/hooks/queries frontend/src/hooks use

Repository: ritik4ever/stellar-portfolio-rebalancer

Length of output: 39398


Use one readiness source for App layout and the banner.

App still derives showBackendBanner, contentTopPad, and errorTop from useReadinessReport, while BackendCapabilitiesBanner reads useReadinessQuery. Since refresh() is not passed into App, readiness can update in the banner before the page offset/error positioning catch up, and the two hooks also poll separately. Derive all readiness decisions from useReadinessQuery or pass the same hook result into the banner.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/App.tsx` at line 310, Unify readiness state in App by deriving
showBackendBanner, contentTopPad, and errorTop from the same useReadinessQuery
result consumed by BackendCapabilitiesBanner. Update the banner integration so
both layout and banner decisions use one readiness source, eliminating the
separate useReadinessReport polling path.

{showApiCompatibilityBanner && apiCompatibility ? (
<div
className={`fixed left-0 right-0 z-40 border-b px-4 py-3 text-sm ${
Expand Down
78 changes: 64 additions & 14 deletions frontend/src/components/BackendCapabilitiesBanner.test.tsx
Original file line number Diff line number Diff line change
@@ -1,83 +1,133 @@
import { describe, it, expect, afterEach } from 'vitest'
import { describe, it, expect, afterEach, vi } from 'vitest'
import { render, screen, cleanup, within } from '@testing-library/react'
import BackendCapabilitiesBanner from './BackendCapabilitiesBanner'
import type { CapabilityNotice } from '../hooks/useReadinessReport'

vi.mock('../hooks/queries/useReadinessQuery', () => ({
useReadinessQuery: vi.fn(),
}))

import { useReadinessQuery } from '../hooks/queries/useReadinessQuery'

afterEach(cleanup)

const defaultProps = { loadError: false, loading: false, belowRealtimeBar: false }
const mockQuery = useReadinessQuery as unknown as ReturnType<typeof vi.fn>

function mockReturn(overrides: Partial<ReturnType<typeof useReadinessQuery>> = {}) {
mockQuery.mockReturnValue({
notices: [],
loadError: false,
loading: false,
report: null,
refresh: vi.fn(),
...overrides,
})
}

function notice(id: string, kind: CapabilityNotice['kind'] = 'disabled', text = 'Some issue.'): CapabilityNotice {
return { id, kind, text }
}

describe('BackendCapabilitiesBanner', () => {
afterEach(() => {
mockQuery.mockReset()
})

it('renders nothing when no notices and no error', () => {
const { container } = render(<BackendCapabilitiesBanner {...defaultProps} notices={[]} />)
mockReturn()
const { container } = render(<BackendCapabilitiesBanner />)
expect(container.firstChild).toBeNull()
})

it('renders load-error message when loadError is true and no notices', () => {
render(<BackendCapabilitiesBanner {...defaultProps} loadError notices={[]} />)
mockReturn({ loadError: true, notices: [] })
render(<BackendCapabilitiesBanner />)
expect(screen.getByRole('status')).toHaveTextContent(/could not load backend service status/i)
})

it('renders notice text', () => {
render(<BackendCapabilitiesBanner {...defaultProps} notices={[notice('database', 'limited', 'DB is down.')]} />)
mockReturn({ notices: [notice('database', 'limited', 'DB is down.')] })
render(<BackendCapabilitiesBanner />)
expect(screen.getByText(/DB is down\./)).toBeInTheDocument()
})

it('attaches a doc link for database notice', () => {
render(<BackendCapabilitiesBanner {...defaultProps} notices={[notice('database', 'limited')]} />)
mockReturn({ notices: [notice('database', 'limited')] })
render(<BackendCapabilitiesBanner />)
const link = screen.getByRole('link', { name: /database setup/i })
expect(link).toHaveAttribute('href', expect.stringContaining('#database-setup'))
expect(link).toHaveAttribute('target', '_blank')
expect(link).toHaveAttribute('rel', 'noopener noreferrer')
})

it('attaches a doc link for queue-workers notice', () => {
render(<BackendCapabilitiesBanner {...defaultProps} notices={[notice('queue-workers')]} />)
mockReturn({ notices: [notice('queue-workers')] })
render(<BackendCapabilitiesBanner />)
const link = screen.getByRole('link', { name: /redis \/ worker setup/i })
expect(link).toHaveAttribute('href', expect.stringContaining('CONTRIBUTING'))
})

it('attaches a doc link for indexer notice', () => {
render(<BackendCapabilitiesBanner {...defaultProps} notices={[notice('indexer')]} />)
mockReturn({ notices: [notice('indexer')] })
render(<BackendCapabilitiesBanner />)
const link = screen.getByRole('link', { name: /environment setup/i })
expect(link).toHaveAttribute('href', expect.stringContaining('ENVIRONMENT'))
})

it('attaches a doc link for auto-rebalancer notice', () => {
render(<BackendCapabilitiesBanner {...defaultProps} notices={[notice('auto-rebalancer')]} />)
mockReturn({ notices: [notice('auto-rebalancer')] })
render(<BackendCapabilitiesBanner />)
const link = screen.getByRole('link', { name: /environment setup/i })
expect(link).toBeInTheDocument()
})

it('renders multiple notices each with their own link', () => {
const notices = [notice('database', 'limited'), notice('queue-workers')]
const { container } = render(<BackendCapabilitiesBanner {...defaultProps} notices={notices} />)
mockReturn({ notices })
const { container } = render(<BackendCapabilitiesBanner />)
const banner = container.querySelector('[role="status"]')!
expect(within(banner as HTMLElement).getByRole('link', { name: /database setup/i })).toBeInTheDocument()
expect(within(banner as HTMLElement).getByRole('link', { name: /redis \/ worker setup/i })).toBeInTheDocument()
})

it('applies top-14 class when belowRealtimeBar is true', () => {
render(<BackendCapabilitiesBanner {...defaultProps} notices={[notice('database', 'limited')]} belowRealtimeBar />)
mockReturn({ notices: [notice('database', 'limited')] })
render(<BackendCapabilitiesBanner belowRealtimeBar />)
expect(screen.getByRole('status').className).toContain('top-14')
})

it('applies top-0 class when belowRealtimeBar is false', () => {
render(<BackendCapabilitiesBanner {...defaultProps} notices={[notice('database', 'limited')]} />)
mockReturn({ notices: [notice('database', 'limited')] })
render(<BackendCapabilitiesBanner />)
expect(screen.getByRole('status').className).toContain('top-0')
})

it('uses amber styling when any notice is limited', () => {
render(<BackendCapabilitiesBanner {...defaultProps} notices={[notice('database', 'limited')]} />)
mockReturn({ notices: [notice('database', 'limited')] })
render(<BackendCapabilitiesBanner />)
expect(screen.getByRole('status').className).toContain('amber')
})

it('uses slate styling when all notices are disabled', () => {
render(<BackendCapabilitiesBanner {...defaultProps} notices={[notice('queue-workers', 'disabled')]} />)
mockReturn({ notices: [notice('queue-workers', 'disabled')] })
render(<BackendCapabilitiesBanner />)
expect(screen.getByRole('status').className).not.toContain('amber')
})

it('updates banner after a simulated capability change on subsequent poll', () => {
mockReturn({ notices: [notice('database', 'limited', 'Initial issue.')] })
const { unmount } = render(<BackendCapabilitiesBanner />)
expect(screen.getByText(/Initial issue\./)).toBeInTheDocument()
unmount()

mockQuery.mockReturnValue({
notices: [notice('database', 'limited', 'Updated issue.')],
loadError: false,
loading: false,
report: null,
refresh: vi.fn(),
})
render(<BackendCapabilitiesBanner />)
expect(screen.getByText(/Updated issue\./)).toBeInTheDocument()
Comment on lines +117 to +131

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Keep the component mounted when simulating a poll update.

Unmounting before changing the mock resets lastNotices, so this test only proves that a fresh mount renders the new notice. It would pass even if an existing banner ignored updated notices.

Update the mock return value and call rerender without unmounting.

Proposed test adjustment
-        const { unmount } = render(<BackendCapabilitiesBanner />)
+        const view = render(<BackendCapabilitiesBanner />)
         expect(screen.getByText(/Initial issue\./)).toBeInTheDocument()
-        unmount()

         mockQuery.mockReturnValue({
             notices: [notice('database', 'limited', 'Updated issue.')],
             loadError: false,
             loading: false,
             report: null,
             refresh: vi.fn(),
         })
-        render(<BackendCapabilitiesBanner />)
+        view.rerender(<BackendCapabilitiesBanner />)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it('updates banner after a simulated capability change on subsequent poll', () => {
mockReturn({ notices: [notice('database', 'limited', 'Initial issue.')] })
const { unmount } = render(<BackendCapabilitiesBanner />)
expect(screen.getByText(/Initial issue\./)).toBeInTheDocument()
unmount()
mockQuery.mockReturnValue({
notices: [notice('database', 'limited', 'Updated issue.')],
loadError: false,
loading: false,
report: null,
refresh: vi.fn(),
})
render(<BackendCapabilitiesBanner />)
expect(screen.getByText(/Updated issue\./)).toBeInTheDocument()
it('updates banner after a simulated capability change on subsequent poll', () => {
mockReturn({ notices: [notice('database', 'limited', 'Initial issue.')] })
const view = render(<BackendCapabilitiesBanner />)
expect(screen.getByText(/Initial issue\./)).toBeInTheDocument()
mockQuery.mockReturnValue({
notices: [notice('database', 'limited', 'Updated issue.')],
loadError: false,
loading: false,
report: null,
refresh: vi.fn(),
})
view.rerender(<BackendCapabilitiesBanner />)
expect(screen.getByText(/Updated issue\./)).toBeInTheDocument()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/components/BackendCapabilitiesBanner.test.tsx` around lines 117
- 131, Update the test around BackendCapabilitiesBanner so it keeps the same
mounted component while simulating the poll change: retain the render result,
change mockQuery’s return value, and call rerender instead of unmounting and
rendering again. Preserve the assertions for the initial and updated notices to
verify an existing banner responds to changed notices.

})
})
36 changes: 26 additions & 10 deletions frontend/src/components/BackendCapabilitiesBanner.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import { useRef } from 'react'
import { Info, AlertTriangle, ExternalLink } from 'lucide-react'
import { useReadinessQuery } from '../hooks/queries/useReadinessQuery'
import type { CapabilityNotice } from '../hooks/useReadinessReport'

type Props = {
notices: CapabilityNotice[]
loadError: boolean
loading: boolean
belowRealtimeBar: boolean
belowRealtimeBar?: boolean
}

interface NoticeHint {
Expand Down Expand Up @@ -40,15 +39,32 @@ const NOTICE_HINTS: Record<string, NoticeHint> = {
},
}

export default function BackendCapabilitiesBanner({ notices, loadError, loading, belowRealtimeBar }: Props) {
const show = loadError || notices.length > 0
function noticesEqual(a: CapabilityNotice[], b: CapabilityNotice[]): boolean {
if (a.length !== b.length) return false
for (let i = 0; i < a.length; i++) {
if (a[i].id !== b[i].id || a[i].kind !== b[i].kind || a[i].text !== b[i].text) return false
}
return true
}

export default function BackendCapabilitiesBanner({ belowRealtimeBar = false }: Props) {
const { notices, loadError, loading } = useReadinessQuery()
const lastNotices = useRef<CapabilityNotice[]>(notices)

if (!noticesEqual(notices, lastNotices.current)) {
lastNotices.current = notices
}

const stableNotices = lastNotices.current
const show = loadError || stableNotices.length > 0

if (!show && !loading) {
return null
}

const positionClass = belowRealtimeBar ? 'top-14' : 'top-0'

if (loadError && notices.length === 0) {
if (loadError && stableNotices.length === 0) {
return (
<div
className={`fixed left-0 right-0 z-[38] border-b border-slate-200 bg-slate-50 px-4 py-2 text-sm text-slate-700 shadow-sm dark:border-slate-700 dark:bg-slate-900/80 dark:text-slate-200 ${positionClass}`}
Expand All @@ -66,11 +82,11 @@ export default function BackendCapabilitiesBanner({ notices, loadError, loading,
)
}

if (notices.length === 0) {
if (stableNotices.length === 0) {
return null
}

const hasLimited = notices.some((n) => n.kind === 'limited')
const hasLimited = stableNotices.some((n) => n.kind === 'limited')

return (
<div
Expand All @@ -89,7 +105,7 @@ export default function BackendCapabilitiesBanner({ notices, loadError, loading,
: 'A few optional backend features are turned off for this environment. Nothing is wrong with your wallet — this is expected when Redis, workers, or certain flags are not enabled.'}
</p>
<ul className="space-y-1.5 leading-snug">
{notices.map((n) => {
{stableNotices.map((n) => {
const hint = NOTICE_HINTS[n.id]
return (
<li key={n.id} className="flex gap-2">
Expand Down
32 changes: 26 additions & 6 deletions frontend/src/components/CorrelationHeatmap.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React from 'react'
import { describe, expect, it } from 'vitest'
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, afterEach } from 'vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import CorrelationHeatmap, { correlationColor } from './CorrelationHeatmap'

const assets = ['XLM', 'BTC', 'ETH']
Expand All @@ -23,9 +24,19 @@ const matrices = {
],
}

const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
})

function Wrapper({ children }: { children: React.ReactNode }) {
return React.createElement(QueryClientProvider, { client: queryClient }, children)
}

afterEach(cleanup)

describe('CorrelationHeatmap', () => {
it('renders diagonal cells as 1.0', () => {
render(<CorrelationHeatmap assets={assets} correlations={matrices} />)
render(<CorrelationHeatmap assets={assets} correlations={matrices} />, { wrapper: Wrapper })

expect(screen.getByTestId('correlation-cell-0-0')).toHaveTextContent('1.0')
expect(screen.getByTestId('correlation-cell-1-1')).toHaveTextContent('1.0')
Expand All @@ -39,15 +50,15 @@ describe('CorrelationHeatmap', () => {
})

it('shows exact coefficient and pair names in the tooltip', async () => {
render(<CorrelationHeatmap assets={assets} correlations={matrices} />)
render(<CorrelationHeatmap assets={assets} correlations={matrices} />, { wrapper: Wrapper })

fireEvent.mouseEnter(screen.getByTestId('correlation-cell-0-1'))

expect(await screen.findByRole('tooltip')).toHaveTextContent('XLM / BTC: -0.50')
})

it('switches matrices when the time range changes', () => {
render(<CorrelationHeatmap assets={assets} correlations={matrices} />)
render(<CorrelationHeatmap assets={assets} correlations={matrices} />, { wrapper: Wrapper })

expect(screen.getByTestId('correlation-cell-0-1')).toHaveTextContent('-0.50')

Expand All @@ -64,9 +75,18 @@ describe('CorrelationHeatmap', () => {
manyAssets.map((__, columnIndex) => (rowIndex === columnIndex ? 1 : 0.1)),
)

render(<CorrelationHeatmap assets={manyAssets} correlations={{ '30D': matrix }} />)
render(<CorrelationHeatmap assets={manyAssets} correlations={{ '30D': matrix }} />, { wrapper: Wrapper })

expect(screen.getAllByRole('gridcell')).toHaveLength(100)
expect(screen.queryByText('A11')).not.toBeInTheDocument()
})

it('opens a drill-down modal when a cell is clicked with correct asset pair context', async () => {
render(<CorrelationHeatmap assets={assets} correlations={matrices} />, { wrapper: Wrapper })

fireEvent.click(screen.getByTestId('correlation-cell-0-1'))

expect(screen.getByText(/correlation detail/i)).toBeInTheDocument()
expect(screen.getByRole('heading', { name: /correlation detail/i })).toBeInTheDocument()
})
Comment on lines +84 to +91

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
fd -H -t f 'vitest.config.*|vite.config.*' frontend --exec cat -n
fd -H -t f 'setup*.ts*' frontend/src --exec cat -n
rg -n --type=ts --type=tsx 'usePriceCandlestickQuery|PRICE_CHART' frontend/src -g '!**/*.test.*'

Repository: ritik4ever/stellar-portfolio-rebalancer

Length of output: 50396


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate heatmap tests and component =="
fd -H -t f 'CorrelationHeatmap' frontend

echo
echo "== test excerpt =="
file="$(fd -H -t f 'CorrelationHeatmap.test.tsx' frontend | head -n1 || true)"
if [ -n "$file" ]; then
  wc -l "$file"
  sed -n '1,140p' "$file" | cat -n
fi

echo
echo "== component outline relevant file =="
if [ -n "$file" ]; then
  comp="$(git rev-parse --show-toplevel)/frontend/src/components/CorrelationHeatmap.tsx"
  if [ -f "$comp" ]; then
    wc -l "$comp"
    ast-grep outline "$comp" --view expanded || true
    sed -n '1,260p' "$comp" | cat -n
  fi
fi

echo
echo "== hook usages =="
rg -n --type=ts --type=tsx 'usePriceCandlestick|QueryClient|refetchInterval|DataChart|data:' frontend/src -g '!**/*.test.*' --glob '!**/node_modules/**' --max-count 200

Repository: ritik4ever/stellar-portfolio-rebalancer

Length of output: 17764


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== remaining component render/modal section =="
file="$(git rev-parse --show-toplevel)/frontend/src/components/CorrelationHeatmap.tsx"
sed -n '260,330p' "$file" | cat -n

echo
echo "== hook file =="
hook="$(git rev-parse --show-toplevel)/frontend/src/hooks/queries/usePriceCandlestickQuery.ts" || true
if [ -f "$hook" ]; then
  wc -l "$hook"
  sed -n '1,220p' "$hook" | cat -n
else
  fd -H -t f 'usePriceCandlestickQuery' frontend
fi

echo
echo "== global fetch/api mocks in Vitest setup files =="
sed -n '1,180p' frontend/src/test/setup.ts | cat -n
rg -n --glob '!**/node_modules/**' 'vi\.stubGlobal\(|fetch|global.fetch|axios|XMLHttpRequest|msw|setupServer|http|api|usePriceCandlestickQuery|queryClient' frontend/src frontend/vitest.config.ts frontend/src/test/setup.ts

Repository: ritik4ever/stellar-portfolio-rebalancer

Length of output: 50395


Assert the clicked pair and mock the candlestick query.

The assertions only check the static modal title, so this would pass for the wrong XLM/BTC modal; assert that XLM and BTC appear in the modal body, and mock usePriceCandlestick (or preload the pair’s queries) so the trend path and its fetch are deterministic.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/components/CorrelationHeatmap.test.tsx` around lines 84 - 91,
Update the drill-down test around CorrelationHeatmap to mock
usePriceCandlestick, or preload the relevant pair queries, so the trend
rendering and fetch behavior are deterministic. After clicking
correlation-cell-0-1, assert that the modal body contains both XLM and BTC in
addition to the existing title assertions, verifying the clicked asset pair
context.

})
Loading
Loading