Build a Canvas-to-Calendar Planner
Connect Canvas LMS to Google Calendar with a Python CLI scheduling tool.
Introduction
30 Second Summary
Every student knows the panic of a deadline sneaking up on them. What if your calendar was smart enough to schedule study time for every assignment, so you never had to cram again?
In this project, you will use Claude Code to build a Python command-line tool that pulls your real assignments from Canvas LMS at UT Austin. The tool classifies each assignment, estimates how long it will take, and schedules study blocks into your Google Calendar before each deadline.
What You'll Build
A Python command-line academic planner that bridges the Canvas LMS REST API and Google Calendar API to automatically generate a backward-scheduled study plan from your real assignments.
By the end of this project, you'll have:
- A Canvas API client that fetches your real courses and upcoming assignments.
- A keyword-based classifier that categorizes each assignment by type and estimates study time.
- A backward-scheduling engine that creates study blocks in Google Calendar, respecting your working hours and avoiding conflicts.
- In the Secret Mission, AI-powered classification using the Anthropic API for smarter time estimates and automatic sub-task breakdown.
Are there any prerequisites?
You should be comfortable with basic Python syntax like variables, functions, and loops. You will also need a Claude Pro subscription ($20/month) for Claude Code, a UT Austin Canvas account, and a Google account.
Install Developer Tools
Your academic planner is a Python project that connects Canvas LMS to Google Calendar. To build it, you'll use Claude Code, an AI coding assistant that runs right in your terminal.
Before writing any code, you need three tools installed and working on your machine. Getting this right now prevents confusing errors later.
In this step, get ready to:
- Check and update Python to version 3.10.7 or higher.
- Install Git for Windows.
- Install Claude Code and sign in with your Claude Pro account.
Check and update Python
Google's Calendar API library requires Python 3.10.7 or higher. Let's check what version you have.
- Press the Windows key to open your search bar.
- Type PowerShell and press Enter to open it.
- Check your Python version by running this command:
python --version
I see 3.10 or higher
Python is ready to go. Move on to the next substep.
I see an older version or it's not installed
- Open the Python downloads page in your browser.
- Click the large Download Python button to download the latest installer.
- Run the downloaded installer.
- On the first screen of the installer, check the box labeled Add python.exe to PATH.
- Click Install Now and wait for the installation to finish.
Why Add python.exe to PATH?
Adding Python to your PATH lets you run python from any terminal window. Without it, your terminal won't recognize the command.
- Close and reopen PowerShell so it picks up the new PATH.
- Verify the installation by running this command:
python --version
You should see a version number of 3.10 or higher printed in the terminal.
Still seeing the old version?
- Make sure you checked Add python.exe to PATH during installation. If you missed it, re-run the installer, select Modify, and check the PATH option.
- Make sure you fully closed and reopened PowerShell after installing. Existing windows keep the old PATH.
- Try running py --version instead. Some Windows installations use py as the command name.
Help me troubleshoot my Python installation on Windows.
Install Git for Windows
Claude Code requires Git to manage your project files. Let's check if Git is already installed on your machine.
- Check if Git is already installed by running this command:
git --version
I see a version number
Git is ready to go. Move on to the next substep.
Command not found
- Open the Git for Windows download page in your browser.
- Click the 64-bit Git for Windows Setup link to download the installer.
- Run the downloaded installer.
- Accept the default settings on each screen by clicking Next.
- Make sure the Git from the command line and also from 3rd-party software option is selected on the PATH environment screen. This is the default.
- Click Install on the final screen and wait for it to finish.
- Close and reopen PowerShell.
- Verify the installation by running this command:
git --version
You should see a version number like git version 2.47.1.windows.1 printed in the terminal.
Still seeing 'command not found'?
- Make sure you fully closed and reopened PowerShell after the Git installation finished.
- Confirm that the PATH option was selected during installation. If you skipped it, re-run the installer and select Git from the command line and also from 3rd-party software.
Help me troubleshoot my Git for Windows installation.
Install Claude Code and sign in
Claude Code is an AI coding assistant that lives in your terminal. You'll use it throughout this project to write, test, and debug your Python code.
Claude Pro subscription required
Claude Code requires an active Claude Pro subscription ($20/month). If you don't have one yet, sign up at claude.ai before continuing.
- Install Claude Code by running this command in PowerShell:
irm https://claude.ai/install.ps1 | iex
What does this command do?
- irm downloads the Claude Code installer script from the web.
- iex runs the downloaded script in your PowerShell session to install Claude Code.
- Close and reopen PowerShell after the installation finishes.
- Verify the installation by running this command:
claude --version
You should see a version number printed in the terminal.
Claude command not recognized?
- Make sure you fully closed and reopened PowerShell after the installation.
- Make sure Git for Windows is installed first. Claude Code requires Git to run.
- Try running the install command again. If PowerShell blocks the script, you may need to run Set-ExecutionPolicy RemoteSigned -Scope CurrentUser first.
Help me troubleshoot my Claude Code installation on Windows.
Now let's create your project folder and start Claude Code inside it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir academic-planner
cd academic-planner
- Confirm you're in the right folder by running:
pwd
You should see a path ending in academic-planner.
- Start Claude Code by running:
claude
On first launch, Claude Code will prompt you to sign in. A browser window will open automatically.
- Follow the sign-in flow in your browser using your Claude Pro account credentials.
- Once authorized, return to your PowerShell window.
You should see the Claude Code prompt appear in your terminal, ready to accept instructions.
Sign-in not working?
- Make sure you have an active Claude Pro subscription. Claude Code does not work with the free Claude plan.
- If the browser window did not open, copy the URL shown in the terminal and paste it into your browser manually.
- Make sure you are signing in with the same account that has the Claude Pro subscription.
Help me troubleshoot my Claude Code sign-in.
Your development environment is ready. Next up, you'll connect to Canvas LMS and fetch your real assignments.
Connect to Canvas LMS
Your development environment is ready with Python, Git, and Claude Code installed. Now it's time to connect to the data source that powers your entire planner: your real assignment data from Canvas LMS.
Canvas has a REST API that lets you pull your courses, assignments, and due dates programmatically. In this step, you'll generate an access token, set up your project files, and build a Python client that fetches your real upcoming assignments.
In this step, get ready to:
- Generate a Canvas API access token from your UT Austin account.
- Create the project configuration files and install Python dependencies.
- Build and test a Canvas client that fetches your real assignments.
Generate your Canvas API access token
Canvas uses access tokens to let external programs read your account data. You'll create a personal access token that your Python script uses to authenticate with the Canvas API.
- Open https://utexas.instructure.com/profile/settings in your browser.
- Make sure you are logged into your UT Austin Canvas account.
- Scroll down to the Approved Integrations section.
- Click + New Access Token.
- Enter Academic Planner as the purpose.
- Leave the expiration field blank.
- Click Generate Token.
- Copy the token immediately and save it somewhere safe.
You can only see this token once
Canvas shows the token exactly once at creation time. If you close the dialog without copying it, you'll need to delete the token and create a new one.
Don't see the Approved Integrations section?
Some UT Austin accounts may have API token generation restricted by institutional policy. Contact UT Austin IT support to request Canvas API access.
Help me get access to the Canvas API at my university.
Create the project and install dependencies
Your project needs two configuration files before you can start coding. The .env file stores your Canvas credentials, and requirements.txt lists the Python packages your project depends on.
- Create a .env file in your project folder with the following content:
CANVAS_API_TOKEN=your-canvas-api-token-here
CANVAS_BASE_URL=https://utexas.instructure.com
- Replace your-canvas-api-token-here with the Canvas API token you copied in the previous substep.
What does this file do?
- CANVAS_API_TOKEN stores your personal Canvas access token so the script can authenticate with the Canvas API.
- CANVAS_BASE_URL sets the Canvas instance URL for UT Austin.
The .env file keeps secrets out of your source code. Never commit this file to version control.
Pro tip: protect your secrets
Ask Claude Code to create a .gitignore file and add .env and token.json to it. This prevents accidentally pushing your credentials to a repository.
You'll verify this connection works when you run the Canvas client later in this step.
- Create a requirements.txt file with the following content:
requests==2.32.3
google-api-python-client==2.166.0
google-auth-httplib2==0.2.0
google-auth-oauthlib==1.2.1
python-dateutil==2.9.0.post0
beautifulsoup4==4.13.4
python-dotenv==1.1.0
What are these packages?
- requests handles HTTP calls to the Canvas API.
- google-api-python-client, google-auth-httplib2, and google-auth-oauthlib connect to Google Calendar (used in the next step).
- python-dateutil parses date strings from Canvas.
- beautifulsoup4 strips HTML tags from assignment descriptions.
- python-dotenv loads your .env file into Python.
- Install all the packages by running this command:
pip install -r requirements.txt
- You should see each package download and install. Look for Successfully installed at the end of the output.
Seeing a pip error?
- Make sure you're running the command from your project folder (academic-planner).
- If you see pip is not recognized, try running python -m pip install -r requirements.txt instead.
- If a specific package version can't be found, remove the version pin (e.g. change requests==2.32.3 to just requests) and try again.
Help me fix my pip installation error.
Build and test the Canvas client
The Canvas client is the foundation of your academic planner. It connects to the Canvas API, fetches your active courses, and collects all upcoming assignments with their due dates and details.
- In your Claude Code session, ask Claude Code to create canvas_client.py by sending this prompt:
Create a canvas_client.py file that reads CANVAS_API_TOKEN and CANVAS_BASE_URL from a .env file using python-dotenv. It should have these functions:
1. get_active_courses() - calls GET /api/v1/courses with enrollment_state=active and per_page=50. Returns a list of dicts with id, name, and course_code.
2. get_assignments(course_id, course_name, course_code) - calls GET /api/v1/courses/{course_id}/assignments with order_by=due_at and per_page=100. Filters to only assignments with a future due_at date. Returns a list of dicts with: id, name, description, due_at, due_date (parsed datetime), points_possible, submission_types, course_name, course_code, html_url.
3. fetch_all_assignments() - loops through all active courses, collects assignments from each, skips courses that return HTTP errors, and sorts everything by due_date.
Add a __main__ block that prints each assignment's course code, name, due date, and points. Use the requests library with Bearer token auth.
What does this code do?
- get_active_courses() calls the Canvas API to list all courses you're currently enrolled in.
- get_assignments() fetches assignments for a single course and filters out anything that's already past due.
- fetch_all_assignments() loops through every course, collects upcoming assignments, and sorts them by due date.
- The __main__ block at the bottom lets you run the file directly to test the connection.
Claude Code generates the file and saves it to your project folder. Take a moment to read through the code it produced.
Awesome, I've got everything!
Great. Double check you've saved canvas_client.py in your academic-planner folder.
I'd like to double check the full code
Here's the complete canvas_client.py file. Compare it against what Claude Code generated and update any differences.
import os
import requests
from datetime import datetime, timezone
from dotenv import load_dotenv
load_dotenv()
CANVAS_API_TOKEN = os.getenv("CANVAS_API_TOKEN")
CANVAS_BASE_URL = os.getenv("CANVAS_BASE_URL", "https://utexas.instructure.com")
HEADERS = {"Authorization": f"Bearer {CANVAS_API_TOKEN}"}
def get_active_courses():
url = f"{CANVAS_BASE_URL}/api/v1/courses"
params = {"enrollment_state": "active", "per_page": 50}
response = requests.get(url, headers=HEADERS, params=params)
response.raise_for_status()
courses = response.json()
return [
{"id": c["id"], "name": c.get("name", "Unknown Course"), "code": c.get("course_code", "")}
for c in courses
if isinstance(c, dict) and "id" in c
]
def get_assignments(course_id, course_name, course_code):
url = f"{CANVAS_BASE_URL}/api/v1/courses/{course_id}/assignments"
params = {"order_by": "due_at", "per_page": 100}
response = requests.get(url, headers=HEADERS, params=params)
response.raise_for_status()
assignments = response.json()
now = datetime.now(timezone.utc)
results = []
for a in assignments:
if not isinstance(a, dict):
continue
due_at = a.get("due_at")
if not due_at:
continue
from dateutil.parser import parse as parse_date
due_date = parse_date(due_at)
if due_date <= now:
continue
results.append({
"id": a.get("id"),
"name": a.get("name", "Untitled"),
"description": a.get("description", ""),
"due_at": due_at,
"due_date": due_date,
"points_possible": a.get("points_possible", 0) or 0,
"submission_types": a.get("submission_types", []),
"course_name": course_name,
"course_code": course_code,
"html_url": a.get("html_url", ""),
})
return results
def fetch_all_assignments():
courses = get_active_courses()
all_assignments = []
for course in courses:
try:
assignments = get_assignments(course["id"], course["name"], course["code"])
all_assignments.extend(assignments)
except requests.exceptions.HTTPError:
print(f" Skipping course: {course['name']} (no access)")
all_assignments.sort(key=lambda a: a["due_date"])
return all_assignments
if __name__ == "__main__":
print("Fetching assignments from Canvas...")
assignments = fetch_all_assignments()
print(f"\nFound {len(assignments)} upcoming assignments:\n")
for a in assignments:
print(f" [{a['course_code']}] {a['name']}")
print(f" Due: {a['due_date'].strftime('%Y-%m-%d %H:%M')} | Points: {a['points_possible']}")
print()
- Test your Canvas connection by running this command:
python canvas_client.py
You should see a list of your real upcoming assignments printed in the terminal. Each entry shows the course code, assignment name, due date, and point value.
What should I see?
The output starts with "Fetching assignments from Canvas..." followed by a count of upcoming assignments. Each assignment is listed with its course code in brackets, the assignment name, due date, and points. If some courses show "Skipping course" messages, that's normal. It means the API doesn't have read access to that particular course.
Seeing an authorization error?
- Double check that your Canvas API token in .env is correct and has no extra spaces or line breaks.
- Make sure CANVAS_BASE_URL is set to https://utexas.instructure.com with no trailing slash.
- If you get a 401 Unauthorized error, your token may have been revoked. Generate a new one from your Canvas profile settings.
- If you see ModuleNotFoundError, the packages didn't install correctly. Rerun pip install -r requirements.txt.
Help me fix my Canvas API connection error.
Your Canvas connection is live and pulling real assignment data. Next up, you'll set up Google Calendar so your planner has somewhere to schedule study blocks.
Set Up Google Calendar API
Your Canvas client is live and pulling real assignment data from your UT Austin courses.
Now you need a destination for all that data. Google Calendar has a REST API that lets you create events programmatically. In this step, you'll set up a Google Cloud project, enable the Calendar API, and write a Python client that can read and write calendar events.
In this step, get ready to:
- Create a Google Cloud project and enable the Google Calendar API.
- Set up OAuth credentials to authorize your desktop application.
- Write and test a Python client that creates calendar events.
Create a Google Cloud project and enable the Calendar API
Google's APIs are managed through the Google Cloud Console. You need a project there before you can use the Calendar API.
- Go to https://console.cloud.google.com/ and sign in with your Google account.
- Click the project dropdown at the top of the page.
- Click New Project.
- Enter Academic Planner as the project name.
- Click Create.
Wait a few seconds for the project to be created. A notification appears when it is ready.
- Click the project dropdown again and select Academic Planner to make it your active project.
- Go to the Calendar API library page.
- Click Enable to turn on the Google Calendar API for your project.
The page redirects to the API overview dashboard after enabling.
Don't see the Enable button?
Make sure your Academic Planner project is selected in the dropdown at the top of the page. If you see "API Enabled" instead of an Enable button, the API is already active.
Help me enable the Google Calendar API in my Google Cloud project.
Configure OAuth consent and create credentials
Google requires every app that accesses user data to go through OAuth2 authorization. This means your script needs credentials that tell Google who is requesting access to your calendar.
What is OAuth2?
OAuth2 is an authorization standard that lets your app access a user's data without needing their password. Instead, the user signs in through a browser and explicitly grants permission. Your app receives a token it can use for future requests.
First, set up the consent screen that users see when authorizing your app.
- Go to the OAuth consent screen page in Google Cloud Console.
- Select External as the user type.
- Click Create.
- Enter Academic Planner as the app name.
- Enter your Google email address in the User support email field.
- Scroll down and enter your Google email address again in the Developer contact information field.
- Click Save and Continue.
You land on the Scopes page. You can skip adding scopes here because the Python client handles them in code.
- Click Save and Continue on the Scopes page.
- On the Test Users page, click + Add Users.
- Enter your Google email address and click Add.
- Click Save and Continue.
Why add yourself as a test user?
Your app is in "Testing" mode, which means only email addresses listed as test users can authorize. Adding your own email lets you sign in when the script runs.
Now create the actual credentials your Python script uses to identify itself.
- Go to the Credentials page in Google Cloud Console.
- Click + Create Credentials at the top of the page.
- Select OAuth client ID.
- Choose Desktop app as the application type.
- Enter Academic Planner Desktop as the name.
- Click Create.
A dialog appears showing your client ID. You do not need to copy these values.
- Click Download JSON in the dialog.
- Rename the downloaded file to credentials.json.
- Move credentials.json into your academic-planner project folder.
What is credentials.json?
This file contains your app's client ID and client secret. It tells Google which application is requesting access. It does not contain your personal login or calendar data.
Keep credentials.json private
Never commit credentials.json to version control. Make sure it is listed in your .gitignore file alongside your .env file.
Test the Google Calendar connection
With your credentials ready, you can write the Python client that talks to Google Calendar. This client handles the full OAuth2 sign-in flow and provides functions for creating and listing events.
- In your Claude Code session, ask Claude Code to create calendar_client.py with the following features:
Claude Code generates the file in your project folder. Let's walk through the key parts of what it should contain.
- Verify the top of calendar_client.py has these imports and constants:
import os
import datetime
from google.auth.transport.requests import Request
from google.oauth2.credentials import Credentials
from google_auth_oauthlib.flow import InstalledAppFlow
from googleapiclient.discovery import build
from googleapiclient.errors import HttpError
# Write access to Google Calendar (not readonly)
SCOPES = ["https://www.googleapis.com/auth/calendar"]
# Maps assignment types to Google Calendar event color IDs (1-11)
COLOR_MAP = {
"homework": "1",
"project": "2",
"essay": "3",
"quiz": "5",
"exam": "11",
"discussion": "6",
"lab": "7",
"other": "8",
}
What do SCOPES and COLOR_MAP do?
- SCOPES defines what permissions your app requests. The calendar scope gives full read and write access. A calendar.readonly scope would only let you read events, which is not enough for creating study blocks.
- COLOR_MAP assigns a Google Calendar color ID (integers 1 through 11) to each assignment type. This makes it easy to visually distinguish homework from exams on your calendar.
- Verify the get_calendar_service() function looks like this:
def get_calendar_service():
# Check for an existing token from a previous authorization
creds = None
if os.path.exists("token.json"):
creds = Credentials.from_authorized_user_file("token.json", SCOPES)
# If no valid credentials, start the authorization flow
if not creds or not creds.valid:
if creds and creds.expired and creds.refresh_token:
creds.refresh(Request())
else:
flow = InstalledAppFlow.from_client_secrets_file("credentials.json", SCOPES)
creds = flow.run_local_server(port=0)
# Save the token for future runs
with open("token.json", "w") as token:
token.write(creds.to_json())
service = build("calendar", "v3", credentials=creds)
return service
What does this function do?
This function handles the full OAuth2 lifecycle.
- First, it checks if a token.json file already exists from a previous run. If it does, the script reuses it instead of asking you to sign in again.
- If the token is missing or expired, it reads credentials.json and opens a browser window where you sign in with Google.
- After you approve, it saves the new token to token.json so you don't have to authorize again on the next run.
- Verify that the helper functions and test block match the following:
def get_existing_events(service, time_min, time_max):
# Fetch all events between two timestamps
events_result = (
service.events()
.list(
calendarId="primary",
timeMin=time_min.isoformat(),
timeMax=time_max.isoformat(),
singleEvents=True,
orderBy="startTime",
)
.execute()
)
return events_result.get("items", [])
def is_slot_available(existing_events, start, end):
# Check if a proposed time slot overlaps with any existing event
for event in existing_events:
event_start_str = event["start"].get("dateTime", event["start"].get("date"))
event_end_str = event["end"].get("dateTime", event["end"].get("date"))
if not event_start_str or not event_end_str:
continue
from dateutil.parser import parse as parse_date
event_start = parse_date(event_start_str)
event_end = parse_date(event_end_str)
# If proposed slot overlaps with this event, it's not available
if start < event_end and end > event_start:
return False
return True
def create_event(service, summary, description, start, end, color_id="1"):
# Build and insert a new calendar event
event = {
"summary": summary,
"description": description,
"start": {
"dateTime": start.isoformat(),
"timeZone": "America/Chicago",
},
"end": {
"dateTime": end.isoformat(),
"timeZone": "America/Chicago",
},
"colorId": color_id,
}
created = service.events().insert(calendarId="primary", body=event).execute()
return created
def delete_event(service, event_id):
# Remove an event by its ID
service.events().delete(calendarId="primary", eventId=event_id).execute()
if __name__ == "__main__":
print("Testing Google Calendar connection...")
service = get_calendar_service()
now = datetime.datetime.now(tz=datetime.timezone.utc)
events = get_existing_events(service, now, now + datetime.timedelta(days=7))
print(f"Found {len(events)} events in the next 7 days.")
for event in events[:5]:
start = event["start"].get("dateTime", event["start"].get("date"))
print(f" {start} - {event.get('summary', 'No title')}")
print("\nGoogle Calendar connection successful!")
What do the helper functions do?
- get_existing_events() fetches all events between two timestamps from your primary calendar. The scheduler uses this to avoid double-booking.
- is_slot_available() checks whether a proposed time slot overlaps with any existing event. It returns False if there is a conflict.
- create_event() builds a calendar event with a summary, description, time range, timezone, and color. The timezone is set to America/Chicago for UT Austin's Central time zone.
- delete_event() removes an event by its ID. Useful for cleaning up test events.
Awesome, I've got everything!
Make sure you have saved calendar_client.py in your academic-planner folder.
I'd like to double check the full code
import os
import datetime
from google.auth.transport.requests import Request
from google.oauth2.credentials import Credentials
from google_auth_oauthlib.flow import InstalledAppFlow
from googleapiclient.discovery import build
from googleapiclient.errors import HttpError
SCOPES = ["https://www.googleapis.com/auth/calendar"]
COLOR_MAP = {
"homework": "1",
"project": "2",
"essay": "3",
"quiz": "5",
"exam": "11",
"discussion": "6",
"lab": "7",
"other": "8",
}
def get_calendar_service():
creds = None
if os.path.exists("token.json"):
creds = Credentials.from_authorized_user_file("token.json", SCOPES)
if not creds or not creds.valid:
if creds and creds.expired and creds.refresh_token:
creds.refresh(Request())
else:
flow = InstalledAppFlow.from_client_secrets_file("credentials.json", SCOPES)
creds = flow.run_local_server(port=0)
with open("token.json", "w") as token:
token.write(creds.to_json())
service = build("calendar", "v3", credentials=creds)
return service
def get_existing_events(service, time_min, time_max):
events_result = (
service.events()
.list(
calendarId="primary",
timeMin=time_min.isoformat(),
timeMax=time_max.isoformat(),
singleEvents=True,
orderBy="startTime",
)
.execute()
)
return events_result.get("items", [])
def is_slot_available(existing_events, start, end):
for event in existing_events:
event_start_str = event["start"].get("dateTime", event["start"].get("date"))
event_end_str = event["end"].get("dateTime", event["end"].get("date"))
if not event_start_str or not event_end_str:
continue
from dateutil.parser import parse as parse_date
event_start = parse_date(event_start_str)
event_end = parse_date(event_end_str)
if start < event_end and end > event_start:
return False
return True
def create_event(service, summary, description, start, end, color_id="1"):
event = {
"summary": summary,
"description": description,
"start": {
"dateTime": start.isoformat(),
"timeZone": "America/Chicago",
},
"end": {
"dateTime": end.isoformat(),
"timeZone": "America/Chicago",
},
"colorId": color_id,
}
created = service.events().insert(calendarId="primary", body=event).execute()
return created
def delete_event(service, event_id):
service.events().delete(calendarId="primary", eventId=event_id).execute()
if __name__ == "__main__":
print("Testing Google Calendar connection...")
service = get_calendar_service()
now = datetime.datetime.now(tz=datetime.timezone.utc)
events = get_existing_events(service, now, now + datetime.timedelta(days=7))
print(f"Found {len(events)} events in the next 7 days.")
for event in events[:5]:
start = event["start"].get("dateTime", event["start"].get("date"))
print(f" {start} - {event.get('summary', 'No title')}")
print("\nGoogle Calendar connection successful!")
Now run the test. The first time you run this script, a browser window opens asking you to sign in with Google and authorize your app.
- Run the calendar client test by running this command:
python calendar_client.py
- A browser window opens. Sign in with the Google account you added as a test user.
- You may see a warning that says "Google hasn't verified this app". Click Continue to proceed.
- Click Continue again to grant calendar access.
What should I see?
After authorizing, your terminal prints how many events it found in the next 7 days, followed by the first few event summaries. The last line reads Google Calendar connection successful!.
- Confirm a token.json file now exists in your academic-planner folder by running:
ls
You should see token.json listed alongside credentials.json, calendar_client.py, and your other project files.
Why does token.json matter?
This file stores your authorization token so the script can access Google Calendar without opening the browser every time. Future runs of the script skip the sign-in step entirely.
Seeing an authorization error?
- Make sure credentials.json is in your academic-planner folder, not in your Downloads folder.
- Verify that you added your Google email as a test user on the OAuth consent screen.
- If you see a redirect_uri_mismatch error, make sure you selected Desktop app (not Web application) when creating the OAuth client ID.
- If the token expires after 7 days, delete token.json and run the script again to re-authorize. This is normal for apps in testing mode.
Help me fix my Google Calendar authorization error.
Your Google Calendar connection is live. Next up, you'll build the classifier that looks at each Canvas assignment and figures out what type it is and how long it will take to finish.
Build the Assignment Classifier
Your Canvas client is pulling real assignments, and your Google Calendar connection is live. Now it's time to make sense of all that raw assignment data.
Each assignment from Canvas arrives as a blob of text and metadata. Your planner needs to know the difference between a 30-minute quiz and a 3-hour project. In this step, you'll build a keyword-based classifier that reads each assignment's name and description, categorizes it by type, and estimates how many minutes of study time it requires.
In this step, get ready to:
- Create a keyword-based classifier that categorizes assignments by type.
- Add time estimation logic that scales with assignment points.
- Test the classifier against your real Canvas assignments.
Create the assignment type classifier
The classifier works by scanning an assignment's name and description for keywords. If it finds "quiz" in the title, it labels the assignment as a quiz. If it finds "essay" or "paper," it labels it as an essay. This simple approach handles the majority of assignments correctly.
- In your Claude Code session, ask Claude Code to create a new file called classifier.py with a keyword mapping dictionary. Start with the imports and keyword definitions:
import json
import os
from bs4 import BeautifulSoup
# Map each assignment type to keywords that identify it
ASSIGNMENT_TYPES = {
"quiz": ["quiz", "quizzes"],
"exam": ["exam", "midterm", "final", "test"],
"essay": ["essay", "paper", "write", "writing", "composition", "reflection"],
"project": ["project", "presentation", "portfolio", "capstone"],
"discussion": ["discussion", "forum", "post", "respond", "reply"],
"lab": ["lab", "laboratory", "experiment"],
"homework": ["homework", "hw", "problem set", "pset", "exercise", "worksheet", "assignment"],
}
What does this code do?
- ASSIGNMENT_TYPES is a dictionary that maps each category to a list of keywords. When the classifier sees any of these keywords in an assignment's name or description, it knows which type to assign.
- BeautifulSoup is imported to strip HTML tags from Canvas descriptions. Canvas stores assignment descriptions as HTML, so you need to extract the plain text before searching for keywords.
You won't see a change yet. The next chunk uses this mapping to classify assignments.
- Add the HTML stripping helper and the main classification function below the keyword mapping in classifier.py:
def strip_html(html_text):
"""Remove HTML tags from Canvas description text."""
if not html_text:
return ""
soup = BeautifulSoup(html_text, "html.parser")
return soup.get_text(separator=" ", strip=True)
def classify_assignment(name, description_html, submission_types):
"""Classify an assignment by checking keywords in name, description, and submission type."""
name_lower = name.lower()
description_text = strip_html(description_html).lower()
combined = f"{name_lower} {description_text}"
# First priority: check the assignment name for keywords
for assignment_type, keywords in ASSIGNMENT_TYPES.items():
for keyword in keywords:
if keyword in name_lower:
return assignment_type
# Second priority: check the description text
for assignment_type, keywords in ASSIGNMENT_TYPES.items():
for keyword in keywords:
if keyword in description_text:
return assignment_type
# Third priority: check Canvas submission types
if submission_types:
sub_types = " ".join(submission_types).lower()
if "online_quiz" in sub_types:
return "quiz"
if "discussion_topic" in sub_types:
return "discussion"
if "online_upload" in sub_types or "online_text_entry" in sub_types:
return "homework"
return "other"
What does this code do?
- strip_html() uses BeautifulSoup to convert HTML descriptions into plain text. Without this, keywords would be buried inside HTML tags like <p>Write an essay...</p>.
- classify_assignment() uses a three-tier priority system. It checks the assignment name first (most reliable), then the description text, then the Canvas submission type metadata. This way, an assignment called "Midterm Exam" gets classified as exam even if the description mentions "homework problems."
- If no keywords match anywhere, the function returns other as a safe fallback.
- Save classifier.py.
You won't see visible output yet. The next substep adds time estimation, and then you'll test everything together.
Claude Code generated different keywords?
If Claude Code used different keyword lists or a different structure, that's OK as long as the function signature matches: classify_assignment(name, description_html, submission_types). You can adjust the keywords to match your courses later. The important thing is the three-tier priority logic (name, then description, then submission type).
Help me fix my classify_assignment function so it matches the expected structure.
Add time estimation logic
Knowing an assignment is a "project" is useful, but your planner also needs to know how long to block on the calendar. The time estimator uses the assignment type and its point value to calculate study time.
- Add the base time estimates dictionary and the estimate_time() function below classify_assignment() in classifier.py:
# Base time in minutes for each assignment type
BASE_TIME_ESTIMATES = {
"quiz": 30,
"exam": 120,
"essay": 120,
"project": 180,
"discussion": 30,
"lab": 90,
"homework": 60,
"other": 60,
}
def estimate_time(assignment_type, points_possible):
"""Estimate study time in minutes based on type and points."""
config_path = "config.json"
overrides = {}
if os.path.exists(config_path):
with open(config_path, "r") as f:
config = json.load(f)
overrides = config.get("time_overrides", {})
# Use override if the user set a custom time for this type
if assignment_type in overrides:
base_minutes = overrides[assignment_type]
else:
base_minutes = BASE_TIME_ESTIMATES.get(assignment_type, 60)
# Scale by points relative to 100 (clamped between 0.5x and 3x)
if points_possible and points_possible > 0:
scale_factor = points_possible / 100.0
estimated = int(base_minutes * max(0.5, min(scale_factor, 3.0)))
else:
estimated = base_minutes
# Cap between 15 minutes and 8 hours
estimated = max(15, min(estimated, 480))
return estimated
What does this code do?
- BASE_TIME_ESTIMATES sets a default study time for each assignment type. A quiz gets 30 minutes, a project gets 180 minutes (3 hours), and so on.
- The function scales the base time by the assignment's point value. A 200-point project gets double the base time. A 50-point quiz gets half.
- The scaling factor is clamped between 0.5x and 3x so estimates stay reasonable. The final result is capped between 15 minutes and 480 minutes (8 hours).
- If a config.json file has a time_overrides section, those values replace the defaults. This lets you customize estimates for your specific courses.
- Now add the classify_and_estimate() wrapper function that combines both steps into a single pipeline:
def classify_and_estimate(assignments):
"""Run classification and time estimation on a list of assignments."""
results = []
for a in assignments:
assignment_type = classify_assignment(
a["name"], a.get("description", ""), a.get("submission_types", [])
)
estimated_minutes = estimate_time(assignment_type, a.get("points_possible", 0))
results.append({
**a,
"type": assignment_type,
"estimated_minutes": estimated_minutes,
})
return results
What does this code do?
classify_and_estimate() loops through every assignment, classifies it, estimates its study time, and returns the original assignment data with two new fields: type and estimated_minutes. This is the single function the rest of your planner calls.
- Save classifier.py.
You won't see output yet. The next substep adds a test script that runs this against your real Canvas data.
Why keyword-based classification instead of AI?
You might expect an AI-powered approach here. Keyword matching is simpler, free, deterministic, and fast. It handles the vast majority of assignment names correctly because instructors follow predictable naming conventions. The Secret Mission upgrades this to AI classification if you want smarter results.
Test classification on your real assignments
Your classifier and estimator are complete. Now you need a test script that fetches your real Canvas assignments and runs them through the pipeline.
- Add this __main__ block at the bottom of classifier.py to create a standalone test script:
if __name__ == "__main__":
from canvas_client import fetch_all_assignments
print("Fetching and classifying assignments...\n")
assignments = fetch_all_assignments()
classified = classify_and_estimate(assignments)
# Print a formatted table of results
print(f"{'Course':<12} {'Assignment':<35} {'Type':<12} {'Time':<8} {'Due':<16}")
print("-" * 85)
for a in classified:
print(
f"{a['course_code']:<12} "
f"{a['name'][:33]:<35} "
f"{a['type']:<12} "
f"{a['estimated_minutes']:>4} min "
f"{a['due_date'].strftime('%m/%d %H:%M'):<16}"
)
What does this code do?
- The if __name__ == "__main__" block only runs when you execute classifier.py directly. It does not run when other files import from this module.
- It imports fetch_all_assignments from the Canvas client you built in Step 2, runs the full classify-and-estimate pipeline, and prints a formatted table with columns for course, assignment name, type, time, and due date.
- Save classifier.py.
- Run the classifier against your real assignments by running this command:
python classifier.py
You should see a table with your real upcoming assignments, each labeled with a type and an estimated time in minutes.
Not seeing any assignments?
- Make sure your .env file contains your Canvas API token and that canvas_client.py is in the same folder as classifier.py.
- If you see "Skipping course" messages for every course, your API token may have expired. Generate a new one from your Canvas profile settings.
- If assignments appear but all show type other, check that the ASSIGNMENT_TYPES dictionary keywords match the naming patterns your instructors use.
- Help me debug why my classifier is not producing the expected output.
Review the output table. Most assignments should have a sensible type. If you notice any obvious misclassifications, you can tweak the keywords in ASSIGNMENT_TYPES to better match your courses' naming conventions.
Awesome, I've got everything!
Great. Double check you've saved classifier.py before moving on.
I'd like to double check the full code
Here is the complete classifier.py file. Compare it against yours to spot any differences.
import json
import os
from bs4 import BeautifulSoup
ASSIGNMENT_TYPES = {
"quiz": ["quiz", "quizzes"],
"exam": ["exam", "midterm", "final", "test"],
"essay": ["essay", "paper", "write", "writing", "composition", "reflection"],
"project": ["project", "presentation", "portfolio", "capstone"],
"discussion": ["discussion", "forum", "post", "respond", "reply"],
"lab": ["lab", "laboratory", "experiment"],
"homework": ["homework", "hw", "problem set", "pset", "exercise", "worksheet", "assignment"],
}
BASE_TIME_ESTIMATES = {
"quiz": 30,
"exam": 120,
"essay": 120,
"project": 180,
"discussion": 30,
"lab": 90,
"homework": 60,
"other": 60,
}
def strip_html(html_text):
if not html_text:
return ""
soup = BeautifulSoup(html_text, "html.parser")
return soup.get_text(separator=" ", strip=True)
def classify_assignment(name, description_html, submission_types):
name_lower = name.lower()
description_text = strip_html(description_html).lower()
combined = f"{name_lower} {description_text}"
for assignment_type, keywords in ASSIGNMENT_TYPES.items():
for keyword in keywords:
if keyword in name_lower:
return assignment_type
for assignment_type, keywords in ASSIGNMENT_TYPES.items():
for keyword in keywords:
if keyword in description_text:
return assignment_type
if submission_types:
sub_types = " ".join(submission_types).lower()
if "online_quiz" in sub_types:
return "quiz"
if "discussion_topic" in sub_types:
return "discussion"
if "online_upload" in sub_types or "online_text_entry" in sub_types:
return "homework"
return "other"
def estimate_time(assignment_type, points_possible):
config_path = "config.json"
overrides = {}
if os.path.exists(config_path):
with open(config_path, "r") as f:
config = json.load(f)
overrides = config.get("time_overrides", {})
if assignment_type in overrides:
base_minutes = overrides[assignment_type]
else:
base_minutes = BASE_TIME_ESTIMATES.get(assignment_type, 60)
if points_possible and points_possible > 0:
scale_factor = points_possible / 100.0
estimated = int(base_minutes * max(0.5, min(scale_factor, 3.0)))
else:
estimated = base_minutes
estimated = max(15, min(estimated, 480))
return estimated
def classify_and_estimate(assignments):
results = []
for a in assignments:
assignment_type = classify_assignment(
a["name"], a.get("description", ""), a.get("submission_types", [])
)
estimated_minutes = estimate_time(assignment_type, a.get("points_possible", 0))
results.append({
**a,
"type": assignment_type,
"estimated_minutes": estimated_minutes,
})
return results
if __name__ == "__main__":
from canvas_client import fetch_all_assignments
print("Fetching and classifying assignments...\n")
assignments = fetch_all_assignments()
classified = classify_and_estimate(assignments)
print(f"{'Course':<12} {'Assignment':<35} {'Type':<12} {'Time':<8} {'Due':<16}")
print("-" * 85)
for a in classified:
print(
f"{a['course_code']:<12} "
f"{a['name'][:33]:<35} "
f"{a['type']:<12} "
f"{a['estimated_minutes']:>4} min "
f"{a['due_date'].strftime('%m/%d %H:%M'):<16}"
)
Your classifier is identifying assignment types and estimating study time across all your courses. Next up, you'll build the backward scheduler that turns these estimates into actual study blocks on your Google Calendar.
Build the Backward Scheduler
Your classifier can now identify each assignment's type and estimate how long it takes. The missing piece is figuring out when to actually study for them.
In this step, you will build a backward scheduler. It starts from each assignment's deadline and works backwards to place study blocks during your available hours in Google Calendar.
In this step, get ready to:
- Create the backward scheduling algorithm that generates time slots.
- Add conflict detection to avoid double-booking existing calendar events.
- Generate color-coded study blocks in Google Calendar.
Create the scheduling algorithm
A backward scheduler works in the opposite direction from how you might expect. Instead of picking the next free slot and assigning work to it, it starts from the deadline and looks for open time slots before that date.
The scheduler needs two things: a way to load your preferred working hours, and a way to generate every available time slot within those hours.
- In your Claude Code session, ask Claude Code to create a scheduler.py file that imports from your existing calendar_client.py and includes a configuration loader function.
- Tell Claude Code the config loader should read from config.json and fall back to defaults: working hours 09:00 to 21:00, 60-minute study blocks, 15-minute breaks, and a 14-day lookahead.
After Claude Code generates the file, review the imports and load_config() function. They should look similar to this:
import json
import os
import datetime
from dateutil.parser import parse as parse_date
from calendar_client import get_calendar_service, get_existing_events, is_slot_available, create_event, COLOR_MAP
def load_config():
config_path = "config.json"
# Default scheduling preferences
defaults = {
"working_hours": {"start": "09:00", "end": "21:00"},
"excluded_days": [],
"study_block_minutes": 60,
"break_minutes": 15,
"lookahead_days": 14,
}
# Override defaults with user's config if it exists
if os.path.exists(config_path):
with open(config_path, "r") as f:
user_config = json.load(f)
defaults.update(user_config)
return defaults
What does this code do?
- The imports pull in your calendar_client.py functions so the scheduler can check for conflicts and create events.
- load_config() sets sensible defaults, then overwrites them with anything the user has customized in config.json. This means the scheduler works out of the box but stays flexible.
Next, check that Claude Code also generated a generate_time_slots() function. This function creates every possible study slot between two dates based on your working hours.
- If Claude Code did not include a time slot generator, ask it to add a generate_time_slots() function that loops through each day, skips excluded days, and creates 60-minute slots within working hours with 15-minute breaks between them.
The function should look similar to this:
def generate_time_slots(start_date, end_date, config):
# Parse working hours from config
work_start_h, work_start_m = map(int, config["working_hours"]["start"].split(":"))
work_end_h, work_end_m = map(int, config["working_hours"]["end"].split(":"))
block_minutes = config["study_block_minutes"]
break_minutes = config["break_minutes"]
excluded_days = config.get("excluded_days", [])
slots = []
current_date = start_date.date() if hasattr(start_date, "date") else start_date
while current_date <= (end_date.date() if hasattr(end_date, "date") else end_date):
# Skip excluded days (e.g., weekends)
day_name = current_date.strftime("%A").lower()
if day_name in [d.lower() for d in excluded_days]:
current_date += datetime.timedelta(days=1)
continue
from zoneinfo import ZoneInfo
tz = ZoneInfo("America/Chicago")
slot_start = datetime.datetime(
current_date.year, current_date.month, current_date.day,
work_start_h, work_start_m, tzinfo=tz
)
day_end = datetime.datetime(
current_date.year, current_date.month, current_date.day,
work_end_h, work_end_m, tzinfo=tz
)
# Generate slots for this day within working hours
while slot_start + datetime.timedelta(minutes=block_minutes) <= day_end:
slot_end = slot_start + datetime.timedelta(minutes=block_minutes)
slots.append((slot_start, slot_end))
slot_start = slot_end + datetime.timedelta(minutes=break_minutes)
current_date += datetime.timedelta(days=1)
return slots
What does this function do?
- It walks through every day from start_date to end_date, skipping any days listed in excluded_days (like weekends if you add them to your config).
- For each valid day, it chops the working window (9 AM to 9 PM by default) into 60-minute slots with 15-minute breaks between them.
- All times use the America/Chicago timezone since UT Austin is in Central time.
- Verify that scheduler.py was saved by checking the file in your project folder.
Claude Code generated something different?
Claude Code may use different variable names or slightly different logic. The key things to check: the function imports from calendar_client, it reads config.json for user settings, and it generates time slots within the working hours window. If the overall structure matches, minor differences are fine.
If the file looks very different, ask Claude Code to regenerate it with the specific requirements listed above.
Help me fix my scheduler.py file
Add conflict detection and working hours
Generating time slots is only half the puzzle. The scheduler also needs to avoid placing study blocks on top of your existing calendar events.
- Ask Claude Code to add a schedule_assignments() function to scheduler.py. Tell it the function should sort assignments by due date (earliest first) and by points (higher points first as a tiebreaker), then iterate through available time slots to schedule study blocks before each deadline.
- Tell Claude Code the function needs a dry_run parameter. When dry_run is True, the function should skip connecting to Google Calendar entirely.
- Specify that the function must use is_slot_available() from calendar_client.py to check each slot against existing events before booking it.
Review the first section of the generated schedule_assignments() function. The sorting and conflict detection logic should look similar to this:
def schedule_assignments(classified_assignments, dry_run=False):
config = load_config()
now = datetime.datetime.now(tz=datetime.timezone.utc)
# Sort by due date first, then by points (higher points = higher priority)
sorted_assignments = sorted(
classified_assignments,
key=lambda a: (a["due_date"], -a.get("points_possible", 0)),
)
# Only connect to Google Calendar if this is a real run
service = None
existing_events = []
if not dry_run:
service = get_calendar_service()
lookahead = datetime.timedelta(days=config["lookahead_days"])
existing_events = get_existing_events(service, now, now + lookahead)
from zoneinfo import ZoneInfo
tz = ZoneInfo("America/Chicago")
now_local = now.astimezone(tz)
# Generate all possible study slots within the lookahead window
all_slots = generate_time_slots(
now_local + datetime.timedelta(hours=1),
now_local + datetime.timedelta(days=config["lookahead_days"]),
config,
)
scheduled = []
used_slots = []
What's happening here?
- Assignments are sorted so the most urgent ones (earliest deadline, highest points) get first pick of the available time slots.
- When dry_run is True, the function skips the Google Calendar connection entirely. This lets you preview a schedule without creating real events.
- The used_slots list tracks which slots the scheduler has already claimed. This prevents two assignments from being scheduled into the same time slot.
The core of the function is a loop that iterates through each assignment and finds eligible time slots. Review this section in your generated code:
for assignment in sorted_assignments:
remaining_minutes = assignment["estimated_minutes"]
block_minutes = config["study_block_minutes"]
due_date = assignment["due_date"]
if due_date.tzinfo is None:
due_date = due_date.replace(tzinfo=datetime.timezone.utc)
due_local = due_date.astimezone(tz)
# Only consider slots that are in the future and before the deadline
eligible_slots = [
(s, e) for s, e in all_slots
if s > now_local and e < due_local and (s, e) not in used_slots
]
blocks_scheduled = []
block_num = 1
for slot_start, slot_end in eligible_slots:
if remaining_minutes <= 0:
break
# Skip slots that conflict with existing calendar events
if not dry_run and not is_slot_available(existing_events, slot_start, slot_end):
continue
How does backward scheduling work here?
The key line is the eligible_slots filter. It only keeps slots where the end time is before the assignment deadline (e < due_local). This guarantees every study block lands before the due date.
If an assignment needs 180 minutes but each block is 60 minutes, the loop keeps claiming slots until remaining_minutes hits zero. That spreads the work across multiple days automatically.
- Save scheduler.py.
You will not see output yet. The next substep adds the event creation code that completes the function.
Generate Google Calendar events
The scheduling loop knows which slots to fill. Now it needs to create actual calendar events with useful details for each study block.
- Ask Claude Code to make sure the inner loop of schedule_assignments() creates a calendar event for each scheduled block. The event summary should follow the format [Course Code] Assignment Name and include a part number if the assignment is split across multiple blocks.
- Tell Claude Code the event description should include the assignment type, estimated total time, due date, and a link back to the Canvas assignment.
- Ask Claude Code to color-code events by assignment type using the colorId field from COLOR_MAP in calendar_client.py.
Review the event creation portion of the loop. It should look similar to this:
actual_minutes = min(remaining_minutes, block_minutes)
actual_end = slot_start + datetime.timedelta(minutes=actual_minutes)
# Format the event summary with course code and part number
summary = f"[{assignment['course_code']}] {assignment['name']}"
if remaining_minutes > block_minutes:
summary += f" (Part {block_num})"
# Build the event description with key details
description = (
f"Type: {assignment['type']}\n"
f"Estimated total time: {assignment['estimated_minutes']} min\n"
f"Due: {due_local.strftime('%A, %B %d at %I:%M %p')}\n"
)
if assignment.get("html_url"):
description += f"Canvas: {assignment['html_url']}\n"
# Color-code the event by assignment type
color_id = COLOR_MAP.get(assignment["type"], "8")
if not dry_run:
create_event(service, summary, description, slot_start, actual_end, color_id)
blocks_scheduled.append({
"start": slot_start,
"end": actual_end,
"minutes": actual_minutes,
})
used_slots.append((slot_start, slot_end))
remaining_minutes -= actual_minutes
block_num += 1
if blocks_scheduled:
scheduled.append({
"assignment": assignment,
"blocks": blocks_scheduled,
"remaining_minutes": remaining_minutes,
})
return scheduled
What does the event creation code do?
- actual_minutes handles the last block of a multi-block assignment. If only 20 minutes remain, it creates a 20-minute event instead of a full 60-minute one.
- The summary uses a [CS 101] Homework 3 (Part 2) format so you can instantly see which course and assignment each block belongs to.
- The remaining_minutes field in the return value tracks whether the scheduler ran out of slots before fully scheduling an assignment. This surfaces a warning later.
- Save scheduler.py.
Awesome, I've got everything!
Great. Double check you have saved scheduler.py before moving on.
I'd like to double check the full code
Here is the complete scheduler.py file. Compare your version against this:
import json
import os
import datetime
from dateutil.parser import parse as parse_date
from calendar_client import get_calendar_service, get_existing_events, is_slot_available, create_event, COLOR_MAP
def load_config():
config_path = "config.json"
defaults = {
"working_hours": {"start": "09:00", "end": "21:00"},
"excluded_days": [],
"study_block_minutes": 60,
"break_minutes": 15,
"lookahead_days": 14,
}
if os.path.exists(config_path):
with open(config_path, "r") as f:
user_config = json.load(f)
defaults.update(user_config)
return defaults
def generate_time_slots(start_date, end_date, config):
work_start_h, work_start_m = map(int, config["working_hours"]["start"].split(":"))
work_end_h, work_end_m = map(int, config["working_hours"]["end"].split(":"))
block_minutes = config["study_block_minutes"]
break_minutes = config["break_minutes"]
excluded_days = config.get("excluded_days", [])
slots = []
current_date = start_date.date() if hasattr(start_date, "date") else start_date
while current_date <= (end_date.date() if hasattr(end_date, "date") else end_date):
day_name = current_date.strftime("%A").lower()
if day_name in [d.lower() for d in excluded_days]:
current_date += datetime.timedelta(days=1)
continue
from zoneinfo import ZoneInfo
tz = ZoneInfo("America/Chicago")
slot_start = datetime.datetime(
current_date.year, current_date.month, current_date.day,
work_start_h, work_start_m, tzinfo=tz
)
day_end = datetime.datetime(
current_date.year, current_date.month, current_date.day,
work_end_h, work_end_m, tzinfo=tz
)
while slot_start + datetime.timedelta(minutes=block_minutes) <= day_end:
slot_end = slot_start + datetime.timedelta(minutes=block_minutes)
slots.append((slot_start, slot_end))
slot_start = slot_end + datetime.timedelta(minutes=break_minutes)
current_date += datetime.timedelta(days=1)
return slots
def schedule_assignments(classified_assignments, dry_run=False):
config = load_config()
now = datetime.datetime.now(tz=datetime.timezone.utc)
sorted_assignments = sorted(
classified_assignments,
key=lambda a: (a["due_date"], -a.get("points_possible", 0)),
)
service = None
existing_events = []
if not dry_run:
service = get_calendar_service()
lookahead = datetime.timedelta(days=config["lookahead_days"])
existing_events = get_existing_events(service, now, now + lookahead)
from zoneinfo import ZoneInfo
tz = ZoneInfo("America/Chicago")
now_local = now.astimezone(tz)
all_slots = generate_time_slots(
now_local + datetime.timedelta(hours=1),
now_local + datetime.timedelta(days=config["lookahead_days"]),
config,
)
scheduled = []
used_slots = []
for assignment in sorted_assignments:
remaining_minutes = assignment["estimated_minutes"]
block_minutes = config["study_block_minutes"]
due_date = assignment["due_date"]
if due_date.tzinfo is None:
due_date = due_date.replace(tzinfo=datetime.timezone.utc)
due_local = due_date.astimezone(tz)
eligible_slots = [
(s, e) for s, e in all_slots
if s > now_local and e < due_local and (s, e) not in used_slots
]
blocks_scheduled = []
block_num = 1
for slot_start, slot_end in eligible_slots:
if remaining_minutes <= 0:
break
if not dry_run and not is_slot_available(existing_events, slot_start, slot_end):
continue
actual_minutes = min(remaining_minutes, block_minutes)
actual_end = slot_start + datetime.timedelta(minutes=actual_minutes)
summary = f"[{assignment['course_code']}] {assignment['name']}"
if remaining_minutes > block_minutes:
summary += f" (Part {block_num})"
description = (
f"Type: {assignment['type']}\n"
f"Estimated total time: {assignment['estimated_minutes']} min\n"
f"Due: {due_local.strftime('%A, %B %d at %I:%M %p')}\n"
)
if assignment.get("html_url"):
description += f"Canvas: {assignment['html_url']}\n"
color_id = COLOR_MAP.get(assignment["type"], "8")
if not dry_run:
create_event(service, summary, description, slot_start, actual_end, color_id)
blocks_scheduled.append({
"start": slot_start,
"end": actual_end,
"minutes": actual_minutes,
})
used_slots.append((slot_start, slot_end))
remaining_minutes -= actual_minutes
block_num += 1
if blocks_scheduled:
scheduled.append({
"assignment": assignment,
"blocks": blocks_scheduled,
"remaining_minutes": remaining_minutes,
})
return scheduled
Now test the scheduler by importing it alongside your classifier to make sure the pieces fit together.
- Run a quick import test by running this command:
python -c "from scheduler import load_config, generate_time_slots, schedule_assignments; print('Scheduler imports OK'); c = load_config(); print(f'Working hours: {c[\"working_hours\"][\"start\"]} - {c[\"working_hours\"][\"end\"]}')"
You should see Scheduler imports OK followed by your configured working hours (09:00 - 21:00 by default).
Seeing an import error?
- If you see ModuleNotFoundError: No module named 'calendar_client', make sure you are running the command from your project folder where calendar_client.py lives.
- If you see FileNotFoundError for config.json, that is fine. The load_config() function should fall back to defaults. If it crashes instead, ask Claude Code to add the os.path.exists() check before reading the file.
Why is my scheduler.py failing to import?
Your scheduling engine is ready. Next up, you will wire everything together into a single CLI command that fetches, classifies, schedules, and creates calendar events in one go.
Run Your Academic Planner End-to-End
Your backward scheduler is built and ready to place study blocks on your calendar. Every module you need, from the Canvas client to the Google Calendar integration, is working independently.
Now it's time to connect everything with a single command. You'll create a CLI entry point that runs the full pipeline: fetching your assignments, classifying them, and scheduling study blocks into Google Calendar.
In this step, get ready to:
- Create main.py to wire the full pipeline together with CLI arguments.
- Run a dry run to preview your study schedule.
- Verify the study blocks in Google Calendar.
Create the main CLI entry point
Your project has four separate modules that each handle one piece of the pipeline. The main.py file connects them into a single script you can run from the terminal.
- In your Claude Code session, ask Claude Code to create main.py with these requirements:
- Import and call the functions from canvas_client, classifier, and scheduler.
- Add a --dry-run flag that previews the schedule without creating calendar events.
- Add a --course flag that filters assignments to a specific course code.
- Add a --days flag that controls how many days ahead to look, defaulting to 14.
- Print a summary table of classified assignments before scheduling.
- Print the final schedule showing each study block's date and time.
Claude Code should generate a file that looks like the code below. Let's walk through it in two parts, starting with the display helpers.
- Verify that the top of your main.py includes these imports and two display functions:
import argparse
import sys
from canvas_client import fetch_all_assignments
from classifier import classify_and_estimate
from scheduler import schedule_assignments
def print_summary(classified):
print(f"\n{'Course':<12} {'Assignment':<35} {'Type':<12} {'Time':<8} {'Due':<16}")
print("-" * 85)
for a in classified:
print(
f"{a['course_code']:<12} "
f"{a['name'][:33]:<35} "
f"{a['type']:<12} "
f"{a['estimated_minutes']:>4} min "
f"{a['due_date'].strftime('%m/%d %H:%M'):<16}"
)
total_minutes = sum(a["estimated_minutes"] for a in classified)
total_hours = total_minutes / 60
print(f"\nTotal estimated study time: {total_minutes} min ({total_hours:.1f} hours)")
def print_schedule(scheduled):
print(f"\nScheduled {len(scheduled)} assignments:\n")
for item in scheduled:
a = item["assignment"]
blocks = item["blocks"]
remaining = item["remaining_minutes"]
print(f" [{a['course_code']}] {a['name']} ({a['type']}, {a['estimated_minutes']} min)")
for b in blocks:
print(f" {b['start'].strftime('%a %m/%d %I:%M %p')} - {b['end'].strftime('%I:%M %p')} ({b['minutes']} min)")
if remaining > 0:
print(f" WARNING: {remaining} min could not be scheduled before the deadline!")
print()
What do these functions do?
- print_summary() formats a table of all your classified assignments with their course code, name, type, estimated study time, and due date. It also calculates the total study hours across all assignments.
- print_schedule() shows each assignment's scheduled study blocks with exact dates and times. If any assignment couldn't be fully scheduled before its deadline, it prints a warning.
- Save main.py.
You won't see any output yet. The main() function you'll verify next ties these helpers together.
- Below the two display functions in main.py, verify that Claude Code generated a main() function and entry point that matches this:
def main():
parser = argparse.ArgumentParser(description="Canvas-to-Calendar Academic Planner")
parser.add_argument("--dry-run", action="store_true", help="Preview schedule without creating events")
parser.add_argument("--course", type=str, help="Filter to a specific course code")
parser.add_argument("--days", type=int, default=14, help="How many days ahead to look (default: 14)")
args = parser.parse_args()
print("Fetching assignments from Canvas...")
assignments = fetch_all_assignments()
if args.course:
assignments = [
a for a in assignments
if args.course.lower() in a["course_code"].lower()
or args.course.lower() in a["course_name"].lower()
]
from datetime import datetime, timedelta, timezone
cutoff = datetime.now(timezone.utc) + timedelta(days=args.days)
assignments = [a for a in assignments if a["due_date"] <= cutoff]
if not assignments:
print("No upcoming assignments found.")
return
print(f"Found {len(assignments)} assignments due in the next {args.days} days.")
classified = classify_and_estimate(assignments)
print_summary(classified)
if args.dry_run:
print("\n--- DRY RUN MODE ---")
print("\nScheduling study blocks...")
scheduled = schedule_assignments(classified, dry_run=args.dry_run)
print_schedule(scheduled)
if args.dry_run:
print("Dry run complete. No calendar events were created.")
print("Run without --dry-run to create events.")
else:
total_blocks = sum(len(item["blocks"]) for item in scheduled)
print(f"Done! Created {total_blocks} study blocks in Google Calendar.")
if __name__ == "__main__":
main()
What does the main function do?
This function runs the full pipeline in five stages:
- argparse parses the three CLI flags so the learner can customize each run.
- fetch_all_assignments() pulls your real assignments from Canvas. If --course is set, assignments are filtered to that course.
- A date cutoff filters out anything due beyond the --days window.
- classify_and_estimate() categorizes each assignment and estimates study time.
- schedule_assignments() places study blocks on your calendar. When --dry-run is set, it simulates scheduling without actually creating events.
- Save main.py.
You'll run this script in the next substep to see the full pipeline in action.
Awesome, I've got everything!
Great. Double check you've saved main.py before moving on.
I'd like to double check the full code
import argparse
import sys
from canvas_client import fetch_all_assignments
from classifier import classify_and_estimate
from scheduler import schedule_assignments
def print_summary(classified):
print(f"\n{'Course':<12} {'Assignment':<35} {'Type':<12} {'Time':<8} {'Due':<16}")
print("-" * 85)
for a in classified:
print(
f"{a['course_code']:<12} "
f"{a['name'][:33]:<35} "
f"{a['type']:<12} "
f"{a['estimated_minutes']:>4} min "
f"{a['due_date'].strftime('%m/%d %H:%M'):<16}"
)
total_minutes = sum(a["estimated_minutes"] for a in classified)
total_hours = total_minutes / 60
print(f"\nTotal estimated study time: {total_minutes} min ({total_hours:.1f} hours)")
def print_schedule(scheduled):
print(f"\nScheduled {len(scheduled)} assignments:\n")
for item in scheduled:
a = item["assignment"]
blocks = item["blocks"]
remaining = item["remaining_minutes"]
print(f" [{a['course_code']}] {a['name']} ({a['type']}, {a['estimated_minutes']} min)")
for b in blocks:
print(f" {b['start'].strftime('%a %m/%d %I:%M %p')} - {b['end'].strftime('%I:%M %p')} ({b['minutes']} min)")
if remaining > 0:
print(f" WARNING: {remaining} min could not be scheduled before the deadline!")
print()
def main():
parser = argparse.ArgumentParser(description="Canvas-to-Calendar Academic Planner")
parser.add_argument("--dry-run", action="store_true", help="Preview schedule without creating events")
parser.add_argument("--course", type=str, help="Filter to a specific course code")
parser.add_argument("--days", type=int, default=14, help="How many days ahead to look (default: 14)")
args = parser.parse_args()
print("Fetching assignments from Canvas...")
assignments = fetch_all_assignments()
if args.course:
assignments = [
a for a in assignments
if args.course.lower() in a["course_code"].lower()
or args.course.lower() in a["course_name"].lower()
]
from datetime import datetime, timedelta, timezone
cutoff = datetime.now(timezone.utc) + timedelta(days=args.days)
assignments = [a for a in assignments if a["due_date"] <= cutoff]
if not assignments:
print("No upcoming assignments found.")
return
print(f"Found {len(assignments)} assignments due in the next {args.days} days.")
classified = classify_and_estimate(assignments)
print_summary(classified)
if args.dry_run:
print("\n--- DRY RUN MODE ---")
print("\nScheduling study blocks...")
scheduled = schedule_assignments(classified, dry_run=args.dry_run)
print_schedule(scheduled)
if args.dry_run:
print("Dry run complete. No calendar events were created.")
print("Run without --dry-run to create events.")
else:
total_blocks = sum(len(item["blocks"]) for item in scheduled)
print(f"Done! Created {total_blocks} study blocks in Google Calendar.")
if __name__ == "__main__":
main()
Run the full pipeline on your real courses
Before creating real calendar events, preview the schedule with a dry run. This lets you review the proposed study blocks without touching your Google Calendar.
- Run the planner in dry-run mode for the next 7 days by running this command:
python main.py --dry-run --days 7
What should I see?
The script fetches your real Canvas assignments, classifies each one, and prints a summary table. You should see your course codes, assignment names, detected types (homework, quiz, essay, etc.), estimated study times, and due dates.
Below the table, you'll see proposed study blocks with specific dates and times. The output ends with "Dry run complete. No calendar events were created."
Review the proposed schedule. If the study blocks, time estimates, and assignment types look reasonable, you're ready to create real events.
- Create the actual calendar events by running the planner without the dry-run flag:
python main.py --days 7
The script prints the same summary table and schedule, then creates real events in your Google Calendar. The final line tells you how many study blocks were created.
Seeing an error when running the script?
- If you see ModuleNotFoundError, make sure you installed all dependencies from requirements.txt in Step 2.
- If you see No upcoming assignments found, try increasing the lookahead window. Run python main.py --dry-run --days 30 to check further ahead.
- If the Google Calendar authorization fails, delete token.json from your project folder and run the script again to re-authorize.
Help me debug an error when running my academic planner script.
Pro tip: filter by course
If you only want to schedule study time for one class, use the --course flag with your course code. For example: python main.py --course CS314 --days 7.
Verify in Google Calendar
The script created real events in your Google Calendar. Time to confirm everything landed correctly.
- Open Google Calendar in your browser at calendar.google.com.
- Switch to the Week view to see your study blocks laid out across the week.
- Click on one of the study block events to view its details.
You should see color-coded study blocks during your working hours. Each event title starts with your course code in brackets, followed by the assignment name.
- Confirm these three things:
- Study blocks fall within your configured working hours (9:00 AM to 9:00 PM by default).
- Study blocks do not overlap with your existing calendar events.
- Every study block is scheduled before the assignment's deadline.
What's in each event's description?
Click any study block to see its details. The description includes the assignment type, total estimated study time, due date, and a direct link to the assignment on Canvas.
Don't see any events on your calendar?
- Check that your terminal output ended with "Done! Created X study blocks in Google Calendar." and not the dry-run message.
- Make sure you're looking at the correct Google account. The events go to the primary calendar of the account you authorized.
- Scroll to the correct date range. Study blocks are scheduled within the --days window you specified.
Help me figure out why my study blocks aren't showing up in Google Calendar.
Secret mission
AI-Powered Classification with the Anthropic API
Your keyword-based classifier works, but it makes mistakes. Assignments with vague names like "Module 5 Deliverable" get misclassified, and time estimates are one-size-fits-all. What if Claude could read each assignment description and give you a smarter breakdown? In this Secret Mission, you will replace the keyword classifier with an AI-powered one that understands context, estimates time more accurately, and breaks large assignments into sub-tasks with their own calendar events.
Before you go
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. The Google Cloud project and Canvas API token are free with no ongoing costs. The only recurring cost is your Claude Pro subscription at $20/month.
Cost warning
Your Claude Pro subscription costs $20/month and will continue billing until you cancel. If you are done using Claude Code, consider pausing or cancelling your subscription to avoid ongoing charges.
Resources you used:
- Google Cloud project with OAuth credentials (free, no ongoing cost).
- Canvas API access token (free, no cost).
- Calendar events created in your Google Calendar (free).
- Claude Pro subscription ($20/month).
- Local project files in your academic-planner folder.
Keep everything running
No action needed. Choose this if you are still actively building or want to re-run the planner regularly.
- Your Canvas API token and Google OAuth credentials will persist, so you can re-run the planner anytime.
- Your Claude Pro subscription bills monthly at $20/month.
- Google OAuth tokens in testing mode expire every 7 days. You will need to re-authorize when that happens.
Pause - I'll come back to this later
Shut down recurring costs while keeping your project files and credentials intact so you can pick things up later.
- Cancel your Claude Pro subscription if you are done using Claude Code. Your project code works independently without it.
- Keep your .env file, credentials.json, and token.json in your project folder. These let you re-run the planner without setting everything up again.
- Your Canvas API token and Google Cloud project remain active at no cost.
Delete - I don't want to use this again
Remove all project resources and start fresh. Follow these steps to clean up everything.
- Delete generated calendar events from Google Calendar. Open Google Calendar in your browser and remove the study blocks manually.
- Revoke your Canvas API token. Go to https://utexas.instructure.com/profile/settings and delete it under Approved Integrations.
- Delete the Google Cloud project. Go to https://console.cloud.google.com/iam-admin/settings and click Shut Down.
- Cancel your Claude Pro subscription if you no longer need Claude Code.
- Delete the academic-planner folder from your computer.
Mission accomplished
That's a Wrap!
Nice work! You just built a fully functional academic planner that reads your real UT Austin assignments from Canvas LMS and auto-schedules study time in your Google Calendar.
You learned how to:
- Connect to the Canvas LMS REST API to fetch real course and assignment data using personal access tokens.
- Build a keyword-based classifier that categorizes assignments by type and estimates study time based on points and assignment category.
- Create a backward-scheduling engine that integrates with the Google Calendar API to auto-schedule study blocks while respecting your working hours and avoiding conflicts.
- Upgrade to AI-powered classification using the Anthropic API for smarter time estimates and automatic sub-task breakdown.
Ready to quiz yourself?