diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 42c9825..673cb9b 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -29,7 +29,7 @@ It assumes: - [x] Monorepo foundation scaffolded with root workspace config, shared runtime, shared product identity, local package linking, and verification scripts - [x] Backend migrated into `backend/` and passing typecheck, build, test, and backend verification gates - [x] Web migrated into `web/` with shared runtime, shared kill-switch gate, shared telemetry bootstrap, and normalized backend URL resolution -- [x] Mobile migrated into `mobile/` with product identity, shared runtime bootstrap, launch-time kill-switch gate, transitional Supabase auth, live backend polling plus websocket-backed updates, startup/error telemetry capture, and secure session storage with invalidation handling +- [x] Mobile migrated into `mobile/` with product identity, shared runtime bootstrap, launch-time kill-switch gate, transitional Supabase auth, live backend polling plus websocket-backed updates, startup/error telemetry capture, secure session storage with invalidation handling, and explicit degraded/offline status surfacing - [-] DRY cleanup completed for runtime/config/bootstrap concerns and shared Supabase bootstrap, but not yet for all auth/session internals - [!] Full common-platform auth replacement remains a follow-up for web and mobile; current implementation uses transitional Supabase-backed auth to stay compatible with the backend's current JWT boundary @@ -313,7 +313,7 @@ Build mobile as a real ecosystem surface, not a mock UI shell. - [x] Define mobile action policy for monitor-first versus control-first flows - [x] Define alert and incident UX - [-] Define operator-safe interventions -- [-] Define offline and degraded-state behavior +- [x] Define offline and degraded-state behavior ### Mobile v1 Scope @@ -444,7 +444,7 @@ Validate that the new monorepo is safer and more coherent than the legacy setup - [x] Define status polling/live update strategy - [ ] Define alert/incident UX - [ ] Define operator-safe interventions -- [ ] Define offline and degraded-state behavior +- [x] Define offline and degraded-state behavior ## 10. Sequencing Recommendations diff --git a/mobile/app/(tabs)/settings.tsx b/mobile/app/(tabs)/settings.tsx index b89a836..337be01 100644 --- a/mobile/app/(tabs)/settings.tsx +++ b/mobile/app/(tabs)/settings.tsx @@ -12,7 +12,7 @@ import { useMobileAuth } from '@/providers/MobileAuthProvider'; export default function SettingsScreen() { const insets = useSafeAreaInsets(); - const { botState, portfolio, pauseTrading, resumeTrading } = useTradingData(); + const { botState, portfolio, pauseTrading, resumeTrading, connectionState, error, lastUpdatedAt } = useTradingData(); const { profile, signOut } = useMobileAuth(); const executionModeIndex = botState?.settings.executionMode === 'Live' ? 2 : botState?.settings.executionMode === 'Paper' ? 1 : 0; const [maxOpenTrades, setMaxOpenTrades] = useState(botState?.settings.maxOpenTrades || 3); @@ -25,6 +25,17 @@ export default function SettingsScreen() { const [oledBlack, setOledBlack] = useState(false); const tradingMode = botState?.health?.tradingControl?.mode ?? 'RUNNING'; const isAdmin = profile?.role === 'admin'; + const brokerStatusText = + connectionState === 'live' ? 'Connected' : connectionState === 'degraded' ? 'Degraded' : 'Offline'; + const brokerHint = error + ? error + : connectionState === 'live' + ? tradingMode === 'PAUSED' + ? 'Trading is currently paused.' + : 'Broker state is sourced from the live backend.' + : lastUpdatedAt + ? `Last backend sync ${Math.max(Math.round((Date.now() - lastUpdatedAt) / 60000), 0)}m ago.` + : 'Waiting for backend connectivity.'; const modeColors = [Colors.text.secondary, Colors.accent.blue, Colors.accent.orange]; @@ -123,11 +134,28 @@ export default function SettingsScreen() { Alpaca - - Connected + + + {brokerStatusText} + - {tradingMode === 'PAUSED' ? 'Trading is currently paused.' : 'Broker state is sourced from the live backend.'} + {brokerHint} @@ -450,6 +478,12 @@ const styles = StyleSheet.create({ fontSize: FontSize.bodySmall, color: Colors.accent.green, }, + degradedText: { + color: Colors.accent.amber, + }, + offlineText: { + color: Colors.accent.red, + }, apiKeyHint: { fontFamily: Fonts.mono.regular, fontSize: FontSize.bodySmall, diff --git a/mobile/components/dashboard/StatusBanner.tsx b/mobile/components/dashboard/StatusBanner.tsx index 56f36d8..b22e372 100644 --- a/mobile/components/dashboard/StatusBanner.tsx +++ b/mobile/components/dashboard/StatusBanner.tsx @@ -5,26 +5,43 @@ import PulsingDot from '@/components/PulsingDot'; import { useTradingData } from '@/providers/TradingDataProvider'; export default function StatusBanner() { - const { botState, connected } = useTradingData(); + const { botState, connectionState, error, lastUpdatedAt } = useTradingData(); const controlMode = botState?.health?.tradingControl?.mode ?? 'RUNNING'; const executionMode = botState?.settings.executionMode || 'Paper'; const uptimeSeconds = botState?.uptime || 0; const uptimeHours = Math.floor(uptimeSeconds / 3600); const uptimeMinutes = Math.floor((uptimeSeconds % 3600) / 60); const isPaused = controlMode === 'PAUSED'; + const statusText = + connectionState === 'offline' ? 'OFFLINE' : connectionState === 'degraded' ? 'DEGRADED' : controlMode; + const statusDetail = error + ? error + : lastUpdatedAt + ? `Synced ${Math.max(Math.round((Date.now() - lastUpdatedAt) / 60000), 0)}m ago` + : 'Awaiting sync'; return ( - - {!isPaused ? : null} + + {connectionState === 'live' && !isPaused ? : null} - {connected ? controlMode : 'OFFLINE'} + {statusText} {executionMode.toUpperCase()} - {uptimeHours}h {uptimeMinutes}m + + {statusDetail} + {uptimeHours}h {uptimeMinutes}m + ); } @@ -68,13 +85,30 @@ const styles = StyleSheet.create({ pausedBadge: { backgroundColor: 'rgba(255,149,0,0.15)', }, + degradedBadge: { + backgroundColor: 'rgba(255,184,0,0.15)', + }, + offlineBadge: { + backgroundColor: 'rgba(255,71,87,0.15)', + }, pausedText: { color: Colors.accent.amber, }, + metaColumn: { + marginLeft: 'auto', + alignItems: 'flex-end', + gap: 2, + minWidth: 110, + }, + detailText: { + fontFamily: Fonts.inter.medium, + fontSize: FontSize.micro, + color: Colors.text.secondary, + maxWidth: 140, + }, uptime: { fontFamily: Fonts.mono.regular, fontSize: FontSize.bodySmall, color: Colors.text.secondary, - marginLeft: 'auto', }, }); diff --git a/mobile/providers/TradingDataProvider.tsx b/mobile/providers/TradingDataProvider.tsx index d8c9ac3..50081cc 100644 --- a/mobile/providers/TradingDataProvider.tsx +++ b/mobile/providers/TradingDataProvider.tsx @@ -115,6 +115,8 @@ interface TradingDataContextValue { loading: boolean; error: string | null; connected: boolean; + connectionState: 'live' | 'degraded' | 'offline'; + lastUpdatedAt: number | null; refresh: () => Promise; pauseTrading: (reason?: string) => Promise<{ error?: string }>; resumeTrading: (reason?: string) => Promise<{ error?: string }>; @@ -142,6 +144,12 @@ export function TradingDataProvider({ children }: { children: ReactNode }) { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [connected, setConnected] = useState(false); + const [lastUpdatedAt, setLastUpdatedAt] = useState(null); + const [statusClock, setStatusClock] = useState(() => Date.now()); + + const markStateSynced = useCallback(() => { + setLastUpdatedAt(Date.now()); + }, []); const fetchState = useCallback(async () => { if (!accessToken || !user) { @@ -171,6 +179,7 @@ export function TradingDataProvider({ children }: { children: ReactNode }) { setBotState(data); setConnected(true); setError(null); + markStateSynced(); } catch (fetchError) { setConnected(false); setError(fetchError instanceof Error ? fetchError.message : 'Failed to load trading state'); @@ -178,7 +187,7 @@ export function TradingDataProvider({ children }: { children: ReactNode }) { } finally { setLoading(false); } - }, [accessToken, user, invalidateSession]); + }, [accessToken, user, invalidateSession, markStateSynced]); useEffect(() => { void fetchState(); @@ -191,6 +200,14 @@ export function TradingDataProvider({ children }: { children: ReactNode }) { return () => clearInterval(interval); }, [accessToken, user, fetchState]); + useEffect(() => { + const interval = setInterval(() => { + setStatusClock(Date.now()); + }, 30000); + + return () => clearInterval(interval); + }, []); + useEffect(() => { if (!accessToken || !user) { return; @@ -232,13 +249,16 @@ export function TradingDataProvider({ children }: { children: ReactNode }) { socket.on('state', (nextState: BotState) => { mergePartialState(nextState); + markStateSynced(); }); socket.on('health_update', (health: HealthSnapshot) => { + markStateSynced(); setBotState((prev) => (prev ? { ...prev, health } : prev)); }); socket.on('symbol_update', ({ symbol, data }: { symbol: string; data: BotState['symbols'][string] }) => { + markStateSynced(); setBotState((prev) => { if (!prev) return prev; return { @@ -252,6 +272,7 @@ export function TradingDataProvider({ children }: { children: ReactNode }) { }); socket.on('new_alert', (alert: BotState['alerts'][number]) => { + markStateSynced(); setBotState((prev) => { if (!prev) return prev; return { @@ -262,14 +283,17 @@ export function TradingDataProvider({ children }: { children: ReactNode }) { }); socket.on('positions_update', (positions: BotState['positions']) => { + markStateSynced(); setBotState((prev) => (prev ? { ...prev, positions } : prev)); }); socket.on('orders_update', (orders: BotState['orders']) => { + markStateSynced(); setBotState((prev) => (prev ? { ...prev, orders } : prev)); }); socket.on('history_update', (trade: BotState['history'][number]) => { + markStateSynced(); setBotState((prev) => { if (!prev) return prev; return { @@ -280,6 +304,7 @@ export function TradingDataProvider({ children }: { children: ReactNode }) { }); socket.on('settings_update', (settings: BotState['settings']) => { + markStateSynced(); setBotState((prev) => (prev ? { ...prev, settings } : prev)); }); @@ -298,7 +323,7 @@ export function TradingDataProvider({ children }: { children: ReactNode }) { return () => { socket?.close(); }; - }, [accessToken, user, fetchState]); + }, [accessToken, user, fetchState, invalidateSession, markStateSynced]); const postTradingAction = useCallback( async (path: '/internal/trading/pause' | '/internal/trading/resume', reason?: string) => { @@ -378,19 +403,38 @@ export function TradingDataProvider({ children }: { children: ReactNode }) { [botState] ); + const connectionState = useMemo<'live' | 'degraded' | 'offline'>(() => { + if (!connected && !botState) { + return 'offline'; + } + + const staleMs = lastUpdatedAt ? statusClock - lastUpdatedAt : Number.POSITIVE_INFINITY; + if (connected && !error && staleMs < 90_000) { + return 'live'; + } + + if (botState || connected) { + return 'degraded'; + } + + return 'offline'; + }, [connected, botState, error, lastUpdatedAt, statusClock]); + const value = useMemo( () => ({ botState, loading, error, connected, + connectionState, + lastUpdatedAt, refresh: fetchState, pauseTrading: (reason?: string) => postTradingAction('/internal/trading/pause', reason), resumeTrading: (reason?: string) => postTradingAction('/internal/trading/resume', reason), portfolio, marketTicker, }), - [botState, loading, error, connected, fetchState, postTradingAction, portfolio, marketTicker] + [botState, loading, error, connected, connectionState, lastUpdatedAt, fetchState, postTradingAction, portfolio, marketTicker] ); return {children};