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
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: "[32m", red: "[31m", yellow: "[33m",
dim: "[2m", reset: "[0m", bold: "[1m",
} : { 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
// 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
- Add an
edit <id>command to update a task's fields - Add recurring tasks:
--repeat daily|weekly|monthly - Add a
statscommand showing completion rate, tasks by priority, overdue count - 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