THU.JUN.18
2026
23:37:52
← back to modules MODULE · 04 · FINISH THE SITE
0 / 10 chapters complete · 0%

Real Tasks & Queue — Add, Check, Persist

By the end, the Home "Today's queue" and the Work "Today's Tasks" stop being decorative checkboxes and become a real task list you can add to, tick off, and delete — and it all survives a refresh, because it lives in the tasks table you made last chapter.
You already have the perfect pattern in this codebase: the "mark chapter done" toggle uses POST-Redirect-GET. We reuse it. Read tasks with a 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.

  1. Add the PRG handlers (add/toggle/delete) near the existing toggle_done block in index.php.
  2. Swap the hardcoded Work tasks array for tasks_for('work'), and the Home queue for tasks_for('home').
  3. Add the add-task form to each lane, and a tiny toggle + delete form to each row.
  4. Add a few tasks. Tick some. Delete one. Refresh hard. They're all still exactly as you left them.
Tasks persist across refreshes and browsers, ticking one moves it to the bottom, deleting removes it, and refreshing after an add does not create a duplicate (that's PRG working). Delete the // 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.

  1. Build tasks_for() and wire both lanes to it.
  2. Implement all three PRG actions: add_task, toggle_task, delete_task.
  3. Reuse the existing qtag chip classes so home tasks and work tasks keep their colored tags.
  4. Make the checkbox itself the toggle: wrap the .cb span in a tiny form button so clicking it posts toggle_task.
  5. 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 activity table — 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.