Class Quest Command Center

Build a private Firebase dashboard that gamifies fictional student progress.

Introduction

30 Second Summary

Classroom progress can disappear into scattered notes or become reduced to a list of scores. Students benefit when effort turns into visible progress without ranking them against their classmates.

In this project, you will build a teacher-operated dashboard that turns completed learning quests into XP. Firebase Authentication and Cloud Firestore keep each teacher's fictional roster private, persistent, and synchronized.

What You'll Build

Picture awarding a student 25 XP and watching their level, quest count, and progress bar update immediately.

By the end of this project, you'll have:

  • A teacher-specific class roster that appears after Google sign-in and stays private to that teacher.
  • A responsive game-style progress dashboard where completed quests increase XP, levels, and progress bars.
  • Live classroom insights that reveal total XP, average level, and students who may need support while search and status filters narrow the roster.
  • Secret Mission: Add a cooperative Class Boss that the whole class defeats by earning 600 combined XP.

Are there any prerequisites?

You'll need a Google account with access to the Firebase free tier. You should be comfortable building web apps in Visual Studio Code.

The guide includes the Windows setup for Node.js if it is missing. You'll run the dashboard in Microsoft Edge.

Before We Start

This checkpoint defines the classroom purpose behind your dashboard. You will commit to rewarding mastery and visible improvement without ranking students against one another.

Set Up the Local Game Shell

Your class dashboard needs a reliable local server before it can show student progress. A pinned toolchain keeps that starting point consistent.

In this step, Node.js v24.21.0 LTS runs the local tooling. Vite 8.3.0 serves the first browser page.

Visual Studio Code keeps the project files together. Microsoft Edge displays the visible Class Quest Command Center shell.

In this step, get ready to:
  • Verify the required Node.js runtime.
  • Create the initial Vite project files.
  • Launch the local game shell in Microsoft Edge.
Verify Node.js and npm

Windows PowerShell lets you check whether the required runtime is already available. The version check determines whether you can continue or need the pinned Node.js installer.

  • Press the Windows key to open Windows search.
  • Type Windows PowerShell into the search field.
  • Press Enter to open Windows PowerShell.
  • Check your installed Node.js version by running this command:
node --version

What does this check do?

The command asks Node.js to print its installed version. The result tells you which setup path to follow.

✔️ I see v24.21.0

Your Node.js runtime matches the project pin. You can continue to the npm check below.

ⓧ I see an older version

Your current runtime does not match the project pin. Installing v24.21.0 LTS gives Vite the supported runtime used throughout this project.

  • Open the official Node.js download page.
  • Choose the Windows installer for v24.21.0 LTS.
  • Close Windows PowerShell before starting the installer.
  • Run the downloaded installer.
  • Approve the Windows permission request if it appears.
  • Complete the installer using its default options.
  • Reopen Windows PowerShell through Windows search.
  • Repeat the Node.js version check shown above.

ⓧ Command not found

Windows cannot find a Node.js installation. The official installer adds the runtime and the bundled npm CLI.

  • Open the official Node.js download page.
  • Choose the Windows installer for v24.21.0 LTS.
  • Close Windows PowerShell before starting the installer.
  • Run the downloaded installer.
  • Approve the Windows permission request if it appears.
  • Complete the installer using its default options.
  • Reopen Windows PowerShell through Windows search.
  • Repeat the Node.js version check shown above.
  • Verify the bundled npm CLI by running this command:
npm --version

What does this check do?

The command prints the installed npm version. A version number confirms that npm is ready to install the packages listed in package.json.

Your terminal should now show an npm version number. That confirms both parts of the local JavaScript toolchain are available.

Still missing Node.js or npm?

Close every open PowerShell window after installation. Reopen PowerShell so Windows reloads the updated command path.

If the commands still fail, restart Windows before repeating both version checks.

Help me verify my Node.js and npm installation on Windows.

Create the Vite project manifest

A project folder gives Vite one defined place for source files and installed packages. The package.json manifest records the project settings needed for a repeatable installation.

  • Press the Windows key to open Windows search.
  • Type Visual Studio Code into the search field.
  • Press Enter to open Visual Studio Code.
  • Click File in the top menu bar.
  • Click Open Folder....
  • Select your Desktop in the folder picker.
  • Click New Folder.
  • Enter class-quest-command-center as the folder name.
  • Select the new class-quest-command-center folder.
  • Click Select Folder to open it in Visual Studio Code.

Seeing a Workspace Trust prompt?

Visual Studio Code may ask whether you trust the folder you created. This folder contains only the files you add during this project.

  • Select Yes, I trust the authors to enable the project tools.
  • Click New File... in the Explorer sidebar.
  • Enter package.json as the file name.
  • Press Enter to create the file.
  • Add the initial Vite project manifest by pasting this code into package.json:
{
  "name": "class-quest-command-center",
  "private": true,
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite"
  },
  "devDependencies": {
    "vite": "8.3.0"
  }
}

What does this manifest do?

  • The name field identifies the project as class-quest-command-center.
  • The private field prevents accidental publication as an npm package.
  • The dev script provides the command that starts Vite.
  • The devDependencies entry pins Vite to 8.3.0.
  • Save package.json by pressing Ctrl+S.
  • Create an integrated terminal by selecting Terminal from the top menu.
  • Select New Terminal.
  • Choose PowerShell from the terminal profile menu if another shell opens.
  • Install the dependencies from package.json by running this command:
npm install

What does this command do?

npm reads package.json and installs its development dependencies. This gives the project its pinned Vite installation.

The installation is complete when the PowerShell prompt returns without a dependency error. Your local project now has the package it needs to run.

Did the dependency installation fail?

Confirm the Visual Studio Code Explorer shows package.json inside class-quest-command-center.

Check that the integrated terminal is using the same folder. Its prompt should end with class-quest-command-center.

Help me fix npm install in my class-quest-command-center folder.

Create and run the browser shell

The browser needs an HTML entry point with a module script. This first page keeps the visible result small so you can prove the complete local path works.

  • Click the class-quest-command-center folder in the Explorer sidebar.
  • Click New File....
  • Enter index.html as the file name.
  • Add the visible project shell by pasting this code into index.html:
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Class Quest Command Center</title>
  </head>
  <body>
    <div id="app">
      <h1>Class Quest Command Center</h1>
    </div>

    <script type="module" src="/src/main.js"></script>
  </body>
</html>

What does this HTML do?

  • The document metadata gives the page its character encoding and responsive viewport.
  • The h1 element provides the visible Class Quest Command Center title.
  • The module script loads /src/main.js through Vite.
  • Save index.html by pressing Ctrl+S.
  • Confirm the Explorer sidebar lists index.html.

Is index.html missing?

Select the class-quest-command-center folder before using New File.... This places the file at the top level of the project.

Help me place index.html in the correct Visual Studio Code folder.

  • Select the class-quest-command-center folder in the Explorer sidebar.
  • Click New Folder.
  • Enter src as the folder name.
  • Press Enter to create the folder.

The Explorer now shows a src folder inside class-quest-command-center.

  • Select the src folder in the Explorer sidebar.
  • Click New File....
  • Enter main.js as the file name.
  • Add the initial JavaScript module by pasting this code into src/main.js:
let students = [];

What does this module do?

The students array starts as an empty in-memory roster. Later code can populate this collection with the fictional class records.

  • Save src/main.js by pressing Ctrl+S.
  • Confirm the Explorer sidebar shows main.js inside src.

Is main.js in the wrong place?

The module path must be src/main.js inside class-quest-command-center. Select the src folder before creating the file.

Help me correct the src/main.js file location in Visual Studio Code.

The Vite server keeps the integrated terminal busy while it runs. Press Ctrl+C whenever you need to stop it.

Before you run it, which result do you expect: a loaded title or an unavailable page.

  • Start the Vite development server by running this command:
npm run dev

What does this command do?

npm runs the dev script from package.json. Vite then serves the project at http://localhost:5173.

  • Leave the Vite terminal running.
  • Press the Windows key to open Windows search.
  • Type Microsoft Edge into the search field.
  • Press Enter to open Microsoft Edge.
  • Enter http://localhost:5173 in the address bar.
  • Press Enter to load the local page.

You should see the Class Quest Command Center title. That first page proves your files, package installation, local server, and browser are connected.

Does the page fail to load?

Keep the Visual Studio Code terminal open while checking the page. Closing that terminal stops the local server.

Confirm the terminal is inside class-quest-command-center. Check that the address bar contains http://localhost:5173.

Help me troubleshoot why my Vite page is not loading in Microsoft Edge.

✔️ Awesome, I've got everything!

Your three project files are saved. Keep the Vite development server running for the next step.

ⓧ I'd like to double check the full code

Compare each file below with the matching file in Visual Studio Code.

{
  "name": "class-quest-command-center",
  "private": true,
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite"
  },
  "devDependencies": {
    "vite": "8.3.0"
  }
}
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Class Quest Command Center</title>
  </head>
  <body>
    <div id="app">
      <h1>Class Quest Command Center</h1>
    </div>

    <script type="module" src="/src/main.js"></script>
  </body>
</html>
let students = [];

Your local shell is running. Next, you will turn that single title into a game dashboard with student cards, levels, and XP progress.

Build the Game Dashboard

Your Vite shell proved that the local project runs in Microsoft Edge. A title alone cannot show how a teacher follows progress across a class.

This step turns that shell into a visible game dashboard. Four fictional students give you a safe roster for testing levels, status badges, quest counts, and progress bars before behavior or cloud storage adds complexity.

In this step, get ready to:
  • Build the dashboard shell for the teacher controls and class roster.
  • Style the interface as a responsive game dashboard.
  • Render four fictional student cards from JavaScript.
Build the dashboard shell

The HTML shell gives every part of the dashboard a stable place in the DOM. Its IDs let JavaScript update student cards and classroom statistics without rebuilding the whole page.

  • Select index.html in the Explorer sidebar.
  • Select the full-code tab below.
  • Replace the current contents of index.html with the complete dashboard shell.

✔️ Awesome, I've got everything!

Keep this tab selected once your dashboard shell matches the complete file.

ⓧ I'd like to double check the full code

Compare your index.html file with this complete dashboard shell.

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta
      name="description"
      content="A teacher-operated game dashboard for tracking fictional student progress."
    />
    <title>Class Quest Command Center</title>
  </head>
  <body>
    <div id="app">
      <header class="topbar">
        <div>
          <p class="eyebrow">Teacher dashboard</p>
          <h1>Class Quest Command Center</h1>
          <p id="sync-status" class="sync-status">Sign in to load your class.</p>
        </div>
        <div class="auth-actions">
          <span id="teacher-name">Guest teacher</span>
          <button id="sign-in-button" type="button">Sign in with Google</button>
          <button id="sign-out-button" class="secondary" type="button" hidden>
            Sign out
          </button>
        </div>
      </header>

      <main>
        <section class="hero-panel">
          <div>
            <p class="eyebrow">Current campaign</p>
            <h2>Mastery Mountain</h2>
            <p>Complete meaningful learning quests and grow as a team.</p>
          </div>
          <div class="quest-rule">
            <span>Quest reward</span>
            <strong>25 XP</strong>
          </div>
        </section>

        <section id="dashboard" hidden>
          <div class="stats-grid">
            <article class="stat-card">
              <span>Total class XP</span>
              <strong id="class-xp">0</strong>
            </article>
            <article class="stat-card">
              <span>Average level</span>
              <strong id="average-level">0</strong>
            </article>
            <article class="stat-card">
              <span>Needs support</span>
              <strong id="support-count">0</strong>
            </article>
          </div>

          <div class="toolbar">
            <label>
              Search students
              <input id="student-search" type="search" placeholder="Search by name or subject" />
            </label>
            <label>
              Progress status
              <select id="status-filter">
                <option value="all">All students</option>
                <option value="support">Needs support</option>
                <option value="on-track">On track</option>
                <option value="ready">Ready to level up</option>
              </select>
            </label>
          </div>

          <section id="student-grid" class="student-grid" aria-label="Student roster"></section>
        </section>

        <section id="empty-state" class="empty-state">
          <h2>Your class adventure is waiting</h2>
          <p>Sign in to create or load your private fictional roster.</p>
        </section>
      </main>

      <div id="toast" class="toast" role="status" aria-live="polite"></div>
    </div>

    <script type="module" src="/src/main.js"></script>
  </body>
</html>

How Does the Dashboard Shell Work?

  • The topbar contains the teacher identity and authentication controls.
  • The dashboard contains the statistics, filters, and student roster.
  • The empty-state gives signed-out teachers a clear next step.
  • The toast provides a live status region for short feedback messages.
  • Save index.html.
  • Return to Microsoft Edge.
  • Refresh http://localhost:5173 by pressing Ctrl+R.

You should see the teacher header, the Mastery Mountain campaign panel, and the signed-out class message. The roster remains hidden until JavaScript supplies the fictional students.

Dashboard Shell Missing?

Check that the module script still points to /src/main.js. Confirm that each opening HTML element has a matching closing element.

If the old shell remains visible, confirm that you saved index.html inside the open class-quest-command-center folder.

Help me compare my dashboard shell with the expected index.html structure.

Style the game dashboard

The stylesheet turns the document structure into a responsive interface. Cards emphasize each student's personal progress without arranging the class as a public leaderboard.

  • Select the src folder in the Explorer sidebar.
  • Click the New File button in the Explorer toolbar.
  • Enter style.css as the file name.
  • Select the full-code tab below.
  • Paste the complete stylesheet into src/style.css.

✔️ Awesome, I've got everything!

Keep this tab selected once every style section is present in src/style.css.

ⓧ I'd like to double check the full code

Compare your src/style.css file with this complete stylesheet.

:root {
  color: #eef2ff;
  background: #090b1a;
  font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
  font-synthesis: none;
}

* {
  box-sizing: border-box;
}

body {
  min-width: 320px;
  min-height: 100vh;
  margin: 0;
  background:
    radial-gradient(circle at top left, rgba(96, 76, 255, 0.2), transparent 32rem),
    #090b1a;
}

button,
input,
select {
  font: inherit;
}

button {
  border: 0;
  cursor: pointer;
}

button:focus-visible,
input:focus-visible,
select:focus-visible {
  outline: 3px solid #8fe3ff;
  outline-offset: 2px;
}

[hidden] {
  display: none !important;
}

#app {
  width: min(1180px, calc(100% - 32px));
  margin: 0 auto;
  padding: 28px 0 56px;
}

.topbar,
.hero-panel,
.student-card,
.stat-card,
.empty-state,
.toolbar {
  border: 1px solid rgba(255, 255, 255, 0.1);
  background: rgba(18, 22, 48, 0.88);
  box-shadow: 0 18px 50px rgba(0, 0, 0, 0.22);
}

.topbar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 24px;
  padding: 24px;
  border-radius: 22px;
}

h1,
h2,
h3,
p {
  margin-top: 0;
}

h1 {
  margin-bottom: 6px;
  font-size: clamp(1.6rem, 4vw, 2.5rem);
}

.eyebrow {
  margin-bottom: 6px;
  color: #9aa7ff;
  font-size: 0.75rem;
  font-weight: 800;
  letter-spacing: 0.16em;
  text-transform: uppercase;
}

.sync-status,
.student-focus,
.hero-panel p,
.empty-state p {
  margin-bottom: 0;
  color: #aeb7d8;
}

.auth-actions {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: 12px;
  flex-wrap: wrap;
}

.auth-actions button,
.award-button {
  padding: 10px 14px;
  border-radius: 12px;
  color: #070916;
  background: #7de2d1;
  font-weight: 800;
}

.auth-actions .secondary {
  color: #eef2ff;
  background: #2e355f;
}

.hero-panel {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 24px;
  margin: 24px 0;
  padding: 28px;
  border-radius: 22px;
  background:
    linear-gradient(120deg, rgba(98, 72, 255, 0.4), rgba(34, 211, 238, 0.12)),
    rgba(18, 22, 48, 0.9);
}

.quest-rule {
  min-width: 132px;
  padding: 16px;
  border-radius: 16px;
  color: #11152b;
  background: #f9d56e;
  text-align: center;
}

.quest-rule span,
.quest-rule strong {
  display: block;
}

.quest-rule span {
  font-size: 0.8rem;
}

.quest-rule strong {
  margin-top: 4px;
  font-size: 1.5rem;
}

.stats-grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 16px;
}

.stat-card {
  padding: 20px;
  border-radius: 18px;
}

.stat-card span,
.stat-card strong {
  display: block;
}

.stat-card span {
  color: #aeb7d8;
}

.stat-card strong {
  margin-top: 8px;
  font-size: 2rem;
}

.toolbar {
  display: grid;
  grid-template-columns: 1fr 240px;
  gap: 16px;
  margin: 16px 0;
  padding: 18px;
  border-radius: 18px;
}

.toolbar label {
  display: grid;
  gap: 8px;
  color: #c6cdec;
  font-size: 0.85rem;
  font-weight: 700;
}

.toolbar input,
.toolbar select {
  width: 100%;
  padding: 11px 12px;
  border: 1px solid #3a426f;
  border-radius: 10px;
  color: #eef2ff;
  background: #10142d;
}

.student-grid {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 16px;
}

.student-card {
  padding: 20px;
  border-radius: 18px;
}

.student-header {
  display: grid;
  grid-template-columns: auto 1fr auto;
  align-items: center;
  gap: 12px;
}

.avatar {
  display: grid;
  width: 48px;
  height: 48px;
  place-items: center;
  border-radius: 14px;
  color: #10142d;
  background: #8fe3ff;
  font-weight: 900;
}

.student-name {
  margin-bottom: 3px;
}

.level-badge,
.status-badge {
  width: fit-content;
  border-radius: 999px;
  font-size: 0.75rem;
  font-weight: 800;
}

.level-badge {
  padding: 7px 10px;
  color: #f9d56e;
  background: rgba(249, 213, 110, 0.12);
}

.status-badge {
  display: inline-block;
  margin: 18px 0 14px;
  padding: 6px 9px;
}

.status-support {
  color: #ffd0d8;
  background: rgba(255, 102, 128, 0.16);
}

.status-on-track {
  color: #b8f7ec;
  background: rgba(125, 226, 209, 0.14);
}

.status-ready {
  color: #ffe9a6;
  background: rgba(249, 213, 110, 0.14);
}

.progress-label,
.card-footer {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
}

.progress-label {
  color: #aeb7d8;
  font-size: 0.82rem;
}

.progress-track {
  height: 10px;
  margin-top: 8px;
  overflow: hidden;
  border-radius: 999px;
  background: #272d52;
}

.progress-fill {
  height: 100%;
  border-radius: inherit;
  background: linear-gradient(90deg, #7367f0, #5eead4);
  transition: width 220ms ease;
}

.card-footer {
  margin-top: 18px;
}

.quest-count {
  color: #c6cdec;
  font-size: 0.85rem;
}

.award-button:hover,
.auth-actions button:hover {
  filter: brightness(1.08);
}

.empty-state,
.no-results {
  padding: 42px;
  border-radius: 20px;
  text-align: center;
}

.no-results {
  grid-column: 1 / -1;
  color: #aeb7d8;
}

.toast {
  position: fixed;
  right: 24px;
  bottom: 24px;
  max-width: min(360px, calc(100% - 48px));
  padding: 14px 18px;
  border-radius: 12px;
  color: #09101d;
  background: #8fe3ff;
  font-weight: 800;
  opacity: 0;
  pointer-events: none;
  transform: translateY(12px);
  transition: 180ms ease;
}

.toast.visible {
  opacity: 1;
  transform: translateY(0);
}

@media (max-width: 760px) {
  .topbar,
  .hero-panel {
    align-items: flex-start;
    flex-direction: column;
  }

  .stats-grid,
  .student-grid,
  .toolbar {
    grid-template-columns: 1fr;
  }

  .auth-actions {
    justify-content: flex-start;
  }
}

What Does the Stylesheet Control?

  • The shared card rules create a consistent dark surface across the header, toolbar, statistics, and roster.
  • The status classes give each progress state a distinct color.
  • The progress track clips each colored fill to the student's current progress.
  • The mobile breakpoint changes multi-column areas into single-column layouts on narrow screens.
  • Save src/style.css.
  • Return to src/main.js in the Explorer sidebar.
  • Replace its current contents with this stylesheet import:
import "./style.css";

What Does This Import Do?

The import adds src/style.css to Vite's module graph. Vite applies the stylesheet while the development server is running.

  • Save src/main.js.
  • Return to Microsoft Edge.

You should see a dark purple dashboard shell with a bordered teacher header and a highlighted Mastery Mountain panel. The layout should fit the browser without horizontal scrolling.

Styles Not Appearing?

Confirm that style.css sits inside the src folder. Check that the import uses the exact relative path ./style.css.

If the page becomes blank, inspect src/main.js for an extra character around the import statement.

Help me find why the Class Quest stylesheet is not loading in Vite.

Render the fictional roster

The roster lives in JavaScript as structured student records. Rendering functions calculate each student's level and turn those records into visible cards.

How Does Level Progress Work?

Each level covers 100 XP. Dividing total XP into groups of 100 gives the current level.

The remainder becomes progress toward the next level. A student with 190 XP reaches Level 2 with 90 XP shown in the current progress bar.

  • Select src/main.js in the Explorer sidebar.
  • Select the full-code tab below.
  • Replace the current contents of src/main.js with the complete local dashboard code.

✔️ Awesome, I've got everything!

Keep this tab selected once your local roster code matches the complete file.

ⓧ I'd like to double check the full code

Compare your src/main.js file with this complete local dashboard implementation.

import "./style.css";

const seedStudents = [
  {
    id: "alex-morgan",
    name: "Alex Morgan",
    initials: "AM",
    focus: "Mathematics",
    xp: 40,
    completedQuests: 1
  },
  {
    id: "jamie-lee",
    name: "Jamie Lee",
    initials: "JL",
    focus: "Science",
    xp: 75,
    completedQuests: 3
  },
  {
    id: "riley-chen",
    name: "Riley Chen",
    initials: "RC",
    focus: "Reading",
    xp: 190,
    completedQuests: 7
  },
  {
    id: "sam-rivera",
    name: "Sam Rivera",
    initials: "SR",
    focus: "History",
    xp: 125,
    completedQuests: 5
  }
];

const dashboard = document.querySelector("#dashboard");
const emptyState = document.querySelector("#empty-state");
const studentGrid = document.querySelector("#student-grid");
const searchInput = document.querySelector("#student-search");
const statusFilter = document.querySelector("#status-filter");
const classXp = document.querySelector("#class-xp");
const averageLevel = document.querySelector("#average-level");
const supportCount = document.querySelector("#support-count");

let students = [...seedStudents];

function getLevel(xp) {
  return Math.floor(xp / 100) + 1;
}

function getLevelProgress(xp) {
  return xp % 100;
}

function getStatus(student) {
  if (student.completedQuests <= 1) {
    return "Needs support";
  }

  if (getLevelProgress(student.xp) >= 75) {
    return "Ready to level up";
  }

  return "On track";
}

function getStatusValue(status) {
  const values = {
    "Needs support": "support",
    "On track": "on-track",
    "Ready to level up": "ready"
  };

  return values[status];
}

function makeElement(tag, className, text) {
  const element = document.createElement(tag);
  element.className = className;
  element.textContent = text;
  return element;
}

function createStudentCard(student) {
  const level = getLevel(student.xp);
  const progress = getLevelProgress(student.xp);
  const status = getStatus(student);
  const card = document.createElement("article");
  card.className = "student-card";

  const cardHeader = document.createElement("div");
  cardHeader.className = "student-header";

  const avatar = makeElement("div", "avatar", student.initials);
  const identity = document.createElement("div");
  identity.append(
    makeElement("h3", "student-name", student.name),
    makeElement("p", "student-focus", student.focus)
  );

  const levelBadge = makeElement("span", "level-badge", `Level ${level}`);
  cardHeader.append(avatar, identity, levelBadge);

  const statusBadge = makeElement(
    "span",
    `status-badge status-${getStatusValue(status)}`,
    status
  );

  const progressLabel = document.createElement("div");
  progressLabel.className = "progress-label";
  progressLabel.append(
    makeElement("span", "", `${student.xp} total XP`),
    makeElement("span", "", `${progress} / 100 XP`)
  );

  const progressTrack = document.createElement("div");
  progressTrack.className = "progress-track";
  progressTrack.setAttribute("role", "progressbar");
  progressTrack.setAttribute("aria-valuemin", "0");
  progressTrack.setAttribute("aria-valuemax", "100");
  progressTrack.setAttribute("aria-valuenow", String(progress));

  const progressFill = document.createElement("div");
  progressFill.className = "progress-fill";
  progressFill.style.width = `${progress}%`;
  progressTrack.append(progressFill);

  const cardFooter = document.createElement("div");
  cardFooter.className = "card-footer";
  cardFooter.append(
    makeElement("span", "quest-count", `${student.completedQuests} quests completed`)
  );

  const awardButton = makeElement("button", "award-button", "Award 25 XP");
  awardButton.type = "button";
  awardButton.dataset.awardStudent = student.id;
  cardFooter.append(awardButton);

  card.append(cardHeader, statusBadge, progressLabel, progressTrack, cardFooter);
  return card;
}

function getVisibleStudents() {
  const term = searchInput.value.trim().toLowerCase();
  const selectedStatus = statusFilter.value;

  return students.filter((student) => {
    const matchesSearch =
      student.name.toLowerCase().includes(term) ||
      student.focus.toLowerCase().includes(term);
    const matchesStatus =
      selectedStatus === "all" || getStatusValue(getStatus(student)) === selectedStatus;

    return matchesSearch && matchesStatus;
  });
}

function renderStudents() {
  studentGrid.replaceChildren();
  const visibleStudents = getVisibleStudents();

  if (visibleStudents.length === 0) {
    studentGrid.append(
      makeElement("p", "no-results", "No students match the current filters.")
    );
    return;
  }

  visibleStudents.forEach((student) => {
    studentGrid.append(createStudentCard(student));
  });
}

function renderStats() {
  const totalXp = students.reduce((total, student) => total + student.xp, 0);
  const average = students.length
    ? students.reduce((total, student) => total + getLevel(student.xp), 0) /
      students.length
    : 0;
  const needsSupport = students.filter(
    (student) => getStatus(student) === "Needs support"
  ).length;

  classXp.textContent = String(totalXp);
  averageLevel.textContent = average.toFixed(1);
  supportCount.textContent = String(needsSupport);
}

function renderDashboard() {
  renderStats();
  renderStudents();
}

dashboard.hidden = false;
emptyState.hidden = true;
renderDashboard();

How Does the Roster Render?

  • The seedStudents array holds four fictional records for safe testing.
  • The getLevel() function converts total XP into a level.
  • The getStatus() function turns quest activity and level progress into a support signal.
  • The createStudentCard() function builds one complete card from a student record.
  • The renderStats() function calculates the class totals from the same in-memory roster.
  • The renderDashboard() function refreshes the statistics and student grid together.

Before you save the finished JavaScript, which four students do you expect the dashboard to display?

  • Save src/main.js.
  • Return to the integrated PowerShell terminal from earlier.
  • Confirm that the Vite server is still running at http://localhost:5173.
  • Return to Microsoft Edge.
  • Refresh the page by pressing Ctrl+R.

You should see cards for Alex Morgan, Jamie Lee, Riley Chen, and Sam Rivera. Each card shows initials, a focus subject, total XP, a level, completed quests, a status badge, and a progress bar.

That is the first complete dashboard view working. Your fictional class now turns structured JavaScript records into progress a teacher can scan.

Check the Starting Statistics

The statistics row should show 430 total class XP. The average level should show 1.5.

The support count should show 1. Alex has completed one quest, which places that card in the Needs support state.

Student Cards Missing?

Confirm that index.html contains student-grid, dashboard, and every statistics ID used by src/main.js.

Compare the braces around seedStudents and createStudentCard(). A missing brace prevents the module from rendering any cards.

Help me debug why the fictional student cards are not rendering.

Your game dashboard now presents a complete fictional class as visible progress. Next up, you will make the Award 25 XP buttons update the correct student.

Award XP Locally

Your four student cards already make the roster visible. The cards still behave like read-only reports.

This step adds event delegation to connect every award button to the matching record in local memory. You will then test whether that progress survives a page refresh.

In this step, get ready to:
  • Connect every award button through one student-grid listener.
  • Recalculate student progress after each completed quest.
  • Test local progress across a browser refresh.
Connect the award buttons

A single listener on #student-grid can handle clicks from every student card. Each button already stores its student identifier in data-award-student.

  • Locate the existing DOM reference block in src/main.js.
  • Add the toast helper after that block by pasting this code:
const toast = document.querySelector("#toast");

let toastTimer = null;

function showToast(message) {
  toast.textContent = message;
  toast.classList.add("visible");
  window.clearTimeout(toastTimer);
  toastTimer = window.setTimeout(() => toast.classList.remove("visible"), 2200);
}

How does the toast helper work?

  • The toast reference targets the live status element at the bottom of the page.
  • The visible class reveals the confirmation message.
  • The timer removes that class after the learner has time to read the message.
  • Save src/main.js.
  • Refresh the dashboard in Microsoft Edge.

You should still see all four student cards. This confirms that the helper has not interrupted the existing render.

Did the student cards disappear?

  • Check that the closing brace for showToast() appears after the timer line.
  • Check that toastTimer uses the same spelling in all three places.

Help me fix the toast helper in my local dashboard.

The click listener uses the button's stored identifier to find one record in students. It updates that record before rendering the dashboard again.

  • Find the closing brace of renderDashboard() in src/main.js.
  • Add the student-grid listener immediately below that function by pasting this code:
studentGrid.addEventListener("click", (event) => {
  const awardButton = event.target.closest("[data-award-student]");

  if (!awardButton) {
    return;
  }

  const student = students.find(
    (student) => student.id === awardButton.dataset.awardStudent
  );

  if (!student) {
    return;
  }

  student.xp += 25;
  student.completedQuests += 1;
  renderDashboard();
  showToast("Quest complete. 25 XP awarded.");
});

What does this listener do?

  • The closest() check identifies whether the click came from an award button.
  • The button's awardStudent value identifies the matching record in students.
  • The two updates add 25 XP plus one completed quest to that local record.
  • The final render recalculates the card plus the classroom statistics from the updated array.

Before you click, which parts of Alex Morgan's card do you expect to change?

  • Save src/main.js.
  • Switch back to the dashboard in Microsoft Edge.
  • Click Award 25 XP on Alex Morgan's card.

You should see Alex move from 40 XP to 65 XP. The quest count increases from one to two.

Alex's status changes to On track. The class total becomes 455 XP while the toast confirms the award.

Does the button stay inactive?

  • Confirm that the listener is attached to studentGrid.
  • Confirm that the selector is written as [data-award-student].
  • Confirm that the listener appears before the first call to renderDashboard().

Help me debug the local XP button.

✔️ Awesome, I've got everything!

Great. Double check that you saved src/main.js after adding the toast helper plus the student-grid listener.

ⓧ I'd like to double check the full code

Compare your local src/main.js file with this complete interactive version.

import "./style.css";

const seedStudents = [
  {
    id: "alex-morgan",
    name: "Alex Morgan",
    initials: "AM",
    focus: "Mathematics",
    xp: 40,
    completedQuests: 1
  },
  {
    id: "jamie-lee",
    name: "Jamie Lee",
    initials: "JL",
    focus: "Science",
    xp: 75,
    completedQuests: 3
  },
  {
    id: "riley-chen",
    name: "Riley Chen",
    initials: "RC",
    focus: "Reading",
    xp: 190,
    completedQuests: 7
  },
  {
    id: "sam-rivera",
    name: "Sam Rivera",
    initials: "SR",
    focus: "History",
    xp: 125,
    completedQuests: 5
  }
];

const dashboard = document.querySelector("#dashboard");
const emptyState = document.querySelector("#empty-state");
const studentGrid = document.querySelector("#student-grid");
const searchInput = document.querySelector("#student-search");
const statusFilter = document.querySelector("#status-filter");
const classXp = document.querySelector("#class-xp");
const averageLevel = document.querySelector("#average-level");
const supportCount = document.querySelector("#support-count");
const toast = document.querySelector("#toast");

let toastTimer = null;

function showToast(message) {
  toast.textContent = message;
  toast.classList.add("visible");
  window.clearTimeout(toastTimer);
  toastTimer = window.setTimeout(() => toast.classList.remove("visible"), 2200);
}

let students = seedStudents.map((student) => ({ ...student }));

function getLevel(xp) {
  return Math.floor(xp / 100) + 1;
}

function getLevelProgress(xp) {
  return xp % 100;
}

function getStatus(student) {
  if (student.completedQuests <= 1) {
    return "Needs support";
  }

  if (getLevelProgress(student.xp) >= 75) {
    return "Ready to level up";
  }

  return "On track";
}

function getStatusValue(status) {
  const values = {
    "Needs support": "support",
    "On track": "on-track",
    "Ready to level up": "ready"
  };

  return values[status];
}

function makeElement(tag, className, text) {
  const element = document.createElement(tag);
  element.className = className;
  element.textContent = text;
  return element;
}

function createStudentCard(student) {
  const level = getLevel(student.xp);
  const progress = getLevelProgress(student.xp);
  const status = getStatus(student);
  const card = document.createElement("article");
  card.className = "student-card";

  const cardHeader = document.createElement("div");
  cardHeader.className = "student-header";

  const avatar = makeElement("div", "avatar", student.initials);
  const identity = document.createElement("div");
  identity.append(
    makeElement("h3", "student-name", student.name),
    makeElement("p", "student-focus", student.focus)
  );

  const levelBadge = makeElement("span", "level-badge", `Level ${level}`);
  cardHeader.append(avatar, identity, levelBadge);

  const statusBadge = makeElement(
    "span",
    `status-badge status-${getStatusValue(status)}`,
    status
  );

  const progressLabel = document.createElement("div");
  progressLabel.className = "progress-label";
  progressLabel.append(
    makeElement("span", "", `${student.xp} total XP`),
    makeElement("span", "", `${progress} / 100 XP`)
  );

  const progressTrack = document.createElement("div");
  progressTrack.className = "progress-track";
  progressTrack.setAttribute("role", "progressbar");
  progressTrack.setAttribute("aria-valuemin", "0");
  progressTrack.setAttribute("aria-valuemax", "100");
  progressTrack.setAttribute("aria-valuenow", String(progress));

  const progressFill = document.createElement("div");
  progressFill.className = "progress-fill";
  progressFill.style.width = `${progress}%`;
  progressTrack.append(progressFill);

  const cardFooter = document.createElement("div");
  cardFooter.className = "card-footer";
  cardFooter.append(
    makeElement("span", "quest-count", `${student.completedQuests} quests completed`)
  );

  const awardButton = makeElement("button", "award-button", "Award 25 XP");
  awardButton.type = "button";
  awardButton.dataset.awardStudent = student.id;
  cardFooter.append(awardButton);

  card.append(cardHeader, statusBadge, progressLabel, progressTrack, cardFooter);
  return card;
}

function getVisibleStudents() {
  const term = searchInput.value.trim().toLowerCase();
  const selectedStatus = statusFilter.value;

  return students.filter((student) => {
    const matchesSearch =
      student.name.toLowerCase().includes(term) ||
      student.focus.toLowerCase().includes(term);
    const matchesStatus =
      selectedStatus === "all" || getStatusValue(getStatus(student)) === selectedStatus;

    return matchesSearch && matchesStatus;
  });
}

function renderStudents() {
  studentGrid.replaceChildren();
  const visibleStudents = getVisibleStudents();

  if (visibleStudents.length === 0) {
    studentGrid.append(
      makeElement("p", "no-results", "No students match the current filters.")
    );
    return;
  }

  visibleStudents.forEach((student) => {
    studentGrid.append(createStudentCard(student));
  });
}

function renderStats() {
  const totalXp = students.reduce((total, student) => total + student.xp, 0);
  const average = students.length
    ? students.reduce((total, student) => total + getLevel(student.xp), 0) /
      students.length
    : 0;
  const needsSupport = students.filter(
    (student) => getStatus(student) === "Needs support"
  ).length;

  classXp.textContent = String(totalXp);
  averageLevel.textContent = average.toFixed(1);
  supportCount.textContent = String(needsSupport);
}

function renderDashboard() {
  renderStats();
  renderStudents();
}

studentGrid.addEventListener("click", (event) => {
  const awardButton = event.target.closest("[data-award-student]");

  if (!awardButton) {
    return;
  }

  const student = students.find(
    (student) => student.id === awardButton.dataset.awardStudent
  );

  if (!student) {
    return;
  }

  student.xp += 25;
  student.completedQuests += 1;
  renderDashboard();
  showToast("Quest complete. 25 XP awarded.");
});

dashboard.hidden = false;
emptyState.hidden = true;
renderDashboard();

searchInput.addEventListener("input", renderStudents);
statusFilter.addEventListener("change", renderStudents);
Watch a level recalculate

A student's level is derived from their current XP each time the dashboard renders. Crossing a 100 XP boundary changes the level plus the progress toward the next level.

Jamie Lee currently has 75 XP. Before you click, what do you expect the level bar to show after another 25 XP?

  • Click Award 25 XP on Jamie Lee's card.

You should see Jamie reach Level 2 with 0 / 100 XP toward the next level. The quest count increases to four.

Jamie's status changes to On track. The class total becomes 480 XP while the average level becomes 1.8.

Test progress across a refresh

The cards now prove that the local game loop responds to completed quests. The next check tests whether the updated state outlives the current page session.

Before you refresh, do you think Alex's and Jamie's new XP totals will remain?

  • Refresh the page in Microsoft Edge.

You will see Alex return to 40 XP with one completed quest. Jamie returns to 75 XP with three completed quests.

The class total returns to 430 XP. This reset is the intended shortfall in the local version.

Why did the progress disappear?

Refreshing the page runs src/main.js from the beginning. The students array is rebuilt from seedStudents in browser memory.

Local memory lasts only for the current page session. The reset proves that the game needs persistent storage before a teacher can rely on it.

That reset did its job. Next, you will move each teacher's progress into Cloud Firestore so the same roster returns after a refresh.

Secure Progress with Firebase

Your local game loop awards XP correctly. However, every refresh rebuilds the fictional roster from its original values.

In this step, you will use Firebase Authentication to identify the teacher. You will store that teacher's private roster in Cloud Firestore so progress survives browser sessions.

In this step, get ready to:
  • Register the Web app and create its Cloud Firestore database.
  • Enable Google sign-in and publish teacher-scoped security rules.
  • Connect the dashboard to realtime cloud data.
Register the Firebase app and database

A Firebase Web app registration gives your browser project the configuration values required to connect to Firebase services. The Cloud Firestore database provides the persistent document storage that the local array was missing.

Creating this project can feel like a billing step. The Spark pricing plan requires no payment information at tutorial scale.

  • Switch back to Microsoft Edge.
  • Open the Firebase console in a new browser tab.
  • Sign in with the Google account you want to use for this project.
  • Start the Firebase project creation flow.
  • Enter class-quest-command-center as the project name.
  • Complete the project creation flow.
  • Click the Web icon on the project overview page.
  • Enter Class Quest Command Center as the app nickname.
  • Click Register app.

What did you register?

The Web app registration represents your browser dashboard inside the Firebase project. Its configuration identifies the correct Firebase project when initializeApp() runs.

Cloud Firestore stores data in documents grouped into collections. Your project uses a separate student subcollection beneath each authenticated teacher.

  • Select Firestore under Databases & Storage in the Firebase console.
  • Click Create database.
  • Select Standard edition.
  • Choose the database location closest to your classroom.
  • Select Production mode.
  • Click Create.

Why use Production mode?

Production mode denies browser reads and writes until your security rules allow them. This starts your student data behind a closed door.

Test mode allows anyone to read and overwrite data. Teacher-scoped rules give this dashboard a safer foundation.

  • Open Project settings in the Firebase console.
  • Find your Web app under Your apps.
  • Select Config under Firebase SDK snippet.
  • Copy the generated Firebase configuration object.
  • Switch back to Visual Studio Code.
  • Create firebase-config.js inside the existing src folder.
  • Add the exported configuration template to src/firebase-config.js by pasting this code:
export const firebaseConfig = {
  apiKey: "your-api-key-here",
  authDomain: "your-project-id.firebaseapp.com",
  projectId: "your-project-id",
  storageBucket: "your-project-id.firebasestorage.app",
  messagingSenderId: "your-messaging-sender-id",
  appId: "your-app-id"
};

What does this configuration do?

  • The exported firebaseConfig object identifies the Firebase project used by your dashboard.
  • The authDomain value connects the app to its authentication flow.
  • The projectId value directs database operations to the correct Cloud Firestore project.
  • Replace every placeholder in src/firebase-config.js with the matching value from the configuration object you copied.
  • Save src/firebase-config.js.
  • Confirm firebase-config.js appears beneath src in the Explorer sidebar.

Missing a configuration value?

Return to Project settings in the Firebase console. Select your Web app under Your apps to display its configuration again.

Keep the export const firebaseConfig = prefix in your file. The final import depends on that named export.

Help me check my Firebase Web app configuration file.

Enable sign-in and publish private rules

Authentication gives every teacher a unique user ID. Security rules use that ID to permit access only when it matches the teacher segment in the document path.

  • Select Authentication under Security in the Firebase console.
  • Open the Sign-in method tab.
  • Select the Google provider.
  • Enable the provider.
  • Select your Google account as the project support email.
  • Click Save.

Why Google popup sign-in?

Popup sign-in keeps this desktop browser project on the current dashboard while the teacher chooses an account. Firebase Authentication then reports the signed-in user through onAuthStateChanged().

New Firebase projects do not authorize localhost automatically. Adding it allows the local Vite page to complete the sign-in flow.

  • Open the Settings tab in Firebase Authentication.
  • Find Authorized domains.
  • Click Add domain.
  • Enter localhost.
  • Confirm the new authorized domain.
  • Switch back to Visual Studio Code.
  • Create firestore.rules inside the existing class-quest-command-center folder.
  • Add the teacher-scoped security policy to firestore.rules by pasting this code:
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /teachers/{teacherId}/students/{studentId} {
      allow read, write: if request.auth != null
        && request.auth.uid == teacherId;
    }
  }
}

How do these rules protect progress?

  • The path targets student documents stored beneath teachers/{teacherId}/students/{studentId}.
  • The request.auth != null condition requires a signed-in user.
  • The UID comparison permits access only when the authenticated teacher owns that path.
  • Save firestore.rules.
  • Return to Firestore under Databases & Storage in the Firebase console.
  • Open the Rules tab.
  • Replace the existing rules with the contents of firestore.rules.
  • Click Publish.

You should see the teacher-scoped rules in the published editor. The database now accepts requests only from the teacher who owns the requested roster path.

Rules fail to publish?

Compare the console editor with firestore.rules character for character. Check the braces around each match block.

Confirm the first line contains the rules version declaration. It must appear before the service block.

Help me fix my teacher-scoped Firestore rules.

The Firebase JavaScript SDK supplies the authentication and database functions used by the browser. Pinning the package keeps your local implementation aligned with version 12.19.0.

  • Open package.json from the Explorer sidebar.
  • Replace its contents with the completed package configuration below:
{
  "name": "class-quest-command-center",
  "private": true,
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "firebase": "12.19.0"
  },
  "devDependencies": {
    "vite": "8.3.0"
  }
}

What changed in the package file?

The dependencies section pins the Firebase JavaScript SDK. The scripts keep development plus production build and preview commands available through npm.

  • Save package.json.
  • Switch to the integrated PowerShell terminal that is running Vite.
  • Stop the current Vite server by pressing Ctrl+C.
  • Install the pinned Firebase JavaScript SDK by running this command:
npm install firebase@12.19.0 --save

What does this command do?

The command downloads Firebase JavaScript SDK version 12.19.0 into the local project. It also records the package under dependencies so future installs reproduce the same setup.

You should see npm finish and return control to the PowerShell prompt. Your project can now import Firebase Authentication and Cloud Firestore modules.

Firebase package not installing?

Confirm the PowerShell prompt is inside the class-quest-command-center folder. The folder must contain package.json.

Check that the Vite process stopped before running the install command. Press Ctrl+C if the terminal still shows the development server.

Help me troubleshoot the Firebase package installation.

Connect authentication and realtime progress

The final browser logic replaces the local-only update with authenticated document writes. A realtime listener keeps the in-memory roster synchronized whenever a student document changes.

The four fictional students are seeded only when the signed-in teacher's student collection is empty. This preserves every existing teacher's progress during later sessions.

  • Open src/main.js from the Explorer sidebar.
  • Select all of the existing local-only code.
  • Replace it with the complete authenticated implementation shown in the double-check tab below.

✔️ Awesome, I've got everything!

Great. Double check that you replaced the local-only implementation and saved src/main.js.

ⓧ I'd like to double check the full code

Use this complete src/main.js file to replace the local-only implementation.

import "./style.css";
import { initializeApp } from "firebase/app";
import {
  getAuth,
  GoogleAuthProvider,
  onAuthStateChanged,
  signInWithPopup,
  signOut
} from "firebase/auth";
import {
  collection,
  doc,
  getDocs,
  getFirestore,
  increment,
  onSnapshot,
  serverTimestamp,
  setDoc,
  updateDoc
} from "firebase/firestore";
import { firebaseConfig } from "./firebase-config.js";

const seedStudents = [
  {
    id: "alex-morgan",
    name: "Alex Morgan",
    initials: "AM",
    focus: "Mathematics",
    xp: 40,
    completedQuests: 1
  },
  {
    id: "jamie-lee",
    name: "Jamie Lee",
    initials: "JL",
    focus: "Science",
    xp: 75,
    completedQuests: 3
  },
  {
    id: "riley-chen",
    name: "Riley Chen",
    initials: "RC",
    focus: "Reading",
    xp: 190,
    completedQuests: 7
  },
  {
    id: "sam-rivera",
    name: "Sam Rivera",
    initials: "SR",
    focus: "History",
    xp: 125,
    completedQuests: 5
  }
];

const app = initializeApp(firebaseConfig);
const auth = getAuth();
const db = getFirestore(app);
const googleProvider = new GoogleAuthProvider();

const dashboard = document.querySelector("#dashboard");
const emptyState = document.querySelector("#empty-state");
const studentGrid = document.querySelector("#student-grid");
const searchInput = document.querySelector("#student-search");
const statusFilter = document.querySelector("#status-filter");
const classXp = document.querySelector("#class-xp");
const averageLevel = document.querySelector("#average-level");
const supportCount = document.querySelector("#support-count");
const signInButton = document.querySelector("#sign-in-button");
const signOutButton = document.querySelector("#sign-out-button");
const teacherName = document.querySelector("#teacher-name");
const syncStatus = document.querySelector("#sync-status");
const toast = document.querySelector("#toast");

let currentUser = null;
let students = [];
let unsubscribeStudents = null;
let toastTimer = null;

function getLevel(xp) {
  return Math.floor(xp / 100) + 1;
}

function getLevelProgress(xp) {
  return xp % 100;
}

function getStatus(student) {
  if (student.completedQuests <= 1) {
    return "Needs support";
  }

  if (getLevelProgress(student.xp) >= 75) {
    return "Ready to level up";
  }

  return "On track";
}

function getStatusValue(status) {
  const values = {
    "Needs support": "support",
    "On track": "on-track",
    "Ready to level up": "ready"
  };

  return values[status];
}

function makeElement(tag, className, text) {
  const element = document.createElement(tag);
  element.className = className;
  element.textContent = text;
  return element;
}

function createStudentCard(student) {
  const level = getLevel(student.xp);
  const progress = getLevelProgress(student.xp);
  const status = getStatus(student);
  const card = document.createElement("article");
  card.className = "student-card";

  const cardHeader = document.createElement("div");
  cardHeader.className = "student-header";

  const avatar = makeElement("div", "avatar", student.initials);
  const identity = document.createElement("div");
  identity.append(
    makeElement("h3", "student-name", student.name),
    makeElement("p", "student-focus", student.focus)
  );

  const levelBadge = makeElement("span", "level-badge", `Level ${level}`);
  cardHeader.append(avatar, identity, levelBadge);

  const statusBadge = makeElement(
    "span",
    `status-badge status-${getStatusValue(status)}`,
    status
  );

  const progressLabel = document.createElement("div");
  progressLabel.className = "progress-label";
  progressLabel.append(
    makeElement("span", "", `${student.xp} total XP`),
    makeElement("span", "", `${progress} / 100 XP`)
  );

  const progressTrack = document.createElement("div");
  progressTrack.className = "progress-track";
  progressTrack.setAttribute("role", "progressbar");
  progressTrack.setAttribute("aria-valuemin", "0");
  progressTrack.setAttribute("aria-valuemax", "100");
  progressTrack.setAttribute("aria-valuenow", String(progress));

  const progressFill = document.createElement("div");
  progressFill.className = "progress-fill";
  progressFill.style.width = `${progress}%`;
  progressTrack.append(progressFill);

  const cardFooter = document.createElement("div");
  cardFooter.className = "card-footer";
  cardFooter.append(
    makeElement("span", "quest-count", `${student.completedQuests} quests completed`)
  );

  const awardButton = makeElement("button", "award-button", "Award 25 XP");
  awardButton.type = "button";
  awardButton.dataset.awardStudent = student.id;
  cardFooter.append(awardButton);

  card.append(cardHeader, statusBadge, progressLabel, progressTrack, cardFooter);
  return card;
}

function getVisibleStudents() {
  const term = searchInput.value.trim().toLowerCase();
  const selectedStatus = statusFilter.value;

  return students.filter((student) => {
    const matchesSearch =
      student.name.toLowerCase().includes(term) ||
      student.focus.toLowerCase().includes(term);
    const matchesStatus =
      selectedStatus === "all" || getStatusValue(getStatus(student)) === selectedStatus;

    return matchesSearch && matchesStatus;
  });
}

function renderStudents() {
  studentGrid.replaceChildren();
  const visibleStudents = getVisibleStudents();

  if (visibleStudents.length === 0) {
    studentGrid.append(
      makeElement("p", "no-results", "No students match the current filters.")
    );
    return;
  }

  visibleStudents.forEach((student) => {
    studentGrid.append(createStudentCard(student));
  });
}

function renderStats() {
  const totalXp = students.reduce((total, student) => total + student.xp, 0);
  const average = students.length
    ? students.reduce((total, student) => total + getLevel(student.xp), 0) /
      students.length
    : 0;
  const needsSupport = students.filter(
    (student) => getStatus(student) === "Needs support"
  ).length;

  classXp.textContent = String(totalXp);
  averageLevel.textContent = average.toFixed(1);
  supportCount.textContent = String(needsSupport);
}

function renderDashboard() {
  renderStats();
  renderStudents();
}

function showToast(message) {
  toast.textContent = message;
  toast.classList.add("visible");
  window.clearTimeout(toastTimer);
  toastTimer = window.setTimeout(() => toast.classList.remove("visible"), 2200);
}

function studentCollection(uid) {
  return collection(db, "teachers", uid, "students");
}

async function ensureSeedData(uid) {
  const snapshot = await getDocs(studentCollection(uid));

  if (!snapshot.empty) {
    return;
  }

  await Promise.all(
    seedStudents.map(({ id, ...student }) =>
      setDoc(doc(db, "teachers", uid, "students", id), {
        ...student,
        updatedAt: serverTimestamp()
      })
    )
  );
}

function subscribeToStudents(uid) {
  unsubscribeStudents = onSnapshot(
    studentCollection(uid),
    (snapshot) => {
      students = snapshot.docs
        .map((studentDocument) => ({
          id: studentDocument.id,
          ...studentDocument.data()
        }))
        .sort((first, second) => first.name.localeCompare(second.name));

      dashboard.hidden = false;
      emptyState.hidden = true;
      syncStatus.textContent = "Progress synced with Cloud Firestore.";
      renderDashboard();
    },
    (error) => {
      syncStatus.textContent = "Progress could not be loaded.";
      showToast(error.message);
    }
  );
}

async function awardXp(studentId) {
  if (!currentUser) {
    return;
  }

  try {
    await updateDoc(doc(db, "teachers", currentUser.uid, "students", studentId), {
      xp: increment(25),
      completedQuests: increment(1),
      updatedAt: serverTimestamp()
    });
    showToast("Quest complete. 25 XP awarded.");
  } catch (error) {
    showToast(error.message);
  }
}

function showSignedOutState() {
  students = [];
  dashboard.hidden = true;
  emptyState.hidden = false;
  teacherName.textContent = "Guest teacher";
  signInButton.hidden = false;
  signOutButton.hidden = true;
  syncStatus.textContent = "Sign in to load your class.";
}

onAuthStateChanged(auth, async (user) => {
  if (unsubscribeStudents) {
    unsubscribeStudents();
    unsubscribeStudents = null;
  }

  currentUser = user;

  if (!user) {
    showSignedOutState();
    return;
  }

  teacherName.textContent = user.displayName || user.email || "Signed-in teacher";
  signInButton.hidden = true;
  signOutButton.hidden = false;
  emptyState.hidden = false;
  emptyState.querySelector("h2").textContent = "Loading your class";
  emptyState.querySelector("p").textContent = "Preparing the latest quest progress.";
  syncStatus.textContent = "Connecting to Cloud Firestore...";

  try {
    await ensureSeedData(user.uid);
    subscribeToStudents(user.uid);
  } catch (error) {
    syncStatus.textContent = "Setup could not be completed.";
    showToast(error.message);
  }
});

signInButton.addEventListener("click", async () => {
  try {
    await signInWithPopup(auth, googleProvider);
  } catch (error) {
    showToast(error.message);
  }
});

signOutButton.addEventListener("click", async () => {
  try {
    await signOut(auth);
  } catch (error) {
    showToast(error.message);
  }
});

studentGrid.addEventListener("click", (event) => {
  const awardButton = event.target.closest("[data-award-student]");

  if (awardButton) {
    awardXp(awardButton.dataset.awardStudent);
  }
});

searchInput.addEventListener("input", renderStudents);
statusFilter.addEventListener("change", renderStudents);

How does the cloud version work?

  • Firebase Authentication updates currentUser whenever the teacher signs in or out.
  • The ensureSeedData() function creates the fictional roster only when that teacher has no student documents.
  • The onSnapshot() listener receives new student data whenever a matching document changes.
  • The awardXp() function uses atomic increments for XP plus completed quests.
  • Save src/main.js.
  • Restart the Vite development server by running this command:
npm run dev

What does this command do?

npm runs the dev script from package.json. Vite serves the updated dashboard at http://localhost:5173.

You should see the local development address in PowerShell. Keep this process running while you test authentication and persistence.

Dashboard fails to load?

Check the browser console for an import or configuration error. Confirm src/firebase-config.js uses the named export expected by src/main.js.

Confirm the Firebase package appears under dependencies in package.json.

Help me diagnose why the Firebase dashboard does not load.

The signed-out screen proves private roster data stays hidden until Firebase Authentication identifies a teacher.

  • Return to http://localhost:5173 in Microsoft Edge.
  • Refresh the page.
  • Click Sign in with Google.
  • Choose the Google account you enabled for the project.

The sign-in popup closes after authentication. You should see the four fictional student cards plus the message Progress synced with Cloud Firestore.

  • Click Award 25 XP on one student card.

You should see that student's XP total and quest count increase immediately. The toast confirms that the quest awarded 25 XP.

Before you refresh, do you expect the new XP value to stay or reset?

  • Refresh Microsoft Edge by pressing Ctrl+R.

You should see the same increased XP value after the realtime snapshot loads. That's the persistence gap closed: the teacher's private roster now survives refreshes.

Progress resets or access is denied?

Confirm you published the rule path for teachers/{teacherId}/students/{studentId}. The authenticated UID must match teacherId.

Confirm localhost appears under Authorized domains if the Google popup cannot complete.

Help me troubleshoot Firebase sign-in or persistent XP.

Your dashboard now has a private cloud-backed game loop that survives refreshes. Next, you will turn its synchronized roster into faster classroom insights.

Add Live Classroom Insights

Your authenticated roster now survives refreshes because each teacher's students live in a private Cloud Firestore path. The next goal is to turn that synchronized roster into quick classroom signals.

Raw XP across a whole class is difficult to scan quickly. This step turns each realtime snapshot into statistics and filtered views that guide the teacher's next move.

In this step, get ready to:
  • Confirm the insight cards calculate statistics from the current student snapshot.
  • Connect the student search and progress-status filters.
  • Verify that signing out hides private roster data.
Confirm snapshot-driven insight calculations

The three insight cards summarize the current students array. The realtime listener replaces that array whenever a student document changes.

  • In the src/main.js file from earlier, locate renderStats().
  • Compare the existing function with this reference:
function renderStats() {
  const totalXp = students.reduce((total, student) => total + student.xp, 0);
  const average = students.length
    ? students.reduce((total, student) => total + getLevel(student.xp), 0) /
      students.length
    : 0;
  const needsSupport = students.filter(
    (student) => getStatus(student) === "Needs support"
  ).length;

  classXp.textContent = String(totalXp);
  averageLevel.textContent = average.toFixed(1);
  supportCount.textContent = String(needsSupport);
}

How are the insights calculated?

  • The first reduce() adds every student's XP to produce the class total.
  • The second reduce() adds the calculated levels before dividing by the number of students.
  • The filter() call counts students whose current status is Needs support.
  • The final three assignments update the existing insight cards in the page.

Before you award more XP, which insight do you think must change after every award?

  • Return to the running dashboard in Microsoft Edge.
  • Read the current value in the Total class XP card.
  • Click Award 25 XP on any student card.

You will see Total class XP increase by 25. The average level changes whenever that award moves a student into a new level.

Good progress. Your insight cards now reflect the same snapshot that renders the student cards.

Connect the search and status filters

A filtered roster is a temporary view of the latest synchronized student data. The source students array stays intact while the search term and selected status decide which cards are visible.

  • Switch back to src/main.js in Visual Studio Code.
  • Insert this function directly above renderStudents():
function getVisibleStudents() {
  const term = searchInput.value.trim().toLowerCase();
  const selectedStatus = statusFilter.value;

  return students.filter((student) => {
    const matchesSearch =
      student.name.toLowerCase().includes(term) ||
      student.focus.toLowerCase().includes(term);
    const matchesStatus =
      selectedStatus === "all" || getStatusValue(getStatus(student)) === selectedStatus;

    return matchesSearch && matchesStatus;
  });
}

What does this filter do?

  • The normalized term makes searches case-insensitive.
  • The search checks both the student's name and focus subject.
  • The status check accepts every student when the selected value is all.
  • A student remains visible only when both checks return true.
  • Replace the existing renderStudents() function with this version:
function renderStudents() {
  studentGrid.replaceChildren();
  const visibleStudents = getVisibleStudents();

  if (visibleStudents.length === 0) {
    studentGrid.append(
      makeElement("p", "no-results", "No students match the current filters.")
    );
    return;
  }

  visibleStudents.forEach((student) => {
    studentGrid.append(createStudentCard(student));
  });
}

How does the roster respond?

The renderer clears the previous cards before requesting the filtered list. It displays a specific empty message when no student matches the current controls.

Every matching record still passes through createStudentCard(). The cards keep the same XP calculations and award controls.

  • Near the bottom of src/main.js, find the existing studentGrid.addEventListener block.
  • Add both filter listeners below that block by pasting:
searchInput.addEventListener("input", renderStudents);
statusFilter.addEventListener("change", renderStudents);

Why use two event types?

The input event refreshes the cards after each search edit. The change event refreshes them when the selected progress status changes.

  • Save src/main.js.
  • Return to Microsoft Edge.
  • Type Alex into the Search students field.

You will see only the Alex Morgan card. The class insight cards continue to summarize the complete synchronized roster.

  • Clear the Search students field.
  • Select Needs support from Progress status.

Every visible card has a Needs support status badge.

  • Select On track from Progress status.

Every visible card now has an On track status badge.

  • Select Ready to level up from Progress status.

Every visible card now has a Ready to level up status badge.

Filters not changing the roster?

Check that renderStudents() calls getVisibleStudents(). A direct loop over students bypasses both controls.

Confirm both event listeners sit outside the student-grid click listener. Nested listeners only attach after a card click.

Help me debug the classroom search and status filters.

✔️ Awesome, I've got everything!

Great. Double check that you saved src/main.js.

ⓧ I'd like to double check the full code

Compare your complete src/main.js file with this reference.

import "./style.css";
import { initializeApp } from "firebase/app";
import {
  getAuth,
  GoogleAuthProvider,
  onAuthStateChanged,
  signInWithPopup,
  signOut
} from "firebase/auth";
import {
  collection,
  doc,
  getDocs,
  getFirestore,
  increment,
  onSnapshot,
  serverTimestamp,
  setDoc,
  updateDoc
} from "firebase/firestore";
import { firebaseConfig } from "./firebase-config.js";

const seedStudents = [
  {
    id: "alex-morgan",
    name: "Alex Morgan",
    initials: "AM",
    focus: "Mathematics",
    xp: 40,
    completedQuests: 1
  },
  {
    id: "jamie-lee",
    name: "Jamie Lee",
    initials: "JL",
    focus: "Science",
    xp: 75,
    completedQuests: 3
  },
  {
    id: "riley-chen",
    name: "Riley Chen",
    initials: "RC",
    focus: "Reading",
    xp: 190,
    completedQuests: 7
  },
  {
    id: "sam-rivera",
    name: "Sam Rivera",
    initials: "SR",
    focus: "History",
    xp: 125,
    completedQuests: 5
  }
];

const app = initializeApp(firebaseConfig);
const auth = getAuth();
const db = getFirestore(app);
const googleProvider = new GoogleAuthProvider();

const dashboard = document.querySelector("#dashboard");
const emptyState = document.querySelector("#empty-state");
const studentGrid = document.querySelector("#student-grid");
const searchInput = document.querySelector("#student-search");
const statusFilter = document.querySelector("#status-filter");
const classXp = document.querySelector("#class-xp");
const averageLevel = document.querySelector("#average-level");
const supportCount = document.querySelector("#support-count");
const signInButton = document.querySelector("#sign-in-button");
const signOutButton = document.querySelector("#sign-out-button");
const teacherName = document.querySelector("#teacher-name");
const syncStatus = document.querySelector("#sync-status");
const toast = document.querySelector("#toast");

let currentUser = null;
let students = [];
let unsubscribeStudents = null;
let toastTimer = null;

function getLevel(xp) {
  return Math.floor(xp / 100) + 1;
}

function getLevelProgress(xp) {
  return xp % 100;
}

function getStatus(student) {
  if (student.completedQuests <= 1) {
    return "Needs support";
  }

  if (getLevelProgress(student.xp) >= 75) {
    return "Ready to level up";
  }

  return "On track";
}

function getStatusValue(status) {
  const values = {
    "Needs support": "support",
    "On track": "on-track",
    "Ready to level up": "ready"
  };

  return values[status];
}

function makeElement(tag, className, text) {
  const element = document.createElement(tag);
  element.className = className;
  element.textContent = text;
  return element;
}

function createStudentCard(student) {
  const level = getLevel(student.xp);
  const progress = getLevelProgress(student.xp);
  const status = getStatus(student);
  const card = document.createElement("article");
  card.className = "student-card";

  const cardHeader = document.createElement("div");
  cardHeader.className = "student-header";

  const avatar = makeElement("div", "avatar", student.initials);
  const identity = document.createElement("div");
  identity.append(
    makeElement("h3", "student-name", student.name),
    makeElement("p", "student-focus", student.focus)
  );

  const levelBadge = makeElement("span", "level-badge", `Level ${level}`);
  cardHeader.append(avatar, identity, levelBadge);

  const statusBadge = makeElement(
    "span",
    `status-badge status-${getStatusValue(status)}`,
    status
  );

  const progressLabel = document.createElement("div");
  progressLabel.className = "progress-label";
  progressLabel.append(
    makeElement("span", "", `${student.xp} total XP`),
    makeElement("span", "", `${progress} / 100 XP`)
  );

  const progressTrack = document.createElement("div");
  progressTrack.className = "progress-track";
  progressTrack.setAttribute("role", "progressbar");
  progressTrack.setAttribute("aria-valuemin", "0");
  progressTrack.setAttribute("aria-valuemax", "100");
  progressTrack.setAttribute("aria-valuenow", String(progress));

  const progressFill = document.createElement("div");
  progressFill.className = "progress-fill";
  progressFill.style.width = `${progress}%`;
  progressTrack.append(progressFill);

  const cardFooter = document.createElement("div");
  cardFooter.className = "card-footer";
  cardFooter.append(
    makeElement("span", "quest-count", `${student.completedQuests} quests completed`)
  );

  const awardButton = makeElement("button", "award-button", "Award 25 XP");
  awardButton.type = "button";
  awardButton.dataset.awardStudent = student.id;
  cardFooter.append(awardButton);

  card.append(cardHeader, statusBadge, progressLabel, progressTrack, cardFooter);
  return card;
}

function getVisibleStudents() {
  const term = searchInput.value.trim().toLowerCase();
  const selectedStatus = statusFilter.value;

  return students.filter((student) => {
    const matchesSearch =
      student.name.toLowerCase().includes(term) ||
      student.focus.toLowerCase().includes(term);
    const matchesStatus =
      selectedStatus === "all" || getStatusValue(getStatus(student)) === selectedStatus;

    return matchesSearch && matchesStatus;
  });
}

function renderStudents() {
  studentGrid.replaceChildren();
  const visibleStudents = getVisibleStudents();

  if (visibleStudents.length === 0) {
    studentGrid.append(
      makeElement("p", "no-results", "No students match the current filters.")
    );
    return;
  }

  visibleStudents.forEach((student) => {
    studentGrid.append(createStudentCard(student));
  });
}

function renderStats() {
  const totalXp = students.reduce((total, student) => total + student.xp, 0);
  const average = students.length
    ? students.reduce((total, student) => total + getLevel(student.xp), 0) /
      students.length
    : 0;
  const needsSupport = students.filter(
    (student) => getStatus(student) === "Needs support"
  ).length;

  classXp.textContent = String(totalXp);
  averageLevel.textContent = average.toFixed(1);
  supportCount.textContent = String(needsSupport);
}

function renderDashboard() {
  renderStats();
  renderStudents();
}

function showToast(message) {
  toast.textContent = message;
  toast.classList.add("visible");
  window.clearTimeout(toastTimer);
  toastTimer = window.setTimeout(() => toast.classList.remove("visible"), 2200);
}

function studentCollection(uid) {
  return collection(db, "teachers", uid, "students");
}

async function ensureSeedData(uid) {
  const snapshot = await getDocs(studentCollection(uid));

  if (!snapshot.empty) {
    return;
  }

  await Promise.all(
    seedStudents.map(({ id, ...student }) =>
      setDoc(doc(db, "teachers", uid, "students", id), {
        ...student,
        updatedAt: serverTimestamp()
      })
    )
  );
}

function subscribeToStudents(uid) {
  unsubscribeStudents = onSnapshot(
    studentCollection(uid),
    (snapshot) => {
      students = snapshot.docs
        .map((studentDocument) => ({
          id: studentDocument.id,
          ...studentDocument.data()
        }))
        .sort((first, second) => first.name.localeCompare(second.name));

      dashboard.hidden = false;
      emptyState.hidden = true;
      syncStatus.textContent = "Progress synced with Cloud Firestore.";
      renderDashboard();
    },
    (error) => {
      syncStatus.textContent = "Progress could not be loaded.";
      showToast(error.message);
    }
  );
}

async function awardXp(studentId) {
  if (!currentUser) {
    return;
  }

  try {
    await updateDoc(doc(db, "teachers", currentUser.uid, "students", studentId), {
      xp: increment(25),
      completedQuests: increment(1),
      updatedAt: serverTimestamp()
    });
    showToast("Quest complete. 25 XP awarded.");
  } catch (error) {
    showToast(error.message);
  }
}

function showSignedOutState() {
  students = [];
  dashboard.hidden = true;
  emptyState.hidden = false;
  teacherName.textContent = "Guest teacher";
  signInButton.hidden = false;
  signOutButton.hidden = true;
  syncStatus.textContent = "Sign in to load your class.";
}

onAuthStateChanged(auth, async (user) => {
  if (unsubscribeStudents) {
    unsubscribeStudents();
    unsubscribeStudents = null;
  }

  currentUser = user;

  if (!user) {
    showSignedOutState();
    return;
  }

  teacherName.textContent = user.displayName || user.email || "Signed-in teacher";
  signInButton.hidden = true;
  signOutButton.hidden = false;
  emptyState.hidden = false;
  emptyState.querySelector("h2").textContent = "Loading your class";
  emptyState.querySelector("p").textContent = "Preparing the latest quest progress.";
  syncStatus.textContent = "Connecting to Cloud Firestore...";

  try {
    await ensureSeedData(user.uid);
    subscribeToStudents(user.uid);
  } catch (error) {
    syncStatus.textContent = "Setup could not be completed.";
    showToast(error.message);
  }
});

signInButton.addEventListener("click", async () => {
  try {
    await signInWithPopup(auth, googleProvider);
  } catch (error) {
    showToast(error.message);
  }
});

signOutButton.addEventListener("click", async () => {
  try {
    await signOut(auth);
  } catch (error) {
    showToast(error.message);
  }
});

studentGrid.addEventListener("click", (event) => {
  const awardButton = event.target.closest("[data-award-student]");

  if (awardButton) {
    awardXp(awardButton.dataset.awardStudent);
  }
});

searchInput.addEventListener("input", renderStudents);
statusFilter.addEventListener("change", renderStudents);
Verify private state across authentication

The signed-in teacher determines which private roster the realtime listener loads. Firebase Authentication also gives the dashboard a clear point for removing private information from the screen.

What happens during sign-out?

The authentication observer stops the existing Firestore listener before handling the next user state. This prevents the previous teacher's snapshot from continuing to update the page.

The existing showSignedOutState() function clears students, hides #dashboard, shows #empty-state, and restores the guest controls.

  • Select All students from Progress status.
  • Choose a student card with an XP total you can recognize.

Before you sign out, do you think any student card should remain visible?

  • Click Sign out in the top bar.

You will see Guest teacher and the signed-out empty state. The insight cards and private student roster are hidden.

Before you sign back in, do you expect the same stored XP to return for this teacher?

  • Click Sign in with Google in the top bar.
  • Select the same Google account in the authentication popup.
  • Check the restored student's XP total.
  • Type Alex into the Search students field.
  • Select each option in the Progress status field.

You will see the same teacher-specific XP return from Cloud Firestore. The search results and status views continue to update from that restored roster.

That's a strong finish. Your private realtime roster now produces classroom signals while the signed-out screen stays clear.

Secret mission

Defeat the Class Boss Together

Your realtime dashboard already tracks each student's progress. In this mission, you will turn their combined XP into a cooperative Class Boss battle that ends at 600 XP.

Clean Up Your Resources

Clean Up Your Resources

Your completed dashboard should stay at $0 on the Firebase Spark pricing plan. Choose whether to keep its resources running, pause them, or delete them entirely.

Resources you used:

  • The local class-quest-command-center project folder you edited in Visual Studio Code.
  • The Vite development server running from that folder.
  • The Firebase Web app registration.
  • The Firebase Authentication configuration for Google sign-in plus localhost authorization.
  • The Cloud Firestore Standard edition database storing private student documents at teachers/{uid}/students.
  • The published Cloud Firestore Security Rules stored in firestore.rules.

Keep everything running

No action needed. Choose this if you are still using the fictional roster or want to keep developing the Class Boss challenge.

  • Keep the class-quest-command-center folder on your computer.
  • Leave the Vite development server running.
  • Leave the Firebase project unchanged.
  • Continue awarding XP whenever you want to test the dashboard.

Pause - I'll come back to this later

Pausing stops the local server while preserving every file and cloud record for your next session.

  • Click Sign out in the Class Quest dashboard.
  • Return to the integrated PowerShell terminal from earlier.
  • Press Ctrl+C to stop the Vite development server.
  • Leave the class-quest-command-center folder where it is.
  • Leave the Firebase project unchanged.

Your teacher-specific progress remains stored in Cloud Firestore. The same roster returns when you restart the server and sign in again.

Delete - I don't want to use this again

This option removes the Firebase project plus your local project folder.

Deleting the cloud project looks drastic. An owner can restore it through Google Cloud for 30 days.

  • Switch back to the Firebase console from earlier.
  • Open Project settings.
  • Select General.
  • Click Delete project.
  • Select every acknowledgement checkbox.
  • Click Delete project again.

The Firebase project enters its 30-day recovery window. That project deletion covers every cloud resource listed above.

  • Return to the integrated PowerShell terminal from earlier.
  • Press Ctrl+C to stop the Vite development server.
  • Close Visual Studio Code.
  • Press the Windows key to open search.
  • Type File Explorer.
  • Press Enter to open File Explorer.
  • Locate the class-quest-command-center folder in the location you chose during setup.
  • Delete the class-quest-command-center folder permanently from File Explorer.

You should no longer see the class-quest-command-center folder in File Explorer. Your local Vite server is stopped.

Nice Work!

Nice Work!

Mission complete. Your Class Quest Command Center now turns fictional student quests into secure live progress for each signed-in teacher.

You learned how to:

  • Built a responsive game-style roster that converts XP into levels. Each student card also communicates quest progress and support status.
  • Experienced the refresh reset caused by in-memory state. Replaced that limitation with Firebase Authentication and private Cloud Firestore paths. Published teacher-scoped security rules so each teacher can access only their own roster.
  • Used realtime snapshots to refresh the dashboard from the latest student documents. Used atomic increments to award XP safely. Turned the live roster into class statistics, search results, and progress filters.
  • Secret Mission: Added a cooperative Class Boss that derives its progress from combined class XP. The boss reaches Boss defeated at 600 XP without a second stored counter.

Ready to quiz yourself?