Real Tasks & Queue — Add, Check, Persist
tasks table you made last chapter.SELECT, add with an INSERT, toggle/delete with UPDATE/DELETE, and redirect after every write so refresh never double-submits. No JavaScript required.You've already seen the pattern — twice
Here's the thing that should make this chapter feel easy: the technique we need is already in index.php. Look near the top, at the block that handles action = toggle_done. It reads a POST, changes some state, then does header('Location: ...'); exit; to redirect. That's POST-Redirect-GET (PRG), and it's the backbone of every form on a server-rendered site. We're going to do the exact same dance for tasks.
Why redirect after a write instead of just rendering the page? Because of the classic "the browser asks to re-submit the form" popup. If a POST renders the page directly, hitting refresh re-sends the POST — adding the task twice, or toggling it back. By redirecting to a plain GET, the final page the browser sits on is harmless to refresh. It's the difference between a form that feels solid and one that double-charges your credit card. Internalize PRG and you've internalized half of server-side web dev.
🐍 Python brain: PRG is the same reason Flask views end with return redirect(url_for(...)) after a successful POST instead of rendering inline. Django's the same. It's a universal web pattern, not a PHP quirk.
Reading tasks instead of hardcoding them
Right now both task lists are PHP arrays literally typed into index.php (you flagged them with // FAKE comments back in chapter 1 — go find them). The fix is to fetch from the database instead. A tiny helper keeps it tidy:
function tasks_for(string $lane): array {
$stmt = db()->prepare(
"SELECT * FROM tasks WHERE lane = ? ORDER BY done, created_at"
);
$stmt->execute([$lane]);
return $stmt->fetchAll();
}
Then the Work tab calls tasks_for('work') and the Home queue calls tasks_for('home'), and the existing foreach that renders the list barely changes — it just loops over real rows instead of a hardcoded array. The ORDER BY done, created_at is a nice touch: unfinished tasks float to the top, finished ones sink, and within each group it's oldest-first.
Mutating tasks — add, toggle, delete
All three writes follow the PRG shape: detect the action in the POST handler at the top of index.php, run one prepared statement, redirect. Here's the add:
if (($_POST['action'] ?? '') === 'add_task') {
$label = trim($_POST['label'] ?? '');
$lane = ($_POST['lane'] ?? 'work') === 'home' ? 'home' : 'work';
if ($label !== '') {
$stmt = db()->prepare(
"INSERT INTO tasks (label, lane, tag) VALUES (?, ?, ?)"
);
$stmt->execute([$label, $lane, strtoupper(substr($lane,0,4))]);
}
header('Location: ' . tabUrl($lane === 'home' ? 'home' : 'work'));
exit;
}
Toggle and delete are the same idea with UPDATE tasks SET done = 1 - done WHERE id = ? and DELETE FROM tasks WHERE id = ?. Each action carries a hidden task id from a tiny form on each row. Notice we validate: $label must be non-empty, $lane is forced to one of two known values rather than trusted from the form. Never trust input — coerce it into a shape you control before it touches the database.
And every value goes through a prepared-statement placeholder, never string concatenation. That's not optional politeness — it's the line between safe code and a SQL-injection hole. The dashboard's existing e() helper handles the other direction: escape on the way out so a task labeled <script> renders as text, not code.
The add-task form
Each task lane gets a one-line form. No JavaScript — it's a plain POST that the PRG handler catches:
<form method="post" class="add-task" action="<?= e(tabUrl('work')) ?>">
<input type="hidden" name="action" value="add_task">
<input type="hidden" name="lane" value="work">
<input name="label" placeholder="add a task…" required>
<button type="submit">+</button>
</form>
This is the progressive-enhancement philosophy paying off again: tasks work with JavaScript completely disabled, because they're just forms and redirects. If you later want a slicker no-reload experience, you can layer a fetch() on top in app.js — but the baseline already works for everyone.
- Add the PRG handlers (add/toggle/delete) near the existing
toggle_doneblock inindex.php. - Swap the hardcoded Work tasks array for
tasks_for('work'), and the Home queue fortasks_for('home'). - Add the add-task form to each lane, and a tiny toggle + delete form to each row.
- Add a few tasks. Tick some. Delete one. Refresh hard. They're all still exactly as you left them.
// FAKE comments above the old arrays — two down. If a refresh ever re-adds a task, your handler is rendering instead of redirecting.
▣ Mini Project: A Task List That Remembers
Let's turn both lanes into a genuinely useful daily driver. The goal: open the dashboard in the morning, dump your tasks in, check them off through the day, and have it all still there tomorrow. The dashboard becomes a tool you actually run, not a demo you look at.
- Build
tasks_for()and wire both lanes to it. - Implement all three PRG actions:
add_task,toggle_task,delete_task. - Reuse the existing
qtagchip classes so home tasks and work tasks keep their colored tags. - Make the checkbox itself the toggle: wrap the
.cbspan in a tiny form button so clicking it poststoggle_task. - Test the full loop, including the refresh-after-add no-duplicate check.
Stretch goals:
- Add a "clear completed" button that deletes all done tasks in one lane.
- Add a due date column and sort overdue tasks to the very top in red.
- Log every completed task into the
activitytable — that's the bridge into the next chapter, where those rows become your real stats.
What you flexed: POST-Redirect-GET (the most important server-form pattern there is), full CRUD with prepared statements, input validation and output escaping as a habit, and rendering UI from database rows instead of literals. Two fake arrays are now real, and you did it with zero JavaScript.