Building the EngiLipi Learning Dashboard: Data Model, Routing & Progress Tracking
Design the Domain/Course/Module/Lesson/Quiz data hierarchy, structure the React Router routes that navigate it, build a progress-tracking hook backed by localStorage, and wire up theme switching — the same architecture EngiLipi itself is built on.
This capstone project is self-referential: you are going to build a small, real version of the very site you're learning from. That means the hierarchy is not arbitrary — it's the actual shape EngiLipi uses to organize content: a learner picks a Domain (like 'Web Development'), which contains Courses, which contain Modules, which contain Lessons, each of which may end in a Quiz, and completing it all the way through marks Completion. Getting this hierarchy right in TypeScript, mapping it cleanly onto nested routes, and tracking progress through it with a reusable hook are the core engineering skills this project teaches — and they transfer directly to understanding how a real multi-level content platform is structured under the hood.
Modeling the content hierarchy
The five-level hierarchy — Domain → Course → Module → Lesson → Quiz — should be modeled as separate, normalized interfaces rather than one deeply nested object. Nesting everything into a single tree looks convenient at first, but it makes two things painful: looking up 'what course is this lesson in' requires walking the whole tree, and updating one lesson's completion status means immutably rebuilding every level above it. Instead, each level references its parent by id, and a lightweight lookup layer (plain objects/maps keyed by id) makes any-direction traversal O(1).
This is the same normalization technique used by tools like Redux's normalizr or a relational database: entities are flat, relationships are ids, and you build the specific view (like 'all lessons in this module, in order') with a small selector function rather than baking one particular traversal into the data shape itself.
• Domain — top-level subject area (e.g. 'Web Development', 'Data Structures'). • Course — belongs to a domain via domainId; groups related modules. • Module — belongs to a course via courseId; groups related lessons. • Lesson — belongs to a module via moduleId; the atomic unit of content, optionally followed by a Quiz. • Quiz — belongs to a lesson via lessonId; a small set of questions gating 'Completion' for that lesson.
Routing that mirrors the hierarchy
React Router's nested routes map naturally onto this structure: each level of the URL corresponds to a level of the content tree, and each route segment's loader/params pull the right slice of data via the id-based lookups from the model layer. This gives learners deep-linkable, bookmarkable URLs for any lesson or quiz — a hard requirement for a real learning platform, and a good forcing function for keeping the data model id-addressable at every level.
Route params (:domainId, :courseId, :moduleId, :lessonId) are strings by nature — the components that read them via useParams are responsible for looking up the corresponding typed entity and handling the 'not found' case explicitly, rather than assuming the id always resolves.
Progress tracking as its own concern
Progress — which lessons are completed, which quizzes were passed and with what score — is deliberately modeled and persisted separately from the static content data (Domain/Course/etc., which would come from a CMS or static JSON in a real app). A useProgress hook, backed by localStorage keyed by user, owns this: isLessonComplete(lessonId), markLessonComplete(lessonId), recordQuizResult(quizId, score), and a derived courseProgressPercent(courseId) computed from how many of that course's lessons are marked complete.
Keeping progress separate from content means the content model can be swapped for a real API later without touching how progress is tracked, and it means progress correctly persists across content updates (adding a new lesson to a module doesn't wipe out a learner's existing completions).
Theme switching and accessibility
Theme (light/dark) is global UI state that many components need to read but almost none need to own — the textbook case for React Context. A ThemeProvider holds the current theme and a toggle function, persists the choice to localStorage, and applies it by setting a data-theme attribute on the document root so CSS custom properties cascade without any per-component theme logic.
Accessibility isn't a separate bolt-on feature here — it's built into the same components: progress indicators use role="progressbar" with aria-valuenow, the theme toggle button has an accessible label that reflects its current state, and quiz options are actual <button>/<input> elements (not clickable <div>s) so they're keyboard-navigable and screen-reader friendly by default.
The content hierarchy types and route structure
Each level stores its children as an ordered array of *ids*, not embedded objects — the objects themselves live in a flat lookup map, avoiding duplication and making updates O(1). Lesson.quizId is optional because not every lesson ends in a quiz — 'Completion' for a quiz-less lesson is just marking it read/done.
// models/content.ts
export interface Domain {
id: string;
name: string;
description: string;
}
export interface Course {
id: string;
domainId: string;
title: string;
moduleIds: string[]; // ordered
}
export interface Module {
id: string;
courseId: string;
title: string;
lessonIds: string[]; // ordered
}
export interface Lesson {
id: string;
moduleId: string;
title: string;
content: string;
quizId?: string;
}
export interface QuizQuestion {
prompt: string;
options: string[];
correctIndex: number;
}
export interface Quiz {
id: string;
lessonId: string;
questions: QuizQuestion[];
}
// routes — mirrors the hierarchy 1:1
// /domains/:domainId
// /domains/:domainId/courses/:courseId
// /domains/:domainId/courses/:courseId/modules/:moduleId
// /domains/:domainId/courses/:courseId/modules/:moduleId/lessons/:lessonId
// /domains/:domainId/courses/:courseId/modules/:moduleId/lessons/:lessonId/quizCode Examples
Every route param maps to an explicit lookup (getLessonById) rather than assuming the id is valid — if a learner hits a stale or malformed URL, lesson is undefined and the page redirects instead of crashing on lesson.title. This 'not found' guard is the same defensive pattern used throughout the hierarchy — every page component that resolves an entity from a route param must handle the case where that id doesn't exist.
if (!lesson) return <Navigate to="/" replace />;
Just like useTasks in Project 2, all persistence logic is centralized in one hook — progress is the single source of truth, written to localStorage on every change, and every derived value (like a course's percent complete) is computed from it rather than tracked separately. courseProgressPercent deliberately recomputes from completedLessonIds and the course's lesson list every call rather than storing a cached percentage — the same 'derive, don't duplicate' principle from the Task Manager's filtering.
const done = lessonIds.filter((id) => progress.completedLessonIds[id]).length;
useTheme throwing when called outside a ThemeProvider converts a whole class of 'context is null' bugs into an immediate, clear error at development time rather than a silent crash deep in a render. Setting a data attribute on the document root (rather than passing theme as a prop through every component) lets all styling live in CSS custom properties scoped by [data-theme="dark"], so components never need theme-aware conditional logic.
document.documentElement.setAttribute("data-theme", theme);
import { Routes, Route, Navigate } from "react-router-dom";
import { DashboardPage } from "./pages/DashboardPage";
import { DomainPage } from "./pages/DomainPage";
import { CoursePage } from "./pages/CoursePage";
import { ModulePage } from "./pages/ModulePage";
import { LessonPage } from "./pages/LessonPage";
import { QuizPage } from "./pages/QuizPage";
export function AppRoutes() {
return (
<Routes>
<Route path="/" element={<DashboardPage />} />
<Route path="/domains/:domainId" element={<DomainPage />} />
<Route path="/domains/:domainId/courses/:courseId" element={<CoursePage />} />
<Route
path="/domains/:domainId/courses/:courseId/modules/:moduleId"
element={<ModulePage />}
/>
<Route
path="/domains/:domainId/courses/:courseId/modules/:moduleId/lessons/:lessonId"
element={<LessonPage />}
/>
<Route
path="/domains/:domainId/courses/:courseId/modules/:moduleId/lessons/:lessonId/quiz"
element={<QuizPage />}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
}
// LessonPage.tsx — reads the id-based hierarchy via useParams + typed lookups
import
…import { useCallback, useEffect, useState } from "react";
import { getModuleById, getLessonIdsForCourse } from "../data/lookups";
interface QuizResult {
score: number; // 0..1
attemptedAt: number;
}
interface ProgressState {
completedLessonIds: Record<string, true>;
quizResults: Record<string, QuizResult>;
}
const STORAGE_KEY = "engilipi:progress";
function loadProgress(): ProgressState {
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? (JSON.parse(raw) as ProgressState) : { completedLessonIds: {}, quizResults: {} };
} catch {
return { completedLessonIds: {}, quizResults: {} };
}
}
export function useProgress() {
const [progress, setProgress] = useState<ProgressState>(loadProgress);
useEffect(() => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(progress));
}, [progress]);
const isLessonComplete = useCallback(
(lessonId: string) => Boolean(progress.completedLessonIds[lessonId]),
[progress]
);
const markLessonComplete = useCallback((lessonId: string) => {
setProgress((prev) => ({
...prev,
completedLessonIds: { ...prev.completedLessonIds, [lessonId]: true },
}));
}, []);
const recor
…import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
type Theme = "light" | "dark";
interface ThemeContextValue {
theme: Theme;
toggleTheme: () => void;
}
const ThemeContext = createContext<ThemeContextValue | null>(null);
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<Theme>(() => {
try {
return (localStorage.getItem("engilipi:theme") as Theme) || "light";
} catch {
return "light";
}
});
useEffect(() => {
document.documentElement.setAttribute("data-theme", theme);
try {
localStorage.setItem("engilipi:theme", theme);
} catch {
/* ignore write failures, e.g. private browsing quota */
}
}, [theme]);
function toggleTheme() {
setTheme((prev) => (prev === "light" ? "dark" : "light"));
}
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>{children}</ThemeContext.Provider>
);
}
export function useTheme(): ThemeContextValue {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error("useTheme must be used within a ThemeProvider");
return ctx;
}
// ThemeToggle.tsx — accessible toggle but
…EngiLipi content hierarchy and completion flow
Each level references its parent by id; useProgress tracks completion independently of this static content tree.
Dashboard
│
▼
Domain (e.g. "Web Development")
│
▼
Course (e.g. "React Fundamentals")
│
▼
Module (e.g. "Hooks")
│
▼
Lesson (e.g. "useEffect")
│
▼
Quiz ──── pass ────► Completion
│ │
└── (no quiz) ──────────┘
mark lesson complete → useProgress → localStorageImportant Rules
Normalize the content hierarchy: store parent-child relationships as id arrays/references, not deeply nested objects, so any level can be looked up or updated in O(1).
Mirror the data hierarchy in the route structure so every entity — domain, course, module, lesson, quiz — has a deep-linkable, bookmarkable URL.
Keep progress-tracking state (what's completed) fully separate from content state (what exists) — they change for different reasons and persist differently.
Drive theme through Context + a DOM attribute (or class) consumed by CSS, not through a theme prop threaded through every component.
This is EngiLipi's own architecture, in miniature
The Domain → Course → Module → Lesson → Quiz hierarchy, the id-based nested routing, and the localStorage-backed progress hook you just built are not a simplified toy version of a different app — they are the same architectural decisions a real content platform like EngiLipi makes, just without a backend database behind the content lookups yet. Understanding why content and progress are modeled as separate concerns here is exactly what lets a future version of this project swap in a real API without touching the routing or the progress-tracking logic at all.
interface Course { id: string; modules: Module[]; } // modules embedded directly interface Course { id: string; moduleIds: string[]; } // modules looked up separately by id
Embedding full child objects means updating one deeply-nested lesson requires immutably rebuilding the course and module above it too; normalizing with id references makes updates local to the entity that actually changed.
const lesson = getLessonById(lessonId); return <h1>{lesson.title}</h1>; // no undefined check const lesson = getLessonById(lessonId); if (!lesson) return <Navigate to="/" replace />; return <h1>{lesson.title}</h1>;
Route params are just strings the router extracted from the URL — they are not guaranteed to correspond to a real entity (a stale bookmark, a typo, a deleted lesson), so every lookup from a param must handle the not-found case.
Why does the Course type store moduleIds: string[] instead of embedding the full Module objects directly? A. String arrays serialize to JSON faster than object arrays in every case B. Normalizing with id references avoids duplicating module data and means updating one nested entity doesn't require immutably rebuilding every level above it C. React Router requires all route data to be strings D. TypeScript does not support arrays of objects
B. Normalizing with id references avoids duplicating module data and means updating one nested entity doesn't require immutably rebuilding every level above it — Storing relationships as ids (with a flat lookup layer resolving them) is the standard normalization technique that keeps updates local and avoids data duplication across nested levels.
Why is progress-tracking state (useProgress) deliberately kept separate from the content data (Domain/Course/Module/Lesson)? A. Because localStorage cannot store more than one type of data B. They change for different reasons and have different lifecycles — content would come from a CMS/API while progress is per-learner state — keeping them separate lets one be swapped or updated without affecting the other C. React Router forbids combining route data with local state D. There is no real reason; it is purely a stylistic choice
B. They change for different reasons and have different lifecycles — content would come from a CMS/API while progress is per-learner state — keeping them separate lets one be swapped or updated without affecting the other — Separating static content from per-learner progress means content can later be migrated to a real backend without touching how completion state is tracked and persisted, and vice versa.
In LessonPage, what does if (!lesson) return <Navigate to="/" replace />; protect against? A. It has no real purpose and could be removed safely B. A route param (lessonId) that doesn't correspond to any real lesson — e.g. a stale bookmark or bad URL — which would otherwise crash the component when it tries to read lesson.title on undefined C. It prevents the theme from being applied incorrectly D. It is required syntax for every React Router route
B. A route param (lessonId) that doesn't correspond to any real lesson — e.g. a stale bookmark or bad URL — which would otherwise crash the component when it tries to read lesson.title on undefined — Route params are just strings extracted from the URL with no guarantee they map to a real entity, so every id-based lookup from params needs an explicit not-found guard before rendering fields off the result.
Extend the Learning Dashboard beyond what's shown: add the QuizPage component that renders a Quiz's questions, scores the attempt, calls recordQuizResult, and only allows navigating past it once a passing score is reached. Add a reusable ProgressBar component (with proper role="progressbar" and aria-valuenow/aria-valuemin/aria-valuemax) used on both the DashboardPage (overall progress) and CoursePage (per-course progress). Write at least one test for useProgress verifying courseProgressPercent updates correctly as lessons are completed.
A minimal QuizPage keeps local state for the learner's selected answer per question index, computes score only on submit by comparing each selection to question.correctIndex, calls recordQuizResult(quiz.id, score), and conditionally renders either a 'Try again' prompt (score below threshold) or a 'Continue' link to the next lesson/module (score at or above threshold) — mirroring the same derive-don't-duplicate pattern used for courseProgressPercent.
For the QuizPage's pass/fail gate, compute the score as (correct answers / total questions) after the learner submits all answers, and compare it against a constant passing threshold (e.g. 0.7) before enabling a 'Continue' link forward.