7. Building the Analytics page

The Home Dashboard gives the project a front door. The Analytics page goes deeper. It turns the snapshot data from Chapter 16 into three charts: catalogue growth, artist diversity, and most-consistent artists.

The database is already there. This page adds one Flask route, three query helpers, one template, and one JavaScript file for the charts.

What the page will show

The Analytics page answers three questions about the user's music library.

  • Catalogue growth: how many tracks first appeared in each monthly snapshot.
  • Artist diversity: how many distinct artists appear in each monthly snapshot.
  • Most-consistent artists: which artists appear across the most snapshot dates.

These charts fit the data you already have. The project stores monthly top-50 snapshots, not every listening event, so the page analyses patterns across snapshots rather than pretending to know exact play counts.

Create the Analytics route

Open app.py and add the following helpers and route after the Home Dashboard route. The route gathers all three chart datasets and sends them to analytics.html.

app.py
# app.py -- additions for the Analytics page

def get_catalogue_growth():
    """Tracks first appearing per month from the snapshots table."""
    conn = get_db_connection()
    if not conn:
        return {'labels': [], 'data': []}
    try:
        cursor = conn.cursor()
        cursor.execute("""
            SELECT strftime('%Y-%m', s.snapshot_date) AS month,
                   COUNT(*) AS discoveries
            FROM snapshots s
            WHERE s.time_range = 'short_term'
              AND NOT EXISTS (
                  SELECT 1 FROM snapshots earlier
                  WHERE earlier.track_id = s.track_id
                    AND earlier.snapshot_date < s.snapshot_date
              )
            GROUP BY month
            ORDER BY month
        """)
        rows = cursor.fetchall()
        return {
            'labels': [_format_month(r['month']) for r in rows],
            'data': [r['discoveries'] for r in rows],
        }
    except sqlite3.Error as e:
        print(f"Catalogue-growth query error: {e}")
        return {'labels': [], 'data': []}
    finally:
        conn.close()


def get_artist_diversity():
    """Distinct artists per month from the snapshots table."""
    conn = get_db_connection()
    if not conn:
        return {'labels': [], 'data': []}
    try:
        cursor = conn.cursor()
        cursor.execute("""
            SELECT strftime('%Y-%m', s.snapshot_date) AS month,
                   COUNT(DISTINCT t.artist_name) AS artist_count
            FROM snapshots s
            JOIN tracks t ON t.track_id = s.track_id
            WHERE s.time_range = 'short_term'
            GROUP BY month
            ORDER BY month
        """)
        rows = cursor.fetchall()
        return {
            'labels': [_format_month(r['month']) for r in rows],
            'data': [r['artist_count'] for r in rows],
        }
    except sqlite3.Error as e:
        print(f"Artist-diversity query error: {e}")
        return {'labels': [], 'data': []}
    finally:
        conn.close()


def get_consistent_artists(limit=10):
    """Top artists by distinct-snapshot-date count."""
    conn = get_db_connection()
    if not conn:
        return {'labels': [], 'data': []}
    try:
        cursor = conn.cursor()
        cursor.execute("""
            SELECT t.artist_name,
                   COUNT(DISTINCT s.snapshot_date) AS appearances
            FROM tracks t
            JOIN snapshots s ON s.track_id = t.track_id
            WHERE s.time_range = 'short_term'
            GROUP BY t.artist_name
            ORDER BY appearances DESC
            LIMIT ?
        """, (limit,))
        rows = cursor.fetchall()
        return {
            'labels': [r['artist_name'] for r in rows],
            'data': [r['appearances'] for r in rows],
        }
    except sqlite3.Error as e:
        print(f"Consistent-artists query error: {e}")
        return {'labels': [], 'data': []}
    finally:
        conn.close()


@app.route('/analytics')
@require_auth
def analytics():
    """Three chart datasets on top of Chapter 16's snapshot schema."""
    chart_data = {
        'growth': get_catalogue_growth(),
        'diversity': get_artist_diversity(),
        'artists': get_consistent_artists(),
    }
    return render_template('analytics.html', chart_data=chart_data)

Each helper returns the same shape: labels for the x-axis, legend, or category names, and data for the numbers Chart.js will draw.

If a query fails or the database is missing data, the helper returns empty arrays. That keeps the page from crashing and lets the template show an empty-state message instead.

Create the Analytics template

Create templates/analytics.html. The template extends the base layout, creates three chart containers, and passes chart_data to JavaScript using |tojson|safe.

templates/analytics.html
{% extends "base.html" %}

{% block title %}Analytics - Music Time Machine{% endblock %}

{% block content %}
<div class="page-header">
  <h1>Analytics</h1>
  <p class="page-subtitle">The shape of your taste, three ways</p>
</div>

<div class="analytics-grid">

  <!-- Catalogue growth -->
  <div class="chart-container">
    <div class="chart-header">
      <h2>Catalogue growth</h2>
      <p class="chart-description">New tracks first seen per month</p>
    </div>
    <div class="chart-canvas-wrapper">
      {% if chart_data.growth.labels %}
        <canvas id="growthChart"></canvas>
      {% else %}
        <p class="chart-empty">Take more snapshots to populate this chart.</p>
      {% endif %}
    </div>
  </div>

  <!-- Artist diversity -->
  <div class="chart-container">
    <div class="chart-header">
      <h2>Artist diversity</h2>
      <p class="chart-description">Distinct artists per month</p>
    </div>
    <div class="chart-canvas-wrapper">
      {% if chart_data.diversity.labels %}
        <canvas id="diversityChart"></canvas>
      {% else %}
        <p class="chart-empty">
          Take more snapshots to populate this chart.
        </p>
      {% endif %}
    </div>
  </div>

  <!-- Most-consistent artists -->
  <div class="chart-container">
    <div class="chart-header">
      <h2>Most-consistent artists</h2>
      <p class="chart-description">Distinct snapshots an artist appears in</p>
    </div>
    <div class="chart-canvas-wrapper">
      {% if chart_data.artists.labels %}
        <canvas id="artistChart"></canvas>
      {% else %}
        <p class="chart-empty">Take more snapshots to populate this chart.</p>
      {% endif %}
    </div>
  </div>

</div>

{% endblock %}

{% block scripts %}
<script>
  const chartData = {{ chart_data|tojson|safe }};
</script>
<script src="{{ static_version('js/analytics-charts.js') }}"></script>
{% endblock %}

The empty-state checks matter. A new database might have no snapshot history yet, or only enough data for one chart. The page should explain that clearly instead of showing a broken canvas.

Add the chart JavaScript

Create static/js/analytics-charts.js. This file reads chartData from the template and creates one Chart.js instance for each canvas that exists on the page.

static/js/analytics-charts.js
// analytics-charts.js -- three chart instances on top of chartData.

document.addEventListener('DOMContentLoaded', function() {

  const spotifyGreen = '#1DB954';
  const darkGray = '#191414';
  const lightGray = '#B3B3B3';

  // Chart 1: Catalogue growth (line)
  const growthEl = document.getElementById('growthChart');
  if (growthEl && chartData.growth.labels.length) {
    new Chart(growthEl.getContext('2d'), {
      type: 'line',
      data: {
        labels: chartData.growth.labels,
        datasets: [{
          label: 'New tracks',
          data: chartData.growth.data,
          borderColor: spotifyGreen,
          backgroundColor: spotifyGreen + '1A', // ~10% alpha
          borderWidth: 2,
          fill: true,
          tension: 0.4,
          pointRadius: 4,
          pointBackgroundColor: spotifyGreen,
          pointBorderColor: '#fff',
          pointBorderWidth: 2,
          pointHoverRadius: 6
        }]
      },
      options: {
        responsive: true,
        maintainAspectRatio: true,
        aspectRatio: 2,
        plugins: {
          legend: { display: false },
          tooltip: {
            backgroundColor: darkGray,
            titleColor: '#fff',
            bodyColor: lightGray,
            borderColor: spotifyGreen,
            borderWidth: 1,
            padding: 12,
            displayColors: false,
            callbacks: {
              label: ctx => `${ctx.parsed.y} new tracks`
            }
          }
        },
        scales: {
          x: { grid: { color: darkGray }, ticks: { color: lightGray } },
          y: {
            grid: { color: darkGray },
            ticks: { color: lightGray },
            beginAtZero: true
          }
        }
      }
    });
  }

  // Chart 2: Artist diversity (bar)
  const diversityEl = document.getElementById('diversityChart');
  if (diversityEl && chartData.diversity.labels.length) {
    new Chart(diversityEl.getContext('2d'), {
      type: 'bar',
      data: {
        labels: chartData.diversity.labels,
        datasets: [{
          label: 'Distinct artists',
          data: chartData.diversity.data,
          backgroundColor: '#7C3AED',
          borderColor: '#7C3AED',
          borderWidth: 0,
          borderRadius: 4
        }]
      },
      options: {
        responsive: true,
        maintainAspectRatio: true,
        plugins: {
          legend: { display: false },
          tooltip: {
            backgroundColor: darkGray,
            titleColor: '#fff',
            bodyColor: lightGray,
            borderColor: spotifyGreen,
            borderWidth: 1,
            padding: 12,
            callbacks: { label: ctx => `${ctx.parsed.y} distinct artists` }
          }
        },
        scales: {
          x: { grid: { color: darkGray }, ticks: { color: lightGray } },
          y: {
            grid: { color: darkGray },
            ticks: { color: lightGray },
            beginAtZero: true
          }
        }
      }
    });
  }

  // Chart 3: Most-consistent artists (horizontal bar)
  const artistEl = document.getElementById('artistChart');
  if (artistEl && chartData.artists.labels.length) {
    new Chart(artistEl.getContext('2d'), {
      type: 'bar',
      data: {
        labels: chartData.artists.labels,
        datasets: [{
          label: 'Snapshots',
          data: chartData.artists.data,
          backgroundColor: spotifyGreen,
          borderColor: spotifyGreen,
          borderWidth: 0,
          borderRadius: 4
        }]
      },
      options: {
        indexAxis: 'y',
        responsive: true,
        maintainAspectRatio: true,
        aspectRatio: 1.2,
        plugins: {
          legend: { display: false },
          tooltip: {
            backgroundColor: darkGray,
            titleColor: '#fff',
            bodyColor: lightGray,
            borderColor: spotifyGreen,
            borderWidth: 1,
            padding: 12,
            displayColors: false,
            callbacks: {
              label: ctx => `Appeared in ${ctx.parsed.x} snapshots`
            }
          }
        },
        scales: {
          x: {
            grid: { color: darkGray },
            ticks: { color: lightGray, stepSize: 1 },
            beginAtZero: true
          },
          y: {
            grid: { display: false },
            ticks: { color: lightGray, font: { size: 11 } }
          }
        }
      }
    });
  }

});

The guards before each chart are important: if (growthEl && chartData.growth.labels.length). They stop JavaScript from trying to draw a chart when the template has shown an empty-state message instead of a canvas.

Add the Analytics styles

Add these rules to dashboard.css. They create the chart cards, keep the canvas area stable, and reduce spacing on smaller screens.

static/css/dashboard.css
/* Analytics Page Styles */

.analytics-grid {
  display: grid;
  gap: 2rem;
  margin-top: 2rem;
}

.analytics-grid .chart-container {
  background: #181818;
  border-radius: 8px;
  padding: 1.5rem;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}

.chart-header {
  margin-bottom: 1rem;
}

.chart-header h2 {
  color: #fff;
  font-size: 1.25rem;
  margin-bottom: 0.5rem;
}

.chart-description {
  color: #B3B3B3;
  font-size: 0.875rem;
  margin: 0;
}

.chart-canvas-wrapper {
  position: relative;
  min-height: 300px;
}

.chart-empty {
  color: #B3B3B3;
  font-style: italic;
  text-align: center;
  padding: 2rem 1rem;
  margin: 0;
}

.error-state {
  background: #282828;
  border: 2px solid #FF4444;
  border-radius: 8px;
  padding: 2rem;
  text-align: center;
  margin-top: 2rem;
}

.error-state p {
  color: #B3B3B3;
  margin-bottom: 0.5rem;
}

/* Responsive adjustments */
@media (max-width: 768px) {
  .analytics-grid .chart-container {
    padding: 1rem;
  }
  
  .chart-header h2 {
    font-size: 1.125rem;
  }
  
  .chart-canvas-wrapper {
    min-height: 250px;
  }
}

Test the page

Run the Flask development server and open http://127.0.0.1:5000/analytics.

  • The catalogue-growth chart should render if the database has at least one snapshot.
  • The artist-diversity chart should render if the database has at least one snapshot.
  • The artist chart should show up to ten artists.
  • Hovering over each chart should show readable tooltip text.
  • An empty database should show empty-state messages instead of broken charts.

If a chart does not render

Open DevTools and check the Console first. Then confirm these four things:

  • The canvas ID in the template matches the ID used in analytics-charts.js.
  • chartData appears in the page source.
  • The JavaScript keys match the Python keys: growth, diversity, and artists.
  • Chart.js loaded successfully from the CDN through base.html.

The most common mistake is a naming mismatch. For example, if Python sends chart_data['diversity'] but JavaScript reads chartData.artistDiversity, the chart has nothing to draw.

This page uses Chart.js 4.4.0. If you copy examples from older tutorials, some option names may not match this version.