Skip to content

Knockout.js Capstone Project — Building a Task Management Dashboard

DodaTech Updated 2026-06-28 9 min read

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

  1. Not saving to localStorage - Without persistence, all data is lost on page refresh. The save function should be called after every mutation.

  2. Sharing mutable state across components - Each component should receive data via parameters, not access global state directly.

  3. Not handling loading states - Async operations (API calls) need loading indicators. The loading observable controls the display of a spinner or skeleton.

  4. Memory leaks from component subscriptions - Components that create subscriptions must dispose them in a dispose callback.

  5. 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

  1. How does the pureComputed for filteredTasks automatically update when any filter changes?
  2. Why is the save function called after every task mutation?
  3. How does the task-card component communicate delete actions back to the parent ViewModel?
  4. What is the purpose of the storage.js service?
  5. 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

How do I deploy this Knockout application?

Build with your preferred bundler (Webpack, Vite) or deploy the raw files to any static host. The AMD version with RequireJS works without a build step.

Can I add real-time collaboration to this project?

Yes. Use WebSocket or BroadcastChannel API to sync task changes between browser tabs or users. Push the task data as JSON on every mutation.

How do I add authentication?

Create an auth service module that stores a token. Add an interceptor pattern to your API service that checks for the token before making requests.

How do I handle offline support?

Use localStorage as the primary store and sync to the server when connectivity returns. The storage service can be extended with a sync queue.

Can I export this project to use with React or Vue?

The architecture patterns (services, ViewModels, separation of concerns) are framework-agnostic. You can port the business logic to any framework while keeping the same structure.

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