Day 14: Mini Project: To-Do App
✅ Today you will learn
- Plan an app as state + actions + render
- Add tasks with validation, priorities and due dates
- Complete, edit (double-click / ✏️), delete and filter tasks
- One delegated listener for the whole list
- Save to localStorage safely and handle blocked storage
🏢 In the real world
During Diwali week Sharma Sweets has 50 small jobs a day: order cashews, pack gift boxes, call wedding customers. Paper lists get lost. A simple, fast to-do app that remembers tasks on the shop’s phone solves a real problem – and the to-do app is the classic interview project because it uses every fundamental from Days 1–13.
The finished app
This preview runs in a sandboxed frame where browsers block localStorage. The app detects that and keeps working in memory. Open the file from the download kit and your tasks survive a reload – that graceful fallback is exactly what professional code does.
Step 1: plan the data and features
| Feature | State change | Concept (Day) |
|---|---|---|
| Add task | tasks.unshift({...}) | Forms & validation (13) |
| Complete | task.done = !task.done | find (8), events (11) |
| Edit | editingId = id → title = new text | Keyboard events (11) |
| Delete | tasks = tasks.filter(...) | filter (8) |
| Filter All / Active / Done | filter = "active" | State → render (12) |
| Sort: open first, high priority first | computed in visibleTasks() | sort with compare (8) |
| Remember tasks | localStorage + JSON | JSON (7), try…catch preview of Day 18 |
| Overdue warning | due < today | Dates (9) |
{
id: 1761380000000, // Date.now() – unique enough for one user
title: 'Pack 20 Diwali gift boxes',
done: false,
priority: 'high', // 'high' | 'normal' | 'low'
due: '2026-10-28', // ISO date string or ''
createdAt: 1761380000000
}Step 2: semantic, accessible HTML
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>My Tasks – To-Do App (JavaScript Mini Project)</title>
<meta name="description" content="A to-do app built with plain JavaScript: add, edit, complete, filter and delete tasks, with priorities, due dates and saving in localStorage.">
<link rel="stylesheet" href="style.css">
<script src="app.js" defer></script>
</head>
<body>
<main class="app">
<header class="app__head">
<h1>📝 My Tasks</h1>
<p class="app__count" id="count" aria-live="polite"></p>
</header>
<form class="add" id="add-form" autocomplete="off">
<label class="sr-only" for="task-input">New task</label>
<input id="task-input" name="title" placeholder="e.g. Pack 20 Diwali boxes" maxlength="120" required>
<label class="sr-only" for="task-priority">Priority</label>
<select id="task-priority" name="priority">
<option value="normal">Normal</option>
<option value="high">High 🔥</option>
<option value="low">Low</option>
</select>
<label class="sr-only" for="task-due">Due date</label>
<input id="task-due" name="due" type="date">
<button type="submit">Add</button>
</form>
<p class="error" id="error" role="alert"></p>
<nav class="filters" aria-label="Filter tasks">
<button type="button" data-filter="all" aria-pressed="true">All</button>
<button type="button" data-filter="active" aria-pressed="false">Active</button>
<button type="button" data-filter="done" aria-pressed="false">Done</button>
<button type="button" class="link" id="clear-done">Clear completed</button>
</nav>
<ul class="list" id="list"></ul>
<p class="empty" id="empty">No tasks here. Add your first one above! 🎉</p>
<p class="note" id="storage-note" hidden>⚠️ Saving is disabled in this browser mode – tasks will reset on reload.</p>
</main>
</body>
</html>- Labels exist for every field (visually hidden with .sr-only) so screen readers announce them.
- The counter uses aria-live="polite" – screen readers announce “2 tasks left” after changes.
- Filter buttons use aria-pressed to expose which filter is active.
- The script uses defer – no “null” errors.
Step 3: storage helpers that never crash
// ---------- 1. STORAGE HELPERS (safe even when storage is blocked) ----------
const STORAGE_KEY = 'ce-todo-v1';
let storageWorks = true;
function loadTasks() {
try {
const saved = localStorage.getItem(STORAGE_KEY);
return saved ? JSON.parse(saved) : [];
} catch (err) {
storageWorks = false; // private mode, sandboxed preview, etc.
return [];
}
}
function saveTasks(tasks) {
if (!storageWorks) return;
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(tasks));
} catch (err) {
storageWorks = false;
}
}Step 4: state and actions
// ---------- 2. STATE ----------
const state = {
tasks: loadTasks(), // [{ id, title, done, priority, due, createdAt }]
filter: 'all', // 'all' | 'active' | 'done'
editingId: null,
};
if (state.tasks.length === 0 && !localStorageHasKey()) {
// friendly sample tasks on the very first visit
state.tasks = [
{ id: 1, title: 'Order cashews for kaju katli', done: true, priority: 'normal', due: '', createdAt: Date.now() - 3000 },
{ id: 2, title: 'Pack 20 Diwali gift boxes', done: false, priority: 'high', due: todayISO(), createdAt: Date.now() - 2000 },
{ id: 3, title: 'Update the website menu prices', done: false, priority: 'low', due: '', createdAt: Date.now() - 1000 },
];
}
function localStorageHasKey() {
try { return localStorage.getItem(STORAGE_KEY) !== null; } catch (err) { return false; }
}
function todayISO() {
const d = new Date();
d.setMinutes(d.getMinutes() - d.getTimezoneOffset()); // local date, not UTC
return d.toISOString().slice(0, 10);
}// ---------- 3. ACTIONS (change state → save → render) ----------
function addTask(title, priority, due) {
state.tasks.unshift({ id: Date.now(), title, done: false, priority, due, createdAt: Date.now() });
commit();
}
function toggleTask(id) {
const task = state.tasks.find(t => t.id === id);
if (task) task.done = !task.done;
commit();
}
function renameTask(id, title) {
const task = state.tasks.find(t => t.id === id);
if (task && title.trim()) task.title = title.trim();
state.editingId = null;
commit();
}
function deleteTask(id) {
state.tasks = state.tasks.filter(t => t.id !== id);
commit();
}
function clearDone() {
state.tasks = state.tasks.filter(t => !t.done);
commit();
}
function commit() {
saveTasks(state.tasks);
render();
}commit() saves and re-renders. Because every change goes through one function, the screen and the saved data can never disagree – the single most important habit in UI programming.
Step 5: render – state becomes HTML
// ---------- 4. RENDER (state → HTML) ----------
const els = {
form: document.querySelector('#add-form'),
input: document.querySelector('#task-input'),
priority: document.querySelector('#task-priority'),
due: document.querySelector('#task-due'),
error: document.querySelector('#error'),
list: document.querySelector('#list'),
empty: document.querySelector('#empty'),
count: document.querySelector('#count'),
filters: document.querySelector('.filters'),
clearDone: document.querySelector('#clear-done'),
storageNote: document.querySelector('#storage-note'),
};
const PRIORITY_ORDER = { high: 0, normal: 1, low: 2 };
function visibleTasks() {
return state.tasks
.filter(t => state.filter === 'all' || (state.filter === 'done' ? t.done : !t.done))
.sort((a, b) => Number(a.done) - Number(b.done) || PRIORITY_ORDER[a.priority] - PRIORITY_ORDER[b.priority] || b.createdAt - a.createdAt);
}
function createTaskItem(task) {
const li = document.createElement('li');
const overdue = !task.done && task.due && task.due < todayISO();
li.className = 'task' + (task.done ? ' task--done' : '') + (task.priority === 'high' ? ' task--high' : '') + (overdue ? ' task--overdue' : '');
li.dataset.id = task.id;
const check = document.createElement('input');
check.type = 'checkbox';
check.checked = task.done;
check.dataset.action = 'toggle';
check.setAttribute('aria-label', 'Mark “' + task.title + '” as done');
const body = document.createElement('div');
body.className = 'task__body';
if (state.editingId === task.id) {
const edit = document.createElement('input');
edit.className = 'task__edit';
edit.value = task.title;
edit.dataset.action = 'edit-input';
edit.setAttribute('aria-label', 'Edit task');
body.append(edit);
} else {
const title = document.createElement('span');
title.className = 'task__title';
title.textContent = task.title; // textContent = safe from XSS
const meta = document.createElement('small');
meta.className = 'task__meta';
const parts = [];
if (task.priority === 'high') parts.push('🔥 High priority');
if (task.priority === 'low') parts.push('Low priority');
if (task.due) parts.push((overdue ? 'Overdue: ' : 'Due ') + formatDate(task.due));
meta.textContent = parts.join(' · ');
body.append(title, meta);
}
const editBtn = iconButton('✏️', 'edit', 'Edit “' + task.title + '”');
const delBtn = iconButton('🗑️', 'delete', 'Delete “' + task.title + '”');
li.append(check, body, editBtn, delBtn);
return li;
}
function iconButton(symbol, action, label) {
const b = document.createElement('button');
b.type = 'button';
b.className = 'icon';
b.textContent = symbol;
b.dataset.action = action;
b.setAttribute('aria-label', label);
return b;
}
function formatDate(iso) {
return new Intl.DateTimeFormat('en-IN', { day: 'numeric', month: 'short' }).format(new Date(iso + 'T00:00:00'));
}
function render() {
const tasks = visibleTasks();
els.list.replaceChildren(...tasks.map(createTaskItem));
const editBox = els.list.querySelector('.task__edit');
if (editBox) { editBox.focus(); editBox.select(); } // put the cursor in the edit box
els.empty.hidden = tasks.length > 0;
const left = state.tasks.filter(t => !t.done).length;
els.count.textContent = `${left} task${left === 1 ? '' : 's'} left · ${state.tasks.length} total`;
els.filters.querySelectorAll('[data-filter]').forEach(b => b.setAttribute('aria-pressed', b.dataset.filter === state.filter));
els.clearDone.hidden = !state.tasks.some(t => t.done);
els.storageNote.hidden = storageWorks;
}- visibleTasks() filters by the current tab, then sorts: unfinished first, then high → normal → low, then newest.
- Task titles are inserted with textContent – a task named <img onerror=…> is shown as text, not executed.
- replaceChildren() swaps the whole list in one DOM update.
- After rendering, an open edit box receives focus so the user can type immediately.
Step 6: events with delegation
// ---------- 5. EVENTS ----------
els.form.addEventListener('submit', e => {
e.preventDefault();
const title = els.input.value.trim();
if (title.length < 2) {
els.error.textContent = 'Please type at least 2 characters.';
els.input.focus();
return;
}
if (state.tasks.some(t => t.title.toLowerCase() === title.toLowerCase() && !t.done)) {
els.error.textContent = 'This task is already in your list.';
return;
}
els.error.textContent = '';
addTask(title, els.priority.value, els.due.value);
els.form.reset();
els.input.focus();
});
// one listener for every task (event delegation)
els.list.addEventListener('click', e => {
const target = e.target.closest('[data-action]');
if (!target) return;
const id = Number(target.closest('.task').dataset.id);
const action = target.dataset.action;
if (action === 'toggle') toggleTask(id);
if (action === 'delete' && confirm('Delete this task?')) deleteTask(id);
if (action === 'edit') { state.editingId = id; render(); }
});
els.list.addEventListener('dblclick', e => {
const item = e.target.closest('.task');
if (item && e.target.closest('.task__title')) { state.editingId = Number(item.dataset.id); render(); }
});
els.list.addEventListener('keydown', e => {
if (e.target.dataset.action !== 'edit-input') return;
const id = Number(e.target.closest('.task').dataset.id);
if (e.key === 'Enter') renameTask(id, e.target.value);
if (e.key === 'Escape') { state.editingId = null; render(); }
});
els.list.addEventListener('focusout', e => {
if (e.target.dataset.action === 'edit-input' && state.editingId !== null) {
renameTask(Number(e.target.closest('.task').dataset.id), e.target.value);
}
});
els.filters.addEventListener('click', e => {
const btn = e.target.closest('[data-filter]');
if (!btn) return;
state.filter = btn.dataset.filter;
render();
});
els.clearDone.addEventListener('click', clearDone);
// ---------- 6. START ----------
els.due.min = todayISO();
render();| User action | Event | Handler does |
|---|---|---|
| Types and presses Enter / Add | submit on the form | Validate, addTask, reset, refocus |
| Ticks a checkbox | click on list (delegated) | toggleTask(id) |
| ✏️ or double-click title | click / dblclick | editingId = id, render |
| Enter / Esc / leaves the edit box | keydown / focusout | renameTask or cancel |
| 🗑️ | click | confirm, deleteTask |
| Filter pill | click on nav (delegated) | state.filter = …, render |
Step 7: test like a QA engineer
| Test | Expected | Our result |
|---|---|---|
| Add a 1-character task | Error “at least 2 characters” | ✅ |
| Add the same open task twice | Error “already in your list” | ✅ |
| Tick a task, choose Active | Task disappears from Active | ✅ |
| Edit with ✏️, type, Enter | Title updated | ✅ |
| Delete, then reload the page | Deleted task stays deleted | ✅ |
| Storage blocked (this preview) | App still works, shows a note | ✅ |
| Task named <b>x</b> | Shown as text | ✅ |
| Keyboard only | Every control reachable with Tab | ✅ |
The stylesheet
:root { --brand: #ca8a04; --ink: #1f2937; --muted: #6b7280; --line: #e5e7eb; --bg: #fffbeb; --card: #fff; --high: #dc2626; }
* { box-sizing: border-box; }
body { margin: 0; font-family: system-ui, "Segoe UI", Roboto, sans-serif; background: var(--bg); color: var(--ink); }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
button, input, select { font: inherit; }
button { cursor: pointer; }
:focus-visible { outline: 3px solid var(--brand); outline-offset: 2px; }
.app { width: min(640px, 100% - 24px); margin: 24px auto; background: var(--card); border-radius: 18px; padding: 20px; box-shadow: 0 12px 30px rgb(0 0 0 / 8%); }
.app__head { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; flex-wrap: wrap; }
.app__head h1 { margin: 0; font-size: 1.6rem; }
.app__count { margin: 0; color: var(--muted); }
.add { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; }
.add input[name="title"] { flex: 1 1 220px; }
.add input, .add select { padding: 10px 12px; border: 1.5px solid var(--line); border-radius: 10px; background: #fff; }
.add button { padding: 10px 18px; border: 0; border-radius: 10px; background: var(--brand); color: #fff; font-weight: 700; }
.error { color: var(--high); min-height: 1.2em; margin: 6px 0 0; font-size: .9rem; }
.filters { display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0 12px; }
.filters button { border: 1.5px solid var(--line); background: #fff; border-radius: 99px; padding: 6px 14px; }
.filters button[aria-pressed="true"] { background: var(--ink); color: #fff; border-color: var(--ink); }
.filters .link { margin-left: auto; border: 0; background: none; color: var(--muted); text-decoration: underline; }
.list { list-style: none; padding: 0; margin: 0; }
.task { display: flex; align-items: center; gap: 10px; padding: 10px 8px; border-bottom: 1px solid var(--line); animation: pop .2s ease-out; }
@keyframes pop { from { opacity: 0; transform: translateY(-4px); } }
.task input[type="checkbox"] { width: 20px; height: 20px; accent-color: var(--brand); flex: 0 0 auto; }
.task__body { flex: 1; min-width: 0; }
.task__title { overflow-wrap: anywhere; }
.task__meta { display: block; font-size: .8rem; color: var(--muted); }
.task--done .task__title { text-decoration: line-through; color: var(--muted); }
.task--high { border-left: 4px solid var(--high); padding-left: 8px; }
.task--overdue .task__meta { color: var(--high); font-weight: 600; }
.task__edit { width: 100%; padding: 6px 8px; border: 1.5px solid var(--brand); border-radius: 8px; }
.icon { border: 0; background: none; font-size: 1.1rem; padding: 4px 6px; border-radius: 8px; }
.icon:hover { background: var(--bg); }
.empty, .note { text-align: center; color: var(--muted); }
@media (prefers-reduced-motion: reduce) { .task { animation: none; } }🏅 Job skills unlocked today
Practice tasks
🏋️ Practice – do it yourself
Quick quiz
🧠 Quick Quiz – test yourself
Interview corner
💼 Interview Corner
Walk me through your to-do app architecture.
State object (tasks, filter, editingId) as the single source of truth; pure-ish action functions that change state and call commit(); commit saves to localStorage (with try/catch fallback) and calls render(); render derives the filtered, sorted list and builds DOM safely with textContent; delegated event listeners translate user input into actions.
How would you sync tasks across devices?
Replace the storage helpers with API calls (fetch to a backend with authentication), keep local state for instant UI (optimistic updates), handle offline mode by queueing changes, and resolve conflicts with timestamps.
Summary
- State + actions + commit() + render() = predictable apps.
- Delegated listeners handle every task button.
- textContent for user text; replaceChildren for fast updates.
- localStorage with JSON and a try/catch fallback.
Day 15: localStorage, sessionStorage and JSON in depth – remember carts, themes and drafts.
