Salary Heatmap
Estimated time: 4–5 hours | Difficulty: Intermediate
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?
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:
- Simple API — you create a chart by passing a configuration object to a constructor. No complicated initialization rituals.
- Well-documented — the official documentation is excellent, with examples for every chart type and every option.
- Beginner-friendly — you can get a chart rendering in under 20 lines of JavaScript. The learning curve is gentle.
- Responsive by default — charts automatically resize when the browser window changes size.
- Built-in interactivity — hover tooltips, click handlers, and animations work out of the box.
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.
Chart.js supports several chart types that are perfect for salary data analysis:
- Bar chart — ideal for comparing values across categories (salary by city, salary by role)
- Horizontal bar chart — same as a bar chart but rotated, which works better when category labels are long (like job titles)
- Doughnut chart — great for showing proportions and distributions (jobs by state, jobs by category)
- Line chart — perfect for showing trends over time (average salary over the weeks you have been collecting data)
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.
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.
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.
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.
/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.
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?
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?
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?
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:
- A bar chart showing average salary by city for the top 10 cities in your database
- A horizontal bar chart showing average salary by role level (Senior, Mid, Junior, etc.)
- A doughnut chart showing the distribution of jobs across states
- A line chart showing how average salaries have trended over the weeks you have collected data
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.
toBase64Image() method. Each of these stretch goals teaches you a new skill while making the dashboard genuinely more useful.
Finished this side quest?