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.
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.
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.
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.
https://jsonplaceholder.typicode.com/posts?_limit=20A search input that filters the displayed posts in real-time as the user types. No extra API call — filter the data already in memory.
Each post card has a Save button. Saved posts survive a page refresh. Users can view only their saved posts.
localStorageClicking a post card opens a slide-in panel showing that post's author. Fetched on demand, cached to avoid repeat requests.
/users/{userId} — fetched on first click only, then served from cacheA toggle button switches between dark and light themes. The chosen theme is remembered across page refreshes.
<body> — all colours controlled by CSS variableslocalStorage and applied on page loadA bar showing numbers that update every time the user interacts with the dashboard.
Single HTML file for the Playground. Keep your code in clearly labelled sections with comments — you will thank yourself later.
:rootvar — only const and letonclick attributes in HTMLinnerHTML with dynamic dataawait inside try/catchconsole.log in final versionDo 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.
localStorageconst 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 };
Add the data loading and card rendering. This is the core of the application — get this right and the rest builds on top cleanly.
async function loadPosts() — fetches, stores in state, then calls renderPosts()finally block (so it always hides, even on error)renderPosts(posts) — clears the grid, builds cards with createElement, inserts via DocumentFragmentcreatePostCard(post) — returns a single card element. Title, truncated body, and Save button.loadPosts() at the very bottom of your scriptconst 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 } }
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 }
createElement — confirmed by reading the codeAdd interactivity. Everything works on the already-loaded data — no extra API calls needed for search or favourites.
filterAndRender() — reads state.query and state.showSaved, filters state.posts, updates state.filtered, calls renderPostsinput listener: update state.query, call filterAndRender()state.showSaved, call filterAndRender()toggleSave(postId) — add/remove from state.saved, persist to localStorage, update the button, update statsfunction 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));
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)); });
Add the slide-in user profile panel with its own async fetch. Cache results to avoid redundant requests. Then polish every detail.
async function openUserPanel(userId) — check cache first, fetch if not cached, render the panelcreateElement: name, username, email, city, company.open class to the <aside> (CSS handles the transition)<div> when the panel is openasync 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);
var anywhere in the JavaScriptinnerHTML used with dynamic data from the APIawait inside try/catchconsole.log left in the submitted versionReplace 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.
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.
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.
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.
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.
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.
When every item on the checklist is confirmed and you are satisfied with the result, submit for review.
js-capstone-ghanahubindex.htmlYour project will be reviewed within 2 business days. Feedback will be sent to your registered email address.
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