Knockout.js Capstone Project — Building a Task Management Dashboard
In this tutorial, you will learn about Knockout.js Capstone Project. We cover key concepts, practical examples, and best practices to help you master this topic.
This capstone project combines everything you have learned about Knockout.js into a full-featured task management dashboard with real-time updates, filtering, and persistence.
What You'll Learn
- Structuring a real Knockout application
- Combining observables, computeds, and bindings
- Implementing CRUD operations with a mock API
- Building custom components and bindings
- Writing automated tests for the full application
Why It Matters
Building a complete application from scratch teaches you how all Knockout concepts work together. You will encounter real-world challenges like state management, async operations, and code organization.
Real-World Use
A team task board where members can create, assign, prioritize, and track tasks across columns (To Do, In Progress, Done). Changes sync across team members in real-time.
Application Architecture
flowchart TD
A[App Shell] --> B[Dashboard]
A --> C[Task Board]
A --> D[Settings]
B --> E[Stats Panel]
B --> F[Recent Tasks]
C --> G[Column: To Do]
C --> H[Column: In Progress]
C --> I[Column: Done]
G --> J[Task Card Component]
H --> J
I --> J
style A fill:#e6f3ff,stroke:#4a90d9,stroke-width:2px
Project Structure
task-dashboard/
index.html
app/
main.js
shell.js
router.js
models/
task.js
column.js
viewmodels/
dashboard.js
task-board.js
settings.js
components/
task-card/
task-card.js
task-card.html
add-task-form/
add-task-form.js
add-task-form.html
services/
api.js
storage.js
bindings/
draggable.js
tooltip.js
templates/
dashboard.html
task-board.html
settings.html
test/
viewmodels/
task-board.spec.js
components/
task-card.spec.js
styles/
main.css
Step 1: Data Models
// app/models/task.js
define(['knockout'], function(ko) {
return function Task(data) {
var self = this;
self.id = data.id || Date.now();
self.title = ko.observable(data.title || '');
self.description = ko.observable(data.description || '');
self.priority = ko.observable(data.priority || 'medium');
self.status = ko.observable(data.status || 'todo');
self.assignee = ko.observable(data.assignee || '');
self.createdAt = data.createdAt || new Date().toISOString();
self.priorityClass = ko.pureComputed(function() {
var map = { high: 'danger', medium: 'warning', low: 'success' };
return map[self.priority()] || 'secondary';
});
self.isOverdue = ko.pureComputed(function() {
return self.status() !== 'done' &&
self.priority() === 'high' &&
new Date(self.createdAt) < new Date(Date.now() - 86400000);
});
};
});
Step 2: Task Board ViewModel
// app/viewmodels/task-board.js
define(['knockout', 'app/models/task', 'app/services/api', 'app/services/storage'],
function(ko, Task, api, storage) {
return function TaskBoardViewModel() {
var self = this;
self.tasks = ko.observableArray([]);
self.filteredTasks = ko.observableArray([]);
self.loading = ko.observable(true);
self.searchQuery = ko.observable('');
self.priorityFilter = ko.observable('all');
self.statusFilter = ko.observable('all');
// Initialize
self.init = function() {
var saved = storage.load('tasks');
if (saved) {
self.tasks(saved.map(function(t) { return new Task(t); }));
self.loading(false);
} else {
api.getTasks().then(function(data) {
self.tasks(data.map(function(t) { return new Task(t); }));
self.loading(false);
});
}
};
// Computed filter
self.filteredTasks = ko.pureComputed(function() {
var query = self.searchQuery().toLowerCase();
var priority = self.priorityFilter();
var status = self.statusFilter();
return self.tasks().filter(function(task) {
if (query && task.title().toLowerCase().indexOf(query) === -1) return false;
if (priority !== 'all' && task.priority() !== priority) return false;
if (status !== 'all' && task.status() !== status) return false;
return true;
});
});
// Tasks grouped by status
self.todoTasks = ko.pureComputed(function() {
return self.filteredTasks().filter(function(t) { return t.status() === 'todo'; });
});
self.inProgressTasks = ko.pureComputed(function() {
return self.filteredTasks().filter(function(t) { return t.status() === 'in-progress'; });
});
self.doneTasks = ko.pureComputed(function() {
return self.filteredTasks().filter(function(t) { return t.status() === 'done'; });
});
// CRUD operations
self.addTask = function(taskData) {
var task = new Task(taskData);
self.tasks.push(task);
self.save();
};
self.updateTask = function(task) {
self.tasks.valueHasMutated();
self.save();
};
self.removeTask = function(task) {
self.tasks.remove(task);
self.save();
};
self.moveTask = function(task, newStatus) {
task.status(newStatus);
self.save();
};
self.save = function() {
var data = self.tasks().map(function(t) {
return {
id: t.id,
title: t.title(),
description: t.description(),
priority: t.priority(),
status: t.status(),
assignee: t.assignee(),
createdAt: t.createdAt
};
});
storage.save('tasks', data);
};
// Stats
self.totalTasks = ko.pureComputed(function() { return self.tasks().length; });
self.completedTasks = ko.pureComputed(function() {
return self.tasks().filter(function(t) { return t.status() === 'done'; }).length;
});
self.completionRate = ko.pureComputed(function() {
if (self.totalTasks() === 0) return 0;
return Math.round((self.completedTasks() / self.totalTasks()) * 100);
});
self.init();
};
});
Step 3: Task Card Component
// app/components/task-card/task-card.js
define(['knockout'], function(ko) {
ko.components.register('task-card', {
viewModel: function(params) {
var self = this;
self.task = params.task;
self.onDelete = params.onDelete;
self.onMove = params.onMove;
self.deleteTask = function() {
if (confirm('Delete this task?')) {
self.onDelete(self.task);
}
};
self.moveToTodo = function() { self.onMove(self.task, 'todo'); };
self.moveToInProgress = function() { self.onMove(self.task, 'in-progress'); };
self.moveToDone = function() { self.onMove(self.task, 'done'); };
},
template:
'<div class="task-card" data-bind="css: task.priorityClass">' +
'<div class="task-header">' +
'<h3 data-bind="text: task.title"></h3>' +
'<span class="priority-badge" data-bind="text: task.priority, css: task.priorityClass"></span>' +
'</div>' +
'<p class="task-description" data-bind="text: task.description"></p>' +
'<div class="task-meta">' +
'<span data-bind="visible: task.assignee">Assignee: <strong data-bind="text: task.assignee"></strong></span>' +
'<span data-bind="visible: task.isOverdue" class="overdue-badge">Overdue</span>' +
'</div>' +
'<div class="task-actions">' +
'<!-- ko if: task.status() !== \'todo\' -->' +
'<button data-bind="click: moveToTodo">← To Do</button>' +
'<!-- /ko -->' +
'<!-- ko if: task.status() !== \'in-progress\' -->' +
'<button data-bind="click: moveToInProgress">In Progress</button>' +
'<!-- /ko -->' +
'<!-- ko if: task.status() !== \'done\' -->' +
'<button data-bind="click: moveToDone">Done →</button>' +
'<!-- /ko -->' +
'<button class="btn-delete" data-bind="click: deleteTask">Delete</button>' +
'</div>' +
'</div>'
});
});
Expected output: Each task renders as a card with title, description, priority badge, assignee, overdue indicator, status-move buttons, and a delete button.
Step 4: Add Task Form Component
// app/components/add-task-form/add-task-form.js
define(['knockout'], function(ko) {
ko.components.register('add-task-form', {
viewModel: function(params) {
var self = this;
self.onAdd = params.onAdd;
self.title = ko.observable('').extend({ required: true });
self.description = ko.observable('');
self.priority = ko.observable('medium');
self.assignee = ko.observable('');
self.submit = function() {
if (!self.title()) return;
self.onAdd({
title: self.title(),
description: self.description(),
priority: self.priority(),
assignee: self.assignee(),
status: 'todo'
});
self.title('');
self.description('');
self.priority('medium');
self.assignee('');
};
},
template:
'<form class="add-task-form" data-bind="submit: submit">' +
'<input type="text" placeholder="Task title" data-bind="value: title, valueUpdate: \'afterkeydown\'">' +
'<textarea placeholder="Description (optional)" data-bind="value: description"></textarea>' +
'<select data-bind="value: priority">' +
'<option value="low">Low Priority</option>' +
'<option value="medium">Medium Priority</option>' +
'<option value="high">High Priority</option>' +
'</select>' +
'<input type="text" placeholder="Assignee" data-bind="value: assignee">' +
'<button type="submit" data-bind="enable: title">Add Task</button>' +
'</form>'
});
});
Step 5: Main Template
<!-- index.html -->
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="styles/main.css">
<script src="lib/require.js"></script>
<script src="app/main.js"></script>
</head>
<body>
<div id="app" class="app-shell">
<header>
<h1>Task Dashboard</h1>
<nav>
<a data-bind="click: function() { navigateTo('dashboard'); }">Dashboard</a>
<a data-bind="click: function() { navigateTo('board'); }">Task Board</a>
<a data-bind="click: function() { navigateTo('settings'); }">Settings</a>
</nav>
</header>
<main data-bind="template: { name: currentView, data: currentData }"></main>
</div>
<!-- Templates -->
<script type="text/html" id="board-template">
<div class="task-board">
<div class="board-toolbar">
<input type="text" placeholder="Search tasks..." data-bind="value: searchQuery, valueUpdate: 'afterkeydown'">
<select data-bind="value: priorityFilter">
<option value="all">All Priorities</option>
<option value="high">High</option>
<option value="medium">Medium</option>
<option value="low">Low</option>
</select>
</div>
<div class="board-columns">
<div class="column">
<h2>To Do (<span data-bind="text: todoTasks().length"></span>)</h2>
<div data-bind="foreach: todoTasks">
<task-card params="task: $data, onDelete: $parent.removeTask, onMove: $parent.moveTask"></task-card>
</div>
</div>
<div class="column">
<h2>In Progress (<span data-bind="text: inProgressTasks().length"></span>)</h2>
<div data-bind="foreach: inProgressTasks">
<task-card params="task: $data, onDelete: $parent.removeTask, onMove: $parent.moveTask"></task-card>
</div>
</div>
<div class="column">
<h2>Done (<span data-bind="text: doneTasks().length"></span>)</h2>
<div data-bind="foreach: doneTasks">
<task-card params="task: $data, onDelete: $parent.removeTask, onMove: $parent.moveTask"></task-card>
</div>
</div>
</div>
<add-task-form params="onAdd: $root.addTask"></add-task-form>
</div>
</script>
</body>
</html>
Step 6: Services
// app/services/api.js
define(['jquery'], function($) {
return {
getTasks: function() {
return new Promise(function(resolve) {
setTimeout(function() {
resolve([
{ id: 1, title: 'Design homepage', priority: 'high', status: 'todo', assignee: 'Alice' },
{ id: 2, title: 'Fix login bug', priority: 'high', status: 'in-progress', assignee: 'Bob' },
{ id: 3, title: 'Write documentation', priority: 'low', status: 'done', assignee: '' }
]);
}, 500);
});
}
};
});
// app/services/storage.js
define([], function() {
return {
save: function(key, data) {
localStorage.setItem('taskboard_' + key, JSON.stringify(data));
},
load: function(key) {
var data = localStorage.getItem('taskboard_' + key);
return data ? JSON.parse(data) : null;
},
remove: function(key) {
localStorage.removeItem('taskboard_' + key);
}
};
});
Step 7: Tests
// test/viewmodels/task-board.spec.js
describe('TaskBoardViewModel', function() {
var vm;
beforeEach(function() {
vm = new TaskBoardViewModel();
// Clear any saved data
localStorage.clear();
});
it('starts with an empty task list', function() {
expect(vm.tasks().length).toBe(0);
});
it('adds a task', function() {
vm.addTask({ title: 'Test task', priority: 'high' });
expect(vm.tasks().length).toBe(1);
expect(vm.tasks()[0].title()).toBe('Test task');
expect(vm.totalTasks()).toBe(1);
});
it('removes a task', function() {
vm.addTask({ title: 'Test task' });
vm.removeTask(vm.tasks()[0]);
expect(vm.tasks().length).toBe(0);
});
it('moves a task between statuses', function() {
vm.addTask({ title: 'Test task' });
var task = vm.tasks()[0];
expect(task.status()).toBe('todo');
vm.moveTask(task, 'in-progress');
expect(task.status()).toBe('in-progress');
});
it('filters tasks by priority', function() {
vm.addTask({ title: 'High task', priority: 'high' });
vm.addTask({ title: 'Low task', priority: 'low' });
vm.priorityFilter('high');
expect(vm.todoTasks().length).toBe(1);
expect(vm.todoTasks()[0].title()).toBe('High task');
});
});
Step 8: Custom Binding (Draggable)
// app/bindings/draggable.js
define(['knockout'], function(ko) {
ko.bindingHandlers.draggable = {
init: function(element, valueAccessor) {
var task = ko.unwrap(valueAccessor());
element.draggable = true;
element.addEventListener('dragstart', function(e) {
e.dataTransfer.setData('application/json', JSON.stringify({ id: task.id }));
element.classList.add('dragging');
});
element.addEventListener('dragend', function() {
element.classList.remove('dragging');
});
ko.utils.domNodeDisposal.addDisposeCallback(element, function() {
element.removeEventListener('dragstart', null);
element.removeEventListener('dragend', null);
});
}
};
});
Running the Application
# Serve with any static file server
npx serve .
# Run tests
npx karma start
Common Mistakes
Not saving to localStorage - Without persistence, all data is lost on page refresh. The
savefunction should be called after every mutation.Sharing mutable state across components - Each component should receive data via parameters, not access global state directly.
Not handling loading states - Async operations (API calls) need loading indicators. The
loadingobservable controls the display of a spinner or skeleton.Memory leaks from component subscriptions - Components that create subscriptions must dispose them in a dispose callback.
Over-complicated filtering logic - All filtering happens in a single computed. Ensure the computed returns a new array (slice) so Knockout detects the change.
Practice Questions
- How does the pureComputed for
filteredTasksautomatically update when any filter changes? - Why is the
savefunction called after every task mutation? - How does the task-card component communicate delete actions back to the parent ViewModel?
- What is the purpose of the
storage.jsservice? - How does the custom draggable binding integrate with HTML5 drag and drop?
Challenge: Add drag-and-drop between columns (dropping a task card changes its status), add a real-time sync feature using BroadcastChannel API, and add keyboard shortcuts for adding tasks (Ctrl+N).
FAQ
Summary
You have built a complete task management dashboard with Knockout.js. The project demonstrates observables, computeds, components, custom bindings, validation, service layers, persistence, and testing. These patterns apply directly to production Knockout applications of any scale.
What's Next
Explore other frameworks in our tutorial series: Ext JS for enterprise-grade component-based applications, or jQuery for DOM manipulation fundamentals.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro