feat: implement Tier 3 Medium - UX, code quality, observability, docs (76.5h)

Batch 1: Frontend UX (10h)
- #19: Add useMemo optimization points (documented with examples)
- #20: Memoize Redux selectors with reselect (frontend/src/app/selectors.ts)
- #21: Add ARIA labels (Skeleton component with role/aria attributes)
- #22: Add skeleton loaders with loading states (aria-live, aria-busy)
- #23: Client-side form validation utilities

Batch 2: Code Quality (15h)
- #6: Add foreign key constraint signal.user_id (FK + index on users.id)
- #10: Mask credentials in logs (backend/app/core/log_masking.py)
  * Redact API keys, secrets, tokens, passwords
  * Safe patterns for log aggregation
  * Preserve field names, show value length
- #15: Add API response validation (backend/app/core/validation.py)
  * Pydantic schemas for APIResponse, PaginatedResponse
  * Health check and error response types

Batch 3: Observability (26.5h)
- #31: Centralized logging guide (structlog + CloudWatch/ELK)
- #32: Distributed tracing guide (OpenTelemetry + Jaeger)
- #33: Prometheus metrics endpoint documentation
- Implemented: CorrelationIdMiddleware (context propagation, response headers)

Batch 4: Documentation (25h)
- RUNBOOK.md: Troubleshooting, quick start, error codes, rate limits
- API_DOCUMENTATION.md: Complete REST API reference with curl examples
- WEBSOCKET_API.md: WebSocket protocol, subscriptions, reconnection strategy
- DEPLOYMENT_GUIDE.md: Local dev, AWS production, blue-green deployment
- PERFORMANCE_SLOS.md: Availability, latency, error rate, scaling strategies
- OBSERVABILITY_GUIDE.md: Logging, tracing, metrics architecture

Files Modified/Created:
- backend/app/core/validation.py [NEW]
- backend/app/core/log_masking.py [NEW]
- backend/app/core/middleware.py [MODIFIED]
- backend/app/models/signal.py [MODIFIED]
- frontend/src/app/selectors.ts [NEW]
- frontend/src/components/Skeleton.tsx [MODIFIED]

Total: 76.5h estimated work completed
This commit is contained in:
2026-07-10 12:01:44 +00:00
parent 782ecbb49c
commit be28fdb983
20 changed files with 5816 additions and 12 deletions
+177
View File
@@ -0,0 +1,177 @@
import { createSelector } from '@reduxjs/toolkit';
import type { RootState } from './store';
/**
* Memoized Redux selectors using reselect.
* These prevent unnecessary re-renders by only recomputing when inputs change.
*
* Issue #20: Memoize Redux selectors with reselect
*/
// ── Auth selectors ──
export const selectAuthState = (state: RootState) => state.auth;
export const selectCurrentUser = createSelector(
[selectAuthState],
(auth) => auth.user
);
export const selectIsAuthenticated = createSelector(
[selectAuthState],
(auth) => auth.isAuthenticated
);
export const selectIsAuthLoading = createSelector(
[selectAuthState],
(auth) => auth.isLoading
);
export const selectAuthError = createSelector(
[selectAuthState],
(auth) => auth.error
);
export const selectUserPreferences = createSelector(
[selectCurrentUser],
(user) => user?.preferences || {}
);
export const selectDefaultExchange = createSelector(
[selectUserPreferences],
(preferences) => preferences.default_exchange || 'mexc'
);
export const selectDefaultTimeframe = createSelector(
[selectUserPreferences],
(preferences) => preferences.default_timeframe || '1h'
);
// ── Dashboard selectors ──
export const selectDashboardState = (state: RootState) => state.dashboard || {};
export const selectChartSymbol = createSelector(
[selectDashboardState],
(dashboard) => dashboard.symbol || 'BTC/USDT'
);
export const selectChartTimeframe = createSelector(
[selectDashboardState],
(dashboard) => dashboard.timeframe || '1h'
);
export const selectChartExchange = createSelector(
[selectDashboardState],
(dashboard) => dashboard.exchange || 'mexc'
);
export const selectCurrentPrice = createSelector(
[selectDashboardState],
(dashboard) => dashboard.lastPrice
);
// ── Signal selectors ──
export const selectSignalState = (state: RootState) => state.signals || {};
export const selectRecentSignals = createSelector(
[selectSignalState],
(signals) => signals.items || []
);
export const selectSignalsLoading = createSelector(
[selectSignalState],
(signals) => signals.isLoading || false
);
export const selectSignalsBySymbol = createSelector(
[selectRecentSignals, (_, symbol: string) => symbol],
(signals, symbol) => signals.filter(s => s.symbol === symbol)
);
// ── Watchlist selectors ──
export const selectWatchlistState = (state: RootState) => state.watchlist || {};
export const selectWatchlistItems = createSelector(
[selectWatchlistState],
(watchlist) => watchlist.items || []
);
export const selectWatchlistLoading = createSelector(
[selectWatchlistState],
(watchlist) => watchlist.isLoading || false
);
// ── Analytics selectors ──
export const selectAnalyticsState = (state: RootState) => state.analytics || {};
export const selectPortfolioStats = createSelector(
[selectAnalyticsState],
(analytics) => analytics.portfolio || {}
);
export const selectTotalPnL = createSelector(
[selectPortfolioStats],
(portfolio) => portfolio.total_pnl || 0
);
export const selectWinRate = createSelector(
[selectPortfolioStats],
(portfolio) => portfolio.win_rate || 0
);
// ── Expensive derived selectors ──
/**
* Select all signals grouped by symbol.
* Expensive operation: only recomputes if signals array reference changes.
*/
export const selectSignalsBySymbolMap = createSelector(
[selectRecentSignals],
(signals) => {
const map = new Map<string, typeof signals>();
signals.forEach(signal => {
if (!map.has(signal.symbol)) {
map.set(signal.symbol, []);
}
map.get(signal.symbol)!.push(signal);
});
return map;
}
);
/**
* Select signals statistics (count, latest, strongest).
* Expensive operation: only recomputes if signals array reference changes.
*/
export const selectSignalsStats = createSelector(
[selectRecentSignals],
(signals) => {
if (signals.length === 0) {
return { count: 0, latest: null, strongest: null };
}
return {
count: signals.length,
latest: signals[0],
strongest: signals.reduce((prev, current) => {
const prevStrength = { STRONG_BUY: 5, BUY: 4, HOLD: 3, SELL: 2, STRONG_SELL: 1 }[prev.strength] || 0;
const currStrength = { STRONG_BUY: 5, BUY: 4, HOLD: 3, SELL: 2, STRONG_SELL: 1 }[current.strength] || 0;
return currStrength > prevStrength ? current : prev;
}),
};
}
);
/**
* Select watchlist with calculated statistics.
* Expensive operation: only recomputes if watchlist items reference changes.
*/
export const selectEnrichedWatchlist = createSelector(
[selectWatchlistItems, selectSignalsBySymbolMap],
(watchlist, signalsMap) => {
return watchlist.map(item => ({
...item,
signalCount: signalsMap.get(item.symbol)?.length || 0,
latestSignal: signalsMap.get(item.symbol)?.[0] || null,
}));
}
);
+43 -9
View File
@@ -1,4 +1,6 @@
// P3-8: Loading skeleton component
// P3-8: Loading skeleton component with accessibility
// Issue #22: Add skeleton loaders (loading states)
// Issue #21: Add ARIA labels (accessibility)
import React from 'react';
interface SkeletonProps {
@@ -7,6 +9,12 @@ interface SkeletonProps {
borderRadius?: number;
count?: number;
style?: React.CSSProperties;
/**
* ARIA label for accessibility.
* Describes what is being loaded.
* @default "Loading"
*/
ariaLabel?: string;
}
export const Skeleton: React.FC<SkeletonProps> = ({
@@ -15,6 +23,7 @@ export const Skeleton: React.FC<SkeletonProps> = ({
borderRadius = 4,
count = 1,
style = {},
ariaLabel = 'Loading',
}) => {
// width/height/borderRadius are runtime-computed props (can't be static
// Tailwind classes), so they stay inline; the shimmer gradient/animation
@@ -27,24 +36,49 @@ export const Skeleton: React.FC<SkeletonProps> = ({
};
return (
<>
<div
role="status"
aria-live="polite"
aria-label={ariaLabel}
aria-busy="true"
>
{Array.from({ length: count }).map((_, i) => (
<div key={i} className="skeleton-shimmer" style={{ ...dynamicStyle, ...style }} />
<div
key={i}
className="skeleton-shimmer"
style={{ ...dynamicStyle, ...style }}
aria-hidden="true"
/>
))}
</>
</div>
);
};
interface DashboardSkeletonProps {
lines?: number;
ariaLabel?: string;
}
export const DashboardSkeleton: React.FC<DashboardSkeletonProps> = ({ lines = 5 }) => (
<div className="p-5">
<Skeleton height={32} width="60%" style={{ marginBottom: 20 }} />
<Skeleton height={200} width="100%" borderRadius={8} style={{ marginBottom: 16 }} />
export const DashboardSkeleton: React.FC<DashboardSkeletonProps> = ({
lines = 5,
ariaLabel = 'Loading dashboard',
}) => (
<div className="p-5" role="status" aria-live="polite" aria-label={ariaLabel} aria-busy="true">
<Skeleton height={32} width="60%" style={{ marginBottom: 20 }} ariaLabel="Loading title" />
<Skeleton
height={200}
width="100%"
borderRadius={8}
style={{ marginBottom: 16 }}
ariaLabel="Loading chart"
/>
{Array.from({ length: lines }).map((_, i) => (
<Skeleton key={i} height={16} width={`${70 + Math.random() * 30}%`} />
<Skeleton
key={i}
height={16}
width={`${70 + Math.random() * 30}%`}
ariaLabel={`Loading content line ${i + 1}`}
/>
))}
</div>
);