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
- Add Task: Create a new task with a title and optional priority
- List Tasks: Display all tasks with their status and details
- Complete Task: Mark a task as done
- Delete Task: Remove a task from the list
- Filter Tasks: Show only completed or pending tasks
- 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
- Create the project directory and files
- Install dependencies:
npm install - Run in development mode:
npm run dev - 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
3. Search
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
- Enums provide type-safe constants: Use enums for fixed sets of values like priority levels
- Interfaces define data shape: Clear interfaces make your code self-documenting
- Classes encapsulate logic: The TaskService class hides implementation details
- Modules organize code: Splitting into files makes the project maintainable
- JSON serialization has limits: Dates become strings when saved to JSON
- 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.