// JavaScript Course — Capstone Project

Build a Live
Dashboard App

Your capstone project for the JavaScript course. Build a real interactive dashboard from scratch — variables, DOM, events, arrays, objects, fetch, async/await, and localStorage all working together.

Capstone Project All 12 Chapters Real API Data Deploy to GitHub

What You Are Building

You will build GhanaHub — a live data dashboard that loads real user and post data from a public API, lets visitors search and filter it, saves favourites across sessions, and works on both desktop and mobile. No frameworks. Pure JavaScript, HTML, and CSS — everything you learned in this course.

Build it phase by phase. Each phase adds one layer of functionality. By Phase 4 you will have a real, deployable web application.

Variables & Types Functions Arrays & Objects DOM Manipulation Events Fetch & Async/Await localStorage Error Handling
The Brief
// Real-World Scenario

A small company in Accra wants an internal dashboard where employees can browse a directory of users and their activity posts. It must load fast, work on mobile, let users search by title, save bookmarked posts across visits, and show a clear error if the connection fails. You are the developer. Build it.

6 Core Features to Build
FEATURE 1Live Post Feed

Fetch 20 posts from the API and render them as cards. Each card shows the title and a truncated body. Cards fade in on load.

  • API: https://jsonplaceholder.typicode.com/posts?_limit=20
  • Show a spinner while fetching, hide it when done — always, even on error
  • Show a user-friendly error message in the UI if the fetch fails
  • Chapters used: 11 (Async/Fetch), 8 (DOM), 6 (Arrays), 10 (Strings)
FEATURE 2Live Search

A search input that filters the displayed posts in real-time as the user types. No extra API call — filter the data already in memory.

  • Filter by post title — case insensitive
  • Debounce by 300ms — not on every single keystroke
  • Show 'No results for "[query]"' when nothing matches
  • A clear button resets the search and shows all posts
  • Chapters used: 9 (Events), 5 (Functions), 10 (Strings), 6 (Arrays)
FEATURE 3Save Favourites

Each post card has a Save button. Saved posts survive a page refresh. Users can view only their saved posts.

  • Store saved post IDs in localStorage
  • Button toggles: ☆ Save★ Saved
  • A Saved Only filter shows only bookmarked posts
  • Count badge on the filter button: Saved (3)
  • Chapters used: 9 (Events), 7 (Objects), 2 (Data Types)
FEATURE 4User Profile Panel

Clicking a post card opens a slide-in panel showing that post's author. Fetched on demand, cached to avoid repeat requests.

  • API: /users/{userId} — fetched on first click only, then served from cache
  • Panel slides in from the right with a CSS transition
  • Closes on Escape or clicking the overlay behind it
  • Shows: name, username, email, city, company
  • Chapters used: 11 (Fetch), 9 (Events), 7 (Objects), 8 (DOM)
FEATURE 5Dark / Light Mode

A toggle button switches between dark and light themes. The chosen theme is remembered across page refreshes.

  • Toggle a class on <body> — all colours controlled by CSS variables
  • Preference saved in localStorage and applied on page load
  • Button label updates: ☀ Light Mode / 🌙 Dark Mode
  • Chapters used: 9 (Events), 8 (DOM), 2 (Data types)
FEATURE 6Live Stats Bar

A bar showing numbers that update every time the user interacts with the dashboard.

  • Posts Loaded: 20 · Showing: 7 (updates when searching) · Saved: 3 (updates when saving)
  • Numbers animate smoothly on change
  • Chapters used: 8 (DOM), 6 (Arrays), 3 (Operators)
Project Structure

Single HTML file for the Playground. Keep your code in clearly labelled sections with comments — you will thank yourself later.

index.html ├── <head> ← charset, title, all CSS as <style> └── <body> ├── <header> ← app title, theme toggle, stats bar ├── <nav> ← search input + filter buttons (All / Saved) ├── <main> │ ├── <div id="spinner"> ← loading indicator │ ├── <div id="post-grid"> ← cards injected here by JS │ └── <div id="empty-state"> ← 'No results' (hidden by default) ├── <aside id="user-panel"> ← slide-in profile panel ├── <div id="overlay"> ← dark overlay behind panel └── <script> ├── // ── STATE ────────────────── ├── // ── FETCH helpers ─────────── ├── // ── RENDER functions ──────── ├── // ── SEARCH & FILTER ───────── ├── // ── FAVOURITES ────────────── ├── // ── USER PANEL ────────────── ├── // ── THEME ─────────────────── └── // ── INIT (runs on load) ─────
HTML Requirements
  • Semantic: header, nav, main, aside, footer
  • All interactive elements are buttons or inputs — not divs
  • Spinner has role="status" for screen readers
  • Icon-only buttons have aria-label
CSS Requirements
  • All colours in CSS variables on :root
  • Post grid uses CSS Grid
  • User panel uses CSS transition for slide-in
  • Responsive: 1 col mobile, 2–3 col desktop
JavaScript Rules
  • No var — only const and let
  • No onclick attributes in HTML
  • No innerHTML with dynamic data
  • All await inside try/catch
Performance Rules
  • User profiles cached — never fetched twice
  • Search input debounced
  • Cards inserted via DocumentFragment
  • No console.log in final version
Phase 1 of 4

HTML Shell + CSS + State Object

Do not fetch any data yet. Build the complete visual skeleton and set up your state object. Getting the structure right now makes every later phase easier.

// Your state object — the single source of truth
const state = {
  posts:     [],          // all fetched posts
  filtered:  [],          // posts currently visible (after search/filter)
  saved: new Set(
    JSON.parse(localStorage.getItem('gh-saved') ?? '[]')
  ),                        // saved post IDs — loaded from localStorage
  query:     '',           // current search string
  showSaved: false,        // is the Saved Only filter active?
  theme: localStorage.getItem('gh-theme') ?? 'dark',
  loading:   false,
  userCache: {},           // { userId: userObject } — avoid re-fetching
  openUser:  null,         // ID of user shown in panel, or null
};

✓ Phase 1 Complete When:

  • Page loads without any console errors
  • Layout looks correct with placeholder content
  • Theme toggle already works — dark/light switch visible
  • Theme preference survives a page refresh
  • User panel exists but is off-screen (translate or left: -100%)
Phase 2 of 4

Fetch Posts + Render Cards

Add the data loading and card rendering. This is the core of the application — get this right and the rest builds on top cleanly.

// Reusable fetch helper
const BASE = 'https://jsonplaceholder.typicode.com';

async function get(path) {
  const res = await fetch(`${BASE}${path}`);
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return res.json();
}

async function loadPosts() {
  setLoading(true);
  try {
    const posts  = await get('/posts?_limit=20');
    state.posts    = posts;
    state.filtered = posts;
    renderPosts(posts);
    updateStats();
  } catch (err) {
    showError('Could not load posts. Check your connection and try again.');
  } finally {
    setLoading(false); // always runs — spinner always hides
  }
}
// Render cards using DocumentFragment for performance
function renderPosts(posts) {
  const grid     = document.getElementById('post-grid');
  const empty    = document.getElementById('empty-state');
  const fragment = document.createDocumentFragment();

  grid.innerHTML  = ''; // safe — no user data in this reset
  empty.hidden   = posts.length > 0;

  posts.forEach(post => fragment.append(createPostCard(post)));
  grid.append(fragment); // single DOM update
}

✓ Phase 2 Complete When:

  • 20 post cards appear on page load
  • Spinner appears then disappears correctly
  • Turning off Wi-Fi and refreshing shows an error message in the UI — not a blank screen
  • All cards built with createElement — confirmed by reading the code
Phase 3 of 4

Search + Filter + Favourites

Add interactivity. Everything works on the already-loaded data — no extra API calls needed for search or favourites.

// Debounce utility — write this once, use it for search
function debounce(fn, delay) {
  let timer;
  return (...args) => {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), delay);
  };
}

searchInput.addEventListener('input', debounce((e) => {
  state.query = e.target.value.toLowerCase().trim();
  filterAndRender();
}, 300));
// One delegation listener handles all card interactions
grid.addEventListener('click', (e) => {
  // Save button clicked
  const saveBtn = e.target.closest('.save-btn');
  if (saveBtn) {
    toggleSave(Number(saveBtn.dataset.id));
    return; // stop — don't also open the user panel
  }
  // Card body clicked — open user panel
  const card = e.target.closest('.post-card');
  if (card) openUserPanel(Number(card.dataset.userId));
});

✓ Phase 3 Complete When:

  • Typing in search filters cards smoothly with no lag
  • 'No results for "xyz"' appears when nothing matches
  • Clear button resets search and shows all posts
  • Clicking ☆ Save toggles to ★ Saved
  • Refreshing the page: ★ Saved buttons are still ★ Saved
  • Saved filter shows only bookmarked posts
  • Stats bar numbers update correctly after every action
Phase 4 of 4

User Panel + Polish

Add the slide-in user profile panel with its own async fetch. Cache results to avoid redundant requests. Then polish every detail.

// User panel — cache-first pattern
async function openUserPanel(userId) {
  showPanel(); // show immediately with spinner inside
  try {
    // Serve from cache if already fetched
    if (!state.userCache[userId]) {
      state.userCache[userId] = await get(`/users/${userId}`);
    }
    renderPanelUser(state.userCache[userId]);
  } catch (err) {
    renderPanelError('Could not load user profile.');
  }
}

function closePanel() {
  document.getElementById('user-panel').classList.remove('open');
  document.getElementById('overlay').hidden = true;
  state.openUser = null;
}

document.addEventListener('keydown', (e) => {
  if (e.key === 'Escape' && state.openUser) closePanel();
});
document.getElementById('overlay').addEventListener('click', closePanel);

✓ Phase 4 Complete When:

  • Clicking a post card opens the side panel with a smooth slide animation
  • User data displays correctly: name, username, email, city, company
  • Clicking the same post card twice: only one network request made (check DevTools → Network tab)
  • Panel closes on Escape, overlay click, and close button
  • All 6 features work together — search + favourites + panel all active at once
  • No console errors at any point during normal use
Final Submission Checklist

✅ Your Project Is Complete When All of These Are True

  • 20 posts load on page load with no console errors
  • Spinner appears during loading and always disappears — including when the fetch fails
  • Error message shown in the UI when fetch fails — not just logged to the console
  • Search filters cards in real-time, case-insensitively, debounced by 300ms
  • 'No results' message appears when the search returns nothing
  • Save/Unsave toggles the button text correctly
  • Saved posts persist — refreshing the page keeps saved state
  • Saved Only filter shows only bookmarked posts with the correct count
  • User panel slides in smoothly when a card is clicked
  • User cache works — same user's data fetched only once (verify in DevTools Network tab)
  • Panel closes on Escape, overlay click, and the close button
  • Dark/light toggle works and preference survives page refresh
  • Stats bar updates after every interaction
  • No var anywhere in the JavaScript
  • No innerHTML used with dynamic data from the API
  • No unhandled rejections — every await inside try/catch
  • Responsive — looks correct on 375px mobile width
  • No console.log left in the submitted version
+ Challenge Extensions — go further

Infinite Scroll

Replace the 20-post load with infinite scroll using IntersectionObserver. Load 10 posts at a time as the user reaches the bottom. Stop when all 100 are loaded.

Post Comments Modal

Add a View Comments button on each card. Clicking it opens a full-screen modal that fetches and displays all comments for that post from /posts/{id}/comments.

Sort Controls

Add sort buttons: Title A–Z, Title Z–A, User ID. Sorting re-renders the filtered list without re-fetching. The current search filter is preserved when sorting changes.

Export Saved Posts

Add an Export button that downloads the saved posts as a .json file using a Blob URL. Include the full post object for each saved ID — not just the IDs.

Keyboard Navigation

Make the entire app navigable by keyboard alone. Arrow keys move between cards, Enter opens the user panel, and all focus states are clearly visible at all times.

Offline Support

Register a Service Worker that caches the API response. When the user goes offline, the previously loaded posts still show from the cache instead of showing an error.

🚀 Submitting Your Project

When every item on the checklist is confirmed and you are satisfied with the result, submit for review.

  1. Save your code — use the Playground Save button (Pro) or copy the HTML to your computer
  2. Create a GitHub repository named js-capstone-ghanahub
  3. Upload your file — name it index.html
  4. Enable GitHub Pages — Settings → Pages → Branch: main → your app is live at a real URL
  5. Submit both links — the GitHub repo URL and the live GitHub Pages URL — in the submission form

Your project will be reviewed within 2 business days. Feedback will be sent to your registered email address.

Start with Phase 1 — Open the Playground

Build the HTML shell first. Get the layout and theme toggle working. Then add data. Then interactivity. One phase at a time.

⚡ Open Playground → JS Tab
fails — not just the console
  • Search filters post titles in real-time, debounced, case-insensitive
  • 'No results' message appears when search finds nothing
  • Save button toggles correctly on every card
  • Saved posts persist — still marked after page refresh
  • Saved filter shows only bookmarked posts
  • Stats bar updates after every search, save, and filter action
  • User panel opens on card click with correct user data
  • User cache works — same user never fetched twice (verify in Network tab)
  • Panel closes on Escape key and outside click
  • Dark/Light theme toggle works and persists across refreshes
  • Mobile layout — single column, search usable, panel full-width on small screens
  • No var anywhere in the JavaScript
  • No innerHTML used with fetched or user data
  • All async code wrapped in try/catch
  • No console.log remaining in the final version
  • One event listener on the grid (delegation) — not one per card
  • Page is usable with keyboard only — Tab, Enter, Escape all work
  • + Challenge Extensions — go further

    Infinite Scroll

    Replace the fixed 20-post load with infinite scroll using IntersectionObserver. Load 10 more posts each time the user reaches the bottom. Stop at 100 total posts.

    Post Comments

    Add a View Comments button to each card. On click, fetch /posts/{id}/comments and render them below the card inline. Collapse them on a second click.

    Sort Options

    Add a sort dropdown: by Post ID (default), by Title A–Z, by Title Z–A. Sorting re-renders the filtered list without a new fetch.

    Keyboard Shortcut System

    Add shortcuts: / focuses the search, Escape clears search, S toggles the Saved filter. Show a ? button that opens a shortcuts modal.

    Export Saved Posts

    Add an Export button that downloads the saved posts as a JSON file using a Blob URL and a programmatic <a> click. File name: saved-posts.json.

    Copy to Clipboard

    Add a Copy Link button to each card that copies https://jsonplaceholder.typicode.com/posts/{id} to the clipboard using the Clipboard API. Show a brief Copied! toast.

    🚀 Submitting Your Project

    When all checklist items are complete and you are happy with the result, submit your project. Your instructor will review both the code quality and the live demo.

    1. Save your file — use the Playground's Save button (Pro) or copy the code to your computer and save as index.html
    2. Create a GitHub repository called js-dashboard-project
    3. Upload your file — name it index.html in the root of the repository
    4. Enable GitHub Pages — Settings → Pages → Source: main branch — so the app goes live at a real URL
    5. Verify it works live — open the GitHub Pages URL and test all 6 features in a fresh browser window
    6. Submit both links — the GitHub repository URL and the live GitHub Pages URL — in the submission form

    Your project will be reviewed within 2 business days. You will receive written feedback on your code quality, JavaScript patterns used, and one thing to improve.

    Open the Playground — Start Building

    Work through the four phases in order. Complete Phase 1 before moving to Phase 2. Each phase builds directly on the last.

    ⚡ Open Playground → JS Tab