Fraser Page/
03 — System Design

Student tasks that create (and complete) themselves

The ask

A new student portal needed a “tasks” concept to guide students through their journey — something to give them a sense of what to do next.

Student home screen grouping tasks into not approved, overdue, due soon, and to-do

What is a task, anyway?

The app's whole job is collecting and tracking student data. A simple checklist looks like it fits the bill, but it doesn't guarantee anything about the data we care about. Talking this through with the dev team, we decided: each task is a read of the student's data and they should be marked pending or complete depending on a condition applied to that data. Completing a task means updating that data, which is what they exist to encourage.

Even then, we weren't quite sure what the tasks should be. The designer had already tried, and so had the client. I looked at the student data we were constructing tasks from: scholarships, tests, applications and financial aid all had statuses. The simple answer: each status maps to a task.

Each task may have four conditions (when it appears, when it's complete, when it's dismissed, and when it expires) and one lifecycle the engine runs for all of them. Students don't tick tasks off. They update something like their application status, and the task reads as complete on the next pass.

Hardcoded now, configurable later

The long-term plan was for admins to configure tasks in the app. The priority, though, was getting the student-facing side built, so v1 would ship with the tasks hardcoded (eighteen of them, each a small class). That left two problems. The hardcoded version couldn't paint us into a corner, and the conditions needed to be stored in a way we could build an admin UI over without creating a mountain of work.

The first was an architecture problem. Each definition class has the same shape as the database row it will become, and the whole set sits behind a repository, so moving to the database means swapping one implementation. The test suite already runs against its own repository, so the engine is tested independently of the eighteen definitions.

Borrowing a language we already had

The second was harder. Status gave us the first list, but not every task keys on one. A survey task, for example, is complete when the student has responses linked to that survey. And we wanted admins to have real freedom over what a task can be, not a dropdown of statuses.

So I reused GRACE's existing filter system for the conditions. Advisers already use it to build and save searches across student data, so a rule like “create this task for every application with a status of interested” is the same object the search UI produces when an adviser builds that filter by hand. When the definitions move into the database, the config screen can be a UI over a filter language the app already uses and already stores.

Making it cheap

There was a catch. The filter system worked by compiling each filter into a live SQL query. Eighteen definitions, up to four conditions each, checked against every application, test and scholarship a student has: a classic N+1 problem, with a database round trip for every check.

So before the engine runs, each task type declares the relations it needs, and those load in one batch. Then I gave the filter system a second way to execute. Alongside the SQL path, I added an in-memory path that evaluates the same filter object against data that's already loaded. After that first load, condition checks never touch the database, so the query count stays flat whether the engine is running eighteen definitions or eighty.

← 02 — Vague client ask → three targeted features04 — The chatbot that replaced the script system →