Project-Based Tutorial
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.
| # | Project | Skill yang Dilatih | Durasi |
|---|---|---|---|
| 1 | Todo App | DOM, Event, localStorage | 2-3 jam |
| 2 | Weather App | Fetch API, async/await, UI | 3-4 jam |
| 3 | Quiz Game | State management, timer, scoring | 3-4 jam |
| 4 | Expense Tracker | CRUD, chart, filter, export | 4-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
<!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)
* {
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)
// ===== 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:
// 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 Update — todos.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
- Buka openweathermap.org
- Sign up (gratis)
- Buat API key di dashboard
- Tunggu ~10 menit sampai aktif
Step 2: 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)
// ===== 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:
const response = await fetch(url);
const data = await response.json();2. Error Handling — Selalu wrap fetch dalam try/catch:
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:
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)
// ===== 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:
function showScreen(name) {
Object.values(screens).forEach(s => s.classList.add('hidden'));
screens[name].classList.remove('hidden');
}2. Timer — setInterval + clearInterval:
timer = setInterval(() => { /* ... */ }, 1000);
clearInterval(timer); // Stop3. 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)
// ===== 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:
// 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:
new Intl.NumberFormat('id-ID', { style: 'currency', currency: 'IDR' }).format(50000);
// "Rp 50.000"3. Blob & Download — Buat file dan trigger download:
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:
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.
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:
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:
| Project | Skill Utama |
|---|---|
| Todo App | DOM manipulation, localStorage, event handling |
| Weather App | API integration, async/await, error handling |
| Quiz Game | State management, timer, dynamic rendering |
| Expense Tracker | CRUD, 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.