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};