Build a Task Scheduler in Java
Use HashMap, PriorityQueue, and ArrayDeque to build a CLI task scheduler.
Introduction
30 Second Summary
Think about how your phone decides which notification to show you first. A text from your boss gets priority over a game update. Behind the scenes, something has to decide what comes first, what can wait, and what to do if you change your mind.
In this project, you will build a command-line task scheduler in Java that processes tasks by priority, supports instant lookups by ID, and lets you undo recent actions using four core data structures.
What You'll Build
A CLI task scheduler that uses HashMap, PriorityQueue, and ArrayDeque to manage tasks through their entire lifecycle from creation to completion.
By the end of this project, you'll have:
- A working task scheduler that processes tasks in priority order using a PriorityQueue with a custom Comparable implementation.
- An undo system built on ArrayDeque as a stack, demonstrating LIFO behavior in a real use case.
- Instant task lookup by ID using a HashMap, proving O(1) performance at scale.
- Secret Mission: Add a task dependency system using ArrayDeque as a FIFO queue, where tasks wait until their dependencies complete.
Are there any prerequisites?
You should be comfortable writing Java classes and familiar with basic object-oriented programming concepts like interfaces, enums, and generics. No prior experience with these specific data structures is needed.
Before We Start
Before you can build a task scheduler, you need the Java Development Kit (JDK) installed and a project folder ready.
In this step, you will verify your Java installation, create the project structure, and confirm that everything compiles.
In this step, get ready to:
- Verify Java JDK 21 or later is installed.
- Create the project folder and source files.
- Compile and run a minimal Java program to confirm the toolchain works.
Verify your Java installation
This project requires JDK 21 or later. The JDK includes both the java runtime and the javac compiler you need to build and run Java programs.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
- Check your Java version by running this command:
java --version
You should see output that includes a version number like 21.x.x or higher.
✔️ I see version 21 or higher
Your Java installation is ready. Move on to the next substep.
ⓧ I see an older version
You have Java installed, but the version is below 21. You need to install a newer version.
macOS
- Install JDK 21 using Homebrew by running:
brew install openjdk@21
- After the install finishes, verify by running:
java --version
javac --version
Both should show version 21 or higher.
Windows
- Download JDK 21 from https://jdk.java.net/.
- Run the installer and follow the prompts.
- Close and reopen PowerShell, then verify by running:
java --version
javac --version
Both should show version 21 or higher.
ⓧ Command not found
Java is not installed on your machine yet. Follow the instructions below for your operating system.
macOS
- Install JDK 21 using Homebrew by running:
brew install openjdk@21
- After the install finishes, verify by running:
java --version
javac --version
Both should show version 21 or higher.
Windows
- Download JDK 21 from https://jdk.java.net/.
- Run the installer and follow the prompts.
- Close and reopen PowerShell, then verify by running:
java --version
javac --version
Both should show version 21 or higher.
Create the project structure
Your project needs a folder to hold the source files. You will create three Java files that will contain the Task model, the TaskScheduler logic, and the Main entry point.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create the project folder, the source directory, and the empty source files by running these commands:
macOS
mkdir task-scheduler && cd task-scheduler
mkdir -p src
touch src/Task.java src/TaskScheduler.java src/Main.java
Windows
mkdir task-scheduler
cd task-scheduler
mkdir src
New-Item src/Task.java
New-Item src/TaskScheduler.java
New-Item src/Main.java
- Confirm the files exist by running:
ls src
You should see Task.java, TaskScheduler.java, and Main.java in the output.
Don't see the files listed?
Make sure you ran all the commands in order. If you are on Windows and ls does not work, try dir src instead.
Help me find my Java files
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code and press Enter to open it.
- In VS Code, click File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop, select the task-scheduler folder, and click Open.
Why open the folder in VS Code?
Opening the project folder (not just individual files) tells VS Code to treat it as your workspace. The integrated terminal will automatically start in the task-scheduler directory, and the Explorer sidebar will show all your source files.
Verify compilation works
Before writing any real logic, confirm that the Java compiler and runtime work together. You will add a single print statement to Main.java and compile it.
- In VS Code's Explorer sidebar on the left, expand the src folder and click Main.java to open it.
- Replace any existing content with the following code:
public class Main {
public static void main(String[] args) {
System.out.println("Task Scheduler ready!");
}
}
What does this code do?
Every Java program needs a main method as its entry point. This minimal version prints a confirmation message so you can verify the compile-and-run cycle works before adding complexity.
- Save Main.java by pressing Cmd+S (macOS) or Ctrl+S (Windows).
- Open the integrated terminal in VS Code by pressing Ctrl+` (both platforms).
- Navigate into the source directory and compile by running these commands:
cd src
javac Main.java
java Main
You should see Task Scheduler ready! printed in the terminal.
Seeing an error instead?
- If you see javac: file not found, make sure you ran cd src first so the terminal is inside the src directory.
- If you see a syntax error, double-check that your code in Main.java matches the code block above exactly.
- If javac is not recognized, return to the first substep and complete the Java installation.
Help me fix my Java compilation error
✔️ Awesome, I've got everything!
Great. Double check you have saved your file.
ⓧ I'd like to double check the full code
Here is the complete Main.java at this point:
public class Main {
public static void main(String[] args) {
System.out.println("Task Scheduler ready!");
}
}
The other two files (Task.java and TaskScheduler.java) should still be empty at this point.
Your environment is ready. Next up, you will build the Task model with an enum state machine that tracks each task through its lifecycle.
Build the Task Model
Your project folder is ready and Java compiles without issues. Now it is time to define the building block everything else depends on: the Task itself.
A task scheduler needs to know two things about every task: what state it is in, and how to decide which task runs first. You will model both with a TaskStatus enum and the Comparable interface.
In this step, get ready to:
- Define a state machine enum for the task lifecycle.
- Build a Task class that orders itself by priority using Comparable.
- Add state transition methods that guard against invalid moves.
Define the TaskStatus enum and Task class
Every task in the scheduler follows a lifecycle: it starts as PENDING, moves to RUNNING, and ends as COMPLETED or CANCELLED. An enum captures these finite states cleanly.
The Task class also implements Comparable<Task> so the PriorityQueue you build later knows how to order tasks. A lower priority number means higher urgency. When two tasks share the same priority, creation time breaks the tie.
- Open src/Task.java in VS Code and paste the following code:
import java.util.ArrayList;
import java.util.List;
// Finite states a task can be in
enum TaskStatus {
PENDING,
RUNNING,
COMPLETED,
CANCELLED
}
public class Task implements Comparable<Task> {
private final String id;
private final String name;
private final int priority; // lower number = higher urgency
private TaskStatus status;
private final long createdAt; // tiebreaker for equal priorities
private List<String> dependencies;
public Task(String id, String name, int priority) {
this.id = id;
this.name = name;
this.priority = priority;
this.status = TaskStatus.PENDING;
this.createdAt = System.nanoTime();
this.dependencies = new ArrayList<>();
}
// Getters for accessing private fields
public String getId() { return id; }
public String getName() { return name; }
public int getPriority() { return priority; }
public TaskStatus getStatus() { return status; }
public long getCreatedAt() { return createdAt; }
public List<String> getDependencies() { return dependencies; }
public void addDependency(String taskId) { dependencies.add(taskId); }
// Comparable: negative return means this task comes first
@Override
public int compareTo(Task other) {
int priorityCompare = Integer.compare(this.priority, other.priority);
if (priorityCompare != 0) return priorityCompare;
return Long.compare(this.createdAt, other.createdAt);
}
@Override
public String toString() {
return String.format("[%s] %s (priority: %d, status: %s)", id, name, priority, status);
}
}
What does this code do?
The TaskStatus enum defines four allowed states. Because it is an enum, the compiler will reject any status value that is not one of these four.
The compareTo method returns a negative number when this task should come before other. It first compares priority numbers (1 beats 3). If both tasks share the same priority, it falls back to createdAt so the older task goes first (FIFO among equals).
- Save Task.java.
- Compile it to check for errors by running this in the terminal from the src directory:
javac Task.java
- You should see no output at all. A silent return means the file compiled successfully.
Seeing a compilation error?
- Check that the file is named exactly Task.java with a capital T. Java requires the filename to match the public class name.
- Make sure the enum block appears before the public class declaration. Java does not allow two public top-level types in one file, but a package-private enum is fine.
- Confirm your terminal is in the src folder by running ls and checking you see Task.java listed.
Help me fix a Java compilation error in my Task.java file
Add task transition methods
The enum defines which states exist, but it does not enforce which transitions are legal. For example, you should not be able to complete a task that was never started.
- In Task.java, find the line that says public void addDependency(String taskId) { dependencies.add(taskId); }.
- Add the following methods directly below it (before the @Override line):
// State transitions with validation
public void start() {
if (status != TaskStatus.PENDING) throw new IllegalStateException("Cannot start task in state: " + status);
status = TaskStatus.RUNNING;
}
public void complete() {
if (status != TaskStatus.RUNNING) throw new IllegalStateException("Cannot complete task in state: " + status);
status = TaskStatus.COMPLETED;
}
public void cancel() {
if (status == TaskStatus.COMPLETED) throw new IllegalStateException("Cannot cancel a completed task");
status = TaskStatus.CANCELLED;
}
public void reset() { status = TaskStatus.PENDING; }
What does this code do?
Each method checks the current state before allowing the transition. If a caller tries an invalid move (like completing a cancelled task), Java throws an IllegalStateException immediately.
The reset() method exists for the undo system you will build in a later step. It sets a task back to PENDING so the scheduler can reprocess it.
- Save Task.java and compile again:
javac Task.java
- You should see no output again, confirming the transition methods compile without errors.
Getting an illegal start of expression error?
- Make sure the new methods sit inside the class braces but outside any other method.
- Check that the addDependency line ends with a closing brace } before your new code begins.
Help me fix a placement error in my Task.java transition methods
Test the Task class
The model compiles, but you should see it in action before moving on. You will create a few tasks with different priorities and print them to confirm everything wires up correctly.
- Open src/Main.java and replace its entire contents with the following:
public class Main {
public static void main(String[] args) {
// Create tasks with different priorities (1 = highest)
Task t1 = new Task("TASK-1", "Deploy server", 1);
Task t2 = new Task("TASK-2", "Write tests", 3);
Task t3 = new Task("TASK-3", "Code review", 2);
// Print each task to verify toString and default PENDING status
System.out.println(t1);
System.out.println(t2);
System.out.println(t3);
}
}
What does this code do?
Each new Task(...) call triggers the constructor, which sets the status to PENDING and records System.nanoTime() as the creation timestamp.
The System.out.println calls invoke the toString() method you defined, so you will see each task's ID, name, priority, and status in a formatted line.
- Save Main.java.
- Compile both files and run the program:
javac Task.java Main.java
java Main
- You should see three lines of output, each showing a task with PENDING status:
[TASK-1] Deploy server (priority: 1, status: PENDING)
[TASK-2] Write tests (priority: 3, status: PENDING)
[TASK-3] Code review (priority: 2, status: PENDING)
Seeing 'cannot find symbol: class Task'?
- Both files must be compiled together because Main.java references the Task class. Run javac Task.java Main.java as one command.
- Make sure both .java files are in the same src folder. Run ls to confirm.
Help me fix a 'cannot find symbol' error when compiling my task scheduler
✔️ Awesome, I've got everything!
Great. Double check you have saved both Task.java and Main.java.
ⓧ I'd like to double check the full code
Here is the complete Task.java at this point:
import java.util.ArrayList;
import java.util.List;
enum TaskStatus {
PENDING,
RUNNING,
COMPLETED,
CANCELLED
}
public class Task implements Comparable<Task> {
private final String id;
private final String name;
private final int priority;
private TaskStatus status;
private final long createdAt;
private List<String> dependencies;
public Task(String id, String name, int priority) {
this.id = id;
this.name = name;
this.priority = priority;
this.status = TaskStatus.PENDING;
this.createdAt = System.nanoTime();
this.dependencies = new ArrayList<>();
}
public String getId() { return id; }
public String getName() { return name; }
public int getPriority() { return priority; }
public TaskStatus getStatus() { return status; }
public long getCreatedAt() { return createdAt; }
public List<String> getDependencies() { return dependencies; }
public void addDependency(String taskId) { dependencies.add(taskId); }
public void start() {
if (status != TaskStatus.PENDING) throw new IllegalStateException("Cannot start task in state: " + status);
status = TaskStatus.RUNNING;
}
public void complete() {
if (status != TaskStatus.RUNNING) throw new IllegalStateException("Cannot complete task in state: " + status);
status = TaskStatus.COMPLETED;
}
public void cancel() {
if (status == TaskStatus.COMPLETED) throw new IllegalStateException("Cannot cancel a completed task");
status = TaskStatus.CANCELLED;
}
public void reset() { status = TaskStatus.PENDING; }
@Override
public int compareTo(Task other) {
int priorityCompare = Integer.compare(this.priority, other.priority);
if (priorityCompare != 0) return priorityCompare;
return Long.compare(this.createdAt, other.createdAt);
}
@Override
public String toString() {
return String.format("[%s] %s (priority: %d, status: %s)", id, name, priority, status);
}
}
And here is the complete Main.java at this point:
public class Main {
public static void main(String[] args) {
Task t1 = new Task("TASK-1", "Deploy server", 1);
Task t2 = new Task("TASK-2", "Write tests", 3);
Task t3 = new Task("TASK-3", "Code review", 2);
System.out.println(t1);
System.out.println(t2);
System.out.println(t3);
}
}
Your Task model is solid. Next, you will build the TaskScheduler class that uses a HashMap for instant lookups and a PriorityQueue to process tasks in the right order.
Implement HashMap and PriorityQueue
Your Task model is ready. Each task knows its priority, its lifecycle state, and how to compare itself against other tasks.
Now you need somewhere to store those tasks and a way to process them in the right order. Think about what a real task scheduler needs: instant retrieval of any task by its ID, and automatic ordering so the most urgent work always gets processed first. These are two very different problems, and they need two very different data structures.
In this step, get ready to:
- Build a TaskScheduler class using a HashMap for O(1) task lookup.
- Add a PriorityQueue for automatic priority-based processing.
- Test both data structures together to observe priority ordering.
Create the TaskScheduler class with HashMap for O(1) lookup
A HashMap stores key-value pairs and retrieves any value by its key in constant time, O(1). Without a HashMap, finding a task by its ID would mean scanning every task in a list until you find it.
With 10,000 tasks, a list scan checks up to 10,000 entries. A HashMap finds the task instantly regardless of size.
- In VS Code, open src/TaskScheduler.java and add the following class with its imports, fields, constructor, and two methods:
import java.util.ArrayDeque;
import java.util.HashMap;
import java.util.Map;
import java.util.PriorityQueue;
public class TaskScheduler {
// HashMap for O(1) task retrieval by ID
private final Map<String, Task> taskMap;
// PriorityQueue for automatic priority-based ordering
private final PriorityQueue<Task> priorityQueue;
// Stack for undo history (used in the next step)
private final ArrayDeque<String> undoStack;
// Counter for generating unique task IDs
private int taskCounter;
public TaskScheduler() {
this.taskMap = new HashMap<>();
this.priorityQueue = new PriorityQueue<>();
this.undoStack = new ArrayDeque<>();
this.taskCounter = 0;
}
public Task addTask(String name, int priority) {
// Generate a unique ID
taskCounter++;
String id = "TASK-" + taskCounter;
Task task = new Task(id, name, priority);
// Store in HashMap for instant lookup by ID
taskMap.put(id, task);
// Store in PriorityQueue for priority-based processing
priorityQueue.offer(task);
System.out.println(" [HashMap.put() - O(1)] Stored task by ID for instant lookup");
System.out.println(" [PriorityQueue.offer() - O(log n)] Inserted into priority heap");
return task;
}
public Task getTask(String id) {
System.out.println(" [HashMap.get() - O(1)] Instant lookup by ID");
Task task = taskMap.get(id);
if (task == null) System.out.println(" Task not found: " + id);
return task;
}
}
What does this code do?
The class declares three data structures. Each solves a different problem:
- taskMap is a HashMap<String, Task> that maps task IDs to Task objects for instant retrieval.
- priorityQueue is a PriorityQueue that keeps tasks ordered by their compareTo logic (lowest priority number first).
- undoStack is an ArrayDeque declared here but used in the next step.
- addTask stores each task in both structures. The HashMap enables instant lookup. The PriorityQueue enables ordered processing.
- getTask retrieves a task from the HashMap in O(1) time, no matter how many tasks exist.
Why store in both HashMap and PriorityQueue?
Each data structure solves one problem well but is terrible at the other. A HashMap gives you instant lookup by key, but has no concept of ordering. A PriorityQueue gives you ordered retrieval, but finding a specific element by ID requires scanning the entire queue.
Using both together gives you the best of both worlds. This dual-storage pattern is common in production systems.
- Save TaskScheduler.java.
- You won't see a change yet. The next chunk adds the processing methods that bring the PriorityQueue to life.
Add priority-based processing with PriorityQueue
A PriorityQueue is a heap-based structure. It does not store elements in sorted order internally. It only guarantees that poll() always returns the highest-priority element.
- In TaskScheduler.java, add the following two methods below the getTask method (before the closing } of the class):
public Task processNext() {
// poll() removes and returns the highest-priority task
Task task = priorityQueue.poll();
if (task == null) {
System.out.println(" No tasks to process.");
return null;
}
System.out.println(" [PriorityQueue.poll() - O(log n)] Retrieved highest-priority task");
// Transition through the state machine: PENDING -> RUNNING -> COMPLETED
task.start();
task.complete();
System.out.println(" Processed: " + task);
return task;
}
public void showStatus() {
System.out.println("\n --- All Tasks ---");
if (taskMap.isEmpty()) { System.out.println(" No tasks."); return; }
for (Task task : taskMap.values()) System.out.println(" " + task);
System.out.println(" --- Queue size: " + priorityQueue.size() + " pending ---");
}
What does this code do?
- processNext() calls priorityQueue.poll() which removes the element with the lowest priority number (highest urgency). This is O(log n) because the heap must rebalance after removal.
- After polling, it transitions the task through the state machine: PENDING to RUNNING to COMPLETED. The enum guards you wrote earlier prevent invalid transitions.
- showStatus() iterates the HashMap values to display all tasks regardless of their state.
Why does poll() return the right task?
When you created the Task class, you implemented Comparable<Task>. The PriorityQueue uses your compareTo method to decide ordering. Lower priority numbers come first. Equal-priority tasks are ordered by creation time (first in, first out).
- Save TaskScheduler.java.
- Compile the file to confirm there are no syntax errors by running:
javac Task.java TaskScheduler.java
- You should see no output. A silent compile means everything is correct.
Seeing a compile error?
- If you see cannot find symbol errors mentioning Task or TaskStatus, make sure both Task.java and TaskScheduler.java are in the same src directory and you are compiling from inside that directory.
- If you see a brace mismatch error, check that the class has a closing } after the showStatus method.
- help me fix this compile error in TaskScheduler.java
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
import java.util.ArrayDeque;
import java.util.HashMap;
import java.util.Map;
import java.util.PriorityQueue;
public class TaskScheduler {
private final Map<String, Task> taskMap;
private final PriorityQueue<Task> priorityQueue;
private final ArrayDeque<String> undoStack;
private int taskCounter;
public TaskScheduler() {
this.taskMap = new HashMap<>();
this.priorityQueue = new PriorityQueue<>();
this.undoStack = new ArrayDeque<>();
this.taskCounter = 0;
}
public Task addTask(String name, int priority) {
taskCounter++;
String id = "TASK-" + taskCounter;
Task task = new Task(id, name, priority);
taskMap.put(id, task);
priorityQueue.offer(task);
System.out.println(" [HashMap.put() - O(1)] Stored task by ID for instant lookup");
System.out.println(" [PriorityQueue.offer() - O(log n)] Inserted into priority heap");
return task;
}
public Task processNext() {
Task task = priorityQueue.poll();
if (task == null) {
System.out.println(" No tasks to process.");
return null;
}
System.out.println(" [PriorityQueue.poll() - O(log n)] Retrieved highest-priority task");
task.start();
task.complete();
System.out.println(" Processed: " + task);
return task;
}
public Task getTask(String id) {
System.out.println(" [HashMap.get() - O(1)] Instant lookup by ID");
Task task = taskMap.get(id);
if (task == null) System.out.println(" Task not found: " + id);
return task;
}
public void showStatus() {
System.out.println("\n --- All Tasks ---");
if (taskMap.isEmpty()) { System.out.println(" No tasks."); return; }
for (Task task : taskMap.values()) System.out.println(" " + task);
System.out.println(" --- Queue size: " + priorityQueue.size() + " pending ---");
}
}
Test HashMap and PriorityQueue together
Time to see both data structures in action. You will add tasks with mixed priorities, process them, and verify that the PriorityQueue returns them in priority order regardless of when they were added.
- Replace the entire contents of src/Main.java with the following test code:
public class Main {
public static void main(String[] args) {
// Create the scheduler (initializes HashMap + PriorityQueue)
TaskScheduler scheduler = new TaskScheduler();
// Add tasks with mixed priorities (1 = highest)
scheduler.addTask("Low priority task", 5);
scheduler.addTask("High priority task", 1);
scheduler.addTask("Medium task", 3);
scheduler.addTask("Urgent task", 1);
scheduler.addTask("Normal task", 2);
// Show all tasks stored in the HashMap
scheduler.showStatus();
// Process tasks - they come out in priority order, not insertion order
System.out.println("\nProcessing in priority order:");
scheduler.processNext();
scheduler.processNext();
scheduler.processNext();
// Look up a specific task by ID - instant HashMap retrieval
System.out.println("\nLookup by ID:");
scheduler.getTask("TASK-2");
}
}
What does this test demonstrate?
- Tasks are added in random priority order (5, 1, 3, 1, 2). The PriorityQueue does not care about insertion order.
- When processNext() runs, it polls the highest-priority tasks first (priority 1, then 1, then 2). This proves the heap is working.
- The getTask("TASK-2") call finds the task instantly by ID. It does not search through the queue or a list.
- Save Main.java.
- Compile and run all three files by running:
javac Task.java TaskScheduler.java Main.java
java Main
- You should see all 5 tasks listed in the status output, followed by 3 tasks processing in priority order (priority 1 tasks first, then priority 2). The lookup by ID should return TASK-2 instantly.
Why do the two priority-1 tasks come out in creation order?
Your compareTo method uses createdAt as a tiebreaker. When two tasks have the same priority number, the one created first gets processed first (FIFO among equals). Without this tiebreaker, equal-priority tasks would have unpredictable ordering.
Tasks not processing in priority order?
- Check that your compareTo method in Task.java uses Integer.compare(this.priority, other.priority). Reversing the arguments reverses the order.
- Make sure you are running javac on all three files before running java Main. Stale .class files from a previous compile will use old code.
- help me debug why my PriorityQueue is not returning tasks in the expected order
✔️ Awesome, I've got everything!
Great. Double check you've saved both files before moving on.
ⓧ I'd like to double check the full code
public class Main {
public static void main(String[] args) {
TaskScheduler scheduler = new TaskScheduler();
scheduler.addTask("Low priority task", 5);
scheduler.addTask("High priority task", 1);
scheduler.addTask("Medium task", 3);
scheduler.addTask("Urgent task", 1);
scheduler.addTask("Normal task", 2);
scheduler.showStatus();
System.out.println("\nProcessing in priority order:");
scheduler.processNext();
scheduler.processNext();
scheduler.processNext();
System.out.println("\nLookup by ID:");
scheduler.getTask("TASK-2");
}
}
Your scheduler can add tasks, retrieve them instantly by ID, and process them in priority order. Next up, you will add undo functionality using an ArrayDeque as a stack, so any action can be reversed with a single command.
Add Undo with ArrayDeque Stack
Your HashMap and PriorityQueue are working together to store and process tasks by priority. But what happens when you process a task by accident, or add the wrong one?
Every production application needs an undo system. In this step, you will activate the undoStack field (the ArrayDeque you already declared) to record every action and reverse it on demand.
In this step, get ready to:
- Push action records onto the undo stack each time a task is added or processed.
- Implement an undo method that reverses the last action using LIFO retrieval.
- Test the undo system by processing a task and then reversing it.
Record actions on the undo stack
For undo to work, the scheduler needs to remember what it did. Each time a task is added or processed, you will push a string-encoded description of that action onto the stack.
Why string-encoded actions?
A production system would use the Command pattern with polymorphic undo objects. For this project, simple strings like ADD:TASK-1 keep the focus on the stack data structure itself. The pattern (type + colon + ID) is easy to parse and reverse.
- In TaskScheduler.java, find the addTask method. Locate this line:
priorityQueue.offer(task);
- Add this line directly below it, before the first System.out.println call:
undoStack.push("ADD:" + id);
- Now find the processNext method. Locate this line:
task.complete();
- Add this line directly below it, before the System.out.println call:
undoStack.push("PROCESS:" + task.getId());
What does this code do?
Each push() call places the action description at the front of the ArrayDeque. This is the LIFO (Last-In-First-Out) behavior that makes it a stack. The most recent action is always on top, ready to be undone first.
The string format TYPE:ID encodes both what happened and which task it happened to. When you undo later, you split on the colon to figure out how to reverse it.
- Save TaskScheduler.java.
Seeing a compilation error?
Make sure undoStack.push("ADD:" + id) is inside the addTask method body, not outside the closing brace. The same goes for the push in processNext.
Help me fix a compilation error in my TaskScheduler undo stack pushes.
Implement the undo method
Now that actions are recorded, you need a method to reverse them. The undo() method pops the most recent action off the stack and reverses its effect.
- Add the following undo() method to TaskScheduler.java, below the getTask method:
public void undo() {
if (undoStack.isEmpty()) { System.out.println(" Nothing to undo."); return; }
String action = undoStack.pop();
System.out.println(" [ArrayDeque.pop() - O(1)] LIFO retrieval of last action");
String[] parts = action.split(":");
String type = parts[0];
String id = parts[1];
if (type.equals("ADD")) {
Task removed = taskMap.remove(id);
priorityQueue.remove(removed);
System.out.println(" Undid ADD: removed " + removed);
} else if (type.equals("PROCESS")) {
Task task = taskMap.get(id);
if (task != null) {
task.reset();
priorityQueue.offer(task);
System.out.println(" Undid PROCESS: " + task.getName() + " is back to PENDING");
}
}
}
What does this code do?
The method starts by calling undoStack.pop() which removes and returns the element at the front of the deque. Because push() also operates at the front, you always get the most recent action back. This is LIFO in action.
For an ADD action, the reversal removes the task from both the HashMap and PriorityQueue. For a PROCESS action, the reversal calls task.reset() to set the status back to PENDING and re-offers it to the priority queue so it can be processed again.
Why ArrayDeque over the legacy Stack class?
Oracle's official docs state that the Deque interface and its implementations "should be used in preference to the legacy Stack class." The old Stack class extends Vector and synchronizes every operation, adding overhead even in single-threaded code. ArrayDeque gives you the same push() and pop() behavior with amortized constant time and no synchronization cost.
- Save TaskScheduler.java.
Method not found error?
Check that undo() is placed inside the TaskScheduler class body (before the final closing brace). Also verify that reset() exists in your Task.java file. It should be there from Step 2.
Help me debug my undo method in TaskScheduler.
Test the undo system
Time to prove that LIFO undo works. You will add tasks, process one, undo the processing, and verify the task returns to PENDING status.
- Replace the contents of Main.java with the following test code:
public class Main {
public static void main(String[] args) {
TaskScheduler scheduler = new TaskScheduler();
scheduler.addTask("High priority task", 1);
scheduler.addTask("Medium task", 3);
System.out.println("\nProcessing next task:");
scheduler.processNext();
scheduler.showStatus();
System.out.println("\nUndoing last action:");
scheduler.undo();
scheduler.showStatus();
System.out.println("\nReprocessing after undo:");
scheduler.processNext();
}
}
What does this test prove?
The test adds two tasks, processes the highest-priority one (completing it), then undoes that processing. After the undo, the task should be PENDING again and back in the priority queue. When processNext() runs a second time, it picks up the same task. This proves the LIFO stack correctly reversed the last action.
- Save Main.java.
- Compile and run all three files from the src directory by running:
javac Task.java TaskScheduler.java Main.java
java Main
- You should see the task get processed (status COMPLETED), then after undo it returns to PENDING, and processNext() picks it up again.
Task not returning to PENDING?
Make sure undoStack.push("PROCESS:" + task.getId()) is placed after task.complete() in your processNext method. If the push happens before complete(), the action is recorded but the state transition might not match what undo expects.
Also check that task.reset() in Task.java sets status = TaskStatus.PENDING without any state checks blocking it.
Help me debug why my undo isn't resetting the task to PENDING.
✔️ Awesome, I've got everything!
Great. Double check you have saved all your files before moving on.
ⓧ I'd like to double check the full code
Here is the complete TaskScheduler.java with the undo stack changes:
import java.util.ArrayDeque;
import java.util.HashMap;
import java.util.Map;
import java.util.PriorityQueue;
public class TaskScheduler {
private final Map<String, Task> taskMap;
private final PriorityQueue<Task> priorityQueue;
private final ArrayDeque<String> undoStack;
private int taskCounter;
public TaskScheduler() {
this.taskMap = new HashMap<>();
this.priorityQueue = new PriorityQueue<>();
this.undoStack = new ArrayDeque<>();
this.taskCounter = 0;
}
public Task addTask(String name, int priority) {
taskCounter++;
String id = "TASK-" + taskCounter;
Task task = new Task(id, name, priority);
taskMap.put(id, task);
priorityQueue.offer(task);
undoStack.push("ADD:" + id);
System.out.println(" [HashMap.put() - O(1)] Stored task by ID for instant lookup");
System.out.println(" [PriorityQueue.offer() - O(log n)] Inserted into priority heap");
return task;
}
public Task processNext() {
Task task = priorityQueue.poll();
if (task == null) {
System.out.println(" No tasks to process.");
return null;
}
System.out.println(" [PriorityQueue.poll() - O(log n)] Retrieved highest-priority task");
task.start();
task.complete();
undoStack.push("PROCESS:" + task.getId());
System.out.println(" Processed: " + task);
return task;
}
public Task getTask(String id) {
System.out.println(" [HashMap.get() - O(1)] Instant lookup by ID");
Task task = taskMap.get(id);
if (task == null) System.out.println(" Task not found: " + id);
return task;
}
public void undo() {
if (undoStack.isEmpty()) { System.out.println(" Nothing to undo."); return; }
String action = undoStack.pop();
System.out.println(" [ArrayDeque.pop() - O(1)] LIFO retrieval of last action");
String[] parts = action.split(":");
String type = parts[0];
String id = parts[1];
if (type.equals("ADD")) {
Task removed = taskMap.remove(id);
priorityQueue.remove(removed);
System.out.println(" Undid ADD: removed " + removed);
} else if (type.equals("PROCESS")) {
Task task = taskMap.get(id);
if (task != null) {
task.reset();
priorityQueue.offer(task);
System.out.println(" Undid PROCESS: " + task.getName() + " is back to PENDING");
}
}
}
public void showStatus() {
System.out.println("\n --- All Tasks ---");
if (taskMap.isEmpty()) { System.out.println(" No tasks."); return; }
for (Task task : taskMap.values()) System.out.println(" " + task);
System.out.println(" --- Queue size: " + priorityQueue.size() + " pending ---");
}
}
Here is the complete Main.java for this step:
public class Main {
public static void main(String[] args) {
TaskScheduler scheduler = new TaskScheduler();
scheduler.addTask("High priority task", 1);
scheduler.addTask("Medium task", 3);
System.out.println("\nProcessing next task:");
scheduler.processNext();
scheduler.showStatus();
System.out.println("\nUndoing last action:");
scheduler.undo();
scheduler.showStatus();
System.out.println("\nReprocessing after undo:");
scheduler.processNext();
}
}
Your scheduler can now reverse its own actions. Next up, you will build the interactive CLI that ties everything together, letting you add, process, undo, and look up tasks in real time.
Build the Interactive CLI
Your scheduler can add tasks, process them by priority, and undo actions. But right now, every operation is hardcoded in Main.java. To actually use this scheduler, you need a way to type commands and see results in real time.
In this step, you will replace the test code in Main.java with an interactive command loop. This loop reads commands from the terminal, dispatches them to the scheduler, and shows which data structure is at work behind each operation.
In this step, get ready to:
- Build a command loop that reads user input and dispatches to scheduler methods.
- See data structure insights printed alongside every operation.
- Compile and run the full end-to-end task scheduler.
Create the command loop
A Scanner reads lines from the terminal. A while (true) loop keeps the program alive until the user types quit. Inside the loop, a switch statement routes each command to the right scheduler method.
- In VS Code, open src/Main.java.
- Select all the existing code and replace it with the following:
import java.util.Scanner;
public class Main {
public static void main(String[] args) {
// Create the scheduler and a Scanner to read terminal input
TaskScheduler scheduler = new TaskScheduler();
Scanner scanner = new Scanner(System.in);
// Print the welcome banner with available commands
System.out.println("=== Task Scheduler ===");
System.out.println("Commands: add <name> <priority>, process, status, get <id>, undo, quit");
System.out.println();
// Main command loop - runs until user types 'quit'
while (true) {
System.out.print("> ");
String input = scanner.nextLine().trim();
if (input.isEmpty()) continue;
// Split input into at most 3 parts: command, arg1, arg2
String[] parts = input.split("\\s+", 3);
String command = parts[0].toLowerCase();
switch (command) {
case "add":
// Validate that name and priority were provided
if (parts.length < 3) { System.out.println(" Usage: add <name> <priority>"); break; }
try {
String name = parts[1];
int priority = Integer.parseInt(parts[2]);
Task added = scheduler.addTask(name, priority);
System.out.println(" Added: " + added);
} catch (NumberFormatException e) {
System.out.println(" Priority must be a number (1 = highest)");
}
break;
case "process":
scheduler.processNext();
break;
case "status":
scheduler.showStatus();
break;
case "get":
// Validate that an ID was provided
if (parts.length < 2) { System.out.println(" Usage: get <id>"); break; }
Task found = scheduler.getTask(parts[1].toUpperCase());
if (found != null) System.out.println(" Found: " + found);
break;
case "undo":
scheduler.undo();
break;
case "quit":
System.out.println(" Goodbye!");
scanner.close();
return;
default:
System.out.println(" Unknown command. Try: add, process, status, get, undo, quit");
}
System.out.println();
}
}
}
What does this code do?
This replaces the hardcoded test code with a live command loop that stays running until the user exits.
- scanner.nextLine().trim() reads one line of input and strips whitespace from both ends.
- input.split("\\s+", 3) splits the line on whitespace into at most 3 tokens. This lets command names with spaces (like task names) stay intact as one piece.
- parts[1].toUpperCase() in the get case converts the typed ID to uppercase so it matches the TASK-1 format stored in the HashMap.
- scanner.close() in the quit case releases the input resource before exiting.
- Save Main.java.
Seeing red underlines in VS Code?
VS Code might show errors because it cannot resolve TaskScheduler or Task from within the editor. These resolve at compile time when javac sees all three files together. You can safely ignore them for now.
help me fix red underlines in my Java project in VS Code
Understand the data structure insights
You might have noticed that the CLI code itself does not print any data structure messages. Those insights are already built into the TaskScheduler methods you wrote in earlier steps.
Where do the insight messages come from?
Each scheduler method already prints which data structure it uses and its time complexity. When the CLI calls these methods, the messages appear automatically.
- add triggers addTask() which prints the HashMap O(1) put and PriorityQueue O(log n) offer messages.
- process triggers processNext() which prints the PriorityQueue O(log n) poll message.
- get triggers getTask() which prints the HashMap O(1) get message.
- undo triggers undo() which prints the ArrayDeque O(1) pop message.
This pattern keeps the CLI thin. The scheduler owns the logic and the messaging. The CLI just routes commands.
Compile and run the complete scheduler
All three files need to compile together since Main.java references TaskScheduler, which references Task.
- Make sure your terminal is in the src directory. Compile all Java files by running:
javac *.java
- Start the interactive scheduler by running:
java Main
You should see the welcome banner and a > prompt waiting for your input.
- Test the full flow by trying these commands in order:
add Deploy 1
add Refactor 3
add Bugfix 2
status
process
process
get TASK-3
undo
status
quit
What should I see?
- add commands show HashMap and PriorityQueue insight messages, then the task details.
- status lists all three tasks as PENDING with a queue size of 3.
- The first process picks Deploy (priority 1), not Refactor (which was added second). Priority ordering is working.
- The second process picks Bugfix (priority 2), skipping Refactor (priority 3).
- get TASK-3 retrieves Bugfix instantly via HashMap with the O(1) message.
- undo reverses the last process, returning Bugfix to PENDING status.
- The final status shows Bugfix as PENDING again with a queue size of 2 (Bugfix and Refactor).
Seeing a compilation error?
- Make sure you are running javac *.java from inside the src directory, not the parent task-scheduler folder.
- Check that all three files (Task.java, TaskScheduler.java, Main.java) are in the same src folder.
- If you see a "cannot find symbol" error, double check that you did not accidentally delete code from TaskScheduler.java or Task.java while editing Main.java.
help me fix my Java compilation error
✔️ Awesome, I've got everything!
Great. Double check you have saved Main.java before moving on.
ⓧ I'd like to double check the full code
Here is the complete Main.java for this step:
import java.util.Scanner;
public class Main {
public static void main(String[] args) {
TaskScheduler scheduler = new TaskScheduler();
Scanner scanner = new Scanner(System.in);
System.out.println("=== Task Scheduler ===");
System.out.println("Commands: add <name> <priority>, process, status, get <id>, undo, quit");
System.out.println();
while (true) {
System.out.print("> ");
String input = scanner.nextLine().trim();
if (input.isEmpty()) continue;
String[] parts = input.split("\\s+", 3);
String command = parts[0].toLowerCase();
switch (command) {
case "add":
if (parts.length < 3) { System.out.println(" Usage: add <name> <priority>"); break; }
try {
String name = parts[1];
int priority = Integer.parseInt(parts[2]);
Task added = scheduler.addTask(name, priority);
System.out.println(" Added: " + added);
} catch (NumberFormatException e) {
System.out.println(" Priority must be a number (1 = highest)");
}
break;
case "process":
scheduler.processNext();
break;
case "status":
scheduler.showStatus();
break;
case "get":
if (parts.length < 2) { System.out.println(" Usage: get <id>"); break; }
Task found = scheduler.getTask(parts[1].toUpperCase());
if (found != null) System.out.println(" Found: " + found);
break;
case "undo":
scheduler.undo();
break;
case "quit":
System.out.println(" Goodbye!");
scanner.close();
return;
default:
System.out.println(" Unknown command. Try: add, process, status, get, undo, quit");
}
System.out.println();
}
}
}
Your task scheduler is fully operational. You have a HashMap for instant lookups, a PriorityQueue for ordered processing, and an ArrayDeque stack for undo. Next up, the Secret Mission challenges you to use that same ArrayDeque class as a FIFO queue for task dependencies.
Secret mission
Add Task Dependencies with ArrayDeque as a Queue
Your scheduler handles priority and undo, but real task systems need one more thing: dependencies. Some tasks cannot start until others finish. Can you use the same ArrayDeque class as a FIFO queue to hold blocked tasks until their dependencies complete?
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your source files for future reference or delete them entirely.
Resources you used:
- Local project folder: task-scheduler/src/ containing Task.java, TaskScheduler.java, and Main.java.
- Compiled .class files generated by javac.
Keep everything running
No action needed. Choose this if you want to keep your task scheduler source code for future reference or plan to extend it further.
- Your source files remain in task-scheduler/src/ on your Desktop.
- You can recompile and run the scheduler anytime with javac *.java && java Main.
Pause - I'll come back to this later
Since this project is entirely local with no running processes, there is nothing to pause. Your files stay on disk until you delete them.
- Close the VS Code window for the task-scheduler project.
- When you come back, reopen the folder in VS Code and recompile.
Delete - I don't want to use this again
Remove all project files and compiled output to start fresh.
- Close the VS Code window for the project.
- On macOS or Linux, open your terminal and run:
cd ~/Desktop
rm -rf task-scheduler
- On Windows, open PowerShell and run:
cd ~\Desktop
Remove-Item -Recurse -Force task-scheduler
- Confirm the folder is gone by running ls on your Desktop. You should no longer see task-scheduler in the output.
Nice Work!
Nice Work!
Nice work! You just built a fully interactive task scheduler powered by four core Java data structures.
You've learned how to:
- Use a HashMap for O(1) task lookup by ID and a PriorityQueue with a custom Comparable implementation for priority-based processing.
- Use ArrayDeque as a stack (push/pop) to build undo functionality with LIFO behavior.
- Implement an enum state machine to manage task lifecycle transitions with validation.
- Secret Mission: Used ArrayDeque as a FIFO queue for task dependency resolution, demonstrating that the same class serves as both stack and queue.
Ready to quiz yourself?