CloneOps AI Worker Dashboard
Build a tested React Native dashboard with resilient API loading and retry.
Introduction
30 Second Summary
A polished mobile screen can carry an interview demo until the network fails. That failure reveals the quality of every engineering decision behind it.
In this project, you will build CloneOps, a resilient React Native dashboard that loads live AI worker tasks on Android. You will deliberately break the task feed before proving its recovery with automated tests.
What You'll Build
You'll demo CloneOps on Android as it recovers a live AI worker task feed after a simulated outage.
By the end of this project, you'll have:
- A polished task dashboard where you can scroll live AI worker tasks. Each card shows whether its worker is running or complete.
- A visible recovery flow that turns a simulated outage into an actionable error screen. Its Retry control restores the live task list.
- An automated test suite using Jest plus React Native Testing Library. It proves malformed API data is rejected. It also proves Retry calls the recovery path.
- Secret Mission: Add tested All, Running, plus Complete filters using derived UI state.
Is this project right for me?
This project assumes production React Native experience plus an Android testing target. The primary path uses Windows PowerShell with an Android Studio Emulator on Windows.
Before We Start
Before the hands-on work begins, commit to building CloneOps as interview-ready evidence for the Aphid role. This Android React Native feature will demonstrate typed API boundaries, resilient recovery UX, virtualized list performance, accessibility, and automated testing.
Scaffold and Run the Android App
CloneOps needs a trustworthy Android baseline before you can make senior-level decisions about network failure. A reachable test target removes uncertainty from the mobile toolchain.
In this step, you will use Expo to create a blank React Native app with SDK 58. You will prove the TypeScript starter can render on your Android emulator.
In this step, get ready to:
- Confirm that adb can reach your running Android test target.
- Generate the clone-ops blank TypeScript project.
- Launch the starter app on your Android emulator.
Why Expo for this project?
Expo provides a production-grade framework for new React Native apps. It keeps this project focused on the feature decisions that matter for the target role.
A framework-free React Native project requires more native environment setup. Expo gives CloneOps a verified Android baseline within the project time limit.
Verify the Android target
Your Android Studio Emulator provides the test target for CloneOps. The Android Debug Bridge connects that target to your development tools.
Windows PowerShell gives you a direct check before the scaffold begins. This catches a missing emulator connection before Expo needs it.
- Press the Windows key to open the search bar.
- Type Android Studio into the search bar.
- Press Enter to open Android Studio.
- Select Device Manager from the right tool window bar.
- Click the start control beside your existing Android emulator.
- Wait for the Android home screen to finish loading.
The Android home screen confirms that the emulator has completed its startup. Next, you can check whether development tools can reach it.
- Press the Windows key to open the search bar.
- Type Windows PowerShell into the search bar.
- Press Enter to open PowerShell.
- Check both the platform tool and connected target by running:
adb --version
adb devices
What do these checks prove?
- The adb --version command confirms that PowerShell can find the Android platform tool.
- The adb devices command lists the Android targets connected to the adb server.
- A target row ending in device confirms that adb can reach the emulator.
You will see adb version details first. The device list below them should contain your emulator with the state device.
Emulator missing from adb?
- Confirm that %LOCALAPPDATA%\Android\Sdk\platform-tools is available to your PowerShell session when adb cannot be found.
- Return to Device Manager when the device list is empty.
- Start the existing emulator before running both checks again.
Ask for help with the exact output you see: Why can PowerShell run adb but not see my Android emulator?
Scaffold the Expo project
The scaffold creates the known project shape that every later step builds on. You will keep clone-ops on your Desktop so its location stays predictable.
- Move PowerShell to your Desktop by running this command:
Set-Location -Path "$HOME\Desktop"
What does this command do?
PowerShell changes its current location to the Desktop inside your Windows home folder. The scaffold therefore creates clone-ops somewhere you can find easily.
The scaffold asks for an app name before it creates the project. Expect the package download to take a few minutes on its first run.
- Use clone-ops when the app-name prompt appears.
- Create the blank TypeScript project by running:
npx create-expo-app@latest --template blank-typescript
What does this scaffold create?
The command selects Expo's blank TypeScript template. It creates the clone-ops folder after you supply the name.
The scaffold also installs the project dependencies. Its generated files include App.tsx, index.ts, and package.json.
Once PowerShell returns to its prompt, the starter exists on disk. Open that generated folder to verify the scaffold itself.
- Press the Windows key to open the search bar.
- Type Visual Studio Code into the search bar.
- Press Enter to open Visual Studio Code.
- Click File in the top menu bar.
- Click Open Folder.
- Select the clone-ops folder on your Desktop.
- Click Select Folder.
You should see App.tsx, index.ts, and package.json in the Explorer sidebar. Those files confirm that the blank project was generated.
- Select package.json in the Explorer sidebar.
- Find the expo entry under dependencies.
✔️ I see the expected Expo version
You should see "expo": "58.0.21" in the manifest. Your scaffold now matches the pinned Expo SDK 58 baseline.
ⓧ I see an older Expo version
An older dependency means this fresh scaffold does not match the project baseline. Removing this untouched generated folder is safe because it contains no feature work.
- Return to the PowerShell window from earlier.
- Remove only the untouched scaffold by running:
Remove-Item -Path "$HOME\Desktop\clone-ops" -Recurse -Force
What does this reset remove?
This command permanently removes the fresh clone-ops folder from your Desktop. It leaves your other files untouched.
- Use clone-ops when the app-name prompt appears again.
- Create a fresh scaffold from the Desktop by running:
Set-Location -Path "$HOME\Desktop"
npx create-expo-app@latest --template blank-typescript
What does this retry do?
The first line restores the expected Desktop location. The second line requests the current blank TypeScript scaffold again.
- Return to Visual Studio Code after the scaffold completes.
- Click File in the top menu bar.
- Click Open Folder.
- Select the fresh clone-ops folder on your Desktop.
- Check the expo entry in package.json again.
ⓧ Expo is missing
A missing dependency means the scaffold stopped before it finished. This folder contains no project work yet, so you can reset it cleanly.
- Return to the PowerShell window from earlier.
- Remove the incomplete scaffold by running:
Remove-Item -Path "$HOME\Desktop\clone-ops" -Recurse -Force
What does this reset remove?
This command removes the incomplete clone-ops folder. A fresh scaffold can then reuse the required project name.
- Use clone-ops when the app-name prompt appears again.
- Regenerate the project from the Desktop by running:
Set-Location -Path "$HOME\Desktop"
npx create-expo-app@latest --template blank-typescript
What does this retry do?
PowerShell returns to the Desktop first. The scaffold then recreates the blank TypeScript app with its dependencies.
- Return to Visual Studio Code after the scaffold completes.
- Click File in the top menu bar.
- Click Open Folder.
- Select the fresh clone-ops folder on your Desktop.
- Check the expo entry in package.json again.
Scaffold did not finish?
Use the matching reset tab above if the dependency download stops before package.json is complete.
Share the final PowerShell output when asking for help: Why did my Expo blank TypeScript scaffold stop before installing its dependencies?
✔️ Awesome, I've got everything!
Your generated files match the scaffold checkpoint. Keep the clone-ops folder open in Visual Studio Code.
ⓧ I'd like to double check the full code
The scaffold controls the starter files at this stage. Compare the generated entry point with the project reference.
- Compare your generated index.ts with this full file:
import { registerRootComponent } from 'expo';
import App from './App';
registerRootComponent(App);
What should the generated project contain?
- The Expo blank TypeScript starter App component is present in App.tsx.
- The index.ts file imports registerRootComponent from expo.
- The entry point imports App from ./App.
- The entry point calls registerRootComponent(App).
- The package.json file contains the Expo-generated clone-ops package manifest.
- The manifest provides scripts for start, android, ios, and web.
- The manifest includes Expo SDK 58, React, React Native, and TypeScript support.
Launch the app on Android
The generated files only become a verified baseline when the Android target renders them. The integrated terminal keeps the development server tied to the open clone-ops folder.
- Click Terminal in the Visual Studio Code top menu bar.
- Click New Terminal.
- Check that the terminal path ends with clone-ops.
The first Android launch can take a few minutes while Expo prepares the bundle. A matching Expo Go version can install on the emulator during this run.
Before you run it, which screen do you expect the Android emulator to show?
- Send the scaffold to your running Android target by running:
npx expo start --android
What does this command do?
- Expo starts the development server from the open clone-ops folder.
- The --android option sends the app to the connected Android emulator.
- The server owns this terminal until you stop it with Ctrl+C.
- Keep the development server terminal open for the next step.
You will see the blank Expo starter screen on the Android emulator. You have cleared the toolchain hurdle for every CloneOps feature that follows.
Starter screen not opening?
- Return to Device Manager if Expo cannot find an Android target.
- Start your existing emulator before retrying the launch command.
- Keep the development server terminal active if the emulator stops receiving updates.
Ask for help with the emulator state and terminal output: Why is my Expo starter app not opening on the connected Android emulator?
Your Android baseline is live. Next, you will replace the starter screen with the first visible CloneOps task feed.
Build a Static Task Dashboard
Your Android target already runs the Expo starter. That proves the mobile toolchain works without showing the product you want to discuss in an interview.
A static React Native vertical slice makes the task feed visible early. A local array with a TypeScript task shape also makes the missing production data boundary clear.
In this step, get ready to:
- Define typed sample tasks for the static product slice.
- Render task cards in a dark FlatList dashboard.
- Inspect the Android hierarchy for list structure plus control semantics.
Replace the starter with typed fixtures
A typed fixture gives the dashboard realistic input before networking introduces delays or failures. The screen derives its count from the same array that feeds the list.
- In the Visual Studio Code Explorer sidebar, select App.tsx.
- Replace its existing contents by pasting the code below:
import { FlatList, Pressable, Text, View } from 'react-native';
type CloneTaskStatus = 'running' | 'complete';
interface CloneTask { id: number; title: string; cloneName: string; status: CloneTaskStatus; }
// Keep fixtures shaped like the future API result.
const tasks: CloneTask[] = [
{ id: 1, title: 'Inspect queue', cloneName: 'Clone 4', status: 'running' }, { id: 2, title: 'Send report', cloneName: 'Clone 8', status: 'complete' },
{ id: 3, title: 'Inspect queue', cloneName: 'Clone 4', status: 'running' }, { id: 4, title: 'Send report', cloneName: 'Clone 8', status: 'complete' },
{ id: 7, title: 'Inspect queue', cloneName: 'Clone 4', status: 'running' }, { id: 8, title: 'Send report', cloneName: 'Clone 8', status: 'complete' },
];
const taskKey = (task: CloneTask) => String(task.id); // Preserve row identity across renders.
export default function App() {
// Derive the summary from the tasks displayed below.
const runningCount = tasks.filter((task) => task.status === 'running').length;
return (
<View style={{ flex: 1, backgroundColor: '#07111f', paddingTop: 48 }}>
<Text style={{ color: '#79b8ff', fontSize: 12, fontWeight: '800', letterSpacing: 1.4, paddingHorizontal: 20 }}>AI WORKFORCE OPERATIONS</Text>
<Text style={{ color: '#ffffff', fontSize: 34, fontWeight: '800', marginTop: 4, paddingHorizontal: 20 }}>CloneOps</Text>
<Text style={{ color: '#a9b8cc', fontSize: 15, marginTop: 6, paddingHorizontal: 20 }}>{runningCount} running of {tasks.length} tasks</Text>
<Pressable accessibilityLabel="Simulate outage" accessibilityRole="button" onPress={() => {}} style={{ alignSelf: 'flex-start', borderColor: '#ff8b8b', borderRadius: 10, borderWidth: 1, margin: 20, paddingHorizontal: 14, paddingVertical: 9 }}><Text style={{ color: '#ffb4b4', fontSize: 13, fontWeight: '700' }}>Simulate outage</Text></Pressable>
<FlatList contentContainerStyle={{ paddingHorizontal: 20, paddingBottom: 24 }} data={tasks} keyExtractor={taskKey} renderItem={({ item }) => (
<View style={{ flexDirection: 'row', alignItems: 'center', backgroundColor: '#10233d', borderColor: '#1d3b60', borderRadius: 16, borderWidth: 1, marginBottom: 12, padding: 16 }}>
<View style={{ flex: 1, paddingRight: 12 }}><Text style={{ color: '#79b8ff', fontSize: 12, fontWeight: '700', marginBottom: 6, textTransform: 'uppercase' }}>{item.cloneName}</Text><Text style={{ color: '#f5f8ff', fontSize: 16, lineHeight: 22 }}>{item.title}</Text></View>
<View style={{ borderRadius: 999, paddingHorizontal: 10, paddingVertical: 6, backgroundColor: item.status === 'complete' ? '#185f45' : '#795500' }}><Text style={{ color: '#ffffff', fontSize: 11, fontWeight: '800' }}>{item.status === 'complete' ? 'Complete' : 'Running'}</Text></View>
</View>
)} />
</View>
);
}
What does this code do?
- The CloneTask interface gives every fixture the same required fields.
- The tasks array supplies predictable data for the first product slice.
- The taskKey function converts each domain ID into the stable string key required by FlatList.
- The runningCount value is derived from the displayed task collection.
- The accessibility properties expose the outage control as a named button.
- Save App.tsx.
- Return to the Android emulator from earlier.
The app reloads with a dark CloneOps heading above a task summary. You should also see coloured status badges plus the outlined outage control.
Your first product slice is running. The Android screen now communicates worker activity from typed task data.
Dashboard not loading?
Check that App.tsx contains matching braces around the FlatList row renderer. Confirm that each task has a unique numeric id value.
If the emulator still shows the starter screen, confirm that App.tsx is saved inside the open clone-ops folder.
Help me diagnose why my CloneOps dashboard is not rendering on the Android emulator.
✔️ Awesome, I've got everything!
Great. Double-check that App.tsx is saved before inspecting the running screen.
ⓧ I'd like to double check the full code
import { FlatList, Pressable, Text, View } from 'react-native';
type CloneTaskStatus = 'running' | 'complete';
interface CloneTask { id: number; title: string; cloneName: string; status: CloneTaskStatus; }
// Keep fixtures shaped like the future API result.
const tasks: CloneTask[] = [
{ id: 1, title: 'Inspect queue', cloneName: 'Clone 4', status: 'running' }, { id: 2, title: 'Send report', cloneName: 'Clone 8', status: 'complete' },
{ id: 3, title: 'Inspect queue', cloneName: 'Clone 4', status: 'running' }, { id: 4, title: 'Send report', cloneName: 'Clone 8', status: 'complete' },
{ id: 7, title: 'Inspect queue', cloneName: 'Clone 4', status: 'running' }, { id: 8, title: 'Send report', cloneName: 'Clone 8', status: 'complete' },
];
const taskKey = (task: CloneTask) => String(task.id); // Preserve row identity across renders.
export default function App() {
// Derive the summary from the tasks displayed below.
const runningCount = tasks.filter((task) => task.status === 'running').length;
return (
<View style={{ flex: 1, backgroundColor: '#07111f', paddingTop: 48 }}>
<Text style={{ color: '#79b8ff', fontSize: 12, fontWeight: '800', letterSpacing: 1.4, paddingHorizontal: 20 }}>AI WORKFORCE OPERATIONS</Text>
<Text style={{ color: '#ffffff', fontSize: 34, fontWeight: '800', marginTop: 4, paddingHorizontal: 20 }}>CloneOps</Text>
<Text style={{ color: '#a9b8cc', fontSize: 15, marginTop: 6, paddingHorizontal: 20 }}>{runningCount} running of {tasks.length} tasks</Text>
<Pressable accessibilityLabel="Simulate outage" accessibilityRole="button" onPress={() => {}} style={{ alignSelf: 'flex-start', borderColor: '#ff8b8b', borderRadius: 10, borderWidth: 1, margin: 20, paddingHorizontal: 14, paddingVertical: 9 }}><Text style={{ color: '#ffb4b4', fontSize: 13, fontWeight: '700' }}>Simulate outage</Text></Pressable>
<FlatList contentContainerStyle={{ paddingHorizontal: 20, paddingBottom: 24 }} data={tasks} keyExtractor={taskKey} renderItem={({ item }) => (
<View style={{ flexDirection: 'row', alignItems: 'center', backgroundColor: '#10233d', borderColor: '#1d3b60', borderRadius: 16, borderWidth: 1, marginBottom: 12, padding: 16 }}>
<View style={{ flex: 1, paddingRight: 12 }}><Text style={{ color: '#79b8ff', fontSize: 12, fontWeight: '700', marginBottom: 6, textTransform: 'uppercase' }}>{item.cloneName}</Text><Text style={{ color: '#f5f8ff', fontSize: 16, lineHeight: 22 }}>{item.title}</Text></View>
<View style={{ borderRadius: 999, paddingHorizontal: 10, paddingVertical: 6, backgroundColor: item.status === 'complete' ? '#185f45' : '#795500' }}><Text style={{ color: '#ffffff', fontSize: 11, fontWeight: '800' }}>{item.status === 'complete' ? 'Complete' : 'Running'}</Text></View>
</View>
)} />
</View>
);
}
Inspect list behavior and accessibility
A mobile dashboard can look correct while exposing weak structure to platform tools. Inspecting the running hierarchy confirms that the task feed exists as repeated rows.
Why use FlatList here?
A FlatList renders a scrollable collection through one row function. Stable keys let React preserve row identity when data changes.
This static feed establishes the render path that later receives live tasks. Performance work now stays connected to a concrete screen.
The inspection view in Android Studio is a little fiddly because it connects to whichever debuggable app is in the foreground. Keep CloneOps visible on the emulator while you inspect it.
- Return to Android Studio from earlier.
- Select the Running Devices tool window.
- Click Toggle Layout Inspector.
- Select the Simulate outage node in the Component Tree.
- Read the Attributes panel to confirm that the control exposes its outage description plus button semantics.
The hierarchy shows repeated task-card views below the dashboard header. The outage control carries the accessible name used by Android inspection tools.
Can't find the dashboard hierarchy?
- Bring CloneOps to the foreground on the Android emulator.
- Reconnect Layout Inspector to the running app process.
- Wait for the Component Tree to refresh after the activity restarts.
Help me connect Layout Inspector to my running CloneOps app and find the accessible Simulate outage control.
Verify the static vertical slice
Before you inspect the finished slice, which visible details should prove that the screen is driven by the local task collection?
- Return to the Android emulator after the save-triggered reload.
- Swipe upward through the task feed.
You should see the CloneOps heading above 3 running of 6 tasks. The feed scrolls through dark cards with Running or Complete badges.
You should also see the outlined Simulate outage control. The screen now has the product shape that live data can replace in the next step.
Your static dashboard now proves the Android product shape. Next, you will replace the fixtures with validated live task data and expose the missing failure path.
Connect Live Tasks and Reveal the Failure Gap
Your static React Native dashboard proves the product shape on Android. The next senior-level decision is where external data becomes trusted application data.
Local fixtures keep every result predictable. This step introduces a typed API boundary for DummyJSON. You will also test how the current interface responds when that remote request fails.
In this step, get ready to:
- Validate remote todo data before it enters CloneOps.
- Fetch live tasks when the app loads.
- Trigger a controlled outage to reveal the missing recovery path.
Build the typed task boundary
A TypeScript type checks code during development. Runtime validation checks the JSON that actually arrives from the network. CloneOps needs both because remote data can violate its documented shape.
- Create src/domain/tasks.ts from the Explorer sidebar in Visual Studio Code.
- Add the domain types and record guard by pasting this first chunk:
export type CloneTaskStatus = 'running' | 'complete';
export interface CloneTask {
id: number;
title: string;
cloneName: string;
status: CloneTaskStatus;
}
type JsonRecord = Record<string, unknown>;
function isRecord(value: unknown): value is JsonRecord {
return typeof value === 'object' && value !== null;
}
What does this code establish?
- The CloneTaskStatus union limits task status values to the two states the dashboard can display.
- The CloneTask interface defines the internal shape used by CloneOps.
- The isRecord() guard confirms that an unknown value is a non-null object before any properties are inspected.
- Save src/domain/tasks.ts.
- Check the editor diagnostics for src/domain/tasks.ts. You should see no errors in this first domain chunk.
Seeing a type error in the domain file?
Check that CloneTaskStatus contains lowercase string literals. Confirm that isRecord() checks both the object type and the null case.
If the diagnostic still remains, help me compare my CloneTask types and isRecord guard with the required TypeScript structure.
- Append the remote todo parser and envelope parser below isRecord() by pasting this chunk:
function parseRemoteTodo(value: unknown): CloneTask {
if (
!isRecord(value) ||
typeof value.id !== 'number' ||
typeof value.todo !== 'string' ||
typeof value.completed !== 'boolean' ||
typeof value.userId !== 'number'
) {
throw new Error('Invalid todo payload');
}
return {
id: value.id,
title: value.todo,
cloneName: `Clone ${value.userId}`,
status: value.completed ? 'complete' : 'running',
};
}
export function parseTasks(payload: unknown): CloneTask[] {
if (!isRecord(payload) || !Array.isArray(payload.todos)) {
throw new Error('Invalid todos response');
}
return payload.todos.map(parseRemoteTodo);
}
How does the boundary protect CloneOps?
- The parseRemoteTodo() function validates every field used by the dashboard.
- The mapper converts the remote todo field into the internal title field.
- The remote userId becomes a product-facing clone name.
- The parseTasks() function rejects a response without a todos array before mapping its entries.
- Save src/domain/tasks.ts.
- Check the editor diagnostics again. You should see no errors attached to the completed parser.
Parser file still showing an error?
Confirm that parseRemoteTodo() closes before parseTasks() begins. Check every property test against the expected primitive type.
For a focused comparison, help me find the structural difference in my runtime todo parser.
Fetch live tasks on mount
The domain parser is ready to guard the network boundary. A small API module now owns the endpoint and returns only validated CloneTask values.
- Create src/api/tasks.ts from the Explorer sidebar.
- Add the task request function by pasting this code:
import { CloneTask, parseTasks } from '../domain/tasks';
export const TASKS_URL = 'https://dummyjson.com/todos';
export async function fetchTasks(
forceFailure = false,
): Promise<CloneTask[]> {
if (forceFailure) {
throw new Error('Simulated network failure');
}
const response = await fetch(TASKS_URL);
const payload: unknown = await response.json();
return parseTasks(payload);
}
What does the API module own?
- The TASKS_URL constant keeps the remote endpoint out of the interface code.
- The fetchTasks() function reads the response body as an unknown value.
- The function returns data only after parseTasks() validates the envelope and every todo.
- The forceFailure parameter creates a repeatable rejection without depending on a real service outage.
- Save src/api/tasks.ts.
- Check the editor diagnostics for src/api/tasks.ts. You should see no import or return-type errors.
API imports not resolving?
Confirm that tasks.ts sits inside src/domain. Confirm that the API file sits inside src/api.
If the paths match and the error remains, help me diagnose the relative import between my API and domain task files.
The dashboard now needs lifecycle state for the request. The loading flag provides an immediate visible state while the task array holds only validated domain values.
- Return to App.tsx from the earlier step.
- Delete the local fixture section from its task type declaration through the sample-task array.
- Replace the import area at the top of App.tsx with this import group:
import { useCallback, useEffect, useState } from 'react';
import {
ActivityIndicator,
FlatList,
Pressable,
StyleSheet,
Text,
View,
} from 'react-native';
import { fetchTasks } from './src/api/tasks';
import { CloneTask } from './src/domain/tasks';
Why add these React hooks?
- The useState() hook stores the current loading flag and validated task array.
- The useEffect() hook starts the first request when the app mounts.
- The useCallback() hook gives the effect and outage button one stable loading function.
- Find the opening line of the App() component.
- Insert this request state and loading function immediately inside the component:
const [tasks, setTasks] = useState<CloneTask[]>([]);
const [isLoading, setIsLoading] = useState(true);
const loadTasks = useCallback(async (forceFailure = false) => {
setIsLoading(true);
try {
const nextTasks = await fetchTasks(forceFailure);
setTasks(nextTasks);
setIsLoading(false);
} catch (error) {
console.error(error);
}
}, []);
useEffect(() => {
void loadTasks();
}, [loadTasks]);
How does the request lifecycle work?
- Each call to loadTasks() activates the loading flag before starting the request.
- A successful request replaces the current task array with validated values.
- The effect calls the same function once when the component mounts.
- The catch block records a failed request without adding another interface state.
- Place this loading branch after the effect and before the running-task count:
if (isLoading) {
return (
<View style={styles.app} accessibilityLiveRegion="polite">
<ActivityIndicator color="#79b8ff" size="large" />
<Text style={styles.summary}>
Synchronizing AI Clone tasks...
</Text>
</View>
);
}
What does the loading branch show?
The branch replaces the dashboard with an activity indicator while the request is active. The live-region setting announces the state change to supported accessibility services.
- Change the running-task count to filter tasks.
- Set the FlatList data prop to tasks.
- Set the outage control's onPress prop to () => void loadTasks(true).
Before you save, do you expect the emulator to keep showing the original fixture titles or replace them with remote tasks?
- Save App.tsx.
- Watch the Android emulator reload the running app.
- Confirm that the loading indicator gives way to remote task cards with generated clone names.
- Scroll the task feed to confirm that the live list remains responsive.
You now have a working live boundary. CloneOps accepts remote todos only after they pass runtime validation.
Live tasks not appearing?
Confirm that the development server still reports the Android app as connected. Check the server output for a network failure or a parser rejection.
If the loading indicator appears immediately and never clears, help me trace the successful fetch path from fetchTasks through parseTasks to setIsLoading.
Stress the current failure path
A successful response proves the happy path. The outage control now gives the same loading function a controlled rejection to expose the current state design.
Before you press it, do you think the dashboard has enough state to give the user a way forward after the request fails?
- Press Simulate outage on the Android emulator.
- Watch the interface after the controlled request rejects.
The loading indicator remains on screen. You have no error message or recovery action because the catch path never moves the interface out of its loading state.
This stuck state is intentional
The request failure is caught, so the app remains running. The current state model can represent loading or loaded data only.
That missing failure state turns a handled exception into an endless spinner for the user. You have now reproduced the exact recovery gap the next step addresses.
Use these tabs to compare the three files created or changed in this step.
✔️ Awesome, I've got everything!
Your live task feed loads on Android. Your controlled outage also leaves the loading indicator visible.
ⓧ I'd like to double check the full code
Check the domain boundary
export type CloneTaskStatus = 'running' | 'complete';
export interface CloneTask {
id: number;
title: string;
cloneName: string;
status: CloneTaskStatus;
}
type JsonRecord = Record<string, unknown>;
function isRecord(value: unknown): value is JsonRecord {
return typeof value === 'object' && value !== null;
}
function parseRemoteTodo(value: unknown): CloneTask {
if (
!isRecord(value) ||
typeof value.id !== 'number' ||
typeof value.todo !== 'string' ||
typeof value.completed !== 'boolean' ||
typeof value.userId !== 'number'
) {
throw new Error('Invalid todo payload');
}
return {
id: value.id,
title: value.todo,
cloneName: `Clone ${value.userId}`,
status: value.completed ? 'complete' : 'running',
};
}
export function parseTasks(payload: unknown): CloneTask[] {
if (!isRecord(payload) || !Array.isArray(payload.todos)) {
throw new Error('Invalid todos response');
}
return payload.todos.map(parseRemoteTodo);
}
This file owns the internal task model and rejects malformed remote values.
Check the API module
import { CloneTask, parseTasks } from '../domain/tasks';
export const TASKS_URL = 'https://dummyjson.com/todos';
export async function fetchTasks(
forceFailure = false,
): Promise<CloneTask[]> {
if (forceFailure) {
throw new Error('Simulated network failure');
}
const response = await fetch(TASKS_URL);
const payload: unknown = await response.json();
return parseTasks(payload);
}
This file owns the documented endpoint and passes every response through the parser.
Check the live dashboard
import { useCallback, useEffect, useState } from 'react';
import {
ActivityIndicator,
FlatList,
Pressable,
StyleSheet,
Text,
View,
} from 'react-native';
import { fetchTasks } from './src/api/tasks';
import { CloneTask } from './src/domain/tasks';
interface TaskCardProps {
task: CloneTask;
}
function TaskCard({ task }: TaskCardProps) {
const isComplete = task.status === 'complete';
return (
<View style={styles.card}>
<View style={styles.copy}>
<Text style={styles.cloneName}>{task.cloneName}</Text>
<Text style={styles.title}>{task.title}</Text>
</View>
<View
style={[
styles.badge,
isComplete ? styles.completeBadge : styles.runningBadge,
]}
>
<Text style={styles.badgeText}>
{isComplete ? 'Complete' : 'Running'}
</Text>
</View>
</View>
);
}
const taskKey = (task: CloneTask) => String(task.id);
export default function App() {
const [tasks, setTasks] = useState<CloneTask[]>([]);
const [isLoading, setIsLoading] = useState(true);
const loadTasks = useCallback(async (forceFailure = false) => {
setIsLoading(true);
try {
const nextTasks = await fetchTasks(forceFailure);
setTasks(nextTasks);
setIsLoading(false);
} catch (error) {
console.error(error);
}
}, []);
useEffect(() => {
void loadTasks();
}, [loadTasks]);
if (isLoading) {
return (
<View style={styles.app} accessibilityLiveRegion="polite">
<ActivityIndicator color="#79b8ff" size="large" />
<Text style={styles.summary}>
Synchronizing AI Clone tasks...
</Text>
</View>
);
}
const runningCount = tasks.filter(
(task) => task.status === 'running',
).length;
return (
<View style={styles.app}>
<View style={styles.screen}>
<View style={styles.header}>
<Text style={styles.eyebrow}>AI WORKFORCE OPERATIONS</Text>
<Text style={styles.heading}>CloneOps</Text>
<Text style={styles.summary}>
{runningCount} running of {tasks.length} tasks
</Text>
<Pressable
accessibilityLabel="Simulate outage"
accessibilityRole="button"
onPress={() => void loadTasks(true)}
style={styles.outlineButton}
>
<Text style={styles.outlineButtonText}>Simulate outage</Text>
</Pressable>
</View>
<FlatList
contentContainerStyle={styles.listContent}
data={tasks}
keyExtractor={taskKey}
renderItem={({ item }) => <TaskCard task={item} />}
/>
</View>
</View>
);
}
const styles = StyleSheet.create({
app: {
flex: 1,
backgroundColor: '#07111f',
paddingTop: 48,
},
screen: {
flex: 1,
},
header: {
paddingHorizontal: 20,
paddingBottom: 18,
},
eyebrow: {
color: '#79b8ff',
fontSize: 12,
fontWeight: '800',
letterSpacing: 1.4,
},
heading: {
color: '#ffffff',
fontSize: 34,
fontWeight: '800',
marginTop: 4,
},
summary: {
color: '#a9b8cc',
fontSize: 15,
marginTop: 6,
},
listContent: {
paddingHorizontal: 20,
paddingBottom: 24,
},
card: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#10233d',
borderColor: '#1d3b60',
borderRadius: 16,
borderWidth: 1,
marginBottom: 12,
padding: 16,
},
copy: {
flex: 1,
paddingRight: 12,
},
cloneName: {
color: '#79b8ff',
fontSize: 12,
fontWeight: '700',
marginBottom: 6,
textTransform: 'uppercase',
},
title: {
color: '#f5f8ff',
fontSize: 16,
lineHeight: 22,
},
badge: {
borderRadius: 999,
paddingHorizontal: 10,
paddingVertical: 6,
},
completeBadge: {
backgroundColor: '#185f45',
},
runningBadge: {
backgroundColor: '#795500',
},
badgeText: {
color: '#ffffff',
fontSize: 11,
fontWeight: '800',
},
outlineButton: {
alignSelf: 'flex-start',
borderColor: '#ff8b8b',
borderRadius: 10,
borderWidth: 1,
marginTop: 16,
paddingHorizontal: 14,
paddingVertical: 9,
},
outlineButtonText: {
color: '#ffb4b4',
fontSize: 13,
fontWeight: '700',
},
});
This file loads validated tasks on mount and deliberately retains the loading state after a controlled failure.
You exposed a user-facing failure that happy-path testing would miss. Next, you will give every request outcome an explicit state and connect the failed request to a visible recovery action.
Add Explicit Failure Recovery
Your live API boundary now works. The controlled outage also proved that a loading flag cannot describe what happened after a failed request.
You will model each outcome with a React reducer and a discriminated TypeScript state. CloneOps can then show the failure clearly. The same loading function can recover the task list.
In this step, get ready to:
- Represent idle, loading, success, and error outcomes with a reducer.
- Extract the task card and dashboard presentation into components.
- Connect Simulate outage and Retry to one stable loading function.
Model every async outcome
A discriminated state gives every request outcome a distinct shape. Rendering can then respond to one explicit status instead of inferring state from separate values.
- Switch back to the open clone-ops project in Visual Studio Code.
- Create a state folder inside src using the project file tree.
- Create taskReducer.ts inside src/state using the project file tree.
You should now see src/state/taskReducer.ts in the project file tree.
- Define the reducer state and action types by adding this first section to src/state/taskReducer.ts:
import { CloneTask } from '../domain/tasks';
export type TaskState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; tasks: CloneTask[] }
| { status: 'error'; message: string };
export type TaskAction =
| { type: 'request' }
| { type: 'success'; tasks: CloneTask[] }
| { type: 'failure'; message: string };
export const initialTaskState: TaskState = { status: 'idle' };
What does this state model do?
- TaskState gives idle, loading, success, and error outcomes separate data shapes.
- The success state must carry a CloneTask array.
- The error state must carry a message that the interface can display.
- TaskAction limits transitions to request, success, or failure events.
- Save src/state/taskReducer.ts.
The file now captures every request outcome that CloneOps needs to render.
- Add the reducer below initialTaskState in src/state/taskReducer.ts by copying this code:
export function taskReducer(
state: TaskState,
action: TaskAction,
): TaskState {
switch (action.type) {
case 'request':
return { status: 'loading' };
case 'success':
return { status: 'success', tasks: action.tasks };
case 'failure':
return { status: 'error', message: action.message };
default: {
const unreachable: never = action;
return state;
}
}
}
How does the reducer close the gap?
- A request moves the app into loading.
- A successful response stores the parsed task collection.
- A failed response stores an error message instead of leaving loading active.
- The never assignment makes an unhandled action visible to TypeScript during development.
- Save src/state/taskReducer.ts.
You should see no TypeScript diagnostics for the completed reducer file.
Seeing a reducer type problem?
Check that every success action carries tasks. Check that every failure action carries message.
Confirm that CloneTask comes from ../domain/tasks.
Help me compare my task reducer with the required discriminated state.
✔️ Awesome, I've got everything!
Your reducer now represents every async outcome. Make sure src/state/taskReducer.ts is saved before continuing.
ⓧ I'd like to double check the full code
import { CloneTask } from '../domain/tasks';
export type TaskState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; tasks: CloneTask[] }
| { status: 'error'; message: string };
export type TaskAction =
| { type: 'request' }
| { type: 'success'; tasks: CloneTask[] }
| { type: 'failure'; message: string };
export const initialTaskState: TaskState = { status: 'idle' };
export function taskReducer(
state: TaskState,
action: TaskAction,
): TaskState {
switch (action.type) {
case 'request':
return { status: 'loading' };
case 'success':
return { status: 'success', tasks: action.tasks };
case 'failure':
return { status: 'error', message: action.message };
default: {
const unreachable: never = action;
return state;
}
}
}
What should match?
The complete file contains the four TaskState variants. It also contains the three TaskAction variants and the exhaustive reducer.
Separate recovery UI from loading logic
The request lifecycle belongs in the app container. The visual outcomes belong in focused components that receive state and callbacks through props.
TaskCard owns one row. TaskDashboard owns loading, error, success, and empty-list presentation.
- Create a components folder inside src using the project file tree.
- Create TaskCard.tsx inside src/components using the project file tree.
You should now see src/components/TaskCard.tsx in the project file tree.
- Add the complete task-row component by selecting the second tab below.
✔️ Awesome, I've got everything!
Your TaskCard shows the clone name and task title. Its badge reflects the task status.
ⓧ I'd like to double check the full code
import { memo } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { CloneTask } from '../domain/tasks';
interface TaskCardProps {
task: CloneTask;
}
export const TaskCard = memo(function TaskCard({ task }: TaskCardProps) {
const isComplete = task.status === 'complete';
return (
<View style={styles.card}>
<View style={styles.copy}>
<Text style={styles.cloneName}>{task.cloneName}</Text>
<Text style={styles.title}>{task.title}</Text>
</View>
<View
style={[
styles.badge,
isComplete ? styles.completeBadge : styles.runningBadge,
]}
>
<Text style={styles.badgeText}>
{isComplete ? 'Complete' : 'Running'}
</Text>
</View>
</View>
);
});
const styles = StyleSheet.create({
card: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#10233d',
borderColor: '#1d3b60',
borderRadius: 16,
borderWidth: 1,
marginBottom: 12,
padding: 16,
},
copy: {
flex: 1,
paddingRight: 12,
},
cloneName: {
color: '#79b8ff',
fontSize: 12,
fontWeight: '700',
marginBottom: 6,
textTransform: 'uppercase',
},
title: {
color: '#f5f8ff',
fontSize: 16,
lineHeight: 22,
},
badge: {
borderRadius: 999,
paddingHorizontal: 10,
paddingVertical: 6,
},
completeBadge: {
backgroundColor: '#185f45',
},
runningBadge: {
backgroundColor: '#795500',
},
badgeText: {
color: '#ffffff',
fontSize: 11,
fontWeight: '800',
},
});
What does TaskCard handle?
- TaskCard receives one CloneTask through a typed prop.
- The isComplete value selects the badge colour and visible status label.
- The card keeps row presentation separate from request state.
- Copy the complete reference into src/components/TaskCard.tsx.
- Save src/components/TaskCard.tsx.
The completed file should show no TypeScript diagnostics.
- Create TaskDashboard.tsx inside src/components using the project file tree.
You should now see both component files inside src/components.
- Add the complete state-driven dashboard by selecting the second tab below.
✔️ Awesome, I've got everything!
Your TaskDashboard now maps each TaskState variant to a visible interface outcome.
ⓧ I'd like to double check the full code
import {
ActivityIndicator,
FlatList,
Pressable,
StyleSheet,
Text,
View,
} from 'react-native';
import { CloneTask } from '../domain/tasks';
import { TaskState } from '../state/taskReducer';
import { TaskCard } from './TaskCard';
interface TaskDashboardProps {
state: TaskState;
onRetry: () => void;
onSimulateFailure: () => void;
}
const renderTask = ({ item }: { item: CloneTask }) => (
<TaskCard task={item} />
);
const taskKey = (task: CloneTask) => String(task.id);
export function TaskDashboard({
state,
onRetry,
onSimulateFailure,
}: TaskDashboardProps) {
if (state.status === 'idle' || state.status === 'loading') {
return (
<View style={styles.centered} accessibilityLiveRegion="polite">
<ActivityIndicator color="#79b8ff" size="large" />
<Text style={styles.muted}>Synchronizing AI Clone tasks...</Text>
</View>
);
}
if (state.status === 'error') {
return (
<View style={styles.centered} accessibilityLiveRegion="assertive">
<Text style={styles.errorTitle}>Task sync failed</Text>
<Text style={styles.muted}>{state.message}</Text>
<Pressable
accessibilityLabel="Retry"
accessibilityRole="button"
onPress={onRetry}
style={styles.primaryButton}
>
<Text style={styles.primaryButtonText}>Retry</Text>
</Pressable>
</View>
);
}
const runningCount = state.tasks.filter(
(task) => task.status === 'running',
).length;
return (
<View style={styles.screen}>
<View style={styles.header}>
<Text style={styles.eyebrow}>AI WORKFORCE OPERATIONS</Text>
<Text style={styles.heading}>CloneOps</Text>
<Text style={styles.summary}>
{runningCount} running of {state.tasks.length} tasks
</Text>
<Pressable
accessibilityLabel="Simulate outage"
accessibilityRole="button"
onPress={onSimulateFailure}
style={styles.outlineButton}
>
<Text style={styles.outlineButtonText}>Simulate outage</Text>
</Pressable>
</View>
<FlatList
contentContainerStyle={
state.tasks.length === 0 ? styles.emptyList : styles.listContent
}
data={state.tasks}
keyExtractor={taskKey}
ListEmptyComponent={
<Text style={styles.muted}>No AI Clone tasks are available.</Text>
}
renderItem={renderTask}
/>
</View>
);
}
const styles = StyleSheet.create({
screen: {
flex: 1,
},
header: {
paddingHorizontal: 20,
paddingBottom: 18,
},
eyebrow: {
color: '#79b8ff',
fontSize: 12,
fontWeight: '800',
letterSpacing: 1.4,
},
heading: {
color: '#ffffff',
fontSize: 34,
fontWeight: '800',
marginTop: 4,
},
summary: {
color: '#a9b8cc',
fontSize: 15,
marginTop: 6,
},
listContent: {
paddingHorizontal: 20,
paddingBottom: 24,
},
emptyList: {
flexGrow: 1,
alignItems: 'center',
justifyContent: 'center',
padding: 24,
},
centered: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
padding: 28,
},
muted: {
color: '#a9b8cc',
fontSize: 15,
lineHeight: 22,
marginTop: 14,
textAlign: 'center',
},
errorTitle: {
color: '#ff8b8b',
fontSize: 24,
fontWeight: '800',
},
primaryButton: {
backgroundColor: '#2f81f7',
borderRadius: 12,
marginTop: 20,
paddingHorizontal: 22,
paddingVertical: 12,
},
primaryButtonText: {
color: '#ffffff',
fontSize: 15,
fontWeight: '800',
},
outlineButton: {
alignSelf: 'flex-start',
borderColor: '#ff8b8b',
borderRadius: 10,
borderWidth: 1,
marginTop: 16,
paddingHorizontal: 14,
paddingVertical: 9,
},
outlineButtonText: {
color: '#ffb4b4',
fontSize: 13,
fontWeight: '700',
},
});
How does TaskDashboard render state?
- Idle and loading states show the polite synchronization indicator.
- The error state announces Task sync failed and exposes an accessible Retry button.
- The success state shows the running count and virtualized task list.
- An empty successful response shows a dedicated no-tasks message.
- Copy the complete reference into src/components/TaskDashboard.tsx.
- Save src/components/TaskDashboard.tsx.
The completed dashboard file should show no TypeScript diagnostics.
Component imports not resolving?
Confirm that both files sit directly inside src/components. Check that taskReducer.ts sits directly inside src/state.
Help me fix the CloneOps component import paths.
Connect one stable recovery path
A stable load function lets the initial request and both buttons share the same transition sequence. Every request begins with request. It ends with either success or failure.
- Replace the current contents of App.tsx with the complete reducer-driven version in the second tab below.
✔️ Awesome, I've got everything!
Your App now owns transitions and delegates all visual outcomes to TaskDashboard.
ⓧ I'd like to double check the full code
import { useCallback } from 'react';
import { useEffect } from 'react';
import { useReducer } from 'react';
import { StyleSheet, View } from 'react-native';
import { fetchTasks } from './src/api/tasks';
import { TaskDashboard } from './src/components/TaskDashboard';
import {
initialTaskState,
taskReducer,
} from './src/state/taskReducer';
export default function App() {
const [state, dispatch] = useReducer(taskReducer, initialTaskState);
const loadTasks = useCallback(async (forceFailure = false) => {
dispatch({ type: 'request' });
try {
const tasks = await fetchTasks(forceFailure);
dispatch({ type: 'success', tasks });
} catch (error) {
dispatch({
type: 'failure',
message:
error instanceof Error ? error.message : 'Unknown network failure',
});
}
}, []);
useEffect(() => {
void loadTasks();
}, [loadTasks]);
return (
<View style={styles.app}>
<TaskDashboard
state={state}
onRetry={() => void loadTasks()}
onSimulateFailure={() => void loadTasks(true)}
/>
</View>
);
}
const styles = StyleSheet.create({
app: {
flex: 1,
backgroundColor: '#07111f',
paddingTop: 48,
},
});
How does recovery flow through App?
- useReducer stores the current TaskState and exposes dispatch.
- loadTasks dispatches request before calling fetchTasks.
- A valid response dispatches success with the parsed tasks.
- A thrown error dispatches failure with a visible message.
- Retry calls loadTasks without forcing another failure.
- Save App.tsx.
The running app reloads on the Android emulator. You should see the CloneOps heading and live task cards again.
Before you trigger the controlled outage, which state do you expect to replace the task list?
- Press Simulate outage in CloneOps.
You should see Task sync failed above Simulated network failure. The Retry button should appear below the message.
- Press Retry.
You should briefly see the loading indicator. The live CloneOps task list should then return with its running-task count.
Recovery does not complete?
If Retry shows the controlled error again, confirm that onRetry calls loadTasks() without true.
If the spinner remains visible, confirm that the catch block dispatches the failure action. Check that TaskDashboard handles state.status === 'error'.
Help me debug the CloneOps outage and Retry flow.
That closes the failure gap. CloneOps now explains an outage and gives the user a direct route back to live tasks. Next, you will protect this behaviour with automated checks.
Test Recovery and Stabilize the List
Your reducer now turns a failed request into a recovery flow on Android. The remaining risk is a regression that silently breaks the API boundary or the Retry control.
In this step, you will use Jest with React Native Testing Library to preserve that behavior. You will also apply memoization to the task rows while keeping the FlatList callbacks stable.
In this step, get ready to:
- Install the test dependencies and configure Jest.
- Prove the parser boundary and Retry interaction with automated tests.
- Stabilize the task rows and verify recovery on Android.
Install the test harness
The Expo test preset connects Jest to the React Native environment. The testing library then lets your test interact with the Retry control through the same accessibility role a user relies on.
- Switch back to Visual Studio Code with the running development server.
- Create a second PowerShell terminal from the terminal area.
- Install the test dependencies by running these commands:
npx expo install jest-expo jest @types/jest "--" --dev
npx expo install @testing-library/react-native "--" --dev
What do these installs add?
- The first command adds Jest types plus the Expo preset that prepares React Native tests.
- The second command adds the helpers used to render components and press accessible controls.
- Both commands record the packages as development dependencies because the app does not ship them to users.
- Return to the left file sidebar after both installations finish.
- Inspect the development dependencies in package.json.
You should see the testing packages recorded under devDependencies.
Installation did not finish?
Confirm that the second PowerShell terminal is inside the clone-ops folder. Keep the original terminal running the Expo development server.
If a package installation still fails, help me diagnose the Expo test dependency installation.
- In package.json, locate the existing scripts object.
- Add the test script using this reference:
"test": "jest"
What does this script do?
The script gives the project one repeatable entry point for its automated checks. Running npm test now delegates to Jest.
- Save package.json.
- Confirm that the test script remains visible inside scripts.
Use these tabs to compare the finished package manifest.
✔️ Awesome, I've got everything!
Your testing dependencies and test script are recorded in package.json.
ⓧ I'd like to double check the full code
{
"name": "clone-ops",
"version": "1.0.0",
"main": "index.ts",
"scripts": {
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web",
"test": "jest"
},
"dependencies": {
"expo": "58.0.21",
"react": "19.3.0",
"react-native": "0.88.1"
},
"devDependencies": {
"@testing-library/react-native": "14.1.0",
"@types/jest": "30.0.0",
"@types/react": "19.3.0",
"jest": "30.5.2",
"jest-expo": "58.0.13",
"test-renderer": "1.3.1",
"typescript": "6.0.3"
},
"private": true
}
- Select the clone-ops folder in the left file sidebar.
- Create jest.config.js inside clone-ops using the new-file control.
- Configure the Expo preset by pasting this code into jest.config.js:
module.exports = {
preset: 'jest-expo',
};
What does this configuration do?
The jest-expo preset prepares Jest to understand the Expo React Native environment. This keeps platform-specific modules from reaching the tests without the expected transformations.
- Save jest.config.js.
- Confirm that jest.config.js appears beside package.json in the clone-ops folder.
Configuration file missing?
Check that the filename ends in .js and sits directly inside clone-ops. A file inside src will not provide the project-level preset.
If Jest cannot find the configuration later, help me check the location and contents of jest.config.js.
Use these tabs to check the complete Jest configuration.
✔️ Awesome, I've got everything!
Your project now points Jest at the Expo preset.
ⓧ I'd like to double check the full code
module.exports = {
preset: 'jest-expo',
};
Prove the API boundary and Retry path
The parser protects the rest of CloneOps from malformed remote data. Its tests need to prove both sides of that contract by accepting the documented envelope and rejecting an invalid todo.
- Select the src/domain folder in the left file sidebar.
- Create an __tests__ folder inside src/domain using the new-folder control.
- Create tasks-test.ts inside src/domain/__tests__ using the new-file control.
- Add the malformed-payload test by pasting this code into tasks-test.ts:
import { parseTasks } from '../tasks';
describe('parseTasks', () => {
it('rejects malformed todo values at the API boundary', () => {
expect(() =>
parseTasks({ todos: [{ id: 'not-a-number' }] }),
).toThrow('Invalid todo payload');
});
});
What does this test prove?
- The test sends an object with a string id into parseTasks.
- The expectation proves that malformed remote data stops at the API boundary with Invalid todo payload.
- Save src/domain/__tests__/tasks-test.ts.
Before you run the first test, do you expect the malformed todo to reach the app or be rejected by the parser?
- Run the parser test through the project script by entering:
npm test
What does this check prove?
Jest discovers files inside __tests__ and executes the malformed-payload assertion. A passing result proves that invalid data is rejected before it becomes a CloneTask.
You should see the parser test pass. The boundary now has automated evidence for its failure behavior.
Parser test failing?
Confirm that tasks-test.ts is inside src/domain/__tests__. Check that its import points one level up to tasks.
If the failure remains, help me diagnose the failing parseTasks test.
- In tasks-test.ts, locate the test that begins with rejects malformed todo values at the API boundary.
- Insert the valid-envelope test immediately above it using this code:
it('maps the remote todo envelope into CloneTask values', () => {
const tasks = parseTasks({
todos: [
{
id: 7,
todo: 'Review support queue',
completed: false,
userId: 3,
},
],
total: 1,
skip: 0,
limit: 1,
});
expect(tasks).toEqual([
{
id: 7,
title: 'Review support queue',
cloneName: 'Clone 3',
status: 'running',
},
]);
});
What does the valid case cover?
- The fixture follows the external todo envelope that parseTasks accepts.
- The expectation checks the exact internal fields that the dashboard consumes.
- The mapping turns userId into cloneName and turns completed into the domain status.
- Save src/domain/__tests__/tasks-test.ts.
- Run both parser cases through the test script by entering:
npm test
What should pass now?
The parser suite now checks a valid mapping plus a malformed payload. Together they protect the conversion between remote JSON and the app's internal task model.
You should see both parser tests pass.
Use these tabs to compare the completed parser test file.
✔️ Awesome, I've got everything!
Your parser suite covers the successful mapping and the malformed boundary.
ⓧ I'd like to double check the full code
import { parseTasks } from '../tasks';
describe('parseTasks', () => {
it('maps the remote todo envelope into CloneTask values', () => {
const tasks = parseTasks({
todos: [
{
id: 7,
todo: 'Review support queue',
completed: false,
userId: 3,
},
],
total: 1,
skip: 0,
limit: 1,
});
expect(tasks).toEqual([
{
id: 7,
title: 'Review support queue',
cloneName: 'Clone 3',
status: 'running',
},
]);
});
it('rejects malformed todo values at the API boundary', () => {
expect(() =>
parseTasks({ todos: [{ id: 'not-a-number' }] }),
).toThrow('Invalid todo payload');
});
});
The component test focuses on the recovery contract. It renders a known error state and interacts with Retry through its button role.
- Select the src/components folder in the left file sidebar.
- Create an __tests__ folder inside src/components using the new-folder control.
- Create TaskDashboard-test.tsx inside src/components/__tests__ using the new-file control.
- Add the component-test imports by pasting this code into TaskDashboard-test.tsx:
import {
render,
screen,
userEvent,
} from '@testing-library/react-native';
import { TaskState } from '../../state/taskReducer';
import { TaskDashboard } from '../TaskDashboard';
What do these imports provide?
- The testing helpers render the dashboard and find visible controls through the rendered screen.
- The userEvent helper presses Retry through its user-facing role.
- The project imports provide the discriminated state type plus the component under test.
- Save src/components/__tests__/TaskDashboard-test.tsx.
- Confirm that TaskDashboard-test.tsx appears inside src/components/__tests__.
- Append the recovery test below the imports using this code:
describe('TaskDashboard', () => {
it('offers recovery after a task sync failure', async () => {
const user = userEvent.setup();
const onRetry = jest.fn();
const state: TaskState = {
status: 'error',
message: 'Simulated network failure',
};
await render(
<TaskDashboard
state={state}
onRetry={onRetry}
onSimulateFailure={jest.fn()}
/>,
);
expect(screen.getByText('Simulated network failure')).toBeTruthy();
await user.press(screen.getByRole('button', { name: 'Retry' }));
expect(onRetry).toHaveBeenCalledTimes(1);
});
});
How does the recovery test work?
- The test passes an explicit error state into TaskDashboard.
- The rendered message proves that the error branch is visible.
- The role query finds Retry through its accessibility contract.
- The final assertion proves that one press calls onRetry exactly once.
- Save src/components/__tests__/TaskDashboard-test.tsx.
- Run the parser and component suites by entering:
npm test
What does the combined run prove?
The combined run checks data validation plus user recovery. Passing suites show that malformed JSON is rejected and Retry still reaches the callback.
You should see every parser and recovery test pass.
Recovery test failing?
Confirm that the state uses status: 'error' and that the accessible button name is Retry. A mismatch prevents the role query from finding the control.
If the component test still fails, help me diagnose the TaskDashboard Retry interaction test.
Use these tabs to compare the completed recovery test.
✔️ Awesome, I've got everything!
Your component test renders the error state and proves that Retry calls its callback.
ⓧ I'd like to double check the full code
import {
render,
screen,
userEvent,
} from '@testing-library/react-native';
import { TaskState } from '../../state/taskReducer';
import { TaskDashboard } from '../TaskDashboard';
describe('TaskDashboard', () => {
it('offers recovery after a task sync failure', async () => {
const user = userEvent.setup();
const onRetry = jest.fn();
const state: TaskState = {
status: 'error',
message: 'Simulated network failure',
};
await render(
<TaskDashboard
state={state}
onRetry={onRetry}
onSimulateFailure={jest.fn()}
/>,
);
expect(screen.getByText('Simulated network failure')).toBeTruthy();
await user.press(screen.getByRole('button', { name: 'Retry' }));
expect(onRetry).toHaveBeenCalledTimes(1);
});
});
Stabilize the list and verify recovery
The task list already uses stable IDs. The final performance pass gives each row a memoized component plus module-scope renderer and key functions, which keeps their references stable across dashboard renders.
- Return to src/components/TaskCard.tsx from earlier.
- Add this import above the React Native import:
import { memo } from 'react';
Why import memo?
The memo wrapper lets React skip a row render when the row receives unchanged props. It is a targeted optimization for the repeated task-card path.
- Locate the exported TaskCard function declaration.
- Replace its declaration with this memoized form:
export const TaskCard = memo(function TaskCard({ task }: TaskCardProps) {
What changes around the row?
The named inner function keeps the component readable in development tools. The exported value now passes through memo while preserving the same task prop and rendered card.
- Change the component's final closing brace to });.
- Save src/components/TaskCard.tsx.
- Check the Android emulator after the app reloads.
You should still see the CloneOps heading and live task cards without a red error screen.
Seeing a syntax error after memoizing?
Check the start and end of the component together. The declaration opens memo(function TaskCard and the component closes with });.
If the syntax still fails, help me check the memo wrapper around TaskCard.
Use these tabs to compare the memoized task card.
✔️ Awesome, I've got everything!
Your task row is memoized and still renders the same task content.
ⓧ I'd like to double check the full code
import { memo } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { CloneTask } from '../domain/tasks';
interface TaskCardProps {
task: CloneTask;
}
export const TaskCard = memo(function TaskCard({ task }: TaskCardProps) {
const isComplete = task.status === 'complete';
return (
<View style={styles.card}>
<View style={styles.copy}>
<Text style={styles.cloneName}>{task.cloneName}</Text>
<Text style={styles.title}>{task.title}</Text>
</View>
<View
style={[
styles.badge,
isComplete ? styles.completeBadge : styles.runningBadge,
]}
>
<Text style={styles.badgeText}>
{isComplete ? 'Complete' : 'Running'}
</Text>
</View>
</View>
);
});
const styles = StyleSheet.create({
card: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#10233d',
borderColor: '#1d3b60',
borderRadius: 16,
borderWidth: 1,
marginBottom: 12,
padding: 16,
},
copy: {
flex: 1,
paddingRight: 12,
},
cloneName: {
color: '#79b8ff',
fontSize: 12,
fontWeight: '700',
marginBottom: 6,
textTransform: 'uppercase',
},
title: {
color: '#f5f8ff',
fontSize: 16,
lineHeight: 22,
},
badge: {
borderRadius: 999,
paddingHorizontal: 10,
paddingVertical: 6,
},
completeBadge: {
backgroundColor: '#185f45',
},
runningBadge: {
backgroundColor: '#795500',
},
badgeText: {
color: '#ffffff',
fontSize: 11,
fontWeight: '800',
},
});
- Return to src/components/TaskDashboard.tsx from earlier.
- Locate the end of the TaskDashboardProps interface.
- Place the stable row helpers below that interface using this code:
const renderTask = ({ item }: { item: CloneTask }) => (
<TaskCard task={item} />
);
const taskKey = (task: CloneTask) => String(task.id);
Why move these helpers outside the component?
- The module creates renderTask once instead of creating a new renderer during each dashboard render.
- The taskKey helper consistently converts each numeric task ID into the string key expected by the list.
- Stable helper references give the memoized row a cleaner render path without promising a fixed performance gain.
- Locate the FlatList inside the successful state.
- Set its key and row props to these stable helpers:
keyExtractor={taskKey}
renderItem={renderTask}
How does FlatList use these props?
The list calls taskKey to identify each task across updates. It calls renderTask to convert each item into a memoized TaskCard.
- Save src/components/TaskDashboard.tsx.
- Check the Android emulator after the app reloads.
You should see the live task list render with the same running count and status badges.
Task rows missing after the change?
Confirm that CloneTask is imported from ../domain/tasks. Check that both helpers sit above the exported dashboard function.
If the list remains empty, help me diagnose the stable FlatList renderer and key extractor.
Use these tabs to compare the stabilized dashboard component.
✔️ Awesome, I've got everything!
Your dashboard uses module-scope row and key helpers with the memoized task card.
ⓧ I'd like to double check the full code
import {
ActivityIndicator,
FlatList,
Pressable,
StyleSheet,
Text,
View,
} from 'react-native';
import { CloneTask } from '../domain/tasks';
import { TaskState } from '../state/taskReducer';
import { TaskCard } from './TaskCard';
interface TaskDashboardProps {
state: TaskState;
onRetry: () => void;
onSimulateFailure: () => void;
}
const renderTask = ({ item }: { item: CloneTask }) => (
<TaskCard task={item} />
);
const taskKey = (task: CloneTask) => String(task.id);
export function TaskDashboard({
state,
onRetry,
onSimulateFailure,
}: TaskDashboardProps) {
if (state.status === 'idle' || state.status === 'loading') {
return (
<View style={styles.centered} accessibilityLiveRegion="polite">
<ActivityIndicator color="#79b8ff" size="large" />
<Text style={styles.muted}>Synchronizing AI Clone tasks...</Text>
</View>
);
}
if (state.status === 'error') {
return (
<View style={styles.centered} accessibilityLiveRegion="assertive">
<Text style={styles.errorTitle}>Task sync failed</Text>
<Text style={styles.muted}>{state.message}</Text>
<Pressable
accessibilityLabel="Retry"
accessibilityRole="button"
onPress={onRetry}
style={styles.primaryButton}
>
<Text style={styles.primaryButtonText}>Retry</Text>
</Pressable>
</View>
);
}
const runningCount = state.tasks.filter(
(task) => task.status === 'running',
).length;
return (
<View style={styles.screen}>
<View style={styles.header}>
<Text style={styles.eyebrow}>AI WORKFORCE OPERATIONS</Text>
<Text style={styles.heading}>CloneOps</Text>
<Text style={styles.summary}>
{runningCount} running of {state.tasks.length} tasks
</Text>
<Pressable
accessibilityLabel="Simulate outage"
accessibilityRole="button"
onPress={onSimulateFailure}
style={styles.outlineButton}
>
<Text style={styles.outlineButtonText}>Simulate outage</Text>
</Pressable>
</View>
<FlatList
contentContainerStyle={
state.tasks.length === 0 ? styles.emptyList : styles.listContent
}
data={state.tasks}
keyExtractor={taskKey}
ListEmptyComponent={
<Text style={styles.muted}>No AI Clone tasks are available.</Text>
}
renderItem={renderTask}
/>
</View>
);
}
const styles = StyleSheet.create({
screen: {
flex: 1,
},
header: {
paddingHorizontal: 20,
paddingBottom: 18,
},
eyebrow: {
color: '#79b8ff',
fontSize: 12,
fontWeight: '800',
letterSpacing: 1.4,
},
heading: {
color: '#ffffff',
fontSize: 34,
fontWeight: '800',
marginTop: 4,
},
summary: {
color: '#a9b8cc',
fontSize: 15,
marginTop: 6,
},
listContent: {
paddingHorizontal: 20,
paddingBottom: 24,
},
emptyList: {
flexGrow: 1,
alignItems: 'center',
justifyContent: 'center',
padding: 24,
},
centered: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
padding: 28,
},
muted: {
color: '#a9b8cc',
fontSize: 15,
lineHeight: 22,
marginTop: 14,
textAlign: 'center',
},
errorTitle: {
color: '#ff8b8b',
fontSize: 24,
fontWeight: '800',
},
primaryButton: {
backgroundColor: '#2f81f7',
borderRadius: 12,
marginTop: 20,
paddingHorizontal: 22,
paddingVertical: 12,
},
primaryButtonText: {
color: '#ffffff',
fontSize: 15,
fontWeight: '800',
},
outlineButton: {
alignSelf: 'flex-start',
borderColor: '#ff8b8b',
borderRadius: 10,
borderWidth: 1,
marginTop: 16,
paddingHorizontal: 14,
paddingVertical: 9,
},
outlineButtonText: {
color: '#ffb4b4',
fontSize: 13,
fontWeight: '700',
},
});
Before the final test run, do you expect the memo and module-scope helpers to change any user-visible behavior?
- Run every automated check by entering:
npm test
What does the final test run confirm?
The final run confirms that the parser contract and Retry interaction still pass after the list changes. The optimization preserves the behavior covered by the tests.
You should see every parser and recovery test pass.
Tests changed after the list update?
Check that renderTask still returns <TaskCard task={item} />. Confirm that the accessible Retry control remains unchanged.
If a suite still fails, help me compare the failing test with the stabilized list code.
Before you test the Android flow, which state should follow Simulate outage and which state should return after Retry?
- Press Simulate outage on the Android emulator.
You should see Task sync failed with the controlled failure message and the Retry control.
- Press Retry on the error screen.
You should see the live CloneOps task list return with its running-task count and status badges.
That closes the quality loop. CloneOps now has automated evidence for its API boundary and recovery path while its virtualized rows use stable render inputs.
Secret mission
Add Derived Status Filters
Operators need a fast way to separate active work from completed work. Add accessible All, Running, and Complete filters that derive the visible list without changing the fetched task collection, then prove the behavior with an interaction test.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project has no ongoing costs.
Resources you used:
- The local clone-ops project folder.
- The Expo development server running in Windows PowerShell.
- The Android Studio Emulator session displaying CloneOps.
Keep everything running
No action needed. Choose this if you are still actively building CloneOps or preparing to demonstrate its recovery flow.
- Your development server continues serving the app to the Android test target.
- Your Android emulator keeps the current CloneOps session available.
- Your clone-ops folder retains the app source plus its passing tests.
Pause - I'll come back to this later
Shut down the running processes to free local memory. Keep the clone-ops folder so you can resume from the same code.
- Switch back to the Windows PowerShell terminal from earlier.
- Press Ctrl+C to stop the Expo development server.
- Close the Android Studio Emulator window.
Good pause point. Your local runtime is quiet while every project file stays ready for your return.
Delete - I don't want to use this again
Remove the local project resources when you are finished with CloneOps. This leaves no project process or folder on your computer.
Protect Your Work
Deleting the clone-ops folder can feel drastic because it contains the whole app. A separate copy preserves anything you want to revisit.
- Switch back to the Windows PowerShell terminal from earlier.
- Press Ctrl+C to stop the Expo development server.
- Close the Android Studio Emulator window.
That is the live environment safely stopped. The clone-ops folder is now the only project resource left.
- Move any source files you want to preserve into a folder outside clone-ops.
- Use your Windows file manager to navigate to the location where you scaffolded clone-ops.
- Select the clone-ops folder.
- Press Shift+Delete to request permanent removal.
- Confirm the permanent deletion when Windows asks.
- Verify that clone-ops no longer appears in its former location.
Cleanup complete. CloneOps no longer has a running local process or project folder on this computer.
Nice Work!
Nice Work!
You completed it! CloneOps now runs as a resilient React Native dashboard on Android.
You've learned how to:
- Built a typed API boundary that maps DummyJSON todos into internal task models. Malformed payloads now stop before they reach the interface.
- Replaced a stuck spinner with explicit loading, success, empty, and error states. The Simulate outage and Retry controls prove that the dashboard can recover from failure.
- Created automated evidence for parsing and recovery with Jest plus React Native Testing Library. Stabilized FlatList rows with memoization plus module-scope renderItem and keyExtractor functions.
- Secret Mission: Added accessible filters for All, Running, and Complete tasks. Used derived state to preserve the fetched collection. Proved the filter interaction with a component test.
Ready to quiz yourself?