CodingNic

Checkpoint Project

Checkpoint Project: Team Directory

Checkpoint Project 50 min read

Checkpoint Project: Team Directory

What This Combines

Every piece of this course, in one page: fetching real data with error handling (Module 3), DOM manipulation (Module 1), event handling including delegation (Module 2), and localStorage (Module 5), tied together with the REST conventions from this module.

๐Ÿ’ก Why this matters: This is the difference between knowing four separate skills and knowing how to build something. Nothing here is new, the challenge is making all of it work together in one coherent page.

โš ๏ธ A note on verification: the GET request against the real, live JSONPlaceholder API returns the exact data shown below. The full page’s DOM logic, delegation, search, and localStorage behavior were verified end to end by simulating that same real response, run it yourself to see it firsthand.

What You’re Building

A team directory: load real people from a server, search by name, favorite the ones you want to keep track of, and have your favorites still there the next time you open the page.

text
team-directory/
โ”œโ”€โ”€ index.html
โ””โ”€โ”€ script.js
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Team Directory</title>
<style>
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
  font-family: Arial, Helvetica, sans-serif;
}
body {
  background: #f4f6f8;
  padding: 30px 20px;
}
.container {
  max-width: 900px;
  margin: 0 auto;
}
h1 {
  color: #222;
  margin-bottom: 6px;
}
.subtitle {
  color: #777;
  margin-bottom: 20px;
}
#searchInput {
  width: 100%;
  padding: 12px 14px;
  border: 1px solid #ddd;
  border-radius: 8px;
  font-size: 15px;
  margin-bottom: 8px;
}
#status {
  color: #888;
  font-size: 14px;
  min-height: 20px;
  margin-bottom: 16px;
}
#userList {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
  gap: 14px;
  margin-bottom: 30px;
}
.card {
  background: #fff;
  border-radius: 10px;
  padding: 16px;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.card img {
  width: 48px;
  height: 48px;
  border-radius: 50%;
  margin-bottom: 8px;
}
.card h3 {
  font-size: 16px;
  color: #222;
  margin-bottom: 2px;
}
.card p {
  font-size: 13px;
  color: #777;
  margin-bottom: 10px;
}
.favBtn {
  border: 1px solid #ddd;
  background: #fff;
  padding: 6px 12px;
  border-radius: 6px;
  cursor: pointer;
  font-size: 13px;
}
.favBtn.active {
  background: #fff3cd;
  border-color: #ffcd39;
}
h2 {
  color: #222;
  margin-bottom: 12px;
}
#favoritesList {
  list-style: none;
}
#favoritesList li {
  display: flex;
  justify-content: space-between;
  align-items: center;
  background: #fff;
  padding: 10px 14px;
  border-radius: 8px;
  margin-bottom: 8px;
}
.removeBtn {
  border: none;
  background: #e74c3c;
  color: #fff;
  padding: 5px 10px;
  border-radius: 6px;
  cursor: pointer;
  font-size: 12px;
}
#noFavorites {
  color: #999;
  font-size: 14px;
}
</style>
</head>
<body>
  <div class="container">
    <h1>Team Directory</h1>
    <p class="subtitle">Search the team and keep track of who you work with most.</p>

    <input id="searchInput" type="text" placeholder="Search by name...">
    <p id="status"></p>

    <div id="userList"></div>

    <h2>My Favorites</h2>
    <ul id="favoritesList"></ul>
    <p id="noFavorites">No favorites yet, click "Favorite" on anyone above.</p>
  </div>

  <script src="script.js"></script>
</body>
</html>

The design is already done. Every element this project touches (#searchInput, #status, #userList, #favoritesList, #noFavorites) is in place. Everything below is about filling in script.js.

Step 1: State and References

Two pieces of state drive this whole page: the full list of users once loaded, and the current list of favorited ids, loaded from localStorage immediately, so favorites are available before anything else even runs.

javascript
const searchInput = document.getElementById("searchInput");
const status = document.getElementById("status");
const userList = document.getElementById("userList");
const favoritesList = document.getElementById("favoritesList");
const noFavorites = document.getElementById("noFavorites");

let allUsers = [];
let favoriteIds = JSON.parse(localStorage.getItem("favoriteIds") || "[]");

localStorage.getItem("favoriteIds") || "[]" (Module 5) falls back to an empty array’s JSON string on a first visit, when nothing has been saved yet, so JSON.parse() always has valid JSON to work with, never null.

Step 2: Fetch, with Real Error Handling

javascript
async function loadUsers() {
  status.textContent = "Loading team members...";

  try {
    const response = await fetch("https://jsonplaceholder.typicode.com/users");
    if (!response.ok) throw new Error(`Request failed with status ${response.status}`);

    allUsers = await response.json();
    renderUsers(allUsers);
    renderFavorites();
    status.textContent = "";
  } catch (error) {
    status.textContent = "Couldn't load team members. Check your connection and try again.";
  }
}

This is Module 3’s error handling pattern exactly: check response.ok and throw for a bad status, catch handles both that and a genuine network failure, and either way the user sees a clear message instead of a silently broken page. renderUsers() and renderFavorites() don’t exist yet, built next.

Step 3: Render the User Cards

javascript
function createUserCard(user) {
  const card = document.createElement("div");
  card.className = "card";
  card.dataset.id = user.id;

  const avatar = document.createElement("img");
  avatar.src = "avatar-placeholder.png";
  avatar.alt = user.name;

  const name = document.createElement("h3");
  name.textContent = user.name;

  const company = document.createElement("p");
  company.textContent = user.company.name;

  const favBtn = document.createElement("button");
  favBtn.className = favoriteIds.includes(user.id) ? "favBtn active" : "favBtn";
  favBtn.textContent = favoriteIds.includes(user.id) ? "โ˜… Favorited" : "โ˜† Favorite";

  card.append(avatar, name, company, favBtn);
  return card;
}

function renderUsers(users) {
  userList.innerHTML = "";

  if (users.length === 0) {
    userList.textContent = "No matching team members.";
    return;
  }

  users.forEach((user) => userList.appendChild(createUserCard(user)));
}

card.dataset.id = user.id (Module 1) is what makes delegation possible in the next step, every card carries its own real id, recoverable from any click inside it. Checking favoriteIds.includes(user.id) here means a card renders already showing “โ˜… Favorited” if it was favorited on a previous visit, the button’s state and localStorage’s state agree from the very first render, not just after a click.

Step 4: Favorite Toggling, with Delegation

Cards are created after the page loads, inside renderUsers(), so a listener attached to each button individually would miss every card rendered after the listener was set up (rendered again after a search, for instance). One delegated listener on #userList (Module 2, lesson 4) handles all of them, forever.

javascript
userList.addEventListener("click", (event) => {
  const btn = event.target.closest(".favBtn");
  if (!btn) return;

  const card = btn.closest(".card");
  const id = Number(card.dataset.id);

  if (favoriteIds.includes(id)) {
    favoriteIds = favoriteIds.filter((favId) => favId !== id);
    btn.textContent = "โ˜† Favorite";
    btn.classList.remove("active");
  } else {
    favoriteIds.push(id);
    btn.textContent = "โ˜… Favorited";
    btn.classList.add("active");
  }

  localStorage.setItem("favoriteIds", JSON.stringify(favoriteIds));
  renderFavorites();
});

Number(card.dataset.id) matters, dataset values are always strings (Module 1), and favoriteIds stores actual numbers, favoriteIds.includes("1") would never match 1. Every toggle immediately saves the updated array back to localStorage, favorites survive not just this click, but a full page reload.

Step 5: The Favorites Panel

javascript
function renderFavorites() {
  favoritesList.innerHTML = "";

  const favorites = allUsers.filter((user) => favoriteIds.includes(user.id));

  if (favorites.length === 0) {
    noFavorites.style.display = "block";
    return;
  }
  noFavorites.style.display = "none";

  favorites.forEach((user) => {
    const item = document.createElement("li");
    item.dataset.id = user.id;
    item.textContent = user.name;

    const removeBtn = document.createElement("button");
    removeBtn.className = "removeBtn";
    removeBtn.textContent = "Remove";
    item.appendChild(removeBtn);

    favoritesList.appendChild(item);
  });
}

allUsers.filter(...) (Course 1) rebuilds the favorites panel from scratch every time it’s called, filtering the full list down to just the favorited ids, rather than trying to track the favorites panel’s contents separately from favoriteIds. One source of truth, favoriteIds, everything else derives from it.

Step 6: Removing a Favorite from Its Own Panel

The favorites panel needs its own delegated listener, its .removeBtn elements are separate from #userList’s .favBtn elements entirely.

javascript
favoritesList.addEventListener("click", (event) => {
  const btn = event.target.closest(".removeBtn");
  if (!btn) return;

  const item = btn.closest("li");
  const id = Number(item.dataset.id);

  favoriteIds = favoriteIds.filter((favId) => favId !== id);
  localStorage.setItem("favoriteIds", JSON.stringify(favoriteIds));

  renderFavorites();
  renderUsers(getFilteredUsers());
});

Removing a favorite here also needs to re-render #userList (built next), so that user’s card flips back to “โ˜† Favorite” too, both views stay in sync with the same favoriteIds array.

Step 7: Search, No New Fetch Required

The full team is already loaded in allUsers, filtering it by name is a plain array operation, no server round-trip needed.

javascript
function getFilteredUsers() {
  const query = searchInput.value.trim().toLowerCase();
  if (!query) return allUsers;
  return allUsers.filter((user) => user.name.toLowerCase().includes(query));
}

searchInput.addEventListener("input", () => {
  renderUsers(getFilteredUsers());
});

.toLowerCase() on both sides (Course 1) makes the search case-insensitive, "clem" matches "Clementine Bauch". This runs on every keystroke (Module 2’s input event), re-rendering #userList from allUsers, the original fetched data never changes, only what’s currently displayed does.

The Complete script.js

javascript
const searchInput = document.getElementById("searchInput");
const status = document.getElementById("status");
const userList = document.getElementById("userList");
const favoritesList = document.getElementById("favoritesList");
const noFavorites = document.getElementById("noFavorites");

let allUsers = [];
let favoriteIds = JSON.parse(localStorage.getItem("favoriteIds") || "[]");

function createUserCard(user) {
  const card = document.createElement("div");
  card.className = "card";
  card.dataset.id = user.id;

  const avatar = document.createElement("img");
  avatar.src = "avatar-placeholder.png";
  avatar.alt = user.name;

  const name = document.createElement("h3");
  name.textContent = user.name;

  const company = document.createElement("p");
  company.textContent = user.company.name;

  const favBtn = document.createElement("button");
  favBtn.className = favoriteIds.includes(user.id) ? "favBtn active" : "favBtn";
  favBtn.textContent = favoriteIds.includes(user.id) ? "โ˜… Favorited" : "โ˜† Favorite";

  card.append(avatar, name, company, favBtn);
  return card;
}

function renderUsers(users) {
  userList.innerHTML = "";
  if (users.length === 0) {
    userList.textContent = "No matching team members.";
    return;
  }
  users.forEach((user) => userList.appendChild(createUserCard(user)));
}

function renderFavorites() {
  favoritesList.innerHTML = "";
  const favorites = allUsers.filter((user) => favoriteIds.includes(user.id));

  if (favorites.length === 0) {
    noFavorites.style.display = "block";
    return;
  }
  noFavorites.style.display = "none";

  favorites.forEach((user) => {
    const item = document.createElement("li");
    item.dataset.id = user.id;
    item.textContent = user.name;

    const removeBtn = document.createElement("button");
    removeBtn.className = "removeBtn";
    removeBtn.textContent = "Remove";
    item.appendChild(removeBtn);

    favoritesList.appendChild(item);
  });
}

function getFilteredUsers() {
  const query = searchInput.value.trim().toLowerCase();
  if (!query) return allUsers;
  return allUsers.filter((user) => user.name.toLowerCase().includes(query));
}

async function loadUsers() {
  status.textContent = "Loading team members...";
  try {
    const response = await fetch("https://jsonplaceholder.typicode.com/users");
    if (!response.ok) throw new Error(`Request failed with status ${response.status}`);
    allUsers = await response.json();
    renderUsers(allUsers);
    renderFavorites();
    status.textContent = "";
  } catch (error) {
    status.textContent = "Couldn't load team members. Check your connection and try again.";
  }
}

userList.addEventListener("click", (event) => {
  const btn = event.target.closest(".favBtn");
  if (!btn) return;

  const card = btn.closest(".card");
  const id = Number(card.dataset.id);

  if (favoriteIds.includes(id)) {
    favoriteIds = favoriteIds.filter((favId) => favId !== id);
    btn.textContent = "โ˜† Favorite";
    btn.classList.remove("active");
  } else {
    favoriteIds.push(id);
    btn.textContent = "โ˜… Favorited";
    btn.classList.add("active");
  }

  localStorage.setItem("favoriteIds", JSON.stringify(favoriteIds));
  renderFavorites();
});

favoritesList.addEventListener("click", (event) => {
  const btn = event.target.closest(".removeBtn");
  if (!btn) return;

  const item = btn.closest("li");
  const id = Number(item.dataset.id);

  favoriteIds = favoriteIds.filter((favId) => favId !== id);
  localStorage.setItem("favoriteIds", JSON.stringify(favoriteIds));

  renderFavorites();
  renderUsers(getFilteredUsers());
});

searchInput.addEventListener("input", () => {
  renderUsers(getFilteredUsers());
});

loadUsers();

Open index.html, the real team loads, search for a name, favorite a few people, reload the page, they’re still favorited.

Try It

Build the full team directory from this lesson, then extend it:

  1. Wire up every step, and confirm the real 10 users from JSONPlaceholder load, search, favorite, and reload correctly.
  2. Add an email field to each card (user.email), and make the search also match against it, not just name.
  3. Add a “Clear All Favorites” button above the favorites panel that empties favoriteIds, clears localStorage, and re-renders both #userList and #favoritesList.
  4. Right now, a failed fetch shows an error message but leaves no way to try again. Add a “Retry” button that appears only when loadUsers() fails, and calls it again when clicked.

Recap

  • This project is every module of this course working together: fetch() with real error handling (Module 3), building and updating the DOM (Module 1), event delegation for content that’s created and re-created after the page loads (Module 2), and localStorage for state that survives a reload (Module 5).
  • favoriteIds was the single source of truth, the favorites panel, each card’s button state, and localStorage all derived from it, rather than three separate things that could drift out of sync.
  • Search filtered already-loaded data instead of fetching again, a network request only happened once, everything after that was DOM updates against data already in memory.

That’s the full course. From here, a follow-up course picks up JavaScript frameworks, building directly on everything practiced across these three courses.