Skip to main content

Overview

The Task Manager frontend is built with vanilla JavaScript following an MVC (Model-View-Controller) pattern. It features a clean separation of concerns with controllers handling business logic, views managing DOM manipulation, and services communicating with the backend API.

Architecture Overview

public/js/
├── controllers/      # Business logic and event handling
│   ├── App.js        # Main application controller
│   ├── Task.js       # Task management logic
│   ├── TaskCategory.js
│   ├── Modal.js      # Modal dialog controller
│   └── Aside.js      # Sidebar panel controller
├── views/           # DOM manipulation and rendering
│   ├── Task.js      # Task card rendering
│   └── TaskCategory.js
├── services/        # API communication
│   ├── Task.js      # Task API calls
│   └── TaskCategory.js
├── utils/           # Helper functions
│   ├── formatDate.js
│   ├── sortCollection.js
│   └── dinamicSelect.js
├── config.js        # Configuration
└── main.js         # Application entry point

Application Bootstrap

Main Entry Point

~/workspace/source/public/js/main.js
import { AppController } from './controllers/App.js';

const appController = new AppController();
appController.init();

App Controller

The AppController orchestrates all sub-controllers:
~/workspace/source/public/js/controllers/App.js
import { API_URL } from '../config.js';
import { TaskController } from './Task.js';
import { TaskCategoryController } from './TaskCategory.js';

export class AppController {
  constructor() {
    this.taskController = new TaskController(API_URL, 'tasks-section');
    this.taskCategoryController = new TaskCategoryController(API_URL);
  }

  async init() {
    await this.taskController.init();
  }
}
The application follows a dependency injection pattern, passing the API URL and container IDs to controllers during initialization.

Task Controller

Initialization and State Management

~/workspace/source/public/js/controllers/Task.js
import { TasksView } from "../views/Task.js";
import { TaskService } from "../services/Task.js";
import { TaskCategoryService } from "../services/TaskCategory.js";
import { sortCollection } from "../utils/sortCollection.js";

export class TaskController {
  constructor(apiUrl, containerId) {
    this.taskService = new TaskService(apiUrl);
    this.taskCategoryService = new TaskCategoryService(apiUrl);
    this.container = document.getElementById(containerId);
    
    // Sort configuration
    this.sortTasksBy = 'title';
    this.sortTasksDirection = 'asc';
    this.sortCategoriesBy = 'name';
    this.sortCategoriesDirection = 'asc';

    this.addEventListeners();
  }

  async init() {
    await TasksView.resetTasksList();
    
    // Render categories
    const categories = await this.getPreparedCategory();
    if (!categories) return;
    categories.forEach(element => {
      TasksView.renderCategory(element);
    });

    // Render tasks
    const tasks = await this.taskService.getAll();
    if (!tasks || tasks.length === 0) return;

    const orderTasks = sortCollection(tasks, this.sortTasksBy, this.sortTasksDirection);
    orderTasks.forEach(element => {
      TasksView.renderCard(element);
    });
  }
}

Event Handling Pattern

The controller uses event delegation for efficient event handling:
~/workspace/source/public/js/controllers/Task.js
async handleClickEvent(e) {
  const action = e.target.closest('[data-action]')?.dataset.action;
  const taskCard = e.target.closest('.task-card');
  const taskId = taskCard?.dataset?.id ? taskCard.dataset.id : null;

  if (!action) { return }
  
  // CRUD operations
  switch (action) {
    case 'create':
      TasksView.openModal(await this.getPreparedCategory());
      break;
    case 'edit':
      TasksView.openModal(
        await this.getPreparedCategory(), 
        await this.taskService.getById(taskId)
      );
      break;
    case 'delete':
      TasksView.openModalConfirmation(await this.taskService.getById(taskId));
      break;
    case 'finish':
      const isCompleted = taskCard.classList.contains('completed');
      const data = { id: taskId, finished: isCompleted ? '' : 'on' }
      const updatedTask = await this.taskService.update(data);
      TasksView.deleteCard(updatedTask._id);
      TasksView.renderCard(updatedTask);
      break;
  }
}
Using data-action attributes enables clean event delegation without requiring individual event listeners on each button.
~/workspace/source/public/js/controllers/Task.js
async handleModalEvent(e) {
  const { type, action, data } = e.detail;
  if (type !== 'task') return;
  
  if (action === 'create') {
    const createdTask = await this.taskService.save(data);
    if (createdTask) { TasksView.renderCard(createdTask); }
  }
  
  if (action === 'update') {
    const updatedTask = await this.taskService.update(data)
    if (updatedTask) {
      TasksView.deleteCard(updatedTask._id);
      TasksView.renderCard(updatedTask);
    }
  }
  
  if (action === 'delete') {
    const deleted = await this.taskService.delete(data.id)
    if (deleted) { TasksView.deleteCard(deleted); }
  }
}

Event Listener Registration

~/workspace/source/public/js/controllers/Task.js
addEventListeners() {
  this.container.addEventListener('click', this.handleClickEvent.bind(this));
  window.addEventListener('modal:confirm', (e) => this.handleModalEvent(e));
  window.addEventListener('categoryUpdated', (e) => this.init());
}
The categoryUpdated event listener automatically refreshes the task list when categories change, ensuring UI consistency.

Task Service Layer

API Communication

~/workspace/source/public/js/services/Task.js
export class TaskService {
  constructor(apiUrl) {
    this.apiClient = `${apiUrl}/tasks`;
  }

  async getAll() {
    try {
      const result = await fetch(this.apiClient);
      const data = await result.json();
      return data;
    } catch (error) {
      return [];
    }
  }

  async getById(id) {
    try {
      const result = await fetch(`${this.apiClient}/${id}`);
      const data = await result.json();
      return data;
    } catch (error) {
      console.log("error:", error);
    }
  }
}

Create Operation

~/workspace/source/public/js/services/Task.js
async save(data) {
  if (!data) return;
  
  const taskCompleted = data.finished === "on" ? true : false;
  const categoryId = data.categoryId === "uncategorized" ? "" : data.categoryId;

  try {
    const result = await fetch(`${this.apiClient}`, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        title: data.title,
        description: data.description,
        completed: taskCompleted,
        categoryId: categoryId,
      }),
    });

    const newTask = await result.json();
    return newTask;
  } catch (error) {
    console.error(error);
  }
}
The service layer transforms form data (checkbox “on” values) into proper boolean values before sending to the API.

Update Operation

~/workspace/source/public/js/services/Task.js
async update(data) {
  if (!data.id) return;
  
  const taskCompleted = data.finished === "on" ? true : false;
  const categoryId = data.categoryId === "uncategorized" ? "" : data.categoryId;

  try {
    const result = await fetch(`${this.apiClient}/${data.id}`, {
      method: "PATCH",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        title: data.title,
        description: data.description,
        completed: taskCompleted,
        categoryId: categoryId,
      }),
    });

    const updatedTask = await result.json();
    return updatedTask;
  } catch (error) {
    console.log(error);
  }
}

Delete Operation

~/workspace/source/public/js/services/Task.js
async delete(id) {
  try {
    await fetch(`${this.apiClient}/${id}`, {
      method: "DELETE",
    });
    return id;
  } catch (error) {
    throw new Error(error);
  }
}

Task View Layer

Rendering Task Cards

~/workspace/source/public/js/views/Task.js
import { formatDate } from "../utils/formatDate.js";

export class TasksView {
  static renderCard(input) {
    if (!input) return;
    
    // Find the category container
    const categoryContainer = input.categoryId ?
      document.querySelector(`[id="${input.categoryId}"]`) :
      document.querySelector('#uncategorized');
    
    // Get the appropriate task list (completed or active)
    const taskList = categoryContainer.querySelector('.tasks-list');
    const taskCompletedList = categoryContainer.querySelector('.tasks-list-completed');

    const taskCard = document.createElement('li');
    const createdAt = this.dateString(formatDate(input.createdAt));
    const updatedAtValues = this.dateString(formatDate(input.updatedAt));
    const finishedAtValues = this.dateString(formatDate(input.finishedAt));

    const updatedTxt = updatedAtValues ? `última actualización: ${updatedAtValues}` : '';
    const finishedTxt = finishedAtValues ? `finalizado: ${finishedAtValues}` : '';

    const taskCompleted = !!input.completed;

    taskCard.classList.add('task-card');
    taskCard.dataset.id = input._id;

    taskCard.innerHTML = `
      <div class="task-info-main">
        <button class="btn_crud btn_crud--finish" data-action="finish">
          <img src="./assets/icons/check.svg">
        </button>
        <p class="task-text">
          <span class="bold">${input.title}: </span>
          <span>${input.description}</span>
        </p>
        <div class="btn_crud--container">
          <button class="btn_crud btn_crud--edit" data-action="edit">
            <img src="./assets/icons/edit.svg">
          </button>
          <button class="btn_crud btn_crud--delete" data-action="delete">
            <img src="./assets/icons/delete.svg">
          </button>
        </div>
      </div>
      <div>
        <p class="substring">
          <span>creado: ${createdAt}</span>
          <span>${updatedTxt}</span>
          <span>${finishedTxt}</span>
        </p>
      </div>
    `;

    if (taskCompleted) {
      taskCard.classList.add('completed');
      taskCompletedList.appendChild(taskCard);
    } else {
      taskList.appendChild(taskCard);
    }
  }
}
Task cards are rendered into different lists based on completion status, providing visual separation between active and completed tasks.
~/workspace/source/public/js/views/Task.js
static openModal(dataCategory, dataTask) {
  let action = 'create';
  let h2 = 'NUEVA TAREA';
  let inputId = '', inputTitle = '', inputDescription = '', inputFinished = '', inputCategory = '';

  if (dataTask) {
    action = 'update';
    h2 = 'ACTUALIZAR TAREA';
    inputTitle = dataTask.title;
    inputDescription = dataTask.description;
    inputId = `<input type="text" name="id" value="${dataTask._id}" hidden>`;
    if (dataTask.completed) { inputFinished = 'checked' }
    inputCategory = dataTask.categoryId;
  }

  const modalFormBody = `
    <form class="form-task">
      <H2>${h2}</H2>
      ${inputId}
      <div class="form-group form-group--column">
        <label for="task-name">Título:</label>
        <input type="text" id="task-name" name="title" value="${inputTitle}" 
               placeholder="Tu próxima tarea" maxlength="25" required>
      </div>
      <div class="form-group form-group--column">
        <label for="task-description">Descripción:</label>
        <textarea id="task-description" name="description" 
                  placeholder="Qué tienes que hacer" maxlength="40" required>
          ${inputDescription}
        </textarea>
      </div>
      <div class="form-group form-group--row">
        <div class="inline">
          <label class="inline" for="task-finish">Finished</label>
          <input class="inline" type="checkbox" id="task-finish" 
                 name="finished" ${inputFinished}>
        </div>
        <div class="inline">
          <label class="inline" for="task-category">Categoría</label>
          <select name="categoryId" id="task-category">
            ${dinamicSelect(dataCategory)}
          </select>
        </div>
      </div>
    </form>
  `
  Modal.open('task', action, modalFormBody);
}

Category Rendering

~/workspace/source/public/js/views/Task.js
static renderCategory(input) {
  const mainContainer = document.getElementById('task-categories-container');
  const categoryContainer = document.createElement('div');

  categoryContainer.classList.add('category-container');
  categoryContainer.id = input._id

  categoryContainer.innerHTML = `
    <H2>${input.name}</H2>
    <ul class='tasks-list'></ul>
    <ul class='tasks-list-completed'></ul>
  `;

  mainContainer.appendChild(categoryContainer);
}
Each category container has two separate lists: one for active tasks and one for completed tasks. This provides clear visual separation and makes it easy to toggle tasks between states without complex DOM manipulation.

Category Controller

State Management

~/workspace/source/public/js/controllers/TaskCategory.js
export class TaskCategoryController {
  constructor(apiURL) {
    this.categoryService = new TaskCategoryService(apiURL);
    this.categorieAside = new AsideController();
    this.categoryManagement = document.getElementById("btn-category-control");
    
    // View state for create, edit, or delete modes
    this.state = {
      mode: "",
      editingId: "",
      selectedIds: new Set(),
    };

    this.addEventListener();
  }
}

Multi-Mode Interface

The category UI supports three modes:
1

List Mode

Default view showing all categories with ability to add new ones
2

Edit Mode

Click a category to edit its name inline
3

Delete Mode

Select multiple categories with checkboxes for bulk deletion

Edit Implementation

~/workspace/source/public/js/controllers/TaskCategory.js
async editCategory(data) {
  const editedCategory = await this.categoryService.update(data);
  const event = new CustomEvent('categoryUpdated', { 
    detail: {action:'update', data: editedCategory } 
  });
  window.dispatchEvent(event);
}

cancelEditMode() {
  this.state.mode = "list";
  this.state.editingId = "";
  this.init();
}

Delete with Selection

~/workspace/source/public/js/controllers/TaskCategory.js
handleClickEvent(e) {
  // Category checked/unchecked
  if (e.target.type === "checkbox") {
    if (e.target.checked) {
      this.state.selectedIds.add(e.target.dataset.id);
    } else {
      this.state.selectedIds.delete(e.target.dataset.id);
    }
  }

  // Delete selected categories
  if ((e.target.id === "del-category-button") && 
      (e.target.dataset.action === "confirmDelete")) {
    const deletedCategories = this.categoryService.delete(this.state.selectedIds);
    this.state.mode = "list";
    this.init();
    
    // Dispatch event to refresh task view
    const event = new CustomEvent('categoryUpdated', { 
      detail: {action:'delete', data: deletedCategories } 
    });
    window.dispatchEvent(event);
  }
}
Using a Set for selectedIds ensures no duplicates and provides efficient add/remove operations.

Category View

Dynamic Mode Rendering

~/workspace/source/public/js/views/TaskCategory.js
export class TaskCategoryView {
  static renderList(categoryContainer, input, state) {
    const categoryUl = document.createElement("ul");
    input.forEach((element) => {
      const li = document.createElement("li");
      li.dataset.id = element._id;
      li.classList.add("category-li");
      
      switch (state.mode) {
        case "list":
          li.innerHTML = `${element.name}`;
          break;
          
        case "delete":
          const check = document.createElement("input");
          const label = document.createElement("label");
          check.type = "checkbox";
          check.dataset.id = element._id;
          check.id = element._id;
          label.textContent = element.name;
          label.htmlFor = element._id;
          li.append(check, label);
          break;
          
        case "edit":
          if (state.editingId === element._id) {
            const input = document.createElement("input");
            input.type = "text";
            input.value = element.name;
            const btnSave = document.createElement("button");
            btnSave.innerHTML = '<img src="./assets/icons/save.svg">';
            btnSave.dataset.action = "saveEdit";
            const btnCancel = document.createElement("button");
            btnCancel.innerHTML = '<img src="./assets/icons/close.svg">';
            btnCancel.dataset.action = "cancelEdit";
            li.append(input, btnSave, btnCancel);
          } else {
            li.textContent = element.name;
          }
          break;
      }
      categoryUl.appendChild(li);
    });
    categoryContainer.appendChild(categoryUl);
  }
}

Utility Functions

Date Formatting

~/workspace/source/public/js/utils/formatDate.js
export function formatDate(isoString) {
  if (!isoString) return null;
  const date = new Date(isoString);
  return {
    day: String(date.getDate()).padStart(2, '0'),
    month: String(date.getMonth() + 1).padStart(2, '0'),
    year: date.getFullYear(),
    hour: String(date.getHours()).padStart(2, '0'),
    minutes: String(date.getMinutes()).padStart(2, '0')
  };
}

Collection Sorting

~/workspace/source/public/js/utils/sortCollection.js
export function sortCollection(collection, property, direction = 'asc') {
  return [...collection].sort((a, b) => {
    if (direction === 'asc') {
      return a[property] > b[property] ? 1 : -1;
    }
    return a[property] < b[property] ? 1 : -1;
  });
}

Dynamic Select Options

~/workspace/source/public/js/utils/dinamicSelect.js
export function dinamicSelect(options) {
  return options.map(option => 
    `<option value="${option._id}">${option.name}</option>`
  ).join('');
}
Utility functions are kept pure and reusable, making them easy to test and maintain.

Custom Event System

Event Architecture

The application uses custom events for cross-component communication:

modal:confirm

Fired when a modal form is submitted

categoryUpdated

Fired when categories are created, updated, or deleted

Event Pattern

// Dispatching an event
const event = new CustomEvent('categoryUpdated', { 
  detail: {
    action: 'create',  // or 'update', 'delete'
    data: categoryData
  }
});
window.dispatchEvent(event);

// Listening for events
window.addEventListener('categoryUpdated', (e) => {
  console.log(e.detail.action, e.detail.data);
  this.refresh();
});
Custom events provide loose coupling between components, making the architecture more maintainable and testable.

Best Practices

  • Controllers: Handle business logic and event coordination
  • Views: Manage DOM manipulation and rendering
  • Services: Abstract API communication
  • Utils: Provide reusable helper functions
Instead of attaching listeners to individual elements, the application uses event delegation on container elements with data-action attributes for better performance.
Services catch network errors and return safe default values (empty arrays) to prevent application crashes.
Controllers maintain local state (sort preferences, edit mode) while the backend remains the source of truth for data.

Performance Considerations

Efficient Rendering: The app only re-renders affected components, not the entire UI, when data changes.
Batch Operations: Multiple categories can be deleted in a single API call, reducing network overhead.
Event Delegation: Single event listeners on containers handle all child element interactions.

Next Steps

Task Management

Understand the backend API for tasks

Categories

Learn about category management backend

API Reference

Complete API endpoint documentation

Build docs developers (and LLMs) love