Skip to content

Commit 20978f1

Browse files
authored
feat: Add basic todo functionality (#74)
1 parent 3d15629 commit 20978f1

File tree

2 files changed

+259
-20
lines changed

2 files changed

+259
-20
lines changed

src/utils/commands.ts

Lines changed: 115 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,44 @@
1-
import packageJson from '../../package.json';
2-
import themes from '../../themes.json';
3-
import { history } from '../stores/history';
4-
import { theme } from '../stores/theme';
1+
import packageJson from "../../package.json";
2+
import themes from "../../themes.json";
3+
import { history } from "../stores/history";
4+
import { theme } from "../stores/theme";
5+
import { todoManager } from "./todo";
56

67
const hostname = window.location.hostname;
78

89
export const commands: Record<string, (args: string[]) => Promise<string> | string> = {
9-
help: () => 'Available commands: ' + Object.keys(commands).join(', '),
10+
help: () => {
11+
const categories = {
12+
System: ["help", "clear", "date", "exit"],
13+
Productivity: ["todo", "weather"],
14+
Customization: ["theme", "banner"],
15+
Network: ["curl", "hostname", "whoami"],
16+
Contact: ["email", "repo", "donate"],
17+
Fun: ["echo", "sudo", "vi", "vim", "emacs"],
18+
};
19+
20+
let output = "Available commands:\n\n";
21+
22+
for (const [category, cmds] of Object.entries(categories)) {
23+
output += `${category}:\n`;
24+
output += cmds.map((cmd) => ` ${cmd}`).join("\n");
25+
output += "\n\n";
26+
}
27+
28+
output +=
29+
'Type "[command] help" or "[command]" without args for more info.';
30+
31+
return output;
32+
},
1033
hostname: () => hostname,
11-
whoami: () => 'guest',
34+
whoami: () => "guest",
1235
date: () => new Date().toLocaleString(),
1336
vi: () => `why use vi? try 'emacs'`,
1437
vim: () => `why use vim? try 'emacs'`,
1538
emacs: () => `why use emacs? try 'vim'`,
16-
echo: (args: string[]) => args.join(' '),
39+
echo: (args: string[]) => args.join(" "),
1740
sudo: (args: string[]) => {
18-
window.open('https://www.youtube.com/watch?v=dQw4w9WgXcQ');
41+
window.open("https://www.youtube.com/watch?v=dQw4w9WgXcQ");
1942

2043
return `Permission denied: unable to run the command '${args[0]}' as root.`;
2144
},
@@ -34,14 +57,14 @@ export const commands: Record<string, (args: string[]) => Promise<string> | stri
3457
}
3558

3659
switch (args[0]) {
37-
case 'ls': {
38-
let result = themes.map((t) => t.name.toLowerCase()).join(', ');
60+
case "ls": {
61+
let result = themes.map((t) => t.name.toLowerCase()).join(", ");
3962
result += `You can preview all these themes here: ${packageJson.repository.url}/tree/master/docs/themes`;
4063

4164
return result;
4265
}
4366

44-
case 'set': {
67+
case "set": {
4568
if (args.length !== 2) {
4669
return usage;
4770
}
@@ -64,42 +87,42 @@ export const commands: Record<string, (args: string[]) => Promise<string> | stri
6487
}
6588
},
6689
repo: () => {
67-
window.open(packageJson.repository.url, '_blank');
90+
window.open(packageJson.repository.url, "_blank");
6891

69-
return 'Opening repository...';
92+
return "Opening repository...";
7093
},
7194
clear: () => {
7295
history.set([]);
7396

74-
return '';
97+
return "";
7598
},
7699
email: () => {
77100
window.open(`mailto:${packageJson.author.email}`);
78101

79102
return `Opening mailto:${packageJson.author.email}...`;
80103
},
81104
donate: () => {
82-
window.open(packageJson.funding.url, '_blank');
105+
window.open(packageJson.funding.url, "_blank");
83106

84-
return 'Opening donation url...';
107+
return "Opening donation url...";
85108
},
86109
weather: async (args: string[]) => {
87-
const city = args.join('+');
110+
const city = args.join("+");
88111

89112
if (!city) {
90-
return 'Usage: weather [city]. Example: weather Brussels';
113+
return "Usage: weather [city]. Example: weather Brussels";
91114
}
92115

93116
const weather = await fetch(`https://wttr.in/${city}?ATm`);
94117

95118
return weather.text();
96119
},
97120
exit: () => {
98-
return 'Please close the tab to exit.';
121+
return "Please close the tab to exit.";
99122
},
100123
curl: async (args: string[]) => {
101124
if (args.length === 0) {
102-
return 'curl: no URL provided';
125+
return "curl: no URL provided";
103126
}
104127

105128
const url = args[0];
@@ -123,4 +146,76 @@ export const commands: Record<string, (args: string[]) => Promise<string> | stri
123146
124147
Type 'help' to see list of available commands.
125148
`,
149+
todo: (args: string[]) => {
150+
const usage = `Usage: todo [command] [args]
151+
152+
Commands:
153+
add <text> Add a new todo
154+
ls [filter] List todos (filter: all, completed, pending)
155+
done <id> Mark todo as completed
156+
rm <id> Remove a todo
157+
clear [completed] Clear todos (add 'completed' to clear only completed)
158+
stats Show todo statistics
159+
160+
Examples:
161+
todo add Buy groceries
162+
todo ls
163+
todo ls pending
164+
todo done 1
165+
todo rm 2
166+
todo clear completed`;
167+
168+
if (args.length === 0) {
169+
return usage;
170+
}
171+
172+
const [subCommand, ...subArgs] = args;
173+
174+
switch (subCommand) {
175+
case "add":
176+
if (subArgs.length === 0) {
177+
return "Error: Please provide todo text. Example: todo add Buy milk";
178+
}
179+
return todoManager.add(subArgs.join(" "));
180+
181+
case "ls":
182+
case "list":
183+
const filter = subArgs[0] as
184+
| "all"
185+
| "completed"
186+
| "pending"
187+
| undefined;
188+
if (filter && !["all", "completed", "pending"].includes(filter)) {
189+
return "Error: Invalid filter. Use: all, completed, or pending";
190+
}
191+
return todoManager.list(filter);
192+
193+
case "done":
194+
case "complete":
195+
const completeId = parseInt(subArgs[0]);
196+
if (isNaN(completeId)) {
197+
return "Error: Please provide a valid todo ID number";
198+
}
199+
return todoManager.complete(completeId);
200+
201+
case "rm":
202+
case "remove":
203+
case "delete":
204+
const removeId = parseInt(subArgs[0]);
205+
if (isNaN(removeId)) {
206+
return "Error: Please provide a valid todo ID number";
207+
}
208+
return todoManager.remove(removeId);
209+
210+
case "clear":
211+
const onlyCompleted = subArgs[0] === "completed";
212+
return todoManager.clear(onlyCompleted);
213+
214+
case "stats":
215+
return todoManager.stats();
216+
217+
default:
218+
return `Unknown todo command: ${subCommand}\n\n${usage}`;
219+
}
220+
},
126221
};

src/utils/todo.ts

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
interface Todo {
2+
id: number;
3+
text: string;
4+
completed: boolean;
5+
createdAt: Date;
6+
completedAt?: Date;
7+
}
8+
9+
class TodoManager {
10+
private todos: Todo[] = [];
11+
private nextId: number = 1;
12+
private readonly storageKey = "terminal-todos";
13+
14+
constructor() {
15+
this.loadTodos();
16+
}
17+
18+
private loadTodos(): void {
19+
const stored = localStorage.getItem(this.storageKey);
20+
if (stored) {
21+
try {
22+
const parsed = JSON.parse(stored);
23+
this.todos = parsed.todos || [];
24+
this.nextId = parsed.nextId || 1;
25+
// Convert date strings back to Date objects
26+
this.todos = this.todos.map((todo) => ({
27+
...todo,
28+
createdAt: new Date(todo.createdAt),
29+
completedAt: todo.completedAt
30+
? new Date(todo.completedAt)
31+
: undefined,
32+
}));
33+
} catch (e) {
34+
console.error("Failed to load todos:", e);
35+
}
36+
}
37+
}
38+
39+
private saveTodos(): void {
40+
localStorage.setItem(
41+
this.storageKey,
42+
JSON.stringify({
43+
todos: this.todos,
44+
nextId: this.nextId,
45+
})
46+
);
47+
}
48+
49+
add(text: string): string {
50+
const todo: Todo = {
51+
id: this.nextId++,
52+
text,
53+
completed: false,
54+
createdAt: new Date(),
55+
};
56+
this.todos.push(todo);
57+
this.saveTodos();
58+
return `✓ Added todo #${todo.id}: ${text}`;
59+
}
60+
61+
list(filter?: "all" | "completed" | "pending"): string {
62+
let filteredTodos = this.todos;
63+
64+
if (filter === "completed") {
65+
filteredTodos = this.todos.filter((t) => t.completed);
66+
} else if (filter === "pending") {
67+
filteredTodos = this.todos.filter((t) => !t.completed);
68+
}
69+
70+
if (filteredTodos.length === 0) {
71+
return filter
72+
? `No ${filter} todos found.`
73+
: 'No todos found. Use "todo add <text>" to create one.';
74+
}
75+
76+
const todoList = filteredTodos
77+
.map((todo) => {
78+
const status = todo.completed ? "✓" : "○";
79+
const prefix = `${status} [${todo.id}]`;
80+
// Add visual indication for completed todos
81+
const text = todo.completed ? `~~${todo.text}~~` : todo.text;
82+
return `${prefix} ${text}`;
83+
})
84+
.join("\n");
85+
86+
const total = this.todos.length;
87+
const completedCount = this.todos.filter((t) => t.completed).length;
88+
const pendingCount = this.todos.filter((t) => !t.completed).length;
89+
90+
const summary = `\n─────────────────────────────\nTotal: ${total} | Completed: ${completedCount} | Pending: ${pendingCount}`;
91+
92+
return todoList + summary;
93+
}
94+
95+
complete(id: number): string {
96+
const todo = this.todos.find((t) => t.id === id);
97+
if (!todo) {
98+
return `Todo #${id} not found.`;
99+
}
100+
if (todo.completed) {
101+
return `Todo #${id} is already completed.`;
102+
}
103+
todo.completed = true;
104+
todo.completedAt = new Date();
105+
this.saveTodos();
106+
return `✓ Completed todo #${id}: ${todo.text}`;
107+
}
108+
109+
remove(id: number): string {
110+
const index = this.todos.findIndex((t) => t.id === id);
111+
if (index === -1) {
112+
return `Todo #${id} not found.`;
113+
}
114+
const removed = this.todos.splice(index, 1)[0];
115+
this.saveTodos();
116+
return `✗ Removed todo #${id}: ${removed.text}`;
117+
}
118+
119+
clear(onlyCompleted: boolean = false): string {
120+
if (onlyCompleted) {
121+
const completedCount = this.todos.filter((t) => t.completed).length;
122+
this.todos = this.todos.filter((t) => !t.completed);
123+
this.saveTodos();
124+
return `Cleared ${completedCount} completed todo(s).`;
125+
} else {
126+
const count = this.todos.length;
127+
this.todos = [];
128+
this.saveTodos();
129+
return `Cleared all ${count} todo(s).`;
130+
}
131+
}
132+
133+
stats(): string {
134+
const total = this.todos.length;
135+
const completed = this.todos.filter((t) => t.completed).length;
136+
const pending = total - completed;
137+
const completionRate =
138+
total > 0 ? ((completed / total) * 100).toFixed(1) : 0;
139+
140+
return `📊 Todo Statistics:\n├─ Total todos: ${total}\n├─ Completed: ${completed}\n├─ Pending: ${pending}\n└─ Completion rate: ${completionRate}%`;
141+
}
142+
}
143+
144+
export const todoManager = new TodoManager();

0 commit comments

Comments
 (0)