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

Logging Real Practice — Music That Remembers

The Music tab's 14-day practice grid is a hardcoded list of numbers, and the playlist forgets everything. By the end you'll log real practice sessions that fill the grid for real, and the playlist will come from the database — so the player you built in chapter 3 reads live data instead of a baked-in array.
A practice_log table with one row per day (upsert to add minutes to today). The grid reads the last 14 days. A tracks table feeds the playlist, so adding a song is an INSERT, not a code edit. Practice minutes flow into the Home stats you built last chapter.

One row per day — the upsert pattern

Practice tracking has a nice natural shape: you might log a few sessions in one day, but the grid shows a single total per day. So instead of inserting a new row every session, we keep one row per date and add to it. That's an upsert — "insert if new, update if it exists" — and MariaDB does it in one statement thanks to the unique key on on_date you created in chapter 4:

function log_practice(int $minutes): void {
    $stmt = db()->prepare(
        "INSERT INTO practice_log (on_date, minutes)
         VALUES (CURDATE(), ?)
         ON DUPLICATE KEY UPDATE minutes = minutes + VALUES(minutes)"
    );
    $stmt->execute([$minutes]);
}

Read that ON DUPLICATE KEY UPDATE clause out loud: "try to insert today's row; if today already exists (the unique key collides), instead add these minutes to what's there." One round trip, no race conditions, no "does today's row exist yet?" check in PHP. This is one of those SQL features that feels like magic the first time it saves you ten lines of code.

🐍 Python brain: upsert is the SQL version of dict.setdefault(key, 0); d[key] += n — "make sure today exists, then add to it" — except the database does it atomically.

Filling the grid from real data

The 14-day grid is currently a literal $mins = [42, 18, 0, 35, ...] array. We swap it for a query of the last 14 days, then map results onto the day cells:

$rows = db()->query(
    "SELECT on_date, minutes FROM practice_log
     WHERE on_date >= CURDATE() - INTERVAL 13 DAY"
)->fetchAll(PDO::FETCH_KEY_PAIR);   // [ '2026-05-29' => 42, ... ]

// then for each of the last 14 dates, look up minutes (0 if absent)

FETCH_KEY_PAIR is a tidy little PDO mode that turns a two-column result into an associative array keyed by the first column — perfect for "date → minutes" lookups. Days with no row simply aren't in the array, so they render as the empty "–" cell. The existing grid markup barely changes; only the data source does.

The playlist comes from the database now

Here's where chapter 3 pays a dividend. Back then, the player read each track's file path from a data-src attribute, and the playlist was a hardcoded PHP array. Now we move that array into a tracks table:

CREATE TABLE tracks (
    id      INT PRIMARY KEY AUTO_INCREMENT,
    title   VARCHAR(200) NOT NULL,
    artist  VARCHAR(200) NOT NULL,
    file    VARCHAR(255) NOT NULL,
    sort    INT NOT NULL DEFAULT 0
);

The Music tab renders rows from SELECT * FROM tracks ORDER BY sort, id instead of the literal array. And here's the beautiful part: app.js doesn't change at all. It still just reads data-src / data-title / data-artist off each row — it neither knows nor cares whether those came from a hardcoded array or a database query. That's the data-attribute bridge from chapter 3 doing exactly what good design is supposed to do: let you swap the back end without touching the front end.

Tying music into the stats you already built

Remember log_activity() from chapter 6? When you log practice, log an activity row too, so the Home tab's MUSIC metric and streak count it:

log_practice($minutes);
log_activity('music', "$minutes min practice");

Now everything connects: practicing fills the music grid and nudges your Home stats and extends your streak. That web of small connections — one action updating several real views — is what makes a dashboard feel alive instead of like a pile of separate widgets.

  1. Add a tiny "log practice" form on the Music tab: a number input for minutes + a submit, posting to a PRG handler that calls log_practice().
  2. Swap the hardcoded $mins grid array for the 14-day query.
  3. Create the tracks table, insert your songs, and render the playlist from it.
  4. Log 30 minutes. Watch today's grid cell fill in, and check the Home MUSIC bar move.
Logging minutes fills today's grid cell and bumps your Home music stat; logging again the same day adds rather than replaces (that's the upsert); the playlist renders from the tracks table and the player still plays it without an app.js change. Cross off the practice-grid and playlist // FAKE comments.

▣ Mini Project: A Practice Tracker You'll Actually Use

Let's make the Music tab a real practice companion. The dream: finish a practice session, punch in the minutes, and watch your streak grid fill cell by cell over the days — that little wall of squares is weirdly motivating once it's real.

  1. Build the log_practice() upsert and the PRG form to call it.
  2. Render the 14-day grid from practice_log using FETCH_KEY_PAIR.
  3. Create + seed the tracks table and render the playlist from it (player untouched).
  4. Call log_activity('music', ...) alongside each practice log so it shows up on Home.
  5. Log a few days (backfill some past dates by hand) and admire the grid + the moving stats.

Stretch goals:

  • Add an instrument or focus-area column and a per-area breakdown.
  • Compute "avg / best" in the grid footer from the real data instead of the static "avg 28m · best 60m".
  • Add a daily goal (say 30 min) and color today's cell green when you hit it.

What you flexed: the upsert (ON DUPLICATE KEY UPDATE), FETCH_KEY_PAIR for clean lookups, moving a hardcoded list into a table without touching the front-end code that consumes it, and weaving one action into multiple real views. The Music tab remembers now.