Gambaran Proyek
Selamat! Anda telah menyelesaikan seluruh fondasi bahasa JavaScript modern—mulai dari sistem tipe, fungsi dan closure, metode array tak-mutasi ES2023+, class dan field privat #, Map & Set, penanganan galat berlapis, mekanisme Event Loop & Promise, komunikasi jaringan fetch(), manipulasi DOM, penyimpanan peramban, ES Modules, keamanan front-end, hingga pengujian otomatis.
Dalam proyek akhir ini, kita akan membangun Aplikasi Pelacak Progres & Pencari Modul Kodeloka yang mencerminkan arsitektur kode front-end produksi:
- Modular & Teruji (
store.js): Mengelola daftar modul yang sudah selesai dipelajari menggunakanSetdan menyimpannya secara aman kelocalStorage(lengkap dengan sinkronisasi lintas tab!). - Cepat & Responsif (
app.js): Menyaring modul berdasarkan kata kunci pencarian (dengan debounce berbasis closure) dan tingkat kesulitan, serta merender hasilnya menggunakan elemen<template>danreplaceChildren(). - 100% Aman dari DOM XSS & Efisien Memori: Seluruh teks dimasukkan melalui
textContent, dan seluruh klik tombol "Tandai Selesai" ditangani oleh satu Event Listener (Event Delegation).
Struktur Berkas Proyek
1. Modul Penyimpanan & Logika State (store.js)
// Berkas: ./store.js
const KUNCI_PENYIMPANAN = "kodeloka:modul-selesai:v1";
export class PenyimpanProgres {
#selesaiSet = new Set();
#kunci;
constructor(kunci = KUNCI_PENYIMPANAN) {
this.#kunci = kunci;
this.#muatDariLokal();
}
#muatDariLokal() {
try {
const mentah = globalThis.localStorage?.getItem(this.#kunci);
if (!mentah) return;
const daftarId = JSON.parse(mentah);
if (Array.isArray(daftarId)) {
this.#selesaiSet = new Set(daftarId.map(Number));
}
} catch (galat) {
console.warn("Gagal memuat progres dari localStorage:", galat);
}
}
#simpanKeLokal() {
try {
const json = JSON.stringify([...this.#selesaiSet]);
globalThis.localStorage?.setItem(this.#kunci, json);
} catch (galat) {
console.warn("Gagal menyimpan progres ke localStorage:", galat);
}
}
apakahSelesai(idModul) {
return this.#selesaiSet.has(Number(idModul));
}
toggleModul(idModul) {
const id = Number(idModul);
if (this.#selesaiSet.has(id)) {
this.#selesaiSet.delete(id);
} else {
this.#selesaiSet.add(id);
}
this.#simpanKeLokal();
return this.#selesaiSet.has(id);
}
hitungRingkasan(totalModul) {
const selesai = this.#selesaiSet.size;
const persen = totalModul > 0 ? Math.round((selesai / totalModul) * 100) : 0;
return { selesai, total: totalModul, persen };
}
muatUlang() {
this.#selesaiSet.clear();
this.#muatDariLokal();
}
}
export function saringModul(daftarModul, { kataKunci = "", tingkat = "semua" } = {}) {
const kueri = kataKunci.trim().toLowerCase();
return daftarModul.filter((modul) => {
const cocokTingkat = tingkat === "semua" || modul.tingkat.toLowerCase() === tingkat.toLowerCase();
const cocokKata =
kueri === "" ||
modul.judul.toLowerCase().includes(kueri) ||
modul.tag.some((t) => t.toLowerCase().includes(kueri));
return cocokTingkat && cocokKata;
});
}
export function buatDebounce(fn, jedaMs = 200) {
let timerId = null;
return (...args) => {
clearTimeout(timerId);
timerId = setTimeout(() => fn(...args), jedaMs);
};
}
2. Modul Antarmuka DOM & Event Delegation (app.js)
// Berkas: ./app.js
import { PenyimpanProgres, saringModul, buatDebounce } from "./store.js";
const DATA_MODUL_AWAL = [
{ id: 1, judul: "Pengantar & Tipe Data", tingkat: "Dasar", durasi: "15 mnt", tag: ["const", "typeof", "??"] },
{ id: 3, judul: "Scope, TDZ, dan Closure", tingkat: "Menengah", durasi: "20 mnt", tag: ["closure", "lexical"] },
{ id: 8, judul: "Event Loop, Promise, dan Async/Await", tingkat: "Lanjut", durasi: "25 mnt", tag: ["promise", "microtask"] },
{ id: 9, judul: "Fetch API & AbortController", tingkat: "Menengah", durasi: "20 mnt", tag: ["fetch", "json", "timeout"] },
{ id: 13, judul: "Keamanan Front-End (XSS & CSP)", tingkat: "Lanjut", durasi: "20 mnt", tag: ["xss", "cors", "csp"] }
];
const penyimpan = new PenyimpanProgres();
const stateFilter = { kataKunci: "", tingkat: "semua" };
const inputCari = document.querySelector("#input-cari");
const pilihTingkat = document.querySelector("#pilih-tingkat");
const wadahDaftar = document.querySelector("#daftar-modul");
const teksProgres = document.querySelector("#ringkasan-progres");
const cetakanKartu = document.querySelector("#cetakan-kartu-modul");
function renderAplikasi() {
const hasilSaring = saringModul(DATA_MODUL_AWAL, stateFilter);
const simpulKartu = hasilSaring.map((modul) => {
const fragmen = cetakanKartu.content.cloneNode(true);
const artikel = fragmen.querySelector(".kartu-modul");
const judulEl = fragmen.querySelector(".kartu-modul__judul");
const infoEl = fragmen.querySelector(".kartu-modul__info");
const tombol = fragmen.querySelector(".tombol-selesai");
const sudahSelesai = penyimpan.apakahSelesai(modul.id);
artikel.dataset.id = String(modul.id);
artikel.classList.toggle("kartu-modul--selesai", sudahSelesai);
// 100% Aman dari XSS karena menggunakan textContent
judulEl.textContent = `${modul.id}. ${modul.judul}`;
infoEl.textContent = `${modul.tingkat} · ${modul.durasi} · Tag: ${modul.tag.join(", ")}`;
tombol.dataset.aksi = "toggle-selesai";
tombol.setAttribute("aria-pressed", String(sudahSelesai));
tombol.textContent = sudahSelesai ? "✓ Selesai Dipelajari" : "Tandai Selesai";
return fragmen;
});
wadahDaftar.replaceChildren(...simpulKartu);
const { selesai, total, persen } = penyimpan.hitungRingkasan(DATA_MODUL_AWAL.length);
teksProgres.textContent = `Progres Belajar: ${selesai} dari ${total} modul selesai (${persen}%)`;
}
// 1. Event Delegation: Cukup SATU listener untuk seluruh tombol di dalam daftar!
wadahDaftar.addEventListener("click", (event) => {
const tombol = event.target.closest("button[data-aksi='toggle-selesai']");
if (!tombol || !wadahDaftar.contains(tombol)) return;
const kartu = tombol.closest(".kartu-modul");
const idModul = Number(kartu?.dataset.id);
if (!idModul) return;
penyimpan.toggleModul(idModul);
renderAplikasi();
});
// 2. Pencarian dengan Debounce
const jalankanPencarian = buatDebounce((nilai) => {
stateFilter.kataKunci = nilai;
renderAplikasi();
}, 180);
inputCari.addEventListener("input", (e) => jalankanPencarian(e.target.value));
pilihTingkat.addEventListener("change", (e) => {
stateFilter.tingkat = e.target.value;
renderAplikasi();
});
// 3. Sinkronisasi Lintas Tab secara Real-Time
window.addEventListener("storage", (event) => {
if (event.key === "kodeloka:modul-selesai:v1") {
penyimpan.muatUlang();
renderAplikasi();
}
});
renderAplikasi();
3. Berkas Pengujian Otomatis (store.test.js)
// Berkas: ./store.test.js
// Jalankan dengan: node --test store.test.js
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { PenyimpanProgres, saringModul } from "./store.js";
describe("saringModul()", () => {
const sampel = [
{ id: 1, judul: "Dasar HTML", tingkat: "Dasar", tag: ["markup", "dom"] },
{ id: 2, judul: "Async JavaScript", tingkat: "Lanjut", tag: ["promise", "fetch"] }
];
it("menyaring berdasarkan kata kunci pada judul maupun tag tanpa peka huruf besar/kecil", () => {
const hasil = saringModul(sampel, { kataKunci: "FETCH" });
assert.equal(hasil.length, 1);
assert.equal(hasil[0].id, 2);
});
it("menyaring berdasarkan tingkat kesulitan", () => {
const hasil = saringModul(sampel, { tingkat: "Dasar" });
assert.equal(hasil.length, 1);
assert.equal(hasil[0].judul, "Dasar HTML");
});
});
describe("PenyimpanProgres", () => {
it("menambah dan menghapus status selesai modul serta menghitung persentase dengan akurat", () => {
const store = new PenyimpanProgres("kunci-uji");
assert.equal(store.apakahSelesai(1), false);
store.toggleModul(1);
assert.equal(store.apakahSelesai(1), true);
assert.deepEqual(store.hitungRingkasan(4), { selesai: 1, total: 4, persen: 25 });
store.toggleModul(1);
assert.equal(store.apakahSelesai(1), false);
});
});