Home / Side Quests / Salary Heatmap

Salary Heatmap

Estimated time: 4–5 hours | Difficulty: Intermediate

Side Quest

What You Will Build

  • Write JPQL aggregate queries to compute salary statistics from your jobs database
  • Build Spring Boot REST endpoints that return aggregated salary data
  • Add Chart.js to your project via CDN and create four distinct chart types
  • Build a responsive analytics dashboard page with interactive visualizations
  • Handle missing and incomplete salary data gracefully
  • Develop data literacy — understanding what your charts actually represent

1. Your Data is Valuable

You have been building the Resumator for weeks now. Every time you searched for jobs, every time you saved a favorite, every time you browsed through results — data was accumulating in your database. Your jobs table is not just a list of search results anymore. It is a dataset. And datasets tell stories.

Think about what is sitting in that table right now. Hundreds, maybe thousands of job listings. Each one has a title, a company name, a city, a state, and — for many of them — a salary range. You have been collecting this data through your normal usage of the application, and now it is time to do something with it.

What do software developers actually earn in Austin versus San Francisco? Which job titles command the highest salaries? Which companies are hiring the most? How have salary ranges changed over the weeks you have been collecting data? These are real questions, and your database has real answers. You just need to extract them and present them visually.

That is exactly what you are going to build in this side quest: a salary analytics dashboard that turns your raw job data into interactive, visual charts. By the end, you will have a page in your Resumator that looks like something out of a professional analytics tool — and every number on it comes from data you collected yourself.

This side quest spans the full stack. You will write JPQL aggregate queries on the backend, build REST endpoints in your Spring Boot controller, add the Chart.js library via CDN on the frontend, and write JavaScript to fetch data and render four different chart types. Along the way, you will also grapple with a question that most tutorials never address: what do your charts actually mean when the underlying data is incomplete?

Why visualization matters A table with 500 rows of salary data is useful but overwhelming. A bar chart that instantly shows you "Austin pays $95K average, San Francisco pays $135K average" communicates the same information in a fraction of a second. Visualization is not decoration — it is a fundamentally different way of understanding data. It lets your brain's pattern recognition do the heavy lifting instead of forcing you to scan numbers one by one.

2. Choosing a Chart Library

You could build charts from scratch using raw HTML canvas or SVG elements. People do this. It is also an enormous amount of work for results that will almost certainly look worse than what a library can give you. In professional development, choosing the right library is a skill in itself. You want something that is well-documented, actively maintained, flexible enough for your needs, and not so complex that you spend more time learning the library than building your feature.

For this side quest, you are going to use Chart.js. Here is why:

You already know how to add external libraries via CDN — you did it with Monaco Editor earlier in the course. Chart.js works the same way. You include a single <script> tag, and the library is available globally. No npm install, no build step, no bundler configuration.

Other chart libraries worth knowing about Chart.js is not the only option. D3.js is the most powerful and flexible charting library, but it has a steep learning curve and requires you to build everything from primitives. Apache ECharts is popular in enterprise applications with complex requirements. Recharts is designed specifically for React applications. Plotly is excellent for scientific and statistical visualizations. For this project, Chart.js gives you the best balance of power and simplicity. As you grow as a developer, you will encounter these other libraries in different contexts.

Chart.js supports several chart types that are perfect for salary data analysis:

You are going to use all four of these in your dashboard.

3. Backend Aggregation Endpoints

Before you can visualize anything, you need the data in a format that Chart.js can consume. Right now, your database has raw job listings — individual rows with individual salary values. What your charts need are aggregated results: averages, counts, and groupings. You need to ask the database questions like "What is the average salary for each city?" and get back a concise result set instead of thousands of individual rows.

This is where JPQL aggregate queries come in. JPQL (Java Persistence Query Language) looks a lot like SQL, but it operates on your Java entities instead of raw database tables. You can use functions like AVG(), COUNT(), MIN(), and MAX() combined with GROUP BY to aggregate your data at the database level — which is vastly more efficient than loading every row into Java and doing the math there.

Create a new repository interface called JobAnalyticsRepository. This will hold all of your aggregate queries:

package com.example.resumator.repository;

import com.example.resumator.model.Job;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import java.util.List;

public interface JobAnalyticsRepository extends JpaRepository<Job, Long> {

    // Average salary by city (top 10 cities by job count)
    @Query("SELECT j.jobCity, AVG(j.jobMinSalary), AVG(j.jobMaxSalary), COUNT(j) " +
           "FROM Job j " +
           "WHERE j.jobCity IS NOT NULL AND j.jobMinSalary IS NOT NULL " +
           "GROUP BY j.jobCity " +
           "ORDER BY COUNT(j) DESC")
    List<Object[]> findAverageSalaryByCity();

    // Average salary by role keyword (extracted from job title)
    @Query("SELECT " +
           "CASE " +
           "  WHEN LOWER(j.jobTitle) LIKE '%senior%' THEN 'Senior' " +
           "  WHEN LOWER(j.jobTitle) LIKE '%lead%' THEN 'Lead' " +
           "  WHEN LOWER(j.jobTitle) LIKE '%principal%' THEN 'Principal' " +
           "  WHEN LOWER(j.jobTitle) LIKE '%staff%' THEN 'Staff' " +
           "  WHEN LOWER(j.jobTitle) LIKE '%junior%' THEN 'Junior' " +
           "  WHEN LOWER(j.jobTitle) LIKE '%entry%' THEN 'Entry Level' " +
           "  WHEN LOWER(j.jobTitle) LIKE '%intern%' THEN 'Intern' " +
           "  ELSE 'Mid Level' " +
           "END, " +
           "AVG(j.jobMinSalary), AVG(j.jobMaxSalary), COUNT(j) " +
           "FROM Job j " +
           "WHERE j.jobMinSalary IS NOT NULL " +
           "GROUP BY " +
           "CASE " +
           "  WHEN LOWER(j.jobTitle) LIKE '%senior%' THEN 'Senior' " +
           "  WHEN LOWER(j.jobTitle) LIKE '%lead%' THEN 'Lead' " +
           "  WHEN LOWER(j.jobTitle) LIKE '%principal%' THEN 'Principal' " +
           "  WHEN LOWER(j.jobTitle) LIKE '%staff%' THEN 'Staff' " +
           "  WHEN LOWER(j.jobTitle) LIKE '%junior%' THEN 'Junior' " +
           "  WHEN LOWER(j.jobTitle) LIKE '%entry%' THEN 'Entry Level' " +
           "  WHEN LOWER(j.jobTitle) LIKE '%intern%' THEN 'Intern' " +
           "  ELSE 'Mid Level' " +
           "END " +
           "ORDER BY AVG(j.jobMaxSalary) DESC")
    List<Object[]> findAverageSalaryByRole();

    // Top employers by number of job listings
    @Query("SELECT j.employerName, COUNT(j), " +
           "AVG(j.jobMinSalary), AVG(j.jobMaxSalary) " +
           "FROM Job j " +
           "WHERE j.employerName IS NOT NULL " +
           "GROUP BY j.employerName " +
           "ORDER BY COUNT(j) DESC")
    List<Object[]> findTopEmployers();

    // Average salary over time (grouped by saved week)
    @Query("SELECT FUNCTION('DATE_FORMAT', j.savedAt, '%Y-%u'), " +
           "AVG(j.jobMinSalary), AVG(j.jobMaxSalary), COUNT(j) " +
           "FROM Job j " +
           "WHERE j.jobMinSalary IS NOT NULL AND j.savedAt IS NOT NULL " +
           "GROUP BY FUNCTION('DATE_FORMAT', j.savedAt, '%Y-%u') " +
           "ORDER BY FUNCTION('DATE_FORMAT', j.savedAt, '%Y-%u') ASC")
    List<Object[]> findSalaryOverTime();

    // Jobs count by state
    @Query("SELECT j.jobState, COUNT(j) " +
           "FROM Job j " +
           "WHERE j.jobState IS NOT NULL " +
           "GROUP BY j.jobState " +
           "ORDER BY COUNT(j) DESC")
    List<Object[]> findJobCountByState();
}

Let us break down what each of these queries does. The findAverageSalaryByCity query groups all jobs by their city, then calculates the average minimum salary, average maximum salary, and total count for each city. The WHERE clause filters out jobs that have no city or no salary data — you cannot calculate an average of nothing. The ORDER BY COUNT(j) DESC sorts the results so the cities with the most job listings appear first.

The findAverageSalaryByRole query is more interesting. There is no "role level" column in your database — you have to extract it from the job title. The CASE expression examines the title and categorizes it: if the title contains "senior," it is classified as "Senior." If it contains "lead," it is "Lead." And so on. This is a common real-world pattern — deriving categories from unstructured text data.

Handling missing salary data Not every job listing includes salary information. Some employers choose not to disclose it. Your queries use WHERE j.jobMinSalary IS NOT NULL to exclude jobs without salary data from the calculations. This is critical — including null values would skew your averages or cause errors. Later in the frontend, you will also want to show users how many jobs have salary data versus how many do not, so they understand the sample size behind each chart.

Now you need a controller to expose these queries as REST endpoints. Create AnalyticsController.java:

package com.example.resumator.controller;

import com.example.resumator.repository.JobAnalyticsRepository;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.*;

@RestController
@RequestMapping("/api/analytics")
@CrossOrigin(origins = "*")
public class AnalyticsController {

    private final JobAnalyticsRepository analyticsRepo;

    public AnalyticsController(JobAnalyticsRepository analyticsRepo) {
        this.analyticsRepo = analyticsRepo;
    }

    @GetMapping("/salary-by-city")
    public ResponseEntity<List<Map<String, Object>>> getSalaryByCity() {
        List<Object[]> results = analyticsRepo.findAverageSalaryByCity();
        List<Map<String, Object>> data = new ArrayList<>();

        int limit = Math.min(results.size(), 10);
        for (int i = 0; i < limit; i++) {
            Object[] row = results.get(i);
            Map<String, Object> entry = new LinkedHashMap<>();
            entry.put("city", row[0]);
            entry.put("avgMinSalary", row[1] != null ? Math.round((Double) row[1]) : 0);
            entry.put("avgMaxSalary", row[2] != null ? Math.round((Double) row[2]) : 0);
            entry.put("jobCount", row[3]);
            data.add(entry);
        }

        return ResponseEntity.ok(data);
    }

    @GetMapping("/salary-by-role")
    public ResponseEntity<List<Map<String, Object>>> getSalaryByRole() {
        List<Object[]> results = analyticsRepo.findAverageSalaryByRole();
        List<Map<String, Object>> data = new ArrayList<>();

        for (Object[] row : results) {
            Map<String, Object> entry = new LinkedHashMap<>();
            entry.put("role", row[0]);
            entry.put("avgMinSalary", row[1] != null ? Math.round((Double) row[1]) : 0);
            entry.put("avgMaxSalary", row[2] != null ? Math.round((Double) row[2]) : 0);
            entry.put("jobCount", row[3]);
            data.add(entry);
        }

        return ResponseEntity.ok(data);
    }

    @GetMapping("/top-employers")
    public ResponseEntity<List<Map<String, Object>>> getTopEmployers() {
        List<Object[]> results = analyticsRepo.findTopEmployers();
        List<Map<String, Object>> data = new ArrayList<>();

        int limit = Math.min(results.size(), 15);
        for (int i = 0; i < limit; i++) {
            Object[] row = results.get(i);
            Map<String, Object> entry = new LinkedHashMap<>();
            entry.put("employer", row[0]);
            entry.put("jobCount", row[1]);
            entry.put("avgMinSalary", row[2] != null ? Math.round((Double) row[2]) : 0);
            entry.put("avgMaxSalary", row[3] != null ? Math.round((Double) row[3]) : 0);
            data.add(entry);
        }

        return ResponseEntity.ok(data);
    }

    @GetMapping("/salary-over-time")
    public ResponseEntity<List<Map<String, Object>>> getSalaryOverTime() {
        List<Object[]> results = analyticsRepo.findSalaryOverTime();
        List<Map<String, Object>> data = new ArrayList<>();

        for (Object[] row : results) {
            Map<String, Object> entry = new LinkedHashMap<>();
            entry.put("week", row[0]);
            entry.put("avgMinSalary", row[1] != null ? Math.round((Double) row[1]) : 0);
            entry.put("avgMaxSalary", row[2] != null ? Math.round((Double) row[2]) : 0);
            entry.put("jobCount", row[3]);
            data.add(entry);
        }

        return ResponseEntity.ok(data);
    }

    @GetMapping("/jobs-by-state")
    public ResponseEntity<List<Map<String, Object>>> getJobsByState() {
        List<Object[]> results = analyticsRepo.findJobCountByState();
        List<Map<String, Object>> data = new ArrayList<>();

        int limit = Math.min(results.size(), 12);
        for (int i = 0; i < limit; i++) {
            Object[] row = results.get(i);
            Map<String, Object> entry = new LinkedHashMap<>();
            entry.put("state", row[0]);
            entry.put("jobCount", row[1]);
            data.add(entry);
        }

        return ResponseEntity.ok(data);
    }
}

Notice the pattern: each endpoint calls a repository method, transforms the raw Object[] results into clean Map objects with descriptive keys, and returns them as JSON. The frontend will receive data that looks like this:

[
  { "city": "Austin", "avgMinSalary": 85000, "avgMaxSalary": 125000, "jobCount": 47 },
  { "city": "San Francisco", "avgMinSalary": 110000, "avgMaxSalary": 165000, "jobCount": 38 },
  { "city": "New York", "avgMinSalary": 95000, "avgMaxSalary": 145000, "jobCount": 35 }
]

Clean, predictable JSON that Chart.js can consume directly. Notice how each endpoint also limits the results — salary-by-city returns the top 10, top-employers returns the top 15, jobs-by-state returns the top 12. This keeps the charts readable. A bar chart with 200 bars is useless. A bar chart with 10 bars tells a clear story.

Why transform Object[] into Map? JPQL aggregate queries return List<Object[]> — each row is an array of raw objects. If you returned these directly, the JSON would be meaningless arrays like ["Austin", 85000, 125000, 47]. By converting each row to a Map with named keys, the frontend gets self-describing data. This is a small amount of extra work in the controller that makes the frontend code dramatically cleaner and easier to maintain.

One important detail about the salary-over-time endpoint: it uses FUNCTION('DATE_FORMAT', j.savedAt, '%Y-%u') to group jobs by the year and week number they were saved. This is a MySQL-specific function called through JPQL's generic FUNCTION() syntax. The format '%Y-%u' produces strings like "2025-14" (year 2025, week 14). Grouping by week smooths out the daily noise — if you grouped by day, you might have some days with 1 job and other days with 20, making the chart erratic. Weekly grouping gives you a cleaner trend line while still showing meaningful change over time.

Also notice the Math.round() calls in the controller. Salary averages often produce values like 95432.17647058824. Nobody needs that many decimal places for a salary chart. Rounding to the nearest whole number at the backend level means the frontend receives clean integers, and you do not have to worry about formatting precision issues in JavaScript.

Testing your endpoints before building the frontend Before writing any Chart.js code, test your endpoints directly. Open your browser and navigate to http://localhost:8080/api/analytics/salary-by-city. You should see a JSON array. If you see an empty array [], your database might not have any jobs with salary data yet — go back to the search page and run some searches to populate data. If you see a 500 error, check the Spring Boot console for the stack trace. Testing endpoints independently before connecting them to the frontend is a professional habit that saves enormous debugging time.

4. Building the Dashboard Page

Now comes the exciting part — building the actual analytics page. You need a new route in your Resumator application that serves a dashboard with canvas elements for each chart. First, you will need to add Chart.js via CDN and create the page structure. Then you will write the JavaScript that fetches data from your endpoints and renders it.

If your Resumator uses Thymeleaf templates, create a new file called analytics.html in your templates folder. If you are serving static HTML, create it in your static folder. Either way, the content is the same. Here is the HTML structure for your dashboard:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Salary Analytics — Resumator</title>
    <!-- Chart.js via CDN — same pattern as Monaco Editor -->
    <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body {
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
            background: #f5f5f5;
            color: #333;
            padding: 2rem;
        }
        .dashboard-header {
            text-align: center;
            margin-bottom: 2rem;
        }
        .dashboard-header h1 { font-size: 2rem; margin-bottom: 0.5rem; }
        .dashboard-header p { color: #666; }
        .dashboard-grid {
            display: grid;
            grid-template-columns: repeat(auto-fit, minmax(500px, 1fr));
            gap: 1.5rem;
            max-width: 1400px;
            margin: 0 auto;
        }
        .chart-card {
            background: white;
            border-radius: 12px;
            padding: 1.5rem;
            box-shadow: 0 2px 8px rgba(0,0,0,0.08);
        }
        .chart-card h3 {
            font-size: 1.1rem;
            margin-bottom: 0.25rem;
        }
        .chart-card .chart-subtitle {
            font-size: 0.85rem;
            color: #888;
            margin-bottom: 1rem;
        }
        .chart-card canvas { width: 100% !important; }
        .data-notice {
            text-align: center;
            margin-top: 2rem;
            padding: 1rem;
            background: #fff3cd;
            border-radius: 8px;
            font-size: 0.9rem;
            color: #664d03;
            max-width: 1400px;
            margin-left: auto;
            margin-right: auto;
        }
        .loading-spinner {
            text-align: center;
            padding: 3rem;
            color: #888;
            font-size: 1.1rem;
        }
        @media (max-width: 600px) {
            .dashboard-grid {
                grid-template-columns: 1fr;
            }
            body { padding: 1rem; }
        }
    </style>
</head>
<body>
    <div class="dashboard-header">
        <h1>Salary Analytics</h1>
        <p>Insights from your collected job data</p>
    </div>

    <div class="dashboard-grid">
        <div class="chart-card">
            <h3>Average Salary by City</h3>
            <p class="chart-subtitle">Top 10 cities by number of listings</p>
            <canvas id="salaryByCityChart"></canvas>
        </div>

        <div class="chart-card">
            <h3>Average Salary by Role Level</h3>
            <p class="chart-subtitle">Based on keywords in job titles</p>
            <canvas id="salaryByRoleChart"></canvas>
        </div>

        <div class="chart-card">
            <h3>Jobs by State</h3>
            <p class="chart-subtitle">Distribution across top states</p>
            <canvas id="jobsByStateChart"></canvas>
        </div>

        <div class="chart-card">
            <h3>Salary Trends Over Time</h3>
            <p class="chart-subtitle">Weekly average from your collected data</p>
            <canvas id="salaryOverTimeChart"></canvas>
        </div>
    </div>

    <div class="data-notice">
        These charts reflect only jobs with reported salary data.
        Not all listings include salaries — your actual dataset may be larger.
    </div>
</body>
</html>

The layout uses CSS Grid with auto-fit and minmax to create a responsive grid that automatically adjusts from two columns on a desktop to a single column on a phone. Each chart lives inside a .chart-card with a white background, rounded corners, and a subtle shadow. The <canvas> elements are where Chart.js will render the actual charts — the library draws directly onto the HTML canvas element.

Now here is the JavaScript that brings it all to life. This is where your endpoints meet Chart.js. Add this script at the bottom of the page, right before the closing </body> tag:

// Color palettes for our charts
const COLORS = {
    blue: ['#3b82f6', '#60a5fa', '#93c5fd', '#bfdbfe', '#dbeafe',
           '#2563eb', '#1d4ed8', '#1e40af', '#1e3a8a', '#172554'],
    warm: ['#f59e0b', '#f97316', '#ef4444', '#ec4899', '#a855f7',
           '#6366f1', '#3b82f6', '#06b6d4', '#10b981', '#84cc16'],
    green: '#10b981',
    red: '#ef4444',
    purple: '#8b5cf6'
};

// Utility: format salary as $XXk
function formatSalary(value) {
    if (!value || value === 0) return '$0';
    return '$' + Math.round(value / 1000) + 'k';
}

// Store chart instances so we can update/destroy them later
const charts = {};

// ============================================
// Chart 1: Average Salary by City (Bar Chart)
// ============================================
async function renderSalaryByCity() {
    try {
        const response = await fetch('/api/analytics/salary-by-city');
        const data = await response.json();

        if (data.length === 0) {
            document.getElementById('salaryByCityChart')
                .parentElement.innerHTML += '<p>No salary data available yet.</p>';
            return;
        }

        const labels = data.map(d => d.city);
        const minSalaries = data.map(d => d.avgMinSalary);
        const maxSalaries = data.map(d => d.avgMaxSalary);
        const midSalaries = data.map((d, i) =>
            Math.round((minSalaries[i] + maxSalaries[i]) / 2));

        const ctx = document.getElementById('salaryByCityChart').getContext('2d');
        charts.salaryByCity = new Chart(ctx, {
            type: 'bar',
            data: {
                labels: labels,
                datasets: [
                    {
                        label: 'Avg Min Salary',
                        data: minSalaries,
                        backgroundColor: '#3b82f680',
                        borderColor: '#3b82f6',
                        borderWidth: 1
                    },
                    {
                        label: 'Midpoint',
                        data: midSalaries,
                        backgroundColor: '#8b5cf680',
                        borderColor: '#8b5cf6',
                        borderWidth: 1
                    },
                    {
                        label: 'Avg Max Salary',
                        data: maxSalaries,
                        backgroundColor: '#10b98180',
                        borderColor: '#10b981',
                        borderWidth: 1
                    }
                ]
            },
            options: {
                responsive: true,
                plugins: {
                    tooltip: {
                        callbacks: {
                            label: function(context) {
                                const idx = context.dataIndex;
                                const count = data[idx].jobCount;
                                return context.dataset.label + ': ' +
                                       formatSalary(context.raw) +
                                       ' (' + count + ' jobs)';
                            }
                        }
                    },
                    legend: { position: 'top' }
                },
                scales: {
                    y: {
                        beginAtZero: true,
                        ticks: {
                            callback: function(value) {
                                return formatSalary(value);
                            }
                        }
                    },
                    x: {
                        ticks: { maxRotation: 45, minRotation: 0 }
                    }
                },
                onClick: function(event, elements) {
                    if (elements.length > 0) {
                        const index = elements[0].index;
                        const city = data[index].city;
                        console.log('Clicked city:', city);
                        highlightCity(city);
                    }
                }
            }
        });
    } catch (error) {
        console.error('Failed to load salary by city:', error);
    }
}

// ==========================================================
// Chart 2: Average Salary by Role (Horizontal Bar Chart)
// ==========================================================
async function renderSalaryByRole() {
    try {
        const response = await fetch('/api/analytics/salary-by-role');
        const data = await response.json();

        if (data.length === 0) return;

        const labels = data.map(d => d.role);
        const minSalaries = data.map(d => d.avgMinSalary);
        const maxSalaries = data.map(d => d.avgMaxSalary);

        const ctx = document.getElementById('salaryByRoleChart').getContext('2d');
        charts.salaryByRole = new Chart(ctx, {
            type: 'bar',
            data: {
                labels: labels,
                datasets: [
                    {
                        label: 'Avg Min Salary',
                        data: minSalaries,
                        backgroundColor: '#f59e0b80',
                        borderColor: '#f59e0b',
                        borderWidth: 1
                    },
                    {
                        label: 'Avg Max Salary',
                        data: maxSalaries,
                        backgroundColor: '#ef444480',
                        borderColor: '#ef4444',
                        borderWidth: 1
                    }
                ]
            },
            options: {
                indexAxis: 'y',   // This makes it a horizontal bar chart
                responsive: true,
                plugins: {
                    tooltip: {
                        callbacks: {
                            label: function(context) {
                                const idx = context.dataIndex;
                                const count = data[idx].jobCount;
                                return context.dataset.label + ': ' +
                                       formatSalary(context.raw) +
                                       ' (' + count + ' jobs)';
                            }
                        }
                    },
                    legend: { position: 'top' }
                },
                scales: {
                    x: {
                        beginAtZero: true,
                        ticks: {
                            callback: function(value) {
                                return formatSalary(value);
                            }
                        }
                    }
                }
            }
        });
    } catch (error) {
        console.error('Failed to load salary by role:', error);
    }
}

// ============================================
// Chart 3: Jobs by State (Doughnut Chart)
// ============================================
async function renderJobsByState() {
    try {
        const response = await fetch('/api/analytics/jobs-by-state');
        const data = await response.json();

        if (data.length === 0) return;

        const labels = data.map(d => d.state);
        const counts = data.map(d => d.jobCount);

        const ctx = document.getElementById('jobsByStateChart').getContext('2d');
        charts.jobsByState = new Chart(ctx, {
            type: 'doughnut',
            data: {
                labels: labels,
                datasets: [{
                    data: counts,
                    backgroundColor: COLORS.warm,
                    borderWidth: 2,
                    borderColor: '#ffffff'
                }]
            },
            options: {
                responsive: true,
                plugins: {
                    tooltip: {
                        callbacks: {
                            label: function(context) {
                                const total = counts.reduce((a, b) => a + b, 0);
                                const pct = ((context.raw / total) * 100).toFixed(1);
                                return context.label + ': ' +
                                       context.raw + ' jobs (' + pct + '%)';
                            }
                        }
                    },
                    legend: {
                        position: 'right',
                        labels: { boxWidth: 12, padding: 8 }
                    }
                }
            }
        });
    } catch (error) {
        console.error('Failed to load jobs by state:', error);
    }
}

// ============================================
// Chart 4: Salary Over Time (Line Chart)
// ============================================
async function renderSalaryOverTime() {
    try {
        const response = await fetch('/api/analytics/salary-over-time');
        const data = await response.json();

        if (data.length === 0) return;

        const labels = data.map(d => 'Week ' + d.week.split('-')[1]);
        const minSalaries = data.map(d => d.avgMinSalary);
        const maxSalaries = data.map(d => d.avgMaxSalary);
        const midSalaries = data.map((d, i) =>
            Math.round((minSalaries[i] + maxSalaries[i]) / 2));

        const ctx = document.getElementById('salaryOverTimeChart').getContext('2d');
        charts.salaryOverTime = new Chart(ctx, {
            type: 'line',
            data: {
                labels: labels,
                datasets: [
                    {
                        label: 'Avg Max Salary',
                        data: maxSalaries,
                        borderColor: '#10b981',
                        backgroundColor: '#10b98120',
                        fill: true,
                        tension: 0.3,
                        pointRadius: 4,
                        pointHoverRadius: 7
                    },
                    {
                        label: 'Midpoint',
                        data: midSalaries,
                        borderColor: '#8b5cf6',
                        backgroundColor: '#8b5cf620',
                        fill: false,
                        tension: 0.3,
                        borderDash: [5, 5],
                        pointRadius: 3,
                        pointHoverRadius: 6
                    },
                    {
                        label: 'Avg Min Salary',
                        data: minSalaries,
                        borderColor: '#3b82f6',
                        backgroundColor: '#3b82f620',
                        fill: true,
                        tension: 0.3,
                        pointRadius: 4,
                        pointHoverRadius: 7
                    }
                ]
            },
            options: {
                responsive: true,
                plugins: {
                    tooltip: {
                        mode: 'index',
                        intersect: false,
                        callbacks: {
                            label: function(context) {
                                const idx = context.dataIndex;
                                const count = data[idx].jobCount;
                                return context.dataset.label + ': ' +
                                       formatSalary(context.raw) +
                                       ' (' + count + ' jobs that week)';
                            }
                        }
                    },
                    legend: { position: 'top' }
                },
                scales: {
                    y: {
                        beginAtZero: false,
                        ticks: {
                            callback: function(value) {
                                return formatSalary(value);
                            }
                        }
                    }
                },
                interaction: {
                    mode: 'nearest',
                    axis: 'x',
                    intersect: false
                }
            }
        });
    } catch (error) {
        console.error('Failed to load salary over time:', error);
    }
}

// ============================================
// Cross-chart interaction: highlight a city
// ============================================
function highlightCity(city) {
    // Log the filter action — in a full implementation,
    // this would re-query the backend with a city filter
    // and update all other charts to show only that city's data
    console.log('Filter all charts by city:', city);

    // Visual feedback: update the page header
    const header = document.querySelector('.dashboard-header p');
    if (header) {
        header.textContent = 'Filtered by: ' + city +
                             ' (click outside chart to reset)';
        header.style.color = '#3b82f6';
        header.style.fontWeight = 'bold';
    }
}

// ============================================
// Initialize all charts on page load
// ============================================
document.addEventListener('DOMContentLoaded', function() {
    renderSalaryByCity();
    renderSalaryByRole();
    renderJobsByState();
    renderSalaryOverTime();
});

That is a lot of JavaScript, so let us walk through the key patterns you are using.

The async/await pattern: Each render function is marked async and uses await fetch() to call your backend endpoints. This is the modern way to make HTTP requests in JavaScript. The await keyword pauses the function until the response arrives, then response.json() converts the response body from JSON text into a JavaScript object. If anything goes wrong, the try/catch block catches the error and logs it to the console instead of crashing the entire page.

The Chart.js constructor: Every chart follows the same pattern: get the canvas context with getContext('2d'), then create a new Chart(ctx, config). The config object has three main sections: type (what kind of chart), data (the labels and datasets), and options (how the chart looks and behaves). Once you understand this pattern, you can create any chart type by changing these three sections.

Custom tooltips: The tooltip.callbacks.label function lets you control exactly what appears when the user hovers over a data point. Instead of just showing the raw number, you format it as a salary with the formatSalary helper and include the job count so users understand the sample size behind each data point.

The horizontal bar trick: Notice that the salary-by-role chart uses type: 'bar' with indexAxis: 'y'. This is how Chart.js creates horizontal bar charts. Instead of a separate chart type, you simply tell it to swap the axes. This works better for role labels because "Senior Software Engineer" is too long to display nicely on a vertical axis.

Storing chart instances: The line charts.salaryByCity = new Chart(ctx, {...}) saves a reference to each chart in a global charts object. This is not just good housekeeping — it is essential for any interactive dashboard. When you need to update a chart (because the user clicked a filter) or destroy it (before re-rendering with new data), you need a handle to the existing instance. Without these references, you would have no way to manipulate charts after their initial creation.

Empty state handling: Each render function checks if (data.length === 0) before attempting to create a chart. This prevents Chart.js from rendering an empty chart with no bars, no slices, and no lines — which would look broken rather than empty. When your database is fresh and has not accumulated much data yet, these checks ensure the dashboard degrades gracefully instead of showing confusing blank canvases.

5. Making Charts Interactive

Static charts are useful, but interactive charts are powerful. When users can hover, click, and explore the data, they discover insights they would never find by just looking at a static image. Chart.js gives you several interactivity features out of the box, and you have already started using some of them. Let us go deeper.

Click to filter: Look at the onClick handler in the salary-by-city chart. When the user clicks a bar, the handler extracts which city was clicked and calls highlightCity(). In a production dashboard, this would trigger a cascade: re-query the backend with a city filter and update every other chart to show only data for that city. The implementation above is a starting point — it logs the click and updates the header. Extending it to filter all charts is your challenge.

Hover tooltips with exact values: Every chart has custom tooltip callbacks that display formatted salary values and job counts. The tooltip mode: 'index' in the line chart means that hovering over any point in a given week shows all three datasets (min, mid, max) simultaneously, so the user can compare them without moving the mouse. The intersect: false setting means the tooltip appears when the mouse is near a point, not just directly on it — much easier to use.

Toggle min/max/midpoint: Chart.js legends are interactive by default. Click on "Avg Min Salary" in the legend, and that dataset disappears from the chart. Click it again, and it comes back. This lets users toggle between seeing the full salary range, just the minimum, just the maximum, or just the midpoint. You did not have to write any code for this — Chart.js includes it automatically.

Taking interactivity further If you want to extend the cross-chart filtering, here is the approach: store the current filter state in a global variable. When the user clicks a city bar, set the filter, then re-fetch all endpoints with a query parameter like /api/analytics/salary-by-role?city=Austin. On the backend, modify your queries to accept an optional city parameter and add it to the WHERE clause. Destroy the existing chart instances (charts.salaryByRole.destroy()) and re-render them with the filtered data. Add a "Clear filter" button that resets everything. This pattern — click to filter, re-query, re-render — is how professional dashboards like Tableau and Metabase work under the hood.

The doughnut chart also has a useful built-in behavior: hovering over a slice pulls it slightly outward and shows the percentage. Your custom tooltip adds the exact job count and percentage calculation, so users see both the absolute number and the relative proportion.

For the line chart, the tension: 0.3 setting creates smooth curves instead of sharp angles between data points. The fill: true setting adds a semi-transparent area under the line, making it easier to see the range between min and max salaries. The dashed midpoint line (borderDash: [5, 5]) visually distinguishes it from the solid min and max lines without adding clutter.

Responsive behavior: Chart.js charts are responsive by default. When you resize your browser window, the charts automatically redraw at the new size. The CSS Grid layout handles the overall page responsiveness — on a wide screen you see two charts side by side, on a narrow screen they stack vertically. The @media (max-width: 600px) breakpoint in the dashboard CSS forces a single-column layout on mobile devices, and each chart card stretches to fill the full width of the screen. This combination of CSS Grid responsiveness and Chart.js canvas responsiveness means your dashboard works well on everything from a 27-inch monitor to an iPhone without any additional JavaScript.

The Destroy-and-Recreate Pattern

When you need to update a chart with new data (for example, after applying a city filter), you cannot just change the data and hope the chart updates. You need to call chart.destroy() to tear down the existing canvas rendering, then create a brand new Chart instance with the new data. This is why the code stores chart instances in the global charts object — so you can access them later when you need to destroy and recreate them. Forgetting to destroy a chart before creating a new one on the same canvas is a common bug that causes memory leaks and visual glitches.

6. The Story Your Data Tells (and Doesn't Tell)

You now have a working analytics dashboard. It looks professional. The charts are interactive. The data comes from real job listings. But before you start making career decisions based on these numbers, we need to have an honest conversation about data quality.

Not all jobs include salary information. Of the hundreds of jobs in your database, only a portion have salary data. When you see "Average Salary in Austin: $95K," that average is calculated from maybe 30 out of 200 Austin jobs. The other 170 jobs might pay more, might pay less — you do not know because the salary was not listed. Your chart is showing the truth, but it is the truth about a subset of the data, not the whole picture.

Selection bias in salary data There is a pattern in which jobs list salaries and which do not. Government jobs and large corporations are more likely to publish salary ranges because of pay transparency laws. Startups and smaller companies are less likely to. This means your "average salary" might be skewed toward certain types of employers. The chart is not wrong — but the sample it draws from is not random.

Wild salary ranges create noisy averages. Some job listings have salary ranges like "$40,000 — $120,000." That is an $80,000 range, which is essentially meaningless. Is this a junior position paying $40K or a senior position paying $120K? When you average the minimum and maximum across many such jobs, the noise partially cancels out, but individual outliers can still skew the results. One job listing with a maximum salary of $500,000 can pull up the average for an entire city.

Your data reflects your searches, not the full market. This is the most subtle and important point. Your database contains jobs that you searched for. If you searched mostly for "Java Developer in Austin," then your data is heavily weighted toward Java jobs in Austin. The charts might show Austin as the most common city and Java as the most common skill — but that is because you searched for those things, not because the job market is actually concentrated there. This is called self-selection bias, and it affects every dataset that is collected through user behavior rather than random sampling.

Data Literacy

Data literacy means understanding not just what a chart shows, but what it does not show. It means asking: "Where did this data come from? How complete is it? What biases might be present? What conclusions can I safely draw, and what conclusions would be overreaching?" A chart can tell a completely true story from incomplete data — and that is not a flaw, it is the nature of all data analysis. Every dataset has boundaries. Every visualization has limitations. The skill is knowing where those boundaries are and communicating them honestly.

This is why your dashboard includes the data notice at the bottom: "These charts reflect only jobs with reported salary data." That one sentence is not a disclaimer — it is an act of intellectual honesty. It tells anyone looking at your dashboard that the numbers are real but the picture is incomplete. Professional data scientists spend enormous effort on this kind of communication. Now you know why.

None of this makes your dashboard less valuable. It is still the best view you have of the salary landscape for the jobs you have been searching for. It still reveals patterns — like which cities tend to pay more, or which role levels command higher salaries — that are genuinely useful for your job search. You just need to hold those insights with the right amount of confidence: "Based on the data I have collected, it appears that..." instead of "The market definitively shows that..."

That nuance — the difference between what data suggests and what it proves — is one of the most important things you can learn as a developer. You will encounter it every time you build analytics features, every time you interpret A/B test results, and every time someone shows you a chart in a meeting and tries to convince you of something. The ability to look at a visualization and ask "What is this not showing me?" is a superpower.

Consider adding a small "Data Summary" section to your dashboard that shows: Total jobs in database: 487. Jobs with salary data: 142 (29%). Two numbers. But those two numbers transform how someone reads every chart on the page. They go from thinking "this is the salary market" to understanding "this is a 29% sample of the jobs I searched for." That context makes the dashboard more honest and more useful, because now the reader knows exactly how much to trust the patterns they see.

This is a skill that extends far beyond this side quest. Every time you build a feature that shows numbers to users — whether it is an analytics dashboard, a reporting tool, or even a simple counter on a homepage — you should ask yourself: "What context does the reader need to interpret this number correctly?" The answer is almost always more than you think. Providing that context is not extra work. It is part of the work.

Knowledge Check

1. Why do the JPQL aggregate queries include WHERE j.jobMinSalary IS NOT NULL?

Exactly right. SQL AVG() actually does skip null values on its own, but including rows with no salary data in the grouping would inflate the job count without contributing to the average. More importantly, the resulting aggregation would misrepresent the data — showing a city has 200 jobs in the salary analysis when only 30 of them actually reported salaries. Filtering nulls in the WHERE clause ensures that every job counted in the results genuinely contributed salary data to the calculation.

2. In Chart.js, what does setting indexAxis: 'y' on a bar chart accomplish?

Correct. Setting indexAxis: 'y' on a type: 'bar' chart flips its orientation so that the category labels appear along the Y-axis and the value bars extend horizontally along the X-axis. This is especially useful when labels are long (like job titles), because horizontal bars give text labels more room to display without overlapping or being truncated. In Chart.js v3+, this replaced the old type: 'horizontalBar' from v2.

3. Your salary dashboard shows that "Austin averages $95K" based on 30 jobs out of 200 total Austin listings. A friend says, "So Austin developers make $95K." What is the most important limitation to point out?

That is the key insight. When only 30 out of 200 jobs report salary data, the average is based on a 15% sample that is not randomly selected. Jobs that list salaries tend to be from larger companies or in states with pay transparency laws. The 170 jobs without listed salaries might pay very differently. The $95K average is a true number calculated from real data, but it represents a subset, not the full market. This is exactly the kind of data literacy that distinguishes someone who can build charts from someone who can interpret them responsibly.

Deliverable

When you are finished, you should have a working analytics dashboard at /analytics in your Resumator application. The page should display four or more interactive Chart.js visualizations:

Each chart should have hover tooltips displaying exact values and job counts. The Chart.js legend toggles should allow showing and hiding individual datasets. Clicking a bar in the city chart should trigger a cross-chart filter action (at minimum, a console log and visual indicator; at best, a full re-query and re-render of all charts). The page should be responsive, working on both desktop and mobile screens.

Beyond the code, you should understand the data quality limitations discussed in Part 6. You should be able to explain to someone why the numbers in your charts are true but incomplete, and what biases are present in your self-collected dataset. That understanding is as much a part of this deliverable as the code itself.

Stretch goals If you finish the core dashboard and want to push further, here are ideas that would make your analytics page truly impressive. Add a summary bar at the top of the dashboard showing headline stats: total jobs in database, jobs with salary data, highest average city, and most common role level. Add a date range filter that lets users select "last 7 days," "last 30 days," or "all time" and re-renders all charts with the filtered data. Add a fifth chart — maybe a radar chart comparing salary, job count, and number of employers across the top 5 cities, giving a multi-dimensional view of each market. Or add an export button that downloads the current chart as a PNG image using Chart.js's built-in toBase64Image() method. Each of these stretch goals teaches you a new skill while making the dashboard genuinely more useful.

Finished this side quest?

← Back to Side Quests