# 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
<Routes>
  <Route path="/login" element={<LoginPage />} />
  <Route path="/register" element={<RegisterPage />} />
  <Route element={<ProtectedRoute />}>
    <Route element={<AppShell />}>
      <Route path="/" element={<Navigate to="/watchlist" />} />
      <Route path="/watchlist" element={<WatchlistPage />} />
      <Route path="/ticker/:symbol" element={<TickerDetailPage />} />
      <Route path="/scanner" element={<ScannerPage />} />
      <Route path="/rankings" element={<RankingsPage />} />
      <Route element={<ProtectedRoute requireAdmin />}>
        <Route path="/admin" element={<AdminPage />} />
      </Route>
    </Route>
  </Route>
</Routes>
```

### 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<T = unknown> {
  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<string, number>;
  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<string, number>;
}

// 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<string, unknown>;
  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}`
