Project-Based Tutorial

5 menit baca

Pengantar

Teori tanpa praktek = nol. Di bab ini kamu akan bikin 4 project nyata dari nol sampai jadi. Setiap project melatih skill berbeda dan bisa langsung masuk portofolio.

#ProjectSkill yang DilatihDurasi
1Todo AppDOM, Event, localStorage2-3 jam
2Weather AppFetch API, async/await, UI3-4 jam
3Quiz GameState management, timer, scoring3-4 jam
4Expense TrackerCRUD, chart, filter, export4-5 jam

Project 1: Todo App

Apa yang Kamu Pelajari

  • Manipulasi DOM (createElement, appendChild, remove)
  • Event handling (click, keypress, change)
  • localStorage (data persist setelah refresh)
  • CSS transitions untuk UX yang smooth

Fitur

  • ✅ Tambah todo
  • ✅ Tandai selesai (strikethrough)
  • ✅ Hapus todo
  • ✅ Filter: Semua / Aktif / Selesai
  • ✅ Counter "X item tersisa"
  • ✅ Data tersimpan di localStorage

Step 1: HTML Structure

html
<!DOCTYPE html>
<html lang="id">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Todo App</title>
  <link rel="stylesheet" href="style.css">
</head>
<body>
  <div class="container">
    <h1>📝 Todo App</h1>
    
    <form id="todo-form">
      <input 
        type="text" 
        id="todo-input" 
        placeholder="Apa yang mau dikerjakan?"
        autocomplete="off"
      >
      <button type="submit">Tambah</button>
    </form>

    <div class="filters">
      <button class="filter-btn active" data-filter="all">Semua</button>
      <button class="filter-btn" data-filter="active">Aktif</button>
      <button class="filter-btn" data-filter="completed">Selesai</button>
    </div>

    <ul id="todo-list"></ul>

    <div class="footer">
      <span id="counter">0 item tersisa</span>
      <button id="clear-completed">Hapus Selesai</button>
    </div>
  </div>

  <script src="app.js"></script>
</body>
</html>

Step 2: CSS (style.css)

css
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
  background: #1a1a2e;
  color: #eee;
  min-height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
}

.container {
  width: 100%;
  max-width: 500px;
  padding: 2rem;
}

h1 {
  text-align: center;
  margin-bottom: 1.5rem;
  font-size: 2rem;
}

#todo-form {
  display: flex;
  gap: 0.5rem;
  margin-bottom: 1rem;
}

#todo-input {
  flex: 1;
  padding: 0.75rem 1rem;
  border: 2px solid #333;
  border-radius: 8px;
  background: #16213e;
  color: #eee;
  font-size: 1rem;
  outline: none;
  transition: border-color 0.2s;
}

#todo-input:focus {
  border-color: #f7df1e;
}

#todo-form button {
  padding: 0.75rem 1.5rem;
  background: #f7df1e;
  color: #000;
  border: none;
  border-radius: 8px;
  font-weight: 600;
  cursor: pointer;
  transition: transform 0.1s;
}

#todo-form button:active {
  transform: scale(0.95);
}

.filters {
  display: flex;
  gap: 0.5rem;
  margin-bottom: 1rem;
}

.filter-btn {
  padding: 0.4rem 0.8rem;
  background: transparent;
  border: 1px solid #333;
  border-radius: 4px;
  color: #888;
  cursor: pointer;
  font-size: 0.85rem;
  transition: all 0.2s;
}

.filter-btn.active {
  border-color: #f7df1e;
  color: #f7df1e;
}

#todo-list {
  list-style: none;
}

.todo-item {
  display: flex;
  align-items: center;
  gap: 0.75rem;
  padding: 0.75rem;
  border-bottom: 1px solid #222;
  animation: slideIn 0.2s ease;
}

@keyframes slideIn {
  from { opacity: 0; transform: translateY(-10px); }
  to { opacity: 1; transform: translateY(0); }
}

.todo-item.completed .todo-text {
  text-decoration: line-through;
  opacity: 0.5;
}

.todo-checkbox {
  width: 20px;
  height: 20px;
  cursor: pointer;
  accent-color: #f7df1e;
}

.todo-text {
  flex: 1;
  font-size: 1rem;
}

.todo-delete {
  background: none;
  border: none;
  color: #f85149;
  cursor: pointer;
  font-size: 1.2rem;
  opacity: 0;
  transition: opacity 0.2s;
}

.todo-item:hover .todo-delete {
  opacity: 1;
}

.footer {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-top: 1rem;
  padding-top: 1rem;
  border-top: 1px solid #222;
}

#counter {
  font-size: 0.85rem;
  color: #888;
}

#clear-completed {
  background: none;
  border: none;
  color: #888;
  cursor: pointer;
  font-size: 0.85rem;
}

#clear-completed:hover {
  color: #f85149;
}

Step 3: JavaScript (app.js)

javascript
// ===== STATE =====
let todos = JSON.parse(localStorage.getItem('todos')) || [];
let currentFilter = 'all';

// ===== DOM ELEMENTS =====
const form = document.getElementById('todo-form');
const input = document.getElementById('todo-input');
const list = document.getElementById('todo-list');
const counter = document.getElementById('counter');
const clearBtn = document.getElementById('clear-completed');
const filterBtns = document.querySelectorAll('.filter-btn');

// ===== FUNCTIONS =====

function saveTodos() {
  localStorage.setItem('todos', JSON.stringify(todos));
}

function createTodoElement(todo) {
  const li = document.createElement('li');
  li.className = `todo-item ${todo.completed ? 'completed' : ''}`;
  li.dataset.id = todo.id;

  li.innerHTML = `
    <input type="checkbox" class="todo-checkbox" ${todo.completed ? 'checked' : ''}>
    <span class="todo-text">${todo.text}</span>
    <button class="todo-delete">✕</button>
  `;

  // Toggle complete
  li.querySelector('.todo-checkbox').addEventListener('change', () => {
    todo.completed = !todo.completed;
    saveTodos();
    render();
  });

  // Delete
  li.querySelector('.todo-delete').addEventListener('click', () => {
    todos = todos.filter(t => t.id !== todo.id);
    saveTodos();
    render();
  });

  return li;
}

function getFilteredTodos() {
  switch (currentFilter) {
    case 'active': return todos.filter(t => !t.completed);
    case 'completed': return todos.filter(t => t.completed);
    default: return todos;
  }
}

function updateCounter() {
  const active = todos.filter(t => !t.completed).length;
  counter.textContent = `${active} item tersisa`;
}

function render() {
  list.innerHTML = '';
  const filtered = getFilteredTodos();
  filtered.forEach(todo => {
    list.appendChild(createTodoElement(todo));
  });
  updateCounter();
}

// ===== EVENT LISTENERS =====

// Add todo
form.addEventListener('submit', (e) => {
  e.preventDefault();
  const text = input.value.trim();
  if (!text) return;

  todos.push({
    id: Date.now(),
    text,
    completed: false,
  });

  input.value = '';
  saveTodos();
  render();
});

// Filter buttons
filterBtns.forEach(btn => {
  btn.addEventListener('click', () => {
    filterBtns.forEach(b => b.classList.remove('active'));
    btn.classList.add('active');
    currentFilter = btn.dataset.filter;
    render();
  });
});

// Clear completed
clearBtn.addEventListener('click', () => {
  todos = todos.filter(t => !t.completed);
  saveTodos();
  render();
});

// ===== INIT =====
render();

Penjelasan Konsep Kunci

1. localStorage — Data tersimpan di browser, tidak hilang saat refresh:

javascript
// Simpan
localStorage.setItem('key', JSON.stringify(data));
// Ambil
const data = JSON.parse(localStorage.getItem('key'));

2. Event Delegation — Kita bisa pasang listener di parent, bukan di setiap child. Tapi di contoh ini kita pasang langsung saat createElement untuk clarity.

3. Immutable Updatetodos.filter(t => t.id !== todo.id) membuat array baru tanpa mengubah yang lama. Ini pattern yang bagus.

🏋️ Challenge Tambahan

  • Tambah fitur edit (double-click untuk edit text)
  • Tambah fitur drag & drop untuk reorder
  • Tambah due date per todo
  • Tambah kategori/tag dengan warna berbeda

Project 2: Weather App

Apa yang Kamu Pelajari

  • Fetch API & async/await
  • Bekerja dengan API pihak ketiga (OpenWeatherMap)
  • Error handling (kota tidak ditemukan, network error)
  • Dynamic UI update berdasarkan data

Fitur

  • 🔍 Cari cuaca berdasarkan nama kota
  • 🌡️ Tampilkan suhu, kelembaban, kecepatan angin
  • 🎨 Background berubah sesuai cuaca (cerah/hujan/berawan)
  • 📍 Deteksi lokasi otomatis (Geolocation API)
  • 📱 Responsive

Step 1: Daftar API Key

  1. Buka openweathermap.org
  2. Sign up (gratis)
  3. Buat API key di dashboard
  4. Tunggu ~10 menit sampai aktif

Step 2: HTML

html
<!DOCTYPE html>
<html lang="id">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Weather App</title>
  <link rel="stylesheet" href="style.css">
</head>
<body>
  <div class="app" id="app">
    <div class="search-box">
      <input type="text" id="city-input" placeholder="Cari kota...">
      <button id="search-btn">🔍</button>
      <button id="location-btn" title="Gunakan lokasi saya">📍</button>
    </div>

    <div id="weather-display" class="hidden">
      <div class="city-name" id="city-name"></div>
      <div class="temperature" id="temperature"></div>
      <div class="description" id="description"></div>
      <div class="icon" id="weather-icon"></div>
      
      <div class="details">
        <div class="detail-item">
          <span class="label">Kelembaban</span>
          <span class="value" id="humidity"></span>
        </div>
        <div class="detail-item">
          <span class="label">Angin</span>
          <span class="value" id="wind"></span>
        </div>
        <div class="detail-item">
          <span class="label">Terasa</span>
          <span class="value" id="feels-like"></span>
        </div>
      </div>
    </div>

    <div id="error-display" class="hidden">
      <p id="error-message"></p>
    </div>

    <div id="loading" class="hidden">
      <p>Memuat...</p>
    </div>
  </div>

  <script src="app.js"></script>
</body>
</html>

Step 3: JavaScript (app.js)

javascript
// ===== CONFIG =====
const API_KEY = 'ISI_API_KEY_KAMU'; // Ganti dengan API key kamu
const BASE_URL = 'https://api.openweathermap.org/data/2.5/weather';

// ===== DOM =====
const cityInput = document.getElementById('city-input');
const searchBtn = document.getElementById('search-btn');
const locationBtn = document.getElementById('location-btn');
const weatherDisplay = document.getElementById('weather-display');
const errorDisplay = document.getElementById('error-display');
const loading = document.getElementById('loading');

// ===== FUNCTIONS =====

function showLoading() {
  loading.classList.remove('hidden');
  weatherDisplay.classList.add('hidden');
  errorDisplay.classList.add('hidden');
}

function showError(message) {
  loading.classList.add('hidden');
  weatherDisplay.classList.add('hidden');
  errorDisplay.classList.remove('hidden');
  document.getElementById('error-message').textContent = message;
}

function showWeather(data) {
  loading.classList.add('hidden');
  errorDisplay.classList.add('hidden');
  weatherDisplay.classList.remove('hidden');

  document.getElementById('city-name').textContent = `${data.name}, ${data.sys.country}`;
  document.getElementById('temperature').textContent = `${Math.round(data.main.temp)}°C`;
  document.getElementById('description').textContent = data.weather[0].description;
  document.getElementById('humidity').textContent = `${data.main.humidity}%`;
  document.getElementById('wind').textContent = `${data.wind.speed} m/s`;
  document.getElementById('feels-like').textContent = `${Math.round(data.main.feels_like)}°C`;

  // Weather icon
  const iconCode = data.weather[0].icon;
  document.getElementById('weather-icon').innerHTML = 
    `<img src="https://openweathermap.org/img/wn/${iconCode}@4x.png" alt="weather">`;

  // Background berdasarkan cuaca
  updateBackground(data.weather[0].main);
}

function updateBackground(weatherMain) {
  const app = document.getElementById('app');
  const backgrounds = {
    Clear: 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)',
    Clouds: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
    Rain: 'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)',
    Drizzle: 'linear-gradient(135deg, #89f7fe 0%, #66a6ff 100%)',
    Thunderstorm: 'linear-gradient(135deg, #0c3483 0%, #a2b6df 100%)',
    Snow: 'linear-gradient(135deg, #e6e9f0 0%, #eef1f5 100%)',
    Mist: 'linear-gradient(135deg, #d7d2cc 0%, #304352 100%)',
  };
  app.style.background = backgrounds[weatherMain] || backgrounds.Clouds;
}

async function fetchWeatherByCity(city) {
  showLoading();
  
  try {
    const url = `${BASE_URL}?q=${encodeURIComponent(city)}&appid=${API_KEY}&units=metric&lang=id`;
    const response = await fetch(url);
    
    if (!response.ok) {
      if (response.status === 404) throw new Error('Kota tidak ditemukan');
      throw new Error('Gagal mengambil data cuaca');
    }
    
    const data = await response.json();
    showWeather(data);
  } catch (error) {
    showError(error.message);
  }
}

async function fetchWeatherByCoords(lat, lon) {
  showLoading();
  
  try {
    const url = `${BASE_URL}?lat=${lat}&lon=${lon}&appid=${API_KEY}&units=metric&lang=id`;
    const response = await fetch(url);
    
    if (!response.ok) throw new Error('Gagal mengambil data cuaca');
    
    const data = await response.json();
    showWeather(data);
  } catch (error) {
    showError(error.message);
  }
}

// ===== EVENT LISTENERS =====

searchBtn.addEventListener('click', () => {
  const city = cityInput.value.trim();
  if (city) fetchWeatherByCity(city);
});

cityInput.addEventListener('keypress', (e) => {
  if (e.key === 'Enter') {
    const city = cityInput.value.trim();
    if (city) fetchWeatherByCity(city);
  }
});

locationBtn.addEventListener('click', () => {
  if (!navigator.geolocation) {
    showError('Browser kamu tidak support geolocation');
    return;
  }
  
  navigator.geolocation.getCurrentPosition(
    (position) => {
      fetchWeatherByCoords(position.coords.latitude, position.coords.longitude);
    },
    () => {
      showError('Tidak bisa mengakses lokasi. Coba izinkan di browser.');
    }
  );
});

// ===== INIT =====
// Coba deteksi lokasi otomatis saat pertama buka
locationBtn.click();

Penjelasan Konsep Kunci

1. Fetch API — Cara modern untuk HTTP request:

javascript
const response = await fetch(url);
const data = await response.json();

2. Error Handling — Selalu wrap fetch dalam try/catch:

javascript
try {
  const res = await fetch(url);
  if (!res.ok) throw new Error('HTTP error');
  // ...
} catch (err) {
  // Handle error
}

3. Geolocation API — Minta izin lokasi user:

javascript
navigator.geolocation.getCurrentPosition(successCallback, errorCallback);

🏋️ Challenge Tambahan

  • Tambah forecast 5 hari (pakai endpoint /forecast)
  • Tambah search history (simpan di localStorage)
  • Tambah unit toggle (Celsius ↔ Fahrenheit)
  • Tambah animasi cuaca (hujan = particles jatuh)

Project 3: Quiz Game

Apa yang Kamu Pelajari

  • State management tanpa framework
  • Timer (setInterval/clearInterval)
  • Scoring system
  • Dynamic content rendering
  • Transition antar "halaman" tanpa reload

Fitur

  • 🎮 10 pertanyaan JavaScript random
  • ⏱️ Timer 15 detik per soal
  • 📊 Score + progress bar
  • 🎉 Hasil akhir + grade
  • 🔄 Main lagi

JavaScript (app.js)

javascript
// ===== DATA =====
const questions = [
  {
    question: "Apa output dari: typeof null?",
    options: ["'null'", "'undefined'", "'object'", "'boolean'"],
    correct: 2,
    explanation: "Ini bug legendaris di JavaScript sejak 1995. typeof null return 'object' karena bug di implementasi awal."
  },
  {
    question: "Apa perbedaan == dan ===?",
    options: [
      "Tidak ada perbedaan",
      "=== lebih cepat",
      "== melakukan type coercion, === tidak",
      "=== hanya untuk string"
    ],
    correct: 2,
    explanation: "== mengkonversi tipe dulu sebelum membandingkan (1 == '1' → true). === membandingkan nilai DAN tipe (1 === '1' → false)."
  },
  {
    question: "Apa output: console.log(0.1 + 0.2 === 0.3)?",
    options: ["true", "false", "undefined", "Error"],
    correct: 1,
    explanation: "0.1 + 0.2 = 0.30000000000000004 karena floating point precision. Gunakan Math.abs(a - b) < Number.EPSILON untuk perbandingan."
  },
  {
    question: "Mana yang BUKAN primitive type di JavaScript?",
    options: ["string", "number", "array", "boolean"],
    correct: 2,
    explanation: "Array adalah object, bukan primitive. 7 primitive types: string, number, bigint, boolean, undefined, null, symbol."
  },
  {
    question: "Apa itu closure?",
    options: [
      "Fungsi yang menutup program",
      "Fungsi yang mengingat scope tempat ia dibuat",
      "Fungsi yang tidak bisa dipanggil lagi",
      "Fungsi tanpa parameter"
    ],
    correct: 1,
    explanation: "Closure = fungsi + referensi ke variabel di scope luar. Fungsi 'mengingat' environment tempat ia diciptakan."
  },
  {
    question: "Apa output: [1,2,3].map(parseInt)?",
    options: ["[1, 2, 3]", "[1, NaN, NaN]", "[1, 1, 1]", "Error"],
    correct: 1,
    explanation: "map melewatkan (value, index) ke callback. parseInt menerima (string, radix). Jadi: parseInt(1,0)=1, parseInt(2,1)=NaN, parseInt(3,2)=NaN."
  },
  {
    question: "Apa perbedaan let dan var?",
    options: [
      "Tidak ada perbedaan",
      "let punya block scope, var punya function scope",
      "var lebih baru dari let",
      "let tidak bisa di-reassign"
    ],
    correct: 1,
    explanation: "var: function-scoped, hoisted. let: block-scoped, temporal dead zone. Selalu pakai let/const, hindari var."
  },
  {
    question: "Apa yang terjadi saat: const obj = {}; obj.name = 'Budi';?",
    options: [
      "Error karena const",
      "Berhasil, obj.name = 'Budi'",
      "undefined",
      "TypeError"
    ],
    correct: 1,
    explanation: "const mencegah reassignment variabel, BUKAN mutasi. obj = {} (error), tapi obj.name = 'x' (OK). Object tetap bisa diubah isinya."
  },
  {
    question: "Promise.all([p1, p2, p3]) akan reject jika:",
    options: [
      "Semua promise reject",
      "Salah satu promise reject",
      "Promise pertama reject",
      "Tidak pernah reject"
    ],
    correct: 1,
    explanation: "Promise.all reject segera saat SATU promise reject (fail-fast). Gunakan Promise.allSettled jika mau tunggu semua selesai."
  },
  {
    question: "Apa output: '5' + 3 dan '5' - 3?",
    options: [
      "'53' dan '53'",
      "8 dan 2",
      "'53' dan 2",
      "Error"
    ],
    correct: 2,
    explanation: "Operator + dengan string = concatenation ('5' + 3 = '53'). Operator - selalu numeric ('5' - 3 = 2). Ini type coercion yang tricky."
  }
];

// ===== STATE =====
let currentQuestion = 0;
let score = 0;
let timer = null;
let timeLeft = 15;
let answered = false;

// ===== DOM =====
const screens = {
  start: document.getElementById('start-screen'),
  quiz: document.getElementById('quiz-screen'),
  result: document.getElementById('result-screen'),
};

// ===== FUNCTIONS =====

function showScreen(name) {
  Object.values(screens).forEach(s => s.classList.add('hidden'));
  screens[name].classList.remove('hidden');
}

function shuffleArray(arr) {
  const shuffled = [...arr];
  for (let i = shuffled.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
  }
  return shuffled;
}

function startQuiz() {
  currentQuestion = 0;
  score = 0;
  showScreen('quiz');
  renderQuestion();
}

function startTimer() {
  timeLeft = 15;
  updateTimerDisplay();
  
  timer = setInterval(() => {
    timeLeft--;
    updateTimerDisplay();
    
    if (timeLeft <= 0) {
      clearInterval(timer);
      handleTimeout();
    }
  }, 1000);
}

function updateTimerDisplay() {
  document.getElementById('timer').textContent = timeLeft;
  document.getElementById('timer-bar').style.width = `${(timeLeft / 15) * 100}%`;
  
  // Warna berubah saat waktu hampir habis
  const timerEl = document.getElementById('timer');
  if (timeLeft <= 5) timerEl.classList.add('danger');
  else timerEl.classList.remove('danger');
}

function renderQuestion() {
  answered = false;
  const q = questions[currentQuestion];
  
  document.getElementById('question-number').textContent = 
    `Soal ${currentQuestion + 1}/${questions.length}`;
  document.getElementById('question-text').textContent = q.question;
  document.getElementById('progress-bar').style.width = 
    `${((currentQuestion) / questions.length) * 100}%`;
  document.getElementById('score-display').textContent = `Score: ${score}`;
  
  const optionsContainer = document.getElementById('options');
  optionsContainer.innerHTML = '';
  
  q.options.forEach((option, index) => {
    const btn = document.createElement('button');
    btn.className = 'option-btn';
    btn.textContent = option;
    btn.addEventListener('click', () => selectAnswer(index));
    optionsContainer.appendChild(btn);
  });
  
  document.getElementById('explanation').classList.add('hidden');
  document.getElementById('next-btn').classList.add('hidden');
  
  startTimer();
}

function selectAnswer(index) {
  if (answered) return;
  answered = true;
  clearInterval(timer);
  
  const q = questions[currentQuestion];
  const buttons = document.querySelectorAll('.option-btn');
  
  // Highlight jawaban
  buttons.forEach((btn, i) => {
    btn.disabled = true;
    if (i === q.correct) btn.classList.add('correct');
    if (i === index && i !== q.correct) btn.classList.add('wrong');
  });
  
  if (index === q.correct) {
    score += timeLeft > 10 ? 15 : timeLeft > 5 ? 10 : 5; // Bonus waktu
  }
  
  // Tampilkan penjelasan
  document.getElementById('explanation').classList.remove('hidden');
  document.getElementById('explanation-text').textContent = q.explanation;
  document.getElementById('next-btn').classList.remove('hidden');
}

function handleTimeout() {
  if (answered) return;
  answered = true;
  
  const q = questions[currentQuestion];
  const buttons = document.querySelectorAll('.option-btn');
  
  buttons.forEach((btn, i) => {
    btn.disabled = true;
    if (i === q.correct) btn.classList.add('correct');
  });
  
  document.getElementById('explanation').classList.remove('hidden');
  document.getElementById('explanation-text').textContent = 
    `⏱️ Waktu habis! ${q.explanation}`;
  document.getElementById('next-btn').classList.remove('hidden');
}

function nextQuestion() {
  currentQuestion++;
  
  if (currentQuestion >= questions.length) {
    showResults();
  } else {
    renderQuestion();
  }
}

function showResults() {
  showScreen('result');
  
  const maxScore = questions.length * 15;
  const percentage = Math.round((score / maxScore) * 100);
  
  let grade, message;
  if (percentage >= 90) { grade = 'A+'; message = 'Luar biasa! Kamu master JavaScript! 🏆'; }
  else if (percentage >= 75) { grade = 'A'; message = 'Hebat! Pemahaman kamu solid! 🌟'; }
  else if (percentage >= 60) { grade = 'B'; message = 'Bagus! Tinggal poles sedikit lagi! 💪'; }
  else if (percentage >= 40) { grade = 'C'; message = 'Lumayan! Terus belajar ya! 📚'; }
  else { grade = 'D'; message = 'Jangan menyerah! Review materi lagi! 🔄'; }
  
  document.getElementById('final-score').textContent = score;
  document.getElementById('max-score').textContent = maxScore;
  document.getElementById('grade').textContent = grade;
  document.getElementById('result-message').textContent = message;
  document.getElementById('percentage').textContent = `${percentage}%`;
}

// ===== EVENT LISTENERS =====
document.getElementById('start-btn').addEventListener('click', () => {
  questions.splice(0, questions.length, ...shuffleArray(questions));
  startQuiz();
});

document.getElementById('next-btn').addEventListener('click', nextQuestion);
document.getElementById('restart-btn').addEventListener('click', () => {
  showScreen('start');
});

Penjelasan Konsep Kunci

1. State Machine — Quiz punya 3 "layar" (start, quiz, result). Kita toggle visibility:

javascript
function showScreen(name) {
  Object.values(screens).forEach(s => s.classList.add('hidden'));
  screens[name].classList.remove('hidden');
}

2. Timer — setInterval + clearInterval:

javascript
timer = setInterval(() => { /* ... */ }, 1000);
clearInterval(timer); // Stop

3. Scoring dengan Bonus — Jawab cepat = poin lebih banyak. Ini bikin game lebih engaging.

🏋️ Challenge Tambahan

  • Tambah kategori soal (DOM, Array, Async, dll)
  • Tambah difficulty level (Easy/Medium/Hard)
  • Tambah leaderboard (localStorage)
  • Tambah sound effects (benar/salah)
  • Fetch soal dari API (Open Trivia DB)

Project 4: Expense Tracker

Apa yang Kamu Pelajari

  • CRUD operations (Create, Read, Update, Delete)
  • Data filtering & sorting
  • Date manipulation
  • Chart/visualization (canvas atau library ringan)
  • Export data (CSV)

Fitur

  • 💰 Tambah pemasukan & pengeluaran
  • 📊 Ringkasan: total pemasukan, pengeluaran, saldo
  • 📅 Filter berdasarkan bulan/tahun
  • 🏷️ Kategori (Makan, Transport, Hiburan, dll)
  • 📈 Chart sederhana (bar chart per kategori)
  • 💾 Export ke CSV
  • 🗑️ Edit & hapus transaksi

JavaScript (app.js)

javascript
// ===== STATE =====
let transactions = JSON.parse(localStorage.getItem('transactions')) || [];
let editingId = null;

// ===== CATEGORIES =====
const categories = {
  income: ['Gaji', 'Freelance', 'Investasi', 'Lainnya'],
  expense: ['Makan', 'Transport', 'Hiburan', 'Belanja', 'Tagihan', 'Kesehatan', 'Pendidikan', 'Lainnya']
};

// ===== FUNCTIONS =====

function generateId() {
  return Date.now().toString(36) + Math.random().toString(36).slice(2);
}

function saveTransactions() {
  localStorage.setItem('transactions', JSON.stringify(transactions));
}

function addTransaction(data) {
  const transaction = {
    id: generateId(),
    type: data.type, // 'income' atau 'expense'
    amount: parseFloat(data.amount),
    category: data.category,
    description: data.description,
    date: data.date || new Date().toISOString().split('T')[0],
    createdAt: Date.now(),
  };
  
  transactions.push(transaction);
  saveTransactions();
  render();
}

function deleteTransaction(id) {
  if (!confirm('Yakin mau hapus transaksi ini?')) return;
  transactions = transactions.filter(t => t.id !== id);
  saveTransactions();
  render();
}

function editTransaction(id) {
  const t = transactions.find(t => t.id === id);
  if (!t) return;
  
  editingId = id;
  document.getElementById('type').value = t.type;
  document.getElementById('amount').value = t.amount;
  document.getElementById('category').value = t.category;
  document.getElementById('description').value = t.description;
  document.getElementById('date').value = t.date;
  document.getElementById('submit-btn').textContent = 'Update';
  updateCategoryOptions();
}

function getFilteredTransactions() {
  const month = document.getElementById('filter-month').value;
  const year = document.getElementById('filter-year').value;
  
  return transactions.filter(t => {
    const date = new Date(t.date);
    if (month && date.getMonth() + 1 !== parseInt(month)) return false;
    if (year && date.getFullYear() !== parseInt(year)) return false;
    return true;
  });
}

function calculateSummary(filtered) {
  const income = filtered
    .filter(t => t.type === 'income')
    .reduce((sum, t) => sum + t.amount, 0);
  
  const expense = filtered
    .filter(t => t.type === 'expense')
    .reduce((sum, t) => sum + t.amount, 0);
  
  return { income, expense, balance: income - expense };
}

function formatCurrency(amount) {
  return new Intl.NumberFormat('id-ID', {
    style: 'currency',
    currency: 'IDR',
    minimumFractionDigits: 0,
  }).format(amount);
}

function getCategoryTotals(filtered) {
  const totals = {};
  filtered
    .filter(t => t.type === 'expense')
    .forEach(t => {
      totals[t.category] = (totals[t.category] || 0) + t.amount;
    });
  return totals;
}

// ===== CHART =====

function renderChart(filtered) {
  const canvas = document.getElementById('chart');
  const ctx = canvas.getContext('2d');
  const totals = getCategoryTotals(filtered);
  const entries = Object.entries(totals).sort((a, b) => b[1] - a[1]);
  
  if (entries.length === 0) {
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    ctx.fillStyle = '#888';
    ctx.textAlign = 'center';
    ctx.fillText('Belum ada data pengeluaran', canvas.width / 2, canvas.height / 2);
    return;
  }
  
  const maxValue = Math.max(...entries.map(e => e[1]));
  const barWidth = Math.min(60, (canvas.width - 40) / entries.length - 10);
  const chartHeight = canvas.height - 60;
  
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  
  const colors = ['#f7df1e', '#ff7b72', '#a5d6ff', '#7ee787', '#d2a8ff', '#ffa657', '#79c0ff', '#f85149'];
  
  entries.forEach(([category, amount], index) => {
    const x = 30 + index * (barWidth + 10);
    const barHeight = (amount / maxValue) * chartHeight;
    const y = chartHeight - barHeight + 20;
    
    // Bar
    ctx.fillStyle = colors[index % colors.length];
    ctx.fillRect(x, y, barWidth, barHeight);
    
    // Label
    ctx.fillStyle = '#888';
    ctx.font = '10px sans-serif';
    ctx.textAlign = 'center';
    ctx.fillText(category, x + barWidth / 2, canvas.height - 5);
    
    // Value
    ctx.fillStyle = '#eee';
    ctx.font = '11px sans-serif';
    ctx.fillText(formatCurrency(amount), x + barWidth / 2, y - 5);
  });
}

// ===== EXPORT CSV =====

function exportCSV() {
  const filtered = getFilteredTransactions();
  if (filtered.length === 0) {
    alert('Tidak ada data untuk di-export');
    return;
  }
  
  const headers = ['Tanggal', 'Tipe', 'Kategori', 'Deskripsi', 'Jumlah'];
  const rows = filtered.map(t => [
    t.date,
    t.type === 'income' ? 'Pemasukan' : 'Pengeluaran',
    t.category,
    t.description,
    t.amount,
  ]);
  
  const csv = [headers, ...rows]
    .map(row => row.map(cell => `"${cell}"`).join(','))
    .join('\n');
  
  const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
  const url = URL.createObjectURL(blob);
  const link = document.createElement('a');
  link.href = url;
  link.download = `expense-tracker-${new Date().toISOString().split('T')[0]}.csv`;
  link.click();
  URL.revokeObjectURL(url);
}

// ===== RENDER =====

function render() {
  const filtered = getFilteredTransactions();
  const summary = calculateSummary(filtered);
  
  // Summary cards
  document.getElementById('total-income').textContent = formatCurrency(summary.income);
  document.getElementById('total-expense').textContent = formatCurrency(summary.expense);
  document.getElementById('balance').textContent = formatCurrency(summary.balance);
  document.getElementById('balance').className = summary.balance >= 0 ? 'positive' : 'negative';
  
  // Transaction list
  const list = document.getElementById('transaction-list');
  list.innerHTML = filtered
    .sort((a, b) => new Date(b.date) - new Date(a.date))
    .map(t => `
      <div class="transaction-item ${t.type}">
        <div class="transaction-info">
          <span class="transaction-category">${t.category}</span>
          <span class="transaction-desc">${t.description}</span>
          <span class="transaction-date">${new Date(t.date).toLocaleDateString('id-ID')}</span>
        </div>
        <div class="transaction-amount ${t.type}">
          ${t.type === 'income' ? '+' : '-'}${formatCurrency(t.amount)}
        </div>
        <div class="transaction-actions">
          <button onclick="editTransaction('${t.id}')" title="Edit">✏️</button>
          <button onclick="deleteTransaction('${t.id}')" title="Hapus">🗑️</button>
        </div>
      </div>
    `).join('');
  
  // Chart
  renderChart(filtered);
}

// ===== EVENT LISTENERS =====

function updateCategoryOptions() {
  const type = document.getElementById('type').value;
  const categorySelect = document.getElementById('category');
  const options = categories[type];
  
  categorySelect.innerHTML = options
    .map(cat => `<option value="${cat}">${cat}</option>`)
    .join('');
}

document.getElementById('type').addEventListener('change', updateCategoryOptions);

document.getElementById('transaction-form').addEventListener('submit', (e) => {
  e.preventDefault();
  
  const data = {
    type: document.getElementById('type').value,
    amount: document.getElementById('amount').value,
    category: document.getElementById('category').value,
    description: document.getElementById('description').value,
    date: document.getElementById('date').value,
  };
  
  if (editingId) {
    // Update existing
    const index = transactions.findIndex(t => t.id === editingId);
    if (index !== -1) {
      transactions[index] = { ...transactions[index], ...data, amount: parseFloat(data.amount) };
      saveTransactions();
    }
    editingId = null;
    document.getElementById('submit-btn').textContent = 'Tambah';
  } else {
    addTransaction(data);
  }
  
  e.target.reset();
  document.getElementById('date').value = new Date().toISOString().split('T')[0];
  render();
});

document.getElementById('filter-month').addEventListener('change', render);
document.getElementById('filter-year').addEventListener('change', render);
document.getElementById('export-btn').addEventListener('click', exportCSV);

// ===== INIT =====
document.getElementById('date').value = new Date().toISOString().split('T')[0];
updateCategoryOptions();
render();

Penjelasan Konsep Kunci

1. CRUD Pattern:

javascript
// Create
transactions.push(newItem);
// Read
transactions.filter(condition);
// Update
transactions[index] = { ...old, ...newData };
// Delete
transactions = transactions.filter(t => t.id !== id);

2. Intl.NumberFormat — Format angka sesuai locale:

javascript
new Intl.NumberFormat('id-ID', { style: 'currency', currency: 'IDR' }).format(50000);
// "Rp 50.000"

3. Blob & Download — Buat file dan trigger download:

javascript
const blob = new Blob([content], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
// Buat <a> tag, set href = url, click()

4. Canvas API — Gambar chart manual:

javascript
const ctx = canvas.getContext('2d');
ctx.fillRect(x, y, width, height);
ctx.fillText(text, x, y);

🏋️ Challenge Tambahan

  • Tambah recurring transactions (gaji bulanan otomatis)
  • Tambah budget per kategori (alert kalau over budget)
  • Tambah pie chart untuk proporsi pengeluaran
  • Tambah multi-currency support
  • Tambah dark/light mode toggle

Tips Umum untuk Semua Project

1. Mulai dari MVP (Minimum Viable Product)

Jangan langsung bikin semua fitur. Mulai dari yang paling basic, pastikan jalan, baru tambah fitur satu-satu.

2. Commit Sering

Setiap fitur selesai = commit. Jangan tunggu project selesai baru commit.

bash
git add .
git commit -m "feat: tambah fitur filter todo"

3. Responsive First

Selalu test di mobile. Pakai Chrome DevTools → Toggle Device Toolbar (Ctrl+Shift+M).

4. Console.log adalah Teman

Kalau bingung kenapa kode nggak jalan:

javascript
console.log('data:', data);
console.log('type:', typeof variable);
console.log('masuk sini?');

5. Baca Error Message

Error message itu petunjuk, bukan musuh. Baca baik-baik:

  • Baris berapa?
  • File mana?
  • Apa pesannya?

6. Deploy!

Jangan cuma di local. Deploy ke GitHub Pages / Netlify / Vercel. Biar bisa dipamer dan masuk portofolio.


Penutup

4 project ini melatih skill yang berbeda-beda:

ProjectSkill Utama
Todo AppDOM manipulation, localStorage, event handling
Weather AppAPI integration, async/await, error handling
Quiz GameState management, timer, dynamic rendering
Expense TrackerCRUD, data processing, visualization, export

Setelah selesai keempat project ini, kamu punya:

  • ✅ Portofolio 4 project
  • ✅ Pengalaman nyata pakai JavaScript
  • ✅ Pemahaman pattern yang sering dipakai di dunia kerja

Next step: Coba bikin project sendiri dari ide kamu. Atau upgrade salah satu project di atas dengan fitur tambahan. Kreativitas kamu yang jadi batasnya! 🚀

Sudah paham materi ini?

Tandai sebagai selesai untuk melacak progress-mu.