EthicsComputerCourses
Join training +91 90532 10052

Project: Support Ticket Dashboard

JavaScript · Lesson 74 of 74 Advanced 11 min read Updated

This project builds the kind of screen you will be asked to build in your first months at a company: an internal dashboard. A customer-support team needs to see its tickets, search them, filter by status and agent, page through the results and mark tickets as resolved. It sounds simple, but doing it well uses almost every professional skill from this course.

You will load real data from a public test API, protect the page from slow networks, cache results, keep all logic in one pure function, render safely, update the server optimistically, keep the filters in the address bar, and make the whole thing usable with a keyboard and a screen reader. At the end you will see how the same code is split into modules for a real Vite project.

What you will build

The dashboard must:

  1. Load tickets and support agents from the server, showing a loading message, and a clear error with a Try again button if loading fails.
  2. Show tickets in a table, 15 per page, with Previous and Next buttons.
  3. Filter by status (all, open, resolved) and by agent, search by subject while typing, and sort newest or oldest first.
  4. Let an agent mark a ticket as Resolved or Reopen it, saving the change to the server.
  5. Remember the filters in the address, so a link such as ?status=open&agentId=3 opens the same view.
  6. Announce results ("Showing 15 of 110 tickets") to screen-reader users.

The data comes from JSONPlaceholder, a free fake API for practice: its 200 /todos become tickets (a completed todo is a resolved ticket), and its 10 /users become support agents. The ticket subjects are placeholder Latin text, which is normal for test data. JSONPlaceholder also accepts PATCH requests and answers as if it saved them, without really changing anything, which is perfect for practising updates.

Plan the state

As in Project: To-Do App, the screen is drawn from one state object. Events change the state, then a single render() function redraws the page from it. The page never stores information that is not in the state.

JavaScript
const state = {
  loading: true,         // waiting for the first answer
  error: "",             // a message for the user, or "" when all is well
  tickets: [],           // { id, subject, agentId, status: "open" | "resolved" }
  agents: new Map(),     // agent id -> name, for quick lookups
  filters: { query: "", status: "all", agentId: 0, sort: "newest", page: 1 },
};

Keeping loading and error as separate, clearly named fields makes the three screens (loading, error, data) easy to tell apart in render(). Agents live in a Map because every table row needs a fast lookup from id to name.

Step 1: The HTML

The HTML is plain and accessible from the start:

HTML
<form class="filters" id="filters">
  <label>Search subject <input type="search" name="query" autocomplete="off"></label>
  <label>Status
    <select name="status">
      <option value="all">All</option>
      <option value="open">Open</option>
      <option value="resolved">Resolved</option>
    </select>
  </label>
  <label>Agent <select name="agentId"><option value="0">All agents</option></select></label>
</form>

<p id="status" role="status"></p>
<div id="error" role="alert" hidden>
  <span id="error-text"></span> <button type="button" id="retry">Try again</button>
</div>

<table id="table" aria-busy="true">
  <caption>Tickets</caption>
  <thead>
    <tr><th scope="col">#</th><th scope="col">Subject</th><th scope="col">Agent</th><th scope="col">Status</th><th scope="col">Action</th></tr>
  </thead>
  <tbody id="rows"></tbody>
</table>

Every control has a <label>. The role="status" paragraph is a live region that announces result counts, and role="alert" makes the error heard at once (see Accessible Widgets with JavaScript). The table has a <caption> and scope="col" headers so screen readers can say which column a cell belongs to, and aria-busy marks it while loading. The name of each control matches a key in state.filters, which will let one event listener handle all of them.

Step 2: The API layer with a timeout and a cache

All server code lives in one place. loadDashboardData() loads tickets and agents in parallel with Promise.all, gives up after 8 seconds with AbortSignal.timeout(), converts the raw API objects into the shape the app uses, and caches the result in sessionStorage for five minutes so switching back to the tab is instant:

JavaScriptRun it yourself
const API = "https://jsonplaceholder.typicode.com";
const CACHE_KEY = "ticket-dashboard-v1";
const CACHE_MINUTES = 5;

async function getJson(path, signal) {
  const response = await fetch(`${API}${path}`, { signal });
  if (!response.ok) throw new Error(`${path} failed with status ${response.status}`);
  return response.json();
}

function readCache() {
  try {
    const saved = JSON.parse(sessionStorage.getItem(CACHE_KEY));
    if (saved && Date.now() - saved.savedAt < CACHE_MINUTES * 60_000) return saved.data;
  } catch {
    // blocked storage or broken JSON: just load again
  }
  return null;
}

function writeCache(data) {
  try {
    sessionStorage.setItem(CACHE_KEY, JSON.stringify({ savedAt: Date.now(), data }));
  } catch {
    // storage full or blocked: the app still works without a cache
  }
}

async function loadDashboardData() {
  const cached = readCache();
  if (cached) return { ...cached, source: "cache" };

  const signal = AbortSignal.timeout(8000);
  const [todos, users] = await Promise.all([getJson("/todos", signal), getJson("/users", signal)]);

  const agents = users.map((u) => ({ id: u.id, name: u.name }));
  const tickets = todos.map((t) => ({
    id: t.id,
    subject: t.title,
    agentId: t.userId,
    status: t.completed ? "resolved" : "open",
  }));
  writeCache({ tickets, agents });
  return { tickets, agents, source: "network" };
}

sessionStorage.removeItem(CACHE_KEY); // start fresh for this demo

const first = await loadDashboardData();
console.log(`${first.tickets.length} tickets, ${first.agents.length} agents from ${first.source}`);
console.log(first.tickets[0]);

const second = await loadDashboardData();
console.log(`Second load came from ${second.source}`);
console.log(`Open: ${second.tickets.filter((t) => t.status === "open").length}`);
Output
200 tickets, 10 agents from network
{ id: 1, subject: 'delectus aut autem', agentId: 1, status: 'open' }
Second load came from cache
Open: 110

Three professional habits are visible here. The app's own field names (subject, agentId, status) are chosen once, in the API layer, so the rest of the code never depends on the server's naming; if the API changes, only this function changes. Storage errors are caught and ignored, because a cache is a bonus and must never break the app. And the cache has an expiry time, as recommended in Memory Management & Leaks.

Step 3: One pure function for filtering, sorting and paging

All of the dashboard's logic, which rows to show, in what order and on which page, fits in one pure function (see Design Patterns & Clean Code). It takes the tickets and the filters and returns exactly what the page needs. It touches no DOM, so you can test it with tiny sample data:

JavaScriptRun it yourself
const tickets = [
  { id: 1, subject: "Refund not received", agentId: 1, status: "open" },
  { id: 2, subject: "Wrong size delivered", agentId: 2, status: "resolved" },
  { id: 3, subject: "Refund for cancelled order", agentId: 2, status: "open" },
  { id: 4, subject: "Cannot log in", agentId: 1, status: "open" },
  { id: 5, subject: "Coupon not applied", agentId: 3, status: "open" },
];

// Pure: from all tickets and the current filters to exactly what the page should show
function selectVisible(allTickets, { query, status, agentId, sort, page }, pageSize) {
  const words = query.trim().toLowerCase();
  const matches = allTickets
    .filter((t) => status === "all" || t.status === status)
    .filter((t) => agentId === 0 || t.agentId === agentId)
    .filter((t) => words === "" || t.subject.toLowerCase().includes(words))
    .toSorted((a, b) => (sort === "newest" ? b.id - a.id : a.id - b.id));

  const pageCount = Math.max(1, Math.ceil(matches.length / pageSize));
  const safePage = Math.min(Math.max(1, page), pageCount);
  const start = (safePage - 1) * pageSize;
  return { rows: matches.slice(start, start + pageSize), total: matches.length, page: safePage, pageCount };
}

const filters = { query: "refund", status: "open", agentId: 0, sort: "newest", page: 1 };
console.log(selectVisible(tickets, filters, 10));

const paged = selectVisible(tickets, { ...filters, query: "", page: 9 }, 2);
console.log(paged.rows.map((t) => t.id), `page ${paged.page} of ${paged.pageCount}`);
Output
{
  rows: [
    {
      id: 3,
      subject: 'Refund for cancelled order',
      agentId: 2,
      status: 'open'
    },
    {
      id: 1,
      subject: 'Refund not received',
      agentId: 1,
      status: 'open'
    }
  ],
  total: 2,
  page: 1,
  pageCount: 1
}
[ 3, 1 ] page 2 of 2

The second call asks for page 9 of a list that has only two pages. Instead of showing an empty table, the function clamps the page into the valid range and returns page 2. This matters in practice: if a user is on page 8 and then types a search that leaves one page of results, they must not be stranded on an empty page. toSorted() returns a sorted copy, so the original array is never changed.

Step 4: Rendering safely

render() draws one of three screens from the state: loading, error or data. Each table row is built with createElement and textContent, never innerHTML, because ticket subjects are typed by customers and could contain HTML (see Web Security: XSS, CSP & CORS):

JavaScript
function renderRow(ticket) {
  const tr = document.createElement("tr");
  const cells = [String(ticket.id), ticket.subject, state.agents.get(ticket.agentId) ?? "Unassigned"];
  for (const text of cells) {
    const td = document.createElement("td");
    td.textContent = text; // text only: subjects come from users
    tr.append(td);
  }
  // ...a status badge and a Resolve/Reopen button with data-id = ticket.id
  return tr;
}

$("#rows").replaceChildren(...view.rows.map(renderRow)) swaps all 15 rows in one operation. Each action button gets an aria-label such as "Resolve ticket 58", because fifteen buttons that are all announced as just "Resolve" would be useless to a screen-reader user.

Step 5: Events with delegation and debouncing

One input listener on the whole form handles every filter, because each control's name matches a key in state.filters. The search box is the exception: it is debounced, so the table redraws once the user pauses, not on every key press (see Timers & Animation Frames). Any filter change sends the user back to page 1:

JavaScript
const applySearch = debounce((text) => {
  state.filters.query = text;
  state.filters.page = 1;
  render();
}, 250);

$("#filters").addEventListener("input", (event) => {
  const { name, value } = event.target;
  if (name === "query") return applySearch(value);
  state.filters[name] = name === "agentId" ? Number(value) : value; // select values are strings
  state.filters.page = 1;
  render();
});

The table body uses event delegation for the Resolve and Reopen buttons: one listener on #rows finds the clicked button with closest("button[data-id]"), so it keeps working when rows are redrawn.

Step 6: Optimistic updates with rollback

When an agent clicks Resolve, waiting a second for the server before anything changes feels slow. An optimistic update changes the state and redraws immediately, then saves in the background. If the save fails, the change is rolled back and the user is told:

JavaScript
const previous = ticket.status;
ticket.status = previous === "open" ? "resolved" : "open"; // optimistic update
render();
try {
  await saveStatus(ticket.id, ticket.status);              // PATCH /todos/:id
  $("#status").textContent = `Ticket ${ticket.id} marked ${ticket.status}.`;
} catch {
  ticket.status = previous;                                 // roll back
  render();
  $("#status").textContent = `Could not update ticket ${ticket.id}. Please try again.`;
}

Optimistic updates suit actions that almost always succeed and are easy to undo. For risky actions such as payments, wait for the server's answer instead.

Step 7: Keep the filters in the address

Support teams share links: "look at the open tickets for Kabir". After each render, syncAddress() writes the non-default filters into the query string with history.replaceState() (not pushState, so the Back button is not filled with one entry per key press), and readAddress() reads them when the page loads, checking every value before trusting it. The Browser APIs lesson explains both. Inside the Try-it editor the address cannot change, so that call is wrapped in try...catch; on a real page it works.

The complete app

Here is everything together. Try the filters, search for qui, page through the results, resolve a ticket, and use only the keyboard for a while:

HTMLTry it yourself
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Support ticket dashboard</title>
  <style>
    body { font-family: system-ui, sans-serif; margin: 1rem; color: #1d2433; }
    .filters { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: end; margin-block-end: 0.75rem; }
    .filters label { display: grid; gap: 0.2rem; font-size: 0.9rem; }
    table { inline-size: 100%; border-collapse: collapse; }
    caption { text-align: start; font-weight: 600; padding-block: 0.5rem; }
    th, td { text-align: start; padding: 0.4rem; border-block-end: 1px solid #ddd; }
    .badge { padding: 0.1rem 0.5rem; border-radius: 1rem; font-size: 0.8rem; }
    .open { background: #fff3cd; }
    .resolved { background: #d1f2dc; }
    .pager { display: flex; gap: 0.75rem; align-items: center; margin-block-start: 0.75rem; }
    [aria-busy="true"] { opacity: 0.5; }
    #error { color: #b00020; }
  </style>
</head>
<body>
  <h1>Support tickets</h1>

  <form class="filters" id="filters">
    <label>Search subject <input type="search" name="query" autocomplete="off"></label>
    <label>Status
      <select name="status">
        <option value="all">All</option>
        <option value="open">Open</option>
        <option value="resolved">Resolved</option>
      </select>
    </label>
    <label>Agent <select name="agentId"><option value="0">All agents</option></select></label>
    <label>Sort
      <select name="sort">
        <option value="newest">Newest first</option>
        <option value="oldest">Oldest first</option>
      </select>
    </label>
  </form>

  <p id="status" role="status"></p>
  <div id="error" role="alert" hidden>
    <span id="error-text"></span> <button type="button" id="retry">Try again</button>
  </div>

  <table id="table" aria-busy="true">
    <caption>Tickets</caption>
    <thead>
      <tr><th scope="col">#</th><th scope="col">Subject</th><th scope="col">Agent</th><th scope="col">Status</th><th scope="col">Action</th></tr>
    </thead>
    <tbody id="rows"></tbody>
  </table>

  <div class="pager">
    <button type="button" id="prev">Previous</button>
    <span id="page-info"></span>
    <button type="button" id="next">Next</button>
  </div>

  <script type="module">
    // ---------- api: talking to the server ----------
    const API = "https://jsonplaceholder.typicode.com";
    const CACHE_KEY = "ticket-dashboard-v1";
    const CACHE_MINUTES = 5;
    const PAGE_SIZE = 15;

    async function getJson(path, options = {}) {
      const response = await fetch(`${API}${path}`, options);
      if (!response.ok) throw new Error(`${path} failed with status ${response.status}`);
      return response.json();
    }

    function readCache() {
      try {
        const saved = JSON.parse(sessionStorage.getItem(CACHE_KEY));
        if (saved && Date.now() - saved.savedAt < CACHE_MINUTES * 60_000) return saved.data;
      } catch {}
      return null;
    }

    function writeCache(data) {
      try {
        sessionStorage.setItem(CACHE_KEY, JSON.stringify({ savedAt: Date.now(), data }));
      } catch {}
    }

    async function loadDashboardData() {
      const cached = readCache();
      if (cached) return cached;
      const signal = AbortSignal.timeout(8000);
      const [todos, users] = await Promise.all([getJson("/todos", { signal }), getJson("/users", { signal })]);
      const data = {
        agents: users.map((u) => ({ id: u.id, name: u.name })),
        tickets: todos.map((t) => ({ id: t.id, subject: t.title, agentId: t.userId, status: t.completed ? "resolved" : "open" })),
      };
      writeCache(data);
      return data;
    }

    function saveStatus(id, status) {
      return getJson(`/todos/${id}`, {
        method: "PATCH",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ completed: status === "resolved" }),
        signal: AbortSignal.timeout(8000),
      });
    }

    // ---------- state: one object describes the whole screen ----------
    const state = {
      loading: true,
      error: "",
      tickets: [],
      agents: new Map(),
      filters: { query: "", status: "all", agentId: 0, sort: "newest", page: 1 },
    };

    function selectVisible(allTickets, { query, status, agentId, sort, page }, pageSize) {
      const words = query.trim().toLowerCase();
      const matches = allTickets
        .filter((t) => status === "all" || t.status === status)
        .filter((t) => agentId === 0 || t.agentId === agentId)
        .filter((t) => words === "" || t.subject.toLowerCase().includes(words))
        .toSorted((a, b) => (sort === "newest" ? b.id - a.id : a.id - b.id));
      const pageCount = Math.max(1, Math.ceil(matches.length / pageSize));
      const safePage = Math.min(Math.max(1, page), pageCount);
      const start = (safePage - 1) * pageSize;
      return { rows: matches.slice(start, start + pageSize), total: matches.length, page: safePage, pageCount };
    }

    // ---------- view: draw the state, never the other way round ----------
    const $ = (selector) => document.querySelector(selector);

    function renderRow(ticket) {
      const tr = document.createElement("tr");
      const cells = [String(ticket.id), ticket.subject, state.agents.get(ticket.agentId) ?? "Unassigned"];
      for (const text of cells) {
        const td = document.createElement("td");
        td.textContent = text; // text only: subjects come from users
        tr.append(td);
      }
      const statusCell = document.createElement("td");
      const badge = document.createElement("span");
      badge.className = `badge ${ticket.status}`;
      badge.textContent = ticket.status === "open" ? "Open" : "Resolved";
      statusCell.append(badge);

      const actionCell = document.createElement("td");
      const button = document.createElement("button");
      button.type = "button";
      button.dataset.id = ticket.id;
      button.textContent = ticket.status === "open" ? "Resolve" : "Reopen";
      button.setAttribute("aria-label", `${button.textContent} ticket ${ticket.id}`);
      actionCell.append(button);

      tr.append(statusCell, actionCell);
      return tr;
    }

    function render() {
      $("#table").setAttribute("aria-busy", String(state.loading));
      $("#error").hidden = !state.error;
      $("#error-text").textContent = state.error;
      if (state.loading) {
        $("#status").textContent = "Loading tickets...";
        return;
      }
      if (state.error) {
        $("#rows").replaceChildren();
        $("#status").textContent = "";
        $("#page-info").textContent = "";
        $("#prev").disabled = $("#next").disabled = true;
        return;
      }
      const view = selectVisible(state.tickets, state.filters, PAGE_SIZE);
      state.filters.page = view.page; // keep the page number valid
      $("#rows").replaceChildren(...view.rows.map(renderRow));
      $("#status").textContent = view.total === 0
        ? "No tickets match these filters."
        : `Showing ${view.rows.length} of ${view.total} tickets`;
      $("#page-info").textContent = `Page ${view.page} of ${view.pageCount}`;
      $("#prev").disabled = view.page <= 1;
      $("#next").disabled = view.page >= view.pageCount;
      syncAddress();
    }

    // ---------- the address bar remembers the filters ----------
    function syncAddress() {
      const params = new URLSearchParams();
      for (const [key, value] of Object.entries(state.filters)) {
        if (String(value) !== String({ query: "", status: "all", agentId: 0, sort: "newest", page: 1 }[key])) {
          params.set(key, value);
        }
      }
      try {
        history.replaceState(null, "", params.size ? `?${params}` : location.pathname);
      } catch {
        // the Try-it editor cannot change its address; a real page can
      }
    }

    function readAddress() {
      const params = new URLSearchParams(location.search);
      const f = state.filters;
      f.query = params.get("query") ?? f.query;
      f.status = ["all", "open", "resolved"].includes(params.get("status")) ? params.get("status") : f.status;
      f.agentId = Number(params.get("agentId")) || 0;
      f.sort = params.get("sort") === "oldest" ? "oldest" : "newest";
      f.page = Number(params.get("page")) || 1;
      const form = $("#filters");
      form.elements.query.value = f.query;
      form.elements.status.value = f.status;
      form.elements.sort.value = f.sort;
    }

    // ---------- controller: events change state, then render ----------
    function debounce(fn, delay) {
      let timerId;
      return (...args) => {
        clearTimeout(timerId);
        timerId = setTimeout(() => fn(...args), delay);
      };
    }

    const applySearch = debounce((text) => {
      state.filters.query = text;
      state.filters.page = 1;
      render();
    }, 250);

    $("#filters").addEventListener("input", (event) => {
      const { name, value } = event.target;
      if (name === "query") return applySearch(value);
      state.filters[name] = name === "agentId" ? Number(value) : value;
      state.filters.page = 1;
      render();
    });
    $("#filters").addEventListener("submit", (event) => event.preventDefault());

    $("#prev").addEventListener("click", () => { state.filters.page--; render(); });
    $("#next").addEventListener("click", () => { state.filters.page++; render(); });

    $("#rows").addEventListener("click", async (event) => {
      const button = event.target.closest("button[data-id]");
      if (!button) return;
      const ticket = state.tickets.find((t) => t.id === Number(button.dataset.id));
      const previous = ticket.status;
      ticket.status = previous === "open" ? "resolved" : "open"; // optimistic update
      render();
      try {
        await saveStatus(ticket.id, ticket.status);
        $("#status").textContent = `Ticket ${ticket.id} marked ${ticket.status}.`;
        writeCache({ tickets: state.tickets, agents: [...state.agents].map(([id, name]) => ({ id, name })) });
      } catch {
        ticket.status = previous; // roll back: the server did not save it
        render();
        $("#status").textContent = `Could not update ticket ${ticket.id}. Please try again.`;
      }
    });

    async function start() {
      state.loading = true;
      state.error = "";
      render();
      try {
        const data = await loadDashboardData();
        state.tickets = data.tickets;
        state.agents = new Map(data.agents.map((a) => [a.id, a.name]));
        const agentSelect = $("#filters").elements.agentId;
        agentSelect.replaceChildren(new Option("All agents", "0"), ...data.agents.map((a) => new Option(a.name, a.id)));
        agentSelect.value = String(state.filters.agentId);
      } catch (error) {
        state.error = error.name === "TimeoutError"
          ? "The server is taking too long to answer."
          : "Tickets could not be loaded. Check your connection.";
      } finally {
        state.loading = false;
        render();
      }
    }

    $("#retry").addEventListener("click", start);
    readAddress();
    start();
  </script>
</body>
</html>

Splitting it into modules

In a real project this single file becomes a small Vite app (see npm, Packages & Build Tools), with one job per module:

Text
ticket-dashboard/
  index.html
  src/
    api.js          loadDashboardData(), saveStatus(): fetch, timeouts, cache
    select.js       selectVisible(): the pure filtering, sorting and paging logic
    view.js         render(), renderRow(): everything that touches the DOM
    address.js      readAddress(), syncAddress()
    main.js         state, event listeners, start()
    select.test.js  unit tests for selectVisible()

Because selectVisible() is pure, its tests need no browser and no network:

JavaScript
// select.test.js (Vitest)
import { describe, it, expect } from "vitest";
import { selectVisible } from "./select.js";

const tickets = [
  { id: 1, subject: "Refund not received", agentId: 1, status: "open" },
  { id: 2, subject: "Wrong size", agentId: 2, status: "resolved" },
];

describe("selectVisible", () => {
  it("filters by status", () => {
    const view = selectVisible(tickets, { query: "", status: "open", agentId: 0, sort: "newest", page: 1 }, 10);
    expect(view.rows.map((t) => t.id)).toEqual([1]);
  });

  it("never returns a page past the end", () => {
    const view = selectVisible(tickets, { query: "", status: "all", agentId: 0, sort: "newest", page: 99 }, 10);
    expect(view.page).toBe(1);
  });
});

Ideas for extending the project: show a count of open tickets per agent with Map.groupBy(), add a "Resolve selected" bulk action with checkboxes, retry failed saves with backoff from Async Patterns: Retry, Cancel & Limit, or keep an offline copy in IndexedDB.

Common mistakes

  • Forgetting to reset the page when filters change, which leaves users on an empty page. Reset to 1 and clamp in the pure function.
  • Comparing a select's value with a number. Form values are strings; convert with Number() before comparing with agentId.
  • Rendering subjects with innerHTML. Customer text is untrusted.
  • Optimistic updates without rollback, so the screen shows a change the server never saved.
  • One giant event handler that filters, sorts and draws in the same place. Keep the logic pure and the drawing separate.
  • No timeout on loading, so a slow network leaves "Loading..." on screen forever.

Key points

  • Model the screen as one state object and redraw it with one render() function.
  • Keep all server code in an API layer that loads in parallel, times out, renames fields and caches with an expiry.
  • Put filtering, sorting and paging in one pure, testable function that clamps the page number.
  • Render untrusted text with textContent, use event delegation, and debounce the search box.
  • Use optimistic updates with rollback for quick, low-risk actions.
  • Keep filters in the address with replaceState, and make the UI accessible with labels, live regions, table headers and clear button names.

Exercise

Add a summary line above the table that shows how many tickets are open for each agent, for example Patricia Lebsack: 14 open, Mrs. Dennis Schulist: 14 open, Clementine Bauch: 13 open, ..., sorted by the number of open tickets (highest first). Use Map.groupBy() or Object.groupBy() and update the line inside render().

Show solution
JavaScript
function openCountsByAgent(tickets, agents) {
  const open = tickets.filter((t) => t.status === "open");
  const groups = Map.groupBy(open, (t) => t.agentId);
  return [...groups]
    .map(([agentId, list]) => ({ name: agents.get(agentId) ?? "Unassigned", count: list.length }))
    .toSorted((a, b) => b.count - a.count)
    .map(({ name, count }) => `${name}: ${count} open`)
    .join(", ");
}

// inside render(), after the loading and error checks:
$("#summary").textContent = openCountsByAgent(state.tickets, state.agents);

Add <p id="summary"></p> above the table. The function is pure, so it can be tested like selectVisible(), and because render() runs after every resolve or reopen, the counts stay correct.

Quick Quiz

1. Why does the dashboard convert API objects (title, userId, completed) into its own shape (subject, agentId, status) in the API layer?

2. A user is on page 8 and types a search that matches only 3 tickets. What should happen?

3. What happens in an optimistic update if the server save fails?

4. Why does the app use history.replaceState() instead of pushState() for filters?

Learn JavaScript with a mentor

Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call