Tier 3 — Advanced Beginner

CLI Task Manager

Full-featured task CLI — priority, due dates, tags, filtering, sorting, ANSI colours.

🐍 Python 🟨 JavaScript

1. The Problem

Build a full-featured task manager CLI: add tasks with priority, due dates, and tags. List tasks with filtering (by priority, tag, overdue, completion). Sort by priority or due date. Mark complete. Delete. Persist to JSON. A real productivity tool, not a toy.

Why this project? This combines everything: class design, file persistence, complex filtering and sorting, date comparison, coloured terminal output, argument parsing, and a polished user experience. It's the most complete CLI project in the Tier 3 set.

2. How to Think About This

The Task class holds data and behaviour together (toString, isOverdue). The TaskManager class handles the collection (CRUD, filtering, sorting, persistence). The CLI layer parses process.argv and routes to the right method.

Parsing multi-word flags like --tag dev,urgent and optional positional arguments like task add "Fix bug" --priority high --due 2026-07-01 requires a small argument parser. Write it from scratch — it's straightforward.

3. The Build

tasks.mjs
import { readFileSync, writeFileSync, existsSync } from "fs";

const DATA_FILE = "tasks.json";
const PRIORITY  = { high: 0, medium: 1, low: 2 };

// ── Task class ─────────────────────────────────────────────────────────────
class Task {
  constructor({ id, title, priority="medium", dueDate=null,
                tags=[], completed=false, createdAt=new Date().toISOString() }) {
    if (!title?.trim()) throw new Error("Title is required");
    if (!PRIORITY.hasOwnProperty(priority)) throw new Error("Priority must be high, medium, or low");
    this.id        = id;
    this.title     = title.trim();
    this.priority  = priority;
    this.dueDate   = dueDate;
    this.tags      = Array.isArray(tags) ? tags : tags.split(",").map(t => t.trim());
    this.completed = completed;
    this.createdAt = createdAt;
  }

  isOverdue() {
    return !this.completed && this.dueDate && new Date(this.dueDate) < new Date();
  }

  format(colours = true) {
    const C = colours ? {
      green: "", red: "", yellow: "",
      dim: "", reset: "", bold: "",
    } : { green:"", red:"", yellow:"", dim:"", reset:"", bold:"" };

    const statusIcon = this.completed ? `${C.green}✓${C.reset}`
                      : this.isOverdue() ? `${C.red}⚠${C.reset}`
                      : "○";
    const prioCol    = this.priority === "high"   ? C.red
                      : this.priority === "low"    ? C.dim : "";
    const prioIcon   = this.priority === "high" ? "!" : this.priority === "low" ? "↓" : " ";
    const due        = this.dueDate ? ` ${C.dim}due:${this.dueDate}${C.reset}` : "";
    const tags       = this.tags.length ? ` ${C.yellow}[${this.tags.join(",")}]${C.reset}` : "";
    const title      = `${prioCol}${this.title}${C.reset}`;
    return `${statusIcon} ${C.dim}#${this.id}${C.reset} ${prioIcon} ${title}${due}${tags}`;
  }
}

// ── TaskManager class ──────────────────────────────────────────────────────
class TaskManager {
  #tasks;
  #nextId;

  constructor(tasks = []) {
    this.#tasks  = tasks.map(t => new Task(t));
    this.#nextId = this.#tasks.reduce((max, t) => Math.max(max, t.id), 0) + 1;
  }

  add(data) {
    const task = new Task({ ...data, id: this.#nextId++ });
    this.#tasks.push(task);
    return task;
  }

  get(id) {
    const task = this.#tasks.find(t => t.id === Number(id));
    if (!task) throw new Error(`Task #${id} not found`);
    return task;
  }

  complete(id) {
    const task  = this.get(id);
    task.completed = !task.completed;
    return task;
  }

  delete(id) {
    const idx = this.#tasks.findIndex(t => t.id === Number(id));
    if (idx === -1) throw new Error(`Task #${id} not found`);
    return this.#tasks.splice(idx, 1)[0];
  }

  filter({ completed, priority, tag, overdue } = {}) {
    let tasks = [...this.#tasks];
    if (completed === "true")  tasks = tasks.filter(t => t.completed);
    if (completed === "false") tasks = tasks.filter(t => !t.completed);
    if (priority)              tasks = tasks.filter(t => t.priority === priority);
    if (tag)                   tasks = tasks.filter(t => t.tags.includes(tag));
    if (overdue)               tasks = tasks.filter(t => t.isOverdue());
    return tasks;
  }

  sort(tasks, by = "priority") {
    return [...tasks].sort((a, b) => {
      if (by === "priority") return PRIORITY[a.priority] - PRIORITY[b.priority];
      if (by === "due") {
        if (!a.dueDate) return 1;
        if (!b.dueDate) return -1;
        return new Date(a.dueDate) - new Date(b.dueDate);
      }
      return a.id - b.id;
    });
  }

  all()     { return [...this.#tasks]; }
  toJSON()  { return JSON.stringify(this.#tasks, null, 2); }
}

// ── Persistence ────────────────────────────────────────────────────────────
function load() {
  if (!existsSync(DATA_FILE)) return new TaskManager();
  try { return new TaskManager(JSON.parse(readFileSync(DATA_FILE,"utf-8"))); }
  catch { return new TaskManager(); }
}
function save(manager) { writeFileSync(DATA_FILE, manager.toJSON(), "utf-8"); }

// ── Argument parser ────────────────────────────────────────────────────────
function parseArgs(args) {
  const result = { positional: [], flags: {} };
  for (let i = 0; i < args.length; i++) {
    if (args[i].startsWith("--")) {
      const key = args[i].slice(2);
      result.flags[key] = args[i+1] && !args[i+1].startsWith("--") ? args[++i] : true;
    } else {
      result.positional.push(args[i]);
    }
  }
  return result;
}

// ── CLI ─────────────────────────────────────────────────────────────────────
const [,, cmd, ...rest] = process.argv;
const { positional, flags } = parseArgs(rest);
const manager = load();

switch (cmd) {
  case "add": {
    const title = positional.join(" ");
    if (!title) { console.error("Usage: node tasks.mjs add <title> [--priority high|medium|low] [--due YYYY-MM-DD] [--tag tag1,tag2]"); break; }
    const task = manager.add({
      title,
      priority: flags.priority ?? "medium",
      dueDate:  flags.due     ?? null,
      tags:     flags.tag ? flags.tag.split(",").map(t => t.trim()) : [],
    });
    save(manager);
    console.log(`Added: ${task.format()}`);
    break;
  }

  case "list": {
    const tasks = manager.sort(
      manager.filter({
        completed: flags.completed,
        priority:  flags.priority,
        tag:       flags.tag,
        overdue:   flags.overdue,
      }),
      flags.sort ?? "priority"
    );
    if (tasks.length === 0) { console.log("No tasks match."); break; }
    tasks.forEach(t => console.log("  " + t.format()));
    const done  = tasks.filter(t => t.completed).length;
    const over  = tasks.filter(t => t.isOverdue()).length;
    console.log(`
  ${tasks.length} tasks — ${done} done${over ? ", " + over + " overdue" : ""}`);
    break;
  }

  case "done": {
    const id   = positional[0];
    if (!id) { console.error("Usage: node tasks.mjs done <id>"); break; }
    const task = manager.complete(id);
    save(manager);
    console.log(`${task.completed ? "Completed" : "Reopened"}: ${task.format()}`);
    break;
  }

  case "delete": {
    const id   = positional[0];
    if (!id) { console.error("Usage: node tasks.mjs delete <id>"); break; }
    const task = manager.delete(id);
    save(manager);
    console.log(`Deleted: #${task.id} ${task.title}`);
    break;
  }

  default:
    console.log([
      "task manager — commands:",
      "  add <title> [--priority high|medium|low] [--due YYYY-MM-DD] [--tag tag1,tag2]",
      "  list [--priority P] [--tag T] [--completed true|false] [--overdue] [--sort priority|due|id]",
      "  done <id>",
      "  delete <id>",
    ].join("
"));
}

4. Test & Prove

tasks.test.mjs
// Test Task and TaskManager classes without file I/O
const PRIORITY = { high:0, medium:1, low:2 };

class Task {
  constructor({id,title,priority="medium",dueDate=null,tags=[],completed=false,createdAt=new Date().toISOString()}){
    if(!title?.trim()) throw new Error("Title required");
    Object.assign(this,{id,title:title.trim(),priority,dueDate,tags:Array.isArray(tags)?tags:tags.split(","),completed,createdAt});
  }
  isOverdue(){ return !this.completed && this.dueDate && new Date(this.dueDate) < new Date(); }
}
class TaskManager {
  #tasks; #nextId;
  constructor(tasks=[]){ this.#tasks=tasks.map(t=>new Task(t)); this.#nextId=this.#tasks.reduce((m,t)=>Math.max(m,t.id),0)+1; }
  add(d){ const t=new Task({...d,id:this.#nextId++}); this.#tasks.push(t); return t; }
  get(id){ const t=this.#tasks.find(t=>t.id===Number(id)); if(!t) throw new Error("not found"); return t; }
  complete(id){ const t=this.get(id); t.completed=!t.completed; return t; }
  delete(id){ const i=this.#tasks.findIndex(t=>t.id===Number(id)); if(i===-1)throw new Error("not found"); return this.#tasks.splice(i,1)[0]; }
  filter({completed,priority,tag,overdue}={}){
    let r=[...this.#tasks];
    if(completed==="true") r=r.filter(t=>t.completed);
    if(completed==="false") r=r.filter(t=>!t.completed);
    if(priority) r=r.filter(t=>t.priority===priority);
    if(tag) r=r.filter(t=>t.tags.includes(tag));
    if(overdue) r=r.filter(t=>t.isOverdue());
    return r;
  }
  all(){ return [...this.#tasks]; }
}

let pass=0,fail=0;
function test(desc,fn){try{fn();console.log("  ✓",desc);pass++;}catch(e){console.log("  ✗",desc,"—",e.message);fail++;}}

const m = new TaskManager();
const t1 = m.add({title:"High task",priority:"high",tags:["dev"]});
const t2 = m.add({title:"Low task", priority:"low"});
const t3 = m.add({title:"Overdue!", dueDate:"2020-01-01"});

test("add increments id",          () => { if(t1.id!==1||t2.id!==2) throw new Error(); });
test("get finds by id",            () => { if(m.get(1).title!=="High task") throw new Error(); });
test("complete toggles",           () => { m.complete(1); if(!m.get(1).completed) throw new Error(); });
test("complete toggles back",      () => { m.complete(1); if(m.get(1).completed) throw new Error(); });
test("filter by priority",         () => { if(m.filter({priority:"high"}).length!==1) throw new Error(); });
test("filter by tag",              () => { if(m.filter({tag:"dev"}).length!==1) throw new Error(); });
test("filter overdue",             () => { if(m.filter({overdue:true}).length!==1) throw new Error(); });
test("filter completed false",     () => { if(m.filter({completed:"false"}).length!==3) throw new Error(m.filter({completed:"false"}).length); });
test("isOverdue checks date",      () => { if(!t3.isOverdue()) throw new Error(); });
test("delete removes task",        () => { m.delete(2); if(m.all().length!==2) throw new Error(); });
test("delete missing throws",      () => { try{m.delete(99);throw new Error("no throw");}catch(e){if(e.message==="no throw")throw e;} });
test("empty title throws",         () => { try{m.add({title:""});throw new Error("no throw");}catch(e){if(e.message==="no throw")throw e;} });

console.log(\`
\${pass} passed, \${fail} failed\`);

5. The Interface

node tasks.mjs add "Fix login bug" --priority high --due 2026-07-01 --tag dev,urgent
node tasks.mjs add "Read Node.js docs" --priority low

node tasks.mjs list
node tasks.mjs list --priority high
node tasks.mjs list --tag dev
node tasks.mjs list --overdue
node tasks.mjs list --sort due

node tasks.mjs done 1
node tasks.mjs delete 2
node tasks.test.mjs

6. Run It

node tasks.mjs add "First task" --priority high --due 2026-12-31
node tasks.mjs list
node tasks.test.mjs

🎯 Try this next

  1. Add an edit <id> command to update a task's fields
  2. Add recurring tasks: --repeat daily|weekly|monthly
  3. Add a stats command showing completion rate, tasks by priority, overdue count
  4. Expose the task manager as a REST API — reuse TaskManager, add HTTP layer on top

What you practised

Writing a custom argument parser (handling --flag value and --flag boolean patterns), class composition (Task knows how to format and check overdue; TaskManager handles the collection), complex multi-criteria filtering with chained filters, date comparison with the Date constructor, ANSI escape codes for terminal colours, and building a real productivity tool you can actually use.

Reference: OOP & Classes · Arrays · Date & Intl

Try It Yourself