Lesson 5 of 8
Completing Resumator
Estimated time: 2–2.5 hours
What You Will Learn
- Build a complete favorites page that displays all saved jobs with their details
- Create JavaScript that fetches, renders, and manages favorite jobs dynamically
- Add a PUT endpoint for saving personal notes on favorite jobs
- Implement debounced auto-save so notes are saved automatically as you type
- Prevent duplicate favorites on both the backend and frontend
- Polish the application with sorting, filtering, badges, animations, toasts, and confirmation dialogs
- Perform a full end-to-end test of the entire Resumator workflow
This is the moment you have been building toward. Over the last four lessons, you planned the architecture, connected to a real job search API, built a beautiful search interface, and — in Lesson 26 — encountered your first real production error and learned how to fix it. You did not give up when things broke. You debugged, you researched, you solved the problem. That is exactly what real developers do every single day.
Now it is time to finish what you started. In this lesson, you will build the favorites page — the place where users can see all the jobs they have saved, add personal notes, remove listings they no longer care about, and generally manage their job search. You will also polish the entire application with the kind of finishing touches that separate a student project from something that feels like a real product: sorting, filtering, smooth animations, toast notifications, and confirmation dialogs.
By the end of this lesson, Resumator will be a fully functional, polished job search application. You will have built something real — something you can show to friends, family, and future employers. Let us finish strong.
FavoriteJob entity, set up the POST /api/favorites endpoint, and fixed the Table 'resumator.favorite_job' doesn't exist error by configuring spring.jpa.hibernate.ddl-auto=update. Your favorites are now saving to the database successfully. Today, we build the page that displays them.
1. The Favorites Page (favorites.html)
Right now, users can save jobs from the search page, but they have no way to see what they have saved. That changes now. We are going to create favorites.html — a dedicated page where users can view, manage, and take notes on all the jobs they have favorited.
Think about what a favorites page needs to show for each saved job: the job title so you know what the position is, the company name so you know who is hiring, the location so you know where the job is, the salary range if one was listed, and the date you saved it so you can track how long ago you found it. Each job should also have action buttons — a way to remove it from favorites, a link to view the original listing, and a place to write personal notes like "Applied on Monday" or "Follow up next week."
And what about when you have no favorites at all? The page needs an empty state — a friendly message that says "You haven't saved any jobs yet" instead of just showing a blank page. Good applications always handle the empty case gracefully.
Here is the complete HTML for the favorites page. Study it carefully — you will see that it follows the same structure as the search page you built in Lesson 25, with a consistent header, navigation, and card layout:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>My Favorites — Resumator</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<header class="app-header">
<div class="header-inner">
<a href="index.html" class="app-logo">Resumator</a>
<nav class="app-nav">
<a href="index.html">Search</a>
<a href="favorites.html" class="active">
Favorites <span id="fav-count-badge" class="badge">0</span>
</a>
</nav>
</div>
</header>
<main class="container">
<div class="page-header">
<h1>My Saved Jobs</h1>
<div class="favorites-controls">
<input
type="text"
id="filter-input"
class="filter-input"
placeholder="Filter favorites..."
>
<select id="sort-select" class="sort-select">
<option value="date-desc">Newest first</option>
<option value="date-asc">Oldest first</option>
<option value="company">Company A–Z</option>
<option value="title">Title A–Z</option>
</select>
</div>
</div>
<!-- Empty state: shown when there are no favorites -->
<div id="empty-state" class="empty-state" style="display: none;">
<div class="empty-icon">☆</div>
<h2>No saved jobs yet</h2>
<p>
Jobs you favorite from the search page will appear here.
<br>
<a href="index.html">Start searching for jobs →</a>
</p>
</div>
<!-- Favorites list: cards are injected here by JavaScript -->
<div id="favorites-list" class="favorites-list"></div>
</main>
<script src="favorites.js"></script>
</body>
</html>
Let us walk through the key parts of this page:
- The header and nav match the search page exactly. Consistency matters — users should feel like they are in the same application. Notice the
Favoriteslink has aclass="active"to highlight it, and a<span id="fav-count-badge">that will display a count like "Favorites (5)." - The controls section has a text input for filtering and a dropdown for sorting. These let users quickly find a specific saved job without scrolling through a long list.
- The empty state (
id="empty-state") starts hidden withstyle="display: none;". JavaScript will show it only when there are zero favorites. It includes a star icon, a friendly message, and a link back to the search page. - The favorites list (
id="favorites-list") is an empty container. JavaScript will fill it with cards dynamically after fetching data from the API.
Key Concept: Empty States
An empty state is what users see when there is no data to display. Good applications never show a blank page — they explain what belongs here and how to get started. Think about how Google Drive shows "No files" with a suggestion to upload, or how a shopping cart shows "Your cart is empty" with a link to keep shopping. Always design for the empty case.
Each favorite card that JavaScript renders will have this structure. You do not need to type this into any file — this is the HTML that your JavaScript will generate dynamically for each saved job:
<!-- This is generated by JavaScript for each favorite -->
<div class="favorite-card" data-id="1">
<div class="card-header">
<h3 class="job-title">Junior Java Developer</h3>
<span class="saved-date">Saved Jan 15, 2025</span>
</div>
<div class="card-body">
<p class="company">TechCorp Inc.</p>
<p class="location">Lansing, MI</p>
<p class="salary">$55,000 – $75,000</p>
</div>
<div class="card-notes">
<label for="notes-1">My Notes:</label>
<textarea
id="notes-1"
class="notes-textarea"
placeholder="Add notes about this job..."
></textarea>
</div>
<div class="card-actions">
<a href="https://example.com/job/123"
class="btn btn-primary"
target="_blank"
rel="noopener noreferrer">
View Listing
</a>
<button class="btn btn-danger remove-btn">Remove</button>
</div>
</div>
Notice the data-id attribute on each card — this stores the database ID of the favorite so that when the user clicks "Remove" or saves notes, your JavaScript knows exactly which record to update. The textarea for notes has a unique id based on the favorite's ID, and the "View Listing" link opens the original job posting in a new tab using target="_blank".
2. The Favorites JavaScript (favorites.js)
The HTML page is just the skeleton. Now we need to bring it to life with JavaScript. The favorites.js file is responsible for everything dynamic on this page: fetching saved jobs from the backend, rendering them as cards, handling the remove button, and auto-saving notes as the user types.
This is the most complex JavaScript file in our application, so we are going to build it piece by piece. First, here is the complete file. Read through it once to get the big picture, and then we will break down every section:
// favorites.js — Manages the favorites page
const API_URL = "/api/favorites";
let allFavorites = []; // Stores all favorites for filtering
// ========================================
// 1. Fetch all favorites when the page loads
// ========================================
async function loadFavorites() {
try {
const response = await fetch(API_URL);
if (!response.ok) {
throw new Error("Failed to fetch favorites");
}
allFavorites = await response.json();
updateBadge(allFavorites.length);
applyFilterAndSort();
} catch (error) {
console.error("Error loading favorites:", error);
showToast("Could not load favorites. Please try again.", "error");
}
}
// ========================================
// 2. Render favorite cards into the page
// ========================================
function renderFavorites(favorites) {
const list = document.getElementById("favorites-list");
const emptyState = document.getElementById("empty-state");
// Clear existing cards
list.innerHTML = "";
// Show empty state if no favorites
if (favorites.length === 0) {
list.style.display = "none";
emptyState.style.display = "block";
return;
}
// Hide empty state and show list
emptyState.style.display = "none";
list.style.display = "grid";
// Create a card for each favorite
favorites.forEach(function(fav) {
const card = document.createElement("div");
card.className = "favorite-card";
card.setAttribute("data-id", fav.id);
// Format the saved date
const savedDate = new Date(fav.savedAt);
const dateString = savedDate.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric"
});
// Build salary display (might be null)
const salaryText = fav.salary
? fav.salary
: "Salary not listed";
card.innerHTML =
'<div class="card-header">' +
' <h3 class="job-title">' + escapeHtml(fav.title) + '</h3>' +
' <span class="saved-date">Saved ' + dateString + '</span>' +
'</div>' +
'<div class="card-body">' +
' <p class="company">' + escapeHtml(fav.company) + '</p>' +
' <p class="location">' + escapeHtml(fav.location) + '</p>' +
' <p class="salary">' + escapeHtml(salaryText) + '</p>' +
'</div>' +
'<div class="card-notes">' +
' <label for="notes-' + fav.id + '">My Notes:</label>' +
' <textarea id="notes-' + fav.id + '" ' +
' class="notes-textarea" ' +
' placeholder="Add notes about this job...">' +
(fav.notes || "") +
' </textarea>' +
'</div>' +
'<div class="card-actions">' +
' <a href="' + escapeHtml(fav.jobUrl) + '" ' +
' class="btn btn-primary" target="_blank" ' +
' rel="noopener noreferrer">' +
' View Listing' +
' </a>' +
' <button class="btn btn-danger remove-btn">Remove</button>' +
'</div>';
// Attach event listeners
const removeBtn = card.querySelector(".remove-btn");
removeBtn.addEventListener("click", function() {
confirmAndRemove(fav.id, fav.title);
});
const textarea = card.querySelector(".notes-textarea");
textarea.addEventListener("input", debounce(function() {
saveNotes(fav.id, textarea.value);
}, 800));
list.appendChild(card);
});
}
// ========================================
// 3. Remove a favorite
// ========================================
async function removeFavorite(id) {
try {
const response = await fetch(API_URL + "/" + id, {
method: "DELETE"
});
if (!response.ok) {
throw new Error("Failed to remove favorite");
}
// Remove from local array
allFavorites = allFavorites.filter(function(fav) {
return fav.id !== id;
});
updateBadge(allFavorites.length);
applyFilterAndSort();
showToast("Job removed from favorites.", "success");
} catch (error) {
console.error("Error removing favorite:", error);
showToast("Could not remove job. Please try again.", "error");
}
}
// ========================================
// 4. Save notes with debounced auto-save
// ========================================
async function saveNotes(id, notes) {
try {
const response = await fetch(API_URL + "/" + id + "/notes", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ notes: notes })
});
if (!response.ok) {
throw new Error("Failed to save notes");
}
// Update local array
const fav = allFavorites.find(function(f) { return f.id === id; });
if (fav) {
fav.notes = notes;
}
showToast("Notes saved!", "success");
} catch (error) {
console.error("Error saving notes:", error);
showToast("Could not save notes. Please try again.", "error");
}
}
// ========================================
// 5. Debounce helper function
// ========================================
function debounce(func, delay) {
let timer;
return function() {
const context = this;
const args = arguments;
clearTimeout(timer);
timer = setTimeout(function() {
func.apply(context, args);
}, delay);
};
}
// ========================================
// 6. Filter and sort
// ========================================
function applyFilterAndSort() {
const filterValue = document.getElementById("filter-input")
.value.toLowerCase();
const sortValue = document.getElementById("sort-select").value;
// Filter
let filtered = allFavorites.filter(function(fav) {
const searchable = (
fav.title + " " + fav.company + " " + fav.location
).toLowerCase();
return searchable.includes(filterValue);
});
// Sort
filtered.sort(function(a, b) {
switch (sortValue) {
case "date-desc":
return new Date(b.savedAt) - new Date(a.savedAt);
case "date-asc":
return new Date(a.savedAt) - new Date(b.savedAt);
case "company":
return a.company.localeCompare(b.company);
case "title":
return a.title.localeCompare(b.title);
default:
return 0;
}
});
renderFavorites(filtered);
}
// ========================================
// 7. Confirmation dialog
// ========================================
function confirmAndRemove(id, title) {
const confirmed = confirm(
'Remove "' + title + '" from your favorites?'
);
if (confirmed) {
removeFavorite(id);
}
}
// ========================================
// 8. Toast notifications
// ========================================
function showToast(message, type) {
// Remove any existing toast
const existing = document.querySelector(".toast");
if (existing) {
existing.remove();
}
const toast = document.createElement("div");
toast.className = "toast toast-" + type;
toast.textContent = message;
document.body.appendChild(toast);
// Trigger animation
setTimeout(function() { toast.classList.add("show"); }, 10);
// Auto-dismiss after 3 seconds
setTimeout(function() {
toast.classList.remove("show");
setTimeout(function() { toast.remove(); }, 300);
}, 3000);
}
// ========================================
// 9. Update the nav badge count
// ========================================
function updateBadge(count) {
const badge = document.getElementById("fav-count-badge");
if (badge) {
badge.textContent = count;
badge.style.display = count > 0 ? "inline-block" : "none";
}
}
// ========================================
// 10. Escape HTML to prevent XSS
// ========================================
function escapeHtml(text) {
const div = document.createElement("div");
div.textContent = text || "";
return div.innerHTML;
}
// ========================================
// 11. Set up event listeners and load data
// ========================================
document.getElementById("filter-input")
.addEventListener("input", debounce(applyFilterAndSort, 300));
document.getElementById("sort-select")
.addEventListener("change", applyFilterAndSort);
// Load favorites when the page is ready
loadFavorites();
That is a lot of code! But look at how it is organized — each section has a clear purpose, and they work together like gears in a machine. Let us break down the most important parts.
Fetching Favorites from the API
The loadFavorites() function runs as soon as the page loads. It sends a GET request to /api/favorites, which returns a JSON array of all saved jobs from your MySQL database. The async/await syntax makes this asynchronous code read almost like synchronous code — the function pauses at await fetch() until the server responds, then continues with the data.
Notice the error handling with try/catch. If the server is down or the network fails, instead of the page silently breaking, the user sees a toast notification saying "Could not load favorites." This is defensive programming — always assume that network requests can fail.
Rendering Cards Dynamically
The renderFavorites() function takes an array of favorite objects and turns each one into a card on the page. It first checks if the array is empty — if so, it shows the empty state. Otherwise, it loops through each favorite and builds the HTML using string concatenation.
One critical detail: notice the escapeHtml() function. Every piece of user-generated or API-provided data is escaped before being inserted into the page. This prevents Cross-Site Scripting (XSS) attacks — a type of security vulnerability where malicious code could be injected through job titles or company names. Never insert raw user data into HTML without escaping it.
The Remove Flow
When the user clicks "Remove," the code does not immediately delete the favorite. Instead, it calls confirmAndRemove(), which shows a browser confirmation dialog asking "Remove [job title] from your favorites?" Only if the user clicks "OK" does it proceed to send the DELETE request. After a successful deletion, it updates the local array (so we do not need to re-fetch from the server), updates the badge count, re-renders the list, and shows a success toast.
Debounced Auto-Save for Notes
The notes textarea uses a technique called debouncing. Without debouncing, every single keystroke would send a network request to save the notes. If someone types "Applied Monday" (14 characters), that would be 14 separate API calls! Debouncing waits until the user stops typing for 800 milliseconds before sending the request. This dramatically reduces the number of API calls while still giving the user the feeling that their notes are saving automatically.
Key Concept: Debouncing
Debouncing is a technique that delays the execution of a function until a certain amount of time has passed since it was last called. If the function is called again before the delay expires, the timer resets. This is essential for performance when handling rapid user input like typing, scrolling, or resizing. In our case, we wait 800ms after the user stops typing before saving their notes to the server.
Now let us see a working demo of the debounce pattern in action. Run the code below and type quickly in the input field. Watch how the "save" function only fires after you stop typing:
// Debounce demo — type in the input to see it work
function debounce(func, delay) {
let timer;
return function() {
clearTimeout(timer);
timer = setTimeout(func, delay);
};
}
// Simulate saving notes
let saveCount = 0;
function saveNotes(text) {
saveCount++;
console.log(
"Save #" + saveCount + ": Saved notes -> \"" + text + "\""
);
}
// Without debounce — fires on EVERY keystroke
let keystrokeCount = 0;
function onKeystroke(text) {
keystrokeCount++;
console.log("Keystroke #" + keystrokeCount + ": \"" + text + "\"");
}
// Create the debounced version (800ms delay)
const debouncedSave = debounce(function() {
saveNotes("(your text here)");
}, 800);
// Simulate rapid typing: 5 keystrokes in quick succession
console.log("--- Simulating 5 rapid keystrokes ---");
onKeystroke("A");
debouncedSave();
onKeystroke("Ap");
debouncedSave();
onKeystroke("App");
debouncedSave();
onKeystroke("Appl");
debouncedSave();
onKeystroke("Apply");
debouncedSave();
console.log("--- Waiting 800ms for debounce... ---");
console.log("(The save will fire once after the delay)");
Notice how there were 5 keystrokes but the debounced save only fires once, after the 800ms delay. In a real application, this means 5 keystrokes result in 1 network request instead of 5. That is a huge performance improvement, especially on mobile connections.
3. Adding the Notes PUT Endpoint
Your favorites JavaScript is ready to save notes, but the backend does not have an endpoint to receive them yet. You need to add a new method to your FavoritesController class that accepts a PUT request with the updated notes text.
Why PUT and not POST? In REST conventions, POST is used to create a new resource, while PUT is used to update an existing one. Since the favorite already exists in the database and we are just updating its notes field, PUT is the correct HTTP method.
Add this method to your FavoritesController class, alongside the existing GET, POST, and DELETE methods:
@PutMapping("/{id}/notes")
public FavoriteJob updateNotes(@PathVariable Long id, @RequestBody Map<String, String> body) {
FavoriteJob job = repository.findById(id)
.orElseThrow(() -> new RuntimeException("Favorite not found"));
job.setNotes(body.get("notes"));
return repository.save(job);
}
Let us break down every piece of this method:
@PutMapping("/{id}/notes")— This maps toPUT /api/favorites/3/notes(where3is the favorite's ID). The{id}is a path variable — a dynamic segment of the URL that Spring extracts automatically.@PathVariable Long id— Spring takes the{id}from the URL and passes it into the method as aLongvalue.@RequestBody Map<String, String> body— The request body is a JSON object like{ "notes": "Applied on Monday" }. Spring automatically converts it into a JavaMapso you can access the notes withbody.get("notes").repository.findById(id)— This searches the database for a favorite with the given ID. It returns anOptional— Java's way of saying "this might or might not exist.".orElseThrow(...)— If the favorite does not exist (maybe it was already deleted), this throws an exception instead of returningnull. This prevents confusingNullPointerExceptionerrors later.job.setNotes(...)— Updates the notes field on the Java object.repository.save(job)— Saves the updated object back to the database and returns it.
You also need to make sure your FavoriteJob entity has a notes field. If you did not add it in Lesson 26, add it now:
// Add this field to your FavoriteJob entity class
@Column(columnDefinition = "TEXT")
private String notes;
// Add getter and setter
public String getNotes() { return notes; }
public void setNotes(String notes) { this.notes = notes; }
The @Column(columnDefinition = "TEXT") annotation tells MySQL to use the TEXT type instead of the default VARCHAR(255). This allows notes to be much longer than 255 characters — users might want to write detailed notes about why they are interested in a job, what they learned about the company, or reminders about interview preparation.
ddl-auto=update will automatically add the new column to your database table the next time you restart the application. You do not need to write any SQL. This is the same mechanism that fixed the "table doesn't exist" error we debugged in the previous lesson.
4. Preventing Duplicate Favorites
Right now, there is nothing stopping a user from clicking the star button on the same job ten times and creating ten duplicate entries in the database. That is a bug — and a common one. Let us fix it on both the backend and the frontend.
Backend: Check Before Saving
First, add a custom query method to your FavoriteJobRepository:
public interface FavoriteJobRepository
extends JpaRepository<FavoriteJob, Long> {
// Spring Data JPA generates the query from the method name!
boolean existsByJobId(String jobId);
}
This is one of the most powerful features of Spring Data JPA. You do not write any SQL or implementation code. Spring reads the method name existsByJobId and automatically generates a query that checks if a row with that jobId exists. It returns true if found, false if not. The naming convention is the magic — existsBy + the field name (JobId).
Now update your POST endpoint to check for duplicates before saving:
@PostMapping
public ResponseEntity<?> addFavorite(@RequestBody FavoriteJob job) {
// Check if this job is already favorited
if (repository.existsByJobId(job.getJobId())) {
return ResponseEntity
.status(HttpStatus.CONFLICT)
.body(Map.of("error", "This job is already in your favorites"));
}
job.setSavedAt(new java.util.Date());
FavoriteJob saved = repository.save(job);
return ResponseEntity.status(HttpStatus.CREATED).body(saved);
}
There are several important changes here:
ResponseEntity<?>— Instead of returning justFavoriteJob, we now return aResponseEntity, which lets us control the HTTP status code. The<?>means the body can be any type (either aFavoriteJobon success or an error message on failure).HttpStatus.CONFLICT(409) — This is the correct HTTP status code for "this resource already exists." It tells the frontend that the save failed because of a duplicate, not because of a server error.HttpStatus.CREATED(201) — On success, we return 201 instead of the default 200. Status 201 specifically means "a new resource was created," which is semantically correct for aPOSTrequest.
Frontend: Show Filled Stars for Already-Favorited Jobs
On the search page, we should visually indicate which jobs have already been saved. Instead of showing an empty star for every job, we check which ones are already in favorites and show a filled star for those.
Add this logic to your search page JavaScript (the file that handles the search results):
// Fetch the list of already-favorited job IDs
async function getFavoritedJobIds() {
try {
const response = await fetch("/api/favorites");
const favorites = await response.json();
// Extract just the jobId from each favorite
return favorites.map(function(fav) { return fav.jobId; });
} catch (error) {
console.error("Could not load favorites:", error);
return [];
}
}
// When rendering search results, check each job
async function renderSearchResults(jobs) {
const favoritedIds = await getFavoritedJobIds();
jobs.forEach(function(job) {
const isFavorited = favoritedIds.includes(job.job_id);
// Use filled star if already favorited, empty star if not
const starSymbol = isFavorited ? "★" : "☆";
const starClass = isFavorited
? "star-btn favorited"
: "star-btn";
// ... build the card HTML with the correct star ...
});
}
// When the star button is clicked
function toggleFavorite(button, job) {
if (button.classList.contains("favorited")) {
showToast("This job is already in your favorites!", "info");
return; // Do not send a duplicate request
}
// ... save the favorite as before ...
}
This approach checks the favorites list once when search results load, rather than checking each job individually. This is more efficient because it only makes one API call instead of potentially dozens.
Key Concept: Idempotency and Duplicate Prevention
In software engineering, an operation is idempotent if performing it multiple times has the same effect as performing it once. For favorites, clicking the star button ten times should produce the same result as clicking it once — exactly one saved favorite. We achieve this by checking on the backend (existsByJobId) and on the frontend (checking for the favorited class). Defending against duplicates on both sides is a best practice — never rely on the frontend alone, because API calls can be made without a browser.
5. Polish Features
Your favorites page is fully functional now — users can view, remove, and take notes on saved jobs. But functional is not enough. The difference between a project and a product is polish. These finishing touches make the application feel professional and delightful to use.
5.1 Sorting Favorites
We already included sorting in our JavaScript with the applyFilterAndSort() function. The dropdown menu lets users sort by date saved (newest or oldest first), company name (A to Z), or job title (A to Z). Let us look at how the sort logic works:
// Sorting demo — see how different sort methods work
var jobs = [
{ title: "Java Developer", company: "Acme Corp", savedAt: "2025-01-15" },
{ title: "Frontend Intern", company: "WebWorks", savedAt: "2025-01-20" },
{ title: "Backend Engineer", company: "DataFlow", savedAt: "2025-01-10" },
{ title: "QA Tester", company: "Acme Corp", savedAt: "2025-01-18" },
{ title: "DevOps Analyst", company: "CloudNine", savedAt: "2025-01-22" }
];
function sortJobs(list, method) {
// Create a copy so we don't modify the original
var sorted = list.slice();
sorted.sort(function(a, b) {
switch (method) {
case "date-desc":
return new Date(b.savedAt) - new Date(a.savedAt);
case "date-asc":
return new Date(a.savedAt) - new Date(b.savedAt);
case "company":
return a.company.localeCompare(b.company);
case "title":
return a.title.localeCompare(b.title);
default:
return 0;
}
});
return sorted;
}
console.log("=== Newest First ===");
sortJobs(jobs, "date-desc").forEach(function(j) {
console.log(j.savedAt + " | " + j.title + " at " + j.company);
});
console.log("\n=== Company A-Z ===");
sortJobs(jobs, "company").forEach(function(j) {
console.log(j.company + " | " + j.title);
});
console.log("\n=== Title A-Z ===");
sortJobs(jobs, "title").forEach(function(j) {
console.log(j.title + " | " + j.company);
});
The .sort() method takes a comparison function that receives two items (a and b) and returns a negative number if a should come first, a positive number if b should come first, or zero if they are equal. For dates, subtracting one Date from another gives the time difference in milliseconds. For strings, localeCompare() handles alphabetical comparison correctly, including special characters and case sensitivity.
5.2 Filtering Within Favorites
The filter input lets users search within their favorites. If you have saved 20 jobs and want to find the one at "TechCorp," you can type "tech" and the list instantly filters down to matching results. Our applyFilterAndSort() function handles this by combining the title, company, and location into one searchable string and checking if it includes the filter text.
The filter input itself is debounced with a 300ms delay — shorter than the notes debounce because filtering is a fast, local operation that does not involve network requests. We just do not want to re-render the entire list on every single keystroke.
5.3 Count Badge in Navigation
The favorites count badge in the navigation shows users how many jobs they have saved without needing to visit the favorites page. The updateBadge() function updates this number whenever favorites are loaded, added, or removed. When the count is zero, the badge is hidden entirely — showing "Favorites (0)" looks awkward.
To add this badge to the search page as well, include this snippet in your search page JavaScript:
// On the search page, update the favorites badge on load
async function updateNavBadge() {
try {
const response = await fetch("/api/favorites");
const favorites = await response.json();
const badge = document.getElementById("fav-count-badge");
if (badge) {
badge.textContent = favorites.length;
badge.style.display = favorites.length > 0
? "inline-block"
: "none";
}
} catch (error) {
// Silently fail — the badge is not critical
}
}
updateNavBadge();
5.4 Smooth Animations
Animations make your application feel alive. When a card is added, it should gently fade in. When a card is removed, it should smoothly slide out. Here is the CSS you need:
/* Favorite card animations */
.favorite-card {
animation: fadeSlideIn 0.3s ease-out;
transition: opacity 0.3s ease, transform 0.3s ease;
}
@keyframes fadeSlideIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Class added before removing a card */
.favorite-card.removing {
opacity: 0;
transform: translateX(-20px);
}
/* Toast notification animations */
.toast {
position: fixed;
bottom: 20px;
right: 20px;
padding: 12px 24px;
border-radius: 8px;
color: white;
font-weight: 500;
opacity: 0;
transform: translateY(20px);
transition: opacity 0.3s ease, transform 0.3s ease;
z-index: 1000;
}
.toast.show {
opacity: 1;
transform: translateY(0);
}
.toast-success { background-color: #16825d; }
.toast-error { background-color: #dc3545; }
.toast-info { background-color: #0d6efd; }
/* Badge styles */
.badge {
display: inline-block;
background-color: #dc3545;
color: white;
font-size: 12px;
font-weight: 700;
padding: 2px 7px;
border-radius: 10px;
margin-left: 4px;
vertical-align: middle;
}
To use the removing animation, update the removeFavorite() function to add the .removing class before actually removing the card from the DOM:
// Animate the card out before removing
async function removeFavorite(id) {
// Find the card element and animate it out
const card = document.querySelector(
'.favorite-card[data-id="' + id + '"]'
);
if (card) {
card.classList.add("removing");
// Wait for the animation to finish (300ms)
await new Promise(function(resolve) {
setTimeout(resolve, 300);
});
}
// Then send the DELETE request and re-render
// ... rest of the remove logic ...
}
5.5 Confirmation Dialog Before Removing
We already implemented this in our confirmAndRemove() function using the browser's built-in confirm() dialog. This is a simple but effective way to prevent accidental deletions. The dialog shows the job title so the user knows exactly what they are about to remove.
In a production application, you might replace the browser's built-in dialog with a custom modal that matches your design. But for now, confirm() works perfectly and is accessible out of the box.
5.6 Toast Notifications
Toast notifications are those small messages that slide in from the corner of the screen to confirm an action. Our showToast() function creates a temporary element, animates it into view, and automatically dismisses it after 3 seconds. We use three types:
- Success (green): "Job saved!" or "Notes saved!" or "Job removed from favorites."
- Error (red): "Could not load favorites. Please try again."
- Info (blue): "This job is already in your favorites!"
Notice how the showToast() function removes any existing toast before showing a new one. This prevents toasts from stacking up if the user performs several actions quickly.
6. Interactive Demo: Favorites Page Logic
Let us put it all together with an interactive demo. The code below simulates a complete favorites page with mock data — no server required. You can see how filtering, sorting, adding notes, and removing favorites all work together. Try modifying the mock data, changing the sort order, or filtering by company name:
// ==========================================
// Interactive Favorites Page Simulation
// ==========================================
// Mock data — pretend this came from the API
var favorites = [
{
id: 1,
title: "Junior Java Developer",
company: "TechCorp Inc.",
location: "Lansing, MI",
salary: "$55,000 - $75,000",
savedAt: "2025-01-15T10:30:00",
notes: "Great company culture!"
},
{
id: 2,
title: "Frontend Developer Intern",
company: "WebWorks Studio",
location: "Detroit, MI",
salary: "$40,000 - $50,000",
savedAt: "2025-01-20T14:15:00",
notes: ""
},
{
id: 3,
title: "Backend Engineer",
company: "DataFlow Systems",
location: "Ann Arbor, MI",
salary: "$80,000 - $100,000",
savedAt: "2025-01-10T09:00:00",
notes: "Need 2 years experience"
},
{
id: 4,
title: "QA Test Analyst",
company: "Acme Solutions",
location: "Grand Rapids, MI",
salary: null,
savedAt: "2025-01-18T16:45:00",
notes: ""
}
];
// Render favorites to console (simulating DOM)
function renderToConsole(list) {
if (list.length === 0) {
console.log("--- EMPTY STATE ---");
console.log("No saved jobs yet.");
console.log("Start searching for jobs!");
return;
}
console.log("--- MY SAVED JOBS (" + list.length + ") ---\n");
list.forEach(function(fav) {
var date = new Date(fav.savedAt).toLocaleDateString("en-US", {
month: "short", day: "numeric", year: "numeric"
});
var salary = fav.salary || "Salary not listed";
console.log(fav.title);
console.log(" Company: " + fav.company);
console.log(" Location: " + fav.location);
console.log(" Salary: " + salary);
console.log(" Saved: " + date);
if (fav.notes) {
console.log(" Notes: " + fav.notes);
}
console.log(" [View Listing] [Remove]");
console.log("");
});
}
// Sort function
function sortFavorites(list, method) {
var sorted = list.slice();
sorted.sort(function(a, b) {
switch (method) {
case "date-desc":
return new Date(b.savedAt) - new Date(a.savedAt);
case "date-asc":
return new Date(a.savedAt) - new Date(b.savedAt);
case "company":
return a.company.localeCompare(b.company);
case "title":
return a.title.localeCompare(b.title);
default:
return 0;
}
});
return sorted;
}
// Filter function
function filterFavorites(list, query) {
return list.filter(function(fav) {
var searchable = (
fav.title + " " + fav.company + " " + fav.location
).toLowerCase();
return searchable.includes(query.toLowerCase());
});
}
// Demo: Show all favorites sorted by newest
console.log("========================================");
console.log("DEMO 1: All favorites (newest first)");
console.log("========================================");
renderToConsole(sortFavorites(favorites, "date-desc"));
// Demo: Filter by "Lansing"
console.log("========================================");
console.log('DEMO 2: Filter by "Lansing"');
console.log("========================================");
renderToConsole(filterFavorites(favorites, "Lansing"));
// Demo: Sort by company name
console.log("========================================");
console.log("DEMO 3: Sorted by company A-Z");
console.log("========================================");
renderToConsole(sortFavorites(favorites, "company"));
// Demo: Remove a favorite
console.log("========================================");
console.log("DEMO 4: After removing ID 2");
console.log("========================================");
var afterRemove = favorites.filter(function(f) {
return f.id !== 2;
});
renderToConsole(sortFavorites(afterRemove, "date-desc"));
// Demo: Empty state
console.log("========================================");
console.log("DEMO 5: Empty state (no favorites)");
console.log("========================================");
renderToConsole([]);
7. Full End-to-End Test
Your application is complete! Now it is time to test the entire workflow from start to finish. An end-to-end test simulates exactly what a real user would do. Follow these steps carefully and make sure each one works before moving on:
./mvnw spring-boot:run), your MySQL database is accessible, and you have your API key configured. Open http://localhost:8080 in your browser.
Step 1: Search for Jobs
- Open the search page (
index.html). - Type "Java developer Lansing" into the search box.
- Click the Search button.
- Verify that job cards appear with titles, companies, locations, and salary information.
- Check the browser console (F12 → Console tab) for any errors. There should be none.
Step 2: Save a Job to Favorites
- Click the star button on one of the job cards.
- You should see a toast notification: "Job saved!"
- The star should change from an empty outline to a filled star.
- Click the same star again — you should see "This job is already in your favorites!" (not a duplicate save).
- Save 2-3 more jobs from the results.
- Check the favorites badge in the navigation — it should show the correct count.
Step 3: Navigate to the Favorites Page
- Click the "Favorites" link in the navigation bar.
- Your saved jobs should appear as cards with all their details.
- The badge count should match the number of cards displayed.
- If you saved zero jobs first, you should see the empty state message.
Step 4: Add Notes
- Click into the "My Notes" textarea on one of the favorite cards.
- Type something like "Applied on Monday, follow up Thursday."
- Stop typing and wait about 1 second.
- You should see a "Notes saved!" toast notification (the debounce fired).
- Refresh the page (Ctrl+R or Cmd+R) — your notes should still be there (they were saved to the database).
Step 5: Sort and Filter
- Use the sort dropdown to change the order — try "Company A-Z" and verify the cards reorder.
- Type a company name into the filter input — only matching cards should appear.
- Clear the filter — all cards should reappear.
Step 6: Remove a Favorite
- Click the "Remove" button on one of the cards.
- A confirmation dialog should appear with the job title.
- Click "OK" to confirm.
- The card should animate out smoothly.
- A "Job removed from favorites" toast should appear.
- The badge count should decrease by one.
- Refresh the page — the removed job should not reappear.
Step 7: Test the Empty State
- Remove all remaining favorites one by one.
- After the last one is removed, the empty state should appear: "No saved jobs yet" with a link to the search page.
- Click the link — it should take you back to the search page.
Step 8: Apply to a Real Job
- Go back to the search page and save a job you are genuinely interested in.
- Navigate to the favorites page.
- Click "View Listing" on that job — it should open the original job posting in a new tab.
- Read through the posting. If it is a real job you are qualified for, consider actually applying. You built a tool that helps you find and track jobs — use it!
Key Concept: End-to-End Testing
End-to-end (E2E) testing verifies that an entire application works correctly from the user's perspective. Unlike unit tests (which test individual functions) or integration tests (which test how components work together), E2E tests simulate real user workflows. In professional development teams, E2E tests are often automated using tools like Selenium, Cypress, or Playwright. For now, you are doing manual E2E testing — which is a perfectly valid and important skill. The goal is to catch bugs that only appear when all the pieces work together.
If everything passed, take a moment to appreciate what you have built. Resumator is a full-stack web application with:
- A Java Spring Boot backend with RESTful API endpoints
- Integration with an external API (JSearch) for real job data
- A MySQL database for persistent storage
- CRUD operations (Create, Read, Update, Delete) for favorites
- A responsive frontend with search, favorites, sorting, and filtering
- Error handling on both the backend and frontend
- Polish features like animations, toasts, and confirmation dialogs
That is not a toy project. That is a real application built with real technologies used by real companies.
Test Your Knowledge
1. What is the purpose of debouncing the notes textarea?
2. Why do we check existsByJobId() on the backend before saving a favorite, even though the frontend already prevents duplicate clicks?
3. What HTTP status code should the server return when a user tries to save a job that is already in their favorites?
Lesson Summary
Congratulations — you have just completed Resumator. Not "completed a tutorial." Not "followed along with a demo." You built a real, full-stack web application from scratch. Let us review everything you accomplished in this lesson:
- Built the favorites page (
favorites.html) with a complete card layout, filter input, sort dropdown, and an empty state for when no favorites exist. - Wrote the favorites JavaScript (
favorites.js) that fetches data from the API, renders cards dynamically, handles remove actions, and auto-saves notes with debouncing. - Added the PUT endpoint for notes, using
@PutMapping,@PathVariable, and@RequestBodyto update a specific favorite's notes in the database. - Prevented duplicate favorites with
existsByJobId()on the backend and filled-star indicators on the frontend. - Polished the application with sorting (by date, company, title), filtering within favorites, a count badge in navigation, smooth CSS animations, confirmation dialogs, and toast notifications.
- Performed a full end-to-end test of the entire workflow: search, favorite, navigate, add notes, sort, filter, remove, and view listings.
Think back to Lesson 23 when you first planned this application on paper. At that point, "build a full-stack job search app" probably felt overwhelming. Now you have done it. You have written Java classes, Spring Boot controllers, MySQL queries, HTML pages, CSS styles, and JavaScript logic. You have debugged a real production error. You have implemented features that professional developers build every day — CRUD operations, API integration, debouncing, XSS prevention, and responsive design.
In the next lesson, we will add Thymeleaf server-side templates and API usage tracking to Resumator — turning a real-world constraint (limited API requests) into a professional feature. But for now, take a moment to appreciate what you have built. You are not just learning to code — you are a coder.
Finished this lesson?