From Zero to AI

Project 7.1: CLI Todo App

Duration: 2 hours

Project Overview

Build a command-line todo application that allows users to manage tasks through an interactive terminal interface. The app will support creating, listing, completing, and deleting tasks, with data persisted to a JSON file.


Learning Objectives

By completing this project, you will practice:

  • Designing data models with interfaces
  • Using classes to encapsulate business logic
  • Working with enums for type-safe constants
  • Handling user input in Node.js
  • Reading and writing JSON files
  • Organizing code into modules

Requirements

Functional Requirements

  1. Add Task: Create a new task with a title and optional priority
  2. List Tasks: Display all tasks with their status and details
  3. Complete Task: Mark a task as done
  4. Delete Task: Remove a task from the list
  5. Filter Tasks: Show only completed or pending tasks
  6. Save/Load: Persist tasks to a JSON file

User Interface

The app should display a menu:

=== Todo App ===
1. Add task
2. List all tasks
3. List pending tasks
4. List completed tasks
5. Complete task
6. Delete task
7. Exit

Choose an option:

Data Models

Define these types for your application:

// types.ts

/**
 * Priority levels for tasks
 */
enum Priority {
  Low = 'low',
  Medium = 'medium',
  High = 'high',
}

/**
 * Represents a single todo item
 */
interface Task {
  id: number;
  title: string;
  completed: boolean;
  priority: Priority;
  createdAt: Date;
  completedAt: Date | null;
}

/**
 * Input for creating a new task
 */
interface CreateTaskInput {
  title: string;
  priority?: Priority;
}

/**
 * Options for filtering tasks
 */
interface FilterOptions {
  completed?: boolean;
  priority?: Priority;
}

Project Structure

Organize your code like this:

cli-todo-app/
├── src/
│   ├── index.ts           # Entry point and main loop
│   ├── types.ts           # Type definitions
│   ├── task-service.ts    # Business logic
│   ├── storage.ts         # File persistence
│   ├── ui.ts              # User interface helpers
│   └── utils.ts           # Utility functions
├── data/
│   └── tasks.json         # Persisted tasks
├── package.json
└── tsconfig.json

Implementation Guide

Step 1: Define Types

Start by creating your type definitions:

// src/types.ts

export enum Priority {
  Low = 'low',
  Medium = 'medium',
  High = 'high',
}

export interface Task {
  id: number;
  title: string;
  completed: boolean;
  priority: Priority;
  createdAt: string; // ISO date string for JSON serialization
  completedAt: string | null;
}

export interface CreateTaskInput {
  title: string;
  priority?: Priority;
}

export interface FilterOptions {
  completed?: boolean;
  priority?: Priority;
}

// For JSON storage
export interface StorageData {
  tasks: Task[];
  nextId: number;
}

Step 2: Create Storage Module

Handle reading and writing to files:

// src/storage.ts
import * as fs from 'fs';
import * as path from 'path';

import { StorageData, Task } from './types';

const DATA_DIR = path.join(__dirname, '..', 'data');
const DATA_FILE = path.join(DATA_DIR, 'tasks.json');

/**
 * Ensures the data directory exists
 */
function ensureDataDir(): void {
  if (!fs.existsSync(DATA_DIR)) {
    fs.mkdirSync(DATA_DIR, { recursive: true });
  }
}

/**
 * Loads tasks from the JSON file
 */
export function loadData(): StorageData {
  ensureDataDir();

  if (!fs.existsSync(DATA_FILE)) {
    return { tasks: [], nextId: 1 };
  }

  try {
    const content = fs.readFileSync(DATA_FILE, 'utf-8');
    return JSON.parse(content) as StorageData;
  } catch (error) {
    console.error('Error loading data, starting fresh');
    return { tasks: [], nextId: 1 };
  }
}

/**
 * Saves tasks to the JSON file
 */
export function saveData(data: StorageData): void {
  ensureDataDir();

  const content = JSON.stringify(data, null, 2);
  fs.writeFileSync(DATA_FILE, content, 'utf-8');
}

Step 3: Create Task Service

Implement the business logic:

// src/task-service.ts
import { loadData, saveData } from './storage';
import { CreateTaskInput, FilterOptions, Priority, StorageData, Task } from './types';

export class TaskService {
  private data: StorageData;

  constructor() {
    this.data = loadData();
  }

  /**
   * Creates a new task
   */
  create(input: CreateTaskInput): Task {
    if (!input.title.trim()) {
      throw new Error('Task title cannot be empty');
    }

    const task: Task = {
      id: this.data.nextId++,
      title: input.title.trim(),
      completed: false,
      priority: input.priority || Priority.Medium,
      createdAt: new Date().toISOString(),
      completedAt: null,
    };

    this.data.tasks.push(task);
    this.save();
    return task;
  }

  /**
   * Gets all tasks, optionally filtered
   */
  getAll(filter?: FilterOptions): Task[] {
    let tasks = [...this.data.tasks];

    if (filter) {
      if (filter.completed !== undefined) {
        tasks = tasks.filter((t) => t.completed === filter.completed);
      }
      if (filter.priority !== undefined) {
        tasks = tasks.filter((t) => t.priority === filter.priority);
      }
    }

    return tasks;
  }

  /**
   * Finds a task by ID
   */
  findById(id: number): Task | undefined {
    return this.data.tasks.find((t) => t.id === id);
  }

  /**
   * Marks a task as completed
   */
  complete(id: number): Task | null {
    const task = this.findById(id);
    if (!task) return null;

    task.completed = true;
    task.completedAt = new Date().toISOString();
    this.save();
    return task;
  }

  /**
   * Deletes a task by ID
   */
  delete(id: number): boolean {
    const index = this.data.tasks.findIndex((t) => t.id === id);
    if (index === -1) return false;

    this.data.tasks.splice(index, 1);
    this.save();
    return true;
  }

  /**
   * Gets task statistics
   */
  getStats(): { total: number; completed: number; pending: number } {
    const total = this.data.tasks.length;
    const completed = this.data.tasks.filter((t) => t.completed).length;
    return {
      total,
      completed,
      pending: total - completed,
    };
  }

  /**
   * Saves data to storage
   */
  private save(): void {
    saveData(this.data);
  }
}

Step 4: Create UI Helpers

Build the user interface utilities:

// src/ui.ts
import * as readline from 'readline';

import { Priority, Task } from './types';

/**
 * Creates a readline interface for user input
 */
export function createReadline(): readline.Interface {
  return readline.createInterface({
    input: process.stdin,
    output: process.stdout,
  });
}

/**
 * Prompts the user for input
 */
export function prompt(rl: readline.Interface, question: string): Promise<string> {
  return new Promise((resolve) => {
    rl.question(question, (answer) => {
      resolve(answer.trim());
    });
  });
}

/**
 * Formats a priority for display
 */
export function formatPriority(priority: Priority): string {
  const symbols: Record<Priority, string> = {
    [Priority.Low]: '[ ]',
    [Priority.Medium]: '[*]',
    [Priority.High]: '[!]',
  };
  return symbols[priority];
}

/**
 * Formats a task for display
 */
export function formatTask(task: Task): string {
  const status = task.completed ? '[x]' : '[ ]';
  const priority = formatPriority(task.priority);
  const date = new Date(task.createdAt).toLocaleDateString();
  return `${task.id}. ${status} ${priority} ${task.title} (${date})`;
}

/**
 * Displays the main menu
 */
export function showMenu(): void {
  console.log('\n=== Todo App ===');
  console.log('1. Add task');
  console.log('2. List all tasks');
  console.log('3. List pending tasks');
  console.log('4. List completed tasks');
  console.log('5. Complete task');
  console.log('6. Delete task');
  console.log('7. Show statistics');
  console.log('8. Exit');
  console.log('');
}

/**
 * Displays a list of tasks
 */
export function displayTasks(tasks: Task[], title: string): void {
  console.log(`\n--- ${title} ---`);
  if (tasks.length === 0) {
    console.log('No tasks found.');
  } else {
    tasks.forEach((task) => console.log(formatTask(task)));
  }
}

/**
 * Parses priority from user input
 */
export function parsePriority(input: string): Priority {
  const normalized = input.toLowerCase().trim();
  switch (normalized) {
    case '1':
    case 'low':
    case 'l':
      return Priority.Low;
    case '2':
    case 'medium':
    case 'm':
      return Priority.Medium;
    case '3':
    case 'high':
    case 'h':
      return Priority.High;
    default:
      return Priority.Medium;
  }
}

/**
 * Clears the console
 */
export function clearConsole(): void {
  console.clear();
}

Step 5: Create Main Application

Tie everything together:

// src/index.ts
import { TaskService } from './task-service';
import { Priority } from './types';
import { clearConsole, createReadline, displayTasks, parsePriority, prompt, showMenu } from './ui';

async function main(): Promise<void> {
  const taskService = new TaskService();
  const rl = createReadline();

  console.log('Welcome to the Todo App!');
  console.log('Your tasks are automatically saved.\n');

  let running = true;

  while (running) {
    showMenu();
    const choice = await prompt(rl, 'Choose an option: ');

    switch (choice) {
      case '1':
        await addTask(rl, taskService);
        break;

      case '2':
        displayTasks(taskService.getAll(), 'All Tasks');
        break;

      case '3':
        displayTasks(taskService.getAll({ completed: false }), 'Pending Tasks');
        break;

      case '4':
        displayTasks(taskService.getAll({ completed: true }), 'Completed Tasks');
        break;

      case '5':
        await completeTask(rl, taskService);
        break;

      case '6':
        await deleteTask(rl, taskService);
        break;

      case '7':
        showStats(taskService);
        break;

      case '8':
        running = false;
        console.log('\nGoodbye! Your tasks have been saved.');
        break;

      default:
        console.log('\nInvalid option. Please try again.');
    }
  }

  rl.close();
}

async function addTask(rl: readline.Interface, taskService: TaskService): Promise<void> {
  const title = await prompt(rl, '\nEnter task title: ');

  if (!title) {
    console.log('Task title cannot be empty.');
    return;
  }

  console.log('Priority: (1) Low, (2) Medium, (3) High');
  const priorityInput = await prompt(rl, 'Choose priority [2]: ');
  const priority = parsePriority(priorityInput || '2');

  try {
    const task = taskService.create({ title, priority });
    console.log(`\nTask created: "${task.title}" with ${task.priority} priority`);
  } catch (error) {
    console.log(`\nError: ${(error as Error).message}`);
  }
}

async function completeTask(rl: readline.Interface, taskService: TaskService): Promise<void> {
  const pendingTasks = taskService.getAll({ completed: false });

  if (pendingTasks.length === 0) {
    console.log('\nNo pending tasks to complete.');
    return;
  }

  displayTasks(pendingTasks, 'Pending Tasks');
  const idInput = await prompt(rl, '\nEnter task ID to complete: ');
  const id = parseInt(idInput, 10);

  if (isNaN(id)) {
    console.log('Invalid task ID.');
    return;
  }

  const task = taskService.complete(id);
  if (task) {
    console.log(`\nTask "${task.title}" marked as completed!`);
  } else {
    console.log('\nTask not found.');
  }
}

async function deleteTask(rl: readline.Interface, taskService: TaskService): Promise<void> {
  const allTasks = taskService.getAll();

  if (allTasks.length === 0) {
    console.log('\nNo tasks to delete.');
    return;
  }

  displayTasks(allTasks, 'All Tasks');
  const idInput = await prompt(rl, '\nEnter task ID to delete: ');
  const id = parseInt(idInput, 10);

  if (isNaN(id)) {
    console.log('Invalid task ID.');
    return;
  }

  const confirm = await prompt(rl, 'Are you sure? (y/n): ');
  if (confirm.toLowerCase() !== 'y') {
    console.log('Deletion cancelled.');
    return;
  }

  const deleted = taskService.delete(id);
  if (deleted) {
    console.log('\nTask deleted successfully.');
  } else {
    console.log('\nTask not found.');
  }
}

function showStats(taskService: TaskService): void {
  const stats = taskService.getStats();
  console.log('\n--- Statistics ---');
  console.log(`Total tasks: ${stats.total}`);
  console.log(`Completed: ${stats.completed}`);
  console.log(`Pending: ${stats.pending}`);

  if (stats.total > 0) {
    const percentage = Math.round((stats.completed / stats.total) * 100);
    console.log(`Progress: ${percentage}%`);
  }
}

// Run the application
main().catch(console.error);

Step 6: Configuration Files

// package.json
{
  "name": "cli-todo-app",
  "version": "1.0.0",
  "description": "A command-line todo application",
  "main": "dist/index.js",
  "scripts": {
    "build": "tsc",
    "start": "node dist/index.js",
    "dev": "ts-node src/index.ts"
  },
  "devDependencies": {
    "@types/node": "^20.0.0",
    "typescript": "^5.0.0",
    "ts-node": "^10.9.0"
  }
}
// tsconfig.json
{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "lib": ["ES2020"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "declaration": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

Running the Project

  1. Create the project directory and files
  2. Install dependencies:
    npm install
    
  3. Run in development mode:
    npm run dev
    
  4. Or build and run:
    npm run build
    npm start
    

Sample Output

Welcome to the Todo App!
Your tasks are automatically saved.

=== Todo App ===
1. Add task
2. List all tasks
3. List pending tasks
4. List completed tasks
5. Complete task
6. Delete task
7. Show statistics
8. Exit

Choose an option: 1

Enter task title: Learn TypeScript generics
Priority: (1) Low, (2) Medium, (3) High
Choose priority [2]: 3

Task created: "Learn TypeScript generics" with high priority

=== Todo App ===
...

Choose an option: 2

--- All Tasks ---
1. [ ] [!] Learn TypeScript generics (1/8/2026)
2. [ ] [*] Review module system (1/8/2026)
3. [x] [ ] Set up project (1/8/2026)

Extensions

Once you have the basic app working, try adding these features:

1. Due Dates

Add optional due dates to tasks:

interface Task {
  // ... existing fields
  dueDate: string | null;
}

// Add a "due today" filter
// Add overdue highlighting

2. Tags/Categories

Allow tasks to have tags:

interface Task {
  // ... existing fields
  tags: string[];
}

// Add commands to filter by tag
// Add a "list tags" command

Implement task search:

// Add a search command that finds tasks by title
search(query: string): Task[] {
  const lower = query.toLowerCase();
  return this.data.tasks.filter(t =>
    t.title.toLowerCase().includes(lower)
  );
}

4. Bulk Operations

Add bulk operations:

// Complete all tasks
completeAll(): number

// Delete completed tasks
clearCompleted(): number

5. Export/Import

Add data export and import:

// Export tasks to a readable format
exportToText(): string

// Import tasks from another JSON file
importFromFile(filePath: string): void

Common Issues and Solutions

Issue: Module not found

Make sure your tsconfig.json has the correct rootDir and outDir settings, and that you are running from the project root.

Issue: File permissions

On some systems, you may need to ensure the data directory is writable.

Issue: Readline not closing

Always call rl.close() when exiting the application to properly close the readline interface.


Key Takeaways

  1. Enums provide type-safe constants: Use enums for fixed sets of values like priority levels
  2. Interfaces define data shape: Clear interfaces make your code self-documenting
  3. Classes encapsulate logic: The TaskService class hides implementation details
  4. Modules organize code: Splitting into files makes the project maintainable
  5. JSON serialization has limits: Dates become strings when saved to JSON
  6. User input needs validation: Always validate and sanitize user input

Resources

Resource Type Description
Node.js readline Documentation Official readline docs
Node.js fs module Documentation File system operations
TypeScript Enums Documentation Enum usage guide

Next Project

Ready for the next challenge? Continue to the Quiz Game project.

Continue to Project 7.2: Quiz Game