Build a Python Study Task Tracker

Build a persistent Python CLI that saves and manages study tasks locally.

Introduction

30 Second Summary

Study tasks often begin as quick notes that disappear when a terminal closes. That makes it hard to track what you finished between study sessions.

In this project, you will build a menu-driven study task tracker with Python. It will remember your tasks between sessions while protecting the program from invalid input.

What You'll Build

Picture reopening your tracker in the Visual Studio Code terminal and finding your saved study tasks waiting for you.

By the end of this project, you'll have:

  • A numbered task menu where you can add a study task and see it appear immediately.
  • Simple completion and deletion controls that update tasks by number while handling invalid selections safely.
  • A persistent task list that returns after a restart from a readable JSON file.
  • Secret Mission: Add Low, Medium, and High priorities to your tasks before displaying higher-priority work first.

Are there any prerequisites?

You need Visual Studio Code on Windows plus basic familiarity with Python loops and conditions.

No third-party packages are required.

Before We Start

This is your moment to commit to building a local study task tracker that keeps your tasks between program restarts. Locking in its purpose now gives every feature a clear goal.

Prepare Python in VS Code

Your tracker needs a reliable way to turn task_tracker.py into a running program. Visual Studio Code cannot run Python by itself.

The Microsoft Python extension gives VS Code interpreter controls. This step proves the setup by running one message from the editor.

In this step, get ready to:
  • Install the official Python extension for Visual Studio Code.
  • Create the tracker workspace with its starter file.
  • Verify that a supported Python interpreter runs the starter file.
Install the Python extension

The Python extension connects VS Code to the Python installation on your computer. It also adds the controls you need to select an interpreter and run files.

  • Press the Windows key to open Windows search.
  • Type Visual Studio Code and press Enter to open it.
  • Open the Extensions view by pressing Ctrl+Shift+X.
  • Type Python in the Extensions search box.

The search results show several Python-related extensions. The official extension is published by Microsoft.

  • Select Python published by Microsoft.
  • Click Install on the extension page.

Nice work. The official extension can now connect VS Code to a Python interpreter.

What does this extension add?

The Python extension for Visual Studio Code has the Marketplace identifier ms-python.python. It adds interpreter selection to VS Code.

The extension also provides the command that runs the active Python file in the terminal. Your installed Python interpreter still performs the actual work.

Extension not installing?

  • Confirm that Microsoft is listed as the extension publisher.
  • Confirm that the extension identifier is ms-python.python.
  • Retry the installation after reconnecting your network.

Help me install the Microsoft Python extension in Visual Studio Code.

Create the workspace and select Python

A VS Code workspace keeps the tracker files together in one named folder. The selected interpreter determines which Python installation runs those files.

  • Click File in the VS Code menu bar.
  • Select Open Folder.

You will see the Windows folder picker. This is where you create the workspace that VS Code opens.

  • Select Desktop in the folder picker.
  • Click New Folder.

An editable folder name appears on your Desktop.

  • Enter study-task-tracker as the folder name.
  • Select the new study-task-tracker folder.
  • Click Select Folder.
  • Confirm that you trust the folder if VS Code asks.

The Explorer sidebar now shows study-task-tracker as the open workspace. Your project now has a dedicated home.

Python versions 3.11 through 3.15 are supported for this project. The terminal check reveals which Python 3 version the Windows launcher currently finds.

  • Click Terminal in the VS Code menu bar.
  • Select New Terminal.
  • Check your Python 3 version by running this command:
py -3 --version

What does this command check?

The Windows Python launcher checks the installed Python 3 interpreter. The version in the output tells you whether that interpreter is supported by this project.

✔️ I see a supported version

Your Python installation is ready if the output reports a version from 3.11 through 3.15. You can continue with interpreter selection.

ⓧ I see an older version

Your existing Python version is below the supported range. Install the current Windows release before continuing.

  • Open the official Python downloads page.
  • Choose the release labelled Latest Python 3 Release - Python 3.15.2.
  • Download the Windows installer offered for that release.
  • Run the downloaded installer from your Downloads folder.
  • Approve the Windows permission request if it appears.
  • Keep the standard installation options.
  • Advance through the installer until it confirms completion.
  • Restart VS Code.

The installer adds the supported Python release to Windows. Return to the study-task-tracker workspace from earlier.

  • Click Terminal in the VS Code menu bar.
  • Select New Terminal.
  • Run the version check shown above again.

You should now see a version from 3.11 through 3.15.

ⓧ Command not found

Windows cannot find the Python 3 launcher. Installing the current Windows release provides the interpreter needed for this project.

  • Open the official Python downloads page.
  • Choose the release labelled Latest Python 3 Release - Python 3.15.2.
  • Download the Windows installer offered for that release.
  • Run the downloaded installer from your Downloads folder.
  • Approve the Windows permission request if it appears.
  • Keep the standard installation options.
  • Advance through the installer until it confirms completion.
  • Restart VS Code.

The installer adds Python and its Windows launcher. Return to the study-task-tracker workspace from earlier.

  • Click Terminal in the VS Code menu bar.
  • Select New Terminal.
  • Run the version check shown above again.

You should now see a version from 3.11 through 3.15.

A supported Python installation is now available. Interpreter selection tells the extension which installation to use for task_tracker.py.

  • Open the Command Palette by pressing Ctrl+Shift+P.
  • Type Python: Select Interpreter.
  • Select an interpreter whose version is inside the supported range.

The selected Python interpreter now appears in the VS Code status bar. The extension uses it whenever you run the tracker.

Run the setup file

A small setup message tests the complete path from the editor to the interpreter. Seeing that message proves the active file can run in the integrated terminal.

  • Click the New File icon in the Explorer sidebar.
  • Type task_tracker.py and press Enter.

The new task_tracker.py file appears in the workspace. It also opens as the active editor tab.

  • Add the setup message to task_tracker.py by copying this code:
print("Study Task Tracker is ready.")

What does this code prove?

This line sends the setup message to the terminal. A successful run confirms that VS Code passed the active file to your selected interpreter.

  • Save task_tracker.py by pressing Ctrl+S.

Before you run the file, what message do you expect the terminal to print?

  • Open the Command Palette by pressing Ctrl+Shift+P.
  • Type Python: Run Python File in Terminal.
  • Press Enter to run the active file.

The terminal prints Study Task Tracker is ready. That is the full setup working.

Setup message missing?

  • Save task_tracker.py before running it again.
  • Confirm that task_tracker.py is the active editor tab.
  • Select the supported interpreter again with Python: Select Interpreter.

Help me run task_tracker.py from VS Code and find why the setup message is missing.

✔️ Awesome, I've got everything!

Great. Double check that you saved task_tracker.py in the study-task-tracker workspace.

ⓧ I'd like to double check the full code

Compare your complete task_tracker.py file with this version.

print("Study Task Tracker is ready.")

Your Python setup now runs files from VS Code. Next up, you will turn this one-line check into a menu that adds and displays study tasks.

Build the First Task List

Your setup message proved that Python can run task_tracker.py. A fixed line of text cannot collect study tasks.

This step turns task_tracker.py into a usable in-memory tracker. Small functions give each task action one clear job.

In this step, get ready to:
  • Create a numbered menu that repeats until you exit.
  • Add study tasks as dictionaries inside a list.
  • Display every task with a friendly number.
Create the menu loop

A menu gives each tracker action a number. A loop keeps that menu available until you choose to exit.

  • Select the existing print("Study Task Tracker is ready.") line in task_tracker.py.
  • Delete the selected setup message.
  • Create the menu function by pasting this code at the top of the empty file:
def show_menu():
    print("\nStudy Task Tracker")
    print("1. Add task")
    print("2. View tasks")
    print("3. Exit")

What Does This Code Do?

  • The show_menu() function keeps every menu label in one reusable place.
  • The \n character starts the tracker heading on a fresh line.
  • The numbered labels give the learner three possible choices.
  • Save task_tracker.py.
  • Confirm that all four print() lines have the same indentation inside show_menu().

Does the menu function look uneven?

Check that each print() line begins with four spaces. Python uses indentation to decide which lines belong to show_menu().

Ask for help if the editor highlights the function as invalid: help me fix the indentation in my Python show_menu function.

The menu also needs a controller that reads each choice. The main() function owns the task list for the current run.

  • Place your cursor on a blank line below show_menu().
  • Add the controller by pasting this code:
def main():
    tasks = []

    while True:
        show_menu()
        choice = input("Choose an option: ").strip()

        if choice == "1":
            add_task(tasks)
        elif choice == "2":
            view_tasks(tasks)
        elif choice == "3":
            print("Goodbye.")
            break
        else:
            print("Choose a number from 1 to 3.")


main()

How Does the Loop Work?

  • The tasks variable starts as an empty list for the current run.
  • The while True loop displays the menu repeatedly.
  • The choice variable holds the number entered at the prompt.
  • The break statement ends the loop when the choice is 3.
  • The final main() call starts the tracker.

Only the Exit path is ready for this first check. The task actions join the loop in the next two chunks.

Before you run it, which part of the menu do you expect to repeat until you exit?

  • Save task_tracker.py.
  • Run the active file with Python: Run Python File in Terminal.
  • Enter 3 at the choice prompt.

You will see the three numbered choices followed by Goodbye.. You have the first control loop working.

Does the menu fail to start?

Confirm that main() appears below the complete main function. Save the file before running it again.

Ask for help if the terminal points to an indentation or syntax problem: help me fix why my Python menu loop does not start.

Add tasks with dictionaries

Each study task needs related values that stay together. A dictionary stores the task title beside its completion state.

  • Place your cursor on a blank line directly above def main():.
  • Add the task creation function by pasting this code:
def add_task(tasks):
    title = input("Task title: ").strip()
    tasks.append({"title": title, "completed": False})
    print("Task added.")

How Is a Task Stored?

  • The title variable holds the text entered at the prompt.
  • The strip() call removes spaces from the beginning or end of that text.
  • The append() call places a new dictionary at the end of tasks.
  • The completed value starts as False because a new task is still pending.
  • Save task_tracker.py.
  • Run the active file with Python: Run Python File in Terminal.
  • Enter 1 at the choice prompt.
  • Enter Revise Python loops at the title prompt.
  • Enter 3 when the menu returns.

The terminal prints Task added. before returning to the menu. Your tracker can now place a task dictionary inside its list.

Does Add Task cause an error?

Confirm that add_task(tasks) uses the same function name as def add_task(tasks):. Check that the new function sits above main().

Ask for help with the task dictionary: help me troubleshoot why add_task cannot append a task dictionary.

Display friendly task numbers

The list stores tasks in order. The enumerate() function pairs each task with a display number that starts at 1.

  • Place your cursor on a blank line directly below the add_task() function.
  • Add the display function by pasting this code:
def view_tasks(tasks):
    if not tasks:
        print("No tasks yet.")
        return

    print("\nYour study tasks")
    for number, task in enumerate(tasks, start=1):
        status = "x" if task["completed"] else " "
        print("{}. [{}] {}".format(number, status, task["title"]))

How Does the Display Work?

  • The empty-list check prints No tasks yet. when there is nothing to display.
  • The return statement stops the function after that empty-state message.
  • The enumerate(tasks, start=1) call produces task numbers that begin at 1.
  • The status variable uses a blank marker for a pending task.
  • The format() call combines the number with the status marker and title.

Before you run the completed tracker, where do you think the first task will appear in the numbered list?

  • Save task_tracker.py.
  • Run the active file with Python: Run Python File in Terminal.
  • Enter 1 at the choice prompt.
  • Enter Revise Python loops at the title prompt.
  • Enter 2 when the menu returns.

You will see 1. [ ] Revise Python loops under the study task heading. The friendly number comes from enumerate(tasks, start=1).

  • Enter 1 when the menu returns.
  • Enter Review Python dictionaries at the title prompt.
  • Enter 2 to display both study tasks.

You should now see 2. [ ] Review Python dictionaries below the first task. That is your first working task list.

Do the tasks fail to appear?

Confirm that view_tasks(tasks) uses the same tasks list that add_task(tasks) updates. Check that the display function sits above main().

Ask for help if the terminal does not show the numbered task: help me troubleshoot why view_tasks does not display my added task.

✔️ Awesome, I've got everything!

Your menu can add two study tasks and display both with friendly numbers. Double check that you saved task_tracker.py.

ⓧ I'd like to double check the full code

Compare your complete task_tracker.py file with this version.

def show_menu():
    print("\nStudy Task Tracker")
    print("1. Add task")
    print("2. View tasks")
    print("3. Exit")


def add_task(tasks):
    title = input("Task title: ").strip()
    tasks.append({"title": title, "completed": False})
    print("Task added.")


def view_tasks(tasks):
    if not tasks:
        print("No tasks yet.")
        return

    print("\nYour study tasks")
    for number, task in enumerate(tasks, start=1):
        status = "x" if task["completed"] else " "
        print("{}. [{}] {}".format(number, status, task["title"]))


def main():
    tasks = []

    while True:
        show_menu()
        choice = input("Choose an option: ").strip()

        if choice == "1":
            add_task(tasks)
        elif choice == "2":
            view_tasks(tasks)
        elif choice == "3":
            print("Goodbye.")
            break
        else:
            print("Choose a number from 1 to 3.")


main()

Your tracker can now add study tasks and show them in a numbered list. Next, you will complete tasks or remove them from that list.

Complete and Delete Tasks

Your tracker already turns typed titles into a numbered list. This step makes those numbers useful for changing a task.

You'll add one action for completion. You'll add another action for deletion. Once both actions work, you'll restart the program to test whether the current task list survives.

In this step, get ready to:
  • Mark a selected study task as complete.
  • Remove a selected study task from the list.
  • Restart the tracker to test whether its tasks survive.
Add completion and deletion functions

The displayed task number gives the learner a friendly way to choose a dictionary. Your new functions convert that number into a list index before changing the selected task.

  • Add both task actions to task_tracker.py by placing this code between view_tasks() and main():
def complete_task(tasks):
    view_tasks(tasks)
    selection = int(input("Task number to complete: "))
    index = selection - 1
    tasks[index]["completed"] = True
    print("Task completed.")


def delete_task(tasks):
    view_tasks(tasks)
    selection = int(input("Task number to delete: "))
    index = selection - 1
    removed_task = tasks.pop(index)
    print("Deleted: {}".format(removed_task["title"]))

What do these functions do?

  • The selection variable holds the friendly task number entered in the terminal.
  • The index variable subtracts one because Python list positions begin at zero.
  • The completion function changes the selected dictionary's completed value to True.
  • The deletion function uses pop() to remove the selected dictionary.
  • Save task_tracker.py.
  • Run task_tracker.py with Python: Run Python File in Terminal.
  • Enter 3 at the menu prompt to exit the current three-choice version.

Your three-choice menu still appears. That confirms Python can define both new functions before main() starts.

Does the tracker stop before the menu?

Check that both new functions sit above main(). Keep every indented line inside its matching function.

Compare the brackets around "completed" with the code block.

Help me fix my complete_task and delete_task functions.

Connect the new menu choices

The menu loop decides which function runs for each number. Updating both control points makes the new task actions reachable from the terminal.

  • Replace show_menu() in task_tracker.py with the first function below.
  • Replace main() with the second function below.
def show_menu():
    print("\nStudy Task Tracker")
    print("1. Add task")
    print("2. View tasks")
    print("3. Complete task")
    print("4. Delete task")
    print("5. Exit")


def main():
    tasks = []

    while True:
        show_menu()
        choice = input("Choose an option: ").strip()

        if choice == "1":
            add_task(tasks)
        elif choice == "2":
            view_tasks(tasks)
        elif choice == "3":
            complete_task(tasks)
        elif choice == "4":
            delete_task(tasks)
        elif choice == "5":
            print("Goodbye.")
            break
        else:
            print("Choose a number from 1 to 5.")

How does the menu reach each action?

  • The updated show_menu() displays five numbered choices.
  • Choice 3 passes the current task list to complete_task(tasks).
  • Choice 4 passes the same list to delete_task(tasks).
  • Choice 5 now exits the loop.
  • Save task_tracker.py.
  • Run the updated tracker with Python: Run Python File in Terminal.
  • Enter 1 at the menu prompt.
  • Enter Revise Python loops as the task title.
  • Enter 1 at the menu prompt again.
  • Enter Practise list methods as the second task title.
  • Enter 3 at the menu prompt.
  • Enter 1 as the task number to complete.

The terminal prints Task completed..

  • Enter 2 at the menu prompt to view the changed list.

You'll see 1. [x] Revise Python loops above the pending second task.

  • Enter 4 at the menu prompt.
  • Enter 2 as the task number to delete.

The terminal prints Deleted: Practise list methods.

  • Enter 2 at the menu prompt to view the remaining task.

You now have direct control over each task's status. The list contains only the completed Revise Python loops task.

Did the wrong task change?

Enter one of the numbers currently displayed by view_tasks(). The function subtracts one before accessing the list.

Check that choice 3 calls complete_task(tasks). Check that choice 4 calls delete_task(tasks).

Help me troubleshoot an incorrect task selection.

Test what survives a restart

The tracker has one completed task left in its current run. Restarting now tests whether that task remains available to a new run.

Before you restart it, predict whether the completed task will still appear.

  • Enter 5 at the menu prompt to stop the current run.
  • Run task_tracker.py again with Python: Run Python File in Terminal.
  • Enter 2 at the menu prompt to view the tasks.

The terminal displays No tasks yet. This is the intended shortfall in the in-memory tracker.

Why did every task disappear?

Each call to main() creates a new empty list with tasks = [].

The completed task existed only inside the previous running program. Ending that run discarded the list.

✔️ Awesome, I've got everything!

Great. Double check that you saved task_tracker.py after connecting all five menu choices.

ⓧ I'd like to double check the full code

Compare your complete task_tracker.py file with this version.

def show_menu():
    print("\nStudy Task Tracker")
    print("1. Add task")
    print("2. View tasks")
    print("3. Complete task")
    print("4. Delete task")
    print("5. Exit")


def add_task(tasks):
    title = input("Task title: ").strip()
    tasks.append({"title": title, "completed": False})
    print("Task added.")


def view_tasks(tasks):
    if not tasks:
        print("No tasks yet.")
        return

    print("\nYour study tasks")
    for number, task in enumerate(tasks, start=1):
        status = "x" if task["completed"] else " "
        print("{}. [{}] {}".format(number, status, task["title"]))


def complete_task(tasks):
    view_tasks(tasks)
    selection = int(input("Task number to complete: "))
    index = selection - 1
    tasks[index]["completed"] = True
    print("Task completed.")


def delete_task(tasks):
    view_tasks(tasks)
    selection = int(input("Task number to delete: "))
    index = selection - 1
    removed_task = tasks.pop(index)
    print("Deleted: {}".format(removed_task["title"]))


def main():
    tasks = []

    while True:
        show_menu()
        choice = input("Choose an option: ").strip()

        if choice == "1":
            add_task(tasks)
        elif choice == "2":
            view_tasks(tasks)
        elif choice == "3":
            complete_task(tasks)
        elif choice == "4":
            delete_task(tasks)
        elif choice == "5":
            print("Goodbye.")
            break
        else:
            print("Choose a number from 1 to 5.")


main()

Your completion and deletion actions now work inside a single run. Next up, you'll give the tracker durable storage so those changes can survive a restart.

Save Tasks with JSON

Your tracker now changes tasks during a session. Restarting the program still wipes every task from memory.

This step uses JSON to save the task list in a local file. The tracker can load that file during its next run.

In this step, get ready to:
  • Write the task list to an indented JSON file.
  • Load saved tasks when the tracker starts.
  • Persist every successful task mutation.
Save new tasks to JSON

JSON serialization turns your Python task list into text that can live on disk. Python's json module handles that conversion.

  • In task_tracker.py, add the storage setup above def show_menu(): by pasting this code:
import json

DATA_FILE = "tasks.json"


def save_tasks(tasks):
    with open(DATA_FILE, "w", encoding="utf-8") as file:
        json.dump(tasks, file, indent=2)

How does JSON storage work?

  • The json module converts the task list into JSON text.
  • The DATA_FILE constant keeps the filename in one place.
  • The save_tasks(tasks) function opens tasks.json in writing mode with encoding="utf-8".
  • The indent=2 setting makes the saved file easy to read.

The storage function exists now. The add action needs to call it after placing a new dictionary in the list.

  • In task_tracker.py, find the current add_task(tasks) function:
def add_task(tasks):
    title = input("Task title: ").strip()
    tasks.append({"title": title, "completed": False})
    print("Task added.")

Where is persistence missing?

This version appends the new dictionary to the in-memory list. Closing the program removes that list.

  • Replace the current function with this saved version:
def add_task(tasks):
    title = input("Task title: ").strip()
    tasks.append({"title": title, "completed": False})
    save_tasks(tasks)
    print("Task added.")

What changed?

The call to save_tasks(tasks) runs immediately after the list changes. Each successful addition now updates tasks.json.

  • Save task_tracker.py.
  • Run the active file with Python: Run Python File in Terminal.
  • Choose 1 from the menu.
  • Enter Practise Python functions as the task title.
  • Choose 5 to exit.
  • Select tasks.json in the Explorer sidebar.

You should see an indented JSON array containing the new task dictionary. That task now exists outside the running program.

Do not see tasks.json?

  • Confirm that save_tasks(tasks) appears directly after tasks.append(...) in add_task(tasks).
  • Confirm that you saved task_tracker.py before running it.

Ask for help with the storage path if the file is still missing: help me find out why tasks.json is not being created by my Python task tracker

Load tasks when the tracker starts

Saving protects the task list after a change. Loading restores that saved list before the menu begins.

  • In task_tracker.py, add this function below DATA_FILE = "tasks.json" and above save_tasks(tasks):
def load_tasks():
    try:
        with open(DATA_FILE, "r", encoding="utf-8") as file:
            return json.load(file)
    except FileNotFoundError:
        return []

What does this code do?

  • The load_tasks() function reads the JSON data from tasks.json.
  • The json.load call converts the stored JSON array back into a Python list.
  • The FileNotFoundError handler returns an empty list when the tracker has no saved file.

The loader is ready. The main() function still creates a fresh empty list on every run.

  • In main(), find this line:
    tasks = []

Why does this erase the session?

This line creates a new empty list whenever main() starts. It ignores the data already stored in tasks.json.

  • Replace the empty-list line with this loader call:
    tasks = load_tasks()

How does startup change?

The menu now begins with the list returned by load_tasks(). A missing file still produces a valid empty list.

  • Save task_tracker.py.

Before you restart the tracker, do you expect the task from the previous run to return?

  • Run the active file with Python: Run Python File in Terminal.
  • Choose 2 to view the task list.

You should see 1. [ ] Practise Python functions. The tracker has restored its first saved task.

Is the saved task missing?

  • Confirm that main() uses tasks = load_tasks().
  • Confirm that tasks.json sits beside task_tracker.py in the study-task-tracker workspace.

Ask for help if the tracker still starts empty: help me troubleshoot why load_tasks is not restoring my saved task

Persist every successful change

Loading restores the latest saved list. Completion or deletion must also update the file whenever the in-memory list changes.

  • In complete_task(tasks), find these lines:
    tasks[index]["completed"] = True
    print("Task completed.")

What happens to completed tasks now?

The completed value changes in memory. The saved dictionary still holds its previous value.

  • Replace those lines with this saved version:
    tasks[index]["completed"] = True
    save_tasks(tasks)
    print("Task completed.")

How is completion saved?

The file update follows the successful change to completed. A restarted tracker can now display the completed marker.

  • In delete_task(tasks), find these lines:
    removed_task = tasks.pop(index)
    print("Deleted: {}".format(removed_task["title"]))

What happens to deleted tasks now?

The selected dictionary leaves the in-memory list. The stored list still includes that dictionary.

  • Replace those lines with this saved version:
    removed_task = tasks.pop(index)
    save_tasks(tasks)
    print("Deleted: {}".format(removed_task["title"]))

How is deletion saved?

The file update runs after tasks.pop(index) removes the selected dictionary. The next run receives the shorter list.

✔️ Awesome, I've got everything!

Great. Double check that you have saved task_tracker.py in VS Code.

ⓧ I'd like to double check the full code

Compare your complete task_tracker.py file with this version.

import json

DATA_FILE = "tasks.json"


def load_tasks():
    try:
        with open(DATA_FILE, "r", encoding="utf-8") as file:
            return json.load(file)
    except FileNotFoundError:
        return []


def save_tasks(tasks):
    with open(DATA_FILE, "w", encoding="utf-8") as file:
        json.dump(tasks, file, indent=2)


def show_menu():
    print("\nStudy Task Tracker")
    print("1. Add task")
    print("2. View tasks")
    print("3. Complete task")
    print("4. Delete task")
    print("5. Exit")


def add_task(tasks):
    title = input("Task title: ").strip()
    tasks.append({"title": title, "completed": False})
    save_tasks(tasks)
    print("Task added.")


def view_tasks(tasks):
    if not tasks:
        print("No tasks yet.")
        return

    print("\nYour study tasks")
    for number, task in enumerate(tasks, start=1):
        status = "x" if task["completed"] else " "
        print("{}. [{}] {}".format(number, status, task["title"]))


def complete_task(tasks):
    view_tasks(tasks)
    selection = int(input("Task number to complete: "))
    index = selection - 1
    tasks[index]["completed"] = True
    save_tasks(tasks)
    print("Task completed.")


def delete_task(tasks):
    view_tasks(tasks)
    selection = int(input("Task number to delete: "))
    index = selection - 1
    removed_task = tasks.pop(index)
    save_tasks(tasks)
    print("Deleted: {}".format(removed_task["title"]))


def main():
    tasks = load_tasks()

    while True:
        show_menu()
        choice = input("Choose an option: ").strip()

        if choice == "1":
            add_task(tasks)
        elif choice == "2":
            view_tasks(tasks)
        elif choice == "3":
            complete_task(tasks)
        elif choice == "4":
            delete_task(tasks)
        elif choice == "5":
            print("Goodbye.")
            break
        else:
            print("Choose a number from 1 to 5.")


main()

The final check proves that each mutation reaches the same JSON file. You will test addition first.

  • Save task_tracker.py.
  • Run the active file with Python: Run Python File in Terminal.
  • Choose 1 from the menu.
  • Enter Practise Python functions if the task list is empty.
  • Choose 3 from the menu.
  • Enter 1 as the task number.
  • Choose 5 to exit.

Before you start the tracker again, do you expect task number 1 to keep its completed marker?

  • Run the active file with Python: Run Python File in Terminal.
  • Choose 2 to view the task list.
  • Select tasks.json in the Explorer sidebar.

You should see 1. [x] Practise Python functions in the terminal. The file should contain the same completed task.

  • Choose 4 from the menu.
  • Enter 1 as the task number.
  • Choose 5 to exit.

Before the last restart, do you expect the deleted task to return?

  • Run the active file with Python: Run Python File in Terminal.
  • Choose 2 to view the task list.

You should see No tasks yet. The deletion survived the restart.

Does a changed task return to its old state?

  • Confirm that complete_task(tasks) calls save_tasks(tasks) after changing the completed value.
  • Confirm that delete_task(tasks) calls save_tasks(tasks) after removing the dictionary.

Ask for help if the file keeps an older state: help me troubleshoot why my JSON file is not updating after task changes

You have turned the temporary tracker into a persistent program. Next, you will protect it from empty titles and invalid task selections.

Validate Task Input

Your tracker in VS Code now saves tasks in a JSON file. That persistence keeps your study list available across sessions.

One brittle input can still end a session with a traceback. You will validate every value that enters the tracker so bad input returns you to the menu with a clear message.

In this step, get ready to:
  • Expose the current crash caused by a non-numeric task selection.
  • Validate task numbers before completion or deletion.
  • Reject unusable task titles and file data.
Test and protect task-number input

The current completion code passes user input straight into int(). A word such as abc cannot be converted to an integer.

Before you run the current tracker, predict whether it can recover after receiving letters for a task number.

  • Run task_tracker.py with Python: Run Python File in Terminal.
  • Enter 1 at the menu.
  • Enter Revise Python loops as the task title.
  • Enter 3 at the menu.
  • Enter abc as the task number.

You will see a traceback caused by a ValueError. This crash is the intended shortfall because the tracker currently assumes every task selection is numeric.

A shared helper can check task selections before either action changes the list. It returns a valid list index or None when the action should stop.

  • Return to task_tracker.py from earlier.
  • Find def complete_task(tasks):.
  • Place the following function directly above it:
def choose_task(tasks, prompt):
    if not tasks:
        print("No tasks available.")
        return None

    try:
        selection = int(input(prompt))
    except ValueError:
        print("Enter a number from the task list.")
        return None

    index = selection - 1
    if index < 0 or index >= len(tasks):
        print("That task number does not exist.")
        return None

    return index

What Does This Function Do?

  • The first check stops the action when there are no tasks to select.
  • The try block attempts to convert the learner's response into a number.
  • The except ValueError block handles letters without ending the program.
  • The range check prevents negative or oversized indexes from reaching the task list.
  • Save task_tracker.py.
  • Run task_tracker.py with Python: Run Python File in Terminal.
  • Enter 5 to exit after the menu loads.

You will see the numbered menu before the program exits. That confirms the new helper has valid Python syntax.

Tracker Does Not Reach the Menu?

Check that def choose_task(tasks, prompt): begins at the left edge of the file. Its nested lines must keep the same indentation shown above.

Compare the parentheses around int(input(prompt)) carefully.

Help me fix the new choose_task function.

The helper only protects an action after that action uses it. Completion now needs to stop when choose_task() returns None.

  • Find def complete_task(tasks):.
  • Replace the entire function with the following version:
def complete_task(tasks):
    view_tasks(tasks)
    index = choose_task(tasks, "Task number to complete: ")
    if index is None:
        return

    tasks[index]["completed"] = True
    save_tasks(tasks)
    print("Task completed.")

How Completion Uses the Helper

The function receives a checked index from choose_task(). An invalid response produces None, so return prevents any task from changing.

  • Save task_tracker.py.

Before you test completion, predict whether abc will still end the tracker.

  • Run task_tracker.py with Python: Run Python File in Terminal.
  • Enter 3 at the menu.
  • Enter abc as the task number.

You will see Enter a number from the task list. before the menu returns. The tracker now absorbs the same input that previously caused a traceback.

Completion Still Crashes?

Make sure complete_task() calls choose_task(tasks, "Task number to complete: ") instead of calling int() directly.

Check that if index is None: appears before the task dictionary is updated.

Help me stop invalid completion input from crashing the tracker.

Deletion needs the same guarded index. Reusing the helper keeps both task actions consistent.

  • Enter 5 at the menu to exit the running tracker.
  • Find def delete_task(tasks): in task_tracker.py.
  • Replace the entire function with the following version:
def delete_task(tasks):
    view_tasks(tasks)
    index = choose_task(tasks, "Task number to delete: ")
    if index is None:
        return

    removed_task = tasks.pop(index)
    save_tasks(tasks)
    print("Deleted: {}".format(removed_task["title"]))

How Deletion Stays Safe

Deletion receives its index from the same validation path as completion. pop() only runs after the helper confirms that the selected position exists.

  • Save task_tracker.py.
  • Run task_tracker.py with Python: Run Python File in Terminal.
  • Enter 4 at the menu.
  • Enter 99 as the task number.

You will see That task number does not exist. before the menu returns. No task is removed.

A Task Was Deleted Unexpectedly?

Check that delete_task() calls choose_task() before tasks.pop(index).

Confirm that the range check rejects indexes below 0 or indexes greater than or equal to len(tasks).

Help me troubleshoot an unexpected task deletion.

Reject blank task titles

A blank title does not crash the tracker, but it creates a task with no useful description. The stripped title can be checked before the dictionary reaches the list.

  • Enter 5 at the menu to exit the running tracker.
  • Find def add_task(tasks): in task_tracker.py.
  • Replace the entire function with the following version:
def add_task(tasks):
    title = input("Task title: ").strip()
    if not title:
        print("A task title cannot be empty.")
        return

    tasks.append({"title": title, "completed": False})
    save_tasks(tasks)
    print("Task added.")

Why Check the Stripped Title?

The strip() call removes surrounding whitespace. The condition catches an empty string before append() changes the list or save_tasks() writes the file.

  • Save task_tracker.py.

Before you test the new check, predict whether pressing Enter on an empty title will create a task.

  • Run task_tracker.py with Python: Run Python File in Terminal.
  • Enter 1 at the menu.
  • Press Enter without typing a title.

You will see A task title cannot be empty. before the menu returns. The task list remains unchanged.

Blank Task Still Appears?

Check that the empty-title condition appears before tasks.append().

Confirm that return is indented inside if not title:.

Help me stop blank titles from being added.

Validate loaded JSON

The file loader currently trusts every value returned by json.load(). Damaged data can raise an exception, while valid JSON with the wrong top-level type can break later task operations.

  • Enter 5 at the menu to exit the running tracker.
  • Find def load_tasks(): in task_tracker.py.
  • Replace the entire function with the following version:
def load_tasks():
    try:
        with open(DATA_FILE, "r", encoding="utf-8") as file:
            tasks = json.load(file)
            if isinstance(tasks, list):
                return tasks
            print("The data file did not contain a task list. Starting fresh.")
    except FileNotFoundError:
        return []
    except json.JSONDecodeError:
        print("The data file is not valid JSON. Starting fresh.")
    return []

How Loading Recovers

  • A missing file still returns an empty list because FileNotFoundError is handled separately.
  • Invalid file syntax is caught as json.JSONDecodeError.
  • Valid decoded data only reaches the menu when isinstance(tasks, list) confirms the expected top-level type.
  • Every rejected value falls through to the final empty list.
  • Save task_tracker.py.

Editing the data file by hand is a little fiddly.

  • Select tasks.json in the Explorer sidebar.
  • Press Ctrl+A to select its contents.
  • Press Ctrl+C to copy the valid JSON.
  • Replace the selected contents with not valid JSON.
  • Save tasks.json.

Before you run the tracker, predict whether the damaged file will produce a traceback or a controlled message.

  • Return to task_tracker.py.
  • Run the file with Python: Run Python File in Terminal.

You will see The data file is not valid JSON. Starting fresh. before the menu loads. No traceback appears.

Invalid JSON Still Causes a Traceback?

Check that except json.JSONDecodeError: aligns with except FileNotFoundError:.

Confirm that the final return [] sits outside both exception blocks.

Help me catch invalid JSON correctly.

The next check uses valid JSON with the wrong shape. An empty object decodes successfully, but the tracker requires a list of task dictionaries.

  • Enter 5 at the menu to exit the tracker.
  • Return to tasks.json.
  • Replace its contents with {}.
  • Save tasks.json.
  • Return to task_tracker.py.
  • Run the file with Python: Run Python File in Terminal.

You will see The data file did not contain a task list. Starting fresh. before the menu loads. The top-level type check prevented the object from becoming the task collection.

  • Enter 5 at the menu to exit the tracker.
  • Return to tasks.json.
  • Press Ctrl+A to select the test object.
  • Press Ctrl+V to restore the copied task list.
  • Save tasks.json.

Before the final run, predict whether the restored list will load after every validation change.

  • Return to task_tracker.py.
  • Run the file with Python: Run Python File in Terminal.
  • Enter 2 at the menu.

You will see your restored numbered task list. You have now handled blank titles, letters, oversized task numbers, invalid JSON, and valid JSON with the wrong shape without a traceback.

✔️ Awesome, I've got everything!

Great work. Double check that you saved task_tracker.py and restored valid list data in tasks.json.

ⓧ I'd like to double check the full code

Compare your complete task_tracker.py file with this version.

import json

DATA_FILE = "tasks.json"


def load_tasks():
    try:
        with open(DATA_FILE, "r", encoding="utf-8") as file:
            tasks = json.load(file)
            if isinstance(tasks, list):
                return tasks
            print("The data file did not contain a task list. Starting fresh.")
    except FileNotFoundError:
        return []
    except json.JSONDecodeError:
        print("The data file is not valid JSON. Starting fresh.")
    return []


def save_tasks(tasks):
    with open(DATA_FILE, "w", encoding="utf-8") as file:
        json.dump(tasks, file, indent=2)


def show_menu():
    print("\nStudy Task Tracker")
    print("1. Add task")
    print("2. View tasks")
    print("3. Complete task")
    print("4. Delete task")
    print("5. Exit")


def add_task(tasks):
    title = input("Task title: ").strip()
    if not title:
        print("A task title cannot be empty.")
        return

    tasks.append({"title": title, "completed": False})
    save_tasks(tasks)
    print("Task added.")


def view_tasks(tasks):
    if not tasks:
        print("No tasks yet.")
        return

    print("\nYour study tasks")
    for number, task in enumerate(tasks, start=1):
        status = "x" if task["completed"] else " "
        print("{}. [{}] {}".format(number, status, task["title"]))


def choose_task(tasks, prompt):
    if not tasks:
        print("No tasks available.")
        return None

    try:
        selection = int(input(prompt))
    except ValueError:
        print("Enter a number from the task list.")
        return None

    index = selection - 1
    if index < 0 or index >= len(tasks):
        print("That task number does not exist.")
        return None

    return index


def complete_task(tasks):
    view_tasks(tasks)
    index = choose_task(tasks, "Task number to complete: ")
    if index is None:
        return

    tasks[index]["completed"] = True
    save_tasks(tasks)
    print("Task completed.")


def delete_task(tasks):
    view_tasks(tasks)
    index = choose_task(tasks, "Task number to delete: ")
    if index is None:
        return

    removed_task = tasks.pop(index)
    save_tasks(tasks)
    print("Deleted: {}".format(removed_task["title"]))


def main():
    tasks = load_tasks()

    while True:
        show_menu()
        choice = input("Choose an option: ").strip()

        if choice == "1":
            add_task(tasks)
        elif choice == "2":
            view_tasks(tasks)
        elif choice == "3":
            complete_task(tasks)
        elif choice == "4":
            delete_task(tasks)
        elif choice == "5":
            print("Goodbye.")
            break
        else:
            print("Choose a number from 1 to 5.")


main()

How the Validation Fits Together

The loader protects the program boundary where file data enters. add_task() and choose_task() protect the boundary where user input enters.

Successful mutations still pass through save_tasks(), so the validation does not weaken persistence.

That is the reliability layer complete. Your tracker now responds to common input mistakes with guidance while keeping the menu available.

Secret mission

Add Priority Sorting

Some study tasks deserve attention before others. Add High, Medium, and Low priorities so the tracker displays urgent work first while preserving completion, deletion, and file persistence.

Clean Up Your Resources

Clean Up Your Resources

The tracker runs entirely on your Windows computer. It has no ongoing costs.

Choose whether to keep the tracker ready, pause your work, or remove its local resources.

Resources you used:

  • The study-task-tracker VS Code workspace folder. It contains task_tracker.py. It also contains the generated tasks.json file.
  • The Python extension for Visual Studio Code published by Microsoft (ms-python.python).

Keep everything running

Leave the current setup in place if you want to keep using the tracker or experiment with its priority system.

  • Keep the study-task-tracker folder with your script and saved tasks.
  • Keep the Python extension for Visual Studio Code installed for future Python projects.

Pause - I'll come back to this later

Pause the tracker to free your terminal while preserving every task. Your local files remain unchanged.

  • Return to the VS Code terminal from earlier if the tracker is still running.
  • Enter 5 at the tracker menu to stop the program.
  • Close the terminal panel.
  • Close VS Code.
  • Leave the study-task-tracker folder unchanged for your next session.

Delete - I don't want to use this again

Remove the project resources if you want to start fresh. Your tracker data disappears when you delete its folder.

The Python extension remains useful for other Python projects. Remove it only if you do not plan to continue using Python in VS Code.

  • Switch back to the VS Code workspace from earlier.
  • Press Ctrl+Shift+X to open the Extensions view.
  • Search your installed extensions for ms-python.python.
  • Select the Python extension for Visual Studio Code published by Microsoft.
  • Click Uninstall.
  • Confirm that the extension no longer appears as installed.

Deleting the folder removes only this project's local files. Your other Python work stays in place.

  • Close VS Code.
  • Press the Windows key to open Windows search.
  • Enter File Explorer in the search field.
  • Press Enter.
  • Locate the study-task-tracker folder that was open as your VS Code workspace.
  • Right-click the study-task-tracker folder.
  • Select Delete.
  • Confirm that the study-task-tracker folder no longer appears in its previous location.

Nice Work!

Nice Work!

Mission complete! You built a persistent study task tracker with Python. It now manages real tasks without losing them between sessions.

You learned how to:

  • Built a menu-driven application from focused functions inside a continuous loop. Used conditions to route every menu choice.
  • Modelled tasks with a list of dictionaries. Used friendly task numbers to complete or delete the correct dictionary.
  • Added file persistence through a readable JSON file named tasks.json. Protected the tracker from empty titles. Handled invalid task numbers. Recovered safely from missing or malformed data.
  • Secret Mission: Added High, Medium, and Low priorities. Sorted the display with PRIORITY_RANK. Preserved the original task numbers for completion and deletion.

Ready to quiz yourself?