# Design Document: Signal Dashboard ## Overview Signal Dashboard is a React 18 + TypeScript SPA that consumes the existing Stock Data Backend REST API (`/api/v1/`). It provides authenticated users with views for watchlist monitoring, per-ticker analysis, trade setup scanning, composite-score rankings, and admin management. The frontend lives in `frontend/` within the existing project root. Vite builds static assets to `frontend/dist/`, which Nginx serves on `signal.thiessen.io`. API requests to `/api/v1/` are proxied to the FastAPI backend — no CORS needed. ### Key Technical Decisions | Decision | Choice | Rationale | |---|---|---| | Build tool | Vite 5 | Fast HMR, native TS/React support, small output | | Routing | React Router v6 | Standard, supports layout routes and guards | | Server state | TanStack Query v5 | Caching, deduplication, background refetch | | Client state | Zustand | Minimal auth store, no boilerplate | | Styling | Tailwind CSS v3 | Utility-first, dark mode built-in, small bundle | | Charts | Recharts | React-native charting, composable, lightweight | | HTTP | Axios | Interceptors for auth/envelope unwrapping | | Testing | Vitest + React Testing Library + fast-check | Vite-native test runner, property-based testing | ## Architecture ```mermaid graph TD subgraph Browser Router[React Router] Pages[Page Components] Hooks[TanStack Query Hooks] Store[Zustand Auth Store] API[API Client - Axios] end Router --> Pages Pages --> Hooks Hooks --> API API --> Store Store --> API subgraph Server Nginx[Nginx - static files + proxy] Backend[FastAPI Backend] end API -->|/api/v1/*| Nginx Nginx -->|proxy_pass| Backend Nginx -->|static| Browser ``` ### Request Flow 1. Component mounts → calls a TanStack Query hook (e.g., `useWatchlist()`) 2. Hook calls an API client function (e.g., `api.watchlist.list()`) 3. Axios sends request with JWT Bearer header from Zustand store 4. Axios response interceptor unwraps `{ status, data, error }` envelope 5. On 401 → Zustand clears token, React Router redirects to `/login` 6. TanStack Query caches the result, component renders data ### Directory Structure ``` frontend/ ├── index.html ├── package.json ├── tsconfig.json ├── vite.config.ts ├── tailwind.config.ts ├── postcss.config.js ├── src/ │ ├── main.tsx # App entry, providers │ ├── App.tsx # Router + layout │ ├── api/ │ │ ├── client.ts # Axios instance, interceptors │ │ ├── auth.ts # login, register │ │ ├── watchlist.ts # watchlist CRUD │ │ ├── tickers.ts # ticker CRUD │ │ ├── scores.ts # scores, rankings, weights │ │ ├── trades.ts # trade setups │ │ ├── ohlcv.ts # OHLCV data │ │ ├── indicators.ts # technical indicators │ │ ├── sr-levels.ts # support/resistance │ │ ├── sentiment.ts # sentiment data │ │ ├── fundamentals.ts # fundamental data │ │ ├── ingestion.ts # manual data fetch │ │ ├── admin.ts # admin endpoints │ │ └── health.ts # health check │ ├── hooks/ │ │ ├── useAuth.ts # login/register/logout mutations │ │ ├── useWatchlist.ts # watchlist queries + mutations │ │ ├── useTickers.ts # ticker queries + mutations │ │ ├── useScores.ts # scores, rankings queries │ │ ├── useTrades.ts # trade setup queries │ │ ├── useTickerDetail.ts # parallel queries for detail view │ │ └── useAdmin.ts # admin queries + mutations │ ├── stores/ │ │ └── authStore.ts # Zustand: token, user, role │ ├── pages/ │ │ ├── LoginPage.tsx │ │ ├── RegisterPage.tsx │ │ ├── WatchlistPage.tsx │ │ ├── TickerDetailPage.tsx │ │ ├── ScannerPage.tsx │ │ ├── RankingsPage.tsx │ │ └── AdminPage.tsx │ ├── components/ │ │ ├── layout/ │ │ │ ├── AppShell.tsx # Sidebar + main content │ │ │ ├── Sidebar.tsx │ │ │ └── MobileNav.tsx │ │ ├── auth/ │ │ │ └── ProtectedRoute.tsx │ │ ├── charts/ │ │ │ └── CandlestickChart.tsx │ │ ├── ui/ │ │ │ ├── ScoreCard.tsx │ │ │ ├── Toast.tsx │ │ │ ├── Skeleton.tsx │ │ │ ├── Badge.tsx │ │ │ └── ConfirmDialog.tsx │ │ ├── watchlist/ │ │ │ ├── WatchlistTable.tsx │ │ │ └── AddTickerForm.tsx │ │ ├── scanner/ │ │ │ └── TradeTable.tsx │ │ ├── rankings/ │ │ │ ├── RankingsTable.tsx │ │ │ └── WeightsForm.tsx │ │ ├── ticker/ │ │ │ ├── SentimentPanel.tsx │ │ │ ├── FundamentalsPanel.tsx │ │ │ ├── IndicatorSelector.tsx │ │ │ └── SROverlay.tsx │ │ └── admin/ │ │ ├── UserTable.tsx │ │ ├── SettingsForm.tsx │ │ ├── JobControls.tsx │ │ └── DataCleanup.tsx │ ├── lib/ │ │ ├── format.ts # Number/date formatting utilities │ │ └── types.ts # Shared TypeScript interfaces │ └── styles/ │ └── globals.css # Tailwind directives + custom vars └── tests/ ├── unit/ └── property/ ``` ## Components and Interfaces ### API Client (`src/api/client.ts`) Central Axios instance with interceptors: ```typescript // Axios instance configuration const apiClient = axios.create({ baseURL: '/api/v1/', timeout: 30_000, headers: { 'Content-Type': 'application/json' }, }); // Request interceptor: attach JWT apiClient.interceptors.request.use((config) => { const token = useAuthStore.getState().token; if (token) config.headers.Authorization = `Bearer ${token}`; return config; }); // Response interceptor: unwrap envelope, handle 401 apiClient.interceptors.response.use( (response) => { const envelope = response.data as APIEnvelope; if (envelope.status === 'error') throw new ApiError(envelope.error); return envelope.data; }, (error) => { if (error.response?.status === 401) { useAuthStore.getState().logout(); } const msg = error.response?.data?.error ?? error.message ?? 'Network error'; throw new ApiError(msg); } ); ``` ### Auth Store (`src/stores/authStore.ts`) ```typescript interface AuthState { token: string | null; username: string | null; role: 'admin' | 'user' | null; login: (token: string) => void; logout: () => void; } ``` - `login()` decodes the JWT payload to extract `sub` (username) and `role`, stores token in `localStorage` - `logout()` clears token from state and `localStorage`, TanStack Query cache is cleared on logout ### Protected Route (`src/components/auth/ProtectedRoute.tsx`) ```typescript // Wraps routes that require authentication // Props: requireAdmin?: boolean // If no token → redirect to /login // If requireAdmin && role !== 'admin' → redirect to /watchlist ``` ### Router Layout ```typescript // Route structure } /> } /> }> }> } /> } /> } /> } /> } /> }> } /> ``` ### TanStack Query Hooks Pattern Each domain has a hook file that exports query/mutation hooks: ```typescript // Example: useWatchlist.ts export function useWatchlist() { return useQuery({ queryKey: ['watchlist'], queryFn: () => api.watchlist.list(), }); } export function useAddToWatchlist() { const qc = useQueryClient(); return useMutation({ mutationFn: (symbol: string) => api.watchlist.add(symbol), onSuccess: () => qc.invalidateQueries({ queryKey: ['watchlist'] }), }); } ``` ### Key UI Components **ScoreCard**: Displays composite score with a colored ring/bar (green > 70, yellow 40-70, red < 40) and expandable dimension breakdown. **CandlestickChart**: Recharts `ComposedChart` with custom `Bar` shapes for OHLCV candles. S/R levels rendered as `ReferenceLine` components with color coding (green = support, red = resistance). **Toast System**: Lightweight toast using React context + portal. Auto-dismiss after 4 seconds. Error toasts in red, success in green. **Skeleton**: Tailwind `animate-pulse` placeholder blocks matching the shape of cards/tables during loading states. ### Formatting Utilities (`src/lib/format.ts`) ```typescript formatPrice(n: number): string // "1,234.56" formatPercent(n: number): string // "12.34%" formatLargeNumber(n: number): string // "1.23B", "456.7M", "12.3K" formatDate(d: string): string // "Jan 15, 2025" formatDateTime(d: string): string // "Jan 15, 2025 2:30 PM" ``` ## Data Models ### TypeScript Interfaces (`src/lib/types.ts`) ```typescript // API envelope (before unwrapping) interface APIEnvelope { status: 'success' | 'error'; data: T | null; error: string | null; } // Auth interface TokenResponse { access_token: string; token_type: string; } // Watchlist interface WatchlistEntry { symbol: string; entry_type: 'auto' | 'manual'; composite_score: number | null; dimensions: DimensionScore[]; rr_ratio: number | null; rr_direction: string | null; sr_levels: SRLevelSummary[]; added_at: string; } interface DimensionScore { dimension: string; score: number; } interface SRLevelSummary { price_level: number; type: 'support' | 'resistance'; strength: number; } // OHLCV interface OHLCVBar { id: number; ticker_id: number; date: string; open: number; high: number; low: number; close: number; volume: number; created_at: string; } // Scores interface ScoreResponse { symbol: string; composite_score: number | null; composite_stale: boolean; weights: Record; dimensions: DimensionScoreDetail[]; missing_dimensions: string[]; computed_at: string | null; } interface DimensionScoreDetail { dimension: string; score: number; is_stale: boolean; computed_at: string | null; } interface RankingEntry { symbol: string; composite_score: number; dimensions: DimensionScoreDetail[]; } interface RankingsResponse { rankings: RankingEntry[]; weights: Record; } // Trade Setups interface TradeSetup { id: number; symbol: string; direction: string; entry_price: number; stop_loss: number; target: number; rr_ratio: number; composite_score: number; detected_at: string; } // S/R Levels interface SRLevel { id: number; price_level: number; type: 'support' | 'resistance'; strength: number; detection_method: string; created_at: string; } interface SRLevelResponse { symbol: string; levels: SRLevel[]; count: number; } // Sentiment interface SentimentScore { id: number; classification: 'bullish' | 'bearish' | 'neutral'; confidence: number; source: string; timestamp: string; } interface SentimentResponse { symbol: string; scores: SentimentScore[]; count: number; dimension_score: number | null; lookback_hours: number; } // Fundamentals interface FundamentalResponse { symbol: string; pe_ratio: number | null; revenue_growth: number | null; earnings_surprise: number | null; market_cap: number | null; fetched_at: string | null; } // Indicators interface IndicatorResult { indicator_type: string; values: Record; score: number; bars_used: number; } interface EMACrossResult { short_ema: number; long_ema: number; short_period: number; long_period: number; signal: 'bullish' | 'bearish' | 'neutral'; } // Tickers interface Ticker { id: number; symbol: string; created_at: string; } // Admin interface AdminUser { id: number; username: string; role: string; has_access: boolean; created_at: string | null; updated_at: string | null; } interface SystemSetting { key: string; value: string; updated_at: string | null; } ``` ## Correctness Properties *A property is a characteristic or behavior that should hold true across all valid executions of a system — essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.* ### Property 1: Token storage round-trip *For any* valid JWT token string, storing it via `authStore.login(token)` and then reading `authStore.token` and `localStorage.getItem('token')` should both return the original token string. **Validates: Requirements 1.1, 1.6** ### Property 2: Bearer token attachment *For any* non-null token in the auth store, every request made through the API client should include an `Authorization` header with value `Bearer {token}`. **Validates: Requirements 1.3, 12.3** ### Property 3: Registration form validation *For any* username string shorter than 1 character or password string shorter than 6 characters, the registration form should reject submission. *For any* username of length >= 1 and password of length >= 6, the form should allow submission. **Validates: Requirements 1.2** ### Property 4: Route protection based on auth state *For any* protected route path, if no token exists in the auth store, navigation should redirect to `/login`. If a valid token exists, navigation should render the protected component. **Validates: Requirements 2.1, 2.2** ### Property 5: API envelope unwrapping *For any* API response with `status: "success"`, the API client should return the `data` field. *For any* API response with `status: "error"`, the API client should throw an error containing the `error` field message. **Validates: Requirements 12.2** ### Property 6: Watchlist entry rendering completeness *For any* watchlist entry, the rendered output should contain the symbol, entry type (with a visual badge distinguishing "auto" from "manual"), composite score, dimension scores, R:R ratio, R:R direction, and S/R levels. **Validates: Requirements 3.2, 3.7** ### Property 7: Symbol click navigation *For any* symbol displayed in the watchlist table, scanner table, or rankings table, clicking that symbol should trigger navigation to `/ticker/{symbol}`. **Validates: Requirements 3.6, 5.6, 6.4** ### Property 8: Score card rendering *For any* score response with a composite score and dimension scores, the ScoreCard component should render the composite score value and one entry per dimension with its name and score. **Validates: Requirements 4.4** ### Property 9: Sentiment panel rendering *For any* sentiment response, the rendered SentimentPanel should display the classification, confidence value, and dimension score. **Validates: Requirements 4.5** ### Property 10: Fundamentals panel rendering *For any* fundamentals response, the rendered FundamentalsPanel should display P/E ratio, revenue growth, earnings surprise, and market cap (or a placeholder for null values). **Validates: Requirements 4.6** ### Property 11: Trade setup rendering *For any* trade setup, the rendered table row should contain the symbol, direction, entry price, stop loss, target, R:R ratio, composite score, and detection timestamp. **Validates: Requirements 5.2** ### Property 12: Scanner filtering *For any* list of trade setups, minimum R:R filter value, and direction filter selection: all displayed setups should have `rr_ratio >= minRR` and (if direction is not "both") `direction === selectedDirection`. **Validates: Requirements 5.3, 5.4** ### Property 13: Scanner sorting *For any* list of trade setups and a selected sort column, the displayed rows should be ordered by that column's values (ascending or descending based on sort direction). **Validates: Requirements 5.5** ### Property 14: Rankings display order *For any* rankings response, the rendered list should display entries in descending order by composite score, with each entry showing rank position, symbol, composite score, and all dimension scores. **Validates: Requirements 6.1, 6.2** ### Property 15: Admin user table rendering *For any* admin user record, the rendered table row should contain the username, role, and access status. **Validates: Requirements 7.2** ### Property 16: Number formatting *For any* finite number, `formatPrice` should produce a string with exactly 2 decimal places. `formatPercent` should produce a string ending with `%`. `formatLargeNumber` should produce a string with an appropriate suffix (`K` for thousands, `M` for millions, `B` for billions) for values >= 1000, and no suffix for smaller values. **Validates: Requirements 13.4** ### Property 17: Weights form rendering *For any* weights map (dimension name → number), the WeightsForm should render one labeled numeric input per dimension key. **Validates: Requirements 11.1** ## Error Handling ### API Client Error Strategy All errors flow through the Axios response interceptor and are surfaced via the Toast system: | Error Type | Detection | Behavior | |---|---|---| | 401 Unauthorized | `error.response.status === 401` | Clear auth store, redirect to `/login` | | API error envelope | `envelope.status === 'error'` | Throw `ApiError` with `envelope.error` message | | Network error | No `error.response` | Throw `ApiError` with "Network error — check your connection" | | Timeout | Axios timeout (30s) | Throw `ApiError` with "Request timed out" | | Unknown | Catch-all | Throw `ApiError` with `error.message` fallback | ### Component-Level Error Handling - **TanStack Query `onError`**: Each mutation hook passes errors to the toast system - **Query error states**: Components check `isError` and render inline error messages - **Ticker Detail partial failure**: Each data section (scores, sentiment, fundamentals, S/R, OHLCV) is an independent query. If one fails, the others still render. Failed sections show an inline error with a retry button. - **Form validation**: Client-side validation before API calls (username length, password length, numeric inputs). Invalid submissions are blocked with inline field errors. ### Toast System ```typescript type ToastType = 'success' | 'error' | 'info'; interface Toast { id: string; type: ToastType; message: string; } // Auto-dismiss after 4 seconds // Max 3 toasts visible at once (oldest dismissed first) // Error toasts: red accent, Success: green accent, Info: blue accent ``` ## Testing Strategy ### Testing Stack | Tool | Purpose | |---|---| | Vitest | Test runner (Vite-native, fast) | | React Testing Library | Component rendering + DOM queries | | fast-check | Property-based testing | | MSW (Mock Service Worker) | API mocking for integration tests | ### Unit Tests Unit tests cover specific examples, edge cases, and integration points: - **Auth flow**: Login stores token, logout clears token, 401 triggers logout - **API client**: Envelope unwrapping for success/error, timeout config, Bearer header - **Routing**: Unauthenticated redirect, admin-only route guard, non-admin redirect - **Component rendering**: Each page renders with mock data, loading skeletons appear, error states display - **Form validation**: Empty username rejected, short password rejected, valid inputs accepted - **Confirmation dialog**: Delete ticker shows confirm before API call - **Partial failure**: Ticker detail renders available sections when one query fails ### Property-Based Tests Each correctness property maps to a single `fast-check` property test with minimum 100 iterations. Tests are tagged with the property reference: ```typescript // Feature: signal-dashboard, Property 16: Number formatting test.prop([fc.float({ min: -1e15, max: 1e15, noNaN: true })], (n) => { const result = formatPrice(n); expect(result).toMatch(/\.\d{2}$/); }); ``` Property tests focus on: - **Pure functions**: `format.ts` utilities (Property 16) - **Store logic**: Auth store token round-trip (Property 1) - **API client interceptors**: Envelope unwrapping (Property 5), Bearer attachment (Property 2) - **Filtering/sorting logic**: Scanner filter functions (Properties 12, 13) - **Component rendering**: Given generated data, components render required fields (Properties 6, 8–11, 14, 15, 17) - **Routing guards**: Protected route behavior based on auth state (Property 4) ### Test Configuration - Vitest config in `frontend/vitest.config.ts` with jsdom environment - `fast-check` configured with `{ numRuns: 100 }` minimum per property - MSW handlers for all API endpoints used in integration tests - Each property test tagged: `Feature: signal-dashboard, Property {N}: {title}`